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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/lazy-weighted-arc-enumeration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hashintel/petrinaut-core": patch
---

Weighted-arc token combinations enumerate lazily in the same lexicographic order, so a transition with a weight-2 coloured input arc no longer materialises every combination per frame. Trajectories are unchanged for every seed.
5 changes: 5 additions & 0 deletions .changeset/token-independent-lambda-fast-path.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hashintel/petrinaut-core": patch
---

Transitions whose lambda reads no input tokens skip combination enumeration and evaluate the lambda once against the first tokens in place order. Trajectories are unchanged for every seed.
4 changes: 2 additions & 2 deletions apps/petrinaut-website/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 1 addition & 2 deletions apps/petrinaut-website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@
"brunch:fixture": "node --experimental-strip-types scripts/brunch-sse-fixture.ts",
"build": "vite build",
"codegen": "node --experimental-strip-types scripts/generate-route-tree.ts",
"dev": "yarn examples:generate && vite",
"dev:optimization": "node scripts/optimization-dev.mjs",
"dev": "bash scripts/dev.sh",
"examples:generate": "node --experimental-strip-types scripts/generate-example-artifacts.ts",
"fix:eslint": "oxlint --fix --type-aware --report-unused-disable-directives-severity=error .",
"generate:icons": "node scripts/generate-favicons.mjs",
Expand Down
12 changes: 12 additions & 0 deletions apps/petrinaut-website/scripts/dev.sh
Original file line number Diff line number Diff line change
@@ -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[@]}"}
137 changes: 0 additions & 137 deletions apps/petrinaut-website/scripts/optimization-dev.mjs

This file was deleted.

87 changes: 72 additions & 15 deletions libs/@hashintel/petrinaut-core/benchmarks/coloured-enumeration.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,15 @@
* Measures how per-frame cost scales with token count for a transition whose
* coloured input arc has weight 2.
*
* `enumerateWeightedMarkingIndicesGenerator` materialises the full per-place
* combination list up front, so the expectation is O(C(n, 2)) = O(n^2) work and
* allocation per transition evaluation per frame.
* Three cases bound the enumeration cost. With a lambda that reads token
* attributes: a transition that never fires examines every combination each
* frame (O(C(n, 2)) lambda evaluations — irreducible), and one that always
* fires examines one. Lazy enumeration makes both allocation-free; the eager
* implementation it replaced also materialised the full C(n, 2) combination
* list per evaluation, which made even the always-fires case quadratic.
* With a token-independent lambda, the artifact carries
* `readsNoInputTokens` and the engine tests only the first combination, so
* even the never-fires case is O(1) per frame.
*/
import { performance } from "node:perf_hooks";

Expand Down Expand Up @@ -52,8 +58,10 @@ const sdcpn = {
outputArcs: [{ placeId: "sink", weight: 1 }],
lambdaType: "predicate",
// Never fires, so token counts stay constant and we measure pure
// enablement/enumeration cost at a fixed marking size.
lambdaCode: "export default Lambda(() => false);",
// enablement/enumeration cost at a fixed marking size. Reads a token
// attribute so the lambda is NOT token-independent — every combination
// must be examined.
lambdaCode: "export default Lambda((input) => input.Pool[0].v < 0);",
transitionKernelCode:
"export default TransitionKernel(() => ({ Sink: [{ v: 1 }] }));",
x: 50,
Expand All @@ -64,16 +72,43 @@ const sdcpn = {
parameters: [],
};

const artifacts = compileHirArtifacts(sdcpn).artifacts;
/**
* The always-fires variant: a self loop that consumes two pool tokens and
* produces two, so the marking size stays constant while the transition fires
* on the first combination every frame.
*/
const selfLoopSdcpn = {
...sdcpn,
transitions: [
{
...sdcpn.transitions[0],
inputArcs: [{ placeId: "pool", weight: 2, type: "standard" }],
outputArcs: [{ placeId: "pool", weight: 2 }],
lambdaCode: "export default Lambda((input) => input.Pool[0].v >= 0);",
transitionKernelCode:
"export default TransitionKernel(() => ({ Pool: [{ v: 1 }, { v: 2 }] }));",
},
],
};

process.stdout.write(
"coloured place, input arc weight 2, transition never fires\n" +
"tokens C(n,2) ns/run-frame\n",
);
/**
* A token-independent never-firing lambda: the compiler flags it, and the
* engine tests only the first combination — O(1) per frame regardless of
* token count.
*/
const tokenIndependentSdcpn = {
...sdcpn,
transitions: [
{
...sdcpn.transitions[0],
lambdaCode: "export default Lambda(() => false);",
},
],
};

for (const tokens of [10, 25, 50, 100, 200, 400]) {
function measure(net, artifacts, tokens) {
const simulator = createMonteCarloSimulator({
sdcpn,
sdcpn: net,
initialMarking: {
pool: Array.from({ length: tokens }, (_, index) => ({ v: index })),
sink: [],
Expand All @@ -95,10 +130,32 @@ for (const tokens of [10, 25, 50, 100, 200, 400]) {
for (const summary of simulator.getSummaries()) {
frames += summary.frameNumber;
}
return (ms / frames) * 1e6;
}

const combinations = (tokens * (tokens - 1)) / 2;
const cases = [
["token-reading lambda, never fires (examines every combination)", sdcpn],
[
"token-reading lambda, always fires (examines one combination)",
selfLoopSdcpn,
],
[
"token-independent lambda, never fires (first combination only)",
tokenIndependentSdcpn,
],
];

for (const [label, net] of cases) {
const artifacts = compileHirArtifacts(net).artifacts;
process.stdout.write(
`${String(tokens).padStart(6)} ${String(combinations).padStart(7)} ` +
`${((ms / frames) * 1e6).toFixed(0).padStart(12)}\n`,
`weight-2 coloured arc, ${label}\n` + "tokens C(n,2) ns/run-frame\n",
);
for (const tokens of [10, 25, 50, 100, 200, 400]) {
const combinations = (tokens * (tokens - 1)) / 2;
process.stdout.write(
`${String(tokens).padStart(6)} ${String(combinations).padStart(7)} ` +
`${measure(net, artifacts, tokens).toFixed(0).padStart(12)}\n`,
);
}
process.stdout.write("\n");
}
52 changes: 52 additions & 0 deletions libs/@hashintel/petrinaut-core/src/hir/artifacts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,3 +235,55 @@ describe("compileHirArtifacts", () => {
}
});
});

describe("readsNoInputTokens", () => {
function lambdaArtifactFor(lambdaCode: string) {
const { artifacts, failures } = compileHirArtifacts({
...sdcpn,
transitions: [{ ...sdcpn.transitions[0]!, lambdaCode }],
});
expect(failures).toEqual([]);
return artifacts.lambdas.ship!;
}

it("is absent when the lambda reads token attributes", () => {
expect(
lambdaArtifactFor(sdcpn.transitions[0]!.lambdaCode).readsNoInputTokens,
).toBeUndefined();
});

it("is set for a constant lambda", () => {
expect(
lambdaArtifactFor("export default Lambda(() => true);")
.readsNoInputTokens,
).toBe(true);
});

it("is set for a parameters-only lambda", () => {
expect(
lambdaArtifactFor(
"export default Lambda((input, parameters) => parameters.threshold > 1);",
).readsNoInputTokens,
).toBe(true);
});

it("is absent for a token-free lambda that draws randomness", () => {
// Skipping enumeration would evaluate the draw once per frame instead of
// once per combination, changing how often the transition fires.
expect(
lambdaArtifactFor("export default Lambda(() => Math.random() > 0.5);")
.readsNoInputTokens,
).toBeUndefined();
});

it("is absent for a lambda that reads only a token count", () => {
const inputPlace = sdcpn.places.find(
(place) => place.id === sdcpn.transitions[0]!.inputArcs[0]!.placeId,
)!;
expect(
lambdaArtifactFor(
`export default Lambda((input) => input.${inputPlace.name}.length > 1);`,
).readsNoInputTokens,
).toBeUndefined();
});
});
7 changes: 7 additions & 0 deletions libs/@hashintel/petrinaut-core/src/hir/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 } : {}),
};
}
}
Expand Down
Loading
Loading