From c9dc0efb227ba35818b302a87919a467f3721d66 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 15 Jul 2026 16:00:14 +0200 Subject: [PATCH] Add place counts to transition lambdas --- .changeset/petrinaut-lambda-place-counts.md | 5 ++ libs/@hashintel/petrinaut-core/src/ai.ts | 4 +- .../petrinaut-core/src/hir/BUFFER_ABI.md | 13 ++- .../petrinaut-core/src/hir/README.md | 9 +- .../petrinaut-core/src/hir/artifacts.test.ts | 20 +++-- .../petrinaut-core/src/hir/compile.test.ts | 3 +- .../petrinaut-core/src/hir/compile.ts | 3 +- .../src/hir/emit-buffer-js.test.ts | 81 ++++++++++++++++-- .../petrinaut-core/src/hir/emit-buffer-js.ts | 83 +++++++++++++++++-- .../petrinaut-core/src/hir/instantiate.ts | 20 ++++- .../petrinaut-core/src/hir/lint.test.ts | 19 +++++ .../src/hir/lower-typescript.test.ts | 24 ++++++ .../src/hir/lower-typescript.ts | 20 +++-- .../petrinaut-core/src/hir/surface-context.ts | 15 ++++ .../petrinaut-core/src/hir/typecheck.ts | 15 ++++ .../src/lsp/lib/check-hir.test.ts | 1 + .../lib/create-sdcpn-language-service.test.ts | 16 ++++ .../src/lsp/lib/generate-virtual-files.ts | 14 +++- .../src/schemas/entity-schemas.ts | 3 +- .../engine/build-simulation-hir.test.ts | 6 +- .../engine/build-simulation.test.ts | 4 +- .../src/simulation/engine/build-simulation.ts | 33 +++++++- .../engine/compute-possible-transition.ts | 16 ++-- .../src/simulation/engine/types.ts | 2 +- .../monte-carlo/transition-effect.ts | 1 + .../petrinaut/docs/petri-net-extensions.md | 15 ++++ 26 files changed, 389 insertions(+), 56 deletions(-) create mode 100644 .changeset/petrinaut-lambda-place-counts.md diff --git a/.changeset/petrinaut-lambda-place-counts.md b/.changeset/petrinaut-lambda-place-counts.md new file mode 100644 index 00000000000..7a6545c93d2 --- /dev/null +++ b/.changeset/petrinaut-lambda-place-counts.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut-core": patch +--- + +Allow transition Lambdas to read current place token counts through an optional third `places` argument. diff --git a/libs/@hashintel/petrinaut-core/src/ai.ts b/libs/@hashintel/petrinaut-core/src/ai.ts index 2bf2a9baec9..f83f79fbad8 100644 --- a/libs/@hashintel/petrinaut-core/src/ai.ts +++ b/libs/@hashintel/petrinaut-core/src/ai.ts @@ -266,10 +266,10 @@ When creating or revising a net: Validate every code-writing change. After any tool call that writes code — lambda, transition kernel, dynamics, visualizer, metric, or scenario code-mode initial state — call ${getNetCompilationErrorsToolName} before continuing and fix any reported diagnostics before relying on the new code. Do not assume a code edit is correct just because the tool call succeeded; mutations only validate the schema, not the runtime contract. -Place names are part of the code surface: lambdas/kernels read \`input.PlaceName\`, metrics read \`state.places.PlaceName.count\`, and scenario code-mode initial state keys are place names. Renaming a place via \`updatePlace\` requires updating every dependent lambda, kernel, dynamics, metric, visualizer, and scenario in the same batch — otherwise you will silently break references. +Place names are part of the code surface: lambdas/kernels read \`input.PlaceName\`, lambdas may also read \`places.PlaceName.count\` through their optional third argument, metrics read \`state.places.PlaceName.count\`, and scenario code-mode initial state keys are place names. Renaming a place via \`updatePlace\` requires updating every dependent lambda, kernel, dynamics, metric, visualizer, and scenario in the same batch — otherwise you will silently break references. Code-surface cheatsheet (exact shapes expected by the runtime): -- Transition lambda (\`transition.lambdaCode\`): \`export default Lambda((input, parameters) => …)\`. Available when stochasticity is enabled OR when colours are enabled and the transition has at least one standard or read input arc from a coloured place. \`input.PlaceName\` is a tuple sized to the input arc weight for coloured standard and read input arcs; token attributes are typed by the colour element: real/integer → number, boolean → boolean, uuid → bigint, string → string (plain JS strings everywhere, compared by value). Read arcs expose tokens in \`input\` but do not consume them when the transition fires. Inhibitor arcs and uncoloured input places are NOT in \`input\`. Predicate → boolean; stochastic → non-negative rate in firings per simulation second (0 disables, Infinity always fires). Must be deterministic. If unavailable or empty, the runtime uses true for predicate-style transitions and Infinity for stochastic-style transitions. +- Transition lambda (\`transition.lambdaCode\`): \`export default Lambda((input, parameters) => …)\`. It may take an optional third \`places\` argument; \`places.PlaceName.count\` is the current total token count for any place in the containing net, including uncoloured places. Available when stochasticity is enabled OR when colours are enabled and the transition has at least one standard or read input arc from a coloured place. \`input.PlaceName\` is a tuple sized to the input arc weight for coloured standard and read input arcs; token attributes are typed by the colour element: real/integer → number, boolean → boolean, uuid → bigint, string → string (plain JS strings everywhere, compared by value). Read arcs expose tokens in \`input\` but do not consume them when the transition fires. Inhibitor arcs and uncoloured input places are NOT in \`input\`. Predicate → boolean; stochastic → non-negative rate in firings per simulation second (0 disables, Infinity always fires). Must be deterministic. If unavailable or empty, the runtime uses true for predicate-style transitions and Infinity for stochastic-style transitions. - Transition kernel (\`transition.transitionKernelCode\`): \`export default TransitionKernel((input, parameters) => …)\`. Available only for transitions with coloured output places. Return \`{ OutputPlaceName: [token, …] }\` sized to the output arc weight. Include only coloured output places; uncoloured output places are auto-populated. Output values must match element types: real/integer use numbers, boolean uses booleans, string uses plain strings (REQUIRED in the token type; a missing/undefined value becomes the empty string \`""\`, and non-string values are stringified via \`String(value)\`). uuid attributes are OPTIONAL in output tokens: omit them to auto-generate a fresh UUID deterministically from the seeded simulation RNG, use \`Uuid.generate()\` for an explicit fresh UUID, \`Uuid.from(value)\` to derive one from a number or arbitrary string, use a UUID string directly, or forward an input token's uuid bigint unchanged. Plain non-UUID values must be wrapped in \`Uuid.from(...)\`; supplying them directly is a type error. When stochasticity is enabled, real attributes may use \`Distribution.Gaussian(mean, sd)\` / \`Distribution.Uniform(min, max)\` / \`Distribution.Lognormal(mu, sigma)\` (never integer/boolean/uuid/string attributes), and chained \`.map(fn)\` on the same distribution shares one draw. When stochasticity is disabled, kernel outputs must use plain values only. Leave empty when no coloured outputs exist. - Differential equation (\`differentialEquation.code\`): \`export default Dynamics((tokens, parameters) => …)\`. \`tokens\` is THIS place's tokens only. Return an array of the same length whose entries provide derivatives for real-valued elements only (i.e. dx/dt, not the new value); integer, boolean, uuid, and string elements are discrete and remain unchanged by dynamics (they can be read from input tokens but never written). The equation's \`colorId\` MUST match every referencing place's \`colorId\`. - Place visualizer (\`place.visualizerCode\`): \`export default Visualization(({ tokens, parameters }) => )\`. Classic React runtime — do NOT import React, do NOT use \`<>…\` fragments, do NOT use hooks. Convention: return a sized \`\`. diff --git a/libs/@hashintel/petrinaut-core/src/hir/BUFFER_ABI.md b/libs/@hashintel/petrinaut-core/src/hir/BUFFER_ABI.md index 089c3a1ad7b..bc432c29130 100644 --- a/libs/@hashintel/petrinaut-core/src/hir/BUFFER_ABI.md +++ b/libs/@hashintel/petrinaut-core/src/hir/BUFFER_ABI.md @@ -37,8 +37,11 @@ The emitter and engine use the same input slot order: Runtime arguments: ```ts +(f64, u64, u8, placeBases, indices, placeCounts) => number | boolean; + placeBases: Int32Array; // one byte offset per colored input arc indices: Int32Array; // one selected token index per input token slot +placeCounts: Uint32Array; // dense counts for every place in the frame ``` An attribute read compiles to: @@ -51,6 +54,12 @@ read view at base + fieldByteOffset Dynamic indexes into transition input tokens are rejected. Static `.map(...)` over transition token tuples is unrolled. +The optional third Lambda parameter exposes count-only place states. A read +such as `places.Queue.count` compiles directly to +`placeCounts[__places[ordinal]]`; `__places` is resolved once when the +artifact is instantiated. `HirLambdaArtifact.placeIds` stores the referenced +net-local place IDs in ordinal order, including for subnet instances. + ## Transition outputs Kernel staging is a reusable `Uint8Array`. @@ -115,13 +124,13 @@ compile to loops over `placeCounts` and `placeOffsets`. ## Artifact validation -Artifacts are `version: 4` and carry a fingerprint of the sanitized SDCPN and +Artifacts are `version: 5` and carry a fingerprint of the sanitized SDCPN and extension settings used for compilation. The engine rejects stale artifacts by checking: - artifact version and compilation-input fingerprint; -- lambda `inputSlotCount`; +- lambda `inputSlotCount` and resolvable `placeIds`; - kernel `inputSlotCount`; - kernel `outputByteCount`; - metric `placeNames` resolution. diff --git a/libs/@hashintel/petrinaut-core/src/hir/README.md b/libs/@hashintel/petrinaut-core/src/hir/README.md index 6d06a46ed70..6296e4db90e 100644 --- a/libs/@hashintel/petrinaut-core/src/hir/README.md +++ b/libs/@hashintel/petrinaut-core/src/hir/README.md @@ -79,10 +79,14 @@ outside the supported HIR subset is a blocking diagnostic. ```ts { - version: 4, + version: 5, fingerprint: string, dynamics: Record, - lambdas: Record, + lambdas: Record, kernels: Record { - it("emits stable v4 artifact shapes", () => { + it("emits stable v5 artifact shapes", () => { const artifacts = compile(); expect({ @@ -187,7 +187,8 @@ describe("compileHirArtifacts", () => { }, "lambda": { "inputSlotCount": 1, - "source": "(f64, u64, u8, placeBases, indices) => { + "placeIds": [], + "source": "(f64, u64, u8, placeBases, indices, placeCounts) => { return ((u8[placeBases[0] + indices[0] * 24 + 16] !== 0) && (f64[(placeBases[0] + indices[0] * 24) >> 3] >= __params["threshold"])); }", }, @@ -205,7 +206,7 @@ describe("compileHirArtifacts", () => { return count; }", }, - "version": 4, + "version": 5, } `); }); @@ -229,9 +230,16 @@ describe("compileHirArtifacts", () => { { x: 4, active: false, status: "queued" }, ]) { const views = packToken(token, pool); - expect(buffer(views.f64, views.u64, views.u8, placeBases, indices)).toBe( - reference({ Source: [token] }, parameters), - ); + expect( + buffer( + views.f64, + views.u64, + views.u8, + placeBases, + indices, + new Uint32Array(), + ), + ).toBe(reference({ Source: [token] }, parameters)); } }); }); diff --git a/libs/@hashintel/petrinaut-core/src/hir/compile.test.ts b/libs/@hashintel/petrinaut-core/src/hir/compile.test.ts index f2200c3e555..b6348c854d9 100644 --- a/libs/@hashintel/petrinaut-core/src/hir/compile.test.ts +++ b/libs/@hashintel/petrinaut-core/src/hir/compile.test.ts @@ -48,7 +48,7 @@ describe("compileHirArtifacts on example models", () => { (_name, sdcpn) => { const { artifacts, failures } = compileHirArtifacts(sdcpn); expect(failures).toEqual([]); - expect(artifacts.version).toBe(4); + expect(artifacts.version).toBe(5); expect(artifacts.fingerprint).toMatch(/^[0-9a-f]{16}$/); const colorById = new Map( @@ -113,6 +113,7 @@ describe("compileHirArtifacts on example models", () => { for (const artifact of Object.values(artifacts.lambdas)) { expect(typeof artifact.source).toBe("string"); expect(typeof artifact.inputSlotCount).toBe("number"); + expect(Array.isArray(artifact.placeIds)).toBe(true); } for (const artifact of Object.values(artifacts.kernels)) { expect(typeof artifact.source).toBe("string"); diff --git a/libs/@hashintel/petrinaut-core/src/hir/compile.ts b/libs/@hashintel/petrinaut-core/src/hir/compile.ts index f808deccb2f..75b9738a525 100644 --- a/libs/@hashintel/petrinaut-core/src/hir/compile.ts +++ b/libs/@hashintel/petrinaut-core/src/hir/compile.ts @@ -106,7 +106,7 @@ export function compileHirArtifacts( ): HirCompileResult { const sanitized = sanitizeSDCPNForExtensions(sdcpn, extensions); const artifacts: HirArtifacts = { - version: 4, + version: 5, fingerprint: fingerprintHirCompilationInput(sanitized, extensions), dynamics: {}, lambdas: {}, @@ -199,6 +199,7 @@ export function compileHirArtifacts( artifacts.lambdas[transition.id] = { source: program.source, inputSlotCount: program.inputSlotCount, + placeIds: program.placeIds, }; } } diff --git a/libs/@hashintel/petrinaut-core/src/hir/emit-buffer-js.test.ts b/libs/@hashintel/petrinaut-core/src/hir/emit-buffer-js.test.ts index 4e8f2e6036d..35211eaf087 100644 --- a/libs/@hashintel/petrinaut-core/src/hir/emit-buffer-js.test.ts +++ b/libs/@hashintel/petrinaut-core/src/hir/emit-buffer-js.test.ts @@ -72,6 +72,10 @@ const lambdaContext: HirLambdaContext = { ], inputPlaces: [(({ slotStart: _s, ...binding }) => binding)(poolSlot)], inputSlots: [poolSlot], + places: [ + { id: "pool", name: "Pool" }, + { id: "buffer", name: "Buffer" }, + ], lambdaType: "stochastic", }; @@ -111,6 +115,37 @@ function compileLambda(code: string, pool: StringPool, parameters = {}) { } describe("emitBufferLambdaJs (token format v2)", () => { + it("reads place counts through the optional third parameter", () => { + const pool = new StringPool(); + const program = emitBufferLambdaJs( + lower( + `export default Lambda((input, parameters, places) => places.Pool.count + places.Buffer.count);`, + "lambda", + ), + lambdaContext, + ); + expect(program).not.toBeNull(); + expect(program!.placeIds).toEqual(["pool", "buffer"]); + const fn = instantiateHirBufferLambda( + program!.source, + {}, + pool, + new Int32Array([1, 0]), + ); + const { views, placeBases, indices } = makeRegion(pool); + + expect( + fn( + views.f64, + views.u64, + views.u8, + placeBases, + indices, + new Uint32Array([4, 7]), + ), + ).toBe(11); + }); + it("reads real/boolean attributes at packed byte offsets", () => { const pool = new StringPool(); const { views, placeBases, indices } = makeRegion(pool); @@ -118,9 +153,16 @@ describe("emitBufferLambdaJs (token format v2)", () => { `export default Lambda((input, parameters) => input.Pool[0].alive ? input.Pool[0].x + input.Pool[1].v : 0);`, pool, ); - expect(fn(views.f64, views.u64, views.u8, placeBases, indices)).toBe( - 1.5 + 8, - ); + expect( + fn( + views.f64, + views.u64, + views.u8, + placeBases, + indices, + new Uint32Array(), + ), + ).toBe(1.5 + 8); }); it("resolves interned strings through the pool", () => { @@ -130,7 +172,16 @@ describe("emitBufferLambdaJs (token format v2)", () => { `export default Lambda((input, parameters) => input.Pool[0].status === "shipped" && input.Pool[1].status.startsWith("q"));`, pool, ); - expect(fn(views.f64, views.u64, views.u8, placeBases, indices)).toBe(true); + expect( + fn( + views.f64, + views.u64, + views.u8, + placeBases, + indices, + new Uint32Array(), + ), + ).toBe(true); }); it("assembles uuid attributes as bigints from the two u64 lanes", () => { @@ -140,7 +191,16 @@ describe("emitBufferLambdaJs (token format v2)", () => { `export default Lambda((input, parameters) => input.Pool[0].id === input.Pool[1].id ? 1 : 0.5);`, pool, ); - expect(fn(views.f64, views.u64, views.u8, placeBases, indices)).toBe(0.5); + expect( + fn( + views.f64, + views.u64, + views.u8, + placeBases, + indices, + new Uint32Array(), + ), + ).toBe(0.5); void uuid; }); @@ -158,7 +218,16 @@ describe("emitBufferLambdaJs (token format v2)", () => { pool, { rate: 2, threshold: 1 }, ); - expect(fn(views.f64, views.u64, views.u8, placeBases, indices)).toBe(3); + expect( + fn( + views.f64, + views.u64, + views.u8, + placeBases, + indices, + new Uint32Array(), + ), + ).toBe(3); }); }); diff --git a/libs/@hashintel/petrinaut-core/src/hir/emit-buffer-js.ts b/libs/@hashintel/petrinaut-core/src/hir/emit-buffer-js.ts index ed1d1102139..6eccae8740f 100644 --- a/libs/@hashintel/petrinaut-core/src/hir/emit-buffer-js.ts +++ b/libs/@hashintel/petrinaut-core/src/hir/emit-buffer-js.ts @@ -5,7 +5,7 @@ * Emitted functions read token attributes straight out of the engine's packed * token bytes at statically-resolved offsets: * - * lambda: (f64, u64, u8, placeBases, indices) => number | boolean + * lambda: (f64, u64, u8, placeBases, indices, placeCounts) => number | boolean * kernel: (f64, u64, u8, placeBases, indices, outF64, outU64, outU8, sink) => void * metric: (f64, u64, u8, placeCounts, placeOffsets) => number * @@ -43,6 +43,12 @@ export type BufferProgram = { inputSlotCount: number; }; +export type BufferLambdaProgram = BufferProgram & { + /** Net-local place IDs referenced by the optional third Lambda parameter, + * in emitted-ordinal order. */ + placeIds: string[]; +}; + export type BufferKernelProgram = BufferProgram & { /** Expected staging byte length — engine-side sanity check. */ outputByteCount: number; @@ -91,6 +97,10 @@ type MetricPlaceRef = { elements: HirTokenElementInfo[]; }; +type CountPlaceRef = { + ordinal: number; +}; + type Value = /** A JS expression producing a number or boolean. */ | { kind: "scalar"; code: string } @@ -110,6 +120,10 @@ type Value = | { kind: "metricState" } /** `state.places` in a metric program. */ | { kind: "placesRecord" } + /** Optional third `places` parameter in a transition Lambda. */ + | { kind: "lambdaPlacesRecord" } + /** `places.` in a transition Lambda (count-only). */ + | ({ kind: "lambdaPlaceState" } & CountPlaceRef) /** `state.places.` — `count`/`tokens` read through `__places`. */ | ({ kind: "placeState" } & MetricPlaceRef) /** `state.places..tokens` — a dynamically-sized token array. */ @@ -167,15 +181,39 @@ class BufferEmitter { string, { name: string; elements: HirTokenElementInfo[] } > | null; + private readonly lambdaPlaceByName: Map< + string, + { name: string; id: string } + > | null; private readonly metricOrdinalByName = new Map(); + private readonly lambdaOrdinalByName = new Map(); + readonly lambdaPlaceIds: string[] = []; constructor( readonly inputSlots: HirArcSlot[], metricContext: HirMetricContext | null = null, + lambdaContext: HirLambdaContext | null = null, ) { this.metricPlaceByName = metricContext ? new Map(metricContext.places.map((place) => [place.name, place])) : null; + this.lambdaPlaceByName = lambdaContext + ? new Map(lambdaContext.places.map((place) => [place.name, place])) + : null; + } + + private lambdaPlaceRef(name: string): CountPlaceRef { + const place = this.lambdaPlaceByName?.get(name); + if (!place) { + throw new BailError(); + } + let ordinal = this.lambdaOrdinalByName.get(name); + if (ordinal === undefined) { + ordinal = this.lambdaPlaceIds.length; + this.lambdaPlaceIds.push(place.id); + this.lambdaOrdinalByName.set(name, ordinal); + } + return { ordinal }; } /** Resolves a place display name to its arc slot (last arc wins, matching @@ -397,6 +435,21 @@ class BufferEmitter { if (target.kind === "placesRecord") { return { kind: "placeState", ...this.metricPlaceRef(expr.field) }; } + if (target.kind === "lambdaPlacesRecord") { + return { + kind: "lambdaPlaceState", + ...this.lambdaPlaceRef(expr.field), + }; + } + if (target.kind === "lambdaPlaceState") { + if (expr.field === "count") { + return { + kind: "scalar", + code: `placeCounts[__places[${target.ordinal}]]`, + }; + } + throw new BailError(); + } if (target.kind === "placeState") { if (expr.field === "count") { return { @@ -772,12 +825,21 @@ class BufferEmitter { } } -function initialEnv(fn: HirFunction): Map { +function initialEnv( + fn: HirFunction, + options: { bindLambdaPlaces?: boolean } = {}, +): Map { const env = new Map(); const tokensParam = fn.params[0]; if (tokensParam) { env.set(tokensParam.name, { kind: "inputRecord" }); } + if (options.bindLambdaPlaces) { + const placesParam = fn.params[2]; + if (placesParam) { + env.set(placesParam.name, { kind: "lambdaPlacesRecord" }); + } + } return env; } @@ -796,20 +858,27 @@ function slotCount(inputSlots: HirArcSlot[]): number { export function emitBufferLambdaJs( fn: HirFunction, context: HirLambdaContext, -): BufferProgram | null { +): BufferLambdaProgram | null { try { - const emitter = new BufferEmitter(context.inputSlots); - const result = emitter.eval(foldHir(fn.body), initialEnv(fn)); + const emitter = new BufferEmitter(context.inputSlots, null, context); + const result = emitter.eval( + foldHir(fn.body), + initialEnv(fn, { bindLambdaPlaces: true }), + ); if (result.kind !== "scalar") { return null; } const source = [ - `(f64, u64, u8, placeBases, indices) => {`, + `(f64, u64, u8, placeBases, indices, placeCounts) => {`, ...emitter.lines.map((line) => ` ${line}`), ` return ${result.code};`, `}`, ].join("\n"); - return { source, inputSlotCount: slotCount(context.inputSlots) }; + return { + source, + inputSlotCount: slotCount(context.inputSlots), + placeIds: emitter.lambdaPlaceIds, + }; } catch (error) { if (error instanceof BailError) { return null; diff --git a/libs/@hashintel/petrinaut-core/src/hir/instantiate.ts b/libs/@hashintel/petrinaut-core/src/hir/instantiate.ts index c6b8a8ef2d8..08f7ca0c9ba 100644 --- a/libs/@hashintel/petrinaut-core/src/hir/instantiate.ts +++ b/libs/@hashintel/petrinaut-core/src/hir/instantiate.ts @@ -52,6 +52,8 @@ export type HirCompiledBufferLambda = ( u8: Uint8Array, placeBases: Int32Array, indices: Int32Array, + /** Dense per-place token counts from the current frame. */ + placeCounts: Uint32Array, ) => number | boolean; /** @@ -86,6 +88,9 @@ export type HirLambdaArtifact = { source: string; /** Expected `indices.length` — engine-side sanity check. */ inputSlotCount: number; + /** Net-local IDs referenced through the optional third Lambda parameter, + * in `__places` ordinal order. */ + placeIds: string[]; }; export type HirKernelArtifact = { @@ -112,7 +117,7 @@ export type HirMetricArtifact = { * `compileHirArtifacts`; the engine has no other compilation path. */ export type HirArtifacts = { - version: 4; + version: 5; /** Hash of the sanitized SDCPN + extensions used during compilation. */ fingerprint: string; dynamics: Record; @@ -184,11 +189,20 @@ export function instantiateHirBufferLambda( source: string, parameterValues: HirParameterValues, stringPool: HirStringPool, + placeIndices: Int32Array = new Int32Array(), ): HirCompiledBufferLambda { - return instantiate( - source, + // eslint-disable-next-line no-new-func, @typescript-eslint/no-implied-eval, @typescript-eslint/no-unsafe-call + return new Function( + "__dist", + "__params", + "__pool", + "__places", + `"use strict"; return (${source});`, + )( + hirDistributionRuntime, parameterValues, stringPool, + placeIndices, ) as HirCompiledBufferLambda; } diff --git a/libs/@hashintel/petrinaut-core/src/hir/lint.test.ts b/libs/@hashintel/petrinaut-core/src/hir/lint.test.ts index 9718e86755f..e5c1f1034aa 100644 --- a/libs/@hashintel/petrinaut-core/src/hir/lint.test.ts +++ b/libs/@hashintel/petrinaut-core/src/hir/lint.test.ts @@ -45,6 +45,10 @@ const lambdaContext: HirLambdaContext = { parameters: [{ name: "rate", type: "real" }], inputPlaces: [poolBinding], inputSlots: [{ ...poolBinding, slotStart: 0 }], + places: [ + { id: "pool", name: "Pool" }, + { id: "out", name: "Out" }, + ], lambdaType: "stochastic", }; @@ -93,6 +97,21 @@ describe("lintHirUserCode", () => { ).toEqual([]); }); + it("types the count-only third Lambda parameter", () => { + expect( + codes( + `export default Lambda((input, parameters, places) => places.Pool.count + places.Out.count);`, + lambdaContext, + ), + ).toEqual([]); + expect( + codes( + `export default Lambda((input, parameters, places) => places.Missing.count);`, + lambdaContext, + ), + ).toContainEqual(["hir:unknown-field", "error"]); + }); + it("reports out-of-subset code as an error by default", () => { const result = lintHirUserCode( `export default Lambda((input) => { let x = 1; return x; });`, diff --git a/libs/@hashintel/petrinaut-core/src/hir/lower-typescript.test.ts b/libs/@hashintel/petrinaut-core/src/hir/lower-typescript.test.ts index 88e4ad064d8..05f7039f44a 100644 --- a/libs/@hashintel/petrinaut-core/src/hir/lower-typescript.test.ts +++ b/libs/@hashintel/petrinaut-core/src/hir/lower-typescript.test.ts @@ -31,6 +31,30 @@ function lowerErr( } describe("lowerTypeScriptToHir", () => { + it("accepts an optional third parameter for Lambdas only", () => { + const lambda = lowerTypeScriptToHir( + `export default Lambda((input, parameters, places) => places.Source.count > 0);`, + "lambda", + ); + expect(lambda.ok).toBe(true); + if (lambda.ok) { + expect(lambda.fn.params.map((parameter) => parameter.name)).toEqual([ + "input", + "parameters", + "places", + ]); + } + + const kernel = lowerTypeScriptToHir( + `export default TransitionKernel((input, parameters, places) => ({}));`, + "kernel", + ); + expect(kernel.ok).toBe(false); + if (!kernel.ok) { + expect(kernel.diagnostics[0]?.code).toBe("hir:too-many-parameters"); + } + }); + describe("module shape", () => { it("lowers the default dynamics template", () => { const fn = lowerOk( diff --git a/libs/@hashintel/petrinaut-core/src/hir/lower-typescript.ts b/libs/@hashintel/petrinaut-core/src/hir/lower-typescript.ts index c900a16d73a..dbcdc6d7cf9 100644 --- a/libs/@hashintel/petrinaut-core/src/hir/lower-typescript.ts +++ b/libs/@hashintel/petrinaut-core/src/hir/lower-typescript.ts @@ -271,11 +271,14 @@ class Lowering { ); } - if (fnArg.parameters.length > 2) { + const maxParameters = this.surface === "lambda" ? 3 : 2; + if (fnArg.parameters.length > maxParameters) { this.fail( - fnArg.parameters[2]!, + fnArg.parameters[maxParameters]!, "hir:too-many-parameters", - "User functions take at most two parameters (tokens and parameters).", + this.surface === "lambda" + ? "Lambda functions take at most three parameters (tokens, parameters, and places)." + : "User functions take at most two parameters (tokens and parameters).", ); } @@ -294,15 +297,18 @@ class Lowering { params.push({ name, span: this.spanOf(parameter.name) }); if (index === 0) { scope.locals.add(name); - } else { + } else if (index === 1) { scope.parametersName = name; + } else { + scope.locals.add(name); } continue; } if (ts.isObjectBindingPattern(parameter.name)) { // Destructured parameter: synthesize a name and route the bound // identifiers to the underlying tokens object / parameter reads. - const syntheticName = index === 0 ? "__input" : "__parameters"; + const syntheticName = + index === 0 ? "__input" : index === 1 ? "__parameters" : "__places"; params.push({ name: syntheticName, span: this.spanOf(parameter.name), @@ -316,13 +322,13 @@ class Lowering { "Renames are not supported when destructuring function parameters.", ); } - if (index === 0) { + if (index === 0 || index === 2) { scope.destructuredFields.set(bound.name, syntheticName); } else { scope.parameterAliases.set(bound.name, bound.sourceName); } } - if (index === 0) { + if (index === 0 || index === 2) { scope.locals.add(syntheticName); } continue; diff --git a/libs/@hashintel/petrinaut-core/src/hir/surface-context.ts b/libs/@hashintel/petrinaut-core/src/hir/surface-context.ts index 3371e0b0917..424bb2715be 100644 --- a/libs/@hashintel/petrinaut-core/src/hir/surface-context.ts +++ b/libs/@hashintel/petrinaut-core/src/hir/surface-context.ts @@ -98,9 +98,19 @@ export type HirLambdaContext = { inputPlaces: HirPlaceBinding[]; /** Buffer-ABI input slot layout (see module doc). */ inputSlots: HirArcSlot[]; + /** Every place in the containing net, available to the Lambda's optional + * third `places` parameter. Duplicate display names use the last place. */ + places: HirLambdaPlaceInfo[]; lambdaType: "predicate" | "stochastic"; }; +export type HirLambdaPlaceInfo = { + /** Display name used as the property key in user code. */ + name: string; + /** Net-local ID, resolved to a dense frame-place index at instantiation. */ + id: string; +}; + export type HirKernelContext = { surface: "kernel"; parameters: HirParameterInfo[]; @@ -307,11 +317,16 @@ export function buildLambdaContext( collectColors(sdcpn, extensions), { includeZeroElementColors: false }, ); + const placesByName = new Map(); + for (const place of net.places) { + placesByName.set(place.name, { name: place.name, id: place.id }); + } return { surface: "lambda", parameters: toParameterInfos(extensions.parameters ? net.parameters : []), inputPlaces: bindings, inputSlots: slots, + places: [...placesByName.values()], lambdaType: getEffectiveTransitionLambdaType(transition, availability), }; } diff --git a/libs/@hashintel/petrinaut-core/src/hir/typecheck.ts b/libs/@hashintel/petrinaut-core/src/hir/typecheck.ts index bd2663ec947..728fd0d2411 100644 --- a/libs/@hashintel/petrinaut-core/src/hir/typecheck.ts +++ b/libs/@hashintel/petrinaut-core/src/hir/typecheck.ts @@ -174,6 +174,21 @@ class Typechecker { if (tokensParam) { env.set(tokensParam.name, this.tokensParamType()); } + if (this.context.surface === "lambda") { + const placesParam = fn.params[2]; + if (placesParam) { + env.set(placesParam.name, { + kind: "record", + fields: this.context.places.map((place) => ({ + name: place.name, + type: { + kind: "record", + fields: [{ name: "count", type: HIR_TYPE_INT }], + }, + })), + }); + } + } const returnType = this.infer(fn.body, env); this.checkReturnType(fn, returnType); return returnType; diff --git a/libs/@hashintel/petrinaut-core/src/lsp/lib/check-hir.test.ts b/libs/@hashintel/petrinaut-core/src/lsp/lib/check-hir.test.ts index f7ce5d2ee3c..62ee9205435 100644 --- a/libs/@hashintel/petrinaut-core/src/lsp/lib/check-hir.test.ts +++ b/libs/@hashintel/petrinaut-core/src/lsp/lib/check-hir.test.ts @@ -13,6 +13,7 @@ const lambdaContext: HirLambdaContext = { parameters: [], inputPlaces: [], inputSlots: [], + places: [], lambdaType: "stochastic", }; diff --git a/libs/@hashintel/petrinaut-core/src/lsp/lib/create-sdcpn-language-service.test.ts b/libs/@hashintel/petrinaut-core/src/lsp/lib/create-sdcpn-language-service.test.ts index 0bfabc4f17e..fbe6130f68d 100644 --- a/libs/@hashintel/petrinaut-core/src/lsp/lib/create-sdcpn-language-service.test.ts +++ b/libs/@hashintel/petrinaut-core/src/lsp/lib/create-sdcpn-language-service.test.ts @@ -163,6 +163,22 @@ describe("SDCPNLanguageServer completions", () => { expect(names).not.toContain("Array"); }); + it("returns every place and its count through the third Lambda parameter", () => { + const placeNames = getCompletionNames( + baseSdcpn, + `export default Lambda((input, parameters, places) => {\n return places.${CURSOR};\n});`, + { type: "transition-lambda" }, + ); + expect(placeNames).toEqual(expect.arrayContaining(["Source", "Target"])); + + const stateNames = getCompletionNames( + baseSdcpn, + `export default Lambda((input, parameters, places) => {\n return places.Source.${CURSOR};\n});`, + { type: "transition-lambda" }, + ); + expect(stateNames).toContain("count"); + }); + it("returns token properties after `input.Source[0].`", () => { const names = getCompletionNames( baseSdcpn, diff --git a/libs/@hashintel/petrinaut-core/src/lsp/lib/generate-virtual-files.ts b/libs/@hashintel/petrinaut-core/src/lsp/lib/generate-virtual-files.ts index 816e3dfc655..2620bb18639 100644 --- a/libs/@hashintel/petrinaut-core/src/lsp/lib/generate-virtual-files.ts +++ b/libs/@hashintel/petrinaut-core/src/lsp/lib/generate-virtual-files.ts @@ -179,6 +179,17 @@ export function generateVirtualFiles( const resolveArcPlace = createArcPlaceResolver(sdcpn, sdcpn, { componentPortsEnabled: extensions.subnets, }); + const placesByName = new Map( + sdcpn.places.map((place) => [place.name, place] as const), + ); + const placesStateProperties = [...placesByName.values()] + .map( + (place) => ` ${JSON.stringify(place.name)}: { readonly count: number };`, + ) + .join("\n"); + const placesStateType = `{ +${placesStateProperties} +}`; // Generate parameters type definition const parametersProperties = (extensions.parameters ? sdcpn.parameters : []) @@ -377,7 +388,8 @@ export function generateVirtualFiles( ...allImports, ``, `export type Input = ${inputType};`, - `export type Lambda = (fn: (input: Input, parameters: Parameters) => ${lambdaReturnType}) => void;`, + `export type Places = ${placesStateType};`, + `export type Lambda = (fn: (input: Input, parameters: Parameters, places: Places) => ${lambdaReturnType}) => void;`, ].join("\n"), }); diff --git a/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts b/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts index fd48c8705db..7ed2d72c250 100644 --- a/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts +++ b/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts @@ -233,13 +233,14 @@ export const transitionSchema = z }), lambdaCode: z.string().meta({ description: [ - "Optional module: `export default Lambda((input, parameters) => …)`.", + "Optional module: `export default Lambda((input, parameters, places) => …)`; the third argument is optional.", "Lambda code is meaningful only when stochasticity is enabled OR when colours are enabled and the transition has at least one standard or read input arc from a coloured place.", "`input` is keyed by INPUT PLACE NAME (PascalCase) for coloured standard and read arcs, and the value is a tuple sized to that arc's weight (weight 2 means a 2-token array).", "Read arc tokens are present in `input` but are not consumed when the transition fires.", "Inhibitor arcs and uncoloured input places are NOT present in `input`.", "Each token is an object keyed by the colour type's element names (e.g. `{ x, y, velocity }`).", "`parameters` is keyed by each parameter's `variableName` value (lower_snake_case, e.g. `parameters.infection_rate`).", + "`places` is keyed by every place name in the containing net and exposes its current token count as `places.PlaceName.count`.", "Predicate lambdas MUST return a boolean (true = enabled given these tokens, false = disabled).", "Stochastic lambdas MUST return a non-negative number = expected firings per simulation second (0 disables, Infinity always fires).", "Lambda is called per token combination satisfying arc weights, so it MUST be deterministic — put randomness in the transition kernel, not here.", diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/build-simulation-hir.test.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/build-simulation-hir.test.ts index f782ae6bf0a..b370475fb82 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/build-simulation-hir.test.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/build-simulation-hir.test.ts @@ -83,8 +83,9 @@ const sdcpn: SDCPN = { lambdaType: "stochastic", inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], outputArcs: [{ placeId: "p2", weight: 1 }], - lambdaCode: `export default Lambda((input, parameters) => { + lambdaCode: `export default Lambda((input, parameters, places) => { const { x } = input.Source[0]; + if (places.Source.count + places.Target.count === 0) return 0; if (x > 0) return parameters.rate; return 0.5; });`, @@ -140,11 +141,12 @@ describe("buildSimulation with HIR artifacts", () => { it("compiles buffer programs for all three surfaces", () => { const { artifacts, failures } = compileHirArtifacts(sdcpn); expect(failures).toEqual([]); - expect(artifacts.version).toBe(4); + expect(artifacts.version).toBe(5); expect(artifacts.fingerprint).toMatch(/^[0-9a-f]{16}$/); expect(typeof artifacts.dynamics.de1!.source).toBe("string"); expect(typeof artifacts.lambdas.t1!.source).toBe("string"); expect(artifacts.lambdas.t1!.inputSlotCount).toBe(1); + expect(artifacts.lambdas.t1!.placeIds).toEqual(["p1", "p2"]); expect(typeof artifacts.kernels.t1!.source).toBe("string"); expect(artifacts.kernels.t1!.inputSlotCount).toBe(1); // One output token: x(f64) + v(f64) + generation(f64) → 24-byte stride. diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/build-simulation.test.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/build-simulation.test.ts index 59d5f987258..17b609e841c 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/build-simulation.test.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/build-simulation.test.ts @@ -33,6 +33,7 @@ function callLambda( new Uint8Array(0), new Int32Array(0), new Int32Array(0), + new Uint32Array(0), ); } @@ -521,7 +522,8 @@ describe("buildSimulation", () => { ], outputArcs: [{ placeId: "port-out", weight: 1 }], lambdaType: "predicate", - lambdaCode: "", + lambdaCode: + 'export default Lambda((input, parameters, places) => places["Port In"].count > 0);', transitionKernelCode: "", x: 50, y: 0, diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/build-simulation.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/build-simulation.ts index d0d485c29ee..45c2b0eb7d2 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/build-simulation.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/build-simulation.ts @@ -69,9 +69,9 @@ function validateHirArtifacts( } const runtimeVersion: unknown = (artifacts as { version?: unknown }).version; - if (runtimeVersion !== 4) { + if (runtimeVersion !== 5) { 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 5. Recompile them from the current net.`, ); } @@ -225,6 +225,13 @@ function sourceItemId(flattenedId: string): string { : flattenedId.slice(separatorIndex + 2); } +function scopeSourceItemId(flattenedItemId: string, sourceId: string): string { + const separatorIndex = flattenedItemId.lastIndexOf("::"); + return separatorIndex === -1 + ? sourceId + : `${flattenedItemId.slice(0, separatorIndex)}::${sourceId}`; +} + /** Error for items whose code has no compiled artifact (outside the * supported subset, or artifacts not supplied). */ function missingArtifactError( @@ -317,6 +324,7 @@ function createLambdaFn({ parameterValues, artifact, expectedSlotCount, + placeIndexById, stringPool, }: { transition: SimulationInput["sdcpn"]["transitions"][number]; @@ -325,6 +333,7 @@ function createLambdaFn({ parameterValues: ParameterValues; artifact: HirLambdaArtifact | undefined; expectedSlotCount: number; + placeIndexById: ReadonlyMap; stringPool: StringPool; }): HirCompiledBufferLambda { const availability = getTransitionLogicAvailability( @@ -350,11 +359,25 @@ function createLambdaFn({ ); } + const placeIndices = new Int32Array(artifact.placeIds.length); + for (const [ordinal, sourcePlaceId] of artifact.placeIds.entries()) { + const flattenedPlaceId = scopeSourceItemId(transition.id, sourcePlaceId); + const placeIndex = placeIndexById.get(flattenedPlaceId); + if (placeIndex === undefined) { + throw new SDCPNItemError( + `The compiled Lambda for transition \`${transition.name}\` references place ID \`${sourcePlaceId}\`, which cannot be resolved in this simulation instance. Recompile the artifacts from the current net.`, + transition.id, + ); + } + placeIndices[ordinal] = placeIndex; + } + try { return instantiateHirBufferLambda( artifact.source, parameterValues, stringPool, + placeIndices, ); } catch (error) { throw new SDCPNItemError( @@ -442,6 +465,7 @@ function createCompiledTransition({ lambdaArtifact, kernelArtifact, stringPool, + placeIndexById, }: { transition: SimulationInput["sdcpn"]["transitions"][number]; sdcpn: SimulationInput["sdcpn"]; @@ -453,6 +477,7 @@ function createCompiledTransition({ lambdaArtifact: HirLambdaArtifact | undefined; kernelArtifact: HirKernelArtifact | undefined; stringPool: StringPool; + placeIndexById: ReadonlyMap; }): CompiledTransition { const expectedSlotCount = computeInputSlotCount( transition, @@ -472,6 +497,7 @@ function createCompiledTransition({ parameterValues, artifact: lambdaArtifact, expectedSlotCount, + placeIndexById, stringPool, }); const kernelFn = createTransitionKernelFn({ @@ -618,6 +644,7 @@ export function buildSimulation(input: SimulationInput): SimulationInstance { sdcpn.transitions.map((transition) => [transition.id, transition]), ); const typesMap = new Map(sdcpn.types.map((type) => [type.id, type])); + const frameLayout = createEngineFrameLayout(sdcpn); // Build parameter values: merge input values with SDCPN defaults // Input values (from simulation store) take precedence over defaults @@ -734,13 +761,13 @@ export function buildSimulation(input: SimulationInput): SimulationInstance { kernelArtifact: input.hirArtifacts?.kernels[sourceItemId(transition.id)], stringPool, + placeIndexById: frameLayout.placeIndexById, }), ); } // 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()) { diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/compute-possible-transition.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/compute-possible-transition.ts index 743825f71c9..bca702262d4 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/compute-possible-transition.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/compute-possible-transition.ts @@ -1,5 +1,5 @@ import { SDCPNItemError } from "../../errors"; -import { materializeEngineFrame } from "../frames/internal-frame"; +import { readEngineFrame } from "../frames/internal-frame"; import { executeBufferKernel, fillPlaceBases, @@ -7,7 +7,6 @@ import { } from "./buffer-transition"; import { enumerateWeightedMarkingIndicesGenerator } from "./enumerate-weighted-markings"; import { nextRandom } from "./seeded-rng"; -import { createTokenRegionViews } from "./token-layout"; import type { ID } from "../../types/sdcpn"; import type { EngineFrame, SimulationInstance } from "./types"; @@ -34,8 +33,8 @@ export function computePossibleTransition( add: Record; newRngState: number; } { - const snapshot = materializeEngineFrame(simulation.frameLayout, frame); - const transitionState = snapshot.transitions[transitionId]; + const frameView = readEngineFrame(simulation.frameLayout, frame); + const transitionState = frameView.getTransitionState(transitionId); if (!transitionState) { throw new Error(`Transition with ID ${transitionId} not found.`); } @@ -49,7 +48,7 @@ export function computePossibleTransition( // Gather input places with their weights relative to this transition. const inputPlaces = transition.inputPlaces.map((inputPlace) => { - const placeState = snapshot.places[inputPlace.placeId]; + const placeState = frameView.getPlaceState(inputPlace.placeId); if (!placeState) { throw new Error( `Place with ID ${inputPlace.placeId} not found in current marking.`, @@ -84,11 +83,7 @@ export function computePossibleTransition( const { timeSinceLastFiringMs } = transitionState; // Shared views over the frame's token byte region. - const tokenViews = createTokenRegionViews( - snapshot.buffer.buffer, - snapshot.buffer.byteOffset, - snapshot.buffer.byteLength, - ); + const tokenViews = frameView.tokenViews; const inputPlacesWithTokenValues = inputPlaces.filter( (place) => place.strideBytes > 0 && place.arcType !== "inhibitor", @@ -133,6 +128,7 @@ export function computePossibleTransition( tokenViews.u8, transition.placeBases, transition.indices, + frameView.placeCounts, ); } catch (err) { throw new SDCPNItemError( diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/types.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/types.ts index b2b6e68089a..6056c0d436d 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/types.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/types.ts @@ -70,7 +70,7 @@ export type CompiledTransition = { name: string; inputPlaces: readonly CompiledTransitionInputPlace[]; outputPlaces: readonly CompiledTransitionPlace[]; - /** Buffer-ABI lambda `(f64, u64, u8, placeBases, indices) => number | + /** Buffer-ABI lambda `(f64, u64, u8, placeBases, indices, placeCounts) => number | * boolean` (token format v2 packed structs); parameters/pool pre-bound. */ lambdaFn: HirCompiledBufferLambda; /** Buffer-ABI kernel writing into `kernelStaging`, or null when the diff --git a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/transition-effect.ts b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/transition-effect.ts index 3f1dcde3b1e..8007149c7df 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/transition-effect.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/transition-effect.ts @@ -93,6 +93,7 @@ export function computeTransitionEffect( frame.tokenViews.u8, transition.placeBases, transition.indices, + frame.placeCounts, ); } catch (error) { throw new SDCPNItemError( diff --git a/libs/@hashintel/petrinaut/docs/petri-net-extensions.md b/libs/@hashintel/petrinaut/docs/petri-net-extensions.md index 235181cc87c..f02211b1869 100644 --- a/libs/@hashintel/petrinaut/docs/petri-net-extensions.md +++ b/libs/@hashintel/petrinaut/docs/petri-net-extensions.md @@ -195,6 +195,21 @@ export default Lambda((tokensByPlace, parameters) => { The same `tokensByPlace` rules from the [Transition kernel](#transition-kernel) section apply: only typed input places appear, and only for normal arcs. A transition with **no input arcs** therefore sees an empty `tokensByPlace` and is always structurally enabled -- this is how you model exogenous arrivals (see [Source transitions](useful-patterns.md#source-transitions-exogenous-arrivals)). +For a guard or rate that depends on the total number of tokens in a place, +Lambda functions may take an optional third `places` argument. It contains +every place in the current net, including uncoloured places, and exposes its +current token count: + +```ts +export default Lambda((tokensByPlace, parameters, places) => { + const totalWaiting = places.Waiting.count + places.Retrying.count; + return totalWaiting < parameters.queue_limit; +}); +``` + +This argument is count-only; use `tokensByPlace` when you need attributes from +the particular coloured tokens selected for the transition. + ## Inhibitor arcs An inhibitor arc is a special input arc that **prevents** a transition from firing when the source place has tokens equal to or greater than the arc weight -- the opposite of a normal arc.