diff --git a/.changeset/lazy-weighted-arc-enumeration.md b/.changeset/lazy-weighted-arc-enumeration.md new file mode 100644 index 00000000000..54d8354342e --- /dev/null +++ b/.changeset/lazy-weighted-arc-enumeration.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut-core": patch +--- + +Weighted-arc token combinations enumerate lazily in the same lexicographic order, so a transition with a weight-2 coloured input arc no longer materialises every combination per frame. Trajectories are unchanged for every seed. diff --git a/.changeset/token-independent-lambda-fast-path.md b/.changeset/token-independent-lambda-fast-path.md new file mode 100644 index 00000000000..5c4a630e05a --- /dev/null +++ b/.changeset/token-independent-lambda-fast-path.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut-core": patch +--- + +Transitions whose lambda reads no input tokens skip combination enumeration and evaluate the lambda once against the first tokens in place order. Trajectories are unchanged for every seed. diff --git a/apps/petrinaut-website/README.md b/apps/petrinaut-website/README.md index 958a1b0b8b2..e4219f7c5cb 100644 --- a/apps/petrinaut-website/README.md +++ b/apps/petrinaut-website/README.md @@ -44,10 +44,10 @@ use provider-pattern discovery instead. From the repository root, run: ```sh -yarn dev:petrinaut-optimization +turbo run dev --filter @apps/petrinaut-website -- --with-optimizer-service ``` -This builds and starts the local Petrinaut Opt Docker image, waits for its +The flag builds and starts the local Petrinaut Opt Docker image, waits for its health endpoint, and starts the website with the real optimization provider. Open [http://localhost:5173/optimization](http://localhost:5173/optimization). Stopping the command also stops and removes its optimizer container. diff --git a/apps/petrinaut-website/package.json b/apps/petrinaut-website/package.json index f8e33266dd9..adac5d9276e 100644 --- a/apps/petrinaut-website/package.json +++ b/apps/petrinaut-website/package.json @@ -7,8 +7,7 @@ "brunch:fixture": "node --experimental-strip-types scripts/brunch-sse-fixture.ts", "build": "vite build", "codegen": "node --experimental-strip-types scripts/generate-route-tree.ts", - "dev": "yarn examples:generate && vite", - "dev:optimization": "node scripts/optimization-dev.mjs", + "dev": "bash scripts/dev.sh", "examples:generate": "node --experimental-strip-types scripts/generate-example-artifacts.ts", "fix:eslint": "oxlint --fix --type-aware --report-unused-disable-directives-severity=error .", "generate:icons": "node scripts/generate-favicons.mjs", diff --git a/apps/petrinaut-website/scripts/dev.sh b/apps/petrinaut-website/scripts/dev.sh new file mode 100644 index 00000000000..6a3c1c1e170 --- /dev/null +++ b/apps/petrinaut-website/scripts/dev.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# The website's dev task. With --with-optimizer-service it also builds and +# starts the local Petrinaut Optimizer, so the /optimization route runs studies +# for real; every other argument goes to Vite: +# +# turbo run dev --filter @apps/petrinaut-website -- --with-optimizer-service +set -euo pipefail +cd "$(dirname "$0")/.." +. ../../libs/@local/petrinaut-optimizer-client/scripts/optimizer-service.sh +optimizer_service_parse "$@" +yarn examples:generate +run_dev_server yarn vite ${OPTIMIZER_FORWARDED[@]+"${OPTIMIZER_FORWARDED[@]}"} diff --git a/apps/petrinaut-website/scripts/optimization-dev.mjs b/apps/petrinaut-website/scripts/optimization-dev.mjs deleted file mode 100644 index 903336115ad..00000000000 --- a/apps/petrinaut-website/scripts/optimization-dev.mjs +++ /dev/null @@ -1,137 +0,0 @@ -#!/usr/bin/env node - -import { spawn } from "node:child_process"; -import { fileURLToPath } from "node:url"; - -const appDirectory = fileURLToPath(new URL("..", import.meta.url)); -const repositoryRoot = fileURLToPath(new URL("../../..", import.meta.url)); -const image = "petrinaut-opt:local"; -const container = `petrinaut-opt-website-dev-${process.pid}`; -// This launcher binds the development container to loopback only, so local -// plaintext HTTP is intentional and is never used by a deployed application. -// nosemgrep: typescript.react.security.react-insecure-request.react-insecure-request -const optimizerOrigin = "http://127.0.0.1:4004"; - -const wait = (durationMs) => - new Promise((resolve) => setTimeout(resolve, durationMs)); - -const run = (command, args, options = {}) => - new Promise((resolve, reject) => { - const child = spawn(command, args, { - cwd: options.cwd ?? repositoryRoot, - env: options.env ?? process.env, - stdio: options.stdio ?? "inherit", - }); - child.once("error", reject); - child.once("exit", (code, signal) => { - if (code === 0) { - resolve({ code, signal }); - } else { - reject( - new Error( - `${command} exited ${signal ? `with ${signal}` : `with code ${code}`}`, - ), - ); - } - }); - }); - -const waitForOptimizer = async () => { - for (let attempt = 0; attempt < 60; attempt += 1) { - try { - const response = await fetch(`${optimizerOrigin}/status`); - if (response.ok) { - return; - } - } catch { - // The container is still starting. - } - await wait(500); - } - throw new Error("Petrinaut Opt did not become healthy within 30 seconds"); -}; - -let containerStarted = false; -let websiteProcess; - -const stopContainer = async () => { - if (!containerStarted) { - return; - } - containerStarted = false; - await run("docker", ["stop", "--timeout", "5", container], { - stdio: "ignore", - }).catch(() => undefined); -}; - -try { - await run("docker", ["info"], { stdio: "ignore" }).catch(() => { - throw new Error( - "Docker is not running. Start Docker Desktop and run the command again.", - ); - }); - - console.log("Building Petrinaut Opt..."); - await run("docker", [ - "build", - "--file", - "apps/petrinaut-opt/docker/Dockerfile", - "--tag", - image, - ".", - ]); - - console.log("Starting Petrinaut Opt on http://127.0.0.1:4004..."); - await run("docker", [ - "run", - "--detach", - "--init", - "--read-only", - "--rm", - "--name", - container, - "--publish", - "127.0.0.1:4004:4004", - image, - ]); - containerStarted = true; - await waitForOptimizer(); - - console.log("Building Petrinaut for the demo website..."); - await run("turbo", ["build", "--filter", "@hashintel/petrinaut"]); - - console.log("Starting the Petrinaut optimization demo..."); - websiteProcess = spawn("yarn", ["vite"], { - cwd: appDirectory, - env: { - ...process.env, - PETRINAUT_OPT_ORIGIN: optimizerOrigin, - VITE_PETRINAUT_OPT_PROVIDER: "service", - }, - stdio: "inherit", - }); - - const forwardSignal = (signal) => websiteProcess?.kill(signal); - const handleSigint = () => forwardSignal("SIGINT"); - const handleSigterm = () => forwardSignal("SIGTERM"); - process.on("SIGINT", handleSigint); - process.on("SIGTERM", handleSigterm); - - const result = await new Promise((resolve, reject) => { - websiteProcess.once("error", reject); - websiteProcess.once("exit", (code, signal) => resolve({ code, signal })); - }); - process.off("SIGINT", handleSigint); - process.off("SIGTERM", handleSigterm); - - if (result.signal) { - process.exitCode = result.signal === "SIGINT" ? 130 : 143; - } else { - process.exitCode = result.code ?? 1; - } -} catch (error) { - console.error(error instanceof Error ? error.message : error); - process.exitCode = 1; -} finally { - await stopContainer(); -} diff --git a/libs/@hashintel/petrinaut-core/benchmarks/coloured-enumeration.mjs b/libs/@hashintel/petrinaut-core/benchmarks/coloured-enumeration.mjs index e5f3c49942b..d4d425bf7bc 100644 --- a/libs/@hashintel/petrinaut-core/benchmarks/coloured-enumeration.mjs +++ b/libs/@hashintel/petrinaut-core/benchmarks/coloured-enumeration.mjs @@ -2,9 +2,15 @@ * Measures how per-frame cost scales with token count for a transition whose * coloured input arc has weight 2. * - * `enumerateWeightedMarkingIndicesGenerator` materialises the full per-place - * combination list up front, so the expectation is O(C(n, 2)) = O(n^2) work and - * allocation per transition evaluation per frame. + * Three cases bound the enumeration cost. With a lambda that reads token + * attributes: a transition that never fires examines every combination each + * frame (O(C(n, 2)) lambda evaluations — irreducible), and one that always + * fires examines one. Lazy enumeration makes both allocation-free; the eager + * implementation it replaced also materialised the full C(n, 2) combination + * list per evaluation, which made even the always-fires case quadratic. + * With a token-independent lambda, the artifact carries + * `readsNoInputTokens` and the engine tests only the first combination, so + * even the never-fires case is O(1) per frame. */ import { performance } from "node:perf_hooks"; @@ -52,8 +58,10 @@ const sdcpn = { outputArcs: [{ placeId: "sink", weight: 1 }], lambdaType: "predicate", // Never fires, so token counts stay constant and we measure pure - // enablement/enumeration cost at a fixed marking size. - lambdaCode: "export default Lambda(() => false);", + // enablement/enumeration cost at a fixed marking size. Reads a token + // attribute so the lambda is NOT token-independent — every combination + // must be examined. + lambdaCode: "export default Lambda((input) => input.Pool[0].v < 0);", transitionKernelCode: "export default TransitionKernel(() => ({ Sink: [{ v: 1 }] }));", x: 50, @@ -64,16 +72,43 @@ const sdcpn = { parameters: [], }; -const artifacts = compileHirArtifacts(sdcpn).artifacts; +/** + * The always-fires variant: a self loop that consumes two pool tokens and + * produces two, so the marking size stays constant while the transition fires + * on the first combination every frame. + */ +const selfLoopSdcpn = { + ...sdcpn, + transitions: [ + { + ...sdcpn.transitions[0], + inputArcs: [{ placeId: "pool", weight: 2, type: "standard" }], + outputArcs: [{ placeId: "pool", weight: 2 }], + lambdaCode: "export default Lambda((input) => input.Pool[0].v >= 0);", + transitionKernelCode: + "export default TransitionKernel(() => ({ Pool: [{ v: 1 }, { v: 2 }] }));", + }, + ], +}; -process.stdout.write( - "coloured place, input arc weight 2, transition never fires\n" + - "tokens C(n,2) ns/run-frame\n", -); +/** + * A token-independent never-firing lambda: the compiler flags it, and the + * engine tests only the first combination — O(1) per frame regardless of + * token count. + */ +const tokenIndependentSdcpn = { + ...sdcpn, + transitions: [ + { + ...sdcpn.transitions[0], + lambdaCode: "export default Lambda(() => false);", + }, + ], +}; -for (const tokens of [10, 25, 50, 100, 200, 400]) { +function measure(net, artifacts, tokens) { const simulator = createMonteCarloSimulator({ - sdcpn, + sdcpn: net, initialMarking: { pool: Array.from({ length: tokens }, (_, index) => ({ v: index })), sink: [], @@ -95,10 +130,32 @@ for (const tokens of [10, 25, 50, 100, 200, 400]) { for (const summary of simulator.getSummaries()) { frames += summary.frameNumber; } + return (ms / frames) * 1e6; +} - const combinations = (tokens * (tokens - 1)) / 2; +const cases = [ + ["token-reading lambda, never fires (examines every combination)", sdcpn], + [ + "token-reading lambda, always fires (examines one combination)", + selfLoopSdcpn, + ], + [ + "token-independent lambda, never fires (first combination only)", + tokenIndependentSdcpn, + ], +]; + +for (const [label, net] of cases) { + const artifacts = compileHirArtifacts(net).artifacts; process.stdout.write( - `${String(tokens).padStart(6)} ${String(combinations).padStart(7)} ` + - `${((ms / frames) * 1e6).toFixed(0).padStart(12)}\n`, + `weight-2 coloured arc, ${label}\n` + "tokens C(n,2) ns/run-frame\n", ); + for (const tokens of [10, 25, 50, 100, 200, 400]) { + const combinations = (tokens * (tokens - 1)) / 2; + process.stdout.write( + `${String(tokens).padStart(6)} ${String(combinations).padStart(7)} ` + + `${measure(net, artifacts, tokens).toFixed(0).padStart(12)}\n`, + ); + } + process.stdout.write("\n"); } diff --git a/libs/@hashintel/petrinaut-core/src/hir/artifacts.test.ts b/libs/@hashintel/petrinaut-core/src/hir/artifacts.test.ts index 05f303a0079..c8643501c7d 100644 --- a/libs/@hashintel/petrinaut-core/src/hir/artifacts.test.ts +++ b/libs/@hashintel/petrinaut-core/src/hir/artifacts.test.ts @@ -235,3 +235,55 @@ describe("compileHirArtifacts", () => { } }); }); + +describe("readsNoInputTokens", () => { + function lambdaArtifactFor(lambdaCode: string) { + const { artifacts, failures } = compileHirArtifacts({ + ...sdcpn, + transitions: [{ ...sdcpn.transitions[0]!, lambdaCode }], + }); + expect(failures).toEqual([]); + return artifacts.lambdas.ship!; + } + + it("is absent when the lambda reads token attributes", () => { + expect( + lambdaArtifactFor(sdcpn.transitions[0]!.lambdaCode).readsNoInputTokens, + ).toBeUndefined(); + }); + + it("is set for a constant lambda", () => { + expect( + lambdaArtifactFor("export default Lambda(() => true);") + .readsNoInputTokens, + ).toBe(true); + }); + + it("is set for a parameters-only lambda", () => { + expect( + lambdaArtifactFor( + "export default Lambda((input, parameters) => parameters.threshold > 1);", + ).readsNoInputTokens, + ).toBe(true); + }); + + it("is absent for a token-free lambda that draws randomness", () => { + // Skipping enumeration would evaluate the draw once per frame instead of + // once per combination, changing how often the transition fires. + expect( + lambdaArtifactFor("export default Lambda(() => Math.random() > 0.5);") + .readsNoInputTokens, + ).toBeUndefined(); + }); + + it("is absent for a lambda that reads only a token count", () => { + const inputPlace = sdcpn.places.find( + (place) => place.id === sdcpn.transitions[0]!.inputArcs[0]!.placeId, + )!; + expect( + lambdaArtifactFor( + `export default Lambda((input) => input.${inputPlace.name}.length > 1);`, + ).readsNoInputTokens, + ).toBeUndefined(); + }); +}); diff --git a/libs/@hashintel/petrinaut-core/src/hir/compile.ts b/libs/@hashintel/petrinaut-core/src/hir/compile.ts index ea84a1ac167..e4577d05af7 100644 --- a/libs/@hashintel/petrinaut-core/src/hir/compile.ts +++ b/libs/@hashintel/petrinaut-core/src/hir/compile.ts @@ -22,6 +22,7 @@ import { type PetrinautExtensionSettings, } from "../extensions"; import { createUserKeyedRecord } from "../validation/record-keys"; +import { analyzeHir } from "./analyze"; import { fingerprintHirCompilationInput } from "./artifact-fingerprint"; import { emitBufferDynamicsJs, @@ -219,10 +220,16 @@ export function compileHirArtifacts( diagnostics: [notCompilableDiagnostic(item.fn)], }); } else { + const { dependencies } = analyzeHir(item.fn); + const readsNoInputTokens = + dependencies.tokenReads.length === 0 && + !dependencies.readsTokenCounts && + dependencies.isDeterministic; artifacts.lambdas[transition.id] = { ...(options.includeHir ? { hir: item.fn } : {}), source: program.source, inputSlotCount: program.inputSlotCount, + ...(readsNoInputTokens ? { readsNoInputTokens: true } : {}), }; } } diff --git a/libs/@hashintel/petrinaut-core/src/hir/instantiate.ts b/libs/@hashintel/petrinaut-core/src/hir/instantiate.ts index e4aa3d1b3ba..49cba5e473f 100644 --- a/libs/@hashintel/petrinaut-core/src/hir/instantiate.ts +++ b/libs/@hashintel/petrinaut-core/src/hir/instantiate.ts @@ -89,6 +89,15 @@ export type HirLambdaArtifact = { source: string; /** Expected `indices.length` — engine-side sanity check. */ inputSlotCount: number; + /** + * Set when the HIR analysis proves the lambda's result cannot depend on + * which tokens a combination selects: it reads no token attributes or + * counts and is a pure function of its inputs. The engines then evaluate + * the first combination instead of enumerating all of them. Absent means + * "assume it reads tokens" — artifacts compiled before this field existed + * keep enumerating. + */ + readsNoInputTokens?: boolean; /** * The lowered HIR the program was emitted from. * 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 fbb56409597..da617f53cf9 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/build-simulation.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/build-simulation.ts @@ -338,7 +338,7 @@ function createLambdaFn({ artifact: HirLambdaArtifact | undefined; expectedSlotCount: number; stringPool: StringPool; -}): HirCompiledBufferLambda { +}): { lambdaFn: HirCompiledBufferLambda; readsNoInputTokens: boolean } { const availability = getTransitionLogicAvailability( transition, sdcpn, @@ -348,7 +348,10 @@ function createLambdaFn({ if (!availability.lambda || transition.lambdaCode.trim() === "") { // Buffer-ABI-shaped constants — the arguments are ignored. - return lambdaType === "stochastic" ? () => Infinity : () => true; + return { + lambdaFn: lambdaType === "stochastic" ? () => Infinity : () => true, + readsNoInputTokens: true, + }; } if (!artifact) { @@ -363,11 +366,14 @@ function createLambdaFn({ } try { - return instantiateHirBufferLambda( - artifact.source, - parameterValues, - stringPool, - ); + return { + lambdaFn: instantiateHirBufferLambda( + artifact.source, + parameterValues, + stringPool, + ), + readsNoInputTokens: artifact.readsNoInputTokens === true, + }; } catch (error) { throw new SDCPNItemError( `Failed to instantiate the compiled Lambda for transition \`${ @@ -479,7 +485,7 @@ function createCompiledTransition({ typesMap, ); const stagingSize = computeKernelStagingSize(transition, placesMap, typesMap); - const lambdaFn = createLambdaFn({ + const { lambdaFn, readsNoInputTokens } = createLambdaFn({ transition, sdcpn, extensions, @@ -573,6 +579,7 @@ function createCompiledTransition({ }; }), lambdaFn, + lambdaReadsNoInputTokens: readsNoInputTokens, kernelFn, placeBases: new Int32Array(coloredInputArcCount), indices: new Int32Array(expectedSlotCount), diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/compiled-transition.test-helpers.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/compiled-transition.test-helpers.ts index dfd3d8ac19c..1a4efef8431 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/compiled-transition.test-helpers.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/compiled-transition.test-helpers.ts @@ -20,6 +20,7 @@ export function makeCompiledTransition({ places, types, lambdaFn, + lambdaReadsNoInputTokens = false, kernelFn = null, }: { transition: Transition; @@ -27,6 +28,8 @@ export function makeCompiledTransition({ types: Color[]; /** Buffer-ABI mock: `(f64, u64, u8, placeBases, indices) => value`. */ lambdaFn: HirCompiledBufferLambda; + /** Marks the mock lambda as token-independent (first-combination path). */ + lambdaReadsNoInputTokens?: boolean; /** Buffer-ABI mock writing into the staging views, or null when the * transition has no colored output places. */ kernelFn?: HirCompiledBufferKernel | null; @@ -93,6 +96,7 @@ export function makeCompiledTransition({ // Capacity is exercised through `buildSimulation`; these hand-built // transitions are unconstrained. capacityConstraints: [], + lambdaReadsNoInputTokens, inputPlaces, outputPlaces, lambdaFn, diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/compute-possible-transition.test.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/compute-possible-transition.test.ts index 26b1d3d0246..ac6cb0f6237 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/compute-possible-transition.test.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/compute-possible-transition.test.ts @@ -112,12 +112,15 @@ function makeSimulation({ types = [], lambdaFns, kernelFns, + tokenIndependentLambdas, }: { places?: Place[]; transitions: Transition[]; types?: Color[]; lambdaFns: ReadonlyMap; kernelFns?: ReadonlyMap; + /** Transitions whose mock lambda is marked token-independent. */ + tokenIndependentLambdas?: ReadonlySet; }): SimulationInstance { const frameLayout = createEngineFrameLayout({ places, @@ -145,6 +148,8 @@ function makeSimulation({ places, types, lambdaFn, + lambdaReadsNoInputTokens: + tokenIndependentLambdas?.has(transition.id) ?? false, kernelFn: kernelFns?.get(transition.id) ?? null, }), ]; @@ -972,3 +977,99 @@ describe("computePossibleTransition", () => { }); }); }); + +describe("token-independent lambdas", () => { + const pool: Place = makePlace("p1", "Pool", "type1"); + const fourTokens = { + p1: { + elements: type1.elements, + tokens: [{ x: 10 }, { x: 20 }, { x: 30 }, { x: 40 }], + }, + }; + const pairTransition = makeTransition({ + id: "t1", + inputArcs: [{ placeId: "p1", weight: 2, type: "standard" }], + outputArcs: [], + }); + + function makePairSimulation( + lambdaFn: HirCompiledBufferLambda, + tokenIndependent: boolean, + ): SimulationInstance { + return makeSimulation({ + places: [pool], + types: [type1], + transitions: [pairTransition], + lambdaFns: new Map([["t1", lambdaFn]]), + tokenIndependentLambdas: tokenIndependent + ? new Set(["t1"]) + : new Set(), + }); + } + + it("evaluates the lambda once and consumes the first tokens", () => { + let calls = 0; + const simulation = makePairSimulation(() => { + calls += 1; + return CERTAIN_FIRING_RATE; + }, true); + const frame = makeTestFrame({ + places: fourTokens, + transitions: { t1: transitionState() }, + }); + + const result = computePossibleTransition(frame, simulation, "t1", 42); + + expect(calls).toBe(1); + expect(result?.remove).toEqual({ p1: new Set([0, 1]) }); + }); + + it("skips the other five combinations a flagless lambda would examine", () => { + // Rate 0 never fires: exp(0) = 1 > U1 for every draw, so the flagless + // path examines all C(4,2) = 6 combinations while the flagged path + // examines one — with identical outcomes. + const runWith = (tokenIndependent: boolean) => { + let calls = 0; + const simulation = makePairSimulation(() => { + calls += 1; + return 0; + }, tokenIndependent); + const frame = makeTestFrame({ + places: fourTokens, + transitions: { t1: transitionState() }, + }); + const { firing, newRngState } = computePossibleTransitionImpl( + frame, + { ...simulation, frameLayout: frame.layout }, + "t1", + 42, + ); + return { calls, firing, newRngState }; + }; + + const flagged = runWith(true); + const flagless = runWith(false); + + expect(flagged.calls).toBe(1); + expect(flagless.calls).toBe(6); + expect(flagged.firing).toBeNull(); + expect(flagless.firing).toBeNull(); + expect(flagged.newRngState).toBe(flagless.newRngState); + }); + + it("fires identically to the enumerating path when the rate is certain", () => { + const runWith = (tokenIndependent: boolean) => { + const simulation = makePairSimulation( + () => CERTAIN_FIRING_RATE, + tokenIndependent, + ); + const frame = makeTestFrame({ + places: fourTokens, + transitions: { t1: transitionState() }, + }); + return computePossibleTransition(frame, simulation, "t1", 42); + }; + + expect(runWith(true)).toEqual(runWith(false)); + }); +}); 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 2fbee9ba9b0..b41c2280987 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 @@ -9,7 +9,10 @@ import { fillTokenIndices, } from "./buffer-transition"; import { hasCapacityHeadroom } from "./capacity"; -import { enumerateWeightedMarkingIndicesGenerator } from "./enumerate-weighted-markings"; +import { + enumerateWeightedMarkingIndicesGenerator, + firstWeightedMarkingIndices, +} from "./enumerate-weighted-markings"; import { nextRandom } from "./seeded-rng"; import { createTokenRegionViews } from "./token-layout"; @@ -127,9 +130,12 @@ export function computePossibleTransition( (place) => place.strideBytes === 0 && place.arcType === "standard", ); - const tokensCombinations = enumerateWeightedMarkingIndicesGenerator( - inputPlacesWithTokenValues, - ); + // A token-independent lambda returns the same value for every combination, + // so either the first combination fires or none does: test only the first + // (structural enablement above guarantees it exists). + const tokensCombinations = transition.lambdaReadsNoInputTokens + ? [firstWeightedMarkingIndices(inputPlacesWithTokenValues)] + : enumerateWeightedMarkingIndicesGenerator(inputPlacesWithTokenValues); // The compiled buffer-ABI lambda reads token attributes at packed-struct // byte offsets straight from the shared views — no per-combination record diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/enumerate-weighted-markings.test.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/enumerate-weighted-markings.test.ts index 3ba8c37cb3d..92abf7dd0ad 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/enumerate-weighted-markings.test.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/enumerate-weighted-markings.test.ts @@ -201,33 +201,41 @@ describe("enumerateWeightedMarkingIndices", () => { }); describe("enumerateWeightedMarkingIndicesGenerator", () => { + /** + * The generator reuses its yielded arrays between iterations, so collecting + * a sequence must clone each marking as it arrives. + */ + function collectMarkings( + places: { count: number; weight: number }[], + ): number[][][] { + return Array.from( + enumerateWeightedMarkingIndicesGenerator(places), + (marking) => marking.map((combo) => [...combo]), + ); + } it("yields [[]] when no places are provided", () => { - const iterator = enumerateWeightedMarkingIndicesGenerator([]); - expect(Array.from(iterator)).toEqual([[]]); + expect(collectMarkings([])).toEqual([[]]); }); it("yields nothing when a weight exceeds the token count", () => { - const iterator = enumerateWeightedMarkingIndicesGenerator([ - { count: 2, weight: 3 }, - ]); - expect(Array.from(iterator)).toEqual([]); + expect(collectMarkings([{ count: 2, weight: 3 }])).toEqual([]); }); it("handles single place with weight 0", () => { const places = [{ count: 3, weight: 0 }]; - const result = Array.from(enumerateWeightedMarkingIndicesGenerator(places)); + const result = collectMarkings(places); expect(result).toEqual([[[]]]); }); it("handles single place with single token", () => { const places = [{ count: 1, weight: 1 }]; - const result = Array.from(enumerateWeightedMarkingIndicesGenerator(places)); + const result = collectMarkings(places); expect(result).toEqual([[[0]]]); }); it("generates all 2-combinations from 3 tokens in single place", () => { const places = [{ count: 3, weight: 2 }]; - const result = Array.from(enumerateWeightedMarkingIndicesGenerator(places)); + const result = collectMarkings(places); expect(result).toEqual([[[0, 1]], [[0, 2]], [[1, 2]]]); }); @@ -238,7 +246,7 @@ describe("enumerateWeightedMarkingIndicesGenerator", () => { { count: 3, weight: 2 }, ]; - const result = Array.from(enumerateWeightedMarkingIndicesGenerator(places)); + const result = collectMarkings(places); // First place combinations: [0,1], [0,2], [1,2] // Second place combinations: [0,1], [0,2], [1,2] @@ -291,7 +299,7 @@ describe("enumerateWeightedMarkingIndicesGenerator", () => { { count: 3, weight: 1 }, // combinations: [0], [1], [2] ]; - const result = Array.from(enumerateWeightedMarkingIndicesGenerator(places)); + const result = collectMarkings(places); // Expected: 2 × 1 × 3 = 6 combinations expect(result).toEqual([ @@ -310,7 +318,7 @@ describe("enumerateWeightedMarkingIndicesGenerator", () => { { count: 3, weight: 3 }, ]; - const result = Array.from(enumerateWeightedMarkingIndicesGenerator(places)); + const result = collectMarkings(places); // Only one combination per place when selecting all tokens expect(result).toEqual([ @@ -327,7 +335,7 @@ describe("enumerateWeightedMarkingIndicesGenerator", () => { { count: 3, weight: 2 }, ]; - const result = Array.from(enumerateWeightedMarkingIndicesGenerator(places)); + const result = collectMarkings(places); // First place contributes empty array // Second place has 3 combinations @@ -344,7 +352,7 @@ describe("enumerateWeightedMarkingIndicesGenerator", () => { { count: 3, weight: 2 }, // C(3,2) = 3 ]; - const result = Array.from(enumerateWeightedMarkingIndicesGenerator(places)); + const result = collectMarkings(places); // Total combinations: 6 × 3 = 18 expect(result).toHaveLength(18); @@ -365,7 +373,7 @@ describe("enumerateWeightedMarkingIndicesGenerator", () => { { count: 2, weight: 1 }, ]; - const result = Array.from(enumerateWeightedMarkingIndicesGenerator(places)); + const result = collectMarkings(places); // Each result should have 2 elements (one per place) // Each place should have its own array @@ -383,4 +391,101 @@ describe("enumerateWeightedMarkingIndicesGenerator", () => { expect(Array.isArray(marking[1])).toBe(true); } }); + + it("reuses the yielded marking and its combination arrays between iterations", () => { + const places = [ + { count: 3, weight: 2 }, + { count: 2, weight: 1 }, + ]; + + const yielded = Array.from( + enumerateWeightedMarkingIndicesGenerator(places), + ); + + // Every yield hands back the same (mutated) structure: consumers that + // keep a marking past the next iteration must copy it. + expect(yielded.length).toBe(6); + for (const marking of yielded) { + expect(marking).toBe(yielded[0]); + expect(marking[0]).toBe(yielded[0]![0]); + } + }); + + it("matches an eager reference implementation on a case matrix", () => { + /** The pre-lazy algorithm, kept as the ordering oracle. */ + function referenceCombinations(n: number, k: number): number[][] { + if (k === 0) { + return [[]]; + } + if (k > n) { + return []; + } + const result: number[][] = []; + const backtrack = (start: number, combo: number[]) => { + if (combo.length === k) { + result.push([...combo]); + return; + } + for (let i = start; i <= n - (k - combo.length); i++) { + combo.push(i); + backtrack(i + 1, combo); + combo.pop(); + } + }; + backtrack(0, []); + return result; + } + + function referenceMarkings( + places: { count: number; weight: number }[], + ): number[][][] { + const perPlace = places.map((place) => + referenceCombinations(place.count, place.weight), + ); + if (perPlace.some((combos) => combos.length === 0)) { + return []; + } + let acc: number[][][] = [[]]; + for (const combos of perPlace) { + const next: number[][][] = []; + for (const partial of acc) { + for (const combo of combos) { + next.push([...partial, combo]); + } + } + acc = next; + } + return acc; + } + + const cases: { count: number; weight: number }[][] = [ + [{ count: 5, weight: 2 }], + [{ count: 6, weight: 3 }], + [{ count: 7, weight: 1 }], + [{ count: 4, weight: 4 }], + [{ count: 4, weight: 0 }], + [ + { count: 4, weight: 2 }, + { count: 3, weight: 1 }, + ], + [ + { count: 3, weight: 1 }, + { count: 2, weight: 2 }, + { count: 4, weight: 3 }, + ], + [ + { count: 2, weight: 0 }, + { count: 3, weight: 2 }, + { count: 2, weight: 1 }, + ], + [ + { count: 5, weight: 2 }, + { count: 5, weight: 2 }, + ], + ]; + + for (const places of cases) { + expect(collectMarkings(places)).toEqual(referenceMarkings(places)); + } + }); }); diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/enumerate-weighted-markings.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/enumerate-weighted-markings.ts index 9df9f6ae9c4..7b8b4d413bf 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/enumerate-weighted-markings.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/enumerate-weighted-markings.ts @@ -3,39 +3,119 @@ type PlaceSpec = { weight: number; // how many tokens to pick }; +/* eslint-disable no-param-reassign -- rewriting the caller's reusable + combination array in place is the point of these helpers: enumeration must + not allocate per combination */ /** - * Generate all k-combinations of indices [0..n-1]. - * Example: indexCombinations(3, 2) -> [ [0,1], [0,2], [1,2] ] + * Reset `combo` to the first k-combination of `[0..n-1]` in lexicographic + * order: `[0, 1, ..., k-1]`. Returns false when no combination exists + * (`k > n`). */ -function indexCombinations(n: number, k: number): number[][] { - if (k === 0) { - return [[]]; - } +function firstIndexCombination(combo: number[], n: number, k: number): boolean { if (k > n) { - return []; + return false; + } + combo.length = k; + for (let index = 0; index < k; index++) { + combo[index] = index; } + return true; +} - const result: number[][] = []; +/** + * Advance `combo` to its lexicographic successor over `[0..n-1]`, in place. + * Returns false when `combo` is the last combination. + */ +function nextIndexCombination(combo: number[], n: number): boolean { + const k = combo.length; + for (let index = k - 1; index >= 0; index--) { + if (combo[index]! < n - k + index) { + combo[index]!++; + for (let rest = index + 1; rest < k; rest++) { + combo[rest] = combo[rest - 1]! + 1; + } + return true; + } + } + return false; +} +/* eslint-enable no-param-reassign */ + +/** + * Enumerate every weighted marking lazily: one k-combination of token indices + * per place, in lexicographic order per place, with the last place advancing + * fastest. + * + * Nothing is materialised up front — a place holding `n` tokens under a + * weight-`w` arc has `C(n, w)` combinations, and building them eagerly made + * transition evaluation quadratic in the token count (see "Weighted-arc + * enumeration" in + * `libs/@local/petrinaut-arch-docs/content/simulation/performance.mdx`). + * Cost is proportional to the combinations the caller actually consumes. + * + * The enumeration order is a contract: the engine fires the first passing + * combination, so a different order changes which tokens a firing consumes + * and diverges seeded trajectories, and `webgpu/pair-selection.ts` reproduces + * this order on the GPU by combinatorial unranking. + * + * The yielded array and its inner arrays are reused between iterations. + * Copy anything kept past the next `next()` call; both engines copy on + * accept and stop iterating. + */ +export function* enumerateWeightedMarkingIndicesGenerator( + places: PlaceSpec[], +): Generator { + if (places.length === 0) { + yield []; + return; + } - function backtrack(start: number, combo: number[]) { - if (combo.length === k) { - result.push(combo.slice()); + const current: number[][] = places.map(() => []); + for (let place = 0; place < places.length; place++) { + const { count, weight } = places[place]!; + if (!firstIndexCombination(current[place]!, count, weight)) { return; } + } - for (let i = start; i <= n - (k - combo.length); i++) { - combo.push(i); - backtrack(i + 1, combo); - combo.pop(); + for (;;) { + yield current; + + let place = places.length - 1; + while ( + place >= 0 && + !nextIndexCombination(current[place]!, places[place]!.count) + ) { + firstIndexCombination( + current[place]!, + places[place]!.count, + places[place]!.weight, + ); + place--; + } + if (place < 0) { + return; } } +} - backtrack(0, []); - return result; +/** + * The first weighted marking without enumeration: the lexicographically first + * combination per place — indices `[0..weight-1]`, the first tokens in place + * order. Callers must have established structural enablement + * (`count >= weight` per place). For a lambda whose result cannot depend on + * which tokens are selected, testing this marking alone is equivalent to + * enumerating all of them: the acceptance draw is shared across combinations, + * so either the first combination fires or none does. + */ +export function firstWeightedMarkingIndices(places: PlaceSpec[]): number[][] { + return places.map((place) => + Array.from({ length: place.weight }, (_, index) => index), + ); } /** - * Enumerate all weighted combinations, returning indices only. + * Enumerate all weighted combinations eagerly, returning indices only. * * Each marking is a flat array of indices, concatenated per place. * @@ -53,61 +133,9 @@ function indexCombinations(n: number, k: number): number[][] { export function enumerateWeightedMarkingIndices( places: PlaceSpec[], ): number[][] { - // 1. combinations per place (of indices) - const perPlaceCombos = places.map((p) => - indexCombinations(p.count, p.weight), - ); - - // 2. check for invalid places - if (perPlaceCombos.some((set) => set.length === 0)) { - return []; - } - - // 3. Cartesian product - let acc: number[][] = [[]]; - for (const comboSet of perPlaceCombos) { - const nextAcc: number[][] = []; - for (const partial of acc) { - for (const combo of comboSet) { - nextAcc.push([...partial, ...combo]); - } - } - acc = nextAcc; - } - - return acc; -} - -export function* enumerateWeightedMarkingIndicesGenerator( - places: PlaceSpec[], -): Generator { - const perPlaceCombos = places.map((p) => - indexCombinations(p.count, p.weight), - ); - - if (perPlaceCombos.some((set) => set.length === 0)) { - return; - } - - if (perPlaceCombos.length === 0) { - yield []; - return; - } - - const current: number[][] = []; - - function* backtrack(index: number): Generator { - if (index === perPlaceCombos.length) { - yield current.map((combo) => combo.slice()); - return; - } - - for (const combo of perPlaceCombos[index]!) { - current.push(combo); - yield* backtrack(index + 1); - current.pop(); - } + const result: number[][] = []; + for (const marking of enumerateWeightedMarkingIndicesGenerator(places)) { + result.push(marking.flat()); } - - yield* backtrack(0); + return result; } diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/types.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/types.ts index 7efb27a70de..ae3b8f5627e 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/types.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/types.ts @@ -81,6 +81,14 @@ export type CompiledTransition = { /** Buffer-ABI lambda `(f64, u64, u8, placeBases, indices) => number | * boolean` (token format v2 packed structs); parameters/pool pre-bound. */ lambdaFn: HirCompiledBufferLambda; + /** + * The lambda's result cannot depend on which tokens a combination selects — + * proved by the HIR analysis, or the lambda is an engine-supplied constant. + * Both engines then evaluate the first combination only, which is + * equivalent to enumerating all of them (one shared acceptance draw, first + * passing combination fires). + */ + lambdaReadsNoInputTokens: boolean; /** Buffer-ABI kernel writing into `kernelStaging`, or null when the * transition has no colored output places. */ kernelFn: HirCompiledBufferKernel | null; diff --git a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/token-independent-lambda.test.ts b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/token-independent-lambda.test.ts new file mode 100644 index 00000000000..c574d822d7b --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/token-independent-lambda.test.ts @@ -0,0 +1,182 @@ +/** + * End-to-end check that the token-independent fast path changes nothing an + * experiment can observe: the same net, seed and artifacts produce identical + * runs whether the compiled artifact carries `readsNoInputTokens` (skip + * enumeration, take the first combination) or has it stripped (enumerate, + * as artifacts compiled before the flag existed do). + */ +import { describe, expect, it } from "vitest"; + +import { compileHirArtifacts } from "../../hir"; +import { createMonteCarloSimulator } from "./monte-carlo-simulator"; +import { createRunState } from "./run-state"; +import { computeTransitionEffect } from "./transition-effect"; + +import type { HirArtifacts } from "../../hir"; +import type { HirCompiledBufferLambda } from "../../hir/instantiate"; +import type { SDCPN } from "../../types/sdcpn"; + +const sdcpn: SDCPN = { + types: [ + { + id: "item", + name: "Item", + iconSlug: "circle", + displayColor: "#00FF00", + elements: [{ elementId: "v", name: "v", type: "real" }], + }, + ], + places: [ + { + id: "pool", + name: "Pool", + colorId: "item", + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + { + id: "sink", + name: "Sink", + colorId: "item", + dynamicsEnabled: false, + differentialEquationId: null, + x: 100, + y: 0, + }, + ], + transitions: [ + { + id: "pair", + name: "Pair", + inputArcs: [{ placeId: "pool", weight: 2, type: "standard" }], + outputArcs: [{ placeId: "sink", weight: 1 }], + lambdaType: "stochastic", + // Reads a parameter but no tokens, so the compiler marks it + // token-independent. The mid-range rate makes runs mix firing and + // non-firing frames. + lambdaCode: + "export default Lambda((input, parameters) => parameters.rate);", + // The output token encodes which tokens were consumed, so a different + // combination choice would change the observable state. + transitionKernelCode: `export default TransitionKernel((input) => ({ + Sink: [{ v: input.Pool[0].v * 1000 + input.Pool[1].v }], +}));`, + x: 50, + y: 0, + }, + ], + differentialEquations: [], + parameters: [ + { + id: "rate", + name: "Rate", + variableName: "rate", + type: "real", + defaultValue: "2", + }, + ], +}; + +function runToCompletion(artifacts: HirArtifacts) { + const simulator = createMonteCarloSimulator({ + sdcpn, + initialMarking: { + pool: [{ v: 10 }, { v: 20 }, { v: 30 }, { v: 40 }, { v: 50 }, { v: 60 }], + sink: [], + }, + parameterValues: { rate: "2" }, + seed: 7, + dt: 0.1, + maxTime: 5, + runCount: 8, + hirArtifacts: artifacts, + }); + simulator.runUntilComplete(); + return { + summaries: simulator.getSummaries(), + snapshots: Array.from({ length: 8 }, (_, index) => + simulator.getRunSnapshot(index), + ), + }; +} + +describe("token-independent lambda fast path", () => { + it("is marked on the compiled artifact", () => { + const { artifacts, failures } = compileHirArtifacts(sdcpn); + expect(failures).toEqual([]); + expect(artifacts.lambdas.pair!.readsNoInputTokens).toBe(true); + }); + + it("produces runs identical to the enumerating path", () => { + const { artifacts } = compileHirArtifacts(sdcpn); + const stripped = JSON.parse(JSON.stringify(artifacts)) as HirArtifacts; + delete stripped.lambdas.pair!.readsNoInputTokens; + + const fast = runToCompletion(artifacts); + const enumerating = runToCompletion(stripped); + + expect(fast.summaries).toEqual(enumerating.summaries); + expect(fast.snapshots).toEqual(enumerating.snapshots); + // The runs actually fired: an all-idle run would make the comparison + // vacuous. + expect( + fast.snapshots.some( + (snapshot) => (snapshot.placeTokenCounts.sink ?? 0) > 0, + ), + ).toBe(true); + }); + + it("evaluates the lambda once instead of once per combination", () => { + // Rate 0 never fires, so the enumerating path examines every C(6, 2) = 15 + // pair while the flagged path examines one, with the same outcome and the + // same RNG state afterwards. + const { artifacts } = compileHirArtifacts(sdcpn); + const runWith = (tokenIndependent: boolean) => { + const run = createRunState( + { + sdcpn, + initialMarking: { + pool: [{ v: 1 }, { v: 2 }, { v: 3 }, { v: 4 }, { v: 5 }, { v: 6 }], + sink: [], + }, + parameterValues: { rate: "0" }, + seed: 7, + dt: 0.1, + maxTime: 5, + runCount: 1, + hirArtifacts: artifacts, + }, + undefined, + 0, + ); + const transition = run.simulation.compiledTransitions.get("pair")!; + let calls = 0; + const counting: HirCompiledBufferLambda = (...args) => { + calls += 1; + return transition.lambdaFn(...args); + }; + const { firing, newRngState } = computeTransitionEffect( + run, + run.currentFrame, + { + ...transition, + lambdaFn: counting, + lambdaReadsNoInputTokens: tokenIndependent, + }, + null, + ); + return { calls, firing, newRngState }; + }; + + const flagged = runWith(true); + const flagless = runWith(false); + + expect(flagged.calls).toBe(1); + expect(flagless.calls).toBe(15); + expect(flagged.firing).toBeNull(); + expect(flagless.firing).toBeNull(); + expect(flagged.newRngState).toBe(flagless.newRngState); + }); +}); 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 5a6dd99d447..a0def224734 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 @@ -5,7 +5,10 @@ import { fillTokenIndices, } from "../engine/buffer-transition"; import { hasCapacityHeadroom } from "../engine/capacity"; -import { enumerateWeightedMarkingIndicesGenerator } from "../engine/enumerate-weighted-markings"; +import { + enumerateWeightedMarkingIndicesGenerator, + firstWeightedMarkingIndices, +} from "../engine/enumerate-weighted-markings"; import { nextRandom } from "../engine/seeded-rng"; import { getPlaceIndex } from "./layout"; @@ -82,9 +85,12 @@ export function computeTransitionEffect( (place) => place.strideBytes === 0 && place.arcType === "standard", ); - const tokenCombinations = enumerateWeightedMarkingIndicesGenerator( - inputPlacesWithValues, - ); + // A token-independent lambda returns the same value for every combination, + // so either the first combination fires or none does: test only the first + // (structural enablement above guarantees it exists). + const tokenCombinations = transition.lambdaReadsNoInputTokens + ? [firstWeightedMarkingIndices(inputPlacesWithValues)] + : enumerateWeightedMarkingIndicesGenerator(inputPlacesWithValues); // The compiled buffer-ABI lambda/kernel read token attributes at // packed-struct byte offsets straight from the frame's shared views (see diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/pair-selection.test.ts b/libs/@hashintel/petrinaut-core/src/webgpu/pair-selection.test.ts index f9fbb58256b..b9be5fda9a7 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/pair-selection.test.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/pair-selection.test.ts @@ -11,14 +11,17 @@ import { /** The engine's own pair order for one place, as the CPU would walk it. */ function cpuPairs(tokenCount: number): [number, number][] { - return [ - ...enumerateWeightedMarkingIndicesGenerator([ + // The generator reuses its yielded arrays between iterations, so copy each + // pair as it arrives. + return Array.from( + enumerateWeightedMarkingIndicesGenerator([ { count: tokenCount, weight: 2 }, ]), - ].map((combination) => { - const [pair] = combination; - return [pair![0]!, pair![1]!] as [number, number]; - }); + (combination) => { + const [pair] = combination; + return [pair![0]!, pair![1]!] as [number, number]; + }, + ); } /** diff --git a/libs/@local/petrinaut-arch-docs/content/optimizer/running-the-loop-locally.mdx b/libs/@local/petrinaut-arch-docs/content/optimizer/running-the-loop-locally.mdx new file mode 100644 index 00000000000..965e73133b6 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/content/optimizer/running-the-loop-locally.mdx @@ -0,0 +1,112 @@ +--- +title: Running the loop locally +description: One command that stands up the whole optimization loop — browser, Python service, and CLI subprocess — without HASH. +sidebar_order: 20 +attachTo: optimizer +--- + +The full optimization loop runs locally without any HASH infrastructure. From +the repository root: + +```sh +turbo run dev --filter @apps/petrinaut-website -- --with-optimizer-service +``` + +Then open [http://localhost:5173/optimization](http://localhost:5173/optimization) +(or whatever port Vite prints). Simulate mode gains the **Optimizations** view, +and studies created there run against the real +[optimizer](layer:optimizer) service. + +Turborepo builds the website's workspace dependencies first, `@hashintel/petrinaut` +included, because `dev` depends on `codegen`, which depends on `^build`. The +website's dev task (`apps/petrinaut-website/scripts/dev.sh`) then hands the +flag to the shell library `libs/@local/petrinaut-optimizer-client/scripts/optimizer-service.sh`, +which does five things in order: + +1. Removes any container left behind by an earlier launcher run. A + hard-killed launcher (closed terminal, crash) never reaches its cleanup, + and the leftover otherwise holds port 4004 — every later launch would fail + with "port is already allocated" — while serving whatever code it was + built from. +2. Reuses an optimizer it does not own that already serves + `127.0.0.1:4004` healthily — the compose stack's container, or a bare + `uvicorn` — and then skips Docker entirely. Note a long-lived one can be + too old for the current protocol. +3. Otherwise builds the `petrinaut-opt:local` Docker image from + `apps/petrinaut-opt/docker/Dockerfile` and runs it read-only on + `127.0.0.1:4004`, polling its `/status` endpoint until healthy (30 + seconds, then it gives up). The image bundles the [CLI](layer:cli) at + `/usr/local/bin/petrinaut`, so the service finds its child executable + without any checkout-specific setup. +4. Starts the website's Vite dev server with + `VITE_PETRINAUT_OPT_PROVIDER=service` and + `PETRINAUT_OPT_ORIGIN=http://127.0.0.1:4004`. The demo website consumes + Petrinaut's **built dist**, not the sources. +5. Stops and removes its container when you stop the command. A reused + optimizer is left alone. + +Docker must be running unless a reusable optimizer is already serving. +Every other argument after `--` is forwarded to Vite, so +`turbo run dev --filter @apps/petrinaut-website -- --with-optimizer-service --port 5175 --strictPort` +pins the website port for tooling that needs to know it. + +## Why plain `yarn dev` shows no Optimizations view + +The editor renders the Optimizations tab only when a +`PetrinautOptimizationContext` is mounted, and the website mounts one only on +the `/optimization` route with `VITE_PETRINAUT_OPT_PROVIDER=service` set. A +plain `turbo run dev` in `@apps/petrinaut-website` leaves the context null: +the tab is hidden and nothing optimization-related is reachable. Storybook +provides a fake optimizer for isolated UI work on the drawers. + +## The request path + +The browser never talks to the Python service directly. Vite's dev server +proxies `/api/petrinaut-opt/*` to the optimizer origin (rewriting the prefix +away), which avoids development-only CORS changes to the service. Behind +that, the service spawns one `petrinaut serve` subprocess per optimization +run and speaks JSON lines to it — the +[subprocess boundary](doc:optimizer/subprocess-boundary) covers that +contract, and the [CLI usage manual](doc:cli/usage-manual) the protocol. + +``` +browser ── /api/petrinaut-opt/* ──▶ Vite proxy ──▶ petrinaut-opt (127.0.0.1:4004) + │ one per run + ▼ + petrinaut serve (JSON lines) +``` + +Override the proxy target with `PETRINAUT_OPT_ORIGIN` when the service runs +somewhere other than `127.0.0.1:4004`. + +## Without Docker + +When iterating on the Python service itself, a container rebuild per change +is the wrong loop. Run the service directly: + +```sh +cd apps/petrinaut-opt +uv sync +uv run uvicorn src.optimization_api:app --reload --port 4004 +``` + +Two things the Docker image otherwise provides become your problem: + +- **The CLI on `PATH`.** The service launches its child as `petrinaut` on a + fixed `PATH` (`/usr/local/bin:/usr/bin:/bin`). In a checkout, build it + (`turbo run build --filter @hashintel/petrinaut-cli`) and link its `bin` + onto that path — the [Python bindings manual](doc:python-bindings/usage-manual) + covers the options. +- **The port.** Bare `uvicorn --reload` defaults to 8000; pass `--port 4004` + or point the website at it with `PETRINAUT_OPT_ORIGIN`. + +Then start the website side alone, with the provider enabled: + +```sh +cd apps/petrinaut-website +VITE_PETRINAUT_OPT_PROVIDER=service yarn dev +``` + +Use the one-command Docker flow when you are working on Petrinaut and just +need a real optimizer behind it; use the uvicorn flow when the service is +what you are changing. diff --git a/libs/@local/petrinaut-arch-docs/content/simulation/performance.mdx b/libs/@local/petrinaut-arch-docs/content/simulation/performance.mdx index ba4155387e3..371504897de 100644 --- a/libs/@local/petrinaut-arch-docs/content/simulation/performance.mdx +++ b/libs/@local/petrinaut-arch-docs/content/simulation/performance.mdx @@ -15,16 +15,16 @@ are the point. ## State of play -| Area | Status | -| ----------------------------------------------------- | ------------------------------------------------------------------------------- | -| [Worker sharding](#worker-sharding) | Shipped. ~4× on 8 shards, byte-identical results at every shard count. | -| [Per-place token capacity](#per-place-token-capacity) | Shipped. Its fixed-size-frame follow-ons are not built. | -| [WebGPU backend](#the-webgpu-backend) | Shipped. ~1780× on the SIR net, for a restricted subset of nets. | -| [Weighted-arc enumeration](#weighted-arc-enumeration) | **Needed refactor** — quadratic cost on weighted coloured arcs (FE-1526). | -| [Hot-path fixes](#hot-path-fixes) | Open. Estimated 5–15× combined, behaviour-preserving. | -| [Whole-loop codegen](#whole-loop-codegen) | Proposed. Where the largest single-thread win is. | -| [WASM and native](#wasm-and-native) | Proposed. A second codegen backend once whole-loop codegen exists. | -| [Event-driven stepping](#event-driven-stepping) | Proposed. A semantics change; potentially a bigger lever than everything above. | +| Area | Status | +| ----------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| [Worker sharding](#worker-sharding) | Shipped. ~4× on 8 shards, byte-identical results at every shard count. | +| [Per-place token capacity](#per-place-token-capacity) | Shipped. Its fixed-size-frame follow-ons are not built. | +| [WebGPU backend](#the-webgpu-backend) | Shipped. ~1780× on the SIR net, for a restricted subset of nets. | +| [Weighted-arc enumeration](#weighted-arc-enumeration) | Fixed (FE-1526, FE-1528). Lazy, allocation-free, and skipped outright for token-independent lambdas. | +| [Hot-path fixes](#hot-path-fixes) | Open. Estimated 5–15× combined, behaviour-preserving. | +| [Whole-loop codegen](#whole-loop-codegen) | Proposed. Where the largest single-thread win is. | +| [WASM and native](#wasm-and-native) | Proposed. A second codegen backend once whole-loop codegen exists. | +| [Event-driven stepping](#event-driven-stepping) | Proposed. A semantics change; potentially a bigger lever than everything above. | ## The two stepping paths @@ -127,41 +127,56 @@ The largest cost in the engine does not show on the profile above, because the SIR net has no weighted coloured arcs. On nets that do, it dwarfs everything else. -:::danger[Needed refactor] -`enumerateWeightedMarkingIndicesGenerator` is a generator, but it is only lazy -over the Cartesian product _across_ arcs. Per arc it eagerly materialises the -full combination list: `indexCombinations(n, k)` backtracks and pushes every -k-combination into an array before a single one is yielded. A transition with -a coloured input arc of weight `w` over a place holding `n` tokens therefore -allocates `C(n, w)` arrays on every evaluation, every frame — and -`computeTransitionEffect` returns on the first accepted combination, so nearly -all of it is discarded. - -Measured, one coloured place, one weight-2 input arc, transition never fires: - -| Tokens in place | `C(n,2)` | ns / run-frame | -| --------------- | -------- | -------------- | -| 10 | 45 | 17 892 | -| 25 | 300 | 58 276 | -| 50 | 1 225 | 214 988 | -| 100 | 4 950 | 870 518 | -| 200 | 19 900 | 3 318 793 | -| 400 | 79 800 | **13 406 833** | - -Clean quadratic, ~170 ns per enumerated combination. At 400 tokens that is -13.4 ms for one run-frame; a 1000-run × 1800-frame experiment would take ~6.7 -hours. Weight 3 makes it cubic. The general bound for one transition is -`∏ᵢ C(nᵢ, wᵢ)` over its coloured non-inhibitor input arcs. - -The fix is contained and changes no architecture: iterate combinations without -materialising them — an in-place lexicographic successor per arc, and an -odometer over arcs for the cross-arc product. Cost becomes proportional to -combinations actually _examined_ (often 1 for a firing transition), with no -allocation per combination. Enumeration order must not change: the engine -fires the first passing combination, and the GPU backend's pair scan -reproduces that order by unranking. Tracked in -[FE-1526](https://linear.app/hash/issue/FE-1526/enumerate-weighted-arc-token-combinations-lazily-in-the-engine). -::: +`enumerateWeightedMarkingIndicesGenerator` was only lazy over the Cartesian +product _across_ arcs: per arc it eagerly materialised the full combination +list, so a transition with a coloured input arc of weight `w` over a place +holding `n` tokens allocated `C(n, w)` arrays on every evaluation, every frame +— and the engine fires the first accepted combination, so nearly all of it was +discarded. Measured before the fix, one weight-2 arc, transition never firing: +13.4 ms per run-frame at 400 tokens (~170 ns per enumerated combination, +quadratic in the token count); a 1000-run × 1800-frame experiment at that size +would have taken ~6.7 hours. Weight 3 made it cubic. The general bound for one +transition is `∏ᵢ C(nᵢ, wᵢ)` over its coloured non-inhibitor input arcs. + +Fixed in +[FE-1526](https://linear.app/hash/issue/FE-1526/enumerate-weighted-arc-token-combinations-lazily-in-the-engine): +enumeration is an in-place lexicographic successor per arc under an odometer +over arcs, allocating nothing per combination and yielding a reused buffer +(consumers copy on accept — the contract is in the module doc comment). The +enumeration order is unchanged, which matters twice over: the engine fires the +first passing combination, so an order change would diverge seeded +trajectories, and the GPU backend reproduces the same order by unranking. + +Measured on the same harness (`benchmarks/coloured-enumeration.mjs`), before → +after: + +| Tokens | `C(n,2)` | Never fires (all examined) | Always fires (one examined) | +| ------ | -------- | --------------------------- | --------------------------- | +| 50 | 1 225 | 221 µs → 40 µs (5.6×) | 45 µs → 3.2 µs (14×) | +| 100 | 4 950 | 1 002 µs → 159 µs (6.3×) | 207 µs → 4.7 µs (44×) | +| 400 | 79 800 | 13 771 µs → 2 463 µs (5.6×) | 3 858 µs → 12.6 µs (305×) | + +A firing transition's cost is now flat in the token count (the residual growth +is the firing's own token compaction). A never-firing transition still +examines every combination — each one's rate must be evaluated — so that case +keeps its `C(n, w)` lambda evaluations, at ~31 ns each instead of ~170 ns. + +That remaining `C(n, w)` term only applies to lambdas whose rate depends on +which tokens are selected. When the HIR analysis proves a lambda reads no +token attributes or counts +([FE-1528](https://linear.app/hash/issue/FE-1528/skip-combination-enumeration-for-lambdas-that-read-no-input-tokens)), +the compiler marks its artifact `readsNoInputTokens` and both engines test +only the first combination — indices `0..w-1` per arc, the first tokens in +place order. This is exact rather than approximate: the acceptance draw is +shared across combinations, so with a combination-independent rate either the +first combination fires or none does — same RNG stream, same consumed tokens, +same trajectories. Engine-supplied constant lambdas (a transition with no +lambda code) take the same path. Measured, never-firing weight-2 arc at 400 +tokens: 2 543 µs per run-frame with a token-reading lambda against **0.9 µs** +token-independent — flat in the token count. The static enumeration-bound lint +([FE-948](https://linear.app/hash/issue/FE-948/show-and-kill-combinatorial-explosion)) +remains the guard for nets whose token-reading lambdas make even lazy +enumeration too much. ### Metric aggregation @@ -484,22 +499,21 @@ codegen is what makes WASM worth emitting. Ordered by measured value per unit of risk. None require a new toolchain, a new ABI, or user-visible change. -| # | Change | Expected | -| --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | -| 1 | Lazy, index-based combination enumeration ([FE-1526](https://linear.app/hash/issue/FE-1526/enumerate-weighted-arc-token-combinations-lazily-in-the-engine)) | up to ~1000× on affected nets; nothing on weight-1 nets | -| 2 | Share one compiled `SimulationDefinition` across a shard's runs | removes 80–230 µs/run and improves inlining | -| 3 | Skip the frame copy when no place has dynamics; otherwise copy only dynamic places | ~20% | -| 4 | Replace `Record` removals/additions with dense per-place-index typed arrays | ~10% + most GC | -| 5 | Resolve place/transition indices at build time; delete `getPlaceIndex` from the hot loop | ~3% | -| 6 | Mutable-in-place metric accumulators + reusable frame cursor | ~30–50% of metric overhead | -| 7 | Single RNG draw per frame reused across transitions where semantics allow | up to 9% — changes RNG streams, permitted but user-visible | - -Items 1–6 are behaviour-preserving. Item 7 changes RNG streams — the +| # | Change | Expected | +| --- | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------- | +| 1 | Share one compiled `SimulationDefinition` across a shard's runs | removes 80–230 µs/run and improves inlining | +| 2 | Skip the frame copy when no place has dynamics; otherwise copy only dynamic places | ~20% | +| 3 | Replace `Record` removals/additions with dense per-place-index typed arrays | ~10% + most GC | +| 4 | Resolve place/transition indices at build time; delete `getPlaceIndex` from the hot loop | ~3% | +| 5 | Mutable-in-place metric accumulators + reusable frame cursor | ~30–50% of metric overhead | +| 6 | Single RNG draw per frame reused across transitions where semantics allow | up to 9% — changes RNG streams, permitted but user-visible | + +Items 1–5 are behaviour-preserving. Item 6 changes RNG streams — the reproducibility decision below permits that across engine versions, but existing seeds would produce different (equally valid) trajectories, which is -a product-visible change. Estimated for items 1–6 combined on a typical -coloured net: **5–15×** before any threading, **20–60×** combined with -sharding on 8 cores. +a product-visible change. Estimated for items 1–5 plus the landed enumeration +fix, on a typical coloured net: **5–15×** before any threading, **20–60×** +combined with sharding on 8 cores. ### Whole-loop codegen diff --git a/libs/@local/petrinaut-optimizer-client/scripts/optimizer-service.sh b/libs/@local/petrinaut-optimizer-client/scripts/optimizer-service.sh new file mode 100644 index 00000000000..af73101a2c3 --- /dev/null +++ b/libs/@local/petrinaut-optimizer-client/scripts/optimizer-service.sh @@ -0,0 +1,113 @@ +# The local Petrinaut Optimizer service for a dev task that wants the real +# optimization provider. Source this file, then call +# `optimizer_service_parse "$@"`: when the arguments carry +# --with-optimizer-service it starts the service, exports the variables that +# point a dev server at it, and arranges for the container it started to stop +# when the task exits. The remaining arguments land in OPTIMIZER_FORWARDED. +# +# The service runs from the petrinaut-opt:local image, built from +# apps/petrinaut-opt/docker/Dockerfile, bound to loopback on port 4004. An +# optimizer already serving that port healthily, the compose stack's container +# or a bare uvicorn during Python work, is reused and left running. + +OPTIMIZER_SERVICE_FLAG="--with-optimizer-service" +OPTIMIZER_SERVICE_ORIGIN="http://127.0.0.1:4004" +OPTIMIZER_FORWARDED=() + +optimizer_repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../.." && pwd)" +optimizer_image="petrinaut-opt:local" +# One fixed name: the container owns port 4004 exclusively anyway, and a fixed +# name lets a new launcher find what an earlier run left behind. +optimizer_container="petrinaut-opt-website-dev" +optimizer_started_container="" + +optimizer_healthy() { + curl --silent --fail --output /dev/null --max-time 2 "$OPTIMIZER_SERVICE_ORIGIN/status" +} + +# Removes stopped containers earlier runs left behind. A hard-killed launcher +# never reaches its cleanup, and the leftover otherwise holds port 4004 while +# serving the code it was built from. The filter is anchored because Docker +# matches names by substring; the optional numeric suffix catches containers +# older launchers named by process id. Only stopped containers are swept, so a +# launcher already serving on the port keeps its container and is reused. +optimizer_remove_leftovers() { + docker ps --all \ + --filter "name=^${optimizer_container}(-[0-9]+)?$" \ + --filter status=exited --filter status=created \ + --format '{{.Names}}' 2>/dev/null | + while read -r name; do + [ -n "$name" ] || continue + echo "Removing leftover Petrinaut Opt dev container $name..." + docker rm --force "$name" >/dev/null 2>&1 || true + done +} + +# Stops only the container this task started, by id, so two launchers never +# stop each other's. +optimizer_stop() { + if [ -n "$optimizer_started_container" ]; then + docker stop --timeout 5 "$optimizer_started_container" >/dev/null 2>&1 || true + optimizer_started_container="" + fi +} + +start_optimizer_service() { + optimizer_remove_leftovers + if optimizer_healthy; then + echo "Reusing the optimizer already serving on $OPTIMIZER_SERVICE_ORIGIN." + else + if ! docker info >/dev/null 2>&1; then + echo "Docker is not running. Start Docker Desktop and run the command again." >&2 + exit 1 + fi + echo "Building Petrinaut Opt..." + docker build \ + --file "$optimizer_repository_root/apps/petrinaut-opt/docker/Dockerfile" \ + --tag "$optimizer_image" "$optimizer_repository_root" + echo "Starting Petrinaut Opt on $OPTIMIZER_SERVICE_ORIGIN..." + optimizer_started_container="$(docker run --detach --init --read-only --rm \ + --name "$optimizer_container" --publish 127.0.0.1:4004:4004 "$optimizer_image")" + trap optimizer_stop EXIT + for _ in $(seq 1 60); do + if optimizer_healthy; then break; fi + sleep 0.5 + done + if ! optimizer_healthy; then + echo "Petrinaut Opt did not become healthy within 30 seconds" >&2 + exit 1 + fi + fi + export PETRINAUT_OPT_ORIGIN="$OPTIMIZER_SERVICE_ORIGIN" + export VITE_PETRINAUT_OPT_PROVIDER=service +} + +# Splits the flag off the task's arguments and starts the service when present. +optimizer_service_parse() { + local with_service=false argument + OPTIMIZER_FORWARDED=() + for argument in "$@"; do + if [ "$argument" = "$OPTIMIZER_SERVICE_FLAG" ]; then + with_service=true + else + OPTIMIZER_FORWARDED+=("$argument") + fi + done + if [ "$with_service" = true ]; then + start_optimizer_service + fi +} + +# Runs the dev server in the foreground, forwarding SIGINT and SIGTERM to it so +# the EXIT trap above still runs after the server has gone. +run_dev_server() { + "$@" & + local child=$! status=0 + trap 'kill -TERM "$child" 2>/dev/null' TERM + trap 'kill -INT "$child" 2>/dev/null' INT + while kill -0 "$child" 2>/dev/null; do + wait "$child" && status=0 || status=$? + done + trap - TERM INT + return "$status" +} diff --git a/package.json b/package.json index 4e64bd3fa40..2888e18031b 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,6 @@ "dev:brunch:panel": "PETRINAUT_WEBSITE_ROOT=\"$PWD/apps/petrinaut-website\" yarn workspace @apps/brunch-agent petrinaut:dev", "dev:brunch:server": "yarn workspace @apps/brunch-agent dev", "dev:frontend": "CARGO_TERM_PROGRESS_WHEN=never turbo dev --log-order stream --filter '@apps/hash-frontend' --", - "dev:petrinaut-optimization": "yarn workspace @apps/petrinaut-website dev:optimization", "doc:task-dependencies": "CARGO_TERM_PROGRESS_WHEN=never cargo run --package hash-repo-chores --bin repo-chores-cli -- task-dependencies --logging-console-level warn", "fix": "npm-run-all --continue-on-error \"fix:*\"", "fix:constraints": "yarn constraints --fix",