Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/place-token-capacity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@hashintel/petrinaut-core": patch
"@hashintel/petrinaut": patch
---

Add an optional per-place token capacity.

A place can now declare a maximum number of tokens it will hold, set from the place properties panel. Useful for supply-chain style models with finite storage, and it is the keystone that converts frames from growable to fixed-size β€” the precondition for a fixed-layout GPU or WASM path.

Capacity participates in transition enablement, following the standard Petri-net capacity constraint: a transition cannot fire if doing so would take any output place above its capacity. Output tokens are applied at the end of a frame, so the check accounts for what transitions earlier in the same frame have already committed β€” several transitions feeding one capped place cannot collectively overflow it.

Deadlock detection includes the same check, so a net whose only remaining transitions are blocked by full output places is reported as deadlocked rather than stepping to `maxTime` with nothing happening.

Nets without capacities are unaffected: the constraint tables are empty and the hot path skips them.
23 changes: 12 additions & 11 deletions libs/@hashintel/petrinaut-core/docs/simulation-performance.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Simulation performance: threads, WASM, and GPU

Status: Β§3 (worker sharding) is implemented. Β§2 and Β§4 measurements still stand
and are unaddressed. Β§5–§9 remain proposals.
Status: Β§3 (worker sharding) and Β§5 (place capacity) are implemented. Β§2 and
Β§4 measurements still stand and are unaddressed. Β§6–§9 remain proposals.

Goal: make **Experiments** (Monte Carlo batches) as fast as possible, with
per-seed parallelism across threads/workers, and decide whether WASM (browser),
Expand All @@ -27,10 +27,11 @@ laptop, Node 25.6, against the built `dist` of this package.
result-preserving β€” now implemented** (Β§3.3): ~4Γ— on 8 shards (10-core
machine), byte-identical output at every shard count. The metric accumulators
were already monoids (`empty`/`merge`), so this was designed for.
4. **Optional per-place token capacity is the keystone** (Β§5). Beyond the
modelling feature, it converts frames from growable to fixed-size, which is
the precondition for SoA layout, a WASM linear-memory ABI, a computable
state-space bound, and any GPU path.
4. **Optional per-place token capacity β€” now implemented** (Β§5.3). Beyond the
modelling feature, it is the keystone that converts frames from growable to
fixed-size, which is the precondition for SoA layout, a WASM linear-memory
ABI, a computable state-space bound, and any GPU path. Those follow-ons are
_not_ done: the runtime still uses growable frames.
5. **WASM is worth doing, but as a second codegen backend behind whole-loop
codegen, not as a rewrite.** Expect ~1.5–3Γ— over _good_ JS, not over
today's engine.
Expand All @@ -39,9 +40,9 @@ laptop, Node 25.6, against the built `dist` of this package.
fork, not an acceleration of the existing one. Offloading _only_ ODEs is
architecturally worse than doing nothing (per-frame round-trip).

Done: **Β§3 worker sharding**.
Remaining, in order: **Β§4 hot-path fixes β†’ Β§5 capacities β†’ Β§6 whole-loop
codegen β†’ Β§7 WASM/native β†’ Β§8 GPU (spike only)**.
Done: **Β§3 worker sharding**, **Β§5 capacities**.
Remaining, in order: **Β§4 hot-path fixes β†’ Β§6 whole-loop codegen β†’ Β§7
WASM/native β†’ Β§8 GPU (spike only)**.

Β§4 item 1 (the quadratic enumeration blow-up) is the single highest-value change
left and is independent of everything else.
Expand Down Expand Up @@ -406,7 +407,7 @@ measured.
allocate `runs Γ— frameBytes` as one contiguous block β€” required for a WASM
linear-memory layout and mandatory for GPU (Β§8).

### 5.3 Proposed semantics
### 5.3 Semantics as implemented

A transition is not enabled when firing would take any output place above its
capacity β€” the supply-side mirror of an input arc that cannot be satisfied. Three
Expand All @@ -428,7 +429,7 @@ details that were decisions rather than consequences:
happening.

Constraints are precomputed per transition at build time
(`engine/capacity.ts`) and would be empty for nets without capacities, so the hot path
(`engine/capacity.ts`) and are empty for nets without capacities, so the hot path
pays nothing for a feature it does not use.

An initial marking above a place's capacity is rejected at build time: capacity
Expand Down
4 changes: 4 additions & 0 deletions libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,10 @@ export const placeSchema = z
description:
"ID of the differential equation used for continuous dynamics, or null when dynamics are disabled. The referenced equation's `colorId` MUST match this place's `colorId`.",
}),
capacity: z.int().nonnegative().nullable().optional().meta({
description:
"Optional maximum number of tokens this place will hold. A transition whose firing would take this place past its capacity is NOT enabled, exactly like a transition without enough input tokens β€” so a full place blocks the transitions that feed it and the limit is never exceeded. The check uses the net change per firing, so a transition that both consumes from and produces into this place is not blocked by its own output. A capacity of 0 means the place can never receive tokens. Omit or set null for unbounded. The initial marking must not exceed it.",
}),
isPort: z.boolean().optional().meta({
description:
"When true, this place is exposed as a component port on instances of the subnet that contains it.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ import {
createEngineFrame,
createEngineFrameLayout,
type EngineFrameSnapshot,
type EngineFrameLayout,
} from "../frames/internal-frame";
import { computeTransitionCapacityConstraints } from "./capacity";
import {
flattenComponentInstancesForSimulation,
getArcPlaceNameOverrideKey,
Expand Down Expand Up @@ -71,7 +73,9 @@ function validateHirArtifacts(
const runtimeVersion: unknown = (artifacts as { version?: unknown }).version;
if (runtimeVersion !== 4) {
throw new Error(
`The compiled HIR artifacts use unsupported version ${String(runtimeVersion)}; expected version 4. Recompile them from the current net.`,
`The compiled HIR artifacts use unsupported version ${String(
runtimeVersion,
)}; expected version 4. Recompile them from the current net.`,
);
}

Expand Down Expand Up @@ -437,6 +441,7 @@ function createCompiledTransition({
extensions,
placesMap,
typesMap,
frameLayout,
arcPlaceNameOverrides,
parameterValues,
lambdaArtifact,
Expand All @@ -448,6 +453,7 @@ function createCompiledTransition({
extensions: PetrinautExtensionSettings;
placesMap: ReadonlyMap<string, SimulationInput["sdcpn"]["places"][number]>;
typesMap: ReadonlyMap<string, SimulationInput["sdcpn"]["types"][number]>;
frameLayout: EngineFrameLayout;
arcPlaceNameOverrides: ReadonlyMap<string, string>;
parameterValues: ParameterValues;
lambdaArtifact: HirLambdaArtifact | undefined;
Expand Down Expand Up @@ -494,6 +500,11 @@ function createCompiledTransition({
return {
id: transition.id,
name: transition.name,
capacityConstraints: computeTransitionCapacityConstraints({
transition,
placeIndexById: frameLayout.placeIndexById,
placeCapacities: frameLayout.placeCapacities,
}),
inputPlaces: transition.inputArcs.map((arc) => {
const placeId = getArcEndpointPlaceId(arc);
if (!placeId) {
Expand Down Expand Up @@ -638,15 +649,27 @@ export function buildSimulation(input: SimulationInput): SimulationInstance {

const packedInitialMarking = new Map<string, PackedInitialPlaceMarking>();
for (const place of sdcpn.places) {
packedInitialMarking.set(
place.id,
packInitialPlaceMarking(
place,
sdcpn,
getInitialMarkingValue(initialMarking, place.id),
stringPool,
),
const packed = packInitialPlaceMarking(
place,
sdcpn,
getInitialMarkingValue(initialMarking, place.id),
stringPool,
);

// Capacity blocks transitions, so it cannot repair a marking that already
// violates it. Rejecting here keeps the invariant true for every frame.
if (
place.capacity !== undefined &&
place.capacity !== null &&
packed.count > place.capacity
) {
throw new SDCPNItemError(
`The initial marking for place \`${place.name}\` has ${packed.count} tokens but its capacity is ${place.capacity}.`,
place.id,
);
}

packedInitialMarking.set(place.id, packed);
}

// Compile all differential equation functions
Expand Down Expand Up @@ -714,6 +737,8 @@ export function buildSimulation(input: SimulationInput): SimulationInstance {
}
}

const frameLayout = createEngineFrameLayout(sdcpn);

// Compile transitions into the shape used by the execution hot path.
const compiledTransitions = new Map<string, CompiledTransition>();
for (const transition of sdcpn.transitions) {
Expand All @@ -725,6 +750,7 @@ export function buildSimulation(input: SimulationInput): SimulationInstance {
extensions,
placesMap,
typesMap,
frameLayout,
arcPlaceNameOverrides: flattened.arcPlaceNameOverrides,
parameterValues:
flattened.transitionParameterValues.get(transition.id) ??
Expand All @@ -740,7 +766,6 @@ export function buildSimulation(input: SimulationInput): SimulationInstance {

// Calculate buffer size and build place states
let bufferByteSize = 0;
const frameLayout = createEngineFrameLayout(sdcpn);
const placeStates: EngineFrameSnapshot["places"] = {};

for (const [placeIndex, placeId] of frameLayout.placeIds.entries()) {
Expand Down
190 changes: 190 additions & 0 deletions libs/@hashintel/petrinaut-core/src/simulation/engine/capacity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { describe, expect, it } from "vitest";

import {
computeTransitionCapacityConstraints,
createPlaceCapacities,
hasAnyPlaceCapacity,
hasCapacityHeadroom,
PLACE_CAPACITY_UNBOUNDED,
} from "./capacity";

import type { Transition } from "../../types/sdcpn";

const placeIndexById = new Map([
["a", 0],
["b", 1],
["c", 2],
]);

function transition(
overrides: Partial<Pick<Transition, "inputArcs" | "outputArcs">>,
): Pick<Transition, "id" | "inputArcs" | "outputArcs"> {
return {
id: "t",
inputArcs: [],
outputArcs: [],
...overrides,
};
}

describe("createPlaceCapacities", () => {
it("treats absent, null and invalid capacities as unbounded", () => {
const capacities = createPlaceCapacities([
{},
{ capacity: null },
{ capacity: -1 },
{ capacity: 1.5 },
{ capacity: 0 },
{ capacity: 10 },
]);

expect([...capacities]).toStrictEqual([
PLACE_CAPACITY_UNBOUNDED,
PLACE_CAPACITY_UNBOUNDED,
PLACE_CAPACITY_UNBOUNDED,
PLACE_CAPACITY_UNBOUNDED,
// Zero is a real limit: the place can never hold a token.
0,
10,
]);
});

it("detects whether any place is bounded", () => {
expect(hasAnyPlaceCapacity(createPlaceCapacities([{}, {}]))).toBe(false);
expect(
hasAnyPlaceCapacity(createPlaceCapacities([{}, { capacity: 3 }])),
).toBe(true);
});
});

describe("computeTransitionCapacityConstraints", () => {
const capacities = createPlaceCapacities([
{ capacity: 5 },
{},
{ capacity: 2 },
]);

it("constrains bounded output places by their arc weight", () => {
const constraints = computeTransitionCapacityConstraints({
transition: transition({ outputArcs: [{ placeId: "a", weight: 3 }] }),
placeIndexById,
placeCapacities: capacities,
});

expect(constraints).toStrictEqual([
{ placeIndex: 0, placeId: "a", delta: 3, capacity: 5 },
]);
});

it("ignores unbounded output places", () => {
expect(
computeTransitionCapacityConstraints({
transition: transition({ outputArcs: [{ placeId: "b", weight: 9 }] }),
placeIndexById,
placeCapacities: capacities,
}),
).toStrictEqual([]);
});

it("sums multiple output arcs into the same place", () => {
const constraints = computeTransitionCapacityConstraints({
transition: transition({
outputArcs: [
{ placeId: "a", weight: 1 },
{ placeId: "a", weight: 2 },
],
}),
placeIndexById,
placeCapacities: capacities,
});

expect(constraints).toStrictEqual([
{ placeIndex: 0, placeId: "a", delta: 3, capacity: 5 },
]);
});

it("nets standard input arcs against output arcs on the same place", () => {
// Consumes 1 and produces 3, so a firing adds 2 on balance.
const constraints = computeTransitionCapacityConstraints({
transition: transition({
inputArcs: [{ placeId: "a", weight: 1, type: "standard" }],
outputArcs: [{ placeId: "a", weight: 3 }],
}),
placeIndexById,
placeCapacities: capacities,
});

expect(constraints).toStrictEqual([
{ placeIndex: 0, placeId: "a", delta: 2, capacity: 5 },
]);
});

it("drops places a firing leaves no fuller", () => {
// A 1-in/1-out self loop cannot overflow its own place, so a full place
// must not block it.
expect(
computeTransitionCapacityConstraints({
transition: transition({
inputArcs: [{ placeId: "a", weight: 1, type: "standard" }],
outputArcs: [{ placeId: "a", weight: 1 }],
}),
placeIndexById,
placeCapacities: capacities,
}),
).toStrictEqual([]);
});

it.each(["read", "inhibitor"] as const)(
"does not offset output weight with a %s arc",
(type) => {
// Read and inhibitor arcs consume nothing, so they cannot make room.
const constraints = computeTransitionCapacityConstraints({
transition: transition({
inputArcs: [{ placeId: "a", weight: 1, type }],
outputArcs: [{ placeId: "a", weight: 1 }],
}),
placeIndexById,
placeCapacities: capacities,
});

expect(constraints).toStrictEqual([
{ placeIndex: 0, placeId: "a", delta: 1, capacity: 5 },
]);
},
);
});

describe("hasCapacityHeadroom", () => {
const constraints = [
{ placeIndex: 0, placeId: "a", delta: 2, capacity: 5 },
] as const;

it("allows a firing that exactly fills the place", () => {
expect(
hasCapacityHeadroom(constraints, new Uint32Array([3, 0, 0]), null),
).toBe(true);
});

it("blocks a firing that would exceed the capacity", () => {
expect(
hasCapacityHeadroom(constraints, new Uint32Array([4, 0, 0]), null),
).toBe(false);
});

it("counts output already committed earlier in the same frame", () => {
const counts = new Uint32Array([2, 0, 0]);

expect(hasCapacityHeadroom(constraints, counts, null)).toBe(true);
// Another transition has already produced 2 tokens this frame, so this
// firing no longer fits even though the frame's counts still say 2.
expect(
hasCapacityHeadroom(constraints, counts, new Uint32Array([2, 0, 0])),
).toBe(false);
});

it("is vacuously true with no constraints", () => {
expect(hasCapacityHeadroom([], new Uint32Array([9, 9, 9]), null)).toBe(
true,
);
});
});
Loading
Loading