From 3ddcbbd51ca51d6f22acfa11ecb4915dd0a73e3c Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 27 Aug 2026 21:14:59 +0200 Subject: [PATCH 1/9] FE-1526: Enumerate weighted-arc token combinations lazily --- .changeset/lazy-weighted-arc-enumeration.md | 5 + .../benchmarks/coloured-enumeration.mjs | 59 +++++-- .../enumerate-weighted-markings.test.ts | 135 +++++++++++++-- .../engine/enumerate-weighted-markings.ts | 163 ++++++++++-------- .../src/webgpu/pair-selection.test.ts | 15 +- .../content/simulation/performance.mdx | 100 +++++------ 6 files changed, 317 insertions(+), 160 deletions(-) create mode 100644 .changeset/lazy-weighted-arc-enumeration.md diff --git a/.changeset/lazy-weighted-arc-enumeration.md b/.changeset/lazy-weighted-arc-enumeration.md new file mode 100644 index 00000000000..7aea25385ea --- /dev/null +++ b/.changeset/lazy-weighted-arc-enumeration.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut-core": patch +--- + +Weighted-arc token combinations now enumerate lazily, in place, in the same lexicographic order. Evaluating a transition with a weight-2 coloured input arc no longer materialises every `C(n, 2)` combination per frame: measured, a firing transition at 400 tokens in the place drops from 3.86 ms to 12.6 µs per run-frame, and a never-firing one from 13.8 ms to 2.5 ms. Trajectories are unchanged for every seed. diff --git a/libs/@hashintel/petrinaut-core/benchmarks/coloured-enumeration.mjs b/libs/@hashintel/petrinaut-core/benchmarks/coloured-enumeration.mjs index e5f3c49942b..3352f58e2cb 100644 --- a/libs/@hashintel/petrinaut-core/benchmarks/coloured-enumeration.mjs +++ b/libs/@hashintel/petrinaut-core/benchmarks/coloured-enumeration.mjs @@ -2,9 +2,12 @@ * 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. + * Two cases bound the enumeration cost: a transition that never fires + * examines every combination each frame (O(C(n, 2)) lambda evaluations — + * irreducible), and a transition 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. */ import { performance } from "node:perf_hooks"; @@ -64,16 +67,28 @@ const sdcpn = { parameters: [], }; -const artifacts = compileHirArtifacts(sdcpn).artifacts; - -process.stdout.write( - "coloured place, input arc weight 2, transition never fires\n" + - "tokens C(n,2) ns/run-frame\n", -); +/** + * 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(() => true);", + transitionKernelCode: + "export default TransitionKernel(() => ({ Pool: [{ v: 1 }, { v: 2 }] }));", + }, + ], +}; -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 +110,26 @@ for (const tokens of [10, 25, 50, 100, 200, 400]) { for (const summary of simulator.getSummaries()) { frames += summary.frameNumber; } + return (ms / frames) * 1e6; +} + +const cases = [ + ["transition never fires (examines every combination)", sdcpn], + ["transition always fires (examines one combination)", selfLoopSdcpn], +]; - const combinations = (tokens * (tokens - 1)) / 2; +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`, + `coloured place, input arc weight 2, ${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/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..0e83d5b2d57 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,104 @@ 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; } /** - * 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 +118,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/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/simulation/performance.mdx b/libs/@local/petrinaut-arch-docs/content/simulation/performance.mdx index ba4155387e3..f1fa096945d 100644 --- a/libs/@local/petrinaut-arch-docs/content/simulation/performance.mdx +++ b/libs/@local/petrinaut-arch-docs/content/simulation/performance.mdx @@ -20,7 +20,7 @@ are the point. | [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). | +| [Weighted-arc enumeration](#weighted-arc-enumeration) | Fixed (FE-1526). Lazy and allocation-free; 305× measured on firing transitions. | | [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. | @@ -127,41 +127,42 @@ 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. The +static enumeration-bound lint +([FE-948](https://linear.app/hash/issue/FE-948/show-and-kill-combinatorial-explosion)) +remains the guard against nets where even that is too much. ### Metric aggregation @@ -484,22 +485,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 From 2c81994786359626ef0a70399ae2178eedb7f75c Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 2 Sep 2026 21:20:32 +0200 Subject: [PATCH 2/9] FE-1526: Shorten the changesets --- .changeset/lazy-weighted-arc-enumeration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/lazy-weighted-arc-enumeration.md b/.changeset/lazy-weighted-arc-enumeration.md index 7aea25385ea..54d8354342e 100644 --- a/.changeset/lazy-weighted-arc-enumeration.md +++ b/.changeset/lazy-weighted-arc-enumeration.md @@ -2,4 +2,4 @@ "@hashintel/petrinaut-core": patch --- -Weighted-arc token combinations now enumerate lazily, in place, in the same lexicographic order. Evaluating a transition with a weight-2 coloured input arc no longer materialises every `C(n, 2)` combination per frame: measured, a firing transition at 400 tokens in the place drops from 3.86 ms to 12.6 µs per run-frame, and a never-firing one from 13.8 ms to 2.5 ms. Trajectories are unchanged for every seed. +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. From 902212432d992d09f8bf3a0fc9c731bc48a38f1f Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 27 Aug 2026 21:55:24 +0200 Subject: [PATCH 3/9] FE-1528: Skip combination enumeration for token-independent lambdas --- .../token-independent-lambda-fast-path.md | 5 + .../benchmarks/coloured-enumeration.mjs | 52 +++++-- .../petrinaut-core/src/hir/artifacts.test.ts | 32 +++++ .../petrinaut-core/src/hir/compile.ts | 7 + .../petrinaut-core/src/hir/instantiate.ts | 9 ++ .../src/simulation/engine/build-simulation.ts | 23 ++-- .../compiled-transition.test-helpers.ts | 4 + .../compute-possible-transition.test.ts | 101 ++++++++++++++ .../engine/compute-possible-transition.ts | 14 +- .../engine/enumerate-weighted-markings.ts | 15 +++ .../src/simulation/engine/types.ts | 8 ++ .../token-independent-lambda.test.ts | 127 ++++++++++++++++++ .../monte-carlo/transition-effect.ts | 14 +- .../content/simulation/performance.mdx | 40 ++++-- 14 files changed, 409 insertions(+), 42 deletions(-) create mode 100644 .changeset/token-independent-lambda-fast-path.md create mode 100644 libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/token-independent-lambda.test.ts diff --git a/.changeset/token-independent-lambda-fast-path.md b/.changeset/token-independent-lambda-fast-path.md new file mode 100644 index 00000000000..e7f54199efd --- /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: the HIR analysis marks their compiled artifact, and both engines evaluate the lambda once against the first tokens in place order instead of walking every combination. Trajectories are unchanged for every seed; a never-firing weight-2 transition over 400 tokens drops from 2.5 ms to under a microsecond per run-frame. diff --git a/libs/@hashintel/petrinaut-core/benchmarks/coloured-enumeration.mjs b/libs/@hashintel/petrinaut-core/benchmarks/coloured-enumeration.mjs index 3352f58e2cb..d4d425bf7bc 100644 --- a/libs/@hashintel/petrinaut-core/benchmarks/coloured-enumeration.mjs +++ b/libs/@hashintel/petrinaut-core/benchmarks/coloured-enumeration.mjs @@ -2,12 +2,15 @@ * Measures how per-frame cost scales with token count for a transition whose * coloured input arc has weight 2. * - * Two cases bound the enumeration cost: a transition that never fires - * examines every combination each frame (O(C(n, 2)) lambda evaluations — - * irreducible), and a transition 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. + * 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"; @@ -55,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, @@ -79,13 +84,28 @@ const selfLoopSdcpn = { ...sdcpn.transitions[0], inputArcs: [{ placeId: "pool", weight: 2, type: "standard" }], outputArcs: [{ placeId: "pool", weight: 2 }], - lambdaCode: "export default Lambda(() => true);", + lambdaCode: "export default Lambda((input) => input.Pool[0].v >= 0);", transitionKernelCode: "export default TransitionKernel(() => ({ Pool: [{ v: 1 }, { v: 2 }] }));", }, ], }; +/** + * 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);", + }, + ], +}; + function measure(net, artifacts, tokens) { const simulator = createMonteCarloSimulator({ sdcpn: net, @@ -114,15 +134,21 @@ function measure(net, artifacts, tokens) { } const cases = [ - ["transition never fires (examines every combination)", sdcpn], - ["transition always fires (examines one combination)", selfLoopSdcpn], + ["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( - `coloured place, input arc weight 2, ${label}\n` + - "tokens C(n,2) ns/run-frame\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; diff --git a/libs/@hashintel/petrinaut-core/src/hir/artifacts.test.ts b/libs/@hashintel/petrinaut-core/src/hir/artifacts.test.ts index 05f303a0079..338dac90bcb 100644 --- a/libs/@hashintel/petrinaut-core/src/hir/artifacts.test.ts +++ b/libs/@hashintel/petrinaut-core/src/hir/artifacts.test.ts @@ -235,3 +235,35 @@ 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); + }); +}); 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.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/enumerate-weighted-markings.ts index 0e83d5b2d57..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 @@ -99,6 +99,21 @@ export function* enumerateWeightedMarkingIndicesGenerator( } } +/** + * 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 eagerly, returning indices only. * 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..c6ddc85505a --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/token-independent-lambda.test.ts @@ -0,0 +1,127 @@ +/** + * 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 type { HirArtifacts } from "../../hir"; +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); + }); +}); 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/@local/petrinaut-arch-docs/content/simulation/performance.mdx b/libs/@local/petrinaut-arch-docs/content/simulation/performance.mdx index f1fa096945d..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) | Fixed (FE-1526). Lazy and allocation-free; 305× measured on firing transitions. | -| [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 @@ -159,10 +159,24 @@ after: 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. The -static enumeration-bound lint +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 against nets where even that is too much. +remains the guard for nets whose token-reading lambdas make even lazy +enumeration too much. ### Metric aggregation From 74a88939a0ba16fb34934514e8b27f5a3a6faa75 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 2 Sep 2026 21:20:33 +0200 Subject: [PATCH 4/9] FE-1528: Shorten the changesets --- .changeset/token-independent-lambda-fast-path.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/token-independent-lambda-fast-path.md b/.changeset/token-independent-lambda-fast-path.md index e7f54199efd..5c4a630e05a 100644 --- a/.changeset/token-independent-lambda-fast-path.md +++ b/.changeset/token-independent-lambda-fast-path.md @@ -2,4 +2,4 @@ "@hashintel/petrinaut-core": patch --- -Transitions whose lambda reads no input tokens skip combination enumeration: the HIR analysis marks their compiled artifact, and both engines evaluate the lambda once against the first tokens in place order instead of walking every combination. Trajectories are unchanged for every seed; a never-firing weight-2 transition over 400 tokens drops from 2.5 ms to under a microsecond per run-frame. +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. From bb1e6597f6d023f174de71e651d524c1726d205e Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 2 Sep 2026 21:33:30 +0200 Subject: [PATCH 5/9] FE-1528: Test the determinism gate and the Monte Carlo fast path --- .../petrinaut-core/src/hir/artifacts.test.ts | 20 +++++++ .../token-independent-lambda.test.ts | 55 +++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/libs/@hashintel/petrinaut-core/src/hir/artifacts.test.ts b/libs/@hashintel/petrinaut-core/src/hir/artifacts.test.ts index 338dac90bcb..c8643501c7d 100644 --- a/libs/@hashintel/petrinaut-core/src/hir/artifacts.test.ts +++ b/libs/@hashintel/petrinaut-core/src/hir/artifacts.test.ts @@ -266,4 +266,24 @@ describe("readsNoInputTokens", () => { ).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/simulation/monte-carlo/token-independent-lambda.test.ts b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/token-independent-lambda.test.ts index c6ddc85505a..c574d822d7b 100644 --- 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 @@ -9,8 +9,11 @@ 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 = { @@ -124,4 +127,56 @@ describe("token-independent lambda fast path", () => { ), ).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); + }); }); From f1cc3bf1153a6f24d21264003b637be37355f42d Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 28 Aug 2026 02:21:31 +0200 Subject: [PATCH 6/9] FE-1529: Document the local optimizer loop and make its launcher self-healing --- .claude/launch.json | 38 +++++ .../scripts/optimization-dev.mjs | 135 +++++++++++++----- .../optimizer/running-the-loop-locally.mdx | 110 ++++++++++++++ 3 files changed, 251 insertions(+), 32 deletions(-) create mode 100644 .claude/launch.json create mode 100644 libs/@local/petrinaut-arch-docs/content/optimizer/running-the-loop-locally.mdx diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 00000000000..b31de674696 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,38 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "petrinaut", + "runtimeExecutable": "sh", + "runtimeArgs": [ + "-c", + "cd /Users/HASH/Code/hash/.claude/worktrees/fe-1217-backend-petrinaut-85a393 && yarn exec turbo run dev --filter @hashintel/petrinaut -- -p 6007 --no-open --ci" + ], + "port": 6007, + "autoPort": false + }, + { + "name": "petrinaut-website", + "runtimeExecutable": "sh", + "runtimeArgs": [ + "-c", + "cd /Users/HASH/Code/hash/.claude/worktrees/adhoc-scenario && yarn exec turbo run dev --filter @apps/petrinaut-website -- --port 5174 --strictPort" + ], + "port": 5174, + "autoPort": false + }, + { + "name": "petrinaut-docs", + "runtimeExecutable": "yarn", + "runtimeArgs": [ + "exec", + "turbo", + "run", + "dev", + "--filter", + "@apps/petrinaut-docs" + ], + "port": 4321 + } + ] +} diff --git a/apps/petrinaut-website/scripts/optimization-dev.mjs b/apps/petrinaut-website/scripts/optimization-dev.mjs index 903336115ad..f2c030ba372 100644 --- a/apps/petrinaut-website/scripts/optimization-dev.mjs +++ b/apps/petrinaut-website/scripts/optimization-dev.mjs @@ -6,7 +6,10 @@ 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}`; +// One fixed name rather than a per-invocation one: the container owns port +// 4004 exclusively anyway, and a fixed name lets a new launcher find and +// replace what an earlier run left behind. +const container = "petrinaut-opt-website-dev"; // 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 @@ -36,6 +39,61 @@ const run = (command, args, options = {}) => }); }); +const capture = (command, args) => + new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: repositoryRoot, + env: process.env, + stdio: ["ignore", "pipe", "ignore"], + }); + let output = ""; + child.stdout.on("data", (chunk) => { + output += chunk; + }); + child.once("error", reject); + child.once("exit", (code) => { + if (code === 0) { + resolve(output); + } else { + reject(new Error(`${command} exited with code ${code}`)); + } + }); + }); + +/** + * Remove containers this launcher started and never stopped. A hard-killed + * launcher (closed terminal, crash) never reaches its cleanup, and the + * detached container then holds port 4004 forever — every later launch would + * fail with "port is already allocated". Removing rather than reusing them + * also keeps the image rebuild meaningful: a leftover keeps serving the code + * it was built from. + */ +const removeLeftoverContainers = async () => { + const names = await capture("docker", [ + "ps", + "--all", + "--filter", + `name=${container}`, + "--format", + "{{.Names}}", + ]).catch(() => ""); + for (const name of names.split("\n").filter(Boolean)) { + console.log(`Removing leftover Petrinaut Opt dev container ${name}...`); + await run("docker", ["rm", "--force", name], { stdio: "ignore" }).catch( + () => undefined, + ); + } +}; + +const isOptimizerHealthy = async () => { + try { + const response = await fetch(`${optimizerOrigin}/status`); + return response.ok; + } catch { + return false; + } +}; + const waitForOptimizer = async () => { for (let attempt = 0; attempt < 60; attempt += 1) { try { @@ -65,43 +123,56 @@ const stopContainer = async () => { }; try { - await run("docker", ["info"], { stdio: "ignore" }).catch(() => { - throw new Error( - "Docker is not running. Start Docker Desktop and run the command again.", - ); - }); + await removeLeftoverContainers(); + + // An optimizer this launcher does not own already serving on the port — + // the compose stack's container, or a bare `uvicorn` during Python work — + // is reused as-is; starting a second container would fail on the port bind. + // Launcher-owned leftovers never reach this check: they were removed above, + // so the freshly built image is what actually serves. + if (await isOptimizerHealthy()) { + console.log(`Reusing the optimizer already serving on ${optimizerOrigin}.`); + } else { + 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("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("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"], { + // Extra arguments go to Vite, so a caller can pin the port: + // `yarn dev:petrinaut-optimization --port 5175 --strictPort`. + websiteProcess = spawn("yarn", ["vite", ...process.argv.slice(2)], { cwd: appDirectory, env: { ...process.env, 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..a5e02821cc0 --- /dev/null +++ b/libs/@local/petrinaut-arch-docs/content/optimizer/running-the-loop-locally.mdx @@ -0,0 +1,110 @@ +--- +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 +yarn dev:petrinaut-optimization +``` + +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. + +The command is `apps/petrinaut-website/scripts/optimization-dev.mjs`, and it +does six 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. Builds `@hashintel/petrinaut` through Turborepo — the demo website consumes + the **built dists**, not the sources. +5. Starts the website's Vite dev server with + `VITE_PETRINAUT_OPT_PROVIDER=service` and + `PETRINAUT_OPT_ORIGIN=http://127.0.0.1:4004`. +6. 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. +Extra arguments are forwarded to Vite, so +`yarn dev:petrinaut-optimization --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. From cfdf244b0c89f232bb45c453f427984752fe4154 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 2 Sep 2026 21:33:40 +0200 Subject: [PATCH 7/9] FE-1529: Own the dev container by id and drop the personal launch config --- .claude/launch.json | 38 --------------- .gitignore | 1 + .../scripts/optimization-dev.mjs | 47 ++++++++++++------- 3 files changed, 30 insertions(+), 56 deletions(-) delete mode 100644 .claude/launch.json diff --git a/.claude/launch.json b/.claude/launch.json deleted file mode 100644 index b31de674696..00000000000 --- a/.claude/launch.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "version": "0.0.1", - "configurations": [ - { - "name": "petrinaut", - "runtimeExecutable": "sh", - "runtimeArgs": [ - "-c", - "cd /Users/HASH/Code/hash/.claude/worktrees/fe-1217-backend-petrinaut-85a393 && yarn exec turbo run dev --filter @hashintel/petrinaut -- -p 6007 --no-open --ci" - ], - "port": 6007, - "autoPort": false - }, - { - "name": "petrinaut-website", - "runtimeExecutable": "sh", - "runtimeArgs": [ - "-c", - "cd /Users/HASH/Code/hash/.claude/worktrees/adhoc-scenario && yarn exec turbo run dev --filter @apps/petrinaut-website -- --port 5174 --strictPort" - ], - "port": 5174, - "autoPort": false - }, - { - "name": "petrinaut-docs", - "runtimeExecutable": "yarn", - "runtimeArgs": [ - "exec", - "turbo", - "run", - "dev", - "--filter", - "@apps/petrinaut-docs" - ], - "port": 4321 - } - ] -} diff --git a/.gitignore b/.gitignore index 34a1652ba8c..1e16af02743 100644 --- a/.gitignore +++ b/.gitignore @@ -155,6 +155,7 @@ seed-users.json AGENTS.local.md CLAUDE.local.md .claude/settings.local.json +.claude/launch.json # Terraform **/.terraform diff --git a/apps/petrinaut-website/scripts/optimization-dev.mjs b/apps/petrinaut-website/scripts/optimization-dev.mjs index f2c030ba372..d43fc10adbd 100644 --- a/apps/petrinaut-website/scripts/optimization-dev.mjs +++ b/apps/petrinaut-website/scripts/optimization-dev.mjs @@ -69,11 +69,19 @@ const capture = (command, args) => * it was built from. */ const removeLeftoverContainers = async () => { + // Anchored, because Docker's name filter matches substrings; 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 below. const names = await capture("docker", [ "ps", "--all", "--filter", - `name=${container}`, + `name=^${container}(-[0-9]+)?$`, + "--filter", + "status=exited", + "--filter", + "status=created", "--format", "{{.Names}}", ]).catch(() => ""); @@ -109,15 +117,17 @@ const waitForOptimizer = async () => { throw new Error("Petrinaut Opt did not become healthy within 30 seconds"); }; -let containerStarted = false; +/** The container this launcher started, by id, so it never stops another launcher's. */ +let startedContainerId = null; let websiteProcess; const stopContainer = async () => { - if (!containerStarted) { + if (startedContainerId === null) { return; } - containerStarted = false; - await run("docker", ["stop", "--timeout", "5", container], { + const containerId = startedContainerId; + startedContainerId = null; + await run("docker", ["stop", "--timeout", "5", containerId], { stdio: "ignore", }).catch(() => undefined); }; @@ -150,19 +160,20 @@ try { ]); 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; + startedContainerId = ( + await capture("docker", [ + "run", + "--detach", + "--init", + "--read-only", + "--rm", + "--name", + container, + "--publish", + "127.0.0.1:4004:4004", + image, + ]) + ).trim(); await waitForOptimizer(); } From 54c171d971b8de4ae9b71693f9ff37830c2d8394 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 2 Sep 2026 23:55:15 +0200 Subject: [PATCH 8/9] FE-1529: Start the optimizer service from the website's dev task `turbo run dev --filter @apps/petrinaut-website -- --with-optimizer-service` builds and starts the local Petrinaut Optimizer around the website's Vite server, replacing the root `dev:petrinaut-optimization` script and the standalone launcher. The service lifecycle lives in @local/petrinaut-optimizer-client's dev-service module so other dev tasks can reuse it. --- .gitignore | 1 - apps/petrinaut-website/README.md | 4 +- apps/petrinaut-website/package.json | 3 +- apps/petrinaut-website/scripts/dev.mjs | 20 ++ .../scripts/optimization-dev.mjs | 219 --------------- .../optimizer/running-the-loop-locally.mdx | 24 +- .../petrinaut-optimizer-client/package.json | 3 +- .../scripts/optimizer-service.mjs | 259 ++++++++++++++++++ package.json | 1 - 9 files changed, 297 insertions(+), 237 deletions(-) create mode 100644 apps/petrinaut-website/scripts/dev.mjs delete mode 100644 apps/petrinaut-website/scripts/optimization-dev.mjs create mode 100644 libs/@local/petrinaut-optimizer-client/scripts/optimizer-service.mjs diff --git a/.gitignore b/.gitignore index 1e16af02743..34a1652ba8c 100644 --- a/.gitignore +++ b/.gitignore @@ -155,7 +155,6 @@ seed-users.json AGENTS.local.md CLAUDE.local.md .claude/settings.local.json -.claude/launch.json # Terraform **/.terraform 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..c7d485b9ab3 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": "node scripts/dev.mjs", "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.mjs b/apps/petrinaut-website/scripts/dev.mjs new file mode 100644 index 00000000000..31c7c607222 --- /dev/null +++ b/apps/petrinaut-website/scripts/dev.mjs @@ -0,0 +1,20 @@ +#!/usr/bin/env node +/** + * 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 + */ +import { fileURLToPath } from "node:url"; + +import { runDevServerWithOptionalService } from "@local/petrinaut-optimizer-client/dev-service"; + +const appDirectory = fileURLToPath(new URL("..", import.meta.url)); + +process.exitCode = await runDevServerWithOptionalService({ + cliArguments: process.argv.slice(2), + cwd: appDirectory, + prepare: { command: "yarn", args: ["examples:generate"] }, + server: { command: "yarn", args: ["vite"] }, +}); diff --git a/apps/petrinaut-website/scripts/optimization-dev.mjs b/apps/petrinaut-website/scripts/optimization-dev.mjs deleted file mode 100644 index d43fc10adbd..00000000000 --- a/apps/petrinaut-website/scripts/optimization-dev.mjs +++ /dev/null @@ -1,219 +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"; -// One fixed name rather than a per-invocation one: the container owns port -// 4004 exclusively anyway, and a fixed name lets a new launcher find and -// replace what an earlier run left behind. -const container = "petrinaut-opt-website-dev"; -// 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 capture = (command, args) => - new Promise((resolve, reject) => { - const child = spawn(command, args, { - cwd: repositoryRoot, - env: process.env, - stdio: ["ignore", "pipe", "ignore"], - }); - let output = ""; - child.stdout.on("data", (chunk) => { - output += chunk; - }); - child.once("error", reject); - child.once("exit", (code) => { - if (code === 0) { - resolve(output); - } else { - reject(new Error(`${command} exited with code ${code}`)); - } - }); - }); - -/** - * Remove containers this launcher started and never stopped. A hard-killed - * launcher (closed terminal, crash) never reaches its cleanup, and the - * detached container then holds port 4004 forever — every later launch would - * fail with "port is already allocated". Removing rather than reusing them - * also keeps the image rebuild meaningful: a leftover keeps serving the code - * it was built from. - */ -const removeLeftoverContainers = async () => { - // Anchored, because Docker's name filter matches substrings; 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 below. - const names = await capture("docker", [ - "ps", - "--all", - "--filter", - `name=^${container}(-[0-9]+)?$`, - "--filter", - "status=exited", - "--filter", - "status=created", - "--format", - "{{.Names}}", - ]).catch(() => ""); - for (const name of names.split("\n").filter(Boolean)) { - console.log(`Removing leftover Petrinaut Opt dev container ${name}...`); - await run("docker", ["rm", "--force", name], { stdio: "ignore" }).catch( - () => undefined, - ); - } -}; - -const isOptimizerHealthy = async () => { - try { - const response = await fetch(`${optimizerOrigin}/status`); - return response.ok; - } catch { - return false; - } -}; - -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"); -}; - -/** The container this launcher started, by id, so it never stops another launcher's. */ -let startedContainerId = null; -let websiteProcess; - -const stopContainer = async () => { - if (startedContainerId === null) { - return; - } - const containerId = startedContainerId; - startedContainerId = null; - await run("docker", ["stop", "--timeout", "5", containerId], { - stdio: "ignore", - }).catch(() => undefined); -}; - -try { - await removeLeftoverContainers(); - - // An optimizer this launcher does not own already serving on the port — - // the compose stack's container, or a bare `uvicorn` during Python work — - // is reused as-is; starting a second container would fail on the port bind. - // Launcher-owned leftovers never reach this check: they were removed above, - // so the freshly built image is what actually serves. - if (await isOptimizerHealthy()) { - console.log(`Reusing the optimizer already serving on ${optimizerOrigin}.`); - } else { - 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..."); - startedContainerId = ( - await capture("docker", [ - "run", - "--detach", - "--init", - "--read-only", - "--rm", - "--name", - container, - "--publish", - "127.0.0.1:4004:4004", - image, - ]) - ).trim(); - await waitForOptimizer(); - } - - console.log("Building Petrinaut for the demo website..."); - await run("turbo", ["build", "--filter", "@hashintel/petrinaut"]); - - console.log("Starting the Petrinaut optimization demo..."); - // Extra arguments go to Vite, so a caller can pin the port: - // `yarn dev:petrinaut-optimization --port 5175 --strictPort`. - websiteProcess = spawn("yarn", ["vite", ...process.argv.slice(2)], { - 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/@local/petrinaut-arch-docs/content/optimizer/running-the-loop-locally.mdx b/libs/@local/petrinaut-arch-docs/content/optimizer/running-the-loop-locally.mdx index a5e02821cc0..01c76059464 100644 --- 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 @@ -9,7 +9,7 @@ The full optimization loop runs locally without any HASH infrastructure. From the repository root: ```sh -yarn dev:petrinaut-optimization +turbo run dev --filter @apps/petrinaut-website -- --with-optimizer-service ``` Then open [http://localhost:5173/optimization](http://localhost:5173/optimization) @@ -17,8 +17,11 @@ Then open [http://localhost:5173/optimization](http://localhost:5173/optimizatio and studies created there run against the real [optimizer](layer:optimizer) service. -The command is `apps/petrinaut-website/scripts/optimization-dev.mjs`, and it -does six things in order: +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.mjs`) then hands the +flag to `@local/petrinaut-optimizer-client`'s `dev-service` module, 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, @@ -35,18 +38,17 @@ does six things in order: 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. Builds `@hashintel/petrinaut` through Turborepo — the demo website consumes - the **built dists**, not the sources. -5. Starts the website's Vite dev server with +4. Starts the website's Vite dev server with `VITE_PETRINAUT_OPT_PROVIDER=service` and - `PETRINAUT_OPT_ORIGIN=http://127.0.0.1:4004`. -6. Stops and removes its container when you stop the command. A reused + `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. -Extra arguments are forwarded to Vite, so -`yarn dev:petrinaut-optimization --port 5175 --strictPort` pins the website -port for tooling that needs to know it. +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 diff --git a/libs/@local/petrinaut-optimizer-client/package.json b/libs/@local/petrinaut-optimizer-client/package.json index 15235455bb6..b4ee540981a 100644 --- a/libs/@local/petrinaut-optimizer-client/package.json +++ b/libs/@local/petrinaut-optimizer-client/package.json @@ -11,7 +11,8 @@ ".": { "types": "./src/index.ts", "default": "./dist/index.js" - } + }, + "./dev-service": "./scripts/optimizer-service.mjs" }, "scripts": { "build": "rimraf dist && tsc --build tsconfig.build.json", diff --git a/libs/@local/petrinaut-optimizer-client/scripts/optimizer-service.mjs b/libs/@local/petrinaut-optimizer-client/scripts/optimizer-service.mjs new file mode 100644 index 00000000000..918565a8792 --- /dev/null +++ b/libs/@local/petrinaut-optimizer-client/scripts/optimizer-service.mjs @@ -0,0 +1,259 @@ +/** + * The local Petrinaut Optimizer service for a dev server that wants the real + * optimization provider: start it before the server, stop it after. + * + * The service runs from the `petrinaut-opt:local` Docker 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. + */ +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +/** The dev-task argument that turns the service on. */ +export const OPTIMIZER_SERVICE_FLAG = "--with-optimizer-service"; + +// Loopback only, so plaintext HTTP is intentional and never reaches a +// deployed application. +// nosemgrep: typescript.react.security.react-insecure-request.react-insecure-request +export const OPTIMIZER_SERVICE_ORIGIN = "http://127.0.0.1:4004"; + +const repositoryRoot = fileURLToPath(new URL("../../../..", import.meta.url)); +const 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. +const container = "petrinaut-opt-website-dev"; + +/** Splits the service flag off a dev task's arguments. */ +export const splitOptimizerServiceFlag = (cliArguments) => ({ + withService: cliArguments.includes(OPTIMIZER_SERVICE_FLAG), + forwarded: cliArguments.filter( + (argument) => argument !== OPTIMIZER_SERVICE_FLAG, + ), +}); + +/** The environment that points a dev server at the service. */ +export const optimizerServiceEnvironment = (env) => ({ + ...env, + PETRINAUT_OPT_ORIGIN: OPTIMIZER_SERVICE_ORIGIN, + VITE_PETRINAUT_OPT_PROVIDER: "service", +}); + +const wait = (durationMs) => + new Promise((resolve) => setTimeout(resolve, durationMs)); + +/** Runs a command to completion, rejecting on a non-zero exit. */ +export const runToCompletion = (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 capture = (command, args) => + new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: repositoryRoot, + env: process.env, + stdio: ["ignore", "pipe", "ignore"], + }); + let output = ""; + child.stdout.on("data", (chunk) => { + output += chunk; + }); + child.once("error", reject); + child.once("exit", (code) => { + if (code === 0) { + resolve(output); + } else { + reject(new Error(`${command} exited with code ${code}`)); + } + }); + }); + +/** + * Runs a long-lived dev server, forwarding SIGINT and SIGTERM to it, and + * resolves with the exit code the process should report. + */ +export const runDevServer = (command, args, options) => + new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: options.cwd, + env: options.env, + stdio: "inherit", + }); + const forward = (signal) => () => child.kill(signal); + const onSigint = forward("SIGINT"); + const onSigterm = forward("SIGTERM"); + process.on("SIGINT", onSigint); + process.on("SIGTERM", onSigterm); + const release = () => { + process.off("SIGINT", onSigint); + process.off("SIGTERM", onSigterm); + }; + child.once("error", (error) => { + release(); + reject(error); + }); + child.once("exit", (code, signal) => { + release(); + if (signal) { + resolve(signal === "SIGINT" ? 130 : 143); + } else { + resolve(code ?? 1); + } + }); + }); + +/** + * Removes stopped containers an earlier run 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. + */ +const removeLeftoverContainers = async () => { + const names = await capture("docker", [ + "ps", + "--all", + "--filter", + `name=^${container}(-[0-9]+)?$`, + "--filter", + "status=exited", + "--filter", + "status=created", + "--format", + "{{.Names}}", + ]).catch(() => ""); + for (const name of names.split("\n").filter(Boolean)) { + console.log(`Removing leftover Petrinaut Opt dev container ${name}...`); + await runToCompletion("docker", ["rm", "--force", name], { + stdio: "ignore", + }).catch(() => undefined); + } +}; + +const isOptimizerHealthy = async () => { + try { + const response = await fetch(`${OPTIMIZER_SERVICE_ORIGIN}/status`); + return response.ok; + } catch { + return false; + } +}; + +const waitForOptimizer = async () => { + for (let attempt = 0; attempt < 60; attempt += 1) { + if (await isOptimizerHealthy()) { + return; + } + await wait(500); + } + throw new Error("Petrinaut Opt did not become healthy within 30 seconds"); +}; + +/** + * Ensures an optimizer serves `OPTIMIZER_SERVICE_ORIGIN`, starting one when + * none does. `stop()` stops only the container this call started, by id, so + * two launchers never stop each other's. + */ +export const startOptimizerService = async () => { + await removeLeftoverContainers(); + + if (await isOptimizerHealthy()) { + console.log( + `Reusing the optimizer already serving on ${OPTIMIZER_SERVICE_ORIGIN}.`, + ); + return { origin: OPTIMIZER_SERVICE_ORIGIN, stop: async () => {} }; + } + + await runToCompletion("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 runToCompletion("docker", [ + "build", + "--file", + "apps/petrinaut-opt/docker/Dockerfile", + "--tag", + image, + ".", + ]); + + console.log(`Starting Petrinaut Opt on ${OPTIMIZER_SERVICE_ORIGIN}...`); + const containerId = ( + await capture("docker", [ + "run", + "--detach", + "--init", + "--read-only", + "--rm", + "--name", + container, + "--publish", + "127.0.0.1:4004:4004", + image, + ]) + ).trim(); + await waitForOptimizer(); + + return { + origin: OPTIMIZER_SERVICE_ORIGIN, + stop: () => + runToCompletion("docker", ["stop", "--timeout", "5", containerId], { + stdio: "ignore", + }).catch(() => undefined), + }; +}; + +/** + * Runs a dev server, with the optimizer service around it when the arguments + * carry the flag. Resolves with the exit code the process should report. + */ +export const runDevServerWithOptionalService = async ({ + cliArguments, + cwd, + prepare, + server, +}) => { + const { withService, forwarded } = splitOptimizerServiceFlag(cliArguments); + let service = null; + try { + if (withService) { + service = await startOptimizerService(); + } + const env = withService + ? optimizerServiceEnvironment(process.env) + : process.env; + if (prepare) { + await runToCompletion(prepare.command, prepare.args, { cwd, env }); + } + return await runDevServer(server.command, [...server.args, ...forwarded], { + cwd, + env, + }); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + return 1; + } finally { + await service?.stop(); + } +}; 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", From 0ae33d0469debfc93c5fa9ff74c7e119277b6655 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 3 Sep 2026 00:15:42 +0200 Subject: [PATCH 9/9] FE-1529: Run the optimizer service through a shell library Starting Docker, polling a health endpoint and wrapping a dev server is shell work: the service lifecycle is a sourced bash library and the website's dev task a short script over it, with no Node wrapper around Vite. --- apps/petrinaut-website/package.json | 2 +- apps/petrinaut-website/scripts/dev.mjs | 20 -- apps/petrinaut-website/scripts/dev.sh | 12 + .../optimizer/running-the-loop-locally.mdx | 6 +- .../petrinaut-optimizer-client/package.json | 3 +- .../scripts/optimizer-service.mjs | 259 ------------------ .../scripts/optimizer-service.sh | 113 ++++++++ 7 files changed, 130 insertions(+), 285 deletions(-) delete mode 100644 apps/petrinaut-website/scripts/dev.mjs create mode 100644 apps/petrinaut-website/scripts/dev.sh delete mode 100644 libs/@local/petrinaut-optimizer-client/scripts/optimizer-service.mjs create mode 100644 libs/@local/petrinaut-optimizer-client/scripts/optimizer-service.sh diff --git a/apps/petrinaut-website/package.json b/apps/petrinaut-website/package.json index c7d485b9ab3..adac5d9276e 100644 --- a/apps/petrinaut-website/package.json +++ b/apps/petrinaut-website/package.json @@ -7,7 +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": "node scripts/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.mjs b/apps/petrinaut-website/scripts/dev.mjs deleted file mode 100644 index 31c7c607222..00000000000 --- a/apps/petrinaut-website/scripts/dev.mjs +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env node -/** - * 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 - */ -import { fileURLToPath } from "node:url"; - -import { runDevServerWithOptionalService } from "@local/petrinaut-optimizer-client/dev-service"; - -const appDirectory = fileURLToPath(new URL("..", import.meta.url)); - -process.exitCode = await runDevServerWithOptionalService({ - cliArguments: process.argv.slice(2), - cwd: appDirectory, - prepare: { command: "yarn", args: ["examples:generate"] }, - server: { command: "yarn", args: ["vite"] }, -}); 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/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 index 01c76059464..965e73133b6 100644 --- 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 @@ -19,9 +19,9 @@ and studies created there run against the real 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.mjs`) then hands the -flag to `@local/petrinaut-optimizer-client`'s `dev-service` module, which does -five things in order: +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, diff --git a/libs/@local/petrinaut-optimizer-client/package.json b/libs/@local/petrinaut-optimizer-client/package.json index b4ee540981a..15235455bb6 100644 --- a/libs/@local/petrinaut-optimizer-client/package.json +++ b/libs/@local/petrinaut-optimizer-client/package.json @@ -11,8 +11,7 @@ ".": { "types": "./src/index.ts", "default": "./dist/index.js" - }, - "./dev-service": "./scripts/optimizer-service.mjs" + } }, "scripts": { "build": "rimraf dist && tsc --build tsconfig.build.json", diff --git a/libs/@local/petrinaut-optimizer-client/scripts/optimizer-service.mjs b/libs/@local/petrinaut-optimizer-client/scripts/optimizer-service.mjs deleted file mode 100644 index 918565a8792..00000000000 --- a/libs/@local/petrinaut-optimizer-client/scripts/optimizer-service.mjs +++ /dev/null @@ -1,259 +0,0 @@ -/** - * The local Petrinaut Optimizer service for a dev server that wants the real - * optimization provider: start it before the server, stop it after. - * - * The service runs from the `petrinaut-opt:local` Docker 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. - */ -import { spawn } from "node:child_process"; -import { fileURLToPath } from "node:url"; - -/** The dev-task argument that turns the service on. */ -export const OPTIMIZER_SERVICE_FLAG = "--with-optimizer-service"; - -// Loopback only, so plaintext HTTP is intentional and never reaches a -// deployed application. -// nosemgrep: typescript.react.security.react-insecure-request.react-insecure-request -export const OPTIMIZER_SERVICE_ORIGIN = "http://127.0.0.1:4004"; - -const repositoryRoot = fileURLToPath(new URL("../../../..", import.meta.url)); -const 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. -const container = "petrinaut-opt-website-dev"; - -/** Splits the service flag off a dev task's arguments. */ -export const splitOptimizerServiceFlag = (cliArguments) => ({ - withService: cliArguments.includes(OPTIMIZER_SERVICE_FLAG), - forwarded: cliArguments.filter( - (argument) => argument !== OPTIMIZER_SERVICE_FLAG, - ), -}); - -/** The environment that points a dev server at the service. */ -export const optimizerServiceEnvironment = (env) => ({ - ...env, - PETRINAUT_OPT_ORIGIN: OPTIMIZER_SERVICE_ORIGIN, - VITE_PETRINAUT_OPT_PROVIDER: "service", -}); - -const wait = (durationMs) => - new Promise((resolve) => setTimeout(resolve, durationMs)); - -/** Runs a command to completion, rejecting on a non-zero exit. */ -export const runToCompletion = (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 capture = (command, args) => - new Promise((resolve, reject) => { - const child = spawn(command, args, { - cwd: repositoryRoot, - env: process.env, - stdio: ["ignore", "pipe", "ignore"], - }); - let output = ""; - child.stdout.on("data", (chunk) => { - output += chunk; - }); - child.once("error", reject); - child.once("exit", (code) => { - if (code === 0) { - resolve(output); - } else { - reject(new Error(`${command} exited with code ${code}`)); - } - }); - }); - -/** - * Runs a long-lived dev server, forwarding SIGINT and SIGTERM to it, and - * resolves with the exit code the process should report. - */ -export const runDevServer = (command, args, options) => - new Promise((resolve, reject) => { - const child = spawn(command, args, { - cwd: options.cwd, - env: options.env, - stdio: "inherit", - }); - const forward = (signal) => () => child.kill(signal); - const onSigint = forward("SIGINT"); - const onSigterm = forward("SIGTERM"); - process.on("SIGINT", onSigint); - process.on("SIGTERM", onSigterm); - const release = () => { - process.off("SIGINT", onSigint); - process.off("SIGTERM", onSigterm); - }; - child.once("error", (error) => { - release(); - reject(error); - }); - child.once("exit", (code, signal) => { - release(); - if (signal) { - resolve(signal === "SIGINT" ? 130 : 143); - } else { - resolve(code ?? 1); - } - }); - }); - -/** - * Removes stopped containers an earlier run 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. - */ -const removeLeftoverContainers = async () => { - const names = await capture("docker", [ - "ps", - "--all", - "--filter", - `name=^${container}(-[0-9]+)?$`, - "--filter", - "status=exited", - "--filter", - "status=created", - "--format", - "{{.Names}}", - ]).catch(() => ""); - for (const name of names.split("\n").filter(Boolean)) { - console.log(`Removing leftover Petrinaut Opt dev container ${name}...`); - await runToCompletion("docker", ["rm", "--force", name], { - stdio: "ignore", - }).catch(() => undefined); - } -}; - -const isOptimizerHealthy = async () => { - try { - const response = await fetch(`${OPTIMIZER_SERVICE_ORIGIN}/status`); - return response.ok; - } catch { - return false; - } -}; - -const waitForOptimizer = async () => { - for (let attempt = 0; attempt < 60; attempt += 1) { - if (await isOptimizerHealthy()) { - return; - } - await wait(500); - } - throw new Error("Petrinaut Opt did not become healthy within 30 seconds"); -}; - -/** - * Ensures an optimizer serves `OPTIMIZER_SERVICE_ORIGIN`, starting one when - * none does. `stop()` stops only the container this call started, by id, so - * two launchers never stop each other's. - */ -export const startOptimizerService = async () => { - await removeLeftoverContainers(); - - if (await isOptimizerHealthy()) { - console.log( - `Reusing the optimizer already serving on ${OPTIMIZER_SERVICE_ORIGIN}.`, - ); - return { origin: OPTIMIZER_SERVICE_ORIGIN, stop: async () => {} }; - } - - await runToCompletion("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 runToCompletion("docker", [ - "build", - "--file", - "apps/petrinaut-opt/docker/Dockerfile", - "--tag", - image, - ".", - ]); - - console.log(`Starting Petrinaut Opt on ${OPTIMIZER_SERVICE_ORIGIN}...`); - const containerId = ( - await capture("docker", [ - "run", - "--detach", - "--init", - "--read-only", - "--rm", - "--name", - container, - "--publish", - "127.0.0.1:4004:4004", - image, - ]) - ).trim(); - await waitForOptimizer(); - - return { - origin: OPTIMIZER_SERVICE_ORIGIN, - stop: () => - runToCompletion("docker", ["stop", "--timeout", "5", containerId], { - stdio: "ignore", - }).catch(() => undefined), - }; -}; - -/** - * Runs a dev server, with the optimizer service around it when the arguments - * carry the flag. Resolves with the exit code the process should report. - */ -export const runDevServerWithOptionalService = async ({ - cliArguments, - cwd, - prepare, - server, -}) => { - const { withService, forwarded } = splitOptimizerServiceFlag(cliArguments); - let service = null; - try { - if (withService) { - service = await startOptimizerService(); - } - const env = withService - ? optimizerServiceEnvironment(process.env) - : process.env; - if (prepare) { - await runToCompletion(prepare.command, prepare.args, { cwd, env }); - } - return await runDevServer(server.command, [...server.args, ...forwarded], { - cwd, - env, - }); - } catch (error) { - console.error(error instanceof Error ? error.message : error); - return 1; - } finally { - await service?.stop(); - } -}; 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" +}