diff --git a/.changeset/petrinaut-hir-compiler.md b/.changeset/petrinaut-hir-compiler.md new file mode 100644 index 00000000000..618c09c82aa --- /dev/null +++ b/.changeset/petrinaut-hir-compiler.md @@ -0,0 +1,10 @@ +--- +"@hashintel/petrinaut": patch +"@hashintel/petrinaut-core": patch +--- + +Compile all user code (dynamics, rates, kernels, metrics) through a new HIR +to programs reading the packed frame buffers directly, replacing Babel. + +Compatibility: code outside the supported TypeScript subset no longer runs — +it is rejected with an error diagnostic pointing at the offending syntax. diff --git a/libs/@hashintel/petrinaut-core/benches/bench.mjs b/libs/@hashintel/petrinaut-core/benches/bench.mjs new file mode 100644 index 00000000000..143e2ca13d3 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/benches/bench.mjs @@ -0,0 +1,505 @@ +import { writeFileSync } from "node:fs"; +/** + * Simple simulator + experiments benchmark, runnable on both the HIR branch + * and main (pre-HIR baseline) against the built package: + * + * yarn build && node --expose-gc benches/bench.mjs [--json out.json] + * + * Feature-detects the HIR: when `../dist/hir.js` exists, artifacts are + * compiled up-front and threaded (this branch); otherwise code strings are + * passed as-is (main's Babel path). Everything runs in-process through the + * public `createMonteCarloSimulator` API — no workers, so timings measure + * the engine itself. + * + * Per scenario it reports: build time (compile + simulator construction), + * run time (median of samples), heap growth, GC pauses, and a work checksum + * (rng state + frames + token bytes per run) that MUST match across branches + * — otherwise the comparison is measuring different work. + */ +import { performance, PerformanceObserver } from "node:perf_hooks"; + +const core = await import("../dist/index.js"); +const hir = await import("../dist/hir.js").catch(() => null); + +const argv = process.argv.slice(2); +const flag = (name) => argv.includes(name); +const arg = (name, fallback) => { + const index = argv.indexOf(name); + return index !== -1 && argv[index + 1] ? argv[index + 1] : fallback; +}; + +const SAMPLES = Number(arg("--samples", 5)); +const WARMUPS = flag("--no-warmup") ? 0 : 2; +const ONLY = arg("--only", null); + +// --------------------------------------------------------------------------- +// Model builders +// --------------------------------------------------------------------------- + +const color = (id, elements) => ({ + id, + name: id, + iconSlug: "circle", + displayColor: "#f00", + elements: elements.map(([name, type], index) => ({ + elementId: `${id}-e${index}`, + name, + type, + })), +}); + +const place = (id, colorId, overrides = {}) => ({ + id, + name: id, + colorId, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + ...overrides, +}); + +const baseNet = () => ({ + types: [], + places: [], + transitions: [], + differentialEquations: [], + parameters: [], + metrics: [], +}); + +function tokens(count, make) { + return Array.from({ length: count }, (_, index) => make(index)); +} + +/** 1 place x N tokens, oscillator ODE, no transitions. Dynamics-dominated. */ +function dynamicsScenario(tokenCount) { + const sdcpn = baseNet(); + sdcpn.types.push( + color("Particle", [ + ["x", "real"], + ["v", "real"], + ]), + ); + sdcpn.differentialEquations.push({ + id: "de1", + name: "Oscillator", + colorId: "Particle", + code: `export default Dynamics((tokens, parameters) => { + return tokens.map(({ x, v }) => ({ x: v, v: -0.5 * x })); +});`, + }); + sdcpn.places.push( + place("Field", "Particle", { + dynamicsEnabled: true, + differentialEquationId: "de1", + }), + place("Sink", null), + ); + // Structurally enabled but never-firing transition: without one, a net + // with no fireable transition deadlocks at frame 1 and dynamics never run. + sdcpn.transitions.push({ + id: "keepalive", + name: "keepalive", + lambdaType: "predicate", + inputArcs: [{ placeId: "Field", weight: 1, type: "read" }], + outputArcs: [{ placeId: "Sink", weight: 1 }], + lambdaCode: "export default Lambda((input, parameters) => false);", + transitionKernelCode: "", + x: 0, + y: 0, + }); + return { + name: `dynamics ${tokenCount} tokens x 200 steps`, + sdcpn, + initialMarking: { + Field: tokens(tokenCount, (index) => ({ + x: 1 + (index % 7) * 0.1, + v: 0, + })), + Sink: 0, + }, + dt: 0.1, + maxTime: 20, + runCount: 1, + }; +} + +/** N tokens, one rarely-firing token-dependent lambda: per step the engine + * enumerates ~N combinations and calls the rate per combination. */ +function lambdaScenario(tokenCount) { + const sdcpn = baseNet(); + sdcpn.types.push( + color("Item", [ + ["x", "real"], + ["priority", "real"], + ["active", "boolean"], + ]), + ); + sdcpn.places.push(place("Pool", "Item"), place("Done", null)); + sdcpn.transitions.push({ + id: "t1", + name: "Consume", + lambdaType: "stochastic", + inputArcs: [{ placeId: "Pool", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "Done", weight: 1 }], + lambdaCode: `export default Lambda((input, parameters) => { + const { x, priority, active } = input.Pool[0]; + if (!active) return 0; + return x > 100 ? 1 : priority * 0.000001; +});`, + transitionKernelCode: "", + x: 0, + y: 0, + }); + return { + name: `lambda ${tokenCount} combinations x 200 steps`, + sdcpn, + initialMarking: { + Pool: tokens(tokenCount, (index) => ({ + x: (index % 50) * 0.1, + priority: 0.5, + active: index % 2 === 0, + })), + Done: 0, + }, + dt: 0.1, + maxTime: 20, + runCount: 1, + }; +} + +/** Two always-firing transitions cycling tokens A<->B; kernels rewrite + * attributes with a distribution, a string and an integer. Kernel-dominated. */ +function kernelScenario(steps) { + const sdcpn = baseNet(); + sdcpn.types.push( + color("Job", [ + ["value", "real"], + ["even", "boolean"], + ["hops", "integer"], + ]), + ); + sdcpn.places.push(place("A", "Job"), place("B", "Job")); + const kernel = ( + from, + to, + ) => `export default TransitionKernel((input, parameters) => { + const noise = Distribution.Gaussian(0, 0.01); + return { + ${to}: [{ + value: noise.map((v) => input.${from}[0].value + v), + even: input.${from}[0].hops % 2 === 0, + hops: input.${from}[0].hops + 1, + }], + }; +});`; + const transition = (id, from, to) => ({ + id, + name: id, + lambdaType: "stochastic", + inputArcs: [{ placeId: from, weight: 1, type: "standard" }], + outputArcs: [{ placeId: to, weight: 1 }], + lambdaCode: `export default Lambda((input, parameters) => Infinity);`, + transitionKernelCode: kernel(from, to), + x: 0, + y: 0, + }); + sdcpn.transitions.push( + transition("ab", "A", "B"), + transition("ba", "B", "A"), + ); + return { + name: `kernel 2 firings x ${steps} steps`, + sdcpn, + initialMarking: { + A: tokens(40, (index) => ({ value: index, even: true, hops: 0 })), + B: tokens(40, (index) => ({ value: -index, even: false, hops: 1 })), + }, + dt: 0.1, + maxTime: steps * 0.1, + runCount: 1, + }; +} + +/** Monte-Carlo experiment: R runs with dynamics + 3 expression metrics + * evaluated every frame over a large place. Experiments/metrics-dominated. */ +function experimentScenario(runCount, tokenCount) { + const base = dynamicsScenario(tokenCount); + const metricSpecs = [ + { + kind: "expression", + id: "energy", + label: "energy", + code: `const field = state.places.Field.tokens; +return field.reduce((sum, t) => sum + t.x * t.x + t.v * t.v, 0);`, + }, + { + kind: "expression", + id: "mean-x", + label: "mean-x", + code: `const field = state.places.Field.tokens; +if (field.length === 0) return 0; +return field.reduce((sum, t) => sum + t.x, 0) / field.length;`, + }, + { + kind: "expression", + id: "count", + label: "count", + code: `return state.places.Field.count;`, + }, + ]; + return { + ...base, + name: `experiment ${runCount} runs x ${tokenCount} tokens x 100 steps, 3 metrics`, + maxTime: 10, + runCount, + metricSpecs, + }; +} + +const SCENARIOS = [ + dynamicsScenario(10_000), + lambdaScenario(1_000), + kernelScenario(2_000), + experimentScenario(Number(arg("--runs", 20)), 2_000), +]; + +// --------------------------------------------------------------------------- +// Harness +// --------------------------------------------------------------------------- + +function buildSimulator(scenario) { + const config = { + sdcpn: scenario.sdcpn, + initialMarking: scenario.initialMarking, + parameterValues: {}, + seed: 1234, + dt: scenario.dt, + maxTime: scenario.maxTime, + runCount: scenario.runCount, + }; + + let metricSpecs = scenario.metricSpecs; + if (hir) { + // This branch: user code must be precompiled to HIR artifacts. + const sdcpnForCompile = metricSpecs + ? { + ...scenario.sdcpn, + metrics: metricSpecs.map((spec) => ({ + id: spec.id, + name: spec.label, + code: spec.code, + })), + } + : scenario.sdcpn; + const { artifacts, failures } = hir.compileHirArtifacts(sdcpnForCompile); + if (failures.length > 0) { + throw new Error( + `HIR compile failures: ${JSON.stringify(failures, null, 2)}`, + ); + } + config.hirArtifacts = artifacts; + if (metricSpecs) { + metricSpecs = metricSpecs.map((spec) => ({ + ...spec, + artifact: artifacts.metrics[spec.id], + })); + } + } + + if (metricSpecs) { + const configs = core.createMonteCarloUserDefinedMetricConfigsFromSpecs( + metricSpecs, + scenario.sdcpn, + ); + config.metrics = configs.map((metricConfig) => + core.createMonteCarloUserDefinedMetric(metricConfig), + ); + } + + return core.createMonteCarloSimulator(config); +} + +function checksum(simulator) { + let acc = 0n; + for (const run of simulator.getSummaries()) { + acc = + acc * 31n + + BigInt(run.rngState) * 7n + + BigInt(run.frameNumber) * 3n + + BigInt(run.tokenValueCount ?? 0); + } + return acc.toString(16); +} + +function sample(scenario) { + globalThis.gc(); + const heapBefore = process.memoryUsage().heapUsed; + + let gcCount = 0; + let gcMs = 0; + const observer = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + gcCount += 1; + gcMs += entry.duration; + } + }); + observer.observe({ entryTypes: ["gc"] }); + + const buildStart = performance.now(); + const simulator = buildSimulator(scenario); + const buildEnd = performance.now(); + + const result = simulator.runUntilComplete({ maxBatches: 1_000_000 }); + const runEnd = performance.now(); + + observer.disconnect(); + const heapAfter = process.memoryUsage().heapUsed; + + if (result.erroredRuns > 0) { + const summary = simulator.getSummaries().find((run) => run.error); + throw new Error(`run errored: ${summary?.error}`); + } + + const digest = checksum(simulator); + // Forced GC with the simulator still alive: what the results actually + // retain, as opposed to heapDeltaMb which includes uncollected garbage. + globalThis.gc(); + const heapRetained = process.memoryUsage().heapUsed; + + return { + buildMs: buildEnd - buildStart, + runMs: runEnd - buildEnd, + heapDeltaMb: (heapAfter - heapBefore) / 1024 / 1024, + retainedMb: (heapRetained - heapBefore) / 1024 / 1024, + rssMb: process.memoryUsage().rss / 1024 / 1024, + gcCount, + gcMs, + checksum: digest, + }; +} + +function median(values) { + const sorted = [...values].sort((a, b) => a - b); + return sorted[Math.floor(sorted.length / 2)]; +} + +// --------------------------------------------------------------------------- +// Compile-time mode: isolate user-code compilation (no simulation). +// HIR branch: compileHirArtifacts (lower + typecheck + emit for every item). +// Baseline: Babel compileUserCode per item + compileMetric per metric. +// --------------------------------------------------------------------------- +if (flag("--compile")) { + const experiment = experimentScenario(1, 10); + const kernelNet = kernelScenario(1); + const lambdaNet = lambdaScenario(10); + const sdcpn = { + ...experiment.sdcpn, + types: [ + ...experiment.sdcpn.types, + ...lambdaNet.sdcpn.types, + ...kernelNet.sdcpn.types, + ], + places: [ + ...experiment.sdcpn.places, + ...lambdaNet.sdcpn.places, + ...kernelNet.sdcpn.places, + ], + transitions: [ + ...experiment.sdcpn.transitions, + ...lambdaNet.sdcpn.transitions, + ...kernelNet.sdcpn.transitions, + ], + differentialEquations: experiment.sdcpn.differentialEquations, + metrics: experiment.metricSpecs.map((spec) => ({ + id: spec.id, + name: spec.label, + code: spec.code, + })), + }; + // 1 dynamics + 4 lambdas + 2 kernels + 3 metrics = 10 compiled programs. + const compileOnce = hir + ? () => { + const { failures } = hir.compileHirArtifacts(sdcpn); + if (failures.length > 0) throw new Error(JSON.stringify(failures)); + } + : () => { + for (const de of sdcpn.differentialEquations) { + core.compileUserCode(de.code, "Dynamics"); + } + for (const transition of sdcpn.transitions) { + if (transition.lambdaCode.trim() !== "") { + core.compileUserCode(transition.lambdaCode, "Lambda"); + } + if (transition.transitionKernelCode.trim() !== "") { + core.compileUserCode( + transition.transitionKernelCode, + "TransitionKernel", + ); + } + } + for (const metric of sdcpn.metrics) { + const compiled = core.compileMetric(metric); + if (!compiled.ok) throw new Error(compiled.error); + } + }; + + const coldStart = performance.now(); + compileOnce(); + const coldMs = performance.now() - coldStart; + + const times = []; + for (let index = 0; index < 50; index += 1) { + const start = performance.now(); + compileOnce(); + times.push(performance.now() - start); + } + const sorted = [...times].sort((a, b) => a - b); + console.log( + `${hir ? "hir" : "baseline"} compile 10 programs: ` + + `cold ${coldMs.toFixed(1)}ms warm median ${sorted[25].toFixed(2)}ms ` + + `min ${sorted[0].toFixed(2)}ms`, + ); + process.exit(0); +} + +const results = []; +const selected = ONLY === null ? SCENARIOS : [SCENARIOS[Number(ONLY)]]; +for (const scenario of selected) { + for (let index = 0; index < WARMUPS; index += 1) { + sample(scenario); + } + const samples = []; + for (let index = 0; index < SAMPLES; index += 1) { + samples.push(sample(scenario)); + } + const row = { + scenario: scenario.name, + variant: hir ? "hir" : "baseline", + buildMs: median(samples.map((entry) => entry.buildMs)), + runMs: median(samples.map((entry) => entry.runMs)), + runMsMin: Math.min(...samples.map((entry) => entry.runMs)), + runMsMax: Math.max(...samples.map((entry) => entry.runMs)), + heapDeltaMb: median(samples.map((entry) => entry.heapDeltaMb)), + retainedMb: median(samples.map((entry) => entry.retainedMb)), + rssMb: median(samples.map((entry) => entry.rssMb)), + gcCount: median(samples.map((entry) => entry.gcCount)), + gcMs: median(samples.map((entry) => entry.gcMs)), + checksum: samples[0].checksum, + }; + results.push(row); + console.log( + `${row.variant} ${row.scenario}\n` + + ` build ${row.buildMs.toFixed(1)}ms run ${row.runMs.toFixed(1)}ms ` + + `(min ${row.runMsMin.toFixed(1)} max ${row.runMsMax.toFixed(1)}) ` + + `heap ${row.heapDeltaMb.toFixed(1)}MB retained ${row.retainedMb.toFixed(1)}MB ` + + `rss ${row.rssMb.toFixed(1)}MB gc ${row.gcCount}x/${row.gcMs.toFixed(1)}ms ` + + `checksum ${row.checksum}`, + ); +} + +const jsonFlag = process.argv.indexOf("--json"); +if (jsonFlag !== -1 && process.argv[jsonFlag + 1]) { + writeFileSync(process.argv[jsonFlag + 1], JSON.stringify(results, null, 2)); +} diff --git a/libs/@hashintel/petrinaut-core/package.json b/libs/@hashintel/petrinaut-core/package.json index d96ad8faef2..a2b43724781 100644 --- a/libs/@hashintel/petrinaut-core/package.json +++ b/libs/@hashintel/petrinaut-core/package.json @@ -34,6 +34,14 @@ "types": "./dist/examples/index.d.d.ts", "import": "./dist/examples/index.js" }, + "./hir": { + "types": "./dist/hir.d.d.ts", + "import": "./dist/hir.js" + }, + "./hir-runtime": { + "types": "./dist/hir-runtime.d.d.ts", + "import": "./dist/hir-runtime.js" + }, "./workers/lsp": { "types": "./dist/workers/lsp.d.d.ts", "import": "./dist/workers/lsp.js" @@ -60,7 +68,6 @@ "test:unit": "vitest" }, "dependencies": { - "@babel/standalone": "7.28.5", "elkjs": "0.11.0", "immer": "10.1.3", "uuid": "14.0.0", @@ -68,7 +75,6 @@ "zod": "4.4.3" }, "devDependencies": { - "@types/babel__standalone": "7.1.9", "@types/node": "22.18.13", "@typescript/native-preview": "7.0.0-dev.20260511.1", "oxlint": "1.63.0", @@ -78,5 +84,13 @@ "typescript": "5.9.3", "vite": "8.1.0", "vitest": "4.1.8" + }, + "peerDependencies": { + "typescript": ">=5.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } } } diff --git a/libs/@hashintel/petrinaut-core/src/actual-mode/timeline.ts b/libs/@hashintel/petrinaut-core/src/actual-mode/timeline.ts index 5b07559fcc3..7275d60740c 100644 --- a/libs/@hashintel/petrinaut-core/src/actual-mode/timeline.ts +++ b/libs/@hashintel/petrinaut-core/src/actual-mode/timeline.ts @@ -1,3 +1,8 @@ +import { + computeTokenSlotLayout, + createTokenRegionViews, + encodeTokenToBytes, +} from "../simulation/engine/token-layout"; import { ACTUAL_MODE_TIMELINE_TICK_MS } from "./constants"; import { getActualModeMarkingAtTransitionFiringIndex, @@ -7,6 +12,7 @@ import { import { parseActualModeTimestampMs } from "./time"; import type { + SimulationFrameRawView, SimulationFrameReader, SimulationFrameState, } from "../simulation/api"; @@ -187,12 +193,102 @@ export const createActualModeTimelineFrameReader = (params: { transitionFiringIndex: point.transitionFiringIndex, }); + // Packs the object marking into a format-v2 token buffer on demand so + // HIR-compiled expression metrics can run against actual-mode frames. + const createRawView = (): SimulationFrameRawView => { + const colorById = new Map( + definition.types.map((color) => [color.id, color]), + ); + const placeIndexById = new Map(); + const placeCounts = new Uint32Array(definition.places.length); + const placeOffsets = new Uint32Array(definition.places.length); + + // Minimal string pool: actual-mode tokens rarely carry strings, but the + // packed layout still needs interning when the colour declares them. + const strings: string[] = [""]; + const idsByString = new Map([["", 0]]); + const stringPool = { + intern(value: string): number { + let id = idsByString.get(value); + if (id === undefined) { + id = strings.length; + strings.push(value); + idsByString.set(value, id); + } + return id; + }, + get(id: number): string { + return strings[id] ?? ""; + }, + }; + + type PackedPlace = { + layout: ReturnType; + tokens: readonly Record[]; + byteOffset: number; + }; + const packedPlaces: PackedPlace[] = []; + let byteLength = 0; + + for (const [index, place] of definition.places.entries()) { + placeIndexById.set(place.id, index); + const placeMarking = marking[place.id]; + placeCounts[index] = getActualModePlaceMarkingTokenCount(placeMarking); + placeOffsets[index] = byteLength; + + const color = place.colorId ? colorById.get(place.colorId) : undefined; + if (!color || color.elements.length === 0) { + continue; + } + const layout = computeTokenSlotLayout(color.elements); + if (layout.strideBytes === 0) { + continue; + } + const tokens = isActualModeTokenColourArray(placeMarking) + ? placeMarking + : []; + // Reserve stride bytes per counted token even when attribute values are + // unavailable (count-only markings) so out-of-place reads stay + // in-bounds and decode to zeros. + byteLength += placeCounts[index]! * layout.strideBytes; + packedPlaces.push({ layout, tokens, byteOffset: placeOffsets[index]! }); + } + + const alignedLength = Math.ceil(byteLength / 8) * 8; + const views = createTokenRegionViews( + new ArrayBuffer(alignedLength), + 0, + alignedLength, + ); + for (const packed of packedPlaces) { + for (const [tokenIndex, token] of packed.tokens.entries()) { + views.u8.set( + encodeTokenToBytes(packed.layout, token, "actual-mode", stringPool), + packed.byteOffset + tokenIndex * packed.layout.strideBytes, + ); + } + } + + return { + ...views, + placeCounts, + placeOffsets, + placeIndexById, + stringPool, + }; + }; + let rawView: SimulationFrameRawView | null = null; + return { number, time: point.timeMs / 1_000, getPlaceTokenCount: (placeId: string) => getActualModePlaceMarkingTokenCount(marking[placeId]), - getPlaceTokens: (place: Place): Record[] => { + getRawView: () => { + rawView ??= createRawView(); + return rawView; + }, + getPlaceTokens: (place: Place) => { const placeMarking = marking[place.id]; if (!place.colorId || !isActualModeTokenColourArray(placeMarking)) { diff --git a/libs/@hashintel/petrinaut-core/src/examples/supply-chain-with-disruption.ts b/libs/@hashintel/petrinaut-core/src/examples/supply-chain-with-disruption.ts index 178b81fdcd7..25f214a80e9 100644 --- a/libs/@hashintel/petrinaut-core/src/examples/supply-chain-with-disruption.ts +++ b/libs/@hashintel/petrinaut-core/src/examples/supply-chain-with-disruption.ts @@ -1127,7 +1127,7 @@ export default TransitionKernel(() => ({}));`, { elementId: "order_priority", name: "priority", - type: "integer", + type: "real", }, { elementId: "order_promise", @@ -1193,10 +1193,9 @@ export default TransitionKernel(() => ({}));`, // reaches 0 the derivative becomes 0 (it holds, ready for arrival predicates). // All other attributes are constant during transit (derivative 0). export default Dynamics((tokens, parameters) => { - return tokens.map(({ eta, risk_score, source, cost }) => ({ + return tokens.map(({ eta }) => ({ eta: eta > 0 ? -1 : 0, risk_score: 0, - source: 0, cost: 0, })); });`, @@ -1208,7 +1207,7 @@ export default Dynamics((tokens, parameters) => { code: `// Every waiting order ages at a constant rate (age derivative = 1), driving // the backorder-conversion and cancellation hazards that depend on age. export default Dynamics((tokens, parameters) => { - return tokens.map(({ age, priority, promised_lead_time }) => ({ + return tokens.map(() => ({ age: 1, priority: 0, promised_lead_time: 0, diff --git a/libs/@hashintel/petrinaut-core/src/hir-runtime.ts b/libs/@hashintel/petrinaut-core/src/hir-runtime.ts new file mode 100644 index 00000000000..b3f0fba78f4 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/hir-runtime.ts @@ -0,0 +1,28 @@ +/** + * Runtime-only entry point for HIR-compiled artifacts. + * + * The simulation workers import from here — it instantiates precompiled + * buffer-program sources without pulling the TS→HIR compiler (and its + * `typescript` dependency) into worker bundles. The full pipeline lives in + * `./hir.ts`. + */ +export { + hirDistributionRuntime, + instantiateHirBufferDynamics, + instantiateHirBufferKernel, + instantiateHirBufferLambda, + instantiateHirMetric, + type HirArtifacts, + type HirCompiledBufferDynamics, + type HirCompiledBufferKernel, + type HirCompiledBufferLambda, + type HirCompiledMetric, + type HirDynamicsArtifact, + type HirKernelArtifact, + type HirKernelSink, + type HirLambdaArtifact, + type HirMetricArtifact, + type HirParameterValues, + type HirStringPool, + type HirStringPoolReader, +} from "./hir/instantiate"; diff --git a/libs/@hashintel/petrinaut-core/src/hir.ts b/libs/@hashintel/petrinaut-core/src/hir.ts new file mode 100644 index 00000000000..c6d026dcdba --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/hir.ts @@ -0,0 +1,93 @@ +/** + * Public entry point for the Petrinaut HIR (high-level intermediate + * representation) — see `hir/README.md` for the design document. + * + * The HIR pipeline: TypeScript user code → `lowerTypeScriptToHir` → + * analyses (`typecheckHir`, `analyzeHir`) / linting (`lintHirUserCode`) / + * compilation (`emit-js` via `tryCompileHir*`). + */ +export { + analyzeHir, + foldHir, + type DistributionDag, + type DistributionDagEdge, + type DistributionDagNode, + type DistributionSink, + type HirAnalysis, + type HirBindingInfo, + type HirDependencies, + type HirTokenRead, +} from "./hir/analyze"; +export { + compileHirArtifacts, + type HirCompileFailure, + type HirCompileResult, +} from "./hir/compile"; +export { + hirDistributionRuntime, + instantiateHirBufferDynamics, + instantiateHirBufferKernel, + instantiateHirBufferLambda, + instantiateHirMetric, + type HirArtifacts, + type HirCompiledBufferDynamics, + type HirCompiledBufferKernel, + type HirCompiledBufferLambda, + type HirCompiledMetric, + type HirDynamicsArtifact, + type HirKernelArtifact, + type HirKernelSink, + type HirLambdaArtifact, + type HirMetricArtifact, + type HirParameterValues, + type HirStringPool, + type HirStringPoolReader, +} from "./hir/instantiate"; +export { + emitBufferDynamicsJs, + emitBufferKernelJs, + emitBufferLambdaJs, + emitBufferMetricJs, + type BufferKernelProgram, + type BufferMetricProgram, + type BufferProgram, +} from "./hir/emit-buffer-js"; +export { emitUserFunctionJs } from "./hir/emit-js"; +export { + formatHirType, + hirChildren, + walkHir, + type HirDiagnostic, + type HirDiagnosticSeverity, + type HirExpr, + type HirFunction, + type HirNodeId, + type HirSurfaceKind, + type HirType, + type Span, +} from "./hir/hir"; +export { + lintHirUserCode, + type HirLintOptions, + type HirLintResult, +} from "./hir/lint"; +export { + lowerTypeScriptToHir, + type LowerTypeScriptResult, +} from "./hir/lower-typescript"; +export { + buildDynamicsContext, + buildKernelContext, + buildLambdaContext, + buildMetricContext, + type HirDynamicsContext, + type HirKernelContext, + type HirLambdaContext, + type HirMetricContext, + type HirMetricPlaceInfo, + type HirParameterInfo, + type HirPlaceBinding, + type HirSurfaceContext, + type HirTokenElementInfo, +} from "./hir/surface-context"; +export { typecheckHir, type HirTypecheckResult } from "./hir/typecheck"; diff --git a/libs/@hashintel/petrinaut-core/src/hir/BUFFER_ABI.md b/libs/@hashintel/petrinaut-core/src/hir/BUFFER_ABI.md new file mode 100644 index 00000000000..83f87d39a8a --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/hir/BUFFER_ABI.md @@ -0,0 +1,127 @@ +# HIR buffer ABI + +This is the contract between `emit-buffer-js.ts`, `instantiate.ts` and the +simulation engine. Keep this file current when changing packed token layout or +artifact metadata. + +## Shared token views + +All emitted programs read token bytes through aligned views over the same token +region: + +- `f64: Float64Array` +- `u64: BigUint64Array` +- `u8: Uint8Array` + +Color elements are packed by `engine/token-layout.ts`. + +| Element type | Storage | +| ------------ | ------------------------------------- | +| `real` | one f64 lane | +| `integer` | one f64 lane, rounded by value codecs | +| `boolean` | one u8 byte, `0` or `1` | +| `string` | one u64 string-pool id | +| `uuid` | two u64 lanes, low then high | + +Token strides are 8-byte aligned. + +## Transition inputs + +The emitter and engine use the same input slot order: + +1. colored input arcs only; +2. inhibitor arcs excluded; +3. arcs in declaration order; +4. one slot per selected token, repeated by arc weight. + +Runtime arguments: + +```ts +placeBases: Int32Array; // one byte offset per colored input arc +indices: Int32Array; // one selected token index per input token slot +``` + +An attribute read compiles to: + +```text +base = placeBases[arc] + indices[slot] * strideBytes +read view at base + fieldByteOffset +``` + +Dynamic indexes into transition input tokens are rejected. Static `.map(...)` +over transition token tuples is unrolled. + +## Transition outputs + +Kernel staging is a reusable `Uint8Array`. + +Order: + +1. colored output arcs in declaration order; +2. each arc's tokens in token order; +3. each token uses the output color's packed stride. + +Kernel signature: + +```ts +( + f64, u64, u8, + placeBases, indices, + outF64, outU64, outU8, + sink, +) => void +``` + +Scalar values are written inline. Values that consume engine state are deferred: + +```ts +sink("dist", u64OrF64Index, distribution); +sink("generate", u64Index, undefined); +sink("from", u64Index, value); +``` + +The sink is called in output arc, token and element declaration order so the +engine preserves deterministic RNG behavior. + +## Dynamics + +Dynamics run per colored place: + +```ts +(placeBytes: Uint8Array, numberOfTokens: number) => Float64Array; +``` + +The result is flat derivatives for real-valued fields only: + +```text +numberOfTokens * realFieldCount +``` + +The field order matches `TokenSlotLayout.realFieldF64Offsets`. + +## Metrics + +Metrics read a frame's raw token region and dense place metadata: + +```ts +(f64, u64, u8, placeCounts, placeOffsets) => number; +``` + +`HirMetricArtifact.placeNames` records places in first-reference order. At +instantiation, `__places[ordinal]` maps those names to frame place indexes. + +Metric token counts are dynamic, so metric `.reduce(...)` and `.concat(...)` +compile to loops over `placeCounts` and `placeOffsets`. + +## Artifact validation + +Artifacts are `version: 3`. + +The engine rejects stale artifacts by checking: + +- lambda `inputSlotCount`; +- kernel `inputSlotCount`; +- kernel `outputByteCount`; +- metric `placeNames` resolution. + +There is no runtime object fallback for missing or unsupported artifacts. diff --git a/libs/@hashintel/petrinaut-core/src/hir/HIR_REVIEW.md b/libs/@hashintel/petrinaut-core/src/hir/HIR_REVIEW.md new file mode 100644 index 00000000000..c8b7891c2cc --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/hir/HIR_REVIEW.md @@ -0,0 +1,230 @@ +# Petrinaut HIR review + +This note is a quick architectural review of the HIR pipeline after the +buffer-native simulator, packed token layout, string/UUID support and +Monte-Carlo expression metric work. + +## What the HIR does here + +Petrinaut accepts a restricted TypeScript surface for four user-code surfaces: + +- differential equations (`dynamics`) +- transition lambdas (`lambda`) +- transition kernels (`kernel`) +- Monte-Carlo expression metrics (`metric`) + +The HIR is the shared representation between that TypeScript surface and the +simulator runtime. It is typechecked by a separate `HirType` inference pass; the +nodes themselves carry ids and source spans, not inline type annotations. + +```text +TypeScript user code + -> lower-typescript.ts + -> hir.ts expression tree + -> typecheck.ts / analyze.ts / lint.ts + -> emit-buffer-js.ts + -> versioned HirArtifacts + -> instantiate.ts in the worker/runtime + -> packed frame buffers +``` + +The important design choice is that the simulator no longer compiles arbitrary +user TypeScript at runtime. `compileHirArtifacts` produces versioned, +JSON-serializable artifacts ahead of execution, and the engine instantiates +those artifacts without importing `typescript`. + +The emitted programs use the packed-buffer ABI: + +- inputs are shared `f64`, `u64` and `u8` views over token bytes; +- `placeBases` identifies the byte offset for each colored input arc; +- `indices` identifies the selected token for each input slot; +- kernels write directly into staging buffers; +- metrics read raw frame views (`placeCounts`, `placeOffsets`, token views); +- strings are stored by per-run string-pool id; +- UUIDs are split across 64-bit lanes and RNG-consuming UUID/distribution work + is deferred through the engine sink. + +## Does the design make sense? + +Yes. The shape fits Petrinaut's problem well. + +The simulator needs deterministic stochastic execution, editor diagnostics with +source ranges, fast repeated transition evaluation and worker bundles that do +not include the full TypeScript compiler. A small HIR gives those concerns a +single place to meet. + +The current HIR is deliberately pure, expression-oriented and mostly loop-free. +That is a good fit for transition logic, because the compiler can symbolically +evaluate structural values like token arrays and output records while emitting +direct scalar buffer reads and writes for the values that actually reach the +runtime. + +First-class distributions are also a strong domain choice. They let the compiler +see the stochastic graph, preserve shared-sample semantics, and defer seeded RNG +sampling to the engine in a deterministic order. + +The buffer ABI is the right direction for the simulator. It avoids repeated +decode/call/re-encode object work for every token combination, matches the +packed frame format, and leaves a plausible path to future Wasm or GPU-oriented +backends. + +## How standard is it? + +The broad compiler shape is standard: + +- parse/lower a source language into an intermediate representation; +- desugar or reject complex source constructs; +- typecheck and analyze the IR; +- emit a lower-level executable representation; +- instantiate that representation in the runtime. + +The name "HIR" is also standard compiler vocabulary. Rust's compiler, for +example, has a High-level IR that stays close to surface syntax while desugaring +some constructs, then lowers further to MIR for flow-sensitive checks and code +generation. + +What is custom here is not the idea of an IR, but the exact representation and +backend: + +- Petrinaut HIR is a bespoke JSON-friendly expression tree, not LLVM IR, MLIR, + SSA, bytecode or a control-flow graph. +- It is closer to a typechecked AST/ANF-style domain IR than to a low-level + compiler IR. +- That is reasonable because the accepted language subset is small, pure and + tightly coupled to SDCPN token semantics. + +If Petrinaut later grows general loops, mutation, recursion, early returns or +large user-defined helper functions, the current expression tree will start +feeling strained. At that point a second normalized IR with basic blocks, +temporaries and explicit control flow would become more conventional. + +## What is working well + +- The IR has stable node ids and source spans, so diagnostics can point back to + user code. +- `SurfaceContext` keeps model-derived facts out of the lowerer and makes the + typechecker/emitter aware of places, parameters, arc weights and token shapes. +- Artifacts are versioned and include runtime sanity metadata such as + `inputSlotCount`, `outputByteCount` and metric `placeNames`. +- The simulator validates artifact metadata before running, which catches stale + artifacts from a changed net. +- The runtime boundary is clean: workers instantiate emitted functions without + importing the TypeScript frontend. +- Tests cover example-model compilation, artifact versioning, stale metadata, + buffer execution, string/UUID token layout and HIR-backed expression metrics. + +## Main concerns + +### Documentation drift + +The HIR README is intentionally short, and the packed-buffer ABI now has a +single home in `BUFFER_ABI.md`. Keep those files current when changing artifact +metadata, token layout or runtime signatures; stale compiler docs make later +conflict resolution and optimization work risky. + +### ABI contract spread across files + +The slot/staging/frame layout contract is currently encoded in several places: + +- `surface-context.ts` +- `emit-buffer-js.ts` +- `instantiate.ts` +- `build-simulation.ts` +- `buffer-transition.ts` +- `token-layout.ts` +- frame reader/raw-view code + +That split is workable, but the ABI is important enough to deserve a single +short spec and perhaps shared assertion helpers. The spec should define arc +ordering, slot ordering, byte offsets, field lane types, output staging order, +string/UUID representation and metric place ordinal binding. + +### Lowerer is pragmatic, not semantic TypeScript + +`lower-typescript.ts` uses the TypeScript AST but mostly lowers syntactically. +That is fine for a deliberately small subset, but it means the HIR frontend is +not a full TypeScript semantic compiler. + +If the subset grows, either keep the language intentionally small and test every +accepted pattern, or use more TypeScript checker information during lowering so +aliases, literals, narrowed values and helper forms are resolved consistently. + +### One-shot lower diagnostics + +The lowerer often returns the first blocking error. That keeps implementation +simple, but editor experience would improve if unsupported syntax diagnostics +could be collected in one pass where practical. + +### Expression IR has a growth limit + +The pure expression design is right for today's transition logic. It is less +suited to: + +- arbitrary loops; +- early returns in nested blocks; +- mutable locals; +- data-dependent token production counts; +- nontrivial helper functions; +- optimizations that require dataflow over control-flow joins. + +If those become product requirements, introduce a normalized lower HIR/MIR +rather than adding special cases to the current emitter. + +### Runtime instantiation still uses generated JavaScript + +The runtime avoids the TypeScript compiler, but it still instantiates emitted +JavaScript through `new Function`. For trusted model code running in workers +that may be acceptable. If user code becomes security-sensitive, the isolation +story should be explicit, and a Wasm backend becomes more attractive. + +## Practical improvement list + +1. Keep `README.md`, `BUFFER_ABI.md` and nearby comments synchronized with + artifact/runtime changes. +2. Add more differential tests between a simple reference interpreter and the + buffer emitter for generated programs inside the accepted subset. +3. Add targeted span tests for metric wrapper offset shifting, including empty + input, CRLF and non-ASCII source. +4. Consider a small normalized-core HIR pass before emission so the emitter sees + fewer surface forms. +5. Make artifact compatibility more explicit: keep `version`, and consider + adding ABI feature flags or a content/layout hash when artifacts can be + cached beyond the current SDCPN snapshot. + +## Concepts and resources + +- Rust compiler HIR: a good reference for the "high-level IR close to surface + syntax, but more compiler-friendly" idea: + +- Rust compiler MIR: useful contrast for when a project outgrows expression + trees and needs explicit control flow, locals and rvalues: + +- GCC GIMPLE: an example of lowering a rich frontend tree into a simpler + restricted representation for analysis and optimization: + +- LLVM IR reference: the canonical typed low-level IR reference, useful for + understanding blocks, instructions, types and lowering targets: + +- MLIR rationale: useful for domain-specific compiler stacks, dialects and + multi-level lowering: + +- Cranelift: a pragmatic compiler backend project focused on translating + frontend IR into executable code: + +- WebAssembly core spec introduction: useful background if the buffer emitter + eventually targets a portable low-level backend instead of generated JS: + +- TypeScript Compiler API wiki: relevant to the current lowerer because it uses + TypeScript ASTs; note that the compiler API is not fully stable: + + +## Bottom line + +The HIR is a sensible and fairly standard compiler move for Petrinaut. The +custom parts are justified by the SDCPN runtime model: packed token frames, +deterministic stochastic semantics, source diagnostics and worker constraints. + +The highest-value next step is not a redesign. It is to keep tightening the +contract: maintain the ABI docs, expand golden/differential tests and keep the +accepted language subset deliberately small until there is a clear need for a +lower control-flow IR. diff --git a/libs/@hashintel/petrinaut-core/src/hir/README.md b/libs/@hashintel/petrinaut-core/src/hir/README.md new file mode 100644 index 00000000000..ce3a1b5b343 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/hir/README.md @@ -0,0 +1,111 @@ +# Petrinaut HIR + +The HIR is Petrinaut's source-spanned intermediate representation for +user-authored simulation code. HIR nodes do not carry inferred types inline; +`typecheck.ts` infers a separate `HirType` side table keyed by node id. The +pipeline is the only runtime path for dynamics, transition lambdas, transition +kernels and Monte-Carlo expression metrics. + +```text +TypeScript user code + -> lower-typescript.ts + -> hir.ts + -> typecheck.ts / analyze.ts / lint.ts + -> emit-buffer-js.ts + -> HirArtifacts version 3 + -> instantiate.ts + -> packed simulation buffers +``` + +`emit-js.ts` is retained as an object-convention reference/test emitter. The +simulator does not fall back to it; unsupported HIR shapes are compile errors. + +## Why HIR exists + +TypeScript is a good authoring surface, but the simulator needs a smaller +representation that can be checked, analyzed and emitted without carrying the +TypeScript compiler into workers. + +The HIR gives Petrinaut: + +- exact source ranges for diagnostics; +- schema-aware checks for parameters, places, token attributes and outputs; +- dependency and stochastic-distribution analysis; +- deterministic seeded RNG semantics for distributions and UUID generation; +- direct packed-buffer reads/writes instead of per-token object conversion. + +## Source subset + +Accepted code is deliberately small: `const` bindings, destructuring, +guard-style returns, ternaries, arithmetic/comparison/logic, `Math.*`, +parameter reads, token reads, `.length`, `.map`, metric `.reduce`/`.concat`, +record/array literals, string/UUID helpers, distributions and constants. + +Rejected code cannot run: loops, mutation, arbitrary helper calls, spread, +computed object keys, dynamic transition-token indexes and structurally dynamic +transition outputs. + +## Main modules + +| Module | Role | +| --------------------- | ------------------------------------------------------------------------- | +| `hir.ts` | JSON-friendly expression tree, node ids, spans and the `HirType` algebra. | +| `lower-typescript.ts` | Lowers the accepted TypeScript subset to HIR. | +| `surface-context.ts` | Builds model-derived facts for each surface. | +| `typecheck.ts` | Infers node types and checks HIR against the surface context. | +| `analyze.ts` | Computes dependencies, distribution DAGs and binding usage. | +| `lint.ts` | Converts semantic checks into editor diagnostics. | +| `emit-buffer-js.ts` | Emits packed-buffer JavaScript programs. | +| `instantiate.ts` | Instantiates artifacts without importing `typescript`. | +| `compile.ts` | Batch-compiles a root net and subnets to `HirArtifacts`. | + +## Runtime artifacts + +`compileHirArtifacts(sdcpn, extensions)` returns: + +```ts +{ + version: 3, + dynamics: Record, + lambdas: Record, + kernels: Record, + metrics: Record, +} +``` + +The engine validates artifact metadata before running. Missing or stale +artifacts produce per-item errors instead of falling back to runtime +compilation. + +## ABI + +The emitted programs use shared views over packed token bytes: + +- `f64`, `u64`, `u8` token-region views; +- `placeBases` and `indices` for transition input selections; +- output staging bytes for kernels; +- `placeCounts` and `placeOffsets` for metrics; +- a per-run string pool; +- engine-handled sinks for distributions and UUIDs. + +The precise runtime contract is in [`BUFFER_ABI.md`](./BUFFER_ABI.md). + +## Tests + +The HIR test suite covers: + +- every shipped example model compiling through the HIR; +- stable artifact metadata and representative emitted sources; +- buffer lambdas/kernels/dynamics/metrics against hand-packed buffers; +- stale artifact rejection; +- deterministic end-to-end simulation behavior. + +## Related notes + +- [`BUFFER_ABI.md`](./BUFFER_ABI.md): packed-buffer runtime contract. +- [`HIR_REVIEW.md`](./HIR_REVIEW.md): architecture review and concepts. +- [`dsl-sketch.md`](./dsl-sketch.md): possible future Petrinaut DSL frontend. diff --git a/libs/@hashintel/petrinaut-core/src/hir/analyze.test.ts b/libs/@hashintel/petrinaut-core/src/hir/analyze.test.ts new file mode 100644 index 00000000000..f44b9ab36f6 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/hir/analyze.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it } from "vitest"; + +import { analyzeHir, foldHir } from "./analyze"; +import { lowerTypeScriptToHir } from "./lower-typescript"; + +import type { HirFunction, HirSurfaceKind } from "./hir"; + +function lower(code: string, surface: HirSurfaceKind): HirFunction { + const result = lowerTypeScriptToHir(code, surface); + if (!result.ok) { + throw new Error(result.diagnostics[0]?.message); + } + return result.fn; +} + +describe("analyzeHir", () => { + describe("dependencies", () => { + it("collects parameter reads", () => { + const analysis = analyzeHir( + lower( + `export default Lambda((input, parameters) => parameters.beta * parameters.alpha + parameters.beta);`, + "lambda", + ), + ); + expect(analysis.dependencies.parameters).toEqual(["alpha", "beta"]); + expect(analysis.dependencies.isDeterministic).toBe(true); + }); + + it("reports a lambda that depends on no parameters", () => { + const analysis = analyzeHir( + lower(`export default Lambda((input, parameters) => 1.5);`, "lambda"), + ); + expect(analysis.dependencies.parameters).toEqual([]); + expect(analysis.dependencies.tokenReads).toEqual([]); + }); + + it("attributes token reads to places for lambdas", () => { + const analysis = analyzeHir( + lower( + `export default Lambda((input, parameters) => input.Pool[0].x + input["Other Place"][1].y);`, + "lambda", + ), + ); + expect(analysis.dependencies.tokenReads).toEqual([ + { place: "Pool", field: "x" }, + { place: "Other Place", field: "y" }, + ]); + }); + + it("attributes token reads through .map callbacks (dynamics)", () => { + const analysis = analyzeHir( + lower( + `export default Dynamics((tokens, parameters) => tokens.map(({ x, y }) => ({ x: y * parameters.g, y: x })));`, + "dynamics", + ), + ); + expect(analysis.dependencies.tokenReads).toEqual([ + { place: "self", field: "y" }, + { place: "self", field: "x" }, + ]); + expect(analysis.dependencies.parameters).toEqual(["g"]); + }); + + it("tracks token count reads and Math.random", () => { + const analysis = analyzeHir( + lower( + `export default Lambda((input) => input.Pool.length * Math.random());`, + "lambda", + ), + ); + expect(analysis.dependencies.readsTokenCounts).toBe(true); + expect(analysis.dependencies.usesMathRandom).toBe(true); + expect(analysis.dependencies.isDeterministic).toBe(false); + }); + }); + + describe("distribution DAG", () => { + it("extracts nodes, edges and kernel output sinks", () => { + const analysis = analyzeHir( + lower( + `export default TransitionKernel((input, parameters) => { + const base = Distribution.Gaussian(0, parameters.sigma); + const scaled = base.map((value) => value * 10); + return { Out: [{ x: scaled, y: 1 }] }; +});`, + "kernel", + ), + ); + const { nodes, edges, sinks } = analysis.distributionDag; + expect(nodes).toHaveLength(2); + + const gaussian = nodes.find((node) => node.kind === "gaussian")!; + const mapped = nodes.find((node) => node.kind === "mapped")!; + expect(gaussian.bindingName).toBe("base"); + expect(gaussian.dependsOnParameters).toEqual(["sigma"]); + expect(mapped.bindingName).toBe("scaled"); + expect(edges).toEqual([{ from: gaussian.nodeId, to: mapped.nodeId }]); + expect(sinks).toEqual([ + { nodeId: mapped.nodeId, place: "Out", tokenIndex: 0, field: "x" }, + ]); + }); + + it("records constant arguments", () => { + const analysis = analyzeHir( + lower( + `export default TransitionKernel((input) => ({ + Out: [{ x: Distribution.Uniform(0, 2 * 5) }], +}));`, + "kernel", + ), + ); + expect(analysis.distributionDag.nodes[0]!.constantArgs).toEqual([0, 10]); + }); + + it("flags shared samples feeding several output slots", () => { + const analysis = analyzeHir( + lower( + `export default TransitionKernel((input) => { + const d = Distribution.Gaussian(0, 1); + return { Out: [{ x: d, y: d }] }; +});`, + "kernel", + ), + ); + const nodeId = analysis.distributionDag.nodes[0]!.nodeId; + expect(analysis.distributionDag.sinks).toHaveLength(2); + expect(analysis.distributionDag.sharedSampleNodeIds).toEqual([nodeId]); + }); + + it("marks per-iteration distributions and dynamic sinks", () => { + const analysis = analyzeHir( + lower( + `export default TransitionKernel((input) => ({ + Out: input.In.map((token) => ({ x: Distribution.Gaussian(token.x, 1) })), +}));`, + "kernel", + ), + ); + const node = analysis.distributionDag.nodes[0]!; + expect(node.perIteration).toBe(true); + expect(node.dependsOnTokens).toEqual([{ place: "In", field: "x" }]); + expect(analysis.distributionDag.sinks).toEqual([ + { + nodeId: node.nodeId, + place: "Out", + tokenIndex: "dynamic", + field: "x", + }, + ]); + expect(analysis.distributionDag.sharedSampleNodeIds).toEqual([]); + }); + + it("tracks distributions through conditionals", () => { + const analysis = analyzeHir( + lower( + `export default TransitionKernel((input, parameters) => { + const a = Distribution.Gaussian(0, 1); + const b = Distribution.Uniform(0, 1); + return { Out: [{ x: parameters.flag ? a : b }] }; +});`, + "kernel", + ), + ); + expect(analysis.distributionDag.sinks).toHaveLength(2); + }); + }); + + describe("bindings", () => { + it("counts references and finds unused bindings", () => { + const analysis = analyzeHir( + lower( + `export default Lambda((input, parameters) => { + const used = parameters.a; + const unused = parameters.b; + return used * 2; +});`, + "lambda", + ), + ); + const used = analysis.bindings.find( + (binding) => binding.name === "used", + )!; + const unused = analysis.bindings.find( + (binding) => binding.name === "unused", + )!; + expect(used.referenceCount).toBe(1); + expect(unused.referenceCount).toBe(0); + }); + }); +}); + +describe("foldHir", () => { + it("folds constant arithmetic, conditionals and Math calls", () => { + const fn = lower( + `export default Lambda((input) => 1 + 2 * 3 > 5 ? Math.sqrt(16) : 0);`, + "lambda", + ); + const folded = foldHir(fn.body); + expect(folded).toMatchObject({ kind: "numberLit", value: 4 }); + }); + + it("keeps non-constant parts intact", () => { + const fn = lower( + `export default Lambda((input, parameters) => parameters.rate * (2 + 3));`, + "lambda", + ); + const folded = foldHir(fn.body); + expect(folded).toMatchObject({ + kind: "binary", + op: "*", + left: { kind: "paramRef" }, + right: { kind: "numberLit", value: 5 }, + }); + }); + + it("never folds Math.random", () => { + const fn = lower( + `export default Lambda((input) => Math.random());`, + "lambda", + ); + expect(foldHir(fn.body).kind).toBe("mathCall"); + }); +}); diff --git a/libs/@hashintel/petrinaut-core/src/hir/analyze.ts b/libs/@hashintel/petrinaut-core/src/hir/analyze.ts new file mode 100644 index 00000000000..478a0eadb6b --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/hir/analyze.ts @@ -0,0 +1,825 @@ +/** + * Static analyses over the HIR. + * + * All analyses share one abstract interpreter: because the HIR is pure, + * non-recursive and loop-free (only structural `map`), a single symbolic + * evaluation of the body is enough to answer: + * + * - which model parameters and token attributes the code depends on, + * - which probability distributions it constructs, how they derive from one + * another (the distribution DAG), and into which output slots their samples + * flow, + * - which distributions share a single draw (the runtime caches a + * distribution object's first sample, so one binding feeding two output + * fields yields identical values), + * - which `const` bindings are never used. + */ +import type { + HirDistributionKind, + HirExpr, + HirFunction, + HirNodeId, + Span, +} from "./hir"; + +/** + * The place a token read refers to: `"self"` for dynamics (the place the + * equation is attached to), otherwise the place display name. The `& {}` + * keeps the `"self"` literal from being absorbed into `string`. + */ +export type HirTokenReadPlace = "self" | (string & {}); + +/** A token attribute read. */ +export type HirTokenRead = { + place: HirTokenReadPlace; + field: string; +}; + +export type HirDependencies = { + /** Model parameters read via `parameters.` (sorted, unique). */ + parameters: string[]; + /** Token attributes read (unique by place+field). */ + tokenReads: HirTokenRead[]; + /** Whether the code reads token counts (`.length`). */ + readsTokenCounts: boolean; + /** Whether the code constructs probability distributions. */ + samplesDistributions: boolean; + /** Whether the code calls `Math.random()` (breaks reproducibility). */ + usesMathRandom: boolean; + /** Whether the code auto-generates UUIDs (consumes the seeded RNG). */ + generatesUuids: boolean; + /** True when the result is a pure function of (tokens, parameters). */ + isDeterministic: boolean; +}; + +export type DistributionDagNode = { + /** HIR node id of the `distribution` / `distributionMap` node. */ + nodeId: HirNodeId; + kind: HirDistributionKind | "mapped"; + span: Span; + /** Name of the `const` binding holding this distribution, if any. */ + bindingName?: string; + /** True when constructed inside a `.map(...)` over tokens — a fresh + * distribution (and draw) is created per token. */ + perIteration: boolean; + /** Argument values when they fold to constants, e.g. `[0, 10]`. */ + constantArgs?: number[]; + /** Parameters feeding the arguments (or the map body). */ + dependsOnParameters: string[]; + /** Token attributes feeding the arguments (or the map body). */ + dependsOnTokens: HirTokenRead[]; +}; + +/** Derivation edge: `to` is `from.map(...)`. */ +export type DistributionDagEdge = { + from: HirNodeId; + to: HirNodeId; +}; + +/** Where a distribution's sample lands in a kernel's output. */ +export type DistributionSink = { + nodeId: HirNodeId; + place: string; + /** Token position within the place's output array, or `"dynamic"` when the + * token is produced by a `.map(...)`. */ + tokenIndex: number | "dynamic"; + field: string; +}; + +export type DistributionDag = { + nodes: DistributionDagNode[]; + edges: DistributionDagEdge[]; + sinks: DistributionSink[]; + /** Nodes whose single draw feeds more than one output slot. */ + sharedSampleNodeIds: HirNodeId[]; +}; + +export type HirBindingInfo = { + name: string; + nameSpan: Span; + referenceCount: number; +}; + +export type HirAnalysis = { + dependencies: HirDependencies; + distributionDag: DistributionDag; + bindings: HirBindingInfo[]; +}; + +// --------------------------------------------------------------------------- +// Abstract values +// --------------------------------------------------------------------------- + +type AbstractValue = + /** An opaque scalar (number/boolean). */ + | { kind: "scalar" } + /** The lambda/kernel input object (`tokensByPlace`). */ + | { kind: "inputRecord" } + /** A token array belonging to a place. */ + | { kind: "tokens"; place: HirTokenReadPlace } + /** A single token of a place. */ + | { kind: "token"; place: HirTokenReadPlace } + | { kind: "record"; fields: Map } + /** `elements` is null for arrays of statically-unknown shape (map results + * carry the per-element value in `element`). */ + | { + kind: "array"; + elements: AbstractValue[] | null; + element?: AbstractValue; + } + | { kind: "dist"; nodeId: HirNodeId } + /** Either of several values (from conditionals). */ + | { kind: "union"; values: AbstractValue[] }; + +const SCALAR: AbstractValue = { kind: "scalar" }; + +type BindingRecord = { + name: string; + nameSpan: Span; + referenceCount: number; +}; + +type DepSink = { + parameters: Set; + tokenReads: Map; +}; + +function createDepSink(): DepSink { + return { parameters: new Set(), tokenReads: new Map() }; +} + +// --------------------------------------------------------------------------- +// Constant folding +// --------------------------------------------------------------------------- + +function constantValue(name: "PI" | "E" | "Infinity" | "NaN"): number { + switch (name) { + case "PI": + return Math.PI; + case "E": + return Math.E; + case "Infinity": + return Infinity; + case "NaN": + return NaN; + } +} + +function literalValue(expr: HirExpr): number | boolean | string | undefined { + if (expr.kind === "numberLit") { + return expr.value; + } + if (expr.kind === "boolLit") { + return expr.value; + } + if (expr.kind === "stringLit") { + return expr.value; + } + if (expr.kind === "constant") { + return constantValue(expr.name); + } + return undefined; +} + +function numberNode( + original: HirExpr, + value: number, +): Extract { + return { + kind: "numberLit", + value, + raw: String(value), + id: original.id, + span: original.span, + }; +} + +function foldBinary( + op: Extract["op"], + left: number | boolean | string, + right: number | boolean | string, +): number | boolean | undefined { + // Strings only participate in equality folding. + if (typeof left === "string" || typeof right === "string") { + if (op === "==") { + return left === right; + } + if (op === "!=") { + return left !== right; + } + return undefined; + } + switch (op) { + case "+": + return typeof left === "number" && typeof right === "number" + ? left + right + : undefined; + case "-": + return Number(left) - Number(right); + case "*": + return Number(left) * Number(right); + case "/": + return Number(left) / Number(right); + case "%": + return Number(left) % Number(right); + case "**": + return Number(left) ** Number(right); + case "<": + return Number(left) < Number(right); + case "<=": + return Number(left) <= Number(right); + case ">": + return Number(left) > Number(right); + case ">=": + return Number(left) >= Number(right); + case "==": + return left === right; + case "!=": + return left !== right; + case "&&": + return typeof left === "boolean" && typeof right === "boolean" + ? left && right + : undefined; + case "||": + return typeof left === "boolean" && typeof right === "boolean" + ? left || right + : undefined; + } +} + +/** + * Folds constant subexpressions. Folded nodes keep the id and span of the + * expression they replace, so diagnostics and analyses remain anchored. + * `Math.random()` and distributions are never folded. + */ +export function foldHir(expr: HirExpr): HirExpr { + switch (expr.kind) { + case "numberLit": + case "boolLit": + case "stringLit": + case "uuidGenerate": + case "constant": + case "localRef": + case "paramRef": + return expr; + case "uuidFrom": + return { ...expr, operand: foldHir(expr.operand) }; + case "stringCall": + return { + ...expr, + target: foldHir(expr.target), + argument: foldHir(expr.argument), + }; + case "unary": { + const operand = foldHir(expr.operand); + const value = literalValue(operand); + if (value !== undefined) { + if (expr.op === "!" && typeof value === "boolean") { + return { ...expr, kind: "boolLit", value: !value } as HirExpr; + } + if (typeof value === "number") { + if (expr.op === "-") { + return numberNode(expr, -value); + } + if (expr.op === "+") { + return numberNode(expr, value); + } + } + } + return { ...expr, operand }; + } + case "binary": { + const left = foldHir(expr.left); + const right = foldHir(expr.right); + const leftValue = literalValue(left); + const rightValue = literalValue(right); + if (leftValue !== undefined && rightValue !== undefined) { + const folded = foldBinary(expr.op, leftValue, rightValue); + if (folded !== undefined) { + return typeof folded === "boolean" + ? ({ ...expr, kind: "boolLit", value: folded } as HirExpr) + : numberNode(expr, folded); + } + } + return { ...expr, left, right }; + } + case "cond": { + const condition = foldHir(expr.condition); + if (condition.kind === "boolLit") { + return condition.value + ? foldHir(expr.thenBranch) + : foldHir(expr.elseBranch); + } + return { + ...expr, + condition, + thenBranch: foldHir(expr.thenBranch), + elseBranch: foldHir(expr.elseBranch), + }; + } + case "let": { + return { + ...expr, + bindings: expr.bindings.map((binding) => ({ + ...binding, + value: foldHir(binding.value), + })), + body: foldHir(expr.body), + }; + } + case "mathCall": { + const args = expr.args.map(foldHir); + if (expr.fn !== "random") { + const values = args.map(literalValue); + if (values.every((value) => typeof value === "number")) { + const mathFn = Math[expr.fn] as (...values: number[]) => number; + return numberNode(expr, mathFn(...(values as number[]))); + } + } + return { ...expr, args }; + } + case "fieldAccess": + return { ...expr, target: foldHir(expr.target) }; + case "indexAccess": + return { + ...expr, + target: foldHir(expr.target), + index: foldHir(expr.index), + }; + case "length": + return { ...expr, target: foldHir(expr.target) }; + case "recordLit": + return { + ...expr, + entries: expr.entries.map((entry) => ({ + ...entry, + value: foldHir(entry.value), + })), + }; + case "arrayLit": + return { ...expr, elements: expr.elements.map(foldHir) }; + case "arrayMap": + return { + ...expr, + target: foldHir(expr.target), + body: foldHir(expr.body), + }; + case "arrayReduce": + // Never folded away — only the subexpressions are folded. + return { + ...expr, + target: foldHir(expr.target), + body: foldHir(expr.body), + initial: foldHir(expr.initial), + }; + case "arrayConcat": + return { ...expr, left: foldHir(expr.left), right: foldHir(expr.right) }; + case "distribution": + return { ...expr, args: expr.args.map(foldHir) }; + case "distributionMap": + return { ...expr, base: foldHir(expr.base), body: foldHir(expr.body) }; + } +} + +function constantArgsOf(args: HirExpr[]): number[] | undefined { + const values: number[] = []; + for (const argument of args) { + const folded = foldHir(argument); + if (folded.kind === "numberLit") { + values.push(folded.value); + } else if (folded.kind === "constant") { + values.push(constantValue(folded.name)); + } else { + return undefined; + } + } + return values; +} + +class Analyzer { + readonly globalDeps = createDepSink(); + readonly depSinkStack: DepSink[] = []; + readonly dagNodes = new Map(); + readonly dagEdges: DistributionDagEdge[] = []; + readonly bindings: BindingRecord[] = []; + readsTokenCounts = false; + usesMathRandom = false; + generatesUuids = false; + /** Depth of `.map(...)` iteration during evaluation. */ + private mapDepth = 0; + + constructor(private readonly fn: HirFunction) {} + + private recordParameter(name: string): void { + this.globalDeps.parameters.add(name); + for (const sink of this.depSinkStack) { + sink.parameters.add(name); + } + } + + private recordTokenRead(read: HirTokenRead): void { + const key = `${read.place}\u0000${read.field}`; + this.globalDeps.tokenReads.set(key, read); + for (const sink of this.depSinkStack) { + sink.tokenReads.set(key, read); + } + } + + private withDepSink(run: () => Result): [Result, DepSink] { + const sink = createDepSink(); + this.depSinkStack.push(sink); + try { + return [run(), sink]; + } finally { + this.depSinkStack.pop(); + } + } + + evaluate(): AbstractValue { + const env = new Map< + string, + { value: AbstractValue; binding?: BindingRecord } + >(); + const tokensParam = this.fn.params[0]; + if (tokensParam) { + env.set(tokensParam.name, { + value: + this.fn.surface === "dynamics" + ? { kind: "tokens", place: "self" } + : { kind: "inputRecord" }, + }); + } + return this.evalExpr(this.fn.body, env); + } + + private evalExpr( + expr: HirExpr, + env: Map, + ): AbstractValue { + switch (expr.kind) { + case "numberLit": + case "boolLit": + case "stringLit": + case "constant": + return SCALAR; + case "uuidGenerate": + this.generatesUuids = true; + return SCALAR; + case "uuidFrom": + this.evalExpr(expr.operand, env); + return SCALAR; + case "stringCall": + this.evalExpr(expr.target, env); + this.evalExpr(expr.argument, env); + return SCALAR; + case "localRef": { + const entry = env.get(expr.name); + if (entry) { + if (entry.binding) { + entry.binding.referenceCount += 1; + } + return entry.value; + } + return SCALAR; + } + case "paramRef": + this.recordParameter(expr.name); + return SCALAR; + case "fieldAccess": { + const target = this.evalExpr(expr.target, env); + return this.accessField(target, expr.field); + } + case "indexAccess": { + const target = this.evalExpr(expr.target, env); + this.evalExpr(expr.index, env); + return this.accessIndex(target, expr.index); + } + case "length": { + const target = this.evalExpr(expr.target, env); + if (target.kind === "tokens" || target.kind === "inputRecord") { + this.readsTokenCounts = true; + } + return SCALAR; + } + case "unary": + this.evalExpr(expr.operand, env); + return SCALAR; + case "binary": + this.evalExpr(expr.left, env); + this.evalExpr(expr.right, env); + return SCALAR; + case "cond": { + this.evalExpr(expr.condition, env); + const thenValue = this.evalExpr(expr.thenBranch, env); + const elseValue = this.evalExpr(expr.elseBranch, env); + if (thenValue === elseValue) { + return thenValue; + } + return { kind: "union", values: [thenValue, elseValue] }; + } + case "let": { + const scoped = new Map(env); + for (const bindingExpr of expr.bindings) { + const binding: BindingRecord = { + name: bindingExpr.name, + nameSpan: bindingExpr.nameSpan, + referenceCount: 0, + }; + this.bindings.push(binding); + const value = this.evalExpr(bindingExpr.value, scoped); + this.nameDistribution(value, bindingExpr.name); + scoped.set(bindingExpr.name, { value, binding }); + } + return this.evalExpr(expr.body, scoped); + } + case "mathCall": { + if (expr.fn === "random") { + this.usesMathRandom = true; + } + for (const argument of expr.args) { + this.evalExpr(argument, env); + } + return SCALAR; + } + case "recordLit": { + const fields = new Map(); + for (const entry of expr.entries) { + fields.set(entry.key, this.evalExpr(entry.value, env)); + } + return { kind: "record", fields }; + } + case "arrayLit": + return { + kind: "array", + elements: expr.elements.map((element) => this.evalExpr(element, env)), + }; + case "arrayMap": { + const target = this.evalExpr(expr.target, env); + const scoped = new Map(env); + scoped.set(expr.param.name, { value: this.elementOf(target) }); + if (expr.indexParam) { + scoped.set(expr.indexParam.name, { value: SCALAR }); + } + this.mapDepth += 1; + const element = this.evalExpr(expr.body, scoped); + this.mapDepth -= 1; + return { kind: "array", elements: null, element }; + } + case "arrayReduce": { + const target = this.evalExpr(expr.target, env); + const initial = this.evalExpr(expr.initial, env); + const scoped = new Map(env); + // The body is evaluated once: the accumulator is seeded with the + // initial value and the element with the target's element (so token + // reads inside the body are attributed like `arrayMap`). + scoped.set(expr.accParam.name, { value: initial }); + scoped.set(expr.param.name, { value: this.elementOf(target) }); + if (expr.indexParam) { + scoped.set(expr.indexParam.name, { value: SCALAR }); + } + this.mapDepth += 1; + const body = this.evalExpr(expr.body, scoped); + this.mapDepth -= 1; + if (body === initial) { + return body; + } + return { kind: "union", values: [initial, body] }; + } + case "arrayConcat": { + const left = this.evalExpr(expr.left, env); + const right = this.evalExpr(expr.right, env); + return { + kind: "array", + elements: null, + element: { + kind: "union", + values: [this.elementOf(left), this.elementOf(right)], + }, + }; + } + case "distribution": { + const [, argDeps] = this.withDepSink(() => { + for (const argument of expr.args) { + this.evalExpr(argument, env); + } + }); + const node: DistributionDagNode = { + nodeId: expr.id, + kind: expr.dist, + span: expr.span, + perIteration: this.mapDepth > 0, + constantArgs: constantArgsOf(expr.args), + dependsOnParameters: [...argDeps.parameters].sort(), + dependsOnTokens: [...argDeps.tokenReads.values()], + }; + this.dagNodes.set(expr.id, node); + return { kind: "dist", nodeId: expr.id }; + } + case "distributionMap": { + const base = this.evalExpr(expr.base, env); + const [, bodyDeps] = this.withDepSink(() => { + const scoped = new Map(env); + scoped.set(expr.param.name, { value: SCALAR }); + this.evalExpr(expr.body, scoped); + }); + const node: DistributionDagNode = { + nodeId: expr.id, + kind: "mapped", + span: expr.span, + perIteration: this.mapDepth > 0, + dependsOnParameters: [...bodyDeps.parameters].sort(), + dependsOnTokens: [...bodyDeps.tokenReads.values()], + }; + this.dagNodes.set(expr.id, node); + for (const baseNodeId of this.distributionNodeIds(base)) { + this.dagEdges.push({ from: baseNodeId, to: expr.id }); + } + return { kind: "dist", nodeId: expr.id }; + } + } + } + + private accessField(target: AbstractValue, field: string): AbstractValue { + switch (target.kind) { + case "inputRecord": + return { kind: "tokens", place: field }; + case "token": + this.recordTokenRead({ place: target.place, field }); + return SCALAR; + case "record": + return target.fields.get(field) ?? SCALAR; + case "union": + return { + kind: "union", + values: target.values.map((value) => this.accessField(value, field)), + }; + default: + return SCALAR; + } + } + + private accessIndex(target: AbstractValue, _index: HirExpr): AbstractValue { + switch (target.kind) { + case "tokens": + return { kind: "token", place: target.place }; + case "array": + if (target.elements) { + // Index may be dynamic; the result may be any element. + return target.elements.length === 1 + ? target.elements[0]! + : { kind: "union", values: target.elements }; + } + return target.element ?? SCALAR; + case "union": + return { + kind: "union", + values: target.values.map((value) => this.accessIndex(value, _index)), + }; + default: + return SCALAR; + } + } + + private elementOf(target: AbstractValue): AbstractValue { + switch (target.kind) { + case "tokens": + return { kind: "token", place: target.place }; + case "array": + if (target.elements) { + return target.elements.length === 1 + ? target.elements[0]! + : { kind: "union", values: target.elements }; + } + return target.element ?? SCALAR; + default: + return SCALAR; + } + } + + private nameDistribution(value: AbstractValue, name: string): void { + for (const nodeId of this.distributionNodeIds(value)) { + const node = this.dagNodes.get(nodeId); + if (node && node.bindingName === undefined) { + node.bindingName = name; + } + } + } + + private distributionNodeIds(value: AbstractValue): HirNodeId[] { + switch (value.kind) { + case "dist": + return [value.nodeId]; + case "union": + return value.values.flatMap((inner) => this.distributionNodeIds(inner)); + default: + return []; + } + } + + collectSinks(result: AbstractValue): DistributionSink[] { + if (this.fn.surface !== "kernel") { + return []; + } + const sinks: DistributionSink[] = []; + const visitToken = ( + token: AbstractValue, + place: string, + tokenIndex: number | "dynamic", + ): void => { + if (token.kind === "record") { + for (const [field, fieldValue] of token.fields) { + for (const nodeId of this.distributionNodeIds(fieldValue)) { + sinks.push({ nodeId, place, tokenIndex, field }); + } + } + } else if (token.kind === "union") { + for (const inner of token.values) { + visitToken(inner, place, tokenIndex); + } + } + }; + const visitPlaceValue = (value: AbstractValue, place: string): void => { + if (value.kind === "array") { + if (value.elements) { + for (const [index, token] of value.elements.entries()) { + visitToken(token, place, index); + } + } else if (value.element) { + visitToken(value.element, place, "dynamic"); + } + } else if (value.kind === "union") { + for (const inner of value.values) { + visitPlaceValue(inner, place); + } + } + }; + const visitOutput = (value: AbstractValue): void => { + if (value.kind === "record") { + for (const [place, placeValue] of value.fields) { + visitPlaceValue(placeValue, place); + } + } else if (value.kind === "union") { + for (const inner of value.values) { + visitOutput(inner); + } + } + }; + visitOutput(result); + return sinks; + } +} + +/** Runs all analyses over a lowered function. */ +export function analyzeHir(fn: HirFunction): HirAnalysis { + const analyzer = new Analyzer(fn); + const result = analyzer.evaluate(); + const sinks = analyzer.collectSinks(result); + + const sinkCountByNode = new Map(); + for (const sink of sinks) { + sinkCountByNode.set( + sink.nodeId, + (sinkCountByNode.get(sink.nodeId) ?? 0) + 1, + ); + } + const sharedSampleNodeIds = [...sinkCountByNode.entries()] + .filter(([nodeId, count]) => { + const node = analyzer.dagNodes.get(nodeId); + // A per-iteration distribution is fresh per token; sharing across a + // dynamic token array is not sharing a draw. Sharing within one token + // record still is, but we cannot distinguish that statically here, so + // stay conservative and only flag non-iterated nodes. + return count > 1 && node && !node.perIteration; + }) + .map(([nodeId]) => nodeId); + + const dagNodes = [...analyzer.dagNodes.values()]; + + const dependencies: HirDependencies = { + parameters: [...analyzer.globalDeps.parameters].sort(), + tokenReads: [...analyzer.globalDeps.tokenReads.values()], + readsTokenCounts: analyzer.readsTokenCounts, + samplesDistributions: dagNodes.length > 0, + usesMathRandom: analyzer.usesMathRandom, + generatesUuids: analyzer.generatesUuids, + isDeterministic: + dagNodes.length === 0 && + !analyzer.usesMathRandom && + !analyzer.generatesUuids, + }; + + return { + dependencies, + distributionDag: { + nodes: dagNodes, + edges: analyzer.dagEdges, + sinks, + sharedSampleNodeIds, + }, + bindings: analyzer.bindings.map((binding) => ({ + name: binding.name, + nameSpan: binding.nameSpan, + referenceCount: binding.referenceCount, + })), + }; +} diff --git a/libs/@hashintel/petrinaut-core/src/hir/artifacts.test.ts b/libs/@hashintel/petrinaut-core/src/hir/artifacts.test.ts new file mode 100644 index 00000000000..0342341a7b2 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/hir/artifacts.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, it } from "vitest"; + +import { StringPool } from "../simulation/engine/string-pool"; +import { + computeTokenSlotLayout, + createTokenRegionViews, + encodeTokenToBytes, +} from "../simulation/engine/token-layout"; +import { compileHirArtifacts } from "./compile"; +import { emitUserFunctionJs } from "./emit-js"; +import { + hirDistributionRuntime, + instantiateHirBufferLambda, +} from "./instantiate"; +import { lowerTypeScriptToHir } from "./lower-typescript"; + +import type { SDCPN, TokenRecord } from "../types/sdcpn"; + +const sdcpn = { + types: [ + { + id: "order", + name: "Order", + iconSlug: "circle", + displayColor: "#00FF00", + elements: [ + { elementId: "x", name: "x", type: "real" }, + { elementId: "active", name: "active", type: "boolean" }, + { elementId: "status", name: "status", type: "string" }, + ], + }, + ], + places: [ + { + id: "source", + name: "Source", + colorId: "order", + dynamicsEnabled: true, + differentialEquationId: "dyn", + x: 0, + y: 0, + }, + { + id: "target", + name: "Target", + colorId: "order", + dynamicsEnabled: false, + differentialEquationId: null, + x: 100, + y: 0, + }, + ], + transitions: [ + { + id: "ship", + name: "Ship", + inputArcs: [{ placeId: "source", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "target", weight: 1 }], + lambdaType: "predicate", + lambdaCode: `export default Lambda((input, parameters) => + input.Source[0].active && input.Source[0].x >= parameters.threshold +);`, + transitionKernelCode: `export default TransitionKernel((input, parameters) => ({ + Target: [{ + x: input.Source[0].x + parameters.bump, + active: false, + status: "done", + }], +}));`, + x: 50, + y: 0, + }, + ], + differentialEquations: [ + { + id: "dyn", + name: "Drift", + colorId: "order", + code: `export default Dynamics((tokens, parameters) => + tokens.map(({ x, active }) => ({ + x: active ? parameters.speed * x : 0, + })) +);`, + }, + ], + parameters: [ + { + id: "threshold", + name: "Threshold", + variableName: "threshold", + type: "real", + defaultValue: "2", + }, + { + id: "bump", + name: "Bump", + variableName: "bump", + type: "real", + defaultValue: "1", + }, + { + id: "speed", + name: "Speed", + variableName: "speed", + type: "real", + defaultValue: "0.5", + }, + ], + metrics: [ + { + id: "done-count", + name: "Done count", + code: `return state.places.Target.tokens.reduce( + (count, token) => token.status === "done" ? count + 1 : count, + 0, +);`, + }, + ], +} satisfies SDCPN; + +const orderElements = sdcpn.types[0]!.elements; +const orderLayout = computeTokenSlotLayout(orderElements); + +function compile() { + const { artifacts, failures } = compileHirArtifacts(sdcpn); + expect(failures).toEqual([]); + return artifacts; +} + +function compileObjectLambda() { + const lowered = lowerTypeScriptToHir( + sdcpn.transitions[0]!.lambdaCode, + "lambda", + ); + if (!lowered.ok) { + throw new Error(lowered.diagnostics[0]?.message); + } + const source = emitUserFunctionJs(lowered.fn); + // eslint-disable-next-line no-new-func, @typescript-eslint/no-implied-eval, @typescript-eslint/no-unsafe-call + return new Function("__dist", `"use strict"; return (${source});`)( + hirDistributionRuntime, + ) as ( + input: { Source: TokenRecord[] }, + parameters: { threshold: number }, + ) => unknown; +} + +function packToken(token: TokenRecord, pool: StringPool) { + const bytes = encodeTokenToBytes(orderLayout, token, "test", pool); + return createTokenRegionViews(bytes.buffer, 0, bytes.byteLength); +} + +describe("compileHirArtifacts", () => { + it("emits stable v3 artifact shapes", () => { + const artifacts = compile(); + + expect({ + version: artifacts.version, + dynamics: artifacts.dynamics.dyn, + lambda: artifacts.lambdas.ship, + kernel: artifacts.kernels.ship, + metric: artifacts.metrics["done-count"], + }).toMatchInlineSnapshot(` + { + "dynamics": { + "source": "(placeBytes, numberOfTokens) => { + "use strict"; + const f64 = new Float64Array(placeBytes.buffer, placeBytes.byteOffset, placeBytes.byteLength >> 3); + const u64 = new BigUint64Array(placeBytes.buffer, placeBytes.byteOffset, placeBytes.byteLength >> 3); + const u8 = placeBytes; + const out = new Float64Array(numberOfTokens * 1); + for (let __i = 0; __i < numberOfTokens; __i++) { + const __b = __i * 24; + out[__i * 1 + 0] = ((u8[__b + 16] !== 0) ? (__params["speed"] * f64[(__b) >> 3]) : 0); + } + return out; + }", + }, + "kernel": { + "inputSlotCount": 1, + "outputByteCount": 24, + "source": "(f64, u64, u8, placeBases, indices, outF64, outU64, outU8, __sink) => { + outF64[0] = (f64[(placeBases[0] + indices[0] * 24) >> 3] + __params["bump"]); + outU8[16] = (false) ? 1 : 0; + outU64[1] = BigInt(__pool.intern("done")); + }", + }, + "lambda": { + "inputSlotCount": 1, + "source": "(f64, u64, u8, placeBases, indices) => { + return ((u8[placeBases[0] + indices[0] * 24 + 16] !== 0) && (f64[(placeBases[0] + indices[0] * 24) >> 3] >= __params["threshold"])); + }", + }, + "metric": { + "placeNames": [ + "Target", + ], + "source": "(f64, u64, u8, placeCounts, placeOffsets) => { + let count = 0; + { const __n = placeCounts[__places[0]]; const __b = placeOffsets[__places[0]]; + for (let __i = 0; __i < __n; __i++) { + count = ((__pool.get(Number(u64[(__b + __i * 24 + 8) >> 3])) === "done") ? (count + 1) : count); + } + } + return count; + }", + }, + "version": 3, + } + `); + }); + + it("matches the object reference emitter for a representative lambda", () => { + const artifacts = compile(); + const pool = new StringPool(); + const parameters = { threshold: 2 }; + const reference = compileObjectLambda(); + const buffer = instantiateHirBufferLambda( + artifacts.lambdas.ship!.source, + parameters, + pool, + ); + const placeBases = new Int32Array([0]); + const indices = new Int32Array([0]); + + for (const token of [ + { x: 3, active: true, status: "queued" }, + { x: 1, active: true, status: "queued" }, + { x: 4, active: false, status: "queued" }, + ]) { + const views = packToken(token, pool); + expect(buffer(views.f64, views.u64, views.u8, placeBases, indices)).toBe( + reference({ Source: [token] }, parameters), + ); + } + }); +}); diff --git a/libs/@hashintel/petrinaut-core/src/hir/compile.test.ts b/libs/@hashintel/petrinaut-core/src/hir/compile.test.ts new file mode 100644 index 00000000000..005ad3175de --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/hir/compile.test.ts @@ -0,0 +1,144 @@ +/** + * Coverage gate: every example model must compile fully through the HIR — + * there is no fallback compiler anymore, so any lowering failure here means + * a shipped model cannot simulate. + */ +import { describe, expect, it } from "vitest"; + +import { + deploymentPipelineSDCPN, + probabilisticSatellitesSDCPN, + productionMachines, + sirModel, + supplyChainWithDisruption, +} from "../examples/index"; +import { + DEFAULT_PETRINAUT_EXTENSIONS, + getTransitionLogicAvailability, +} from "../extensions"; +import { buildSimulation } from "../simulation/engine/build-simulation"; +import { compileHirArtifacts } from "./compile"; + +import type { SDCPN } from "../types/sdcpn"; + +const EXAMPLES: [string, SDCPN][] = [ + ["production-with-machine-failure", productionMachines.petriNetDefinition], + ["deployment-pipeline", deploymentPipelineSDCPN.petriNetDefinition], + ["satellites-launcher", probabilisticSatellitesSDCPN.petriNetDefinition], + ["sir-model", sirModel.petriNetDefinition], + [ + "supply-chain-with-disruption", + supplyChainWithDisruption.petriNetDefinition, + ], +]; + +describe("compileHirArtifacts on example models", () => { + it.each(EXAMPLES)("compiles every item of %s", (_name, sdcpn) => { + const { failures } = compileHirArtifacts(sdcpn); + expect( + failures.map((failure) => ({ + item: `${failure.itemType}:${failure.itemId}`, + message: failure.diagnostics[0]?.message, + })), + ).toEqual([]); + }); + + it.each(EXAMPLES)( + "compiles every lambda, kernel and dynamics of %s to the buffer ABI", + (_name, sdcpn) => { + const { artifacts, failures } = compileHirArtifacts(sdcpn); + expect(failures).toEqual([]); + expect(artifacts.version).toBe(3); + + const colorById = new Map( + [ + ...sdcpn.types, + ...(sdcpn.subnets ?? []).flatMap((subnet) => subnet.types), + ].map((color) => [color.id, color]), + ); + + const missing: string[] = []; + const nets = [ + { net: sdcpn, subnet: null }, + ...(sdcpn.subnets ?? []).map((subnet) => ({ net: subnet, subnet })), + ]; + for (const { net, subnet } of nets) { + const differentialEquations = subnet + ? subnet.differentialEquations + : sdcpn.differentialEquations; + const transitions = subnet ? subnet.transitions : sdcpn.transitions; + + for (const de of differentialEquations) { + const color = de.colorId ? colorById.get(de.colorId) : undefined; + if ( + color?.elements.some((element) => element.type === "real") && + !artifacts.dynamics[de.id] + ) { + missing.push(`dynamics:${de.id}`); + } + } + for (const transition of transitions) { + const availability = getTransitionLogicAvailability( + transition, + sdcpn, + DEFAULT_PETRINAUT_EXTENSIONS, + net, + ); + if ( + availability.lambda && + transition.lambdaCode.trim() !== "" && + !artifacts.lambdas[transition.id] + ) { + missing.push(`lambda:${transition.id}`); + } + if ( + availability.transitionKernel && + !artifacts.kernels[transition.id] + ) { + missing.push(`kernel:${transition.id}`); + } + } + } + // Every declared metric (they use reduce/concat/guard ifs) must + // compile to a buffer metric program — there is no other metric path. + for (const metric of sdcpn.metrics ?? []) { + if (!artifacts.metrics[metric.id]) { + missing.push(`metric:${metric.id}`); + } + } + expect(missing).toEqual([]); + + // Artifacts carry the buffer-ABI metadata the engine validates against. + for (const artifact of Object.values(artifacts.lambdas)) { + expect(typeof artifact.source).toBe("string"); + expect(typeof artifact.inputSlotCount).toBe("number"); + } + for (const artifact of Object.values(artifacts.kernels)) { + expect(typeof artifact.source).toBe("string"); + expect(typeof artifact.inputSlotCount).toBe("number"); + expect(typeof artifact.outputByteCount).toBe("number"); + } + for (const artifact of Object.values(artifacts.dynamics)) { + expect(typeof artifact.source).toBe("string"); + } + for (const artifact of Object.values(artifacts.metrics)) { + expect(typeof artifact.source).toBe("string"); + expect(Array.isArray(artifact.placeNames)).toBe(true); + } + }, + ); + + it.each(EXAMPLES)("builds a runnable simulation for %s", (_name, sdcpn) => { + const { artifacts } = compileHirArtifacts(sdcpn); + const simulation = buildSimulation({ + sdcpn, + initialMarking: {}, + parameterValues: {}, + seed: 1, + dt: 0.1, + maxTime: 1, + hirArtifacts: artifacts, + }); + expect(simulation.compiledTransitions.size).toBeGreaterThan(0); + }); +}); diff --git a/libs/@hashintel/petrinaut-core/src/hir/compile.ts b/libs/@hashintel/petrinaut-core/src/hir/compile.ts new file mode 100644 index 00000000000..1dd414ef199 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/hir/compile.ts @@ -0,0 +1,276 @@ +/** + * Compilation of user code through the HIR pipeline. + * + * `compileHirArtifacts` batch-compiles a whole SDCPN (root net and all + * subnets) into serializable artifact sources (`HirArtifacts`) — the only + * runtime compilation path. Per item it produces: + * + * - a buffer-ABI program (`emit-buffer-js.ts`) when the code typechecks cleanly + * and scalarizes to direct packed-buffer reads/writes. + * + * Items whose code cannot be lowered get no artifact and are reported in + * `failures`; `buildSimulation` refuses to run them (the LSP shows the same + * diagnostics with exact source ranges). + * + * Note: this module (transitively) imports `typescript` — keep it out of the + * simulation worker bundles; those only need `instantiate.ts`. + */ +import { + DEFAULT_PETRINAUT_EXTENSIONS, + getTransitionLogicAvailability, + sanitizeSDCPNForExtensions, + type PetrinautExtensionSettings, +} from "../extensions"; +import { + emitBufferDynamicsJs, + emitBufferKernelJs, + emitBufferLambdaJs, + emitBufferMetricJs, +} from "./emit-buffer-js"; +import { lowerTypeScriptToHir } from "./lower-typescript"; +import { + buildDynamicsContext, + buildKernelContext, + buildLambdaContext, + buildMetricContext, +} from "./surface-context"; +import { typecheckHir } from "./typecheck"; + +import type { SDCPN, Subnet } from "../types/sdcpn"; +import type { HirDiagnostic, HirFunction, HirSurfaceKind } from "./hir"; +import type { HirArtifacts } from "./instantiate"; +import type { HirNetScope, HirSurfaceContext } from "./surface-context"; + +export type HirCompileFailure = { + itemId: string; + itemType: + | "differential-equation" + | "transition-lambda" + | "transition-kernel" + | "metric"; + diagnostics: HirDiagnostic[]; +}; + +export type HirCompileResult = { + artifacts: HirArtifacts; + /** Items whose code could not be lowered (no artifact emitted). */ + failures: HirCompileFailure[]; +}; + +function notCompilableDiagnostic(fn: HirFunction): HirDiagnostic { + return { + code: "hir:not-compilable", + message: + "This code shape cannot be compiled to a buffer program (e.g. dynamic token indices, structurally-dynamic results). Restructure it as static token records / `.map(...)` over input tokens.", + severity: "error", + span: fn.body.span, + }; +} + +type ItemResult = + | { ok: true; fn: HirFunction } + | { ok: false; diagnostics: HirDiagnostic[] }; + +function lowerAndCheck( + code: string, + surface: HirSurfaceKind, + context: HirSurfaceContext | null, +): ItemResult { + const lowered = lowerTypeScriptToHir(code, surface); + if (!lowered.ok) { + return { ok: false, diagnostics: lowered.diagnostics }; + } + if (context) { + const checked = typecheckHir(lowered.fn, context); + const errors = checked.diagnostics.filter( + (diagnostic) => diagnostic.severity === "error", + ); + if (errors.length > 0) { + return { ok: false, diagnostics: errors }; + } + } + return { ok: true, fn: lowered.fn }; +} + +/** + * Batch-compiles all dynamics/lambda/kernel code of an SDCPN (root and + * subnets) to buffer programs — the simulator's only compilation path. + * Items whose code cannot be lowered, fails the schema typecheck, or does + * not scalarize to a buffer program are reported in `failures` (mirrored by + * the LSP as error diagnostics); such items cannot simulate. + */ +export function compileHirArtifacts( + sdcpn: SDCPN, + extensions: PetrinautExtensionSettings = DEFAULT_PETRINAUT_EXTENSIONS, +): HirCompileResult { + const sanitized = sanitizeSDCPNForExtensions(sdcpn, extensions); + const artifacts: HirArtifacts = { + version: 3, + dynamics: {}, + lambdas: {}, + kernels: {}, + metrics: {}, + }; + const failures: HirCompileFailure[] = []; + + const colorById = new Map( + [ + ...sanitized.types, + ...(sanitized.subnets ?? []).flatMap((subnet) => subnet.types), + ].map((color) => [color.id, color]), + ); + + const nets: { net: HirNetScope; subnet: Subnet | null }[] = [ + { net: sanitized, subnet: null }, + ...(sanitized.subnets ?? []).map((subnet) => ({ net: subnet, subnet })), + ]; + + for (const { net, subnet } of nets) { + const differentialEquations = subnet + ? subnet.differentialEquations + : sanitized.differentialEquations; + const transitions = subnet ? subnet.transitions : sanitized.transitions; + + for (const de of differentialEquations) { + const color = de.colorId ? colorById.get(de.colorId) : undefined; + if ( + !color || + !color.elements.some((element) => element.type === "real") + ) { + continue; + } + const context = de.colorId + ? buildDynamicsContext(sanitized, de.colorId, extensions, net) + : null; + const item = lowerAndCheck(de.code, "dynamics", context); + if (!item.ok) { + failures.push({ + itemId: de.id, + itemType: "differential-equation", + diagnostics: item.diagnostics, + }); + continue; + } + const source = emitBufferDynamicsJs(item.fn, color.elements); + if (source === null) { + failures.push({ + itemId: de.id, + itemType: "differential-equation", + diagnostics: [notCompilableDiagnostic(item.fn)], + }); + continue; + } + artifacts.dynamics[de.id] = { source }; + } + + for (const transition of transitions) { + const availability = getTransitionLogicAvailability( + transition, + sanitized, + extensions, + net, + ); + + if (availability.lambda && transition.lambdaCode.trim() !== "") { + const context = buildLambdaContext( + sanitized, + transition, + extensions, + net, + ); + const item = lowerAndCheck(transition.lambdaCode, "lambda", context); + if (!item.ok) { + failures.push({ + itemId: transition.id, + itemType: "transition-lambda", + diagnostics: item.diagnostics, + }); + } else { + const program = emitBufferLambdaJs(item.fn, context); + if (program === null) { + failures.push({ + itemId: transition.id, + itemType: "transition-lambda", + diagnostics: [notCompilableDiagnostic(item.fn)], + }); + } else { + artifacts.lambdas[transition.id] = { + source: program.source, + inputSlotCount: program.inputSlotCount, + }; + } + } + } + + if (availability.transitionKernel) { + const context = buildKernelContext( + sanitized, + transition, + extensions, + net, + ); + const item = lowerAndCheck( + transition.transitionKernelCode, + "kernel", + context, + ); + if (!item.ok) { + failures.push({ + itemId: transition.id, + itemType: "transition-kernel", + diagnostics: item.diagnostics, + }); + } else { + const program = emitBufferKernelJs(item.fn, context); + if (program === null) { + failures.push({ + itemId: transition.id, + itemType: "transition-kernel", + diagnostics: [notCompilableDiagnostic(item.fn)], + }); + } else { + artifacts.kernels[transition.id] = { + source: program.source, + inputSlotCount: program.inputSlotCount, + outputByteCount: program.outputByteCount, + }; + } + } + } + } + } + + // Metrics live on the root net only and see every root place by name. + const metricContext = buildMetricContext(sanitized, extensions); + for (const metric of sanitized.metrics ?? []) { + if (metric.code.trim() === "") { + // Empty drafts get no artifact; running them reports a missing-artifact + // error, but they must not block the rest of the net from compiling. + continue; + } + const item = lowerAndCheck(metric.code, "metric", metricContext); + if (!item.ok) { + failures.push({ + itemId: metric.id, + itemType: "metric", + diagnostics: item.diagnostics, + }); + continue; + } + const program = emitBufferMetricJs(item.fn, metricContext); + if (program === null) { + failures.push({ + itemId: metric.id, + itemType: "metric", + diagnostics: [notCompilableDiagnostic(item.fn)], + }); + continue; + } + artifacts.metrics[metric.id] = { + source: program.source, + placeNames: program.placeNames, + }; + } + + return { artifacts, failures }; +} diff --git a/libs/@hashintel/petrinaut-core/src/hir/dsl-sketch.md b/libs/@hashintel/petrinaut-core/src/hir/dsl-sketch.md new file mode 100644 index 00000000000..32c47a43d1a --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/hir/dsl-sketch.md @@ -0,0 +1,231 @@ +# Petrinaut DSL — design sketch + +A domain-specific language for Petrinaut user code (dynamics, lambdas, +kernels, metrics, scenario expressions), designed to **lower to the same HIR +as TypeScript** ([README](./README.md)). Users could switch a model — or a +single item — from TypeScript to the DSL without changing semantics, because +both frontends meet at the HIR. + +Status: design sketch only; nothing here is implemented. + +## Design goals + +1. **Analyzable by construction.** The TS frontend must reject code outside + the HIR subset; the DSL simply cannot express anything outside it. Every + valid DSL program lowers, so the runtime never falls back and every model + gets the distribution DAG, dependency info and optimized compilation. +2. **Expression-oriented, OCaml-flavoured.** `let … in` bindings, `if/then/else` + as an expression, no statements, no mutation. +3. **Distributions are syntax.** `~Gaussian(0, sigma)` is a literal, visibly + distinct from a number — the stochastic structure of a kernel is readable + at a glance and trivially extractable. +4. **Less ceremony than the TS surface.** No + `export default TransitionKernel((input, parameters) => …)` wrapper; the + editor pane _is_ the function body, and the model context (places, + parameters, attributes) is ambient. +5. **LSP-native.** Hand-written parser with error recovery producing a + partial tree — diagnostics, completions and hovers must work on incomplete + code while typing. + +## Surface examples + +Dynamics (per-token derivative; `'` marks a derivative binding — only real +attributes admit one): + +``` +per { x, v } -> + x' = v + v' = -params.k * x +``` + +Lambda — stochastic rate (`Pool` is an input place, `count Pool` its token +count; `inf` = always fire): + +``` +let pressure = count Pool * params.rate in +if pressure > 10 then inf else pressure +``` + +Lambda — predicate: + +``` +Pool[0].active && Pool[0].x >= params.threshold +``` + +Transition kernel (`~` introduces a distribution; `|>` maps over it; +output places are record labels checked against the transition's output arcs): + +``` +let noise = ~Gaussian(0, params.sigma) in +{ Target = [ { x = Pool[0].x + , v = noise |> v -> v * 2 + , generation = Pool[0].generation + 1 } ] } +``` + +Kernel over all input tokens: + +``` +{ Out = Pool.map(token -> { x = token.x + 1, v = token.v }) } +``` + +Metric (future surface): + +``` +sum Infected.tokens by t -> t.viral_load +``` + +Notes: + +- `params.` is the single namespace for model parameters (mirrors + `paramRef`). +- Input places are bare identifiers (quoted `` `Other Place` `` when the name + isn't identifier-shaped, including `Instance::Port` scoped names). +- `count ` lowers to `length`; `inf` to the `Infinity` constant. +- Newline-separated record fields avoid comma-noise in dynamics; both `,` and + newline are accepted separators. + +## Grammar (EBNF) + +``` +program := dynamics_body | expr +dynamics_body := "per" pattern "->" deriv_block +deriv_block := { deriv_binding } +deriv_binding := ident "'" "=" expr (NEWLINE | ";") +pattern := "{" ident { ("," | NEWLINE) ident } "}" | ident + +expr := "let" ident "=" expr "in" expr + | "if" expr "then" expr "else" expr + | pipe_expr +pipe_expr := or_expr { "|>" lambda_expr } (* distribution map *) +lambda_expr := ident "->" expr +or_expr := and_expr { "||" and_expr } +and_expr := cmp_expr { "&&" cmp_expr } +cmp_expr := add_expr [ ("<" | "<=" | ">" | ">=" | "==" | "!=") add_expr ] +add_expr := mul_expr { ("+" | "-") mul_expr } +mul_expr := unary_expr { ("*" | "/" | "%") unary_expr } +unary_expr := ("-" | "!") unary_expr | pow_expr +pow_expr := postfix_expr [ "^" unary_expr ] (* right assoc *) +postfix_expr := primary { "." ident | "[" expr "]" + | ".map" "(" lambda_expr ")" } +primary := NUMBER | "true" | "false" | "inf" | "pi" | "e" + | ident | quoted_ident + | "~" ident "(" args ")" (* distribution *) + | "count" primary + | math_fn "(" args ")" (* sin, cos, sqrt, … *) + | "(" expr ")" + | record | list +record := "{" [ field { ("," | NEWLINE) field } ] "}" +field := (ident | quoted_ident) "=" expr +list := "[" [ expr { "," expr } ] "]" +args := [ expr { "," expr } ] +``` + +Deliberate omissions: loops, recursion, user function definitions, strings, +mutation, sequencing. Comments: `(* … *)` and `# line comment`. + +## Implementation plan + +### Lexer + parser + +Hand-written: a lexer with precise token spans, and a recursive-descent parser +with a Pratt loop for binary operators (the grammar above is small enough that +the Pratt table is ~15 entries). Rationale over parser generators: + +- **Error recovery** is the whole game for an editor language. On error, emit + an `ErrorNode` carrying the span, synchronize on layout anchors (`let`, + `in`, `,`, `}`, `]`, newline in deriv blocks), and keep parsing — the + result is always a full tree with holes, never a hard failure. +- Documents are one expression, tens of lines — full reparse per keystroke is + microseconds; no incremental parsing needed (unlike tree-sitter, which + would still be a fine later swap for highlighting). + +### AST → HIR lowering + +Near 1:1 (`let`→`let`, `if`→`cond`, `|>`/`.map` on distributions → +`distributionMap`, `~D(…)`→`distribution`, `count`→`length`, records/lists → +`recordLit`/`arrayLit`, `per`-block → the `arrayMap`-over-tokens shape the +buffer-native emitter wants, with `x' = e` becoming record entry `x: e`). +`ErrorNode`s lower to a poisoned `unknown` node so downstream analyses still +run on the valid parts. Spans carry over directly — the DSL text _is_ the user +content. + +### Semantic analysis + +Reuse the HIR stack verbatim: `typecheckHir` + `analyzeHir` + `lintHirUserCode` +already operate on HIR with `SurfaceContext`, so the DSL gets every rule +(discrete derivatives, distribution-into-int, arc-weight bounds, shared +samples, …) for free. DSL-specific resolution happens pre-lowering: + +- bare identifiers resolve, in order: pattern/let/lambda bindings → input + place names → error with "did you mean" (Levenshtein over places/bindings); +- `params.x` checked against the parameter list at resolution time (same + diagnostics as `hir:unknown-parameter`). + +Type checking is bidirectional at the root: the surface fixes the expected +result type (derivative record / bool-or-rate / output record), which flows +into record literals and lists — this yields better messages than pure +inference ("this should be a token list for place `Target`" instead of a +mismatch at the leaf). + +### LSP integration + +The existing infrastructure was built language-agnostic in the right places — +transport, JSON-RPC protocol, diagnostics-store client, Monaco sync components +are all reusable unchanged. What changes is inside the worker: + +1. **Document model.** DSL items don't need virtual TS files, prefixes or + offset adjustment: the checker runs on the raw document. The + `SDCPNLanguageServer` grows a per-item language tag + (`Transition.language?: "typescript" | "petrinaut"` in the schema), and + dispatches per item to the TS service or the DSL analyzer. +2. **Diagnostics.** DSL parse/resolve/type errors are `HirDiagnostic`s → + the existing `check-hir.ts` conversion → the same + `publishDiagnostics` fan-out. No protocol change (this is already how HIR + lints ship today). +3. **Completions.** Context + partial tree driven: after `params.` list + parameters (typed); after a place expression `.` list attributes from the + color; in record position inside a kernel output, list the missing output + places / missing attributes; keywords (`let`, `if`, `then`, `else`, `in`, + `per`, `count`, `~Gaussian|Uniform|Lognormal`) elsewhere. The partial tree + plus `SurfaceContext` answers "what is expected here" — no type-service + needed. +4. **Hover/signature help.** Hover shows the resolved kind (parameter with + type/default, place with color and arc weight, binding with inferred + `HirType`); signature help covers distribution constructors and math + functions from the same tables the HIR uses. +5. **Monaco.** Register a `petrinaut` language with a Monarch tokenizer (~40 + lines); the existing `CompletionSync`/`HoverSync`/`DiagnosticsSync` + providers just need registering for the new language id alongside + `"typescript"`. + +### Migration story (TS ⇄ DSL) + +Because both frontends meet at the HIR, migration is a pretty-printer: + +- **TS → DSL**: `lowerTypeScriptToHir` then print HIR as DSL. Any item the + TS frontend can lower (the analyzable subset — in practice the default + templates and most real models) converts automatically; items outside the + subset stay TS until rewritten. A one-shot "Convert to Petrinaut language" + action per item, with a model-wide bulk action, both no-ops on failure. +- **DSL → TS**: same in reverse (print HIR as the TS idiom) — useful as an + escape hatch and for trust-building diffing. +- Storage: per-item `language` field next to the code string; default stays + `"typescript"` until the DSL is on by default. + +## Open questions + +- **Numeric semantics**: keep JS doubles (bit-compatibility with existing + runs, trivial JS backend) vs. defining int as i64 (matches token format v2's + ±2^53 discussion, matters for WASM/GPU backends). Sketch assumes doubles. +- **Comprehension power**: metrics realistically need `sum`/`filter`; is + `sum by ` special syntax (analyzable, GPU-reducible) or a + general `reduce` (more expressive, harder to fuse)? Leaning special forms. +- **Time**: none of the surfaces currently receive `t`/`dt`; if dynamics ever + become time-dependent, `t` becomes an ambient like `params`. +- **Multi-token kernels with data-dependent counts** (produce N tokens where + N is computed): today's TS surface can't either (tuple types); needs an arc + semantics decision before syntax. +- **Naming**: place references by display name mirror the TS surface but are + rename-fragile; the DSL could resolve through stable ids under the hood + (documents store ids, editor renders names) — a real advantage over the TS + frontend worth deciding early. diff --git a/libs/@hashintel/petrinaut-core/src/hir/emit-buffer-js.test.ts b/libs/@hashintel/petrinaut-core/src/hir/emit-buffer-js.test.ts new file mode 100644 index 00000000000..c6acd0f8d31 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/hir/emit-buffer-js.test.ts @@ -0,0 +1,639 @@ +import { describe, expect, it } from "vitest"; + +import { StringPool } from "../simulation/engine/string-pool"; +import { + createTokenRegionViews, + encodeTokenToBytes, + computeTokenSlotLayout, +} from "../simulation/engine/token-layout"; +import { + emitBufferDynamicsJs, + emitBufferKernelJs, + emitBufferLambdaJs, + emitBufferMetricJs, +} from "./emit-buffer-js"; +import { + instantiateHirBufferDynamics, + instantiateHirBufferKernel, + instantiateHirBufferLambda, + instantiateHirMetric, +} from "./instantiate"; +import { lowerTypeScriptToHir } from "./lower-typescript"; + +import type { RuntimeDistribution } from "../simulation/authoring/user-code/distribution"; +import type { HirFunction } from "./hir"; +import type { + HirKernelContext, + HirLambdaContext, + HirMetricContext, +} from "./surface-context"; + +function lower( + code: string, + surface: "lambda" | "kernel" | "dynamics" | "metric", +) { + const result = lowerTypeScriptToHir(code, surface); + if (!result.ok) { + throw new Error(result.diagnostics[0]?.message); + } + return result.fn as HirFunction; +} + +// Pool place: x(real) v(real) alive(boolean) status(string) id(uuid). +// Packed layout (v2): 8-aligned fields in declaration order — x@0, v@8, +// status@16 (u64 handle), id@24 (2×u64) — then alive(u8)@40; stride 48. +const poolElements = [ + { name: "x", type: "real" as const }, + { name: "v", type: "real" as const }, + { name: "status", type: "string" as const }, + { name: "id", type: "uuid" as const }, + { name: "alive", type: "boolean" as const }, +]; +const poolColorElements = poolElements.map((element, index) => ({ + elementId: `e${index}`, + name: element.name, + type: element.type, +})); +const poolLayout = computeTokenSlotLayout(poolColorElements); + +const poolSlot = { + name: "Pool", + colorId: "c1", + elements: poolElements, + tokenCount: 2, + slotStart: 0, +}; + +const lambdaContext: HirLambdaContext = { + surface: "lambda", + parameters: [ + { name: "rate", type: "real" }, + { name: "threshold", type: "real" }, + ], + inputPlaces: [(({ slotStart: _s, ...binding }) => binding)(poolSlot)], + inputSlots: [poolSlot], + lambdaType: "stochastic", +}; + +// A frame region with two Pool tokens, back to back. `placeBases` has one +// entry per input arc (the Pool arc's place region starts at byte 0); +// `indices` one selected token index per slot (strides are baked in). +function makeRegion(pool: StringPool) { + // hi = 0x0123456789abcdef, lo = 0xfedcba9876543210 (avoids no-bitwise) + const uuid = + 0x0123456789abcdefn * 18446744073709551616n + 0xfedcba9876543210n; + const tokenA = encodeTokenToBytes( + poolLayout, + { x: 1.5, v: -2, status: "shipped", id: uuid, alive: true }, + "test", + pool, + ); + const tokenB = encodeTokenToBytes( + poolLayout, + { x: 4, v: 8, status: "queued", id: 7n, alive: false }, + "test", + pool, + ); + const bytes = new Uint8Array(poolLayout.strideBytes * 2); + bytes.set(tokenA, 0); + bytes.set(tokenB, poolLayout.strideBytes); + const views = createTokenRegionViews(bytes.buffer, 0, bytes.byteLength); + const placeBases = new Int32Array([0]); + const indices = new Int32Array([0, 1]); + return { views, placeBases, indices, uuid }; +} + +function compileLambda(code: string, pool: StringPool, parameters = {}) { + const program = emitBufferLambdaJs(lower(code, "lambda"), lambdaContext); + expect(program).not.toBeNull(); + expect(program!.inputSlotCount).toBe(2); + return instantiateHirBufferLambda(program!.source, parameters, pool); +} + +describe("emitBufferLambdaJs (token format v2)", () => { + it("reads real/boolean attributes at packed byte offsets", () => { + const pool = new StringPool(); + const { views, placeBases, indices } = makeRegion(pool); + const fn = compileLambda( + `export default Lambda((input, parameters) => input.Pool[0].alive ? input.Pool[0].x + input.Pool[1].v : 0);`, + pool, + ); + expect(fn(views.f64, views.u64, views.u8, placeBases, indices)).toBe( + 1.5 + 8, + ); + }); + + it("resolves interned strings through the pool", () => { + const pool = new StringPool(); + const { views, placeBases, indices } = makeRegion(pool); + const fn = compileLambda( + `export default Lambda((input, parameters) => input.Pool[0].status === "shipped" && input.Pool[1].status.startsWith("q"));`, + pool, + ); + expect(fn(views.f64, views.u64, views.u8, placeBases, indices)).toBe(true); + }); + + it("assembles uuid attributes as bigints from the two u64 lanes", () => { + const pool = new StringPool(); + const { views, placeBases, indices, uuid } = makeRegion(pool); + const fn = compileLambda( + `export default Lambda((input, parameters) => input.Pool[0].id === input.Pool[1].id ? 1 : 0.5);`, + pool, + ); + expect(fn(views.f64, views.u64, views.u8, placeBases, indices)).toBe(0.5); + void uuid; + }); + + it("binds parameters and supports guards/destructuring", () => { + const pool = new StringPool(); + const { views, placeBases, indices } = makeRegion(pool); + const fn = compileLambda( + `export default Lambda((input, parameters) => { + const { rate, threshold } = parameters; + const { x, alive } = input.Pool[0]; + if (!alive) return 0; + if (x < threshold) return 0; + return rate * x; +});`, + pool, + { rate: 2, threshold: 1 }, + ); + expect(fn(views.f64, views.u64, views.u8, placeBases, indices)).toBe(3); + }); +}); + +describe("emitBufferKernelJs (token format v2)", () => { + // Out place: a(real) b(real) label(string) id(uuid) flag(boolean) — same + // packing rules as Pool: a@0, b@8, label@16, id@24 (2×u64), flag@40; + // stride 48. + const outElements = [ + { name: "a", type: "real" as const }, + { name: "b", type: "real" as const }, + { name: "label", type: "string" as const }, + { name: "id", type: "uuid" as const }, + { name: "flag", type: "boolean" as const }, + ]; + const outLayout = computeTokenSlotLayout( + outElements.map((element, index) => ({ + elementId: `o${index}`, + name: element.name, + type: element.type, + })), + ); + const fieldOffset = (name: string) => + outLayout.fields.find((field) => field.element.name === name)!.byteOffset; + + const kernelContext: HirKernelContext = { + surface: "kernel", + parameters: [], + inputPlaces: lambdaContext.inputPlaces, + inputSlots: lambdaContext.inputSlots, + outputPlaces: [ + { name: "Out", colorId: "c2", elements: outElements, tokenCount: 2 }, + ], + outputSlots: [ + { + name: "Out", + colorId: "c2", + elements: outElements, + tokenCount: 2, + slotStart: 0, + }, + ], + stochasticity: true, + }; + + type SinkCall = { kind: string; index: number; payload: unknown }; + + function runKernel(code: string, pool: StringPool) { + const program = emitBufferKernelJs(lower(code, "kernel"), kernelContext); + expect(program).not.toBeNull(); + expect(program!.inputSlotCount).toBe(2); + const fn = instantiateHirBufferKernel(program!.source, {}, pool); + const { views, placeBases, indices } = makeRegion(pool); + const staging = new Uint8Array(program!.outputByteCount); + const stagingViews = createTokenRegionViews( + staging.buffer, + 0, + staging.byteLength, + ); + const sinkCalls: SinkCall[] = []; + fn( + views.f64, + views.u64, + views.u8, + placeBases, + indices, + stagingViews.f64, + stagingViews.u64, + stagingViews.u8, + (kind, index, payload) => sinkCalls.push({ kind, index, payload }), + ); + return { program: program!, stagingViews, sinkCalls }; + } + + it("writes static values at packed offsets and defers RNG values through the sink", () => { + const pool = new StringPool(); + const { program, stagingViews, sinkCalls } = runKernel( + `export default TransitionKernel((input) => { + const noise = Distribution.Gaussian(0, 1); + return { + Out: [ + { a: input.Pool[0].x + 1, b: noise, label: "shipped", flag: true }, + { a: 2, b: 3, label: input.Pool[1].status, id: Uuid.from("order-1"), flag: false }, + ], + }; +});`, + pool, + ); + + const stride = outLayout.strideBytes; + expect(program.outputByteCount).toBe(2 * stride); + + // Token 0 static writes at the packed offsets. + expect(stagingViews.f64[fieldOffset("a") / 8]).toBe(1.5 + 1); + expect(pool.get(Number(stagingViews.u64[fieldOffset("label") / 8]))).toBe( + "shipped", + ); + expect(stagingViews.u8[fieldOffset("flag")]).toBe(1); + + // Token 1 static writes, one stride further. + expect(stagingViews.f64[(stride + fieldOffset("a")) / 8]).toBe(2); + expect(stagingViews.f64[(stride + fieldOffset("b")) / 8]).toBe(3); + expect( + pool.get(Number(stagingViews.u64[(stride + fieldOffset("label")) / 8])), + ).toBe("queued"); + expect(stagingViews.u8[stride + fieldOffset("flag")]).toBe(0); + + // Deferred slots arrive in (token, element-declaration) order: token 0's + // distribution (b) then omitted uuid (id, auto-generate), then token 1's + // Uuid.from. Indices are 64-bit lanes into the staging buffer. + expect(sinkCalls.map(({ kind, index }) => ({ kind, index }))).toEqual([ + { kind: "dist", index: fieldOffset("b") / 8 }, + { kind: "generate", index: fieldOffset("id") / 8 }, + { kind: "from", index: (stride + fieldOffset("id")) / 8 }, + ]); + expect(sinkCalls[0]!.payload as RuntimeDistribution).toMatchObject({ + __brand: "distribution", + type: "gaussian", + mean: 0, + deviation: 1, + }); + expect(sinkCalls[2]!.payload).toBe("order-1"); + }); + + it("forwards whole input tokens, deferring uuid copies through the sink", () => { + const forwardContext: HirKernelContext = { + ...kernelContext, + outputPlaces: [ + { name: "Pool", colorId: "c1", elements: poolElements, tokenCount: 2 }, + ], + outputSlots: [poolSlot], + }; + const program = emitBufferKernelJs( + lower( + `export default TransitionKernel((input) => ({ Pool: [input.Pool[0], input.Pool[1]] }));`, + "kernel", + ), + forwardContext, + ); + expect(program).not.toBeNull(); + expect(program!.outputByteCount).toBe(2 * poolLayout.strideBytes); + + const pool = new StringPool(); + const fn = instantiateHirBufferKernel(program!.source, {}, pool); + const { views, placeBases, indices, uuid } = makeRegion(pool); + const staging = new Uint8Array(program!.outputByteCount); + const stagingViews = createTokenRegionViews( + staging.buffer, + 0, + staging.byteLength, + ); + const sinkCalls: SinkCall[] = []; + fn( + views.f64, + views.u64, + views.u8, + placeBases, + indices, + stagingViews.f64, + stagingViews.u64, + stagingViews.u8, + (kind, index, payload) => sinkCalls.push({ kind, index, payload }), + ); + + const stride = poolLayout.strideBytes; + // Real/boolean/string attributes are copied inline. + expect(stagingViews.f64[0]).toBe(1.5); // token 0 x + expect(stagingViews.f64[1]).toBe(-2); // token 0 v + expect(stagingViews.f64[stride / 8]).toBe(4); // token 1 x + expect(pool.get(Number(stagingViews.u64[2]))).toBe("shipped"); + expect(pool.get(Number(stagingViews.u64[stride / 8 + 2]))).toBe("queued"); + expect(stagingViews.u8[40]).toBe(1); + expect(stagingViews.u8[stride + 40]).toBe(0); + // uuid copies are bigints, deferred through the sink as "from". + expect(sinkCalls.map(({ kind }) => kind)).toEqual(["from", "from"]); + expect(sinkCalls[0]!.payload).toBe(uuid); + expect(sinkCalls[1]!.payload).toBe(7n); + }); +}); + +describe("emitBufferDynamicsJs (token format v2)", () => { + it("computes derivatives from packed bytes without record decoding", () => { + const pool = new StringPool(); + const { views } = makeRegion(pool); + const source = emitBufferDynamicsJs( + lower( + `export default Dynamics((tokens, parameters) => { + const g = parameters.g; + return tokens.map(({ x, v, alive }) => ({ + x: alive ? v : 0, + v: -g * x, + })); +});`, + "dynamics", + ), + poolElements, + ); + expect(source).not.toBeNull(); + const fn = instantiateHirBufferDynamics(source!, { g: 2 }, pool); + const result = fn(views.u8, 2); + // Token A: alive → x' = v = -2, v' = -2 * 1.5 = -3 + // Token B: dead → x' = 0, v' = -2 * 4 = -8 + expect([...result]).toEqual([-2, -3, 0, -8]); + }); + + it("reads string attributes in dynamics (read-only)", () => { + const pool = new StringPool(); + const { views } = makeRegion(pool); + const source = emitBufferDynamicsJs( + lower( + `export default Dynamics((tokens) => tokens.map(({ v, status }) => ({ + x: status === "shipped" ? v : 0, +})));`, + "dynamics", + ), + poolElements, + ); + expect(source).not.toBeNull(); + const fn = instantiateHirBufferDynamics(source!, {}, pool); + expect([...fn(views.u8, 2)]).toEqual([-2, 0, 0, 0]); + }); + + it("bails to null when the body is not a token map", () => { + expect( + emitBufferDynamicsJs( + lower(`export default Dynamics((tokens) => [{ x: 1 }]);`, "dynamics"), + poolElements, + ), + ).toBeNull(); + }); +}); +// --------------------------------------------------------------------------- +// Metric programs (Monte-Carlo-style frame buffers) +// --------------------------------------------------------------------------- + +const metricContext: HirMetricContext = { + surface: "metric", + parameters: [], + places: [ + { + name: "Pool", + elements: [ + { name: "x", type: "real" }, + { name: "status", type: "string" }, + ], + }, + { name: "Buffer", elements: [{ name: "x", type: "real" }] }, + // Uncolored place: exposes `count` and an empty-record tokens array. + { name: "Bin", elements: [] }, + ], +}; + +const metricPoolLayout = computeTokenSlotLayout([ + { elementId: "e0", name: "x", type: "real" }, + { elementId: "e1", name: "status", type: "string" }, +]); +const metricBufferLayout = computeTokenSlotLayout([ + { elementId: "e0", name: "x", type: "real" }, +]); + +/** + * Hand-packs one Monte-Carlo-style frame: dense `placeCounts`/`placeOffsets` + * in frame order (Bin, Pool, Buffer — deliberately different from the metric + * context order so `__places` mapping is exercised), plus shared views over + * the token region. + */ +function makeMetricFrame(pool: StringPool) { + const poolTokens = [ + { x: 1.5, status: "shipped" }, + { x: 2.5, status: "queued" }, + { x: 3, status: "shipped" }, + ]; + const bufferTokens = [{ x: 10 }, { x: 20 }]; + + const poolBytes = poolTokens.length * metricPoolLayout.strideBytes; + const bufferBytes = bufferTokens.length * metricBufferLayout.strideBytes; + const bytes = new Uint8Array(poolBytes + bufferBytes); + for (const [index, token] of poolTokens.entries()) { + bytes.set( + encodeTokenToBytes(metricPoolLayout, token, "test", pool), + index * metricPoolLayout.strideBytes, + ); + } + for (const [index, token] of bufferTokens.entries()) { + bytes.set( + encodeTokenToBytes(metricBufferLayout, token, "test", pool), + poolBytes + index * metricBufferLayout.strideBytes, + ); + } + const views = createTokenRegionViews(bytes.buffer, 0, bytes.byteLength); + + // Frame order: Bin=0, Pool=1, Buffer=2. + const placeIndexByName: Record = { + Bin: 0, + Pool: 1, + Buffer: 2, + }; + const placeCounts = new Uint32Array([ + 5, + poolTokens.length, + bufferTokens.length, + ]); + const placeOffsets = new Uint32Array([0, 0, poolBytes]); + return { views, placeCounts, placeOffsets, placeIndexByName }; +} + +function compileMetric( + code: string, + pool: StringPool, + placeIndexByName: Record, +) { + const program = emitBufferMetricJs(lower(code, "metric"), metricContext); + expect(program).not.toBeNull(); + const placeIndices = new Int32Array( + program!.placeNames.map((name) => placeIndexByName[name]!), + ); + return instantiateHirMetric(program!.source, placeIndices, pool); +} + +describe("emitBufferMetricJs (Monte-Carlo frame buffers)", () => { + it("reads place counts (including uncolored places)", () => { + const pool = new StringPool(); + const frame = makeMetricFrame(pool); + const fn = compileMetric( + `return state.places.Bin.count + 10 * state.places.Pool.count;`, + pool, + frame.placeIndexByName, + ); + expect( + fn( + frame.views.f64, + frame.views.u64, + frame.views.u8, + frame.placeCounts, + frame.placeOffsets, + ), + ).toBe(5 + 30); + }); + + it("compiles reduce over place tokens to a loop", () => { + const pool = new StringPool(); + const frame = makeMetricFrame(pool); + const fn = compileMetric( + `return state.places.Pool.tokens.reduce((sum, t) => sum + t.x, 0);`, + pool, + frame.placeIndexByName, + ); + expect( + fn( + frame.views.f64, + frame.views.u64, + frame.views.u8, + frame.placeCounts, + frame.placeOffsets, + ), + ).toBe(1.5 + 2.5 + 3); + }); + + it("compiles reduce over a concat as sequential loops with a running index", () => { + const pool = new StringPool(); + const frame = makeMetricFrame(pool); + const fn = compileMetric( + `const fleet = state.places.Pool.tokens.concat(state.places.Buffer.tokens); +if (fleet.length === 0) return -1; +const indexSum = fleet.reduce((acc, t, i) => acc + i, 0); +return fleet.reduce((sum, t) => sum + t.x, 0) / fleet.length + indexSum;`, + pool, + frame.placeIndexByName, + ); + // x sum = 7 + 30 = 37 over 5 tokens; index sum = 0+1+2+3+4 = 10. + expect( + fn( + frame.views.f64, + frame.views.u64, + frame.views.u8, + frame.placeCounts, + frame.placeOffsets, + ), + ).toBe(37 / 5 + 10); + }); + + it("supports early-return guards around reduces", () => { + const pool = new StringPool(); + const frame = makeMetricFrame(pool); + const fn = compileMetric( + `const tokens = state.places.Buffer.tokens; +if (tokens.length === 0) return 0; +return tokens.reduce((sum, t) => sum + t.x, 0) / tokens.length;`, + pool, + frame.placeIndexByName, + ); + expect( + fn( + frame.views.f64, + frame.views.u64, + frame.views.u8, + frame.placeCounts, + frame.placeOffsets, + ), + ).toBe(15); + + // Empty frame → the guard branch wins (loops run zero iterations). + const emptyCounts = new Uint32Array([0, 0, 0]); + expect( + fn( + frame.views.f64, + frame.views.u64, + frame.views.u8, + emptyCounts, + frame.placeOffsets, + ), + ).toBe(0); + }); + + it("compares string attributes through the pool inside reduce loops", () => { + const pool = new StringPool(); + const frame = makeMetricFrame(pool); + const fn = compileMetric( + `return state.places.Pool.tokens.reduce( + (count, t) => t.status === "shipped" ? count + 1 : count, + 0, +);`, + pool, + frame.placeIndexByName, + ); + expect( + fn( + frame.views.f64, + frame.views.u64, + frame.views.u8, + frame.placeCounts, + frame.placeOffsets, + ), + ).toBe(2); + }); + + it("indexes place tokens with dynamic indices", () => { + const pool = new StringPool(); + const frame = makeMetricFrame(pool); + const fn = compileMetric( + `return state.places.Pool.tokens[state.places.Bin.count - 4].x;`, + pool, + frame.placeIndexByName, + ); + // Bin.count = 5 → index 1 → x = 2.5. + expect( + fn( + frame.views.f64, + frame.views.u64, + frame.views.u8, + frame.placeCounts, + frame.placeOffsets, + ), + ).toBe(2.5); + }); + + it("registers referenced places in first-reference order", () => { + const program = emitBufferMetricJs( + lower( + `return state.places.Buffer.count + state.places.Pool.count + state.places.Buffer.count;`, + "metric", + ), + metricContext, + ); + expect(program?.placeNames).toEqual(["Buffer", "Pool"]); + }); + + it("bails to null on reduce over non-place arrays", () => { + expect( + emitBufferMetricJs( + lower( + `return [1, 2, 3].reduce((sum, value) => sum + value, 0);`, + "metric", + ), + metricContext, + ), + ).toBeNull(); + }); +}); diff --git a/libs/@hashintel/petrinaut-core/src/hir/emit-buffer-js.ts b/libs/@hashintel/petrinaut-core/src/hir/emit-buffer-js.ts new file mode 100644 index 00000000000..73c0a5907d5 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/hir/emit-buffer-js.ts @@ -0,0 +1,1106 @@ +/** + * Buffer-ABI JavaScript emission for lambdas, transition kernels and + * metrics. + * + * Emitted functions read token attributes straight out of the engine's packed + * token bytes at statically-resolved offsets: + * + * lambda: (f64, u64, u8, placeBases, indices) => number | boolean + * kernel: (f64, u64, u8, placeBases, indices, outF64, outU64, outU8, sink) => void + * metric: (f64, u64, u8, placeCounts, placeOffsets) => number + * + * The full ABI is documented in `BUFFER_ABI.md`. + * + * Emission works by symbolic evaluation: structural values (input tuples, + * tokens, records, arrays) exist only at compile time; everything that + * reaches the output is a scalar JS expression or a distribution constant. + * `.map(...)` over token tuples is unrolled (arc weights are static). + * Shapes the evaluator cannot scalarize return `null`; `compileHirArtifacts` + * reports those as compile failures. + * + * Only emit from functions whose typecheck produced no errors: the emitter + * relies on attribute/place validity established by `typecheckHir`. + */ +import { + computeTokenSlotLayout, + type TokenSlotLayout, +} from "../simulation/engine/token-layout"; +import { foldHir } from "./analyze"; + +import type { HirExpr, HirFunction } from "./hir"; +import type { + HirArcSlot, + HirKernelContext, + HirLambdaContext, + HirMetricContext, + HirTokenElementInfo, +} from "./surface-context"; + +export type BufferProgram = { + /** JS source of the emitted function (arrow expression). */ + source: string; + /** Expected `indices.length` — engine-side sanity check. */ + inputSlotCount: number; +}; + +export type BufferKernelProgram = BufferProgram & { + /** Expected staging byte length — engine-side sanity check. */ + outputByteCount: number; +}; + +export type BufferMetricProgram = { + /** JS source of the emitted function (arrow expression). */ + source: string; + /** Places referenced by the program, in emitted-ordinal order — the + * instantiation binds `__places[ordinal] → frame place index`. */ + placeNames: string[]; +}; + +/** Maximum tokens per arc slot we are willing to unroll `.map` over. */ +const MAX_UNROLL = 16; + +const RESERVED_NAMES = [ + "f64", + "u64", + "u8", + "__pool", + "__out", + "__sink", + "__places", + "placeBases", + "placeCounts", + "placeOffsets", + "indices", + "out", + "distSink", + "__params", + "__dist", + "Math", + "Infinity", + "NaN", +]; + +/** Internal bail signal: shape outside the buffer-ABI subset. */ +class BailError extends Error {} + +/** One place referenced by a metric program (registered ordinal + color). */ +type MetricPlaceRef = { + ordinal: number; + elements: HirTokenElementInfo[]; +}; + +type Value = + /** A JS expression producing a number or boolean. */ + | { kind: "scalar"; code: string } + /** A JS expression producing a RuntimeDistribution (a hoisted const). */ + | { kind: "dist"; code: string } + /** `Uuid.generate()` / `Uuid.from(...)` — resolved by the engine sink. */ + | { kind: "uuidSentinel"; mode: "generate" | "from"; code: string } + /** The lambda/kernel first parameter (`tokensByPlace`). */ + | { kind: "inputRecord" } + /** A whole input arc slot's token tuple. */ + | { kind: "tokens"; slot: HirArcSlot } + /** One token; `baseCode` is a JS expression for its float base offset. */ + | { kind: "token"; baseCode: string; elements: HirTokenElementInfo[] } + | { kind: "record"; fields: Map } + | { kind: "array"; items: Value[] } + /** The metric `state` parameter. */ + | { kind: "metricState" } + /** `state.places` in a metric program. */ + | { kind: "placesRecord" } + /** `state.places.` — `count`/`tokens` read through `__places`. */ + | ({ kind: "placeState" } & MetricPlaceRef) + /** `state.places..tokens` — a dynamically-sized token array. */ + | ({ kind: "placeTokens" } & MetricPlaceRef) + /** `a.concat(b)` over place token arrays (parts in source order). */ + | { kind: "placeTokensConcat"; parts: MetricPlaceRef[] }; + +function quoteKey(key: string): string { + return JSON.stringify(key); +} + +function emitNumber(value: number, raw: string): string { + if (Number.isNaN(value)) { + return "NaN"; + } + if (Number(raw) === value) { + return raw; + } + if (value === Infinity) { + return "Infinity"; + } + if (value === -Infinity) { + return "-Infinity"; + } + return String(value); +} + +class NameAllocator { + private readonly used: Set; + + constructor(used: Iterable) { + this.used = new Set(used); + } + + allocate(preferred: string): string { + let name = preferred; + let suffix = 2; + while (this.used.has(name)) { + name = `${preferred}_${suffix}`; + suffix += 1; + } + this.used.add(name); + return name; + } +} + +class BufferEmitter { + /** Hoisted statements (consts, metric reduce loops), in evaluation order. */ + readonly lines: string[] = []; + readonly names = new NameAllocator(RESERVED_NAMES); + + /** Metric-referenced place names in emitted-ordinal order. */ + readonly placeNames: string[] = []; + private readonly metricPlaceByName: Map< + string, + { name: string; elements: HirTokenElementInfo[] } + > | null; + private readonly metricOrdinalByName = new Map(); + + constructor( + readonly inputSlots: HirArcSlot[], + metricContext: HirMetricContext | null = null, + ) { + this.metricPlaceByName = metricContext + ? new Map(metricContext.places.map((place) => [place.name, place])) + : null; + } + + /** Resolves a place display name to its arc slot (last arc wins, matching + * the runtime's object-key overwrite). */ + private slotForPlace(name: string): HirArcSlot | null { + for (let index = this.inputSlots.length - 1; index >= 0; index -= 1) { + if (this.inputSlots[index]!.name === name) { + return this.inputSlots[index]!; + } + } + return null; + } + + /** Registers (on first reference) and returns the metric place ref for a + * `state.places.` access. Unknown names bail — typecheck already + * errors on them, so this only guards mistyped programs. */ + private metricPlaceRef(name: string): MetricPlaceRef { + const place = this.metricPlaceByName?.get(name); + if (!place) { + throw new BailError(); + } + let ordinal = this.metricOrdinalByName.get(name); + if (ordinal === undefined) { + ordinal = this.placeNames.length; + this.placeNames.push(name); + this.metricOrdinalByName.set(name, ordinal); + } + return { ordinal, elements: place.elements }; + } + + /** `placeCounts[...]` read for one referenced place. */ + private metricCountCode(ordinal: number): string { + return `placeCounts[__places[${ordinal}]]`; + } + + /** Flattens a reduce/concat target into place parts; anything that is not + * a place token array (or a concat of them) bails to the object path. */ + private concatParts(value: Value): MetricPlaceRef[] { + if (value.kind === "placeTokens") { + return [{ ordinal: value.ordinal, elements: value.elements }]; + } + if (value.kind === "placeTokensConcat") { + return value.parts; + } + throw new BailError(); + } + + /** Token layouts computed once per arc slot; byte offsets and strides are + * baked into the emitted code as literal constants. */ + private readonly layoutBySlot = new Map(); + + private layoutOf(elements: readonly HirTokenElementInfo[]): TokenSlotLayout { + return computeTokenSlotLayout( + elements.map((element, index) => ({ + elementId: String(index), + name: element.name, + type: element.type, + })), + ); + } + + private slotLayout(slot: HirArcSlot): TokenSlotLayout { + let layout = this.layoutBySlot.get(slot); + if (!layout) { + layout = this.layoutOf(slot.elements); + this.layoutBySlot.set(slot, layout); + } + return layout; + } + + /** Which `placeBases` entry an arc slot reads from (its arc index). */ + private placeBaseIndex(slot: HirArcSlot): number { + return this.inputSlots.indexOf(slot); + } + + private tokenOfSlot(slot: HirArcSlot, tokenIndex: number): Value { + const stride = this.slotLayout(slot).strideBytes; + const slotIndex = slot.slotStart + tokenIndex; + const strideCode = stride === 0 ? "0" : `indices[${slotIndex}] * ${stride}`; + return { + kind: "token", + baseCode: `placeBases[${this.placeBaseIndex(slot)}] + ${strideCode}`, + elements: slot.elements, + }; + } + + /** + * Emits a read of one attribute from the packed token struct (format v2): + * `baseCode` is the token's base BYTE offset within the token region; + * field offsets come from `computeTokenSlotLayout`. real/integer are f64, + * booleans u8 (0/1), strings u64 pool handles (resolved through `__pool`), + * uuids two u64 lanes assembled into one bigint. + */ + /** Public wrappers for the kernel emitter. */ + tokenValueOf(slot: HirArcSlot, tokenIndex: number): Value { + return this.tokenOfSlot(slot, tokenIndex); + } + + readTokenField( + token: Extract, + field: string, + ): Value | undefined { + try { + return this.readAttribute(token, field); + } catch (error) { + if (error instanceof BailError) { + return undefined; + } + throw error; + } + } + + private readAttribute( + token: Extract, + field: string, + ): Value { + const layout = this.layoutOf(token.elements); + const layoutField = layout.fields.find( + (candidate) => candidate.element.name === field, + ); + if (!layoutField) { + throw new BailError(); + } + const byteOffset = layoutField.byteOffset; + const base = + byteOffset === 0 ? token.baseCode : `${token.baseCode} + ${byteOffset}`; + switch (layoutField.kind) { + case "f64": { + const read = `f64[(${base}) >> 3]`; + return { + kind: "scalar", + code: + layoutField.element.type === "integer" + ? `Math.round(${read})` + : read, + }; + } + case "u8": + return { kind: "scalar", code: `(u8[${base}] !== 0)` }; + case "u64": + return { + kind: "scalar", + code: `__pool.get(Number(u64[(${base}) >> 3]))`, + }; + case "u64x2": + return { + kind: "scalar", + code: `((u64[((${base}) >> 3) + 1] << 64n) | u64[(${base}) >> 3])`, + }; + } + } + + private scalar(value: Value): string { + if (value.kind !== "scalar") { + throw new BailError(); + } + return value.code; + } + + eval(expr: HirExpr, env: Map): Value { + switch (expr.kind) { + case "numberLit": + return { kind: "scalar", code: emitNumber(expr.value, expr.raw) }; + case "boolLit": + return { kind: "scalar", code: expr.value ? "true" : "false" }; + case "stringLit": + return { kind: "scalar", code: JSON.stringify(expr.value) }; + case "stringCall": + return { + kind: "scalar", + code: `${this.scalar(this.eval(expr.target, env))}.${expr.fn}(${this.scalar( + this.eval(expr.argument, env), + )})`, + }; + case "uuidGenerate": + return { kind: "uuidSentinel", mode: "generate", code: "0" }; + case "uuidFrom": + return { + kind: "uuidSentinel", + mode: "from", + code: this.scalar(this.eval(expr.operand, env)), + }; + case "constant": + switch (expr.name) { + case "PI": + return { kind: "scalar", code: "Math.PI" }; + case "E": + return { kind: "scalar", code: "Math.E" }; + case "Infinity": + return { kind: "scalar", code: "Infinity" }; + case "NaN": + return { kind: "scalar", code: "NaN" }; + } + break; + case "localRef": { + const value = env.get(expr.name); + if (!value) { + throw new BailError(); + } + return value; + } + case "paramRef": + return { kind: "scalar", code: `__params[${quoteKey(expr.name)}]` }; + case "fieldAccess": { + const target = this.eval(expr.target, env); + if (target.kind === "inputRecord") { + const slot = this.slotForPlace(expr.field); + if (!slot) { + throw new BailError(); + } + return { kind: "tokens", slot }; + } + if (target.kind === "metricState") { + if (expr.field !== "places") { + throw new BailError(); + } + return { kind: "placesRecord" }; + } + if (target.kind === "placesRecord") { + return { kind: "placeState", ...this.metricPlaceRef(expr.field) }; + } + if (target.kind === "placeState") { + if (expr.field === "count") { + return { + kind: "scalar", + code: this.metricCountCode(target.ordinal), + }; + } + if (expr.field === "tokens") { + return { + kind: "placeTokens", + ordinal: target.ordinal, + elements: target.elements, + }; + } + throw new BailError(); + } + if (target.kind === "token") { + return this.readAttribute(target, expr.field); + } + if (target.kind === "record") { + const field = target.fields.get(expr.field); + if (!field) { + throw new BailError(); + } + return field; + } + throw new BailError(); + } + case "indexAccess": { + const target = this.eval(expr.target, env); + if (target.kind === "placeTokens") { + // Metric token counts are dynamic, so any scalar index is allowed; + // metric code is expected to guard with `.length` (out-of-range + // reads land in other places' token bytes or zeroed capacity). + const index = this.scalar(this.eval(expr.index, env)); + const stride = this.layoutOf(target.elements).strideBytes; + const strideCode = stride === 0 ? "0" : `(${index}) * ${stride}`; + return { + kind: "token", + baseCode: `placeOffsets[__places[${target.ordinal}]] + ${strideCode}`, + elements: target.elements, + }; + } + const index = foldHir(expr.index); + if (index.kind !== "numberLit" || !Number.isInteger(index.value)) { + // Dynamic transition-token indices would read unchecked memory. + throw new BailError(); + } + if (target.kind === "tokens") { + if (index.value < 0 || index.value >= target.slot.tokenCount) { + throw new BailError(); + } + return this.tokenOfSlot(target.slot, index.value); + } + if (target.kind === "array") { + const item = target.items[index.value]; + if (!item) { + throw new BailError(); + } + return item; + } + throw new BailError(); + } + case "length": { + const target = this.eval(expr.target, env); + if (target.kind === "tokens") { + return { kind: "scalar", code: String(target.slot.tokenCount) }; + } + if (target.kind === "array") { + return { kind: "scalar", code: String(target.items.length) }; + } + if (target.kind === "placeTokens") { + return { kind: "scalar", code: this.metricCountCode(target.ordinal) }; + } + if (target.kind === "placeTokensConcat") { + return { + kind: "scalar", + code: `(${target.parts + .map((part) => this.metricCountCode(part.ordinal)) + .join(" + ")})`, + }; + } + throw new BailError(); + } + case "unary": + return { + kind: "scalar", + code: `(${expr.op}${this.scalar(this.eval(expr.operand, env))})`, + }; + case "binary": { + const op = + expr.op === "==" ? "===" : expr.op === "!=" ? "!==" : expr.op; + return { + kind: "scalar", + code: `(${this.scalar(this.eval(expr.left, env))} ${op} ${this.scalar( + this.eval(expr.right, env), + )})`, + }; + } + case "cond": { + const condition = this.scalar(this.eval(expr.condition, env)); + const thenValue = this.eval(expr.thenBranch, env); + const elseValue = this.eval(expr.elseBranch, env); + if (thenValue.kind === "scalar" && elseValue.kind === "scalar") { + return { + kind: "scalar", + code: `(${condition} ? ${thenValue.code} : ${elseValue.code})`, + }; + } + if (thenValue.kind === "dist" && elseValue.kind === "dist") { + return { + kind: "dist", + code: `(${condition} ? ${thenValue.code} : ${elseValue.code})`, + }; + } + // Structural conditionals (records/tuples) stay on the object path. + throw new BailError(); + } + case "let": { + const scoped = new Map(env); + for (const binding of expr.bindings) { + const value = this.eval(binding.value, scoped); + scoped.set(binding.name, this.hoist(binding.name, value)); + } + return this.eval(expr.body, scoped); + } + case "mathCall": { + const args = expr.args.map((argument) => + this.scalar(this.eval(argument, env)), + ); + return { kind: "scalar", code: `Math.${expr.fn}(${args.join(", ")})` }; + } + case "recordLit": { + const fields = new Map(); + for (const entry of expr.entries) { + fields.set(entry.key, this.eval(entry.value, env)); + } + return { kind: "record", fields }; + } + case "arrayLit": + return { + kind: "array", + items: expr.elements.map((element) => this.eval(element, env)), + }; + case "arrayMap": { + const target = this.eval(expr.target, env); + let items: Value[]; + if (target.kind === "tokens") { + if (target.slot.tokenCount > MAX_UNROLL) { + throw new BailError(); + } + items = Array.from({ length: target.slot.tokenCount }, (_, index) => + this.tokenOfSlot(target.slot, index), + ); + } else if (target.kind === "array") { + if (target.items.length > MAX_UNROLL) { + throw new BailError(); + } + items = target.items; + } else { + throw new BailError(); + } + return { + kind: "array", + items: items.map((item, index) => { + const scoped = new Map(env); + scoped.set(expr.param.name, item); + if (expr.indexParam) { + scoped.set(expr.indexParam.name, { + kind: "scalar", + code: String(index), + }); + } + return this.eval(expr.body, scoped); + }), + }; + } + case "arrayReduce": + return this.evalReduce(expr, env); + case "arrayConcat": { + const left = this.eval(expr.left, env); + const right = this.eval(expr.right, env); + return { + kind: "placeTokensConcat", + parts: [...this.concatParts(left), ...this.concatParts(right)], + }; + } + case "distribution": { + const args = expr.args.map((argument) => + this.scalar(this.eval(argument, env)), + ); + // Always hoisted to a const: object identity is what makes several + // sinks share one draw at sampling time. + const name = this.names.allocate("__d"); + this.lines.push( + `const ${name} = __dist.${expr.dist}(${args.join(", ")});`, + ); + return { kind: "dist", code: name }; + } + case "distributionMap": { + const base = this.eval(expr.base, env); + if (base.kind !== "dist") { + throw new BailError(); + } + const paramName = this.names.allocate(expr.param.name); + const scoped = new Map(env); + scoped.set(expr.param.name, { kind: "scalar", code: paramName }); + const body = this.evalCallbackBody(expr.body, scoped); + const name = this.names.allocate("__d"); + this.lines.push( + `const ${name} = __dist.map(${base.code}, (${paramName}) => ${body});`, + ); + return { kind: "dist", code: name }; + } + } + throw new BailError(); + } + + /** + * Emits a `.reduce(...)` over place token arrays (metric surface) as + * sequential loops sharing one accumulator — token counts are dynamic, so + * unrolling is impossible. A reduce over a concat emits one loop per part; + * when the index parameter is used, a global running index continues + * across parts. + * + * Note: a reduce nested inside a `cond` branch is evaluated eagerly (the + * loop is emitted unconditionally before the ternary that consumes the + * accumulator). That is semantically safe — the HIR is pure — and only + * costs redundant work on the untaken branch. + */ + private evalReduce( + expr: Extract, + env: Map, + ): Value { + const target = this.eval(expr.target, env); + const parts = this.concatParts(target); + const initial = this.scalar(this.eval(expr.initial, env)); + + const accName = this.names.allocate(expr.accParam.name); + this.lines.push(`let ${accName} = ${initial};`); + let indexName: string | null = null; + if (expr.indexParam) { + indexName = this.names.allocate(expr.indexParam.name); + this.lines.push(`let ${indexName} = 0;`); + } + + for (const part of parts) { + const stride = this.layoutOf(part.elements).strideBytes; + const countName = this.names.allocate("__n"); + const baseName = this.names.allocate("__b"); + const iterName = this.names.allocate("__i"); + + const scoped = new Map(env); + scoped.set(expr.accParam.name, { kind: "scalar", code: accName }); + scoped.set(expr.param.name, { + kind: "token", + baseCode: + stride === 0 ? baseName : `${baseName} + ${iterName} * ${stride}`, + elements: part.elements, + }); + if (expr.indexParam && indexName) { + scoped.set(expr.indexParam.name, { kind: "scalar", code: indexName }); + } + + // Body `const` bindings (and nested reduce loops) evaluated after this + // mark belong inside the per-iteration loop body. + const mark = this.lines.length; + const body = this.scalar(this.eval(expr.body, scoped)); + const bodyLines = this.lines.splice(mark); + + this.lines.push( + `{ const ${countName} = ${this.metricCountCode(part.ordinal)}; const ${baseName} = placeOffsets[__places[${part.ordinal}]];`, + ` for (let ${iterName} = 0; ${iterName} < ${countName}; ${iterName}++) {`, + ...bodyLines.map((line) => ` ${line}`), + ` ${accName} = ${body};`, + ...(indexName ? [` ${indexName} += 1;`] : []), + ` }`, + `}`, + ); + } + + return { kind: "scalar", code: accName }; + } + + /** + * Evaluates a distribution-map callback body to a self-contained JS body + * (its `let` bindings must stay inside the callback, not hoisted). + */ + private evalCallbackBody(expr: HirExpr, env: Map): string { + if (expr.kind !== "let") { + return `(${this.scalar(this.eval(expr, env))})`; + } + const statements: string[] = []; + const scoped = new Map(env); + for (const binding of expr.bindings) { + const value = this.eval(binding.value, scoped); + if (value.kind !== "scalar") { + throw new BailError(); + } + const name = this.names.allocate(binding.name); + statements.push(`const ${name} = ${value.code};`); + scoped.set(binding.name, { kind: "scalar", code: name }); + } + statements.push(`return ${this.scalar(this.eval(expr.body, scoped))};`); + return `{ ${statements.join(" ")} }`; + } + + /** Binds a `const`: scalars and distributions become hoisted consts (so + * they evaluate once); structural values stay symbolic. */ + hoist(name: string, value: Value): Value { + if (value.kind === "scalar") { + const jsName = this.names.allocate(name); + this.lines.push(`const ${jsName} = ${value.code};`); + return { kind: "scalar", code: jsName }; + } + if (value.kind === "dist") { + // Distribution constructions are already hoisted consts; a `cond` of + // two dists is cheap to re-evaluate but must keep identity — hoist it. + if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(value.code)) { + const jsName = this.names.allocate(name); + this.lines.push(`const ${jsName} = ${value.code};`); + return { kind: "dist", code: jsName }; + } + return value; + } + return value; + } +} + +function initialEnv(fn: HirFunction): Map { + const env = new Map(); + const tokensParam = fn.params[0]; + if (tokensParam) { + env.set(tokensParam.name, { kind: "inputRecord" }); + } + return env; +} + +function slotCount(inputSlots: HirArcSlot[]): number { + return inputSlots.reduce((sum, slot) => sum + slot.tokenCount, 0); +} + +/** + * Emits a buffer-ABI lambda: + * + * (f64, u64, u8, placeBases, indices) => number | boolean + * + * Reads `__params` for model parameters (bound at instantiation). Returns + * `null` when the function shape cannot be scalarized. + */ +export function emitBufferLambdaJs( + fn: HirFunction, + context: HirLambdaContext, +): BufferProgram | null { + try { + const emitter = new BufferEmitter(context.inputSlots); + const result = emitter.eval(foldHir(fn.body), initialEnv(fn)); + if (result.kind !== "scalar") { + return null; + } + const source = [ + `(f64, u64, u8, placeBases, indices) => {`, + ...emitter.lines.map((line) => ` ${line}`), + ` return ${result.code};`, + `}`, + ].join("\n"); + return { source, inputSlotCount: slotCount(context.inputSlots) }; + } catch (error) { + if (error instanceof BailError) { + return null; + } + throw error; + } +} + +/** + * Emits a buffer-ABI kernel (token format v2): + * + * (f64, u64, u8, placeBases, indices, outF64, outU64, outU8, sink) => void + * + * Output attributes are written into per-transition staging bytes at + * compile-time-constant offsets (colored output arcs place-major, tokens + * back-to-back with baked strides). Values that consume the seeded RNG — + * distributions, generated/converted UUIDs — are deferred through + * `sink(kind, index, payload)`, and the emitted calls follow (arc, token, + * element-declaration) order so the engine reproduces the exact RNG stream + * by processing them in call order. String attributes intern through + * `__pool` inline. Returns `null` for shapes that don't scalarize (which + * `compileHirArtifacts` reports as a compile failure — there is no other + * program). + */ +export function emitBufferKernelJs( + fn: HirFunction, + context: HirKernelContext, +): BufferKernelProgram | null { + try { + const emitter = new BufferEmitter(context.inputSlots); + const result = emitter.eval(foldHir(fn.body), initialEnv(fn)); + if (result.kind !== "record") { + return null; + } + + const writes: string[] = []; + let byteBase = 0; + + for (const slot of context.outputSlots) { + const layout = computeTokenSlotLayout( + slot.elements.map((element, index) => ({ + elementId: String(index), + name: element.name, + type: element.type, + })), + ); + const entry = result.fields.get(slot.name); + if (!entry) { + return null; + } + + // Resolve the entry to one token Value per output slot position. + let tokens: Value[]; + if (entry.kind === "array") { + tokens = entry.items; + } else if (entry.kind === "tokens") { + // Whole input tuple forwarded to an output place. + if (entry.slot.tokenCount !== slot.tokenCount) { + return null; + } + tokens = Array.from({ length: entry.slot.tokenCount }, (_, index) => + emitter.tokenValueOf(entry.slot, index), + ); + } else { + return null; + } + if (tokens.length !== slot.tokenCount) { + return null; + } + + /* eslint-disable no-bitwise -- `at >> 3` converts compile-time-constant + byte offsets of 8-aligned fields to 64-bit lane indices */ + for (const [tokenIndex, token] of tokens.entries()) { + const tokenBase = byteBase + tokenIndex * layout.strideBytes; + // Element declaration order — this is the engine's RNG order. + for (const element of slot.elements) { + const field = layout.fields.find( + (candidate) => candidate.element.name === element.name, + )!; + const at = tokenBase + field.byteOffset; + + let value: Value | undefined; + if (token.kind === "record") { + value = token.fields.get(element.name); + } else if (token.kind === "token") { + value = emitter.readTokenField(token, element.name); + } else { + return null; + } + + // Omitted uuid attributes auto-generate from the seeded RNG. + if (value === undefined) { + if (element.type === "uuid") { + writes.push(`__sink("generate", ${at >> 3}, 0);`); + continue; + } + return null; + } + + if (value.kind === "dist") { + if (element.type !== "real") { + return null; + } + writes.push(`__sink("dist", ${at >> 3}, ${value.code});`); + continue; + } + if (value.kind === "uuidSentinel") { + writes.push( + value.mode === "generate" + ? `__sink("generate", ${at >> 3}, 0);` + : `__sink("from", ${at >> 3}, ${value.code});`, + ); + continue; + } + if (value.kind !== "scalar") { + return null; + } + + switch (element.type) { + case "real": + writes.push(`outF64[${at >> 3}] = ${value.code};`); + break; + case "integer": + writes.push(`outF64[${at >> 3}] = Math.round(${value.code});`); + break; + case "boolean": + writes.push(`outU8[${at}] = (${value.code}) ? 1 : 0;`); + break; + case "string": + writes.push( + `outU64[${at >> 3}] = BigInt(__pool.intern(${value.code}));`, + ); + break; + case "uuid": + // A uuid-typed scalar is a bigint (copied from an input token) + // or a string (converted deterministically) — defer strings. + writes.push(`__sink("from", ${at >> 3}, ${value.code});`); + break; + } + } + } + /* eslint-enable no-bitwise */ + + byteBase += slot.tokenCount * layout.strideBytes; + } + + const source = [ + `(f64, u64, u8, placeBases, indices, outF64, outU64, outU8, __sink) => {`, + ...emitter.lines.map((line) => ` ${line}`), + ...writes.map((line) => ` ${line}`), + `}`, + ].join("\n"); + return { + source, + inputSlotCount: slotCount(context.inputSlots), + outputByteCount: byteBase, + }; + } catch (error) { + if (error instanceof BailError) { + return null; + } + throw error; + } +} + +/** + * Emits a buffer-ABI metric program (token format v2): + * + * (f64, u64, u8, placeCounts, placeOffsets) => number + * + * - `f64`/`u64`/`u8` — shared views over the frame's token byte region. + * - `placeCounts`/`placeOffsets` — the frame's dense per-place token counts + * and byte offsets (Monte-Carlo frame buffer / engine frame fields). + * - `__places` (instantiation-bound `Int32Array`) maps each emitted place + * ordinal (see `placeNames`) to its frame place index; `__pool` resolves + * interned string attributes. + * + * `state.places..count` reads compile to `placeCounts` lookups; + * `.tokens` accesses index the packed token structs at compile-time-constant + * strides/offsets; `.reduce(...)` over token arrays (or `.concat(...)`s of + * them) compiles to loops since counts are dynamic. Returns `null` for + * shapes that don't scalarize — `compileHirArtifacts` reports those as + * compile failures (there is no other metric program). + */ +export function emitBufferMetricJs( + fn: HirFunction, + context: HirMetricContext, +): BufferMetricProgram | null { + const stateParam = fn.params[0]; + if (!stateParam) { + return null; + } + try { + const emitter = new BufferEmitter([], context); + const env = new Map(); + env.set(stateParam.name, { kind: "metricState" }); + const result = emitter.eval(foldHir(fn.body), env); + if (result.kind !== "scalar") { + return null; + } + const source = [ + `(f64, u64, u8, placeCounts, placeOffsets) => {`, + ...emitter.lines.map((line) => ` ${line}`), + ` return ${result.code};`, + `}`, + ].join("\n"); + return { source, placeNames: emitter.placeNames }; + } catch (error) { + if (error instanceof BailError) { + return null; + } + throw error; + } +} + +/** + * Compiles a dynamics function to the buffer-native v2 shape: + * + * (placeBytes: Uint8Array, numberOfTokens) => Float64Array + * + * Reads token attributes at packed-struct byte offsets through views created + * once per call; derivatives are written flat as `numberOfTokens × realField` + * in layout field order (matching `TokenSlotLayout.realFieldF64Offsets`). + * References `__params` / `__pool`, bound at instantiation. Returns `null` + * when the body doesn't fit the `tokens.map((token, index?) => ({ ... }))` + * shape. + */ +export function emitBufferDynamicsJs( + fn: HirFunction, + elements: readonly HirTokenElementInfo[], +): string | null { + const tokensParam = fn.params[0]; + if (!tokensParam) { + return null; + } + const layout = computeTokenSlotLayout( + elements.map((element, index) => ({ + elementId: String(index), + name: element.name, + type: element.type, + })), + ); + const realFields = layout.fields.filter( + (field) => field.element.type === "real", + ); + if (realFields.length === 0) { + return null; + } + + let body = foldHir(fn.body); + let outerBindings: Extract["bindings"] = []; + if (body.kind === "let") { + outerBindings = body.bindings; + body = body.body; + } + if ( + body.kind !== "arrayMap" || + body.target.kind !== "localRef" || + body.target.name !== tokensParam.name + ) { + return null; + } + const mapBody = body.body; + const innerBindings = mapBody.kind === "let" ? mapBody.bindings : []; + const record = mapBody.kind === "let" ? mapBody.body : mapBody; + if (record.kind !== "recordLit") { + return null; + } + + try { + const emitter = new BufferEmitter([]); + const env = new Map(); + + // Token-independent bindings, hoisted before the loop. + for (const binding of outerBindings) { + env.set( + binding.name, + emitter.hoist(binding.name, emitter.eval(binding.value, env)), + ); + } + const hoisted = [...emitter.lines]; + emitter.lines.length = 0; + + const loopEnv = new Map(env); + loopEnv.set(body.param.name, { + kind: "token", + baseCode: "__b", + elements: [...elements], + }); + if (body.indexParam) { + loopEnv.set(body.indexParam.name, { kind: "scalar", code: "__i" }); + } + for (const binding of innerBindings) { + loopEnv.set( + binding.name, + emitter.hoist(binding.name, emitter.eval(binding.value, loopEnv)), + ); + } + + const writes: string[] = []; + for (const [fieldIndex, field] of realFields.entries()) { + const entry = record.entries.find( + (candidate) => candidate.key === field.element.name, + ); + // Missing real derivatives default to 0 (`out` is zero-initialised), + // matching the object adapter's `?? 0`. + if (!entry) { + continue; + } + const value = emitter.eval(entry.value, loopEnv); + if (value.kind !== "scalar") { + throw new BailError(); + } + writes.push( + ` out[__i * ${realFields.length} + ${fieldIndex}] = ${value.code};`, + ); + } + const perIteration = emitter.lines; + + return [ + `(placeBytes, numberOfTokens) => {`, + ` "use strict";`, + ` const f64 = new Float64Array(placeBytes.buffer, placeBytes.byteOffset, placeBytes.byteLength >> 3);`, + ` const u64 = new BigUint64Array(placeBytes.buffer, placeBytes.byteOffset, placeBytes.byteLength >> 3);`, + ` const u8 = placeBytes;`, + ...hoisted.map((line) => ` ${line}`), + ` const out = new Float64Array(numberOfTokens * ${realFields.length});`, + ` for (let __i = 0; __i < numberOfTokens; __i++) {`, + ` const __b = __i * ${layout.strideBytes};`, + ...perIteration.map((line) => ` ${line}`), + ...writes, + ` }`, + ` return out;`, + `}`, + ].join("\n"); + } catch (error) { + if (error instanceof BailError) { + return null; + } + throw error; + } +} diff --git a/libs/@hashintel/petrinaut-core/src/hir/emit-js.test.ts b/libs/@hashintel/petrinaut-core/src/hir/emit-js.test.ts new file mode 100644 index 00000000000..96d0d9d856f --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/hir/emit-js.test.ts @@ -0,0 +1,141 @@ +/** + * Behavioral tests for the object-convention emitter (`emitUserFunctionJs`). + * The simulator only runs buffer programs now, but the object emitter remains + * a reference backend — these tests instantiate its emitted source directly + * and evaluate it with sample inputs. + */ +import { describe, expect, it } from "vitest"; + +import { emitUserFunctionJs } from "./emit-js"; +import { hirDistributionRuntime } from "./instantiate"; +import { lowerTypeScriptToHir } from "./lower-typescript"; + +import type { RuntimeDistribution } from "../simulation/authoring/user-code/distribution"; + +type UserFunction = (tokens: unknown, parameters?: unknown) => unknown; + +function compileUserFunction( + code: string, + surface: "lambda" | "kernel", +): UserFunction { + const lowered = lowerTypeScriptToHir(code, surface); + if (!lowered.ok) { + throw new Error(lowered.diagnostics[0]?.message); + } + const source = emitUserFunctionJs(lowered.fn); + // eslint-disable-next-line no-new-func, @typescript-eslint/no-implied-eval, @typescript-eslint/no-unsafe-call + return new Function("__dist", `"use strict"; return (${source});`)( + hirDistributionRuntime, + ) as UserFunction; +} + +describe("emitUserFunctionJs (lambda)", () => { + it("compiles lambdas with bindings, length and conditionals", () => { + const hirFn = compileUserFunction( + `export default Lambda((input, parameters) => { + const pressure = input.Pool.length * parameters.rate; + return pressure > 10 ? Infinity : pressure; +});`, + "lambda", + ); + + const tokensByPlace = { Pool: [{ x: 1 }, { x: 2 }, { x: 3 }] }; + expect(hirFn(tokensByPlace, { rate: 0.5 })).toBe(1.5); + expect(hirFn(tokensByPlace, { rate: 2 })).toBe(6); + expect(hirFn(tokensByPlace, { rate: 5 })).toBe(Infinity); + }); + + it("supports predicates over token attributes", () => { + const fn = compileUserFunction( + `export default Lambda((input, parameters) => input.Pool[0].active && input.Pool[0].x >= parameters.threshold);`, + "lambda", + ); + expect(fn({ Pool: [{ active: true, x: 5 }] }, { threshold: 4 })).toBe(true); + expect(fn({ Pool: [{ active: true, x: 3 }] }, { threshold: 4 })).toBe( + false, + ); + }); + + it("rejects out-of-subset code at lowering", () => { + expect( + lowerTypeScriptToHir( + `export default Lambda((input) => { let x = 1; return x; });`, + "lambda", + ).ok, + ).toBe(false); + }); + + it("renames user bindings that collide with emitter internals", () => { + const fn = compileUserFunction( + `export default Lambda((input, parameters) => { + const __params = parameters.rate * 2; + return __params + 1; +});`, + "lambda", + ); + expect(fn({}, { rate: 3 })).toBe(7); + }); +}); + +describe("emitUserFunctionJs (kernel)", () => { + it("produces runtime distribution objects compatible with the engine", () => { + const fn = compileUserFunction( + `export default TransitionKernel((input, parameters) => { + const noise = Distribution.Gaussian(0, parameters.sigma); + return { Out: [{ x: noise.map((value) => value * 2), y: 1 }] }; +});`, + "kernel", + ); + const output = fn({}, { sigma: 3 }) as Record< + string, + Record[] + >; + const x = output.Out![0]!.x as RuntimeDistribution; + expect(x.__brand).toBe("distribution"); + expect(x.type).toBe("mapped"); + if (x.type === "mapped") { + expect(x.inner).toMatchObject({ + type: "gaussian", + mean: 0, + deviation: 3, + }); + expect(x.fn(2)).toBe(4); + } + expect(output.Out![0]!.y).toBe(1); + }); + + it("shares one distribution object across aliased outputs", () => { + const fn = compileUserFunction( + `export default TransitionKernel((input) => { + const d = Distribution.Uniform(0, 1); + return { Out: [{ x: d, y: d }] }; +});`, + "kernel", + ); + const output = fn({}, {}) as Record[]>; + // Same object identity → the engine's sample cache yields one draw. + expect(output.Out![0]!.x).toBe(output.Out![0]!.y); + }); + + it("passes input tokens through .map kernels", () => { + const fn = compileUserFunction( + `export default TransitionKernel((input, parameters) => ({ + Out: input.In.map((token, index) => ({ x: token.x + index, y: token.y * parameters.k })), +}));`, + "kernel", + ); + const output = fn( + { + In: [ + { x: 1, y: 2 }, + { x: 10, y: 20 }, + ], + }, + { k: 3 }, + ) as Record; + expect(output.Out).toEqual([ + { x: 1, y: 6 }, + { x: 11, y: 60 }, + ]); + }); +}); diff --git a/libs/@hashintel/petrinaut-core/src/hir/emit-js.ts b/libs/@hashintel/petrinaut-core/src/hir/emit-js.ts new file mode 100644 index 00000000000..39f191e37e2 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/hir/emit-js.ts @@ -0,0 +1,251 @@ +/** + * Compiles HIR functions to JavaScript source. + * + * This object-convention emitter is kept as a reference/test backend. Runtime + * simulation uses `emit-buffer-js.ts` artifacts only; unsupported buffer shapes + * are compile errors rather than fallback calls into this module. + */ +import { foldHir } from "./analyze"; + +import type { HirExpr, HirFunction } from "./hir"; + +/** Names the emitted code relies on; user locals are renamed away from them. */ +const RESERVED_NAMES = [ + "__dist", + "__params", + "currentState", + "dimensions", + "numberOfTokens", + "result", + "Math", + "Infinity", + "NaN", +]; + +class NameAllocator { + private readonly used: Set; + + constructor(used: Iterable) { + this.used = new Set(used); + } + + child(): NameAllocator { + return new NameAllocator(this.used); + } + + allocate(preferred: string): string { + let name = preferred; + let suffix = 2; + while (this.used.has(name)) { + name = `${preferred}_${suffix}`; + suffix += 1; + } + this.used.add(name); + return name; + } +} + +type EmitScope = { + /** HIR local name → emitted JS name. */ + names: Map; + allocator: NameAllocator; +}; + +function childScope(scope: EmitScope): EmitScope { + return { names: new Map(scope.names), allocator: scope.allocator.child() }; +} + +function quoteKey(key: string): string { + return JSON.stringify(key); +} + +function emitNumber(value: number, raw: string): string { + // Preserve the exact source spelling when it still parses to the same + // value; otherwise round-trip through String (lossless for doubles). + if ( + Number(raw) === value || + (Number.isNaN(Number(raw)) && Number.isNaN(value)) + ) { + return Number.isNaN(Number(raw)) ? String(value) : raw; + } + if (value === Infinity) { + return "Infinity"; + } + if (value === -Infinity) { + return "-Infinity"; + } + return String(value); +} + +function emitExpr(expr: HirExpr, scope: EmitScope): string { + switch (expr.kind) { + case "numberLit": + return emitNumber(expr.value, expr.raw); + case "boolLit": + return expr.value ? "true" : "false"; + case "stringLit": + return JSON.stringify(expr.value); + case "stringCall": + return `${emitExpr(expr.target, scope)}.${expr.fn}(${emitExpr(expr.argument, scope)})`; + case "uuidGenerate": + // The engine's kernel-output encoder resolves this sentinel by drawing + // from the seeded RNG in element order. + return `({ __petrinautUuid: "generate" })`; + case "uuidFrom": + return `({ __petrinautUuid: "from", value: ${emitExpr(expr.operand, scope)} })`; + case "constant": + switch (expr.name) { + case "PI": + return "Math.PI"; + case "E": + return "Math.E"; + case "Infinity": + return "Infinity"; + case "NaN": + return "NaN"; + } + break; + case "localRef": + return scope.names.get(expr.name) ?? expr.name; + case "paramRef": + return `__params[${quoteKey(expr.name)}]`; + case "fieldAccess": + return `${emitExpr(expr.target, scope)}[${quoteKey(expr.field)}]`; + case "indexAccess": + return `${emitExpr(expr.target, scope)}[${emitExpr(expr.index, scope)}]`; + case "length": + return `${emitExpr(expr.target, scope)}.length`; + case "unary": + return `(${expr.op}${emitExpr(expr.operand, scope)})`; + case "binary": { + const op = expr.op === "==" ? "===" : expr.op === "!=" ? "!==" : expr.op; + return `(${emitExpr(expr.left, scope)} ${op} ${emitExpr(expr.right, scope)})`; + } + case "cond": + return `(${emitExpr(expr.condition, scope)} ? ${emitExpr(expr.thenBranch, scope)} : ${emitExpr(expr.elseBranch, scope)})`; + case "let": + // `let` only appears as a function/callback body; those emit blocks. + // eslint-disable-next-line no-use-before-define -- mutual recursion + return `(() => ${emitBody(expr, scope)})()`; + case "mathCall": + return `Math.${expr.fn}(${expr.args + .map((argument) => emitExpr(argument, scope)) + .join(", ")})`; + case "recordLit": + return `{ ${expr.entries + .map( + (entry) => `${quoteKey(entry.key)}: ${emitExpr(entry.value, scope)}`, + ) + .join(", ")} }`; + case "arrayLit": + return `[${expr.elements + .map((element) => emitExpr(element, scope)) + .join(", ")}]`; + case "arrayMap": { + const bodyScope = childScope(scope); + const paramName = bodyScope.allocator.allocate(expr.param.name); + bodyScope.names.set(expr.param.name, paramName); + let params = paramName; + if (expr.indexParam) { + const indexName = bodyScope.allocator.allocate(expr.indexParam.name); + bodyScope.names.set(expr.indexParam.name, indexName); + params = `${paramName}, ${indexName}`; + } + // eslint-disable-next-line no-use-before-define -- mutual recursion + return `${emitExpr(expr.target, scope)}.map((${params}) => ${emitBody(expr.body, bodyScope)})`; + } + case "arrayReduce": { + const bodyScope = childScope(scope); + const accName = bodyScope.allocator.allocate(expr.accParam.name); + bodyScope.names.set(expr.accParam.name, accName); + const paramName = bodyScope.allocator.allocate(expr.param.name); + bodyScope.names.set(expr.param.name, paramName); + let params = `${accName}, ${paramName}`; + if (expr.indexParam) { + const indexName = bodyScope.allocator.allocate(expr.indexParam.name); + bodyScope.names.set(expr.indexParam.name, indexName); + params = `${params}, ${indexName}`; + } + // eslint-disable-next-line no-use-before-define -- mutual recursion + return `${emitExpr(expr.target, scope)}.reduce((${params}) => ${emitBody(expr.body, bodyScope)}, ${emitExpr(expr.initial, scope)})`; + } + case "arrayConcat": + return `${emitExpr(expr.left, scope)}.concat(${emitExpr(expr.right, scope)})`; + case "distribution": + return `__dist.${expr.dist}(${expr.args + .map((argument) => emitExpr(argument, scope)) + .join(", ")})`; + case "distributionMap": { + const bodyScope = childScope(scope); + const paramName = bodyScope.allocator.allocate(expr.param.name); + bodyScope.names.set(expr.param.name, paramName); + // eslint-disable-next-line no-use-before-define -- mutual recursion + return `__dist.map(${emitExpr(expr.base, scope)}, (${paramName}) => ${emitBody(expr.body, bodyScope)})`; + } + } + throw new Error("Unreachable HIR node in emitExpr"); +} + +/** Emits a callback/function body: a block for `let`, an expression otherwise. */ +function emitBody(expr: HirExpr, scope: EmitScope): string { + if (expr.kind !== "let") { + // Parenthesize object literals so they aren't parsed as blocks. + const emitted = emitExpr(expr, scope); + return expr.kind === "recordLit" ? `(${emitted})` : emitted; + } + const bodyScope = childScope(scope); + const statements: string[] = []; + for (const binding of expr.bindings) { + const value = emitExpr(binding.value, bodyScope); + const name = bodyScope.allocator.allocate(binding.name); + bodyScope.names.set(binding.name, name); + statements.push(`const ${name} = ${value};`); + } + statements.push(`return ${emitExpr(expr.body, bodyScope)};`); + return `{ ${statements.join(" ")} }`; +} + +/** + * Emits a user-function-shaped JavaScript expression: + * `(tokens, parameters) => result`. + * + * The emitted code may reference `__dist` (distribution runtime) and + * `__params` (the parameters object); callers bind both when instantiating. + * The declared `parameters` parameter is accepted for signature compatibility + * but reads go through `__params` — instantiators alias them. + */ +export function emitUserFunctionJs(fn: HirFunction): string { + const folded = foldHir(fn.body); + const allocator = new NameAllocator(RESERVED_NAMES); + const scope: EmitScope = { names: new Map(), allocator }; + + const paramNames = fn.params.map((parameter) => + allocator.allocate(parameter.name), + ); + for (const [index, parameter] of fn.params.entries()) { + scope.names.set(parameter.name, paramNames[index]!); + } + + // The second user parameter *is* the parameters object: alias __params to + // it so emitted `__params[...]` reads resolve to the call argument. + const signature = paramNames.join(", "); + const statements: string[] = []; + if (fn.params.length > 1) { + statements.push(` const __params = ${paramNames[1]!};`); + } + + if (folded.kind === "let") { + const bodyScope = childScope(scope); + for (const binding of folded.bindings) { + const value = emitExpr(binding.value, bodyScope); + const name = bodyScope.allocator.allocate(binding.name); + bodyScope.names.set(binding.name, name); + statements.push(` const ${name} = ${value};`); + } + statements.push(` return ${emitExpr(folded.body, bodyScope)};`); + return [`(${signature}) => {`, ...statements, `}`].join("\n"); + } + + statements.push(` return ${emitExpr(folded, scope)};`); + return [`(${signature}) => {`, ...statements, `}`].join("\n"); +} diff --git a/libs/@hashintel/petrinaut-core/src/hir/hir.ts b/libs/@hashintel/petrinaut-core/src/hir/hir.ts new file mode 100644 index 00000000000..ff3ac6ef996 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/hir/hir.ts @@ -0,0 +1,471 @@ +/** + * HIR — high-level intermediate representation for Petrinaut user code. + * + * The HIR is a source-spanned, JSON-serializable expression tree that sits + * between the surface language (today TypeScript, later the Petrinaut DSL) and + * the execution backends (JavaScript today, WASM/GPU later). Nodes carry ids + * and spans, not inferred types; `typecheck.ts` computes a separate + * `Map`. + * + * Design invariants: + * - Every node carries a stable `id` (unique within one `HirFunction`) and a + * `span` into the *user-visible* source text, so analyses can be keyed off + * nodes without mutating them and diagnostics always land on the right + * range in the editor. + * - The tree is pure and expression-oriented (OCaml-like): `let` bindings, + * conditionals and `map` comprehensions instead of statements and loops. + * There is no mutation and no recursion, which makes symbolic evaluation + * (distribution DAGs, dependency sets, constant folding) decidable. + * - Probability distributions are first-class node kinds, not opaque calls, + * so the sampling structure of a transition kernel is directly visible. + */ + +/** Half-open span into the user-visible source text, in UTF-16 code units. */ +export type Span = { + start: number; + length: number; +}; + +/** Node identifier, unique within a single `HirFunction`. */ +export type HirNodeId = number; + +/** + * The user-code surfaces the HIR can currently represent. + * + * `dynamics` — differential equations (`Dynamics(...)`) + * `lambda` — transition firing predicates / stochastic rates (`Lambda(...)`) + * `kernel` — transition kernels (`TransitionKernel(...)`) + * `metric` — Monte-Carlo/timeline metrics: a bare function *body* over a + * `state` object (statements ending in `return `), not an + * `export default` module. + * + * Scenario expressions are expressible with the same node set but do not + * have a lowering entry point yet (see `README.md`). + */ +export type HirSurfaceKind = "dynamics" | "lambda" | "kernel" | "metric"; + +/** Scalar and structural types inferred over HIR nodes. */ +export type HirType = + | { kind: "real" } + | { kind: "int" } + | { kind: "bool" } + | { + kind: "record"; + fields: { name: string; type: HirType }[]; + } + | { + kind: "array"; + element: HirType; + /** Statically-known length (e.g. arc-weight token tuples). */ + length?: number; + } + /** A 128-bit identifier attribute (surfaced to user code as `bigint`). */ + | { kind: "uuid" } + /** An interned string attribute. */ + | { kind: "string" } + /** A probability distribution over reals. */ + | { kind: "distribution" } + | { kind: "unknown" }; + +export const HIR_TYPE_REAL: HirType = { kind: "real" }; +export const HIR_TYPE_INT: HirType = { kind: "int" }; +export const HIR_TYPE_BOOL: HirType = { kind: "bool" }; +export const HIR_TYPE_UUID: HirType = { kind: "uuid" }; +export const HIR_TYPE_STRING: HirType = { kind: "string" }; +export const HIR_TYPE_DISTRIBUTION: HirType = { kind: "distribution" }; +export const HIR_TYPE_UNKNOWN: HirType = { kind: "unknown" }; + +export type HirBinaryOp = + | "+" + | "-" + | "*" + | "/" + | "%" + | "**" + | "<" + | "<=" + | ">" + | ">=" + | "==" + | "!=" + | "&&" + | "||"; + +export type HirUnaryOp = "-" | "+" | "!"; + +/** Math builtins the HIR understands (subset of ECMAScript `Math`). */ +export const HIR_MATH_FNS = [ + "abs", + "acos", + "asin", + "atan", + "atan2", + "cbrt", + "ceil", + "cos", + "cosh", + "exp", + "floor", + "hypot", + "log", + "log10", + "log2", + "max", + "min", + "pow", + "random", + "round", + "sign", + "sin", + "sinh", + "sqrt", + "tan", + "tanh", + "trunc", +] as const; + +export type HirMathFn = (typeof HIR_MATH_FNS)[number]; + +export type HirConstantName = "PI" | "E" | "Infinity" | "NaN"; + +export type HirDistributionKind = "gaussian" | "uniform" | "lognormal"; + +type HirNodeBase = { + id: HirNodeId; + span: Span; +}; + +/** Numeric literal. `raw` preserves the exact source text (e.g. `"1e-9"`). */ +export type HirNumberLit = HirNodeBase & { + kind: "numberLit"; + value: number; + raw: string; +}; + +export type HirBoolLit = HirNodeBase & { + kind: "boolLit"; + value: boolean; +}; + +/** String literal — valid wherever `string` attributes flow (comparisons, + * kernel outputs, uuid coercion). */ +export type HirStringLit = HirNodeBase & { + kind: "stringLit"; + value: string; +}; + +/** `Uuid.generate()` — a fresh UUID drawn from the seeded RNG when the + * transition fires (kernel outputs only). */ +export type HirUuidGenerate = HirNodeBase & { + kind: "uuidGenerate"; +}; + +/** `Uuid.from(value)` — deterministic conversion of a value to a UUID + * (kernel outputs only). */ +export type HirUuidFrom = HirNodeBase & { + kind: "uuidFrom"; + operand: HirExpr; +}; + +export const HIR_STRING_FNS = ["startsWith", "endsWith", "includes"] as const; + +export type HirStringFn = (typeof HIR_STRING_FNS)[number]; + +/** Pure string predicate: `target.startsWith(arg)` etc. */ +export type HirStringCall = HirNodeBase & { + kind: "stringCall"; + fn: HirStringFn; + target: HirExpr; + argument: HirExpr; +}; + +/** Named numeric constant (`Math.PI`, `Infinity`, ...). */ +export type HirConstant = HirNodeBase & { + kind: "constant"; + name: HirConstantName; +}; + +/** Reference to a local binding: a function parameter, `let`/`const` binding, + * or `map` callback parameter. */ +export type HirLocalRef = HirNodeBase & { + kind: "localRef"; + name: string; +}; + +/** Model parameter access — lowered from `parameters.`. */ +export type HirParamRef = HirNodeBase & { + kind: "paramRef"; + name: string; +}; + +/** Record field access: `target.field` / `target["field"]`. */ +export type HirFieldAccess = HirNodeBase & { + kind: "fieldAccess"; + target: HirExpr; + field: string; + /** Span of just the field name, for precise diagnostics. */ + fieldSpan: Span; +}; + +/** Array element access: `target[index]`. */ +export type HirIndexAccess = HirNodeBase & { + kind: "indexAccess"; + target: HirExpr; + index: HirExpr; +}; + +/** Array length access: `target.length`. */ +export type HirLength = HirNodeBase & { + kind: "length"; + target: HirExpr; +}; + +export type HirUnary = HirNodeBase & { + kind: "unary"; + op: HirUnaryOp; + operand: HirExpr; +}; + +export type HirBinary = HirNodeBase & { + kind: "binary"; + op: HirBinaryOp; + left: HirExpr; + right: HirExpr; +}; + +/** Conditional expression — lowered from ternaries. */ +export type HirCond = HirNodeBase & { + kind: "cond"; + condition: HirExpr; + thenBranch: HirExpr; + elseBranch: HirExpr; +}; + +export type HirLetBinding = { + name: string; + nameSpan: Span; + value: HirExpr; +}; + +/** Sequential (non-recursive) bindings scoping a body expression — lowered + * from `const` statements followed by `return`. */ +export type HirLet = HirNodeBase & { + kind: "let"; + bindings: HirLetBinding[]; + body: HirExpr; +}; + +export type HirMathCall = HirNodeBase & { + kind: "mathCall"; + fn: HirMathFn; + args: HirExpr[]; +}; + +export type HirRecordEntry = { + key: string; + keySpan: Span; + value: HirExpr; +}; + +export type HirRecordLit = HirNodeBase & { + kind: "recordLit"; + entries: HirRecordEntry[]; +}; + +export type HirArrayLit = HirNodeBase & { + kind: "arrayLit"; + elements: HirExpr[]; +}; + +/** + * Array comprehension — lowered from `collection.map((param, indexParam) => + * body)`. Object-destructured callback parameters are desugared during + * lowering into `fieldAccess(localRef(param), field)` nodes. + */ +export type HirArrayMap = HirNodeBase & { + kind: "arrayMap"; + target: HirExpr; + param: { name: string; span: Span }; + indexParam?: { name: string; span: Span }; + body: HirExpr; +}; + +/** + * Array fold — lowered from `collection.reduce((acc, element, index?) => + * body, initial)`. The callback must be an inline arrow/function expression + * with 2–3 parameters, and `.reduce` takes exactly two call arguments. + */ +export type HirArrayReduce = HirNodeBase & { + kind: "arrayReduce"; + target: HirExpr; + accParam: { name: string; span: Span }; + param: { name: string; span: Span }; + indexParam?: { name: string; span: Span }; + body: HirExpr; + initial: HirExpr; +}; + +/** Array concatenation — lowered from `a.concat(b)` (single argument). */ +export type HirArrayConcat = HirNodeBase & { + kind: "arrayConcat"; + left: HirExpr; + right: HirExpr; +}; + +/** Distribution construction: `Distribution.Gaussian(mean, deviation)`, ... */ +export type HirDistribution = HirNodeBase & { + kind: "distribution"; + dist: HirDistributionKind; + args: HirExpr[]; +}; + +/** Derived distribution: `base.map((param) => body)`. */ +export type HirDistributionMap = HirNodeBase & { + kind: "distributionMap"; + base: HirExpr; + param: { name: string; span: Span }; + body: HirExpr; +}; + +export type HirExpr = + | HirNumberLit + | HirBoolLit + | HirStringLit + | HirStringCall + | HirUuidGenerate + | HirUuidFrom + | HirConstant + | HirLocalRef + | HirParamRef + | HirFieldAccess + | HirIndexAccess + | HirLength + | HirUnary + | HirBinary + | HirCond + | HirLet + | HirMathCall + | HirRecordLit + | HirArrayLit + | HirArrayMap + | HirArrayReduce + | HirArrayConcat + | HirDistribution + | HirDistributionMap; + +/** + * A lowered user function. `params[0]` is the tokens/input parameter, + * `params[1]` (when present) is the parameters object — its property + * accesses are lowered to `paramRef` nodes, so it never appears as a + * `localRef` in the body. + */ +export type HirFunction = { + hirVersion: 1; + surface: HirSurfaceKind; + params: { name: string; span: Span }[]; + body: HirExpr; + /** Span of the whole user function expression in the source text. */ + span: Span; +}; + +export type HirDiagnosticSeverity = "error" | "warning" | "info" | "hint"; + +/** + * Diagnostic produced by lowering or by HIR analyses. Spans are always + * relative to the user-visible source text (not any generated wrapper), so + * they can be surfaced in the editor without further adjustment. + */ +export type HirDiagnostic = { + /** Stable string code, e.g. `"hir:unsupported-syntax"`. */ + code: string; + message: string; + severity: HirDiagnosticSeverity; + span: Span; +}; + +/** Returns the direct children of a node, in source order. */ +export function hirChildren(expr: HirExpr): HirExpr[] { + switch (expr.kind) { + case "numberLit": + case "boolLit": + case "stringLit": + case "uuidGenerate": + case "constant": + case "localRef": + case "paramRef": + return []; + case "uuidFrom": + return [expr.operand]; + case "stringCall": + return [expr.target, expr.argument]; + case "fieldAccess": + return [expr.target]; + case "indexAccess": + return [expr.target, expr.index]; + case "length": + return [expr.target]; + case "unary": + return [expr.operand]; + case "binary": + return [expr.left, expr.right]; + case "cond": + return [expr.condition, expr.thenBranch, expr.elseBranch]; + case "let": + return [...expr.bindings.map((binding) => binding.value), expr.body]; + case "mathCall": + return expr.args; + case "recordLit": + return expr.entries.map((entry) => entry.value); + case "arrayLit": + return expr.elements; + case "arrayMap": + return [expr.target, expr.body]; + case "arrayReduce": + // Source order: target, callback body, initial value. + return [expr.target, expr.body, expr.initial]; + case "arrayConcat": + return [expr.left, expr.right]; + case "distribution": + return expr.args; + case "distributionMap": + return [expr.base, expr.body]; + } +} + +/** Depth-first pre-order walk over an expression tree. */ +export function walkHir(expr: HirExpr, visit: (node: HirExpr) => void): void { + visit(expr); + for (const child of hirChildren(expr)) { + walkHir(child, visit); + } +} + +/** Formats a type for use in diagnostics. */ +export function formatHirType(type: HirType): string { + switch (type.kind) { + case "real": + return "real"; + case "int": + return "integer"; + case "bool": + return "boolean"; + case "uuid": + return "uuid"; + case "string": + return "string"; + case "distribution": + return "Distribution"; + case "record": + return `{ ${type.fields + .map((field) => `${field.name}: ${formatHirType(field.type)}`) + .join("; ")} }`; + case "array": + return type.length === undefined + ? `${formatHirType(type.element)}[]` + : `[${Array.from({ length: type.length }) + .fill(formatHirType(type.element)) + .join(", ")}]`; + case "unknown": + return "unknown"; + } +} diff --git a/libs/@hashintel/petrinaut-core/src/hir/instantiate.ts b/libs/@hashintel/petrinaut-core/src/hir/instantiate.ts new file mode 100644 index 00000000000..5d1e103875c --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/hir/instantiate.ts @@ -0,0 +1,225 @@ +/** + * Instantiation of HIR-emitted JavaScript programs. + * + * Deliberately free of any compiler dependency (no `typescript` import): the + * simulation workers instantiate precompiled artifact sources without + * bundling the TS→HIR frontend. Compilation lives in `compile.ts`; sources + * are emitted by `emit-buffer-js.ts` (the simulator's only program shape — + * packed-struct buffer access with compile-time-constant offsets/strides). + */ +import type { RuntimeDistribution } from "../simulation/authoring/user-code/distribution"; + +export type HirParameterValues = Record; + +/** Per-run string pool view needed by compiled programs. Structural subset + * of the engine's `StringPool`. */ +export type HirStringPool = { + get(id: number): string; + intern(value: string): number; +}; + +/** Read-only pool view — all metric programs need (they never intern). */ +export type HirStringPoolReader = Pick; + +/** + * Deferred kernel-output slots that consume the seeded RNG or need engine + * conversion. Emitted calls arrive in (arc, token, element-declaration) + * order — process them in call order to reproduce the RNG stream. + * `index` is a 64-bit lane index into the staging buffer. + */ +export type HirKernelSink = ( + kind: "dist" | "generate" | "from", + index: number, + payload: unknown, +) => void; + +/** Buffer-native dynamics (token format v2: one place's packed token bytes). + * Parameters and the string pool are pre-bound. */ +export type HirCompiledBufferDynamics = ( + placeBytes: Uint8Array, + numberOfTokens: number, +) => Float64Array; + +/** + * Buffer-ABI lambda: reads token attributes at compile-time-constant byte + * offsets through the frame's shared views. `placeBases[arc]` is each input + * arc's place base byte offset; `indices[slot]` the selected token index per + * slot (strides are baked into the program). Parameters/pool pre-bound. + */ +export type HirCompiledBufferLambda = ( + f64: Float64Array, + u64: BigUint64Array, + u8: Uint8Array, + placeBases: Int32Array, + indices: Int32Array, +) => number | boolean; + +/** + * Buffer-ABI metric: reads a frame's packed token region through shared + * views plus its dense per-place `placeCounts`/`placeOffsets` arrays and + * returns one number. The place-ordinal→frame-place-index table and string + * pool are pre-bound at instantiation. + */ +export type HirCompiledMetric = ( + f64: Float64Array, + u64: BigUint64Array, + u8: Uint8Array, + placeCounts: Uint32Array, + placeOffsets: Uint32Array, +) => number; + +/** Buffer-ABI kernel: writes output attributes into per-transition staging + * (place-major, baked offsets); RNG-consuming slots defer through the sink. */ +export type HirCompiledBufferKernel = ( + f64: Float64Array, + u64: BigUint64Array, + u8: Uint8Array, + placeBases: Int32Array, + indices: Int32Array, + outF64: Float64Array, + outU64: BigUint64Array, + outU8: Uint8Array, + sink: HirKernelSink, +) => void; + +export type HirLambdaArtifact = { + source: string; + /** Expected `indices.length` — engine-side sanity check. */ + inputSlotCount: number; +}; + +export type HirKernelArtifact = { + source: string; + inputSlotCount: number; + /** Expected staging byte length — engine-side sanity check. */ + outputByteCount: number; +}; + +export type HirDynamicsArtifact = { + source: string; +}; + +export type HirMetricArtifact = { + source: string; + /** Places referenced by the program, in `__places` ordinal order. */ + placeNames: string[]; +}; + +/** + * Precompiled HIR programs for one SDCPN, keyed by item id (differential + * equation id / transition id / metric id, pre-flattening — the engine + * resolves flattened `path::id` ids back to their source id). Produced by + * `compileHirArtifacts`; the engine has no other compilation path. + */ +export type HirArtifacts = { + version: 3; + dynamics: Record; + lambdas: Record; + kernels: Record; + metrics: Record; +}; + +/** + * Distribution constructors injected into emitted code as `__dist`. + */ +export const hirDistributionRuntime = { + gaussian: (mean: number, deviation: number): RuntimeDistribution => ({ + __brand: "distribution", + type: "gaussian", + mean, + deviation, + }), + uniform: (min: number, max: number): RuntimeDistribution => ({ + __brand: "distribution", + type: "uniform", + min, + max, + }), + lognormal: (mu: number, sigma: number): RuntimeDistribution => ({ + __brand: "distribution", + type: "lognormal", + mu, + sigma, + }), + map: ( + inner: RuntimeDistribution, + fn: (value: number) => number, + ): RuntimeDistribution => ({ + __brand: "distribution", + type: "mapped", + inner, + fn, + }), +}; + +function instantiate( + source: string, + parameterValues: HirParameterValues, + stringPool: HirStringPool, +): unknown { + // eslint-disable-next-line no-new-func, @typescript-eslint/no-implied-eval, @typescript-eslint/no-unsafe-call + return new Function( + "__dist", + "__params", + "__pool", + `"use strict"; return (${source});`, + )(hirDistributionRuntime, parameterValues, stringPool); +} + +export function instantiateHirBufferDynamics( + source: string, + parameterValues: HirParameterValues, + stringPool: HirStringPool, +): HirCompiledBufferDynamics { + return instantiate( + source, + parameterValues, + stringPool, + ) as HirCompiledBufferDynamics; +} + +export function instantiateHirBufferLambda( + source: string, + parameterValues: HirParameterValues, + stringPool: HirStringPool, +): HirCompiledBufferLambda { + return instantiate( + source, + parameterValues, + stringPool, + ) as HirCompiledBufferLambda; +} + +export function instantiateHirBufferKernel( + source: string, + parameterValues: HirParameterValues, + stringPool: HirStringPool, +): HirCompiledBufferKernel { + return instantiate( + source, + parameterValues, + stringPool, + ) as HirCompiledBufferKernel; +} + +/** + * Instantiates a compiled metric program. `placeIndices[ordinal]` maps each + * of the artifact's `placeNames` to the frame's place index (resolve once + * per experiment/simulation, not per frame). Metrics have no parameters + * object and never intern strings; `__params`/`__dist` are bound for ABI + * parity with the other programs but unused. + */ +export function instantiateHirMetric( + source: string, + placeIndices: Int32Array, + stringPool: HirStringPoolReader, +): HirCompiledMetric { + // eslint-disable-next-line no-new-func, @typescript-eslint/no-implied-eval, @typescript-eslint/no-unsafe-call + return new Function( + "__dist", + "__params", + "__pool", + "__places", + `"use strict"; return (${source});`, + )(hirDistributionRuntime, {}, stringPool, placeIndices) as HirCompiledMetric; +} diff --git a/libs/@hashintel/petrinaut-core/src/hir/lint.test.ts b/libs/@hashintel/petrinaut-core/src/hir/lint.test.ts new file mode 100644 index 00000000000..a4ce4337038 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/hir/lint.test.ts @@ -0,0 +1,363 @@ +import { describe, expect, it } from "vitest"; + +import { lintHirUserCode } from "./lint"; + +import type { + HirDynamicsContext, + HirKernelContext, + HirLambdaContext, + HirMetricContext, +} from "./surface-context"; + +const dynamicsContext: HirDynamicsContext = { + surface: "dynamics", + parameters: [{ name: "g", type: "real" }], + elements: [ + { name: "x", type: "real" }, + { name: "v", type: "real" }, + { name: "count", type: "integer" }, + { name: "alive", type: "boolean" }, + ], +}; + +const poolBinding = { + name: "Pool", + colorId: "color-1", + elements: [ + { name: "x", type: "real" as const }, + { name: "alive", type: "boolean" as const }, + ], + tokenCount: 2, +}; + +const outBinding = { + name: "Out", + colorId: "color-2", + elements: [ + { name: "x", type: "real" as const }, + { name: "count", type: "integer" as const }, + ], + tokenCount: 1, +}; + +const lambdaContext: HirLambdaContext = { + surface: "lambda", + parameters: [{ name: "rate", type: "real" }], + inputPlaces: [poolBinding], + inputSlots: [{ ...poolBinding, slotStart: 0 }], + lambdaType: "stochastic", +}; + +const kernelContext: HirKernelContext = { + surface: "kernel", + parameters: [{ name: "sigma", type: "real" }], + inputPlaces: lambdaContext.inputPlaces, + inputSlots: lambdaContext.inputSlots, + outputPlaces: [outBinding], + outputSlots: [{ ...outBinding, slotStart: 0 }], + stochasticity: true, +}; + +function codes(code: string, context: Parameters[1]) { + return lintHirUserCode(code, context).diagnostics.map((diagnostic) => [ + diagnostic.code, + diagnostic.severity, + ]); +} + +describe("lintHirUserCode", () => { + it("returns no diagnostics for the default templates", () => { + expect( + codes( + `export default Dynamics((tokens, parameters) => { + return tokens.map(({ x, v }) => { + return { x: 1, v: 1 }; + }); +});`, + dynamicsContext, + ), + ).toEqual([]); + expect( + codes( + `export default Lambda((input, parameters) => 1.0);`, + lambdaContext, + ), + ).toEqual([]); + expect( + codes( + `export default TransitionKernel((input, parameters) => ({ + Out: [{ x: 0, count: 0 }], +}));`, + kernelContext, + ), + ).toEqual([]); + }); + + it("reports out-of-subset code as an error by default", () => { + const result = lintHirUserCode( + `export default Lambda((input) => { let x = 1; return x; });`, + lambdaContext, + ); + expect(result.fn).toBeUndefined(); + expect(result.diagnostics).toHaveLength(1); + expect(result.diagnostics[0]!.severity).toBe("error"); + expect(result.diagnostics[0]!.message).toContain( + "restricted TypeScript subset", + ); + }); + + it("downgrades out-of-subset code to the configured severity", () => { + const result = lintHirUserCode( + `export default Lambda((input) => { let x = 1; return x; });`, + lambdaContext, + { subsetSeverity: "info" }, + ); + expect(result.diagnostics[0]!.severity).toBe("info"); + }); + + it("keeps real errors (unknown identifier) as errors when lowering fails", () => { + const result = lintHirUserCode( + `export default Lambda((input) => nonsense + 1);`, + lambdaContext, + ); + expect(result.diagnostics[0]!.code).toBe("hir:unknown-identifier"); + expect(result.diagnostics[0]!.severity).toBe("error"); + }); + + describe("typecheck rules", () => { + it("flags derivatives on discrete attributes with a friendly message", () => { + const code = `export default Dynamics((tokens, parameters) => { + return tokens.map(({ x }) => { + return { x: 1, count: 1 }; + }); +});`; + const result = lintHirUserCode(code, dynamicsContext); + const diagnostic = result.diagnostics.find( + (candidate) => candidate.code === "hir:discrete-derivative", + )!; + expect(diagnostic.message).toContain("count"); + expect(diagnostic.message).toContain("integer"); + expect( + code.slice( + diagnostic.span.start, + diagnostic.span.start + diagnostic.span.length, + ), + ).toBe("count"); + }); + + it("flags unknown token attributes at the field span", () => { + const code = `export default Lambda((input, parameters) => input.Pool[0].missing > 0);`; + const result = lintHirUserCode(code, lambdaContext); + const diagnostic = result.diagnostics.find( + (candidate) => candidate.code === "hir:unknown-field", + )!; + expect( + code.slice( + diagnostic.span.start, + diagnostic.span.start + diagnostic.span.length, + ), + ).toBe("missing"); + }); + + it("flags unknown parameters and places", () => { + expect( + codes( + `export default Lambda((input, parameters) => parameters.nope);`, + lambdaContext, + ), + ).toContainEqual(["hir:unknown-parameter", "error"]); + expect( + codes( + `export default Lambda((input, parameters) => input.Nowhere[0].x);`, + lambdaContext, + ), + ).toContainEqual(["hir:unknown-field", "error"]); + }); + + it("flags out-of-bounds token indices (arc weight)", () => { + expect( + codes( + `export default Lambda((input, parameters) => input.Pool[2].x);`, + lambdaContext, + ), + ).toContainEqual(["hir:index-out-of-bounds", "error"]); + }); + + it("flags Distribution into a discrete attribute (H-6519 rule)", () => { + expect( + codes( + `export default TransitionKernel((input, parameters) => ({ + Out: [{ x: 0, count: Distribution.Gaussian(0, 1) }], +}));`, + kernelContext, + ), + ).toContainEqual(["hir:distribution-discrete-attribute", "error"]); + }); + + it("flags distributions outside kernels", () => { + expect( + codes( + `export default Lambda((input, parameters) => { + const d = Distribution.Gaussian(0, 1); + return 1.0; +});`, + lambdaContext, + ), + ).toContainEqual(["hir:distribution-outside-kernel", "error"]); + }); + + it("flags unknown output places and token count mismatches", () => { + const results = codes( + `export default TransitionKernel((input, parameters) => ({ + Elsewhere: [{ x: 0 }], + Out: [{ x: 0, count: 0 }, { x: 1, count: 0 }], +}));`, + kernelContext, + ); + expect(results).toContainEqual(["hir:unknown-output-place", "error"]); + expect(results).toContainEqual(["hir:output-token-count", "error"]); + }); + + it("flags missing attributes in kernel outputs", () => { + expect( + codes( + `export default TransitionKernel((input, parameters) => ({ + Out: [{ x: 0 }], +}));`, + kernelContext, + ), + ).toContainEqual(["hir:missing-attribute", "error"]); + }); + + it("flags predicate/stochastic return type mismatches", () => { + expect( + codes(`export default Lambda((input, parameters) => true);`, { + ...lambdaContext, + lambdaType: "stochastic", + }), + ).toContainEqual(["hir:lambda-return", "error"]); + expect( + codes(`export default Lambda((input, parameters) => 1);`, { + ...lambdaContext, + lambdaType: "predicate", + }), + ).toContainEqual(["hir:lambda-return", "error"]); + }); + }); + + describe("semantic rules", () => { + it("warns on Math.random", () => { + expect( + codes( + `export default Lambda((input, parameters) => Math.random());`, + lambdaContext, + ), + ).toContainEqual(["hir:math-random", "warning"]); + }); + + it("warns when a transition can never fire", () => { + expect( + codes( + `export default Lambda((input, parameters) => 2 - 2);`, + lambdaContext, + ), + ).toContainEqual(["hir:transition-never-fires", "warning"]); + expect( + codes(`export default Lambda((input, parameters) => false);`, { + ...lambdaContext, + lambdaType: "predicate", + }), + ).toContainEqual(["hir:transition-never-fires", "warning"]); + }); + + it("informs about shared distribution samples", () => { + expect( + codes( + `export default TransitionKernel((input, parameters) => { + const d = Distribution.Gaussian(0, 1); + return { Out: [{ x: d, count: 0 }] }; +});`, + kernelContext, + ), + ).toEqual([]); + expect( + codes( + `export default TransitionKernel((input, parameters) => { + const d = Distribution.Gaussian(0, 1); + return { Out: [{ x: d, count: 0 }], Out2: [{ x: d }] }; +});`, + { + ...kernelContext, + outputPlaces: [ + ...kernelContext.outputPlaces, + { + name: "Out2", + colorId: "color-3", + elements: [{ name: "x", type: "real" }], + tokenCount: 1, + }, + ], + }, + ), + ).toContainEqual(["hir:shared-sample", "info"]); + }); + + it("lints metric bodies: return type, unknown places, compilability", () => { + const metricContext: HirMetricContext = { + surface: "metric", + parameters: [], + places: [ + { + name: "Pool", + elements: [{ name: "x", type: "real" }], + }, + { name: "Bin", elements: [] }, + ], + }; + + // Clean metric: counts, reduce, concat, guard. + expect( + codes( + `const all = state.places.Pool.tokens; +if (all.length === 0) return state.places.Bin.count; +return all.reduce((sum, t) => sum + t.x, 0) / all.length;`, + metricContext, + ), + ).toEqual([]); + + // Non-numeric result. + expect(codes(`return true;`, metricContext)).toContainEqual([ + "hir:metric-return", + "error", + ]); + + // Unknown place. + expect( + codes(`return state.places.Missing.count;`, metricContext), + ).toContainEqual(["hir:unknown-field", "error"]); + + // Typecheck-clean but not buffer-compilable (map over dynamic tokens). + expect( + codes( + `return state.places.Pool.tokens.map((t) => t.x)[0];`, + metricContext, + ), + ).toContainEqual(["hir:not-compilable", "error"]); + }); + + it("hints at unused bindings but ignores underscore names", () => { + const results = codes( + `export default Lambda((input, parameters) => { + const unused = parameters.rate; + const _ignored = parameters.rate; + return 1.0; +});`, + lambdaContext, + ); + expect(results).toContainEqual(["hir:unused-binding", "hint"]); + expect( + results.filter(([code]) => code === "hir:unused-binding"), + ).toHaveLength(1); + }); + }); +}); diff --git a/libs/@hashintel/petrinaut-core/src/hir/lint.ts b/libs/@hashintel/petrinaut-core/src/hir/lint.ts new file mode 100644 index 00000000000..cbb0aeaa6aa --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/hir/lint.ts @@ -0,0 +1,195 @@ +/** + * HIR-based semantic linting. + * + * Combines lowering, type checking and analyses into a single diagnostics + * pass over one piece of user code. All spans are relative to the + * user-visible source text, so results can be surfaced in the editor without + * adjustment. + * + * Lowering failures short-circuit: when the code is outside the analyzable + * subset the result carries a single positioned diagnostic (downgraded to a + * severity chosen by the caller) and no artifacts. + */ +import { analyzeHir, foldHir } from "./analyze"; +import { emitBufferMetricJs } from "./emit-buffer-js"; +import { walkHir } from "./hir"; +import { lowerTypeScriptToHir } from "./lower-typescript"; +import { typecheckHir } from "./typecheck"; + +import type { HirAnalysis } from "./analyze"; +import type { HirDiagnostic, HirFunction, Span } from "./hir"; +import type { HirSurfaceContext } from "./surface-context"; +import type { HirTypecheckResult } from "./typecheck"; + +export type HirLintResult = { + diagnostics: HirDiagnostic[]; + /** Present when lowering succeeded. */ + fn?: HirFunction; + analysis?: HirAnalysis; + typecheck?: HirTypecheckResult; +}; + +export type HirLintOptions = { + /** + * Severity for out-of-subset diagnostics. The HIR pipeline is the only + * compiler — code outside the subset cannot run — so these default to + * errors; tooling that only analyzes may downgrade them. + * @default "error" + */ + subsetSeverity?: HirDiagnostic["severity"]; +}; + +/** Lint codes that describe out-of-subset code rather than definite bugs. */ +const SUBSET_CODES = new Set([ + "hir:unsupported-statement", + "hir:unsupported-syntax", + "hir:unsupported-operator", + "hir:unsupported-call", + "hir:mutable-binding", + "hir:destructured-binding", + "hir:destructured-parameter", + "hir:if-statement", + "hir:loop-statement", + "hir:early-return", + "hir:spread", + "hir:computed-key", + "hir:map-callback", + "hir:unknown-math-function", + "hir:math-reference", +]); + +const SUBSET_NOTE = + " Petrinaut compiles a restricted TypeScript subset — rewrite using `const` bindings, conditionals (`?:` or guard `if`s), `.map(...)` and `Math.*`."; + +function collectMathRandomSpans(fn: HirFunction): Span[] { + const spans: Span[] = []; + walkHir(fn.body, (expr) => { + if (expr.kind === "mathCall" && expr.fn === "random") { + spans.push(expr.span); + } + }); + return spans; +} + +/** + * Lints one piece of user code (a full `export default Ctor(...)` module). + * + * Callers should skip this when the TypeScript checker already reports errors + * for the same code — HIR lints assume syntactically and type-valid input. + */ +export function lintHirUserCode( + code: string, + context: HirSurfaceContext, + options: HirLintOptions = {}, +): HirLintResult { + const subsetSeverity = options.subsetSeverity ?? "error"; + const lowered = lowerTypeScriptToHir(code, context.surface); + + if (!lowered.ok) { + return { + diagnostics: lowered.diagnostics.map((diagnostic) => + SUBSET_CODES.has(diagnostic.code) + ? { + ...diagnostic, + severity: subsetSeverity, + message: diagnostic.message + SUBSET_NOTE, + } + : diagnostic, + ), + }; + } + + const { fn } = lowered; + const typecheck = typecheckHir(fn, context); + const analysis = analyzeHir(fn); + const diagnostics: HirDiagnostic[] = [...typecheck.diagnostics]; + + // --- Reproducibility ----------------------------------------------------- + if (analysis.dependencies.usesMathRandom) { + for (const node of collectMathRandomSpans(fn)) { + diagnostics.push({ + code: "hir:math-random", + severity: "warning", + message: + "`Math.random()` is not seeded — simulation runs will not be reproducible." + + (context.surface === "kernel" + ? " Use `Distribution.Uniform(min, max)` instead." + : ""), + span: node, + }); + } + } + + // --- Dead transitions ---------------------------------------------------- + if (context.surface === "lambda") { + const folded = foldHir(fn.body); + if ( + (context.lambdaType === "stochastic" && + folded.kind === "numberLit" && + folded.value === 0) || + (context.lambdaType === "predicate" && + folded.kind === "boolLit" && + !folded.value) + ) { + diagnostics.push({ + code: "hir:transition-never-fires", + severity: "warning", + message: + context.lambdaType === "stochastic" + ? "This rate is always 0 — the transition will never fire." + : "This predicate is always false — the transition will never fire.", + span: folded.span, + }); + } + } + + // --- Shared distribution draws ------------------------------------------- + for (const nodeId of analysis.distributionDag.sharedSampleNodeIds) { + const node = analysis.distributionDag.nodes.find( + (candidate) => candidate.nodeId === nodeId, + ); + if (!node) { + continue; + } + const sinkCount = analysis.distributionDag.sinks.filter( + (sink) => sink.nodeId === nodeId, + ).length; + diagnostics.push({ + code: "hir:shared-sample", + severity: "info", + message: `This distribution feeds ${sinkCount} output attributes — they will all receive the same sampled value. Construct separate distributions for independent samples.`, + span: node.span, + }); + } + + // --- Unused bindings ------------------------------------------------------- + for (const binding of analysis.bindings) { + if (binding.referenceCount === 0 && !binding.name.startsWith("_")) { + diagnostics.push({ + code: "hir:unused-binding", + severity: "hint", + message: `\`${binding.name}\` is never used.`, + span: binding.nameSpan, + }); + } + } + + // --- Buffer compilability (metrics) --------------------------------------- + // Metrics have exactly one program shape — surface non-compilable bodies + // here so the editor errors match `compileHirArtifacts` failures. + if ( + context.surface === "metric" && + !diagnostics.some((diagnostic) => diagnostic.severity === "error") && + emitBufferMetricJs(fn, context) === null + ) { + diagnostics.push({ + code: "hir:not-compilable", + severity: "error", + message: + "This code shape cannot be compiled to a buffer program (e.g. dynamic token indices, structurally-dynamic results). Restructure it as static token records / `.map(...)` over input tokens.", + span: fn.body.span, + }); + } + + return { diagnostics, fn, analysis, typecheck }; +} diff --git a/libs/@hashintel/petrinaut-core/src/hir/lower-typescript.test.ts b/libs/@hashintel/petrinaut-core/src/hir/lower-typescript.test.ts new file mode 100644 index 00000000000..88e4ad064d8 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/hir/lower-typescript.test.ts @@ -0,0 +1,659 @@ +import { describe, expect, it } from "vitest"; + +import { lowerTypeScriptToHir } from "./lower-typescript"; + +import type { HirExpr } from "./hir"; + +function lowerOk( + code: string, + surface: "dynamics" | "lambda" | "kernel" | "metric", +) { + const result = lowerTypeScriptToHir(code, surface); + if (!result.ok) { + throw new Error( + `Expected lowering to succeed, got: ${result.diagnostics + .map((diagnostic) => diagnostic.message) + .join("; ")}`, + ); + } + return result.fn; +} + +function lowerErr( + code: string, + surface: "dynamics" | "lambda" | "kernel" | "metric", +) { + const result = lowerTypeScriptToHir(code, surface); + if (result.ok) { + throw new Error("Expected lowering to fail"); + } + return result.diagnostics; +} + +describe("lowerTypeScriptToHir", () => { + describe("module shape", () => { + it("lowers the default dynamics template", () => { + const fn = lowerOk( + `export default Dynamics((tokens, parameters) => { + return tokens.map(({ x, y }) => { + return { x: 1, y: 1 }; + }); +});`, + "dynamics", + ); + expect(fn.surface).toBe("dynamics"); + expect(fn.params.map((parameter) => parameter.name)).toEqual([ + "tokens", + "parameters", + ]); + expect(fn.body.kind).toBe("arrayMap"); + }); + + it("rejects a missing default export with a full-file span", () => { + const [diagnostic] = lowerErr(`const x = 1;`, "lambda"); + expect(diagnostic!.code).toBe("hir:unsupported-statement"); + }); + + it("rejects the wrong constructor", () => { + const [diagnostic] = lowerErr( + `export default Lambda((input) => true);`, + "kernel", + ); + expect(diagnostic!.code).toBe("hir:missing-constructor"); + }); + + it("short-circuits on syntax errors with parse diagnostics", () => { + const diagnostics = lowerErr( + `export default Lambda((input) => { return 1 + ; });`, + "lambda", + ); + expect(diagnostics[0]!.code).toBe("hir:parse-error"); + expect(diagnostics[0]!.span.start).toBeGreaterThan(0); + }); + }); + + describe("expressions", () => { + it("lowers parameters access to paramRef", () => { + const fn = lowerOk( + `export default Lambda((input, parameters) => parameters.rate * 2);`, + "lambda", + ); + expect(fn.body).toMatchObject({ + kind: "binary", + op: "*", + left: { kind: "paramRef", name: "rate" }, + right: { kind: "numberLit", value: 2 }, + }); + }); + + it("keeps exact numeric literals", () => { + const fn = lowerOk(`export default Lambda((input) => 1e-9);`, "lambda"); + expect(fn.body).toMatchObject({ kind: "numberLit", raw: "1e-9" }); + }); + + it("lowers token field access chains", () => { + const fn = lowerOk( + `export default Lambda((input, parameters) => input.Pool[0].x > 1);`, + "lambda", + ); + expect(fn.body).toMatchObject({ + kind: "binary", + op: ">", + left: { + kind: "fieldAccess", + field: "x", + target: { + kind: "indexAccess", + target: { kind: "fieldAccess", field: "Pool" }, + }, + }, + }); + }); + + it("lowers blocks with const bindings to let", () => { + const fn = lowerOk( + `export default Lambda((input, parameters) => { + const rate = parameters.base * 2; + return rate + 1; +});`, + "lambda", + ); + expect(fn.body.kind).toBe("let"); + const letNode = fn.body as Extract; + expect(letNode.bindings[0]!.name).toBe("rate"); + expect(letNode.body.kind).toBe("binary"); + }); + + it("desugars destructured map params to field accesses", () => { + const fn = lowerOk( + `export default Dynamics((tokens, parameters) => tokens.map(({ x }) => ({ x: x * 2 })));`, + "dynamics", + ); + const mapNode = fn.body as Extract; + const record = mapNode.body as Extract; + expect(record.entries[0]!.value).toMatchObject({ + kind: "binary", + left: { + kind: "fieldAccess", + field: "x", + target: { kind: "localRef", name: "__element" }, + }, + }); + }); + + it("supports .length and index parameters", () => { + const fn = lowerOk( + `export default Dynamics((tokens) => tokens.map((token, index) => ({ x: index / tokens.length })));`, + "dynamics", + ); + const mapNode = fn.body as Extract; + expect(mapNode.indexParam?.name).toBe("index"); + const record = mapNode.body as Extract; + expect(record.entries[0]!.value).toMatchObject({ + kind: "binary", + op: "/", + right: { kind: "length" }, + }); + }); + + it("unwraps type assertions and parentheses", () => { + const fn = lowerOk( + `export default Lambda((input) => ((1 as number)) + 2);`, + "lambda", + ); + expect(fn.body).toMatchObject({ + kind: "binary", + left: { kind: "numberLit", value: 1 }, + }); + }); + }); + + describe("distributions", () => { + it("lowers Distribution constructors", () => { + const fn = lowerOk( + `export default TransitionKernel((input, parameters) => ({ + Out: [{ x: Distribution.Gaussian(0, parameters.sigma) }], +}));`, + "kernel", + ); + const record = fn.body as Extract; + const tokens = record.entries[0]!.value as Extract< + HirExpr, + { kind: "arrayLit" } + >; + const token = tokens.elements[0] as Extract< + HirExpr, + { kind: "recordLit" } + >; + expect(token.entries[0]!.value).toMatchObject({ + kind: "distribution", + dist: "gaussian", + args: [{ kind: "numberLit" }, { kind: "paramRef", name: "sigma" }], + }); + }); + + it("distinguishes distribution .map from array .map", () => { + const fn = lowerOk( + `export default TransitionKernel((input) => { + const base = Distribution.Uniform(0, 1); + const scaled = base.map((value) => value * 10); + return { Out: [{ x: scaled }] }; +});`, + "kernel", + ); + const letNode = fn.body as Extract; + expect(letNode.bindings[1]!.value.kind).toBe("distributionMap"); + }); + + it("expands Math function references in distribution .map", () => { + const fn = lowerOk( + `export default TransitionKernel((input) => ({ + Out: [{ x: Distribution.Gaussian(0, 10).map(Math.cos) }], +}));`, + "kernel", + ); + const record = fn.body as Extract; + const tokens = record.entries[0]!.value as Extract< + HirExpr, + { kind: "arrayLit" } + >; + const token = tokens.elements[0] as Extract< + HirExpr, + { kind: "recordLit" } + >; + expect(token.entries[0]!.value).toMatchObject({ + kind: "distributionMap", + body: { kind: "mathCall", fn: "cos" }, + }); + }); + }); + + describe("destructuring", () => { + it("lowers const { a, b } = parameters to parameter reads", () => { + const fn = lowerOk( + `export default Lambda((input, parameters) => { + const { rate, threshold } = parameters; + return rate * threshold; +});`, + "lambda", + ); + const letNode = fn.body as Extract; + expect(letNode.bindings).toHaveLength(2); + expect(letNode.bindings[0]).toMatchObject({ + name: "rate", + value: { kind: "paramRef", name: "rate" }, + }); + expect(letNode.bindings[1]).toMatchObject({ + name: "threshold", + value: { kind: "paramRef", name: "threshold" }, + }); + }); + + it("supports renames when destructuring parameters", () => { + const fn = lowerOk( + `export default Lambda((input, parameters) => { + const { rate: r } = parameters; + return r; +});`, + "lambda", + ); + const letNode = fn.body as Extract; + expect(letNode.bindings[0]).toMatchObject({ + name: "r", + value: { kind: "paramRef", name: "rate" }, + }); + }); + + it("lowers object destructuring from token expressions", () => { + const fn = lowerOk( + `export default Lambda((tokens, parameters) => { + const { x, y } = tokens.Space[0]; + return x + y; +});`, + "lambda", + ); + const letNode = fn.body as Extract; + // temp binding + two field reads + expect(letNode.bindings).toHaveLength(3); + expect(letNode.bindings[1]).toMatchObject({ + name: "x", + value: { kind: "fieldAccess", field: "x" }, + }); + expect(letNode.bindings[2]).toMatchObject({ + name: "y", + value: { kind: "fieldAccess", field: "y" }, + }); + }); + + it("lowers array destructuring to index reads", () => { + const fn = lowerOk( + `export default Lambda((tokens, parameters) => { + const [a, b] = tokens.Space; + return a.x - b.x; +});`, + "lambda", + ); + const letNode = fn.body as Extract; + expect(letNode.bindings[1]).toMatchObject({ + name: "a", + value: { kind: "indexAccess", index: { kind: "numberLit", value: 0 } }, + }); + expect(letNode.bindings[2]).toMatchObject({ + name: "b", + value: { kind: "indexAccess", index: { kind: "numberLit", value: 1 } }, + }); + }); + + it("does not create a temp binding when destructuring a local", () => { + const fn = lowerOk( + `export default Lambda((tokens, parameters) => { + const token = tokens.Space[0]; + const { x } = token; + return x; +});`, + "lambda", + ); + const letNode = fn.body as Extract; + expect(letNode.bindings.map((binding) => binding.name)).toEqual([ + "token", + "x", + ]); + }); + + it("supports destructured function parameters", () => { + const fn = lowerOk( + `export default Lambda(({ Pool }, { rate }) => Pool[0].x * rate);`, + "lambda", + ); + expect(fn.params.map((parameter) => parameter.name)).toEqual([ + "__input", + "__parameters", + ]); + expect(fn.body).toMatchObject({ + kind: "binary", + left: { + kind: "fieldAccess", + field: "x", + target: { + kind: "indexAccess", + target: { kind: "fieldAccess", field: "Pool" }, + }, + }, + right: { kind: "paramRef", name: "rate" }, + }); + }); + + it("rejects rest and defaults in destructuring", () => { + expect( + lowerErr( + `export default Lambda((input, parameters) => { + const { a = 1 } = parameters; + return a; +});`, + "lambda", + )[0]!.code, + ).toBe("hir:destructured-binding"); + expect( + lowerErr( + `export default Lambda((input, parameters) => { + const { ...rest } = parameters; + return 1; +});`, + "lambda", + )[0]!.code, + ).toBe("hir:destructured-binding"); + }); + }); + + describe("guard clauses and early returns", () => { + it("lowers a guard if + early return to a conditional", () => { + const fn = lowerOk( + `export default Lambda((input, parameters) => { + const order = input.OpenOrders[0]; + if (order.age < order.promised_lead_time) return 0; + return parameters.rate * (order.priority > 0.5 ? 1.8 : 1); +});`, + "lambda", + ); + const letNode = fn.body as Extract; + expect(letNode.bindings[0]!.name).toBe("order"); + expect(letNode.body).toMatchObject({ + kind: "cond", + condition: { kind: "binary", op: "<" }, + thenBranch: { kind: "numberLit", value: 0 }, + elseBranch: { kind: "binary", op: "*" }, + }); + }); + + it("lowers if/else where both branches return", () => { + const fn = lowerOk( + `export default Lambda((input, parameters) => { + if (parameters.enabled) { + return parameters.rate; + } else { + return 0; + } +});`, + "lambda", + ); + expect(fn.body).toMatchObject({ + kind: "cond", + thenBranch: { kind: "paramRef", name: "rate" }, + elseBranch: { kind: "numberLit", value: 0 }, + }); + }); + + it("supports chained guards with bindings in between", () => { + const fn = lowerOk( + `export default Lambda((input, parameters) => { + if (input.Pool.length == 0) return 0; + const first = input.Pool[0]; + if (first.x < 0) return 0.5; + return first.x; +});`, + "lambda", + ); + expect(fn.body).toMatchObject({ + kind: "cond", + elseBranch: { + kind: "let", + body: { kind: "cond" }, + }, + }); + }); + + it("rejects unreachable code after return", () => { + const [diagnostic] = lowerErr( + `export default Lambda((input) => { + return 1; + return 2; +});`, + "lambda", + ); + expect(diagnostic!.code).toBe("hir:unreachable-code"); + }); + + it("rejects if branches that do not return", () => { + const [diagnostic] = lowerErr( + `export default Lambda((input, parameters) => { + if (parameters.enabled) { + const x = 1; + } + return 1; +});`, + "lambda", + ); + expect(diagnostic!.code).toBe("hir:missing-return"); + }); + }); + + describe("out-of-subset rejections with positions", () => { + it("rejects let bindings", () => { + const code = `export default Lambda((input) => { + let x = 1; + return x; +});`; + const [diagnostic] = lowerErr(code, "lambda"); + expect(diagnostic!.code).toBe("hir:mutable-binding"); + expect(code.slice(diagnostic!.span.start).startsWith("let x = 1")).toBe( + true, + ); + }); + + it("rejects unknown identifiers at the right span", () => { + const code = `export default Lambda((input) => wat + 1);`; + const [diagnostic] = lowerErr(code, "lambda"); + expect(diagnostic!.code).toBe("hir:unknown-identifier"); + expect( + code.slice( + diagnostic!.span.start, + diagnostic!.span.start + diagnostic!.span.length, + ), + ).toBe("wat"); + }); + + it("rejects arbitrary function calls", () => { + const [diagnostic] = lowerErr( + `export default Lambda((input) => fetch("x"));`, + "lambda", + ); + expect(diagnostic!.code).toBe("hir:unsupported-call"); + }); + + it("rejects object spread", () => { + const [diagnostic] = lowerErr( + `export default TransitionKernel((input) => ({ + Out: input.In.map((token) => ({ ...token })), +}));`, + "kernel", + ); + expect(diagnostic!.code).toBe("hir:spread"); + }); + + it("rejects bare use of the parameters object", () => { + const [diagnostic] = lowerErr( + `export default Lambda((input, parameters) => parameters);`, + "lambda", + ); + expect(diagnostic!.code).toBe("hir:bare-parameters-object"); + }); + }); + + describe("reduce and concat", () => { + it("lowers .reduce with (acc, element) callbacks", () => { + const fn = lowerOk( + `export default Lambda((input) => input.Pool.reduce((sum, token) => sum + token.x, 0));`, + "lambda", + ); + expect(fn.body.kind).toBe("arrayReduce"); + const reduce = fn.body as Extract; + expect(reduce.accParam.name).toBe("sum"); + expect(reduce.param.name).toBe("token"); + expect(reduce.indexParam).toBeUndefined(); + expect(reduce.initial.kind).toBe("numberLit"); + expect(reduce.body.kind).toBe("binary"); + }); + + it("lowers .reduce with an index parameter", () => { + const fn = lowerOk( + `export default Lambda((input) => input.Pool.reduce((acc, token, index) => acc + index, 0));`, + "lambda", + ); + const reduce = fn.body as Extract; + expect(reduce.indexParam?.name).toBe("index"); + }); + + it("rejects .reduce without an initial value", () => { + const [diagnostic] = lowerErr( + `export default Lambda((input) => input.Pool.reduce((sum, token) => sum + token.x));`, + "lambda", + ); + expect(diagnostic!.code).toBe("hir:reduce-arity"); + }); + + it("rejects .reduce callbacks with one parameter", () => { + const [diagnostic] = lowerErr( + `export default Lambda((input) => input.Pool.reduce((sum) => sum, 0));`, + "lambda", + ); + expect(diagnostic!.code).toBe("hir:reduce-arity"); + }); + + it("lowers single-argument .concat", () => { + const fn = lowerOk( + `export default Lambda((input) => input.Pool.concat(input.Buffer).length);`, + "lambda", + ); + expect(fn.body.kind).toBe("length"); + const length = fn.body as Extract; + expect(length.target.kind).toBe("arrayConcat"); + }); + + it("rejects multi-argument .concat", () => { + const [diagnostic] = lowerErr( + `export default Lambda((input) => input.Pool.concat(input.A, input.B).length);`, + "lambda", + ); + expect(diagnostic!.code).toBe("hir:concat-arity"); + }); + }); + + describe("metric surface", () => { + it("lowers a metric body with state in scope and no parameters object", () => { + const fn = lowerOk( + `const pool = state.places.Pool.tokens; +if (pool.length === 0) return 0; +return pool.reduce((sum, t) => sum + t.x, 0) / pool.length;`, + "metric", + ); + expect(fn.surface).toBe("metric"); + expect(fn.params.map((parameter) => parameter.name)).toEqual(["state"]); + expect(fn.body.kind).toBe("let"); + }); + + it("maps node spans onto the raw metric body", () => { + const code = `const total = state.places.Pool.count; +return total;`; + const fn = lowerOk(code, "metric"); + const letExpr = fn.body as Extract; + const binding = letExpr.bindings[0]!; + expect( + code.slice( + binding.nameSpan.start, + binding.nameSpan.start + binding.nameSpan.length, + ), + ).toBe("total"); + expect( + code.slice( + binding.value.span.start, + binding.value.span.start + binding.value.span.length, + ), + ).toBe("state.places.Pool.count"); + }); + + it("maps diagnostic spans onto the raw metric body", () => { + const code = `const a = 1; +return missing;`; + const [diagnostic] = lowerErr(code, "metric"); + expect(diagnostic!.code).toBe("hir:unknown-identifier"); + expect( + code.slice( + diagnostic!.span.start, + diagnostic!.span.start + diagnostic!.span.length, + ), + ).toBe("missing"); + }); + + it("shifts parse-error spans onto the raw metric body", () => { + const code = `return 1 +;`; + const [diagnostic] = lowerErr(code, "metric"); + expect(diagnostic!.code).toBe("hir:parse-error"); + expect(diagnostic!.span.start).toBeLessThanOrEqual(code.length); + }); + + it("requires the metric body to end in a return", () => { + const [diagnostic] = lowerErr(`const a = 1;`, "metric"); + expect(diagnostic!.code).toBe("hir:missing-return"); + }); + + it("rejects a bare parameters-style object (metrics have none)", () => { + const [diagnostic] = lowerErr(`return parameters.rate;`, "metric"); + expect(diagnostic!.code).toBe("hir:unknown-identifier"); + }); + }); + + describe("spans", () => { + it("every node span maps back into the source text", () => { + const code = `export default Lambda((input, parameters) => { + const a = parameters.alpha; + return a > 0 ? a * 2 : Math.abs(a); +});`; + const fn = lowerOk(code, "lambda"); + const stack = [fn.body]; + while (stack.length > 0) { + const node = stack.pop()!; + expect(node.span.start).toBeGreaterThanOrEqual(0); + expect(node.span.start + node.span.length).toBeLessThanOrEqual( + code.length, + ); + switch (node.kind) { + case "binary": + stack.push(node.left, node.right); + break; + case "let": + stack.push(...node.bindings.map((binding) => binding.value)); + stack.push(node.body); + break; + case "cond": + stack.push(node.condition, node.thenBranch, node.elseBranch); + break; + case "mathCall": + stack.push(...node.args); + break; + default: + break; + } + } + }); + }); +}); diff --git a/libs/@hashintel/petrinaut-core/src/hir/lower-typescript.ts b/libs/@hashintel/petrinaut-core/src/hir/lower-typescript.ts new file mode 100644 index 00000000000..c900a16d73a --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/hir/lower-typescript.ts @@ -0,0 +1,1575 @@ +/** + * Lowers user-authored TypeScript modules into the HIR. + * + * The lowering accepts the analyzable subset of TypeScript that Petrinaut + * user code is written in: a module of the form + * + * export default ((tokens, parameters) => ) + * + * where the body is a pure expression tree — `const` bindings (with object + * and array destructuring), guard-clause `if`/early returns, ternaries, + * arithmetic/logic, `Math.*` calls, token/parameter access, `.map(...)` + * comprehensions and `Distribution.*` constructors. + * + * Anything outside that subset short-circuits lowering and produces a single + * `HirDiagnostic` whose span points at the offending syntax in the + * user-visible source text. The HIR pipeline is the only compiler, so these + * surface as errors in the LSP and the affected item cannot simulate until + * fixed. + */ +import ts from "typescript"; + +import { HIR_MATH_FNS, HIR_STRING_FNS, walkHir } from "./hir"; + +import type { + HirBinaryOp, + HirDiagnostic, + HirDistributionKind, + HirExpr, + HirFunction, + HirLetBinding, + HirMathFn, + HirSurfaceKind, + Span, +} from "./hir"; + +export type LowerTypeScriptResult = + | { ok: true; fn: HirFunction; diagnostics: HirDiagnostic[] } + | { ok: false; diagnostics: HirDiagnostic[] }; + +const CONSTRUCTOR_NAMES: Record, string> = { + dynamics: "Dynamics", + lambda: "Lambda", + kernel: "TransitionKernel", +}; + +/** + * Metric user code is a bare function *body* (with `state` in scope), not an + * `export default` module. It is wrapped in this prefix (plus a closing + * `\n}`) before parsing; all spans in the lowering result are shifted back by + * the prefix length so they map onto the raw user body. + */ +const METRIC_PREFIX = "(state) => {\n"; +const METRIC_SUFFIX = "\n}"; + +const DISTRIBUTION_FACTORIES: Record = { + Gaussian: "gaussian", + Uniform: "uniform", + Lognormal: "lognormal", +}; + +const MATH_CONSTANTS = new Set(["PI", "E"]); + +const BINARY_OPS: Partial> = { + [ts.SyntaxKind.PlusToken]: "+", + [ts.SyntaxKind.MinusToken]: "-", + [ts.SyntaxKind.AsteriskToken]: "*", + [ts.SyntaxKind.SlashToken]: "/", + [ts.SyntaxKind.PercentToken]: "%", + [ts.SyntaxKind.AsteriskAsteriskToken]: "**", + [ts.SyntaxKind.LessThanToken]: "<", + [ts.SyntaxKind.LessThanEqualsToken]: "<=", + [ts.SyntaxKind.GreaterThanToken]: ">", + [ts.SyntaxKind.GreaterThanEqualsToken]: ">=", + [ts.SyntaxKind.EqualsEqualsToken]: "==", + [ts.SyntaxKind.EqualsEqualsEqualsToken]: "==", + [ts.SyntaxKind.ExclamationEqualsToken]: "!=", + [ts.SyntaxKind.ExclamationEqualsEqualsToken]: "!=", + [ts.SyntaxKind.AmpersandAmpersandToken]: "&&", + [ts.SyntaxKind.BarBarToken]: "||", +}; + +/** `Omit` distributed over a union, preserving each variant's shape. */ +type DistributiveOmit = Type extends unknown + ? Omit + : never; + +/** Internal signal used to short-circuit lowering with one diagnostic. */ +class LowerError extends Error { + diagnostic: HirDiagnostic; + + constructor(diagnostic: HirDiagnostic) { + super(diagnostic.message); + this.diagnostic = diagnostic; + } +} + +type LowerScope = { + /** In-scope local names: fn params, `const` bindings, map params. */ + locals: Set; + /** Locals whose bound value is distribution-valued (drives `.map` + * disambiguation between arrays and distributions). */ + distributionLocals: Set; + /** Identifiers introduced by object-destructured map params, mapped to the + * synthetic parameter they read from. */ + destructuredFields: Map; + /** Identifiers introduced by destructuring the top-level parameters + * function parameter, mapped to the model parameter they read. */ + parameterAliases: Map; + /** Name of the `parameters` function parameter, if declared. */ + parametersName: string | null; +}; + +function childScope(scope: LowerScope): LowerScope { + return { + locals: new Set(scope.locals), + distributionLocals: new Set(scope.distributionLocals), + destructuredFields: new Map(scope.destructuredFields), + parameterAliases: new Map(scope.parameterAliases), + parametersName: scope.parametersName, + }; +} + +class Lowering { + private nextId = 0; + + constructor( + private readonly sourceFile: ts.SourceFile, + private readonly surface: HirSurfaceKind, + ) {} + + spanOf(node: ts.Node): Span { + const start = node.getStart(this.sourceFile); + return { start, length: node.getWidth(this.sourceFile) }; + } + + fail(node: ts.Node, code: string, message: string): never { + throw new LowerError({ + code, + message, + severity: "error", + span: this.spanOf(node), + }); + } + + private make>( + node: ts.Node, + expr: Init, + ): Extract { + return { + ...expr, + id: this.nextId++, + span: this.spanOf(node), + } as unknown as Extract; + } + + /** + * Lowers a wrapped metric body (`(state) => { }`, see + * `METRIC_PREFIX`). Spans are still relative to the wrapped text — the + * caller shifts them back onto the raw user body. + */ + lowerMetricModule(): HirFunction { + const [statement, ...rest] = this.sourceFile.statements; + if (rest.length > 0) { + this.fail( + rest[0]!, + "hir:unsupported-statement", + "Metric code must be a single function body ending in `return`.", + ); + } + const arrow = + statement && + ts.isExpressionStatement(statement) && + ts.isArrowFunction(statement.expression) + ? statement.expression + : null; + const arrowBody = arrow && ts.isBlock(arrow.body) ? arrow.body : null; + if (!arrow || !arrowBody) { + throw new LowerError({ + code: "hir:unsupported-syntax", + message: "Metric code must be a function body ending in `return`.", + severity: "error", + span: { start: 0, length: Math.max(this.sourceFile.text.length, 1) }, + }); + } + const stateParam = arrow.parameters[0]!; + const scope: LowerScope = { + locals: new Set(["state"]), + distributionLocals: new Set(), + destructuredFields: new Map(), + parameterAliases: new Map(), + // Metrics have no parameters object. + parametersName: null, + }; + const body = this.lowerBlock(arrowBody, scope); + return { + hirVersion: 1, + surface: "metric", + params: [{ name: "state", span: this.spanOf(stateParam.name) }], + body, + span: this.spanOf(arrow), + }; + } + + lowerModule(): HirFunction { + if (this.surface === "metric") { + // Metric code has no module wrapper — see `lowerMetricModule`. + return this.lowerMetricModule(); + } + const constructorName = CONSTRUCTOR_NAMES[this.surface]; + let exportAssignment: ts.ExportAssignment | undefined; + + for (const statement of this.sourceFile.statements) { + if (ts.isExportAssignment(statement) && !statement.isExportEquals) { + if (exportAssignment) { + this.fail( + statement, + "hir:multiple-default-exports", + "Only one default export is allowed.", + ); + } + exportAssignment = statement; + } else if ( + ts.isImportDeclaration(statement) && + statement.importClause?.isTypeOnly + ) { + // Type-only imports are erased; ignore them. + } else { + this.fail( + statement, + "hir:unsupported-statement", + `Only \`export default ${constructorName}(...)\` is supported at the top level.`, + ); + } + } + + if (!exportAssignment) { + throw new LowerError({ + code: "hir:missing-default-export", + message: `Expected \`export default ${constructorName}(...)\`.`, + severity: "error", + span: { start: 0, length: Math.max(this.sourceFile.text.length, 1) }, + }); + } + + const call = exportAssignment.expression; + if ( + !ts.isCallExpression(call) || + !ts.isIdentifier(call.expression) || + call.expression.text !== constructorName + ) { + this.fail( + call, + "hir:missing-constructor", + `The default export must be a \`${constructorName}(...)\` call.`, + ); + } + if (call.arguments.length !== 1) { + this.fail( + call, + "hir:constructor-arity", + `\`${constructorName}\` expects exactly one function argument.`, + ); + } + + const fnArg = call.arguments[0]!; + if (!ts.isArrowFunction(fnArg) && !ts.isFunctionExpression(fnArg)) { + this.fail( + fnArg, + "hir:missing-function", + `\`${constructorName}\` expects a function argument, e.g. \`(tokens, parameters) => ...\`.`, + ); + } + + if (fnArg.parameters.length > 2) { + this.fail( + fnArg.parameters[2]!, + "hir:too-many-parameters", + "User functions take at most two parameters (tokens and parameters).", + ); + } + + const params: HirFunction["params"] = []; + const scope: LowerScope = { + locals: new Set(), + distributionLocals: new Set(), + destructuredFields: new Map(), + parameterAliases: new Map(), + parametersName: null, + }; + + for (const [index, parameter] of fnArg.parameters.entries()) { + if (ts.isIdentifier(parameter.name)) { + const name = parameter.name.text; + params.push({ name, span: this.spanOf(parameter.name) }); + if (index === 0) { + scope.locals.add(name); + } else { + scope.parametersName = name; + } + continue; + } + if (ts.isObjectBindingPattern(parameter.name)) { + // Destructured parameter: synthesize a name and route the bound + // identifiers to the underlying tokens object / parameter reads. + const syntheticName = index === 0 ? "__input" : "__parameters"; + params.push({ + name: syntheticName, + span: this.spanOf(parameter.name), + }); + for (const element of parameter.name.elements) { + const bound = this.bindingElementParts(element); + if (bound.name !== bound.sourceName) { + this.fail( + element, + "hir:destructured-parameter", + "Renames are not supported when destructuring function parameters.", + ); + } + if (index === 0) { + scope.destructuredFields.set(bound.name, syntheticName); + } else { + scope.parameterAliases.set(bound.name, bound.sourceName); + } + } + if (index === 0) { + scope.locals.add(syntheticName); + } + continue; + } + this.fail( + parameter.name, + "hir:destructured-parameter", + "Function parameters must be plain names or object destructuring like `({ Pool }, { rate })`.", + ); + } + + const body = ts.isBlock(fnArg.body) + ? this.lowerBlock(fnArg.body, scope) + : this.lowerExpr(fnArg.body, scope); + + return { + hirVersion: 1, + surface: this.surface, + params, + body, + span: this.spanOf(fnArg), + }; + } + + /** Lowers a `{ const...; if guards...; return expr; }` block. */ + private lowerBlock(block: ts.Block, outerScope: LowerScope): HirExpr { + return this.lowerStatements( + block.statements, + childScope(outerScope), + block, + ); + } + + /** + * Lowers a statement list to an expression. Supported statements: + * `const` bindings (with object/array destructuring), guard-clause + * `if (cond) return a;` (the remaining statements become the else branch), + * terminating `if/else` where both branches return, and a final `return`. + */ + private lowerStatements( + statements: readonly ts.Statement[], + scope: LowerScope, + anchor: ts.Node, + ): HirExpr { + const bindings: HirLetBinding[] = []; + + const wrapBindings = (body: HirExpr): HirExpr => { + if (bindings.length === 0) { + return body; + } + return { + kind: "let", + bindings, + body, + id: this.nextId++, + span: this.spanOf(anchor), + }; + }; + + const failUnreachable = (statement: ts.Statement): never => + this.fail( + statement, + "hir:unreachable-code", + "This code is unreachable — the function has already returned.", + ); + + for (const [index, statement] of statements.entries()) { + if (ts.isVariableStatement(statement)) { + // eslint-disable-next-line no-bitwise -- ts.NodeFlags is a bitfield + if (!(statement.declarationList.flags & ts.NodeFlags.Const)) { + this.fail( + statement, + "hir:mutable-binding", + "`let` and `var` are not supported — use `const` (bindings are immutable).", + ); + } + for (const declaration of statement.declarationList.declarations) { + bindings.push(...this.lowerDeclaration(declaration, scope)); + } + } else if (ts.isReturnStatement(statement)) { + if (index !== statements.length - 1) { + failUnreachable(statements[index + 1]!); + } + if (!statement.expression) { + this.fail( + statement, + "hir:empty-return", + "The function must return a value.", + ); + } + return wrapBindings(this.lowerExpr(statement.expression, scope)); + } else if (ts.isIfStatement(statement)) { + const condition = this.lowerExpr(statement.expression, scope); + const thenBranch = this.lowerBranch(statement.thenStatement, scope); + + if (statement.elseStatement) { + // Both branches terminate; nothing may follow. + if (index !== statements.length - 1) { + failUnreachable(statements[index + 1]!); + } + const elseBranch = this.lowerBranch(statement.elseStatement, scope); + return wrapBindings({ + kind: "cond", + condition, + thenBranch, + elseBranch, + id: this.nextId++, + span: this.spanOf(statement), + }); + } + + // Guard clause: the remaining statements are the else branch. + const rest = statements.slice(index + 1); + if (rest.length === 0) { + this.fail( + statement, + "hir:missing-return", + "A guard `if` must be followed by more statements ending in `return`.", + ); + } + const elseBranch = this.lowerStatements( + rest, + childScope(scope), + rest[0]!, + ); + return wrapBindings({ + kind: "cond", + condition, + thenBranch, + elseBranch, + id: this.nextId++, + span: this.spanOf(statement), + }); + } else if ( + ts.isForStatement(statement) || + ts.isForOfStatement(statement) || + ts.isForInStatement(statement) || + ts.isWhileStatement(statement) || + ts.isDoStatement(statement) + ) { + this.fail( + statement, + "hir:loop-statement", + "Loops are not supported — use `.map(...)` over token arrays.", + ); + } else { + this.fail( + statement, + "hir:unsupported-statement", + `Unsupported statement (${ts.SyntaxKind[statement.kind]}) — only \`const\` bindings, guard \`if\`s and a final \`return\` are supported.`, + ); + } + } + + this.fail( + anchor, + "hir:missing-return", + "The function body must end with a `return` statement.", + ); + } + + /** Lowers an `if` branch, which must terminate with a `return`. */ + private lowerBranch(statement: ts.Statement, scope: LowerScope): HirExpr { + if (ts.isReturnStatement(statement)) { + if (!statement.expression) { + this.fail( + statement, + "hir:empty-return", + "The function must return a value.", + ); + } + return this.lowerExpr(statement.expression, scope); + } + if (ts.isBlock(statement)) { + return this.lowerStatements( + statement.statements, + childScope(scope), + statement, + ); + } + this.fail( + statement, + "hir:if-statement", + "`if` branches must end with a `return` statement.", + ); + } + + /** + * Lowers one `const` declaration to bindings, expanding object and array + * destructuring patterns. `const { a, b } = parameters` binds directly to + * parameter reads. + */ + private lowerDeclaration( + declaration: ts.VariableDeclaration, + scope: LowerScope, + ): HirLetBinding[] { + if (!declaration.initializer) { + this.fail( + declaration, + "hir:missing-initializer", + "`const` bindings must have an initializer.", + ); + } + const initializer = declaration.initializer; + const pattern = declaration.name; + + const registerBinding = (name: string, value: HirExpr): void => { + scope.locals.add(name); + scope.destructuredFields.delete(name); + scope.parameterAliases.delete(name); + if (this.isDistributionValued(value, scope)) { + scope.distributionLocals.add(name); + } else { + scope.distributionLocals.delete(name); + } + }; + + // Simple binding: const name = expr + if (ts.isIdentifier(pattern)) { + const value = this.lowerExpr(initializer, scope); + registerBinding(pattern.text, value); + return [{ name: pattern.text, nameSpan: this.spanOf(pattern), value }]; + } + + // Destructuring from the parameters object binds parameter reads + // directly: const { a, b: alias } = parameters + if ( + ts.isObjectBindingPattern(pattern) && + ts.isIdentifier(initializer) && + initializer.text === scope.parametersName && + !scope.locals.has(initializer.text) + ) { + const bindings: HirLetBinding[] = []; + for (const element of pattern.elements) { + const bound = this.bindingElementParts(element); + const value = this.make(element, { + kind: "paramRef", + name: bound.sourceName, + }); + registerBinding(bound.name, value); + bindings.push({ + name: bound.name, + nameSpan: bound.nameSpan, + value, + }); + } + return bindings; + } + + // General destructuring: bind the source once, then one binding per + // element reading from it. + const source = this.lowerExpr(initializer, scope); + const bindings: HirLetBinding[] = []; + let sourceRefName: string; + if (source.kind === "localRef") { + sourceRefName = source.name; + } else { + sourceRefName = `__destructured_${this.nextId}`; + scope.locals.add(sourceRefName); + bindings.push({ + name: sourceRefName, + nameSpan: this.spanOf(pattern), + value: source, + }); + } + + const sourceRef = (node: ts.Node): HirExpr => + this.make(node, { kind: "localRef", name: sourceRefName }); + + if (ts.isObjectBindingPattern(pattern)) { + for (const element of pattern.elements) { + const bound = this.bindingElementParts(element); + const value = this.make(element, { + kind: "fieldAccess", + target: sourceRef(element), + field: bound.sourceName, + fieldSpan: bound.sourceSpan, + }); + registerBinding(bound.name, value); + bindings.push({ name: bound.name, nameSpan: bound.nameSpan, value }); + } + return bindings; + } + + if (ts.isArrayBindingPattern(pattern)) { + for (const [elementIndex, element] of pattern.elements.entries()) { + if (ts.isOmittedExpression(element)) { + continue; + } + if ( + element.dotDotDotToken || + element.initializer || + !ts.isIdentifier(element.name) + ) { + this.fail( + element, + "hir:destructured-binding", + "Only simple array destructuring like `const [a, b] = ...` is supported.", + ); + } + const index = this.make(element, { + kind: "numberLit", + value: elementIndex, + raw: String(elementIndex), + }); + const value = this.make(element, { + kind: "indexAccess", + target: sourceRef(element), + index, + }); + registerBinding(element.name.text, value); + bindings.push({ + name: element.name.text, + nameSpan: this.spanOf(element.name), + value, + }); + } + return bindings; + } + + this.fail( + pattern, + "hir:destructured-binding", + "Unsupported binding pattern.", + ); + } + + /** Extracts (boundName, sourceProperty) from an object binding element, + * supporting renames (`{ a: alias }`). */ + private bindingElementParts(element: ts.BindingElement): { + name: string; + nameSpan: Span; + sourceName: string; + sourceSpan: Span; + } { + if ( + element.dotDotDotToken || + element.initializer || + !ts.isIdentifier(element.name) + ) { + this.fail( + element, + "hir:destructured-binding", + "Only simple destructuring like `{ a, b }` or `{ a: alias }` is supported (no defaults, rest or nesting).", + ); + } + if (element.propertyName !== undefined) { + if ( + !ts.isIdentifier(element.propertyName) && + !ts.isStringLiteralLike(element.propertyName) + ) { + this.fail( + element.propertyName, + "hir:destructured-binding", + "Computed keys are not supported in destructuring.", + ); + } + return { + name: element.name.text, + nameSpan: this.spanOf(element.name), + sourceName: element.propertyName.text, + sourceSpan: this.spanOf(element.propertyName), + }; + } + return { + name: element.name.text, + nameSpan: this.spanOf(element.name), + sourceName: element.name.text, + sourceSpan: this.spanOf(element.name), + }; + } + + private lowerExpr(node: ts.Expression, scope: LowerScope): HirExpr { + // Unwrap constructs that are transparent at runtime. + if (ts.isParenthesizedExpression(node)) { + return this.lowerExpr(node.expression, scope); + } + if ( + ts.isAsExpression(node) || + ts.isSatisfiesExpression(node) || + ts.isNonNullExpression(node) + ) { + return this.lowerExpr(node.expression, scope); + } + + if (ts.isNumericLiteral(node)) { + return this.make(node, { + kind: "numberLit", + value: Number(node.text), + raw: node.getText(this.sourceFile), + }); + } + if (node.kind === ts.SyntaxKind.TrueKeyword) { + return this.make(node, { kind: "boolLit", value: true }); + } + if (node.kind === ts.SyntaxKind.FalseKeyword) { + return this.make(node, { kind: "boolLit", value: false }); + } + if (ts.isIdentifier(node)) { + return this.lowerIdentifier(node, scope); + } + if (ts.isPrefixUnaryExpression(node)) { + return this.lowerUnary(node, scope); + } + if (ts.isBinaryExpression(node)) { + const op = BINARY_OPS[node.operatorToken.kind]; + if (!op) { + this.fail( + node.operatorToken, + "hir:unsupported-operator", + `Unsupported operator \`${node.operatorToken.getText(this.sourceFile)}\`.`, + ); + } + return this.make(node, { + kind: "binary", + op, + left: this.lowerExpr(node.left, scope), + right: this.lowerExpr(node.right, scope), + }); + } + if (ts.isConditionalExpression(node)) { + return this.make(node, { + kind: "cond", + condition: this.lowerExpr(node.condition, scope), + thenBranch: this.lowerExpr(node.whenTrue, scope), + elseBranch: this.lowerExpr(node.whenFalse, scope), + }); + } + if (ts.isPropertyAccessExpression(node)) { + return this.lowerPropertyAccess(node, scope); + } + if (ts.isElementAccessExpression(node)) { + const argument = node.argumentExpression; + if (ts.isStringLiteralLike(argument)) { + return this.make(node, { + kind: "fieldAccess", + target: this.lowerExpr(node.expression, scope), + field: argument.text, + fieldSpan: this.spanOf(argument), + }); + } + return this.make(node, { + kind: "indexAccess", + target: this.lowerExpr(node.expression, scope), + index: this.lowerExpr(argument, scope), + }); + } + if (ts.isCallExpression(node)) { + return this.lowerCall(node, scope); + } + if (ts.isObjectLiteralExpression(node)) { + return this.lowerObjectLiteral(node, scope); + } + if (ts.isArrayLiteralExpression(node)) { + const elements: HirExpr[] = []; + for (const element of node.elements) { + if (ts.isSpreadElement(element)) { + this.fail( + element, + "hir:spread", + "Spread elements are not supported yet — list tokens explicitly.", + ); + } + elements.push(this.lowerExpr(element, scope)); + } + return this.make(node, { kind: "arrayLit", elements }); + } + if (ts.isStringLiteralLike(node)) { + return this.make(node, { kind: "stringLit", value: node.text }); + } + + this.fail( + node, + "hir:unsupported-syntax", + `Unsupported syntax (${ts.SyntaxKind[node.kind]}).`, + ); + } + + private lowerIdentifier(node: ts.Identifier, scope: LowerScope): HirExpr { + const name = node.text; + if (name === "Infinity") { + return this.make(node, { kind: "constant", name: "Infinity" }); + } + if (name === "NaN") { + return this.make(node, { kind: "constant", name: "NaN" }); + } + const destructuredBase = scope.destructuredFields.get(name); + if (destructuredBase !== undefined) { + const target = this.make(node, { + kind: "localRef", + name: destructuredBase, + }); + return this.make(node, { + kind: "fieldAccess", + target, + field: name, + fieldSpan: this.spanOf(node), + }); + } + const aliasedParameter = scope.parameterAliases.get(name); + if (aliasedParameter !== undefined) { + return this.make(node, { kind: "paramRef", name: aliasedParameter }); + } + if (scope.locals.has(name)) { + return this.make(node, { kind: "localRef", name }); + } + if (name === scope.parametersName) { + this.fail( + node, + "hir:bare-parameters-object", + `The parameters object can only be used via property access, e.g. \`${name}.rate\`.`, + ); + } + this.fail( + node, + "hir:unknown-identifier", + `Unknown identifier \`${name}\`.`, + ); + } + + private lowerUnary( + node: ts.PrefixUnaryExpression, + scope: LowerScope, + ): HirExpr { + const op = + node.operator === ts.SyntaxKind.MinusToken + ? "-" + : node.operator === ts.SyntaxKind.PlusToken + ? "+" + : node.operator === ts.SyntaxKind.ExclamationToken + ? "!" + : null; + if (!op) { + this.fail( + node, + "hir:unsupported-operator", + "Unsupported unary operator.", + ); + } + // Fold `-1` style negated literals so raw text is preserved. + if (op === "-" && ts.isNumericLiteral(node.operand)) { + return this.make(node, { + kind: "numberLit", + value: -Number(node.operand.text), + raw: node.getText(this.sourceFile), + }); + } + return this.make(node, { + kind: "unary", + op, + operand: this.lowerExpr(node.operand, scope), + }); + } + + private lowerPropertyAccess( + node: ts.PropertyAccessExpression, + scope: LowerScope, + ): HirExpr { + const property = node.name.text; + + if (ts.isIdentifier(node.expression)) { + const objectName = node.expression.text; + + if (objectName === "Math") { + if (MATH_CONSTANTS.has(property)) { + return this.make(node, { + kind: "constant", + name: property as "PI" | "E", + }); + } + this.fail( + node, + "hir:math-reference", + `\`Math.${property}\` can only be used as a function call.`, + ); + } + + if (objectName === "Number") { + if (property === "POSITIVE_INFINITY") { + return this.make(node, { kind: "constant", name: "Infinity" }); + } + if (property === "NaN") { + return this.make(node, { kind: "constant", name: "NaN" }); + } + this.fail( + node, + "hir:unsupported-syntax", + `\`Number.${property}\` is not supported.`, + ); + } + + if ( + objectName === scope.parametersName && + !scope.locals.has(objectName) + ) { + return this.make(node, { kind: "paramRef", name: property }); + } + } + + if (property === "length") { + return this.make(node, { + kind: "length", + target: this.lowerExpr(node.expression, scope), + }); + } + + return this.make(node, { + kind: "fieldAccess", + target: this.lowerExpr(node.expression, scope), + field: property, + fieldSpan: this.spanOf(node.name), + }); + } + + private lowerCall(node: ts.CallExpression, scope: LowerScope): HirExpr { + const callee = node.expression; + + if (ts.isPropertyAccessExpression(callee)) { + const method = callee.name.text; + + // Math.fn(...) + if ( + ts.isIdentifier(callee.expression) && + callee.expression.text === "Math" + ) { + if (!(HIR_MATH_FNS as readonly string[]).includes(method)) { + this.fail( + callee.name, + "hir:unknown-math-function", + `\`Math.${method}\` is not supported.`, + ); + } + return this.make(node, { + kind: "mathCall", + fn: method as HirMathFn, + args: node.arguments.map((argument) => + this.lowerExpr(argument, scope), + ), + }); + } + + // Uuid.generate() / Uuid.from(value) + if ( + ts.isIdentifier(callee.expression) && + callee.expression.text === "Uuid" + ) { + if (method === "generate") { + if (node.arguments.length !== 0) { + this.fail( + node, + "hir:uuid-arity", + "`Uuid.generate()` takes no arguments.", + ); + } + return this.make(node, { kind: "uuidGenerate" }); + } + if (method === "from") { + if (node.arguments.length !== 1) { + this.fail( + node, + "hir:uuid-arity", + "`Uuid.from(value)` takes exactly one argument.", + ); + } + return this.make(node, { + kind: "uuidFrom", + operand: this.lowerExpr(node.arguments[0]!, scope), + }); + } + this.fail( + callee.name, + "hir:unknown-uuid-helper", + `Unknown helper \`Uuid.${method}\` — expected \`generate\` or \`from\`.`, + ); + } + + // Distribution.Gaussian(...) / Uniform / Lognormal + if ( + ts.isIdentifier(callee.expression) && + callee.expression.text === "Distribution" + ) { + const dist = DISTRIBUTION_FACTORIES[method]; + if (!dist) { + this.fail( + callee.name, + "hir:unknown-distribution", + `Unknown distribution \`Distribution.${method}\` — expected Gaussian, Uniform or Lognormal.`, + ); + } + return this.make(node, { + kind: "distribution", + dist, + args: node.arguments.map((argument) => + this.lowerExpr(argument, scope), + ), + }); + } + + // .map(...) + if (method === "map") { + return this.lowerMapCall(node, callee, scope); + } + + // .reduce((acc, element, index?) => ..., initial) + if (method === "reduce") { + return this.lowerReduceCall(node, callee, scope); + } + + // .concat(other) + if (method === "concat") { + if (node.arguments.length !== 1) { + this.fail( + node, + "hir:concat-arity", + "`.concat(...)` takes exactly one array argument.", + ); + } + return this.make(node, { + kind: "arrayConcat", + left: this.lowerExpr(callee.expression, scope), + right: this.lowerExpr(node.arguments[0]!, scope), + }); + } + + // String predicates: .startsWith(arg) etc. + if ((HIR_STRING_FNS as readonly string[]).includes(method)) { + if (node.arguments.length !== 1) { + this.fail( + node, + "hir:string-call-arity", + `\`.${method}(...)\` takes exactly one argument.`, + ); + } + return this.make(node, { + kind: "stringCall", + fn: method as (typeof HIR_STRING_FNS)[number], + target: this.lowerExpr(callee.expression, scope), + argument: this.lowerExpr(node.arguments[0]!, scope), + }); + } + } + + this.fail( + node, + "hir:unsupported-call", + "Only `Math.*`, `Distribution.*`, `.map(...)`, `.reduce(...)` and `.concat(...)` calls are supported.", + ); + } + + private lowerReduceCall( + node: ts.CallExpression, + callee: ts.PropertyAccessExpression, + scope: LowerScope, + ): HirExpr { + if (node.arguments.length !== 2) { + this.fail( + node, + "hir:reduce-arity", + "`.reduce(...)` expects exactly two arguments: a callback and an initial value.", + ); + } + const target = this.lowerExpr(callee.expression, scope); + const callback = node.arguments[0]!; + if (!ts.isArrowFunction(callback) && !ts.isFunctionExpression(callback)) { + this.fail( + callback, + "hir:map-callback", + "`.reduce(...)` expects an inline function, e.g. `.reduce((sum, token) => sum + token.x, 0)`.", + ); + } + if (callback.parameters.length < 2 || callback.parameters.length > 3) { + this.fail( + callback, + "hir:reduce-arity", + "`.reduce(...)` callbacks take (accumulator, element) or (accumulator, element, index).", + ); + } + + const bodyScope = childScope(scope); + const bindParam = ( + parameter: ts.ParameterDeclaration, + ): { name: string; span: Span } => { + if (!ts.isIdentifier(parameter.name)) { + this.fail( + parameter.name, + "hir:destructured-binding", + "`.reduce(...)` callback parameters must be plain names.", + ); + } + const bound = { + name: parameter.name.text, + span: this.spanOf(parameter.name), + }; + bodyScope.locals.add(bound.name); + bodyScope.distributionLocals.delete(bound.name); + bodyScope.destructuredFields.delete(bound.name); + bodyScope.parameterAliases.delete(bound.name); + return bound; + }; + + const accParam = bindParam(callback.parameters[0]!); + const param = bindParam(callback.parameters[1]!); + const indexParam = callback.parameters[2] + ? bindParam(callback.parameters[2]) + : undefined; + + // The initial value is evaluated in the outer scope. + const initial = this.lowerExpr(node.arguments[1]!, scope); + + const body = ts.isBlock(callback.body) + ? this.lowerBlock(callback.body, bodyScope) + : this.lowerExpr(callback.body, bodyScope); + + return this.make(node, { + kind: "arrayReduce", + target, + accParam, + param, + indexParam, + body, + initial, + }); + } + + private lowerMapCall( + node: ts.CallExpression, + callee: ts.PropertyAccessExpression, + scope: LowerScope, + ): HirExpr { + if (node.arguments.length !== 1) { + this.fail( + node, + "hir:map-arity", + "`.map(...)` expects exactly one callback argument.", + ); + } + const target = this.lowerExpr(callee.expression, scope); + const callback = node.arguments[0]!; + + if (this.isDistributionValued(target, scope)) { + return this.lowerDistributionMap(node, target, callback, scope); + } + + if (!ts.isArrowFunction(callback) && !ts.isFunctionExpression(callback)) { + this.fail( + callback, + "hir:map-callback", + "`.map(...)` expects an inline function, e.g. `.map((token) => ...)`.", + ); + } + if (callback.parameters.length > 2) { + this.fail( + callback.parameters[2]!, + "hir:map-arity", + "`.map(...)` callbacks take at most (element, index).", + ); + } + + const bodyScope = childScope(scope); + let param: { name: string; span: Span }; + + const firstParam = callback.parameters[0]; + if (!firstParam) { + param = { name: "__element", span: this.spanOf(callback) }; + } else if (ts.isIdentifier(firstParam.name)) { + param = { + name: firstParam.name.text, + span: this.spanOf(firstParam.name), + }; + bodyScope.locals.add(param.name); + bodyScope.distributionLocals.delete(param.name); + bodyScope.destructuredFields.delete(param.name); + bodyScope.parameterAliases.delete(param.name); + } else if (ts.isObjectBindingPattern(firstParam.name)) { + param = { name: "__element", span: this.spanOf(firstParam.name) }; + for (const element of firstParam.name.elements) { + if ( + element.dotDotDotToken || + element.propertyName !== undefined || + !ts.isIdentifier(element.name) + ) { + this.fail( + element, + "hir:destructured-binding", + "Only simple destructuring like `({ x, y })` is supported in `.map(...)` callbacks.", + ); + } + bodyScope.destructuredFields.set(element.name.text, param.name); + bodyScope.locals.delete(element.name.text); + bodyScope.distributionLocals.delete(element.name.text); + } + bodyScope.locals.add(param.name); + } else { + this.fail( + firstParam.name, + "hir:destructured-binding", + "Array destructuring is not supported in `.map(...)` callbacks.", + ); + } + + let indexParam: { name: string; span: Span } | undefined; + const secondParam = callback.parameters[1]; + if (secondParam) { + if (!ts.isIdentifier(secondParam.name)) { + this.fail( + secondParam.name, + "hir:destructured-binding", + "The `.map(...)` index parameter must be a plain name.", + ); + } + indexParam = { + name: secondParam.name.text, + span: this.spanOf(secondParam.name), + }; + bodyScope.locals.add(indexParam.name); + bodyScope.distributionLocals.delete(indexParam.name); + bodyScope.destructuredFields.delete(indexParam.name); + bodyScope.parameterAliases.delete(indexParam.name); + } + + const body = ts.isBlock(callback.body) + ? this.lowerBlock(callback.body, bodyScope) + : this.lowerExpr(callback.body, bodyScope); + + return this.make(node, { + kind: "arrayMap", + target, + param, + indexParam, + body, + }); + } + + private lowerDistributionMap( + node: ts.CallExpression, + base: HirExpr, + callback: ts.Expression, + scope: LowerScope, + ): HirExpr { + // `dist.map(Math.cos)` — expand the function reference to a callback. + if ( + ts.isPropertyAccessExpression(callback) && + ts.isIdentifier(callback.expression) && + callback.expression.text === "Math" + ) { + const method = callback.name.text; + if (!(HIR_MATH_FNS as readonly string[]).includes(method)) { + this.fail( + callback.name, + "hir:unknown-math-function", + `\`Math.${method}\` is not supported.`, + ); + } + const paramName = "__sample"; + const argRef = this.make(callback, { + kind: "localRef", + name: paramName, + }); + const body = this.make(callback, { + kind: "mathCall", + fn: method as HirMathFn, + args: [argRef], + }); + return this.make(node, { + kind: "distributionMap", + base, + param: { name: paramName, span: this.spanOf(callback) }, + body, + }); + } + + if (!ts.isArrowFunction(callback) && !ts.isFunctionExpression(callback)) { + this.fail( + callback, + "hir:map-callback", + "Distribution `.map(...)` expects an inline function or a `Math.*` reference.", + ); + } + if (callback.parameters.length !== 1) { + this.fail( + callback, + "hir:map-arity", + "Distribution `.map(...)` callbacks take exactly one parameter (the sampled value).", + ); + } + const parameter = callback.parameters[0]!; + if (!ts.isIdentifier(parameter.name)) { + this.fail( + parameter.name, + "hir:destructured-binding", + "Distribution `.map(...)` parameters must be plain names.", + ); + } + + const bodyScope = childScope(scope); + const paramName = parameter.name.text; + bodyScope.locals.add(paramName); + bodyScope.distributionLocals.delete(paramName); + bodyScope.destructuredFields.delete(paramName); + bodyScope.parameterAliases.delete(paramName); + + const body = ts.isBlock(callback.body) + ? this.lowerBlock(callback.body, bodyScope) + : this.lowerExpr(callback.body, bodyScope); + + return this.make(node, { + kind: "distributionMap", + base, + param: { name: paramName, span: this.spanOf(parameter.name) }, + body, + }); + } + + private lowerObjectLiteral( + node: ts.ObjectLiteralExpression, + scope: LowerScope, + ): HirExpr { + const entries: { key: string; keySpan: Span; value: HirExpr }[] = []; + for (const property of node.properties) { + if (ts.isPropertyAssignment(property)) { + const name = property.name; + if (ts.isIdentifier(name) || ts.isStringLiteralLike(name)) { + entries.push({ + key: name.text, + keySpan: this.spanOf(name), + value: this.lowerExpr(property.initializer, scope), + }); + } else { + this.fail( + name, + "hir:computed-key", + "Computed object keys are not supported.", + ); + } + } else if (ts.isShorthandPropertyAssignment(property)) { + entries.push({ + key: property.name.text, + keySpan: this.spanOf(property.name), + value: this.lowerIdentifier(property.name, scope), + }); + } else if (ts.isSpreadAssignment(property)) { + this.fail( + property, + "hir:spread", + "Object spread is not supported yet — list attributes explicitly.", + ); + } else { + this.fail( + property, + "hir:unsupported-syntax", + "Only `key: value` entries are supported in object literals.", + ); + } + } + return this.make(node, { kind: "recordLit", entries }); + } + + /** + * Conservative distribution-ness test, used to disambiguate `.map(...)` + * between array comprehensions and derived distributions. + */ + private isDistributionValued(expr: HirExpr, scope: LowerScope): boolean { + switch (expr.kind) { + case "distribution": + case "distributionMap": + return true; + case "localRef": + return scope.distributionLocals.has(expr.name); + case "cond": + return ( + this.isDistributionValued(expr.thenBranch, scope) || + this.isDistributionValued(expr.elseBranch, scope) + ); + case "let": + return this.isDistributionValued(expr.body, scope); + default: + return false; + } + } +} + +/** + * Shifts a span from wrapped-metric-source coordinates back onto the raw + * user body: subtracts the prefix length and clamps the result into + * `[0, codeLength]` (spans covering the synthetic prefix/suffix collapse to + * the nearest edge of the user text). + */ +function shiftMetricSpan(span: Span, codeLength: number): void { + const start = Math.min( + Math.max(0, span.start - METRIC_PREFIX.length), + codeLength, + ); + const end = Math.min( + Math.max(start, span.start + span.length - METRIC_PREFIX.length), + codeLength, + ); + // eslint-disable-next-line no-param-reassign -- in-place span rebasing over freshly-built nodes is the point of this helper + span.start = start; + // eslint-disable-next-line no-param-reassign -- see above + span.length = end - start; +} + +/** Shifts every span in a lowered metric function (nodes, binding names, + * record keys, callback params, fn/params spans) onto the raw user body. */ +function shiftMetricFunctionSpans(fn: HirFunction, codeLength: number): void { + shiftMetricSpan(fn.span, codeLength); + for (const param of fn.params) { + shiftMetricSpan(param.span, codeLength); + } + walkHir(fn.body, (node) => { + shiftMetricSpan(node.span, codeLength); + switch (node.kind) { + case "fieldAccess": + shiftMetricSpan(node.fieldSpan, codeLength); + break; + case "let": + for (const binding of node.bindings) { + shiftMetricSpan(binding.nameSpan, codeLength); + } + break; + case "recordLit": + for (const entry of node.entries) { + shiftMetricSpan(entry.keySpan, codeLength); + } + break; + case "arrayMap": + shiftMetricSpan(node.param.span, codeLength); + if (node.indexParam) { + shiftMetricSpan(node.indexParam.span, codeLength); + } + break; + case "arrayReduce": + shiftMetricSpan(node.accParam.span, codeLength); + shiftMetricSpan(node.param.span, codeLength); + if (node.indexParam) { + shiftMetricSpan(node.indexParam.span, codeLength); + } + break; + case "distributionMap": + shiftMetricSpan(node.param.span, codeLength); + break; + default: + break; + } + }); +} + +function parseErrorDiagnostics( + sourceFile: ts.SourceFile, +): HirDiagnostic[] | null { + // `parseDiagnostics` is not part of the public API but has been stable + // across TypeScript versions. + const parseDiagnostics = ( + sourceFile as ts.SourceFile & { + parseDiagnostics?: ts.DiagnosticWithLocation[]; + } + ).parseDiagnostics; + if (!parseDiagnostics || parseDiagnostics.length === 0) { + return null; + } + return parseDiagnostics.slice(0, 3).map((diagnostic) => ({ + code: "hir:parse-error", + message: ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"), + severity: "error" as const, + span: { + start: diagnostic.start, + length: Math.max(diagnostic.length, 1), + }, + })); +} + +/** + * Lowers a metric function body (`state` in scope, statements ending in + * `return`). The body is wrapped as `(state) => { ... }` for parsing; all + * spans in the result (including diagnostics) are shifted back so they are + * relative to the raw user body. + */ +function lowerMetricBodyToHir(code: string): LowerTypeScriptResult { + const wrapped = METRIC_PREFIX + code + METRIC_SUFFIX; + const sourceFile = ts.createSourceFile( + "user-metric.ts", + wrapped, + ts.ScriptTarget.ES2020, + /* setParentNodes */ true, + ); + + const shiftDiagnostic = (diagnostic: HirDiagnostic): HirDiagnostic => { + const span = { ...diagnostic.span }; + shiftMetricSpan(span, code.length); + return { ...diagnostic, span }; + }; + + const parseErrors = parseErrorDiagnostics(sourceFile); + if (parseErrors) { + return { ok: false, diagnostics: parseErrors.map(shiftDiagnostic) }; + } + + try { + const fn = new Lowering(sourceFile, "metric").lowerMetricModule(); + shiftMetricFunctionSpans(fn, code.length); + return { ok: true, fn, diagnostics: [] }; + } catch (error) { + if (error instanceof LowerError) { + return { ok: false, diagnostics: [shiftDiagnostic(error.diagnostic)] }; + } + throw error; + } +} + +/** + * Lowers user-authored TypeScript to an `HirFunction`. + * + * For module surfaces (dynamics/lambda/kernel), `code` must be the + * user-visible `export default Ctor(...)` module; for the `metric` surface it + * is a bare function body with `state` in scope. All spans in the result are + * relative to `code`. Returns `ok: false` with a positioned diagnostic when + * the code is syntactically invalid or falls outside the analyzable subset. + */ +export function lowerTypeScriptToHir( + code: string, + surface: HirSurfaceKind, +): LowerTypeScriptResult { + if (surface === "metric") { + return lowerMetricBodyToHir(code); + } + + const sourceFile = ts.createSourceFile( + "user-code.ts", + code, + ts.ScriptTarget.ES2020, + /* setParentNodes */ true, + ); + + // Short-circuit on parse errors so downstream diagnostics don't pile on top + // of syntactically broken code. + const parseErrors = parseErrorDiagnostics(sourceFile); + if (parseErrors) { + return { ok: false, diagnostics: parseErrors }; + } + + try { + const fn = new Lowering(sourceFile, surface).lowerModule(); + return { ok: true, fn, diagnostics: [] }; + } catch (error) { + if (error instanceof LowerError) { + return { ok: false, diagnostics: [error.diagnostic] }; + } + throw error; + } +} diff --git a/libs/@hashintel/petrinaut-core/src/hir/surface-context.ts b/libs/@hashintel/petrinaut-core/src/hir/surface-context.ts new file mode 100644 index 00000000000..3371e0b0917 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/hir/surface-context.ts @@ -0,0 +1,385 @@ +/** + * Surface contexts describe the model-derived environment a piece of user + * code executes in: which parameters exist, which places deliver tokens (and + * with which attributes), and what shape the result must have. + * + * They are consumed by the HIR type checker, lint rules and the buffer-ABI + * emitters. Lowering and generic JS emission work without a context so they + * stay usable where the SDCPN is not available (e.g. isolated tests). + * + * ## Slot layout invariant (buffer ABI) + * + * `inputSlots` lists one entry per **colored, non-inhibitor input arc whose + * color has at least one element**, in arc order — this must match the + * engine's `inputPlacesWithTokenValues` filter (`dimensions > 0 && arcType + * !== "inhibitor"`, see `compute-possible-transition.ts`). Each entry + * occupies `tokenCount` (arc weight) consecutive token slots. `outputSlots` + * lists colored output arcs in arc order (including zero-element colors, + * which occupy no floats but must still be produced by kernels). + * + * When several arcs reference places with the same display name, the runtime + * `tokensByPlace` object keeps the LAST arc's tokens (object key overwrite) — + * both the merged `inputPlaces` bindings and name-based slot resolution + * follow that rule. + */ +import { getArcEndpoint } from "../arc-endpoints"; +import { + createArcPlaceResolver, + DEFAULT_PETRINAUT_EXTENSIONS, + getEffectiveTransitionLambdaType, + getTransitionLogicAvailability, + type PetrinautExtensionSettings, +} from "../extensions"; + +import type { + Color, + ColorElementType, + ComponentInstance, + InputArc, + OutputArc, + Parameter, + Place, + SDCPN, + Transition, +} from "../types/sdcpn"; + +/** The net a transition/differential equation belongs to (root or subnet). */ +export type HirNetScope = { + places: Place[]; + parameters: Parameter[]; + componentInstances?: ComponentInstance[]; +}; + +export type HirParameterInfo = { + /** The identifier used in user code (`parameters.`). */ + name: string; + type: ColorElementType; +}; + +export type HirTokenElementInfo = { + name: string; + type: ColorElementType; +}; + +/** A place, as seen from a transition through its arcs (merged by name). */ +export type HirPlaceBinding = { + /** Display name used as the object key in user code (scoped + * `Instance::Port` for component ports). */ + name: string; + colorId: string; + elements: HirTokenElementInfo[]; + /** Number of tokens delivered/produced (arc weight; last arc wins for + * duplicate names, matching runtime object-key overwrite). */ + tokenCount: number; +}; + +/** + * One arc's worth of token slots in the buffer ABI (see module doc). + * `slotStart` is the index of the arc's first token slot. + */ +export type HirArcSlot = { + name: string; + colorId: string; + elements: HirTokenElementInfo[]; + tokenCount: number; + slotStart: number; +}; + +export type HirDynamicsContext = { + surface: "dynamics"; + parameters: HirParameterInfo[]; + /** Attributes of the color the differential equation is attached to. */ + elements: HirTokenElementInfo[]; +}; + +export type HirLambdaContext = { + surface: "lambda"; + parameters: HirParameterInfo[]; + inputPlaces: HirPlaceBinding[]; + /** Buffer-ABI input slot layout (see module doc). */ + inputSlots: HirArcSlot[]; + lambdaType: "predicate" | "stochastic"; +}; + +export type HirKernelContext = { + surface: "kernel"; + parameters: HirParameterInfo[]; + inputPlaces: HirPlaceBinding[]; + /** Buffer-ABI input slot layout (see module doc). */ + inputSlots: HirArcSlot[]; + outputPlaces: HirPlaceBinding[]; + /** Buffer-ABI output slot layout: colored output arcs in arc order. */ + outputSlots: HirArcSlot[]; + /** Whether the stochasticity extension is enabled (Distribution allowed). */ + stochasticity: boolean; +}; + +/** One root-net place as seen by metric code (`state.places.`). */ +export type HirMetricPlaceInfo = { + name: string; + /** Attributes of the place's color; empty for uncolored places (they still + * expose `count` and an empty-record `tokens` array). */ + elements: HirTokenElementInfo[]; +}; + +export type HirMetricContext = { + surface: "metric"; + /** Metrics have no parameters object — always empty. */ + parameters: HirParameterInfo[]; + /** ALL places of the root net, keyed by display name (last name wins for + * duplicates, matching the runtime object-key overwrite). */ + places: HirMetricPlaceInfo[]; +}; + +export type HirSurfaceContext = + | HirDynamicsContext + | HirLambdaContext + | HirKernelContext + | HirMetricContext; + +const SCOPE_SEPARATOR = "::"; + +function toParameterInfos(parameters: Parameter[]): HirParameterInfo[] { + return parameters.map((parameter) => ({ + name: parameter.variableName, + type: parameter.type, + })); +} + +/** + * Display name for the place an arc points at — must match the property keys + * generated for the LSP `Input`/`Output` types and the runtime + * `tokensByPlace` objects (`Instance::Port` scoping for component ports). + */ +function getArcPlaceDisplayName( + arc: InputArc | OutputArc, + sdcpn: SDCPN, + net: HirNetScope, +): string { + const endpoint = getArcEndpoint(arc); + + if (endpoint.kind === "place") { + const place = net.places.find((pl) => pl.id === endpoint.placeId); + return place?.name ?? endpoint.placeId; + } + + const instance = net.componentInstances?.find( + (inst) => inst.id === endpoint.componentInstanceId, + ); + const subnet = instance + ? sdcpn.subnets?.find((sn) => sn.id === instance.subnetId) + : undefined; + const portPlace = subnet?.places.find( + (pl) => pl.id === endpoint.portPlaceId && pl.isPort, + ); + + if (instance && portPlace) { + return `${instance.name}${SCOPE_SEPARATOR}${portPlace.name}`; + } + + return endpoint.componentInstanceId + SCOPE_SEPARATOR + endpoint.portPlaceId; +} + +type ArcCollection = { + /** Merged by display name; last arc wins (runtime key-overwrite parity). */ + bindings: HirPlaceBinding[]; + /** Per-arc slots in arc order (engine filter applied). */ + slots: HirArcSlot[]; +}; + +function collectArcs( + arcs: (InputArc | OutputArc)[], + sdcpn: SDCPN, + net: HirNetScope, + extensions: PetrinautExtensionSettings, + colorById: Map, + options: { includeZeroElementColors: boolean }, +): ArcCollection { + const resolveArcPlace = createArcPlaceResolver(sdcpn, net, { + componentPortsEnabled: extensions.subnets, + }); + const bindings = new Map(); + const slots: HirArcSlot[] = []; + let slotStart = 0; + + for (const arc of arcs) { + if ("type" in arc && arc.type === "inhibitor") { + continue; + } + const place = resolveArcPlace(arc); + if (!extensions.colors || !place?.colorId) { + continue; + } + const color = colorById.get(place.colorId); + if (!color) { + continue; + } + if (color.elements.length === 0 && !options.includeZeroElementColors) { + continue; + } + + const name = getArcPlaceDisplayName(arc, sdcpn, net); + const elements = color.elements.map((element) => ({ + name: element.name, + type: element.type, + })); + + // Last arc with a given name wins, matching the runtime's object-key + // overwrite when building `tokensByPlace`. + bindings.set(name, { + name, + colorId: color.id, + elements, + tokenCount: arc.weight, + }); + + slots.push({ + name, + colorId: color.id, + elements, + tokenCount: arc.weight, + slotStart, + }); + slotStart += arc.weight; + } + + return { bindings: [...bindings.values()], slots }; +} + +function collectColors( + sdcpn: SDCPN, + extensions: PetrinautExtensionSettings, +): Map { + const allColors = extensions.colors + ? [ + ...sdcpn.types, + ...(sdcpn.subnets ?? []).flatMap((subnet) => subnet.types), + ] + : []; + return new Map(allColors.map((color) => [color.id, color])); +} + +/** + * Builds the context for a differential equation attached to `color`. + * Pass `net` when the equation belongs to a subnet (its parameters apply). + */ +export function buildDynamicsContext( + sdcpn: SDCPN, + colorId: string, + extensions: PetrinautExtensionSettings = DEFAULT_PETRINAUT_EXTENSIONS, + net: HirNetScope = sdcpn, +): HirDynamicsContext | null { + const color = collectColors(sdcpn, extensions).get(colorId); + if (!color) { + return null; + } + return { + surface: "dynamics", + parameters: toParameterInfos(extensions.parameters ? net.parameters : []), + elements: color.elements.map((element) => ({ + name: element.name, + type: element.type, + })), + }; +} + +/** + * Builds the context for a transition's lambda. Pass `net` when the + * transition belongs to a subnet. + */ +export function buildLambdaContext( + sdcpn: SDCPN, + transition: Transition, + extensions: PetrinautExtensionSettings = DEFAULT_PETRINAUT_EXTENSIONS, + net: HirNetScope = sdcpn, +): HirLambdaContext { + const availability = getTransitionLogicAvailability( + transition, + sdcpn, + extensions, + net, + ); + const { bindings, slots } = collectArcs( + transition.inputArcs, + sdcpn, + net, + extensions, + collectColors(sdcpn, extensions), + { includeZeroElementColors: false }, + ); + return { + surface: "lambda", + parameters: toParameterInfos(extensions.parameters ? net.parameters : []), + inputPlaces: bindings, + inputSlots: slots, + lambdaType: getEffectiveTransitionLambdaType(transition, availability), + }; +} + +/** + * Builds the context for a metric: every place of the root net by display + * name. Uncolored places (and places whose color has no elements) expose + * `count` plus an empty-record `tokens` array. + */ +export function buildMetricContext( + sdcpn: SDCPN, + extensions: PetrinautExtensionSettings = DEFAULT_PETRINAUT_EXTENSIONS, +): HirMetricContext { + const colorById = collectColors(sdcpn, extensions); + const placesByName = new Map(); + for (const place of sdcpn.places) { + const color = place.colorId ? colorById.get(place.colorId) : undefined; + placesByName.set(place.name, { + name: place.name, + elements: (color?.elements ?? []).map((element) => ({ + name: element.name, + type: element.type, + })), + }); + } + return { + surface: "metric", + parameters: [], + places: [...placesByName.values()], + }; +} + +/** + * Builds the context for a transition's kernel. Pass `net` when the + * transition belongs to a subnet. + */ +export function buildKernelContext( + sdcpn: SDCPN, + transition: Transition, + extensions: PetrinautExtensionSettings = DEFAULT_PETRINAUT_EXTENSIONS, + net: HirNetScope = sdcpn, +): HirKernelContext { + const colorById = collectColors(sdcpn, extensions); + const inputs = collectArcs( + transition.inputArcs, + sdcpn, + net, + extensions, + colorById, + { includeZeroElementColors: false }, + ); + const outputs = collectArcs( + transition.outputArcs, + sdcpn, + net, + extensions, + colorById, + // Zero-element colored outputs still require a (token-count-sized) entry + // in the kernel result, they just carry no attribute floats. + { includeZeroElementColors: true }, + ); + return { + surface: "kernel", + parameters: toParameterInfos(extensions.parameters ? net.parameters : []), + inputPlaces: inputs.bindings, + inputSlots: inputs.slots, + outputPlaces: outputs.bindings, + outputSlots: outputs.slots, + stochasticity: extensions.stochasticity, + }; +} diff --git a/libs/@hashintel/petrinaut-core/src/hir/typecheck.ts b/libs/@hashintel/petrinaut-core/src/hir/typecheck.ts new file mode 100644 index 00000000000..bdc2f502a9e --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/hir/typecheck.ts @@ -0,0 +1,978 @@ +/** + * Type inference and checking over the HIR. + * + * Types flow bottom-up from literals and the surface context (token attribute + * types, parameter types). The checker is deliberately pragmatic: `unknown` + * propagates silently, and diagnostics are only produced where the checker is + * confident — TypeScript remains the authoritative type oracle for TS-authored + * code, so these diagnostics focus on friendlier, domain-specific messages + * (unknown attribute, derivative on a discrete attribute, Distribution into a + * discrete attribute, ...) and on standalone use where no TS checker runs + * (the future DSL, runtime compilation). + */ +import { + formatHirType, + HIR_TYPE_BOOL, + HIR_TYPE_DISTRIBUTION, + HIR_TYPE_INT, + HIR_TYPE_REAL, + HIR_TYPE_STRING, + HIR_TYPE_UNKNOWN, + HIR_TYPE_UUID, +} from "./hir"; + +import type { + HirDiagnostic, + HirExpr, + HirFunction, + HirMathFn, + HirNodeId, + HirType, + Span, +} from "./hir"; +import type { HirSurfaceContext, HirTokenElementInfo } from "./surface-context"; + +export type HirTypecheckResult = { + /** Inferred type per HIR node id. */ + types: Map; + returnType: HirType; + diagnostics: HirDiagnostic[]; +}; + +const MATH_FN_ARITY: Record = { + abs: { min: 1, max: 1 }, + acos: { min: 1, max: 1 }, + asin: { min: 1, max: 1 }, + atan: { min: 1, max: 1 }, + atan2: { min: 2, max: 2 }, + cbrt: { min: 1, max: 1 }, + ceil: { min: 1, max: 1 }, + cos: { min: 1, max: 1 }, + cosh: { min: 1, max: 1 }, + exp: { min: 1, max: 1 }, + floor: { min: 1, max: 1 }, + hypot: { min: 1, max: Infinity }, + log: { min: 1, max: 1 }, + log10: { min: 1, max: 1 }, + log2: { min: 1, max: 1 }, + max: { min: 1, max: Infinity }, + min: { min: 1, max: Infinity }, + pow: { min: 2, max: 2 }, + random: { min: 0, max: 0 }, + round: { min: 1, max: 1 }, + sign: { min: 1, max: 1 }, + sin: { min: 1, max: 1 }, + sinh: { min: 1, max: 1 }, + sqrt: { min: 1, max: 1 }, + tan: { min: 1, max: 1 }, + tanh: { min: 1, max: 1 }, + trunc: { min: 1, max: 1 }, +}; + +/** Math functions that always produce integers. */ +const INT_MATH_FNS = new Set([ + "ceil", + "floor", + "round", + "sign", + "trunc", +]); + +function elementType(element: HirTokenElementInfo): HirType { + switch (element.type) { + case "real": + return HIR_TYPE_REAL; + case "integer": + return HIR_TYPE_INT; + case "boolean": + return HIR_TYPE_BOOL; + case "uuid": + return HIR_TYPE_UUID; + case "string": + return HIR_TYPE_STRING; + } +} + +function tokenRecordType(elements: HirTokenElementInfo[]): HirType { + return { + kind: "record", + fields: elements.map((element) => ({ + name: element.name, + type: elementType(element), + })), + }; +} + +function isNumeric(type: HirType): boolean { + return type.kind === "real" || type.kind === "int" || type.kind === "unknown"; +} + +function isBoolish(type: HirType): boolean { + return type.kind === "bool" || type.kind === "unknown"; +} + +/** Least upper bound, collapsing to `unknown` on structural mismatch. */ +function joinTypes(left: HirType, right: HirType): HirType { + if (left.kind === "unknown" || right.kind === "unknown") { + return HIR_TYPE_UNKNOWN; + } + if (left.kind === right.kind) { + if (left.kind === "array" && right.kind === "array") { + return { + kind: "array", + element: joinTypes(left.element, right.element), + length: left.length === right.length ? left.length : undefined, + }; + } + if (left.kind === "record" && right.kind === "record") { + const rightByName = new Map( + right.fields.map((field) => [field.name, field.type]), + ); + if ( + left.fields.length !== right.fields.length || + !left.fields.every((field) => rightByName.has(field.name)) + ) { + return HIR_TYPE_UNKNOWN; + } + return { + kind: "record", + fields: left.fields.map((field) => ({ + name: field.name, + type: joinTypes(field.type, rightByName.get(field.name)!), + })), + }; + } + return left; + } + if ( + (left.kind === "int" && right.kind === "real") || + (left.kind === "real" && right.kind === "int") + ) { + return HIR_TYPE_REAL; + } + return HIR_TYPE_UNKNOWN; +} + +class Typechecker { + readonly types = new Map(); + readonly diagnostics: HirDiagnostic[] = []; + + constructor(private readonly context: HirSurfaceContext) {} + + report( + span: Span, + code: string, + message: string, + severity: HirDiagnostic["severity"] = "error", + ): void { + this.diagnostics.push({ code, message, severity, span }); + } + + check(fn: HirFunction): HirType { + const env = new Map(); + const tokensParam = fn.params[0]; + if (tokensParam) { + env.set(tokensParam.name, this.tokensParamType()); + } + const returnType = this.infer(fn.body, env); + this.checkReturnType(fn, returnType); + return returnType; + } + + private tokensParamType(): HirType { + const context = this.context; + switch (context.surface) { + case "dynamics": + return { kind: "array", element: tokenRecordType(context.elements) }; + case "lambda": + case "kernel": + return { + kind: "record", + fields: context.inputPlaces.map((place) => ({ + name: place.name, + type: { + kind: "array", + element: tokenRecordType(place.elements), + length: place.tokenCount, + } satisfies HirType, + })), + }; + case "metric": + return { + kind: "record", + fields: [ + { + name: "places", + type: { + kind: "record", + fields: context.places.map((place) => ({ + name: place.name, + type: { + kind: "record", + fields: [ + { name: "count", type: HIR_TYPE_INT }, + { + name: "tokens", + type: { + kind: "array", + element: tokenRecordType(place.elements), + } satisfies HirType, + }, + ], + } satisfies HirType, + })), + }, + }, + ], + }; + } + } + + private infer(expr: HirExpr, env: Map): HirType { + const type = this.inferUncached(expr, env); + this.types.set(expr.id, type); + return type; + } + + private inferUncached(expr: HirExpr, env: Map): HirType { + switch (expr.kind) { + case "numberLit": + return Number.isInteger(expr.value) ? HIR_TYPE_INT : HIR_TYPE_REAL; + case "boolLit": + return HIR_TYPE_BOOL; + case "stringLit": + return HIR_TYPE_STRING; + case "stringCall": { + const targetType = this.infer(expr.target, env); + const argumentType = this.infer(expr.argument, env); + if (targetType.kind !== "string" && targetType.kind !== "unknown") { + this.report( + expr.target.span, + "hir:type-mismatch", + `\`.${expr.fn}(...)\` is only available on strings, not ${formatHirType(targetType)}.`, + ); + } + if (argumentType.kind !== "string" && argumentType.kind !== "unknown") { + this.report( + expr.argument.span, + "hir:type-mismatch", + `\`.${expr.fn}(...)\` expects a string argument, got ${formatHirType(argumentType)}.`, + ); + } + return HIR_TYPE_BOOL; + } + case "uuidGenerate": + this.checkUuidHelperAllowed(expr.span); + return HIR_TYPE_UUID; + case "uuidFrom": { + this.checkUuidHelperAllowed(expr.span); + this.infer(expr.operand, env); + return HIR_TYPE_UUID; + } + case "constant": + return HIR_TYPE_REAL; + case "localRef": + return env.get(expr.name) ?? HIR_TYPE_UNKNOWN; + case "paramRef": { + const parameter = this.context.parameters.find( + (candidate) => candidate.name === expr.name, + ); + if (!parameter) { + this.report( + expr.span, + "hir:unknown-parameter", + `Unknown parameter \`${expr.name}\`.`, + ); + return HIR_TYPE_UNKNOWN; + } + return elementType({ name: parameter.name, type: parameter.type }); + } + case "fieldAccess": { + const targetType = this.infer(expr.target, env); + if (targetType.kind === "record") { + const field = targetType.fields.find( + (candidate) => candidate.name === expr.field, + ); + if (!field) { + this.report( + expr.fieldSpan, + "hir:unknown-field", + `\`${expr.field}\` does not exist here — expected one of: ${targetType.fields + .map((candidate) => candidate.name) + .join(", ")}.`, + ); + return HIR_TYPE_UNKNOWN; + } + return field.type; + } + if (targetType.kind !== "unknown") { + this.report( + expr.fieldSpan, + "hir:invalid-field-access", + `Cannot access field \`${expr.field}\` on a ${formatHirType(targetType)} value.`, + ); + } + return HIR_TYPE_UNKNOWN; + } + case "indexAccess": { + const targetType = this.infer(expr.target, env); + const indexType = this.infer(expr.index, env); + if (!isNumeric(indexType)) { + this.report( + expr.index.span, + "hir:invalid-index", + "Array indices must be numbers.", + ); + } + if (targetType.kind === "array") { + if ( + targetType.length !== undefined && + expr.index.kind === "numberLit" && + expr.index.value >= targetType.length + ) { + this.report( + expr.span, + "hir:index-out-of-bounds", + `Index ${expr.index.raw} is out of bounds — only ${targetType.length} token(s) are available here.`, + ); + } + return targetType.element; + } + if (targetType.kind !== "unknown") { + this.report( + expr.span, + "hir:invalid-index", + `Cannot index into a ${formatHirType(targetType)} value.`, + ); + } + return HIR_TYPE_UNKNOWN; + } + case "length": { + const targetType = this.infer(expr.target, env); + if ( + targetType.kind !== "array" && + targetType.kind !== "string" && + targetType.kind !== "unknown" + ) { + this.report( + expr.span, + "hir:invalid-length", + `\`.length\` is only available on arrays and strings, not ${formatHirType(targetType)}.`, + ); + } + return HIR_TYPE_INT; + } + case "unary": { + const operandType = this.infer(expr.operand, env); + if (expr.op === "!") { + if (!isBoolish(operandType)) { + this.report( + expr.span, + "hir:type-mismatch", + `\`!\` expects a boolean, got ${formatHirType(operandType)}.`, + ); + } + return HIR_TYPE_BOOL; + } + if (!isNumeric(operandType)) { + this.report( + expr.span, + "hir:type-mismatch", + `Unary \`${expr.op}\` expects a number, got ${formatHirType(operandType)}.`, + ); + return HIR_TYPE_UNKNOWN; + } + return operandType; + } + case "binary": { + const leftType = this.infer(expr.left, env); + const rightType = this.infer(expr.right, env); + switch (expr.op) { + case "&&": + case "||": + if (!isBoolish(leftType) || !isBoolish(rightType)) { + this.report( + expr.span, + "hir:type-mismatch", + `\`${expr.op}\` expects booleans.`, + ); + } + return HIR_TYPE_BOOL; + case "==": + case "!=": + return HIR_TYPE_BOOL; + case "<": + case "<=": + case ">": + case ">=": + if (!isNumeric(leftType) || !isNumeric(rightType)) { + this.report( + expr.span, + "hir:type-mismatch", + `\`${expr.op}\` expects numbers.`, + ); + } + return HIR_TYPE_BOOL; + case "/": + case "**": + this.expectNumeric(expr, leftType, rightType); + return HIR_TYPE_REAL; + default: + this.expectNumeric(expr, leftType, rightType); + return leftType.kind === "int" && rightType.kind === "int" + ? HIR_TYPE_INT + : leftType.kind === "unknown" || rightType.kind === "unknown" + ? HIR_TYPE_UNKNOWN + : HIR_TYPE_REAL; + } + } + case "cond": { + const conditionType = this.infer(expr.condition, env); + if (!isBoolish(conditionType)) { + this.report( + expr.condition.span, + "hir:type-mismatch", + `Conditions must be booleans, got ${formatHirType(conditionType)}.`, + ); + } + return joinTypes( + this.infer(expr.thenBranch, env), + this.infer(expr.elseBranch, env), + ); + } + case "let": { + const scoped = new Map(env); + for (const binding of expr.bindings) { + scoped.set(binding.name, this.infer(binding.value, scoped)); + } + return this.infer(expr.body, scoped); + } + case "mathCall": { + const arity = MATH_FN_ARITY[expr.fn]; + if (expr.args.length < arity.min || expr.args.length > arity.max) { + this.report( + expr.span, + "hir:math-arity", + arity.min === arity.max + ? `\`Math.${expr.fn}\` expects ${arity.min} argument(s).` + : `\`Math.${expr.fn}\` expects at least ${arity.min} argument(s).`, + ); + } + let allInt = true; + for (const argument of expr.args) { + const argumentType = this.infer(argument, env); + if (!isNumeric(argumentType)) { + this.report( + argument.span, + "hir:type-mismatch", + `\`Math.${expr.fn}\` expects numbers, got ${formatHirType(argumentType)}.`, + ); + } + if (argumentType.kind !== "int") { + allInt = false; + } + } + if (INT_MATH_FNS.has(expr.fn)) { + return HIR_TYPE_INT; + } + if ( + (expr.fn === "min" || expr.fn === "max" || expr.fn === "abs") && + allInt + ) { + return HIR_TYPE_INT; + } + return HIR_TYPE_REAL; + } + case "recordLit": { + const seen = new Set(); + const fields: { name: string; type: HirType }[] = []; + for (const entry of expr.entries) { + if (seen.has(entry.key)) { + this.report( + entry.keySpan, + "hir:duplicate-key", + `Duplicate key \`${entry.key}\`.`, + ); + } + seen.add(entry.key); + fields.push({ name: entry.key, type: this.infer(entry.value, env) }); + } + return { kind: "record", fields }; + } + case "arrayLit": { + let element: HirType = HIR_TYPE_UNKNOWN; + for (const [index, item] of expr.elements.entries()) { + const itemType = this.infer(item, env); + element = index === 0 ? itemType : joinTypes(element, itemType); + } + return { kind: "array", element, length: expr.elements.length }; + } + case "arrayMap": { + const targetType = this.infer(expr.target, env); + let elementType_: HirType = HIR_TYPE_UNKNOWN; + let length: number | undefined; + if (targetType.kind === "array") { + elementType_ = targetType.element; + length = targetType.length; + } else if (targetType.kind !== "unknown") { + this.report( + expr.target.span, + "hir:invalid-map", + `\`.map(...)\` is only available on arrays, not ${formatHirType(targetType)}.`, + ); + } + const scoped = new Map(env); + scoped.set(expr.param.name, elementType_); + if (expr.indexParam) { + scoped.set(expr.indexParam.name, HIR_TYPE_INT); + } + return { + kind: "array", + element: this.infer(expr.body, scoped), + length, + }; + } + case "arrayReduce": { + const targetType = this.infer(expr.target, env); + let elementType_: HirType = HIR_TYPE_UNKNOWN; + if (targetType.kind === "array") { + elementType_ = targetType.element; + } else if (targetType.kind !== "unknown") { + this.report( + expr.target.span, + "hir:invalid-map", + `\`.reduce(...)\` is only available on arrays, not ${formatHirType(targetType)}.`, + ); + } + const initialType = this.infer(expr.initial, env); + const scoped = new Map(env); + // The accumulator is seeded with the initial value's type; the result + // is the join of the initial and body types (e.g. int seed + real + // body → real). + scoped.set(expr.accParam.name, initialType); + scoped.set(expr.param.name, elementType_); + if (expr.indexParam) { + scoped.set(expr.indexParam.name, HIR_TYPE_INT); + } + const bodyType = this.infer(expr.body, scoped); + return joinTypes(initialType, bodyType); + } + case "arrayConcat": { + const leftType = this.infer(expr.left, env); + const rightType = this.infer(expr.right, env); + const elementOf = (type: HirType, span: Span): HirType => { + if (type.kind === "array") { + return type.element; + } + if (type.kind !== "unknown") { + this.report( + span, + "hir:invalid-map", + `\`.concat(...)\` is only available on arrays, not ${formatHirType(type)}.`, + ); + } + return HIR_TYPE_UNKNOWN; + }; + return { + kind: "array", + element: joinTypes( + elementOf(leftType, expr.left.span), + elementOf(rightType, expr.right.span), + ), + // The combined length is dynamic. + }; + } + case "distribution": { + this.checkDistributionAllowed(expr.span); + if (expr.args.length !== 2) { + this.report( + expr.span, + "hir:distribution-arity", + "Distributions take exactly two arguments.", + ); + } + for (const argument of expr.args) { + const argumentType = this.infer(argument, env); + if (!isNumeric(argumentType)) { + this.report( + argument.span, + "hir:type-mismatch", + `Distribution arguments must be numbers, got ${formatHirType(argumentType)}.`, + ); + } + } + return HIR_TYPE_DISTRIBUTION; + } + case "distributionMap": { + const baseType = this.infer(expr.base, env); + if (baseType.kind !== "distribution" && baseType.kind !== "unknown") { + this.report( + expr.base.span, + "hir:invalid-map", + `Distribution \`.map(...)\` requires a distribution, got ${formatHirType(baseType)}.`, + ); + } + const scoped = new Map(env); + scoped.set(expr.param.name, HIR_TYPE_REAL); + const bodyType = this.infer(expr.body, scoped); + if (!isNumeric(bodyType)) { + this.report( + expr.body.span, + "hir:type-mismatch", + `Distribution \`.map(...)\` must return a number, got ${formatHirType(bodyType)}.`, + ); + } + return HIR_TYPE_DISTRIBUTION; + } + } + } + + private expectNumeric( + expr: Extract, + leftType: HirType, + rightType: HirType, + ): void { + if (!isNumeric(leftType) || !isNumeric(rightType)) { + this.report( + expr.span, + "hir:type-mismatch", + `\`${expr.op}\` expects numbers.`, + ); + } + } + + private checkUuidHelperAllowed(span: Span): void { + if (this.context.surface !== "kernel") { + this.report( + span, + "hir:uuid-outside-kernel", + "`Uuid.generate()` / `Uuid.from(...)` are only meaningful in transition kernel outputs.", + ); + } + } + + private checkDistributionAllowed(span: Span): void { + if (this.context.surface !== "kernel") { + this.report( + span, + "hir:distribution-outside-kernel", + "Distributions can only be produced in transition kernel outputs — they are sampled when the transition fires.", + ); + } else if (!this.context.stochasticity) { + this.report( + span, + "hir:distribution-disabled", + "Distributions require the stochasticity extension, which is disabled for this net.", + ); + } + } + + /** Surface-specific checks on the function's result type. */ + private checkReturnType(fn: HirFunction, returnType: HirType): void { + const context = this.context; + const bodySpan = fn.body.kind === "let" ? fn.body.body.span : fn.body.span; + + switch (context.surface) { + case "dynamics": { + if (returnType.kind !== "array") { + if (returnType.kind !== "unknown") { + this.report( + bodySpan, + "hir:dynamics-return", + `Dynamics must return an array of derivative records (one per token), got ${formatHirType(returnType)}.`, + ); + } + return; + } + if (returnType.element.kind !== "record") { + return; + } + const elementByName = new Map( + context.elements.map((element) => [element.name, element]), + ); + for (const field of returnType.element.fields) { + const element = elementByName.get(field.name); + if (!element) { + this.report( + this.derivativeKeySpan(fn.body, field.name) ?? bodySpan, + "hir:unknown-attribute", + `\`${field.name}\` is not an attribute of this type.`, + ); + } else if (element.type !== "real") { + this.report( + this.derivativeKeySpan(fn.body, field.name) ?? bodySpan, + "hir:discrete-derivative", + `\`${field.name}\` is a discrete (${element.type}) attribute — only real attributes can have derivatives.`, + ); + } else if (!isNumeric(field.type)) { + this.report( + this.derivativeKeySpan(fn.body, field.name) ?? bodySpan, + "hir:type-mismatch", + `The derivative of \`${field.name}\` must be a number, got ${formatHirType(field.type)}.`, + ); + } + } + return; + } + case "lambda": { + if (context.lambdaType === "predicate") { + if (!isBoolish(returnType)) { + this.report( + bodySpan, + "hir:lambda-return", + `Predicate lambdas must return a boolean, got ${formatHirType(returnType)}.`, + ); + } + } else if (!isNumeric(returnType)) { + this.report( + bodySpan, + "hir:lambda-return", + `Stochastic lambdas must return a number (the firing rate), got ${formatHirType(returnType)}.`, + ); + } + return; + } + case "kernel": { + if (returnType.kind !== "record") { + if (returnType.kind !== "unknown") { + this.report( + bodySpan, + "hir:kernel-return", + `Transition kernels must return an object mapping output places to token arrays, got ${formatHirType(returnType)}.`, + ); + } + return; + } + this.checkKernelOutput(fn, returnType); + return; + } + case "metric": { + if (!isNumeric(returnType)) { + this.report( + bodySpan, + "hir:metric-return", + `Metrics must return a number, got ${formatHirType(returnType)}.`, + ); + } + return; + } + } + } + + private checkKernelOutput( + fn: HirFunction, + returnType: Extract, + ): void { + const context = this.context; + if (context.surface !== "kernel") { + return; + } + const placeByName = new Map( + context.outputPlaces.map((place) => [place.name, place]), + ); + const outputRecord = this.resultRecordLit(fn.body); + + for (const place of context.outputPlaces) { + if (!returnType.fields.some((field) => field.name === place.name)) { + this.report( + outputRecord?.span ?? fn.body.span, + "hir:missing-output-place", + `The kernel must return tokens for output place \`${place.name}\`.`, + ); + } + } + + for (const field of returnType.fields) { + const place = placeByName.get(field.name); + const keySpan = + outputRecord?.entries.find((entry) => entry.key === field.name) + ?.keySpan ?? fn.body.span; + if (!place) { + this.report( + keySpan, + "hir:unknown-output-place", + `\`${field.name}\` is not an output place of this transition${ + context.outputPlaces.length > 0 + ? ` — expected: ${context.outputPlaces + .map((candidate) => candidate.name) + .join(", ")}` + : "" + }.`, + ); + continue; + } + if (field.type.kind !== "array") { + continue; + } + if ( + field.type.length !== undefined && + field.type.length !== place.tokenCount + ) { + this.report( + keySpan, + "hir:output-token-count", + `\`${field.name}\` receives ${place.tokenCount} token(s) per firing (arc weight), but ${field.type.length} were returned.`, + ); + } + if (field.type.element.kind !== "record") { + continue; + } + const elementByName = new Map( + place.elements.map((element) => [element.name, element]), + ); + for (const tokenField of field.type.element.fields) { + const element = elementByName.get(tokenField.name); + if (!element) { + this.report( + keySpan, + "hir:unknown-attribute", + `\`${tokenField.name}\` is not an attribute of tokens in \`${field.name}\`.`, + ); + continue; + } + if ( + tokenField.type.kind === "distribution" && + element.type !== "real" + ) { + this.report( + keySpan, + "hir:distribution-discrete-attribute", + `\`${tokenField.name}\` is a discrete (${element.type}) attribute — Distributions can only produce real attributes.`, + ); + } + // uuid attributes accept uuids or UUID strings (converted + // deterministically by the engine). + if (element.type === "uuid") { + if ( + tokenField.type.kind !== "uuid" && + tokenField.type.kind !== "string" && + tokenField.type.kind !== "unknown" + ) { + this.report( + keySpan, + "hir:type-mismatch", + `\`${tokenField.name}\` is a uuid attribute — provide a uuid (\`Uuid.generate()\`, \`Uuid.from(...)\`, an input token's uuid), a UUID string, or omit it to auto-generate.`, + ); + } + continue; + } + if (element.type === "string") { + if ( + tokenField.type.kind !== "string" && + tokenField.type.kind !== "unknown" + ) { + this.report( + keySpan, + "hir:type-mismatch", + `\`${tokenField.name}\` is a string attribute but received ${formatHirType(tokenField.type)}.`, + ); + } + continue; + } + if ( + tokenField.type.kind === "string" || + tokenField.type.kind === "uuid" + ) { + this.report( + keySpan, + "hir:type-mismatch", + `\`${tokenField.name}\` is a ${element.type} attribute but received ${formatHirType(tokenField.type)}.`, + ); + } + if (tokenField.type.kind === "bool" && element.type !== "boolean") { + this.report( + keySpan, + "hir:type-mismatch", + `\`${tokenField.name}\` is a ${element.type} attribute but received a boolean.`, + ); + } + if ( + (tokenField.type.kind === "real" || tokenField.type.kind === "int") && + element.type === "boolean" + ) { + this.report( + keySpan, + "hir:type-mismatch", + `\`${tokenField.name}\` is a boolean attribute but received a number.`, + ); + } + } + for (const element of place.elements) { + // uuid attributes may be omitted — the engine generates a fresh UUID + // from the seeded RNG per firing. + if (element.type === "uuid") { + continue; + } + if ( + !field.type.element.fields.some( + (tokenField) => tokenField.name === element.name, + ) + ) { + this.report( + keySpan, + "hir:missing-attribute", + `Tokens for \`${field.name}\` are missing the \`${element.name}\` attribute.`, + ); + } + } + } + } + + /** The record literal the function ultimately returns, if syntactically + * evident (unwrapping `let` bodies). */ + private resultRecordLit( + expr: HirExpr, + ): Extract | null { + if (expr.kind === "recordLit") { + return expr; + } + if (expr.kind === "let") { + return this.resultRecordLit(expr.body); + } + return null; + } + + /** Span of the `name:` key inside the derivative record literal returned by + * a dynamics body, when it is syntactically evident. */ + private derivativeKeySpan(expr: HirExpr, key: string): Span | null { + if (expr.kind === "let") { + return this.derivativeKeySpan(expr.body, key); + } + if (expr.kind === "arrayMap") { + return this.derivativeKeySpan(expr.body, key); + } + if (expr.kind === "arrayLit") { + for (const element of expr.elements) { + const span = this.derivativeKeySpan(element, key); + if (span) { + return span; + } + } + return null; + } + if (expr.kind === "recordLit") { + return expr.entries.find((entry) => entry.key === key)?.keySpan ?? null; + } + if (expr.kind === "cond") { + return ( + this.derivativeKeySpan(expr.thenBranch, key) ?? + this.derivativeKeySpan(expr.elseBranch, key) + ); + } + return null; + } +} + +/** Runs type inference and surface checks over a lowered function. */ +export function typecheckHir( + fn: HirFunction, + context: HirSurfaceContext, +): HirTypecheckResult { + const checker = new Typechecker(context); + const returnType = checker.check(fn); + return { + types: checker.types, + returnType, + diagnostics: checker.diagnostics, + }; +} diff --git a/libs/@hashintel/petrinaut-core/src/index.ts b/libs/@hashintel/petrinaut-core/src/index.ts index 6ad41881619..79377fe7d18 100644 --- a/libs/@hashintel/petrinaut-core/src/index.ts +++ b/libs/@hashintel/petrinaut-core/src/index.ts @@ -166,6 +166,7 @@ export type { SimulationConfig, SimulationErrorEvent, SimulationEvent, + SimulationFrameRawView, SimulationFrameReader, SimulationFrameState, SimulationFrameSummary, @@ -243,6 +244,16 @@ export type { TextDocumentIdentifier, } from "./lsp"; +// --- HIR (type-only from the main entry; the compiler itself stays in the +// LSP worker, runtime instantiation in ./hir-runtime) --- +export type { + HirArtifacts, + HirCompileFailure, + HirCompileResult, + HirDiagnostic, + HirMetricArtifact, +} from "./hir"; + // --- Playback --- export { createPlayback, @@ -314,13 +325,6 @@ export { generateDefaultTransitionKernelCode, generateDefaultVisualizerCode, } from "./default-codes"; -export { - compileMetric, - type CompiledMetric, - type CompileMetricOutcome, - type MetricPlaceState, - type MetricState, -} from "./simulation/authoring/metric/compile-metric"; export { compileScenario, type CompiledPlaceMarking, @@ -330,7 +334,7 @@ export { type ScenarioCompilationError, type ScenarioParameterValues, } from "./simulation/authoring/scenario/compile-scenario"; -export { buildMetricState } from "./simulation/frames/metric-state"; +export { createHirMetricEvaluator } from "./simulation/frames/hir-metric"; export { coerceTokenAttributeValue, coerceTokenRecord, @@ -365,7 +369,6 @@ export { PETRINAUT_UUID_NAMESPACE, toUuid, } from "./simulation/engine/uuid"; -export { compileUserCode } from "./simulation/authoring/user-code/compile-user-code"; export { displayNameSchema, validateDisplayName, diff --git a/libs/@hashintel/petrinaut-core/src/lsp/language-client.ts b/libs/@hashintel/petrinaut-core/src/lsp/language-client.ts index c382ca936cb..b939b384a29 100644 --- a/libs/@hashintel/petrinaut-core/src/lsp/language-client.ts +++ b/libs/@hashintel/petrinaut-core/src/lsp/language-client.ts @@ -1,3 +1,5 @@ +import { DiagnosticSeverity } from "vscode-languageserver-types"; + import { createWorkerLspTransport, type LspTransport, @@ -5,6 +7,8 @@ import { } from "./transport"; import type { PetrinautExtensionSettings } from "../extensions"; +// Type-only: must not pull the compiler (`typescript`) into client bundles. +import type { HirCompileResult } from "../hir"; import type { ReadableStore } from "../store"; import type { SDCPN } from "../types/sdcpn"; import type { @@ -25,6 +29,12 @@ import type { export type DiagnosticsSnapshot = { byUri: Map; total: number; + /** + * Error-severity diagnostics only. Use this (not `total`) to decide whether + * the net is simulatable — warnings/hints (e.g. HIR semantic lints) don't + * block running. + */ + errorCount: number; }; /** @@ -74,6 +84,16 @@ export interface LanguageClient { uri: DocumentUri, position: Position, ): Promise; + /** + * Compiles the SDCPN's user code to HIR artifacts (in the worker, where the + * TypeScript frontend lives). Pass the result as `hirArtifacts` when + * starting simulations/experiments — the engine has no compiler of its own. + */ + requestHirArtifacts( + this: void, + sdcpn: SDCPN, + extensions?: PetrinautExtensionSettings, + ): Promise; /** * Tear down the transport. Pending requests reject with "Worker terminated". @@ -89,6 +109,7 @@ export type CreateLanguageClientConfig = const EMPTY_DIAGNOSTICS: DiagnosticsSnapshot = { byUri: new Map(), total: 0, + errorCount: 0, }; function createReadableStore(initial: T): ReadableStore & { @@ -119,14 +140,20 @@ function buildSnapshot( ): DiagnosticsSnapshot { const byUri = new Map(); let total = 0; + let errorCount = 0; for (const param of allParams) { if (param.diagnostics.length === 0) { continue; } byUri.set(param.uri, param.diagnostics); total += param.diagnostics.length; + for (const diagnostic of param.diagnostics) { + if (diagnostic.severity === DiagnosticSeverity.Error) { + errorCount += 1; + } + } } - return { byUri, total }; + return { byUri, total, errorCount }; } /** @@ -287,6 +314,12 @@ export function createLanguageClient( position, }); }, + requestHirArtifacts(sdcpn, extensions) { + return sendRequest("sdcpn/compileHirArtifacts", { + sdcpn, + extensions, + }); + }, dispose() { if (disposed) { diff --git a/libs/@hashintel/petrinaut-core/src/lsp/lib/check-hir.ts b/libs/@hashintel/petrinaut-core/src/lsp/lib/check-hir.ts new file mode 100644 index 00000000000..e73007cb283 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/lsp/lib/check-hir.ts @@ -0,0 +1,137 @@ +/** + * Bridges HIR semantic lints into the TypeScript-diagnostic-shaped pipeline + * used by the checker and the LSP worker. + * + * HIR lint spans are already relative to the user-visible source text, so no + * prefix adjustment is needed — the produced diagnostics can be serialized + * with the same `serializeDiagnostic` path as adjusted TS diagnostics. + */ +import ts from "typescript"; + +import { lintHirUserCode } from "../../hir"; + +import type { HirDiagnostic, HirSurfaceContext } from "../../hir"; + +/** + * Stable numeric codes for HIR diagnostics (ts.Diagnostic.code is numeric). + * Codes ≥ 99000 are reserved for Petrinaut's own checks. Append only — these + * appear in editor UI and may be referenced in docs. + */ +export const HIR_DIAGNOSTIC_CODES: Record = { + "hir:parse-error": 99001, + "hir:missing-default-export": 99002, + "hir:multiple-default-exports": 99003, + "hir:missing-constructor": 99004, + "hir:constructor-arity": 99005, + "hir:missing-function": 99006, + "hir:too-many-parameters": 99007, + "hir:destructured-parameter": 99008, + "hir:unsupported-statement": 99009, + "hir:mutable-binding": 99010, + "hir:destructured-binding": 99011, + "hir:missing-initializer": 99012, + "hir:early-return": 99013, + "hir:empty-return": 99014, + "hir:if-statement": 99015, + "hir:loop-statement": 99016, + "hir:missing-return": 99017, + "hir:unsupported-operator": 99018, + "hir:unsupported-syntax": 99019, + "hir:unsupported-call": 99020, + "hir:unknown-identifier": 99021, + "hir:bare-parameters-object": 99022, + "hir:math-reference": 99023, + "hir:unknown-math-function": 99024, + "hir:unknown-distribution": 99025, + "hir:map-arity": 99026, + "hir:map-callback": 99027, + "hir:computed-key": 99028, + "hir:spread": 99029, + "hir:string-value": 99030, + "hir:uuid-arity": 99031, + "hir:unknown-uuid-helper": 99032, + "hir:uuid-outside-kernel": 99033, + "hir:string-call-arity": 99034, + "hir:unknown-parameter": 99040, + "hir:unknown-field": 99041, + "hir:invalid-field-access": 99042, + "hir:invalid-index": 99043, + "hir:index-out-of-bounds": 99044, + "hir:invalid-length": 99045, + "hir:type-mismatch": 99046, + "hir:duplicate-key": 99047, + "hir:invalid-map": 99048, + "hir:math-arity": 99049, + "hir:distribution-arity": 99050, + "hir:distribution-outside-kernel": 99051, + "hir:distribution-disabled": 99052, + "hir:dynamics-return": 99053, + "hir:unknown-attribute": 99054, + "hir:discrete-derivative": 99055, + "hir:lambda-return": 99056, + "hir:kernel-return": 99057, + "hir:unknown-output-place": 99058, + "hir:output-token-count": 99059, + "hir:distribution-discrete-attribute": 99060, + "hir:missing-attribute": 99061, + "hir:missing-output-place": 99062, + "hir:unreachable-code": 99063, + "hir:math-random": 99070, + "hir:transition-never-fires": 99071, + "hir:shared-sample": 99072, + "hir:unused-binding": 99073, + // --- Appended (registry is append-only) --- + "hir:reduce-arity": 99035, + "hir:concat-arity": 99036, + "hir:metric-return": 99064, + "hir:not-compilable": 99074, +}; + +const FALLBACK_CODE = 99000; + +function toTsCategory( + severity: HirDiagnostic["severity"], +): ts.DiagnosticCategory { + switch (severity) { + case "error": + return ts.DiagnosticCategory.Error; + case "warning": + return ts.DiagnosticCategory.Warning; + case "info": + return ts.DiagnosticCategory.Message; + case "hint": + return ts.DiagnosticCategory.Suggestion; + } +} + +function toTsDiagnostic(diagnostic: HirDiagnostic): ts.Diagnostic { + return { + file: undefined, + start: diagnostic.span.start, + length: diagnostic.span.length, + messageText: diagnostic.message, + category: toTsCategory(diagnostic.severity), + code: HIR_DIAGNOSTIC_CODES[diagnostic.code] ?? FALLBACK_CODE, + source: "hir", + }; +} + +/** + * Runs the HIR semantic linter over one item's user code, returning + * TS-diagnostic-shaped results with offsets relative to the user content. + * + * Callers should only invoke this when TypeScript reported no errors for the + * item — HIR lints assume type-valid input, and stacking both would be noise. + */ +export function getHirDiagnosticsForItem( + code: string, + context: HirSurfaceContext, +): ts.Diagnostic[] { + if (code.trim() === "") { + return []; + } + // Out-of-subset code is an error: the HIR pipeline is the only compiler, + // so such code cannot simulate. + const result = lintHirUserCode(code, context, { subsetSeverity: "error" }); + return result.diagnostics.map(toTsDiagnostic); +} diff --git a/libs/@hashintel/petrinaut-core/src/lsp/lib/checker.test.ts b/libs/@hashintel/petrinaut-core/src/lsp/lib/checker.test.ts index 4a3ab30571d..508eecd38d8 100644 --- a/libs/@hashintel/petrinaut-core/src/lsp/lib/checker.test.ts +++ b/libs/@hashintel/petrinaut-core/src/lsp/lib/checker.test.ts @@ -82,9 +82,14 @@ describe("checkSDCPN", () => { // WHEN const result = check(sdcpn); - // THEN + // THEN — valid; only the HIR unused-binding hint remains (`value`) expect(result.isValid).toBe(true); - expect(result.itemDiagnostics).toHaveLength(0); + const nonHintDiagnostics = result.itemDiagnostics.flatMap((item) => + item.diagnostics.filter( + (diag) => diag.category !== ts.DiagnosticCategory.Suggestion, + ), + ); + expect(nonHintDiagnostics).toHaveLength(0); }); it("returns valid for code accessing defined parameters", () => { @@ -115,9 +120,14 @@ describe("checkSDCPN", () => { // WHEN const result = check(sdcpn); - // THEN + // THEN — valid; only HIR unused-binding hints remain (`a`, `e`) expect(result.isValid).toBe(true); - expect(result.itemDiagnostics).toHaveLength(0); + const nonHintDiagnostics = result.itemDiagnostics.flatMap((item) => + item.diagnostics.filter( + (diag) => diag.category !== ts.DiagnosticCategory.Suggestion, + ), + ); + expect(nonHintDiagnostics).toHaveLength(0); }); it("returns invalid when accessing undefined token property", () => { @@ -1355,4 +1365,135 @@ describe("checkSDCPN", () => { expect(result.itemDiagnostics).toHaveLength(0); }); }); + + describe("HIR semantic lints", () => { + it("surfaces Math.random as a warning with source 'hir' at the right span", () => { + // GIVEN — TS-valid code with a reproducibility problem + const lambdaCode = `export default Lambda((input, parameters) => { + return input.Source[0].value * Math.random(); +});`; + const sdcpn = createSDCPN({ + types: [{ id: "color1", elements: [{ name: "value", type: "real" }] }], + places: [ + { id: "place1", name: "Source", colorId: "color1" }, + { id: "place2", name: "Target", colorId: "color1" }, + ], + transitions: [ + { + id: "t1", + lambdaType: "stochastic", + inputArcs: [{ placeId: "place1", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "place2", weight: 1 }], + lambdaCode, + transitionKernelCode: `export default TransitionKernel((input, parameters) => { + return { Target: [input.Source[0]] }; + });`, + }, + ], + }); + + // WHEN + const result = check(sdcpn); + + // THEN — a warning (does not invalidate the net) at Math.random() + expect(result.isValid).toBe(true); + const lambdaItem = result.itemDiagnostics.find( + (item) => item.itemType === "transition-lambda", + ); + expect(lambdaItem).toBeDefined(); + const hirDiagnostic = lambdaItem!.diagnostics.find( + (diag) => diag.source === "hir", + ); + expect(hirDiagnostic).toBeDefined(); + expect(hirDiagnostic!.category).toBe(ts.DiagnosticCategory.Warning); + expect( + lambdaCode.slice( + hirDiagnostic!.start!, + hirDiagnostic!.start! + hirDiagnostic!.length!, + ), + ).toBe("Math.random()"); + }); + + it("skips HIR lints when TypeScript already reports errors", () => { + // GIVEN — code with a TS error (unknown property) + const sdcpn = createSDCPN({ + types: [{ id: "color1", elements: [{ name: "value", type: "real" }] }], + places: [ + { id: "place1", name: "Source", colorId: "color1" }, + { id: "place2", name: "Target", colorId: "color1" }, + ], + transitions: [ + { + id: "t1", + lambdaType: "predicate", + inputArcs: [{ placeId: "place1", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "place2", weight: 1 }], + lambdaCode: `export default Lambda((input, parameters) => { + return input.Source[0].missing > Math.random(); + });`, + transitionKernelCode: `export default TransitionKernel((input, parameters) => { + return { Target: [input.Source[0]] }; + });`, + }, + ], + }); + + // WHEN + const result = check(sdcpn); + + // THEN — only TS diagnostics; no doubled-up HIR results + expect(result.isValid).toBe(false); + const lambdaItem = result.itemDiagnostics.find( + (item) => item.itemType === "transition-lambda", + )!; + expect( + lambdaItem.diagnostics.every((diag) => diag.source !== "hir"), + ).toBe(true); + }); + + it("marks out-of-subset code as an error (it cannot be compiled)", () => { + // GIVEN — valid TS using a loop (outside the HIR subset) + const sdcpn = createSDCPN({ + types: [{ id: "color1", elements: [{ name: "value", type: "real" }] }], + places: [ + { id: "place1", name: "Source", colorId: "color1" }, + { id: "place2", name: "Target", colorId: "color1" }, + ], + transitions: [ + { + id: "t1", + lambdaType: "predicate", + inputArcs: [{ placeId: "place1", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "place2", weight: 1 }], + lambdaCode: `export default Lambda((input, parameters) => { + let total = 0; + for (const token of input.Source) { + total += token.value; + } + return total > 0; + });`, + transitionKernelCode: `export default TransitionKernel((input, parameters) => { + return { Target: [input.Source[0]] }; + });`, + }, + ], + }); + + // WHEN + const result = check(sdcpn); + + // THEN — the HIR pipeline is the only compiler, so this blocks running + expect(result.isValid).toBe(false); + const lambdaItem = result.itemDiagnostics.find( + (item) => item.itemType === "transition-lambda", + )!; + const hirDiagnostic = lambdaItem.diagnostics.find( + (diag) => diag.source === "hir", + )!; + expect(hirDiagnostic.category).toBe(ts.DiagnosticCategory.Error); + expect( + ts.flattenDiagnosticMessageText(hirDiagnostic.messageText, "\n"), + ).toContain("restricted TypeScript subset"); + }); + }); }); diff --git a/libs/@hashintel/petrinaut-core/src/lsp/lib/checker.ts b/libs/@hashintel/petrinaut-core/src/lsp/lib/checker.ts index 6fb9458e375..839c740b262 100644 --- a/libs/@hashintel/petrinaut-core/src/lsp/lib/checker.ts +++ b/libs/@hashintel/petrinaut-core/src/lsp/lib/checker.ts @@ -3,8 +3,15 @@ import { getTransitionLogicAvailability, type PetrinautExtensionSettings, } from "../../extensions"; +import { + buildDynamicsContext, + buildKernelContext, + buildLambdaContext, +} from "../../hir"; +import { getHirDiagnosticsForItem } from "./check-hir"; import { getItemFilePath } from "./file-paths"; +import type { HirSurfaceContext } from "../../hir"; import type { SDCPN } from "../../types/sdcpn"; import type { SDCPNLanguageServer } from "./create-sdcpn-language-service"; import type ts from "typescript"; @@ -26,15 +33,48 @@ export type SDCPNDiagnostic = { }; export type SDCPNCheckResult = { - /** Whether the SDCPN is valid (no errors) */ + /** Whether the SDCPN is valid (no error-severity diagnostics). */ isValid: boolean; /** All diagnostics grouped by item */ itemDiagnostics: SDCPNDiagnostic[]; }; +/** TS `DiagnosticCategory.Error` (kept numeric — `typescript` is imported + * type-only here). */ +const TS_CATEGORY_ERROR = 1; + +/** + * Collects TS diagnostics for one code file and, when TypeScript found no + * errors, appends HIR semantic lints (friendlier domain rules, analyzability + * notes). HIR spans are user-content-relative, matching the adjusted TS + * diagnostics. + */ +function collectItemDiagnostics( + server: SDCPNLanguageServer, + filePath: string, + hirContext: HirSurfaceContext | null, +): ts.Diagnostic[] { + const semanticDiagnostics = server.getSemanticDiagnostics(filePath); + const syntacticDiagnostics = server.getSyntacticDiagnostics(filePath); + const allDiagnostics = [...syntacticDiagnostics, ...semanticDiagnostics]; + + const hasTsError = allDiagnostics.some( + (diagnostic) => diagnostic.category === TS_CATEGORY_ERROR, + ); + if (!hasTsError && hirContext) { + const userContent = server.getUserContent(filePath); + if (userContent !== undefined) { + allDiagnostics.push(...getHirDiagnosticsForItem(userContent, hirContext)); + } + } + + return allDiagnostics; +} + /** - * Checks the validity of an SDCPN by running TypeScript validation - * on all user-provided code (transitions and differential equations). + * Checks the validity of an SDCPN by running TypeScript validation and HIR + * semantic lints on all user-provided code (transitions and differential + * equations). */ export function checkSDCPN( sdcpn: SDCPN, @@ -50,9 +90,11 @@ export function checkSDCPN( const filePath = getItemFilePath("differential-equation-code", { id: de.id, }); - const semanticDiagnostics = server.getSemanticDiagnostics(filePath); - const syntacticDiagnostics = server.getSyntacticDiagnostics(filePath); - const allDiagnostics = [...syntacticDiagnostics, ...semanticDiagnostics]; + const allDiagnostics = collectItemDiagnostics( + server, + filePath, + de.colorId ? buildDynamicsContext(sdcpn, de.colorId, extensions) : null, + ); if (allDiagnostics.length > 0) { itemDiagnostics.push({ @@ -77,14 +119,11 @@ export function checkSDCPN( const lambdaFilePath = getItemFilePath("transition-lambda-code", { transitionId: transition.id, }); - const lambdaSemanticDiagnostics = - server.getSemanticDiagnostics(lambdaFilePath); - const lambdaSyntacticDiagnostics = - server.getSyntacticDiagnostics(lambdaFilePath); - const lambdaDiagnostics = [ - ...lambdaSyntacticDiagnostics, - ...lambdaSemanticDiagnostics, - ]; + const lambdaDiagnostics = collectItemDiagnostics( + server, + lambdaFilePath, + buildLambdaContext(sdcpn, transition, extensions), + ); if (lambdaDiagnostics.length > 0) { itemDiagnostics.push({ @@ -100,14 +139,11 @@ export function checkSDCPN( const kernelFilePath = getItemFilePath("transition-kernel-code", { transitionId: transition.id, }); - const kernelSemanticDiagnostics = - server.getSemanticDiagnostics(kernelFilePath); - const kernelSyntacticDiagnostics = - server.getSyntacticDiagnostics(kernelFilePath); - const kernelDiagnostics = [ - ...kernelSyntacticDiagnostics, - ...kernelSemanticDiagnostics, - ]; + const kernelDiagnostics = collectItemDiagnostics( + server, + kernelFilePath, + buildKernelContext(sdcpn, transition, extensions), + ); if (kernelDiagnostics.length > 0) { itemDiagnostics.push({ @@ -121,7 +157,11 @@ export function checkSDCPN( } return { - isValid: itemDiagnostics.length === 0, + isValid: !itemDiagnostics.some((item) => + item.diagnostics.some( + (diagnostic) => diagnostic.category === TS_CATEGORY_ERROR, + ), + ), itemDiagnostics, }; } diff --git a/libs/@hashintel/petrinaut-core/src/lsp/lib/ts-to-lsp.ts b/libs/@hashintel/petrinaut-core/src/lsp/lib/ts-to-lsp.ts index f10c93baa53..703d0f44026 100644 --- a/libs/@hashintel/petrinaut-core/src/lsp/lib/ts-to-lsp.ts +++ b/libs/@hashintel/petrinaut-core/src/lsp/lib/ts-to-lsp.ts @@ -97,6 +97,6 @@ export function serializeDiagnostic( ), message: ts.flattenDiagnosticMessageText(diag.messageText, "\n"), code: diag.code, - source: "ts", + source: diag.source ?? "ts", }; } diff --git a/libs/@hashintel/petrinaut-core/src/lsp/worker/language-server.worker.ts b/libs/@hashintel/petrinaut-core/src/lsp/worker/language-server.worker.ts index d1437ec9d74..4d95f74d5c9 100644 --- a/libs/@hashintel/petrinaut-core/src/lsp/worker/language-server.worker.ts +++ b/libs/@hashintel/petrinaut-core/src/lsp/worker/language-server.worker.ts @@ -21,6 +21,8 @@ import { import { createWorkerThreadRuntime } from "../../environment"; import { DEFAULT_PETRINAUT_EXTENSIONS } from "../../extensions"; +import { buildMetricContext, compileHirArtifacts } from "../../hir"; +import { getHirDiagnosticsForItem } from "../lib/check-hir"; import { checkSDCPN } from "../lib/checker"; import { SDCPNLanguageServer } from "../lib/create-sdcpn-language-service"; import { filePathToUri, uriToFilePath } from "../lib/document-uris"; @@ -122,6 +124,7 @@ function publishAllDiagnostics( } // Include diagnostics for all active metric sessions + const metricHirContext = buildMetricContext(sdcpn, extensions); for (const [, session] of metricSessions) { const metricFiles = server.getMetricFileNames(session.sessionId); for (const filePath of metricFiles) { @@ -137,6 +140,16 @@ function publishAllDiagnostics( const semanticDiags = server.getSemanticDiagnostics(filePath); const syntacticDiags = server.getSyntacticDiagnostics(filePath); const allDiags = [...syntacticDiags, ...semanticDiags]; + // When TypeScript is clean, run the HIR lint over the metric body — + // it reports domain rules and (metric-only) buffer-compilability. + const hasTsError = allDiags.some( + (diag) => diag.category === ts.DiagnosticCategory.Error, + ); + if (!hasTsError) { + allDiags.push( + ...getHirDiagnosticsForItem(userContent, metricHirContext), + ); + } params.push({ uri, diagnostics: allDiags.map((diag) => @@ -367,6 +380,15 @@ workerRuntime.onMessage((data) => { // --- Requests (send response) --- + case "sdcpn/compileHirArtifacts": { + const { id } = data; + respond( + id, + compileHirArtifacts(data.params.sdcpn, data.params.extensions), + ); + break; + } + case "textDocument/completion": { const { id } = data; if (!server) { diff --git a/libs/@hashintel/petrinaut-core/src/lsp/worker/protocol.ts b/libs/@hashintel/petrinaut-core/src/lsp/worker/protocol.ts index ee97bd9db8c..9df7c2aee59 100644 --- a/libs/@hashintel/petrinaut-core/src/lsp/worker/protocol.ts +++ b/libs/@hashintel/petrinaut-core/src/lsp/worker/protocol.ts @@ -135,6 +135,15 @@ type ClientRequest = id: number; method: "textDocument/signatureHelp"; params: TextDocumentPositionParams; + } + | { + jsonrpc: "2.0"; + id: number; + method: "sdcpn/compileHirArtifacts"; + params: { + sdcpn: SDCPN; + extensions?: PetrinautExtensionSettings; + }; }; /** Any message from the main thread to the worker. */ diff --git a/libs/@hashintel/petrinaut-core/src/simulation/ARCHITECTURE.md b/libs/@hashintel/petrinaut-core/src/simulation/ARCHITECTURE.md index e002b18d1b0..a1b62d69024 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/ARCHITECTURE.md +++ b/libs/@hashintel/petrinaut-core/src/simulation/ARCHITECTURE.md @@ -8,9 +8,14 @@ The simulation module is split into five boundaries: - `api.ts` defines the public Core contract. Consumers receive `SimulationFrameReader` and summary state, not engine storage objects. -- `authoring/metric/`, `authoring/scenario/`, and `authoring/user-code/` - compile user-authored inputs. Shared same-realm hardening helpers live in - `authoring/sandbox.ts`. +- `authoring/metric/` and `authoring/scenario/` compile metric/scenario + inputs (`new Function` with same-realm hardening from + `authoring/sandbox.ts`). Dynamics/lambda/kernel code is compiled solely + through the HIR pipeline (`src/hir/README.md`) into + `SimulationInput.hirArtifacts` — produced off-engine (LSP worker) and + instantiated dependency-free via `src/hir-runtime.ts`. Buffer-ABI programs + read/write packed token bytes directly (`engine/buffer-transition.ts`); + unsupported HIR shapes are compile errors. - `engine/` builds SDCPN definitions into runnable state and advances internal `EngineFrame` state. `EngineFrame` is an `ArrayBuffer`; the SDCPN-specialized `EngineFrameLayout` lives on the `SimulationInstance`. diff --git a/libs/@hashintel/petrinaut-core/src/simulation/api.ts b/libs/@hashintel/petrinaut-core/src/simulation/api.ts index 87206b2121c..96de3ec3fe0 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/api.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/api.ts @@ -1,8 +1,9 @@ import type { AbortSignalLike, WorkerFactoryLike } from "../environment"; import type { PetrinautExtensionSettings } from "../extensions"; +import type { HirArtifacts } from "../hir-runtime"; import type { EventStream } from "../instance"; import type { ReadableStore } from "../store"; -import type { Place, SDCPN, TokenRecord } from "../types/sdcpn"; +import type { Color, Place, SDCPN, TokenRecord } from "../types/sdcpn"; export type SimulationState = | "Initializing" @@ -67,6 +68,13 @@ export type SimulationConfig = { dt: number; /** Maximum simulation time. Null = no limit. */ maxTime: number | null; + /** + * Precompiled HIR artifacts for the net's user code, produced by + * `compileHirArtifacts` (or `LanguageClient.requestHirArtifacts`). The + * engine has no compiler of its own: items with user code and no artifact + * fail to build. + */ + hirArtifacts?: HirArtifacts; backpressure?: BackpressureConfig; /** Optional cancellation. Aborting tears down the simulation. */ signal?: AbortSignalLike; @@ -103,6 +111,26 @@ export type SimulationFrameState = { }; }; +/** + * Raw (buffer-level) view over one frame's packed token data, consumed by + * HIR-compiled expression metrics. Internal: the arrays alias the frame's + * live buffers and are only valid while the reader itself is. + */ +export type SimulationFrameRawView = { + /** Shared views over the frame's token byte region (format v2). */ + f64: Float64Array; + u64: BigUint64Array; + u8: Uint8Array; + /** Dense per-place token counts, indexed by frame place index. */ + placeCounts: Uint32Array; + /** Dense per-place byte offsets into the token region. */ + placeOffsets: Uint32Array; + /** Place id → frame place index (stable across frames of one run). */ + placeIndexById: ReadonlyMap; + /** Resolves interned `string` token attributes. */ + stringPool?: { get(id: number): string }; +}; + export interface SimulationFrameReader { /** Frame index in the simulation history. */ readonly number: number; @@ -110,8 +138,14 @@ export interface SimulationFrameReader { readonly time: number; getPlaceTokenCount(placeId: string): number; + /** + * Raw buffer access for compiled expression metrics. Optional — readers + * over non-buffer sources may omit it, in which case expression metrics + * cannot evaluate against their frames. + */ + getRawView?(): SimulationFrameRawView; /** Typed token records for a coloured place; `[]` for uncoloured places. */ - getPlaceTokens(place: Place): TokenRecord[]; + getPlaceTokens(place: Place, color?: Color | null): TokenRecord[]; getTransitionState(transitionId: string): { /** * Time elapsed since this transition last fired, in milliseconds. diff --git a/libs/@hashintel/petrinaut-core/src/simulation/authoring/metric/compile-metric.test.ts b/libs/@hashintel/petrinaut-core/src/simulation/authoring/metric/compile-metric.test.ts deleted file mode 100644 index bf8d3d37f79..00000000000 --- a/libs/@hashintel/petrinaut-core/src/simulation/authoring/metric/compile-metric.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { compileMetric, type MetricState } from "./compile-metric"; - -import type { Metric } from "../../../types/sdcpn"; - -const metric = (overrides: Partial = {}): Metric => ({ - id: "m1", - name: "Test", - code: "return 0;", - ...overrides, -}); - -const state = (overrides?: Partial): MetricState => ({ - places: { - A: { count: 3, tokens: [] }, - B: { count: 7, tokens: [] }, - }, - ...overrides, -}); - -describe("compileMetric", () => { - it("compiles a simple metric that returns a number", () => { - const outcome = compileMetric(metric({ code: "return 42;" })); - expect(outcome.ok).toBe(true); - if (outcome.ok) { - expect(outcome.fn(state())).toBe(42); - } - }); - - it("exposes place counts via state.places..count", () => { - const outcome = compileMetric( - metric({ code: "return state.places.A.count + state.places.B.count;" }), - ); - expect(outcome.ok).toBe(true); - if (outcome.ok) { - expect(outcome.fn(state())).toBe(10); - } - }); - - it("rejects empty code at compile time", () => { - const outcome = compileMetric(metric({ code: " " })); - expect(outcome.ok).toBe(false); - }); - - it("returns a compile error for syntactically invalid code", () => { - const outcome = compileMetric(metric({ code: "return (" })); - expect(outcome.ok).toBe(false); - }); - - it("throws at runtime when the result is not a finite number", () => { - const outcome = compileMetric(metric({ code: "return 'oops';" })); - expect(outcome.ok).toBe(true); - if (outcome.ok) { - expect(() => outcome.fn(state())).toThrow(/finite number/); - } - }); - - it("throws at runtime for NaN / Infinity", () => { - const nanFn = compileMetric(metric({ code: "return 0/0;" })); - expect(nanFn.ok).toBe(true); - if (nanFn.ok) { - expect(() => nanFn.fn(state())).toThrow(/finite number/); - } - - const infFn = compileMetric(metric({ code: "return 1/0;" })); - expect(infFn.ok).toBe(true); - if (infFn.ok) { - expect(() => infFn.fn(state())).toThrow(/finite number/); - } - }); - - it("shadows dangerous globals so they appear undefined inside the metric", () => { - const outcome = compileMetric( - metric({ code: "return typeof window === 'undefined' ? 1 : 0;" }), - ); - expect(outcome.ok).toBe(true); - if (outcome.ok) { - expect(outcome.fn(state())).toBe(1); - } - - const outcome2 = compileMetric( - metric({ code: "return typeof Function === 'undefined' ? 1 : 0;" }), - ); - expect(outcome2.ok).toBe(true); - if (outcome2.ok) { - expect(outcome2.fn(state())).toBe(1); - } - }); - - it("blocks the .constructor escape route on literals", () => { - // `({}).constructor.constructor("return globalThis")()` would otherwise - // walk back to the host realm even with `Function` shadowed as a var. - const outcome = compileMetric( - metric({ code: "return ({}).constructor.constructor('return 1')();" }), - ); - expect(outcome.ok).toBe(true); - if (outcome.ok) { - expect(() => outcome.fn(state())).toThrow(/\.constructor/); - } - }); - - it("freezes the state argument so metrics cannot mutate it", () => { - const outcome = compileMetric( - metric({ - code: ` - try { state.places.A.count = 999; } catch (_) {} - return state.places.A.count; - `, - }), - ); - expect(outcome.ok).toBe(true); - if (outcome.ok) { - expect(outcome.fn(state())).toBe(3); - } - }); -}); diff --git a/libs/@hashintel/petrinaut-core/src/simulation/authoring/metric/compile-metric.ts b/libs/@hashintel/petrinaut-core/src/simulation/authoring/metric/compile-metric.ts deleted file mode 100644 index 4b0d8f78fa1..00000000000 --- a/libs/@hashintel/petrinaut-core/src/simulation/authoring/metric/compile-metric.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { runSandboxed, SHADOWED_GLOBALS } from "../sandbox"; - -import type { Metric, TokenRecord } from "../../../types/sdcpn"; - -// -- Public types ------------------------------------------------------------- - -/** - * State of a single place exposed to a compiled metric. - * - * - `count`: number of tokens currently in the place. - * - `tokens`: for colored places, an array of token objects keyed by the - * color element names (see `Color.elements`). Empty for uncolored places. - */ -export interface MetricPlaceState { - count: number; - tokens: TokenRecord[]; -} - -/** - * Snapshot of the SDCPN state passed to a compiled metric on every frame. - * - * Keyed by place **name** (not ID) for ergonomic author-facing access: - * `state.places.Infected.count`. - */ -export interface MetricState { - places: Record; -} - -export type CompiledMetric = (state: MetricState) => number; - -export type CompileMetricOutcome = - | { ok: true; fn: CompiledMetric } - | { ok: false; error: string }; - -// -- Hardened evaluator ------------------------------------------------------- - -/** - * Wrap a plain object in a prototype-less, frozen copy. - * Severs the prototype chain so `obj.constructor.constructor("return globalThis")()` - * cannot escape to globals on the user-facing arguments. - */ -function createSafeState(state: MetricState): MetricState { - const places = Object.create(null) as Record; - for (const [name, value] of Object.entries(state.places)) { - places[name] = Object.freeze( - Object.assign(Object.create(null), value), - ) as MetricPlaceState; - } - return Object.freeze( - Object.assign(Object.create(null), { places: Object.freeze(places) }), - ) as MetricState; -} - -// -- Compiler ----------------------------------------------------------------- - -/** - * Compile a metric's user code into an executable `(state) => number` function. - * - * The supplied code is treated as a function body that must `return` a number. - * It runs in strict mode with dangerous globals shadowed, the `state` argument - * frozen with no prototype chain, and the `.constructor` chain blocked on - * built-in prototypes for the duration of the call (see `runSandboxed`). - * - * On invalid output (non-number / NaN / non-finite), the returned function - * throws — callers should catch and decide how to render the failed frame. - */ -export function compileMetric(metric: Metric): CompileMetricOutcome { - const code = metric.code.trim(); - if (code === "") { - return { ok: false, error: "Metric code is empty" }; - } - - let rawFn: (state: MetricState) => unknown; - try { - // eslint-disable-next-line no-new-func, @typescript-eslint/no-implied-eval -- intentional: user-authored metric code - rawFn = new Function( - "state", - `"use strict"; var ${SHADOWED_GLOBALS}; ${code}`, - ) as (state: MetricState) => unknown; - } catch (err) { - return { - ok: false, - error: `Failed to compile metric "${metric.name}": ${err instanceof Error ? err.message : String(err)}`, - }; - } - - const fn: CompiledMetric = (state) => { - const result = runSandboxed(() => rawFn(createSafeState(state))); - if (typeof result !== "number" || !Number.isFinite(result)) { - throw new Error( - `Metric "${metric.name}" returned ${String(result)}, expected a finite number.`, - ); - } - return result; - }; - - return { ok: true, fn }; -} diff --git a/libs/@hashintel/petrinaut-core/src/simulation/authoring/user-code/compile-user-code.test.ts b/libs/@hashintel/petrinaut-core/src/simulation/authoring/user-code/compile-user-code.test.ts deleted file mode 100644 index d708c604be6..00000000000 --- a/libs/@hashintel/petrinaut-core/src/simulation/authoring/user-code/compile-user-code.test.ts +++ /dev/null @@ -1,368 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { compileUserCode } from "./compile-user-code"; - -describe("compileUserCode", () => { - describe("basic functionality", () => { - it("should compile a simple function with default export", () => { - const code = ` - export default Dynamics((a, b, c) => { - return a + b + c; - }); - `; - const fn = compileUserCode<[number, number, number]>(code, "Dynamics"); - const result = fn(1, 2, 3); - expect(result).toBe(6); - }); - - it("should compile an arrow function with implicit return", () => { - const code = ` - export default Dynamics((x, y) => x * y); - `; - const fn = compileUserCode<[number, number]>(code, "Dynamics"); - const result = fn(5, 7); - expect(result).toBe(35); - }); - - it("should handle multiple parameters", () => { - const code = ` - export default Dynamics((a, b, c, d) => { - return a + b * c - d; - }); - `; - const fn = compileUserCode<[number, number, number, number]>( - code, - "Dynamics", - ); - const result = fn(10, 2, 5, 3); - expect(result).toBe(17); - }); - }); - - describe("TypeScript type annotations", () => { - it("should strip TypeScript type annotations from parameters", () => { - const code = ` - export default Dynamics((x: number, y: number) => { - return x + y; - }); - `; - const fn = compileUserCode<[number, number]>(code, "Dynamics"); - const result = fn(10, 20); - expect(result).toBe(30); - }); - - it("should strip return type annotations", () => { - const code = ` - export default Dynamics((x: number, y: number): number => { - return x * y; - }); - `; - const fn = compileUserCode<[number, number]>(code, "Dynamics"); - const result = fn(5, 6); - expect(result).toBe(30); - }); - - it("should strip type annotations from variable declarations", () => { - const code = ` - export default Dynamics((a: number, b: number): number => { - const sum: number = a + b; - const product: number = sum * 2; - return product; - }); - `; - const fn = compileUserCode<[number, number]>(code, "Dynamics"); - const result = fn(3, 7); - expect(result).toBe(20); - }); - - it("should handle complex TypeScript types", () => { - const code = ` - export default Dynamics((arr: number[]): number => { - return arr.reduce((sum: number, n: number) => sum + n, 0); - }); - `; - const fn = compileUserCode<[number[]]>(code, "Dynamics"); - const result = fn([1, 2, 3, 4, 5]); - expect(result).toBe(15); - }); - - it("should handle optional parameters", () => { - const code = ` - export default Dynamics((a: number, b?: number): number => { - return b !== undefined ? a + b : a; - }); - `; - const fn = compileUserCode<[number, number?]>(code, "Dynamics"); - expect(fn(5, 10)).toBe(15); - expect(fn(5)).toBe(5); - }); - }); - - describe("complex logic", () => { - it("should handle array operations", () => { - const code = ` - export default Dynamics((matrix) => { - return matrix.map(row => row.map(x => x * 2)); - }); - `; - const fn = compileUserCode<[number[][]]>(code, "Dynamics"); - const result = fn([ - [1, 2], - [3, 4], - ]); - expect(result).toEqual([ - [2, 4], - [6, 8], - ]); - }); - - it("should handle closures", () => { - const code = ` - export default Dynamics((multiplier) => { - return (value) => value * multiplier; - }); - `; - const fn = compileUserCode<[number]>(code, "Dynamics"); - const innerFn = fn(5); - expect(typeof innerFn).toBe("function"); - expect((innerFn as (v: number) => number)(10)).toBe(50); - }); - - it("should handle async functions", () => { - const code = ` - export default Dynamics(async (x, y) => { - return x + y; - }); - `; - const fn = compileUserCode<[number, number]>(code, "Dynamics"); - const result = fn(5, 10); - expect(result).toBeInstanceOf(Promise); - }); - - it("should handle destructuring", () => { - const code = ` - export default Dynamics(({ a, b }) => { - return a + b; - }); - `; - const fn = compileUserCode<[{ a: number; b: number }]>(code, "Dynamics"); - const result = fn({ a: 5, b: 10 }); - expect(result).toBe(15); - }); - }); - - describe("constructor function name validation", () => { - it("should accept correct constructor function name", () => { - const code = ` - export default Dynamics((x) => x * 2); - `; - expect(() => compileUserCode(code, "Dynamics")).not.toThrow(); - }); - - it("should work with different constructor function names", () => { - const code = ` - export default Flow((x, y) => x + y); - `; - const fn = compileUserCode<[number, number]>(code, "Flow"); - const result = fn(5, 10); - expect(result).toBe(15); - }); - - it("should throw error when constructor function name doesn't match", () => { - const code = ` - export default WrongName((x) => x * 2); - `; - expect(() => compileUserCode(code, "Dynamics")).toThrow( - "Default export must use the constructor function 'Dynamics'", - ); - }); - - it("should throw error when default export is missing", () => { - const code = ` - const fn = Dynamics((x) => x * 2); - `; - expect(() => compileUserCode(code, "Dynamics")).toThrow( - "Module must have a default export", - ); - }); - - it("should throw error when export is not default", () => { - const code = ` - export const fn = Dynamics((x) => x * 2); - `; - expect(() => compileUserCode(code, "Dynamics")).toThrow( - "Module must have a default export", - ); - }); - }); - - describe("edge cases", () => { - it("should handle whitespace variations", () => { - const code = ` - export default Dynamics( (x) => x * 2 ) ; - `; - const fn = compileUserCode<[number]>(code, "Dynamics"); - const result = fn(5); - expect(result).toBe(10); - }); - - it("should handle multiline functions", () => { - const code = ` - export default Dynamics((x, y) => { - const step1 = x * 2; - const step2 = y + 5; - const step3 = step1 + step2; - return step3; - }); - `; - const fn = compileUserCode<[number, number]>(code, "Dynamics"); - const result = fn(3, 7); - expect(result).toBe(18); - }); - - it("should handle functions that return objects", () => { - const code = ` - export default Dynamics((x, y) => { - return { sum: x + y, product: x * y }; - }); - `; - const fn = compileUserCode<[number, number]>(code, "Dynamics"); - const result = fn(3, 4); - expect(result).toEqual({ sum: 7, product: 12 }); - }); - - it("should handle functions that return arrays", () => { - const code = ` - export default Dynamics((x, y) => { - return [x, y, x + y]; - }); - `; - const fn = compileUserCode<[number, number]>(code, "Dynamics"); - const result = fn(5, 10); - expect(result).toEqual([5, 10, 15]); - }); - - it("should handle empty parameter list", () => { - const code = ` - export default Dynamics(() => { - return 42; - }); - `; - const fn = compileUserCode<[]>(code, "Dynamics"); - const result = fn(); - expect(result).toBe(42); - }); - - it("should preserve function behavior", () => { - const code = ` - export default Dynamics(function(multiplier) { - return multiplier * 2; - }); - `; - const fn = compileUserCode<[number]>(code, "Dynamics"); - const result = fn(5); - expect(result).toBe(10); - }); - }); - - describe("error handling", () => { - it("should throw descriptive error for invalid code", () => { - const code = ` - export default Dynamics((x) => { - invalid syntax here - }); - `; - expect(() => compileUserCode(code, "Dynamics")).toThrow(); - }); - - it("should throw error if constructor function doesn't return a function", () => { - const code = ` - export default Dynamics(42); - `; - expect(() => compileUserCode(code, "Dynamics")).toThrow(); - }); - - it("should throw error for empty code", () => { - const code = ""; - expect(() => compileUserCode(code, "Dynamics")).toThrow( - "Module must have a default export", - ); - }); - }); - - describe("real-world differential equations", () => { - it("should handle exponential growth equation", () => { - const code = ` - export default Dynamics((population: number, rate: number): number => { - return population * rate; - }); - `; - const fn = compileUserCode<[number, number]>(code, "Dynamics"); - const result = fn(100, 1.05); - expect(result).toBe(105); - }); - - it("should handle Lotka-Volterra predator-prey model", () => { - const code = ` - export default Dynamics((prey: number, predator: number, alpha: number, beta: number): number[] => { - const dPrey = alpha * prey - beta * prey * predator; - const dPredator = -beta * predator + alpha * prey * predator; - return [dPrey, dPredator]; - }); - `; - const fn = compileUserCode<[number, number, number, number]>( - code, - "Dynamics", - ); - const result = fn(100, 10, 0.1, 0.02); - expect(Array.isArray(result)).toBe(true); - expect((result as number[]).length).toBe(2); - }); - - it("should handle state vector operations", () => { - const code = ` - export default Dynamics((state: number[][]): number[][] => { - return state.map(vector => - vector.map(component => component * 0.5) - ); - }); - `; - const fn = compileUserCode<[number[][]]>(code, "Dynamics"); - const result = fn([ - [10, 20], - [30, 40], - ]); - expect(result).toEqual([ - [5, 10], - [15, 20], - ]); - }); - }); - - describe("Uuid runtime injection", () => { - it("exposes Uuid.generate() and Uuid.from() sentinels", () => { - const code = ` - export default TransitionKernel(() => { - return { generated: Uuid.generate(), derived: Uuid.from("order-1") }; - }); - `; - const fn = compileUserCode(code, "TransitionKernel"); - expect(fn()).toEqual({ - generated: { __petrinautUuid: "generate" }, - derived: { __petrinautUuid: "from", value: "order-1" }, - }); - }); - - it("keeps Uuid available when Distribution is disabled", () => { - const code = ` - export default TransitionKernel(() => { - return Uuid.generate(); - }); - `; - const fn = compileUserCode(code, "TransitionKernel", { - enableDistribution: false, - }); - expect(fn()).toEqual({ __petrinautUuid: "generate" }); - }); - }); -}); diff --git a/libs/@hashintel/petrinaut-core/src/simulation/authoring/user-code/compile-user-code.ts b/libs/@hashintel/petrinaut-core/src/simulation/authoring/user-code/compile-user-code.ts deleted file mode 100644 index 30ae6749a54..00000000000 --- a/libs/@hashintel/petrinaut-core/src/simulation/authoring/user-code/compile-user-code.ts +++ /dev/null @@ -1,121 +0,0 @@ -import * as Babel from "@babel/standalone"; - -import { distributionRuntimeCode } from "./distribution"; -import { uuidRuntimeCode } from "./uuid-runtime"; - -type CompileUserCodeOptions = { - /** - * Whether user code should receive the stochastic `Distribution` helper. - * Disable this for document modes where stochastic behaviour is unavailable. - * @default true - */ - enableDistribution?: boolean; -}; - -/** - * Strips TypeScript type annotations from code to make it executable JavaScript. - * Uses Babel standalone (browser-compatible) to properly parse and transform TypeScript code. - * - * @param code - TypeScript code with type annotations - * @returns JavaScript code without type annotations - */ -function stripTypeAnnotations(code: string): string { - const result = Babel.transform(code, { - filename: "input.ts", - presets: [ - ["typescript", { allowDeclareFields: true, onlyRemoveTypeImports: true }], - ], - }); - return result.code ?? ""; -} - -/** - * Compiles TypeScript/JavaScript module code into an executable function. - * Expects a JavaScript module with a default export using a specific constructor function. - * - * @param code - The TypeScript/JavaScript module code with a default export. - * Should follow the pattern: `export default ConstructorFn((a, b, c) => { ... })` - * Can include TypeScript type annotations which will be stripped. - * @param constructorFnName - The name of the constructor function that wraps the user code. - * This must match the function used in the default export. - * @returns A compiled function that can be executed - * - * @example - * ```typescript - * const code = ` - * export default Dynamics((a: number, b: number, c: number) => { - * return a + b + c; - * }); - * `; - * const fn = compileUserCode(code, "Dynamics"); - * const result = fn(1, 2, 3); // returns 6 - * ``` - */ -export function compileUserCode( - code: string, - constructorFnName: string, - options: CompileUserCodeOptions = {}, -): (...args: T) => unknown { - // Strip TypeScript type annotations and remove leading/trailing whitespace - const sanitizedCode = stripTypeAnnotations(code.trim()); - - // Verify that the code has a default export - const defaultExportRegex = /export\s+default\s+/; - if (!defaultExportRegex.test(sanitizedCode)) { - throw new Error( - `Module must have a default export. Expected pattern: export default ${constructorFnName}(...)`, - ); - } - - // Verify that the default export uses the specified constructor function - const constructorPattern = new RegExp( - `export\\s+default\\s+${constructorFnName}\\s*\\(`, - ); - if (!constructorPattern.test(sanitizedCode)) { - throw new Error( - `Default export must use the constructor function '${constructorFnName}'. Expected pattern: export default ${constructorFnName}(...)`, - ); - } - - try { - // Create a mock constructor function that extracts the wrapped function - const mockConstructor = ` - function ${constructorFnName}(fn) { - if (typeof fn !== 'function') { - throw new Error('${constructorFnName} expects a function as argument'); - } - return fn; - } - `; - - // Create an executable module-like environment - const executableCode = ` - ${options.enableDistribution === false ? "" : distributionRuntimeCode} - ${uuidRuntimeCode} - ${mockConstructor} - let __default_export__; - ${sanitizedCode.replace(/export\s+default\s+/, "__default_export__ = ")} - return __default_export__; - `; - - // Use Function constructor to create and execute the module - // eslint-disable-next-line no-new-func, @typescript-eslint/no-implied-eval, @typescript-eslint/no-unsafe-call - const compiledFunction = new Function(executableCode)() as ( - ...args: T - ) => unknown; - - if (typeof compiledFunction !== "function") { - throw new Error( - `Expected default export to be a function, got ${typeof compiledFunction}`, - ); - } - - return compiledFunction; - } catch (error) { - throw new Error( - `Failed to compile user code: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - } -} diff --git a/libs/@hashintel/petrinaut-core/src/simulation/authoring/user-code/distribution.ts b/libs/@hashintel/petrinaut-core/src/simulation/authoring/user-code/distribution.ts index c7f881cde23..cd8f8bd8b39 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/authoring/user-code/distribution.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/authoring/user-code/distribution.ts @@ -40,27 +40,6 @@ export function isDistribution(value: unknown): value is RuntimeDistribution { ); } -/** - * JavaScript source code that defines the Distribution namespace at runtime. - * Injected into the compiled user code execution context so that - * Distribution.Gaussian() and Distribution.Uniform() are available. - */ -export const distributionRuntimeCode = ` - function __addMap(dist) { - dist.map = function(fn) { - return __addMap({ __brand: "distribution", type: "mapped", inner: dist, fn: fn }); - }; - return dist; - } - var Distribution = { - Gaussian: function(mean, deviation) { - return __addMap({ __brand: "distribution", type: "gaussian", mean: mean, deviation: deviation }); - }, - Uniform: function(min, max) { - return __addMap({ __brand: "distribution", type: "uniform", min: min, max: max }); - }, - Lognormal: function(mu, sigma) { - return __addMap({ __brand: "distribution", type: "lognormal", mu: mu, sigma: sigma }); - } - }; -`; +// Distribution construction for compiled user code lives in +// `hir/instantiate.ts` (`hirDistributionRuntime`) — emitted programs call it +// through the injected `__dist` binding instead of injected source code. diff --git a/libs/@hashintel/petrinaut-core/src/simulation/authoring/user-code/uuid-runtime.ts b/libs/@hashintel/petrinaut-core/src/simulation/authoring/user-code/uuid-runtime.ts deleted file mode 100644 index aeb35a5bc21..00000000000 --- a/libs/@hashintel/petrinaut-core/src/simulation/authoring/user-code/uuid-runtime.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Sentinel values produced by the `Uuid` helper inside user code. They are - * resolved during transition kernel output encoding: - * - * - `Uuid.generate()` → a fresh UUID drawn from the seeded simulation RNG - * (same draw as an omitted uuid output field). - * - `Uuid.from(value)` → the total `toUuid` conversion of `value` (valid UUID - * strings parse; anything else maps deterministically via UUIDv5). - */ -export type UuidSentinel = - | { __petrinautUuid: "generate" } - | { __petrinautUuid: "from"; value: unknown }; - -/** - * Checks if a value is a `Uuid.generate()` / `Uuid.from(value)` sentinel. - */ -export function isUuidSentinel(value: unknown): value is UuidSentinel { - if (typeof value !== "object" || value === null) { - return false; - } - const tag = (value as Record).__petrinautUuid; - return tag === "generate" || tag === "from"; -} - -/** - * JavaScript source code that defines the `Uuid` namespace at runtime. - * Injected unconditionally into the compiled user code execution context - * (unlike `Distribution`, which is gated on the stochasticity extension) so - * uuid token attributes can always be produced. - */ -export const uuidRuntimeCode = ` - var Uuid = { - generate: function() { - return { __petrinautUuid: "generate" }; - }, - from: function(value) { - return { __petrinautUuid: "from", value: value }; - } - }; -`; diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/buffer-transition.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/buffer-transition.ts new file mode 100644 index 00000000000..c4eefa631df --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/buffer-transition.ts @@ -0,0 +1,161 @@ +/** + * Shared buffer-ABI transition execution helpers used by both the single-run + * engine (`compute-possible-transition.ts`) and the Monte Carlo path + * (`monte-carlo/transition-effect.ts`). + * + * Buffer-ABI lambdas/kernels read token attributes at statically-resolved + * byte offsets from the frame's packed token structs (token format v2) — no + * per-combination record decoding. The per-transition `placeBases`/`indices`/ + * `kernelStaging` scratch lives on the `CompiledTransition` and is reused + * across evaluations; the engine is single-threaded per simulation instance. + */ +import { sampleDistribution } from "./sample-distribution"; +import { generateUuidFromRng, toUuid } from "./uuid"; + +import type { HirKernelSink } from "../../hir-runtime"; +import type { RuntimeDistribution } from "../authoring/user-code/distribution"; +import type { TokenRegionViews } from "./token-layout"; +import type { CompiledTransition } from "./types"; + +const UUID_LO_MASK = 0xffffffffffffffffn; + +/** + * Fills `placeBases` with each colored non-inhibitor input arc's place base + * BYTE offset within the token region, in arc order — matching the emitter's + * `placeBases[arc]` indexing (see `hir/surface-context.ts`). + * + * Runs once per transition evaluation (the bases don't change across + * combinations). Returns `false` when the arc count does not match the + * compiled program's expectation (stale compiled program) — callers must + * throw an `SDCPNItemError`. + */ +export function fillPlaceBases( + placeBases: Int32Array, + places: readonly { byteOffset: number }[], +): boolean { + if (places.length !== placeBases.length) { + return false; + } + for (const [arcIndex, place] of places.entries()) { + // eslint-disable-next-line no-param-reassign -- writes into the reusable scratch buffer + placeBases[arcIndex] = place.byteOffset; + } + return true; +} + +/** + * Flattens one enumerated combination's selected token indices into the + * reusable `indices` array, in slot order (per colored non-inhibitor input + * arc, `weight` slots each). Returns `false` on slot-count mismatch (stale + * compiled program) — callers must throw an `SDCPNItemError`. + */ +export function fillTokenIndices( + indices: Int32Array, + combinationIndices: readonly (readonly number[])[], +): boolean { + let slot = 0; + for (const tokenIndices of combinationIndices) { + for (const tokenIndex of tokenIndices) { + if (slot >= indices.length) { + return false; + } + // eslint-disable-next-line no-param-reassign -- writes into the reusable scratch buffer + indices[slot] = tokenIndex; + slot += 1; + } + } + return slot === indices.length; +} + +/** + * Runs a transition's buffer-ABI kernel and materializes its output tokens. + * + * The kernel writes attribute values into the transition's reusable + * `kernelStaging` bytes (colored output arcs place-major, tokens + * back-to-back). RNG-consuming values — distributions, generated/converted + * UUIDs — arrive through the sink in emitted call order, which the engine + * processes immediately so the RNG stream is reproduced deterministically + * per seed. + * + * Afterwards the staging bytes are sliced into per-token `Uint8Array` blocks + * keyed by place ID (later arcs to the same place overwrite earlier ones, + * matching the object-convention semantics); uncolored output places get + * `weight` empty blocks. + */ +export function executeBufferKernel(args: { + transition: CompiledTransition; + /** Views over the frame's token byte region. */ + views: TokenRegionViews; + rngState: number; +}): { add: Record; newRngState: number } { + const { transition, views, rngState } = args; + const kernelFn = transition.kernelFn; + if (kernelFn === null) { + throw new Error( + `Transition ${transition.id} has no compiled kernel program`, + ); + } + + const { kernelStaging, kernelStagingViews } = transition; + // Fresh padding bytes per firing, matching the object path's per-token + // zeroed buffers (attribute lanes are fully overwritten by the kernel). + kernelStaging.fill(0); + + let rng = rngState; + const sink: HirKernelSink = (kind, index, payload) => { + /* eslint-disable no-bitwise -- uuid lane splitting */ + if (kind === "dist") { + const [value, nextRng] = sampleDistribution( + payload as RuntimeDistribution, + rng, + ); + rng = nextRng; + kernelStagingViews.f64[index] = value; + } else if (kind === "generate") { + const [uuid, nextRng] = generateUuidFromRng(rng); + rng = nextRng; + kernelStagingViews.u64[index] = uuid & UUID_LO_MASK; + kernelStagingViews.u64[index + 1] = uuid >> 64n; + } else { + const uuid = toUuid(payload); + kernelStagingViews.u64[index] = uuid & UUID_LO_MASK; + kernelStagingViews.u64[index + 1] = uuid >> 64n; + } + /* eslint-enable no-bitwise */ + }; + + kernelFn( + views.f64, + views.u64, + views.u8, + transition.placeBases, + transition.indices, + kernelStagingViews.f64, + kernelStagingViews.u64, + kernelStagingViews.u8, + sink, + ); + + const add: Record = {}; + let stagingOffset = 0; + for (const outputPlace of transition.outputPlaces) { + if (outputPlace.tokenLayout === null) { + add[outputPlace.placeId] = Array.from( + { length: outputPlace.weight }, + () => new Uint8Array(0), + ); + continue; + } + const { strideBytes } = outputPlace.tokenLayout; + const tokenBlocks: Uint8Array[] = []; + for (let tokenIndex = 0; tokenIndex < outputPlace.weight; tokenIndex++) { + tokenBlocks.push( + kernelStaging.slice(stagingOffset, stagingOffset + strideBytes), + ); + stagingOffset += strideBytes; + } + add[outputPlace.placeId] = tokenBlocks; + } + + return { add, newRngState: rng }; +} diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/build-simulation-hir.test.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/build-simulation-hir.test.ts new file mode 100644 index 00000000000..0bd00b93aa7 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/build-simulation-hir.test.ts @@ -0,0 +1,189 @@ +/** + * End-to-end tests for HIR-compiled artifacts: the engine runs buffer-ABI + * programs only — identical inputs must produce identical frames (including + * RNG stream evolution), simulations without artifacts must fail with a + * per-item error, and stale artifact metadata must be rejected. + */ +import { describe, expect, it } from "vitest"; + +import { compileHirArtifacts } from "../../hir"; +import { buildSimulation } from "./build-simulation"; +import { computeNextFrame } from "./compute-next-frame"; + +import type { HirArtifacts } from "../../hir-runtime"; +import type { SDCPN } from "../../types/sdcpn"; +import type { SimulationInput, SimulationInstance } from "./types"; + +const sdcpn: SDCPN = { + types: [ + { + id: "type1", + name: "Particle", + iconSlug: "circle", + displayColor: "#FF0000", + elements: [ + { elementId: "e1", name: "x", type: "real" }, + { elementId: "e2", name: "v", type: "real" }, + { elementId: "e3", name: "generation", type: "integer" }, + ], + }, + ], + differentialEquations: [ + { + id: "de1", + name: "Oscillator", + colorId: "type1", + code: `export default Dynamics((tokens, parameters) => { + return tokens.map(({ x, v }) => { + return { x: v, v: -parameters.k * x }; + }); +});`, + }, + ], + parameters: [ + { + id: "param1", + name: "Spring constant", + variableName: "k", + type: "real", + defaultValue: "2", + }, + { + id: "param2", + name: "Rate", + variableName: "rate", + type: "real", + defaultValue: "5", + }, + ], + places: [ + { + id: "p1", + name: "Source", + colorId: "type1", + dynamicsEnabled: true, + differentialEquationId: "de1", + x: 0, + y: 0, + }, + { + id: "p2", + name: "Target", + colorId: "type1", + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + ], + transitions: [ + { + id: "t1", + name: "Hop", + lambdaType: "stochastic", + inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "p2", weight: 1 }], + lambdaCode: `export default Lambda((input, parameters) => { + const { x } = input.Source[0]; + if (x > 0) return parameters.rate; + return 0.5; +});`, + transitionKernelCode: `export default TransitionKernel((input, parameters) => { + const noise = Distribution.Gaussian(0, 0.1); + return { + Target: [ + { + x: input.Source[0].x, + v: noise.map((value) => value + input.Source[0].v), + generation: input.Source[0].generation + 1, + }, + ], + }; +});`, + x: 0, + y: 0, + }, + ], +}; + +function makeInput(hirArtifacts?: SimulationInput["hirArtifacts"]) { + return { + sdcpn, + initialMarking: { + p1: [ + { x: 1, v: 0, generation: 0 }, + { x: -0.5, v: 2, generation: 0 }, + ], + p2: [], + }, + parameterValues: { k: "2", rate: "5" }, + seed: 1234, + dt: 0.05, + maxTime: null, + hirArtifacts, + } satisfies SimulationInput; +} + +function runFrames(instance: SimulationInstance, count: number): number[][] { + let simulation = instance; + const frames: number[][] = []; + for (let step = 0; step < count; step++) { + const result = computeNextFrame(simulation); + simulation = result.simulation; + const frame = simulation.frames[simulation.currentFrameNumber]!; + frames.push([...new Float64Array(frame)]); + } + return frames; +} + +describe("buildSimulation with HIR artifacts", () => { + it("compiles buffer programs for all three surfaces", () => { + const { artifacts, failures } = compileHirArtifacts(sdcpn); + expect(failures).toEqual([]); + expect(artifacts.version).toBe(3); + expect(typeof artifacts.dynamics.de1!.source).toBe("string"); + expect(typeof artifacts.lambdas.t1!.source).toBe("string"); + expect(artifacts.lambdas.t1!.inputSlotCount).toBe(1); + expect(typeof artifacts.kernels.t1!.source).toBe("string"); + expect(artifacts.kernels.t1!.inputSlotCount).toBe(1); + // One output token: x(f64) + v(f64) + generation(f64) → 24-byte stride. + expect(artifacts.kernels.t1!.outputByteCount).toBe(24); + }); + + it("instantiates buffer programs and scratch for the engine", () => { + const { artifacts } = compileHirArtifacts(sdcpn); + const simulation = buildSimulation(makeInput(artifacts)); + const compiled = simulation.compiledTransitions.get("t1")!; + expect(typeof compiled.lambdaFn).toBe("function"); + expect(typeof compiled.kernelFn).toBe("function"); + expect(compiled.placeBases).toHaveLength(1); + expect(compiled.indices).toHaveLength(1); + expect(compiled.kernelStaging).toHaveLength(24); + }); + + it("produces identical frames for identical inputs (deterministic)", () => { + const { artifacts } = compileHirArtifacts(sdcpn); + + const firstRun = buildSimulation(makeInput(artifacts)); + const secondRun = buildSimulation(makeInput(artifacts)); + + expect(runFrames(firstRun, 50)).toEqual(runFrames(secondRun, 50)); + }); + + it("throws a per-item error when artifacts are missing", () => { + expect(() => buildSimulation(makeInput())).toThrow(/has not been compiled/); + }); + + it("throws when artifact metadata is stale", () => { + const { artifacts } = compileHirArtifacts(sdcpn); + const stale: HirArtifacts = { + ...artifacts, + lambdas: { + t1: { ...artifacts.lambdas.t1!, inputSlotCount: 99 }, + }, + }; + expect(() => buildSimulation(makeInput(stale))).toThrow( + /does not match|stale|has not been compiled/i, + ); + }); +}); diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/build-simulation.test.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/build-simulation.test.ts index c1bb7803dc5..59d5f987258 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/build-simulation.test.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/build-simulation.test.ts @@ -1,12 +1,41 @@ import { describe, expect, it } from "vitest"; +import { compileHirArtifacts } from "../../hir"; import { compileSimulationFrameReader } from "../frames/frame-reader"; import { materializeEngineFrame } from "../frames/internal-frame"; -import { buildSimulation } from "./build-simulation"; +import { buildSimulation as buildSimulationRaw } from "./build-simulation"; import { decodePlaceTokens } from "./token-layout.test-helpers"; +import type { HirCompiledBufferLambda } from "../../hir-runtime"; import type { SimulationInput } from "./types"; +/** buildSimulation with HIR artifacts compiled from the input's SDCPN (the + * engine no longer compiles user code itself). */ +function buildSimulation( + input: SimulationInput, +): ReturnType { + return buildSimulationRaw({ + ...input, + hirArtifacts: + input.hirArtifacts ?? + compileHirArtifacts(input.sdcpn, input.extensions).artifacts, + }); +} + +/** Calls a buffer-ABI lambda with empty views (for constant lambdas whose + * result does not depend on token attributes). */ +function callLambda( + lambdaFn: HirCompiledBufferLambda | undefined, +): number | boolean | undefined { + return lambdaFn?.( + new Float64Array(0), + new BigUint64Array(0), + new Uint8Array(0), + new Int32Array(0), + new Int32Array(0), + ); +} + describe("buildSimulation", () => { it("packs and decodes integer and boolean token attributes", () => { const input: SimulationInput = { @@ -126,7 +155,9 @@ describe("buildSimulation", () => { maxTime: null, }); - expect(simulation.compiledTransitions.get("t1")?.lambdaFn({})).toBe(true); + expect(callLambda(simulation.compiledTransitions.get("t1")?.lambdaFn)).toBe( + true, + ); }); it("uses an always-firing stochastic Lambda when stochasticity is enabled and Lambda code is empty", () => { @@ -167,7 +198,7 @@ describe("buildSimulation", () => { maxTime: null, }); - expect(simulation.compiledTransitions.get("t1")?.lambdaFn({})).toBe( + expect(callLambda(simulation.compiledTransitions.get("t1")?.lambdaFn)).toBe( Infinity, ); }); @@ -217,7 +248,9 @@ describe("buildSimulation", () => { maxTime: null, }); - expect(simulation.compiledTransitions.get("t1")?.lambdaFn({})).toBe(false); + expect(callLambda(simulation.compiledTransitions.get("t1")?.lambdaFn)).toBe( + false, + ); }); it("still compiles predicate Lambda code when stochasticity is disabled but coloured inputs exist", () => { @@ -273,11 +306,11 @@ describe("buildSimulation", () => { dt: 0.1, maxTime: null, }), - ).toThrow("Failed to compile Lambda function"); + ).toThrow("The Lambda code for `Move` has not been compiled"); }); it("does not expose Distribution to transition kernels when stochasticity is disabled", () => { - const simulation = buildSimulation({ + const input: SimulationInput = { sdcpn: { types: [ { @@ -329,11 +362,24 @@ describe("buildSimulation", () => { seed: 42, dt: 0.1, maxTime: null, - }); + }; - expect(() => - simulation.compiledTransitions.get("t1")?.transitionKernelFn({}), - ).toThrow("Distribution"); + // Distribution usage is rejected at compile time when stochasticity is + // disabled — the kernel gets no artifact... + const { artifacts, failures } = compileHirArtifacts( + input.sdcpn, + input.extensions, + ); + expect(artifacts.kernels.t1).toBeUndefined(); + expect(failures).toMatchObject([ + { itemId: "t1", itemType: "transition-kernel" }, + ]); + expect(failures[0]!.diagnostics[0]!.message).toMatch( + /Distribution|stochasticity/, + ); + + // ...so the simulation refuses to start. + expect(() => buildSimulation(input)).toThrow(/has not been compiled/); }); it("ignores supplied parameter values when parameters are disabled", () => { @@ -553,7 +599,7 @@ describe("buildSimulation", () => { id: "diffeq1", name: "Differential Equation 1", colorId: "type1", - code: "export default Dynamics((placeValues, t) => { return new Float64Array([0, 0]); });", + code: "export default Dynamics((tokens) => tokens.map(() => ({})));", }, ], parameters: [], @@ -650,13 +696,13 @@ describe("buildSimulation", () => { id: "diffeq1", name: "Differential Equation 1", colorId: "type1", - code: "export default Dynamics((placeValues, t) => { return new Float64Array([0]); });", + code: "export default Dynamics((tokens) => tokens.map(() => ({})));", }, { id: "diffeq2", name: "Differential Equation 2", colorId: "type2", - code: "export default Dynamics((placeValues, t) => { return new Float64Array([0, 0]); });", + code: "export default Dynamics((tokens) => tokens.map(() => ({})));", }, ], parameters: [], @@ -698,7 +744,7 @@ describe("buildSimulation", () => { lambdaType: "stochastic", lambdaCode: "export default Lambda((tokens) => { return 1.0; });", transitionKernelCode: - "export default TransitionKernel((tokens) => { return [[[1.0, 2.0]]]; });", + 'export default TransitionKernel((input) => ({ "Place 2": [{ x: 1.0, y: 2.0 }] }));', x: 50, y: 0, }, @@ -710,7 +756,7 @@ describe("buildSimulation", () => { lambdaType: "stochastic", lambdaCode: "export default Lambda((tokens) => { return 2.0; });", transitionKernelCode: - "export default TransitionKernel((tokens) => { return [[[5.0]]]; });", + 'export default TransitionKernel((input) => ({ "Place 3": [{ x: 5.0 }] }));', x: 150, y: 0, }, @@ -797,7 +843,7 @@ describe("buildSimulation", () => { const kernelTransition = simulationInstance.compiledTransitions.get("t2"); expect(kernelTransition).toBeDefined(); - expect(typeof kernelTransition?.transitionKernelFn).toBe("function"); + expect(typeof kernelTransition?.kernelFn).toBe("function"); }); it("throws error when initialMarking references non-existent place", () => { @@ -817,7 +863,7 @@ describe("buildSimulation", () => { id: "diffeq1", name: "Differential Equation 1", colorId: "type1", - code: "export default Dynamics((placeValues, t) => { return new Float64Array([0]); });", + code: "export default Dynamics((tokens) => tokens.map(() => ({})));", }, ], parameters: [], @@ -868,7 +914,7 @@ describe("buildSimulation", () => { id: "diffeq1", name: "Differential Equation 1", colorId: "type1", - code: "export default Dynamics((placeValues, t) => { return new Float64Array([0, 0]); });", + code: "export default Dynamics((tokens) => tokens.map(() => ({})));", }, ], parameters: [], 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 f73e05f36a8..aa279a82c69 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/build-simulation.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/build-simulation.ts @@ -7,12 +7,15 @@ import { sanitizeSDCPNForExtensions, type PetrinautExtensionSettings, } from "../../extensions"; +import { + instantiateHirBufferDynamics, + instantiateHirBufferKernel, + instantiateHirBufferLambda, +} from "../../hir-runtime"; import { deriveDefaultParameterValues, mergeParameterValues, } from "../../parameter-values"; -import { compileUserCode } from "../authoring/user-code/compile-user-code"; -import { isDistribution } from "../authoring/user-code/distribution"; import { createEngineFrame, createEngineFrameLayout, @@ -27,22 +30,23 @@ import { computeTokenSlotLayout, createTokenRegionViews, encodeTokenToBytes, - readTokenRecord, type TokenSlotLayout, } from "./token-layout"; import { coerceTokenRecord } from "./token-values"; -import type { TokenRecord } from "../../types/sdcpn"; +import type { + HirCompiledBufferKernel, + HirCompiledBufferLambda, + HirDynamicsArtifact, + HirKernelArtifact, + HirLambdaArtifact, +} from "../../hir-runtime"; import type { CompiledTransition, DifferentialEquationFn, - LambdaFn, ParameterValues, SimulationInput, SimulationInstance, - TransitionKernelOutput, - TransitionKernelFn, - TransitionTokenValues, } from "./types"; type ColorElement = @@ -53,21 +57,6 @@ type PackedInitialPlaceMarking = { count: number; }; -type UserDifferentialEquationFn = ( - tokens: TokenRecord[], - parameters: ParameterValues, -) => Record[]; - -type UserLambdaFn = ( - tokenValues: TransitionTokenValues, - parameters: ParameterValues, -) => number | boolean; - -type UserTransitionKernelFn = ( - tokenValues: TransitionTokenValues, - parameters: ParameterValues, -) => TransitionKernelOutput; - function getInitialMarkingValue( initialMarking: SimulationInput["initialMarking"], placeId: string, @@ -160,70 +149,6 @@ function packInitialPlaceMarking( return { bytes, count: value.length }; } -function createDifferentialEquationFn({ - placeId, - tokenLayout, - parameterValues, - userFn, - stringPool, -}: { - placeId: string; - tokenLayout: TokenSlotLayout; - parameterValues: ParameterValues; - userFn: UserDifferentialEquationFn; - stringPool: StringPool; -}): DifferentialEquationFn { - const { strideBytes } = tokenLayout; - const realFields = tokenLayout.fields.filter( - (field) => field.element.type === "real", - ); - const realFieldCount = realFields.length; - - return (placeBytes, numberOfTokens) => { - if (placeBytes.byteLength !== numberOfTokens * strideBytes) { - throw new Error( - `Place ${placeId} has ${ - placeBytes.byteLength - } token bytes in frame, expected ${numberOfTokens * strideBytes}`, - ); - } - - const views = createTokenRegionViews( - placeBytes.buffer, - placeBytes.byteOffset, - placeBytes.byteLength, - ); - - const inputTokens: TokenRecord[] = []; - for (let tokenIndex = 0; tokenIndex < numberOfTokens; tokenIndex++) { - inputTokens.push( - readTokenRecord( - tokenLayout, - views, - tokenIndex * strideBytes, - stringPool, - ), - ); - } - - const resultTokens = userFn(inputTokens, parameterValues); - const result = new Float64Array(numberOfTokens * realFieldCount); - const tokenCount = Math.min(resultTokens.length, numberOfTokens); - - for (let tokenIndex = 0; tokenIndex < tokenCount; tokenIndex++) { - const token = resultTokens[tokenIndex]!; - for (let fieldIndex = 0; fieldIndex < realFieldCount; fieldIndex++) { - const field = realFields[fieldIndex]!; - result[tokenIndex * realFieldCount + fieldIndex] = Number( - token[field.element.name] ?? 0, - ); - } - } - - return result; - }; -} - function getPlaceElements( placeId: string, placesMap: ReadonlyMap, @@ -250,17 +175,120 @@ function getPlaceElements( return type.elements; } +/** + * Recovers the pre-flattening item id: flattening scopes ids as + * `instancePath::originalId`, while HIR artifacts are keyed by the original + * (root or subnet-local) id. + */ +function sourceItemId(flattenedId: string): string { + const separatorIndex = flattenedId.lastIndexOf("::"); + return separatorIndex === -1 + ? flattenedId + : flattenedId.slice(separatorIndex + 2); +} + +/** Error for items whose code has no compiled artifact (outside the + * supported subset, or artifacts not supplied). */ +function missingArtifactError( + kind: string, + name: string, + itemId: string, +): SDCPNItemError { + return new SDCPNItemError( + `The ${kind} code for \`${name}\` has not been compiled. Either the code is outside the supported Petrinaut code subset (check the Diagnostics tab for details) or the simulation was started without compiled artifacts.`, + itemId, + ); +} + +/** + * Expected `indices.length` for a transition: one slot per token of each + * colored, non-inhibitor input arc whose color has at least one element — + * must match the emitter's layout (see `hir/surface-context.ts`). + */ +function computeInputSlotCount( + transition: SimulationInput["sdcpn"]["transitions"][number], + placesMap: ReadonlyMap, + typesMap: ReadonlyMap, +): number { + let slots = 0; + for (const arc of transition.inputArcs) { + if (arc.type === "inhibitor") { + continue; + } + const placeId = getArcEndpointPlaceId(arc); + const place = placeId ? placesMap.get(placeId) : undefined; + const color = place?.colorId ? typesMap.get(place.colorId) : undefined; + if (color && color.elements.length > 0) { + slots += arc.weight; + } + } + return slots; +} + +/** + * Expected `placeBases.length` for a transition: one entry per colored, + * non-inhibitor input arc whose color has at least one element — the arcs + * that contribute slots in `computeInputSlotCount`, counted once each. + */ +function computeColoredInputArcCount( + transition: SimulationInput["sdcpn"]["transitions"][number], + placesMap: ReadonlyMap, + typesMap: ReadonlyMap, +): number { + let arcs = 0; + for (const arc of transition.inputArcs) { + if (arc.type === "inhibitor") { + continue; + } + const placeId = getArcEndpointPlaceId(arc); + const place = placeId ? placesMap.get(placeId) : undefined; + const color = place?.colorId ? typesMap.get(place.colorId) : undefined; + if (color && color.elements.length > 0) { + arcs += 1; + } + } + return arcs; +} + +/** + * Expected kernel staging byte length: colored output arcs place-major (arc + * order), `weight` tokens back-to-back at the color's packed stride — must + * match the kernel emitter's `outputByteCount` (see `hir/emit-buffer-js.ts`). + */ +function computeKernelStagingSize( + transition: SimulationInput["sdcpn"]["transitions"][number], + placesMap: ReadonlyMap, + typesMap: ReadonlyMap, +): number { + let bytes = 0; + for (const arc of transition.outputArcs) { + const placeId = getArcEndpointPlaceId(arc); + const place = placeId ? placesMap.get(placeId) : undefined; + const color = place?.colorId ? typesMap.get(place.colorId) : undefined; + if (color) { + bytes += arc.weight * computeTokenSlotLayout(color.elements).strideBytes; + } + } + return bytes; +} + function createLambdaFn({ transition, sdcpn, extensions, parameterValues, + artifact, + expectedSlotCount, + stringPool, }: { transition: SimulationInput["sdcpn"]["transitions"][number]; sdcpn: SimulationInput["sdcpn"]; extensions: PetrinautExtensionSettings; parameterValues: ParameterValues; -}): LambdaFn { + artifact: HirLambdaArtifact | undefined; + expectedSlotCount: number; + stringPool: StringPool; +}): HirCompiledBufferLambda { const availability = getTransitionLogicAvailability( transition, sdcpn, @@ -269,20 +297,30 @@ function createLambdaFn({ const lambdaType = getEffectiveTransitionLambdaType(transition, availability); if (!availability.lambda || transition.lambdaCode.trim() === "") { + // Buffer-ABI-shaped constants — the arguments are ignored. return lambdaType === "stochastic" ? () => Infinity : () => true; } - try { - const userFn = compileUserCode<[TransitionTokenValues, ParameterValues]>( - transition.lambdaCode, - "Lambda", - { enableDistribution: extensions.stochasticity }, - ) as UserLambdaFn; + if (!artifact) { + throw missingArtifactError("Lambda", transition.name, transition.id); + } + + if (artifact.inputSlotCount !== expectedSlotCount) { + throw new SDCPNItemError( + `The compiled Lambda for transition \`${transition.name}\` expects ${artifact.inputSlotCount} input token slot(s) but the net requires ${expectedSlotCount}. The compiled artifacts are stale — recompile them from the current net.`, + transition.id, + ); + } - return (tokenValues) => userFn(tokenValues, parameterValues); + try { + return instantiateHirBufferLambda( + artifact.source, + parameterValues, + stringPool, + ); } catch (error) { throw new SDCPNItemError( - `Failed to compile Lambda function for transition \`${ + `Failed to instantiate the compiled Lambda for transition \`${ transition.name }\`:\n\n${error instanceof Error ? error.message : String(error)}`, transition.id, @@ -292,15 +330,21 @@ function createLambdaFn({ function createTransitionKernelFn({ transition, - extensions, placesMap, parameterValues, + artifact, + expectedSlotCount, + expectedStagingSize, + stringPool, }: { transition: SimulationInput["sdcpn"]["transitions"][number]; - extensions: PetrinautExtensionSettings; placesMap: ReadonlyMap; parameterValues: ParameterValues; -}): TransitionKernelFn { + artifact: HirKernelArtifact | undefined; + expectedSlotCount: number; + expectedStagingSize: number; + stringPool: StringPool; +}): HirCompiledBufferKernel | null { const hasTypedOutputPlace = transition.outputArcs.some((arc) => { const placeId = getArcEndpointPlaceId(arc); const place = placeId ? placesMap.get(placeId) : undefined; @@ -308,36 +352,40 @@ function createTransitionKernelFn({ }); if (!hasTypedOutputPlace) { - return () => ({}); + return null; + } + + if (!artifact) { + throw missingArtifactError( + "transition kernel", + transition.name, + transition.id, + ); + } + + if (artifact.inputSlotCount !== expectedSlotCount) { + throw new SDCPNItemError( + `The compiled transition kernel for transition \`${transition.name}\` expects ${artifact.inputSlotCount} input token slot(s) but the net requires ${expectedSlotCount}. The compiled artifacts are stale — recompile them from the current net.`, + transition.id, + ); + } + + if (artifact.outputByteCount !== expectedStagingSize) { + throw new SDCPNItemError( + `The compiled transition kernel for transition \`${transition.name}\` writes ${artifact.outputByteCount} output byte(s) but the net requires ${expectedStagingSize}. The compiled artifacts are stale — recompile them from the current net.`, + transition.id, + ); } try { - const userFn = compileUserCode<[TransitionTokenValues, ParameterValues]>( - transition.transitionKernelCode, - "TransitionKernel", - { enableDistribution: extensions.stochasticity }, - ) as UserTransitionKernelFn; - - return (tokenValues) => { - const output = userFn(tokenValues, parameterValues); - if (!extensions.stochasticity) { - for (const [placeName, tokens] of Object.entries(output)) { - for (const token of tokens) { - for (const [elementName, value] of Object.entries(token)) { - if (isDistribution(value)) { - throw new Error( - `Transition kernel output for place "${placeName}" returned a Distribution for "${elementName}", but stochasticity is disabled.`, - ); - } - } - } - } - } - return output; - }; + return instantiateHirBufferKernel( + artifact.source, + parameterValues, + stringPool, + ); } catch (error) { throw new SDCPNItemError( - `Failed to compile transition kernel for transition \`${ + `Failed to instantiate the compiled transition kernel for transition \`${ transition.name }\`:\n\n${error instanceof Error ? error.message : String(error)}`, transition.id, @@ -347,19 +395,64 @@ function createTransitionKernelFn({ function createCompiledTransition({ transition, + sdcpn, + extensions, placesMap, typesMap, arcPlaceNameOverrides, - lambdaFn, - transitionKernelFn, + parameterValues, + lambdaArtifact, + kernelArtifact, + stringPool, }: { transition: SimulationInput["sdcpn"]["transitions"][number]; + sdcpn: SimulationInput["sdcpn"]; + extensions: PetrinautExtensionSettings; placesMap: ReadonlyMap; typesMap: ReadonlyMap; arcPlaceNameOverrides: ReadonlyMap; - lambdaFn: LambdaFn; - transitionKernelFn: TransitionKernelFn; + parameterValues: ParameterValues; + lambdaArtifact: HirLambdaArtifact | undefined; + kernelArtifact: HirKernelArtifact | undefined; + stringPool: StringPool; }): CompiledTransition { + const expectedSlotCount = computeInputSlotCount( + transition, + placesMap, + typesMap, + ); + const coloredInputArcCount = computeColoredInputArcCount( + transition, + placesMap, + typesMap, + ); + const stagingSize = computeKernelStagingSize(transition, placesMap, typesMap); + const lambdaFn = createLambdaFn({ + transition, + sdcpn, + extensions, + parameterValues, + artifact: lambdaArtifact, + expectedSlotCount, + stringPool, + }); + const kernelFn = createTransitionKernelFn({ + transition, + placesMap, + parameterValues, + artifact: kernelArtifact, + expectedSlotCount, + expectedStagingSize: stagingSize, + stringPool, + }); + + const kernelStaging = new Uint8Array(stagingSize); + const kernelStagingViews = createTokenRegionViews( + kernelStaging.buffer, + 0, + kernelStaging.byteLength, + ); + return { id: transition.id, name: transition.name, @@ -423,7 +516,11 @@ function createCompiledTransition({ }; }), lambdaFn, - transitionKernelFn, + kernelFn, + placeBases: new Int32Array(coloredInputArcCount), + indices: new Int32Array(expectedSlotCount), + kernelStaging, + kernelStagingViews, }; } @@ -525,8 +622,6 @@ export function buildSimulation(input: SimulationInput): SimulationInstance { `Differential equation with ID ${place.differentialEquationId} referenced by place ${place.id} does not exist in SDCPN`, ); } - const { code } = differentialEquation; - try { if (!place.colorId) { continue; @@ -543,21 +638,28 @@ export function buildSimulation(input: SimulationInput): SimulationInstance { continue; } - const userFn = compileUserCode<[TokenRecord[], ParameterValues]>( - code, - "Dynamics", - { enableDistribution: extensions.stochasticity }, - ) as UserDifferentialEquationFn; + const placeParameterValues = + flattened.placeParameterValues.get(place.id) ?? parameterValues; + + const artifact: HirDynamicsArtifact | undefined = + input.hirArtifacts?.dynamics[sourceItemId(differentialEquation.id)]; + if (!artifact) { + throw missingArtifactError( + "dynamics", + differentialEquation.name, + place.id, + ); + } + + // Buffer-native program: matches the byte-addressed + // DifferentialEquationFn shape directly — no per-token record decoding. differentialEquationFns.set( place.id, - createDifferentialEquationFn({ - placeId: place.id, - tokenLayout: computeTokenSlotLayout(type.elements), - parameterValues: - flattened.placeParameterValues.get(place.id) ?? parameterValues, - userFn, + instantiateHirBufferDynamics( + artifact.source, + placeParameterValues, stringPool, - }), + ), ); } catch (error) { throw new SDCPNItemError( @@ -576,25 +678,19 @@ export function buildSimulation(input: SimulationInput): SimulationInstance { transition.id, createCompiledTransition({ transition, + sdcpn, + extensions, placesMap, typesMap, arcPlaceNameOverrides: flattened.arcPlaceNameOverrides, - lambdaFn: createLambdaFn({ - transition, - sdcpn, - extensions, - parameterValues: - flattened.transitionParameterValues.get(transition.id) ?? - parameterValues, - }), - transitionKernelFn: createTransitionKernelFn({ - transition, - extensions, - placesMap, - parameterValues: - flattened.transitionParameterValues.get(transition.id) ?? - parameterValues, - }), + parameterValues: + flattened.transitionParameterValues.get(transition.id) ?? + parameterValues, + lambdaArtifact: + input.hirArtifacts?.lambdas[sourceItemId(transition.id)], + kernelArtifact: + input.hirArtifacts?.kernels[sourceItemId(transition.id)], + stringPool, }), ); } 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 new file mode 100644 index 00000000000..cd4ea80398d --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/compiled-transition.test-helpers.ts @@ -0,0 +1,106 @@ +/** + * Test helper for hand-building `CompiledTransition` values with mock + * buffer-ABI lambda/kernel programs. Mirrors `build-simulation.ts`'s + * `createCompiledTransition` scratch allocation (placeBases/indices/ + * kernelStaging) so engine hot-path code can run against fixtures without + * compiling real HIR artifacts. Not shipped — only imported from `*.test.ts`. + */ +import { getArcEndpointPlaceId } from "../../arc-endpoints"; +import { computeTokenSlotLayout, createTokenRegionViews } from "./token-layout"; + +import type { + HirCompiledBufferKernel, + HirCompiledBufferLambda, +} from "../../hir-runtime"; +import type { Color, Place, Transition } from "../../types/sdcpn"; +import type { CompiledTransition } from "./types"; + +export function makeCompiledTransition({ + transition, + places, + types, + lambdaFn, + kernelFn = null, +}: { + transition: Transition; + places: Place[]; + types: Color[]; + /** Buffer-ABI mock: `(f64, u64, u8, placeBases, indices) => value`. */ + lambdaFn: HirCompiledBufferLambda; + /** Buffer-ABI mock writing into the staging views, or null when the + * transition has no colored output places. */ + kernelFn?: HirCompiledBufferKernel | null; +}): CompiledTransition { + const placesMap = new Map(places.map((place) => [place.id, place])); + const typesMap = new Map(types.map((type) => [type.id, type])); + const getElements = (placeId: string) => { + const place = placesMap.get(placeId); + if (!place?.colorId) { + return null; + } + + return typesMap.get(place.colorId)?.elements ?? null; + }; + + const inputPlaces = transition.inputArcs.map((arc) => { + const placeId = getArcEndpointPlaceId(arc)!; + const elements = getElements(placeId); + return { + placeId, + placeName: placesMap.get(placeId)?.name ?? placeId, + weight: arc.weight, + arcType: arc.type, + elements, + tokenLayout: elements ? computeTokenSlotLayout(elements) : null, + }; + }); + const outputPlaces = transition.outputArcs.map((arc) => { + const placeId = getArcEndpointPlaceId(arc)!; + const elements = getElements(placeId); + return { + placeId, + placeName: placesMap.get(placeId)?.name ?? placeId, + weight: arc.weight, + elements, + tokenLayout: elements ? computeTokenSlotLayout(elements) : null, + }; + }); + + // One placeBases entry per colored non-inhibitor input arc, `weight` token + // slots each — matching the engine's `inputPlacesWithTokenValues` filter. + const coloredInputArcs = inputPlaces.filter( + (inputPlace) => + inputPlace.arcType !== "inhibitor" && + inputPlace.tokenLayout !== null && + inputPlace.tokenLayout.strideBytes > 0, + ); + const slotCount = coloredInputArcs.reduce( + (sum, inputPlace) => sum + inputPlace.weight, + 0, + ); + + // Kernel staging: colored output arcs place-major, `weight` tokens each. + const stagingSize = outputPlaces.reduce( + (sum, outputPlace) => + sum + (outputPlace.tokenLayout?.strideBytes ?? 0) * outputPlace.weight, + 0, + ); + const kernelStaging = new Uint8Array(stagingSize); + + return { + id: transition.id, + name: transition.name, + inputPlaces, + outputPlaces, + lambdaFn, + kernelFn, + placeBases: new Int32Array(coloredInputArcs.length), + indices: new Int32Array(slotCount), + kernelStaging, + kernelStagingViews: createTokenRegionViews( + kernelStaging.buffer, + 0, + kernelStaging.byteLength, + ), + }; +} diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/compute-next-frame.test.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/compute-next-frame.test.ts index 76189db8692..2b07faf3ce8 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/compute-next-frame.test.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/compute-next-frame.test.ts @@ -1,11 +1,26 @@ import { describe, expect, it } from "vitest"; -import { buildSimulation } from "./build-simulation"; +import { compileHirArtifacts } from "../../hir"; +import { buildSimulation as buildSimulationRaw } from "./build-simulation"; import { computeNextFrame } from "./compute-next-frame"; import { decodePlaceTokens } from "./token-layout.test-helpers"; import { parseUuid } from "./uuid"; import type { SDCPN } from "../../types/sdcpn"; +import type { SimulationInput as SimulationInputForArtifacts } from "./types"; + +/** buildSimulation with HIR artifacts compiled from the input's SDCPN (the + * engine no longer compiles user code itself). */ +function buildSimulation( + input: SimulationInputForArtifacts, +): ReturnType { + return buildSimulationRaw({ + ...input, + hirArtifacts: + input.hirArtifacts ?? + compileHirArtifacts(input.sdcpn, input.extensions).artifacts, + }); +} describe("computeNextFrame", () => { it("should compute next frame with dynamics and transitions", () => { 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 2685b7056a6..0a021802eb3 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 @@ -1,11 +1,14 @@ +/* eslint-disable no-param-reassign -- buffer-ABI kernel mocks write into the + staging views they receive */ import { describe, expect, it } from "vitest"; -import { getArcEndpointPlaceId } from "../../arc-endpoints"; +import { compileHirArtifacts } from "../../hir"; +import { instantiateHirBufferKernel } from "../../hir-runtime"; import { createEngineFrameLayout } from "../frames/internal-frame"; +import { makeCompiledTransition } from "./compiled-transition.test-helpers"; import { computePossibleTransition as computePossibleTransitionImpl } from "./compute-possible-transition"; import { nextRandom } from "./seeded-rng"; import { StringPool } from "./string-pool"; -import { computeTokenSlotLayout } from "./token-layout"; import { decodeTokenBlock, makeTestFrame, @@ -13,14 +16,12 @@ import { } from "./token-layout.test-helpers"; import { formatUuid, parseUuid, toUuid } from "./uuid"; -import type { Color, Place, Transition } from "../../types/sdcpn"; import type { - CompiledTransition, - LambdaFn, - SimulationInstance, - TransitionKernelFn, - TransitionTokenValues, -} from "./types"; + HirCompiledBufferKernel, + HirCompiledBufferLambda, +} from "../../hir-runtime"; +import type { Color, Place, SDCPN, Transition } from "../../types/sdcpn"; +import type { SimulationInstance } from "./types"; const type1: Color = { id: "type1", @@ -55,79 +56,47 @@ function makeTransition( return { name: "Transition 1", lambdaType: "stochastic", - lambdaCode: "return 1.0;", - transitionKernelCode: "return {};", + lambdaCode: "", + transitionKernelCode: "", x: 0, y: 0, ...transition, }; } -function makeCompiledTransitions({ +const makeMiniSdcpn = ( + places: Place[], + types: Color[], + transition: Transition, +): SDCPN => ({ + types, + differentialEquations: [], + parameters: [], + places, + transitions: [transition], +}); + +/** Compiles a transition's kernel code to a real buffer program and + * instantiates it against `stringPool`. */ +function compileKernelFn({ places, - transitions, types, - lambdaFns, - transitionKernelFns, + transition, + stringPool, }: { places: Place[]; - transitions: Transition[]; types: Color[]; - lambdaFns: ReadonlyMap; - transitionKernelFns: ReadonlyMap; -}): Map { - const placesMap = new Map(places.map((place) => [place.id, place])); - const typesMap = new Map(types.map((type) => [type.id, type])); - const getElements = (placeId: string) => { - const place = placesMap.get(placeId); - if (!place?.colorId) { - return null; - } - - return typesMap.get(place.colorId)?.elements ?? null; - }; - - return new Map( - transitions.map((transition) => { - const lambdaFn = lambdaFns.get(transition.id); - const transitionKernelFn = transitionKernelFns.get(transition.id); - if (!lambdaFn || !transitionKernelFn) { - throw new Error(`Missing compiled functions for ${transition.id}`); - } - - return [ - transition.id, - { - id: transition.id, - name: transition.name, - inputPlaces: transition.inputArcs.map((arc) => { - const placeId = getArcEndpointPlaceId(arc)!; - const elements = getElements(placeId); - return { - placeId, - placeName: placesMap.get(placeId)?.name ?? placeId, - weight: arc.weight, - arcType: arc.type, - elements, - tokenLayout: elements ? computeTokenSlotLayout(elements) : null, - }; - }), - outputPlaces: transition.outputArcs.map((arc) => { - const placeId = getArcEndpointPlaceId(arc)!; - const elements = getElements(placeId); - return { - placeId, - placeName: placesMap.get(placeId)?.name ?? placeId, - weight: arc.weight, - elements, - tokenLayout: elements ? computeTokenSlotLayout(elements) : null, - }; - }), - lambdaFn, - transitionKernelFn, - }, - ]; - }), + transition: Transition; + stringPool: StringPool; +}): HirCompiledBufferKernel { + const { artifacts, failures } = compileHirArtifacts( + makeMiniSdcpn(places, types, { ...transition, lambdaCode: "" }), + ); + expect(failures).toEqual([]); + return instantiateHirBufferKernel( + artifacts.kernels[transition.id]!.source, + {}, + stringPool, ); } @@ -136,13 +105,13 @@ function makeSimulation({ transitions, types = [], lambdaFns, - transitionKernelFns, + kernelFns, }: { places?: Place[]; transitions: Transition[]; types?: Color[]; - lambdaFns: ReadonlyMap; - transitionKernelFns: ReadonlyMap; + lambdaFns: ReadonlyMap; + kernelFns?: ReadonlyMap; }): SimulationInstance { const frameLayout = createEngineFrameLayout({ places, @@ -157,13 +126,24 @@ function makeSimulation({ ), types: new Map(types.map((type) => [type.id, type])), differentialEquationFns: new Map(), - compiledTransitions: makeCompiledTransitions({ - places, - transitions, - types, - lambdaFns, - transitionKernelFns, - }), + compiledTransitions: new Map( + transitions.map((transition) => { + const lambdaFn = lambdaFns.get(transition.id); + if (!lambdaFn) { + throw new Error(`Missing compiled lambda for ${transition.id}`); + } + return [ + transition.id, + makeCompiledTransition({ + transition, + places, + types, + lambdaFn, + kernelFn: kernelFns?.get(transition.id) ?? null, + }), + ]; + }), + ), parameterValues: {}, dt: 0.1, maxTime: null, @@ -200,9 +180,6 @@ describe("computePossibleTransition", () => { const simulation = makeSimulation({ transitions: [transition], lambdaFns: new Map([["t1", () => 1.0]]), - transitionKernelFns: new Map([ - ["t1", () => ({ p2: [{ x: 1.0 }] })], - ]), }); const frame = makeTestFrame({ places: { @@ -225,9 +202,6 @@ describe("computePossibleTransition", () => { const simulation = makeSimulation({ transitions: [transition], lambdaFns: new Map([["t1", () => 1.0]]), - transitionKernelFns: new Map([ - ["t1", () => ({})], - ]), }); const frame = makeTestFrame({ places: { @@ -249,8 +223,6 @@ describe("computePossibleTransition", () => { { placeId: "p2", weight: 1, type: "inhibitor" }, ], outputArcs: [{ placeId: "p3", weight: 1 }], - lambdaCode: "return 10.0;", - transitionKernelCode: "return { Target: [{ x: 5.0 }] };", }); const simulation = makeSimulation({ places: [ @@ -261,8 +233,13 @@ describe("computePossibleTransition", () => { transitions: [transition], types: [type1], lambdaFns: new Map([["t1", () => 10.0]]), - transitionKernelFns: new Map([ - ["t1", () => ({ Target: [{ x: 5.0 }] })], + kernelFns: new Map([ + [ + "t1", + (_f64, _u64, _u8, _placeBases, _indices, outF64) => { + outF64[0] = 5.0; + }, + ], ]), }); const frame = makeTestFrame({ @@ -295,11 +272,9 @@ describe("computePossibleTransition", () => { ], outputArcs: [{ placeId: "p3", weight: 1 }], lambdaType: "predicate", - lambdaCode: "return true;", - transitionKernelCode: "return { Target: input.Guard };", }); - let lambdaInput: TransitionTokenValues | null = null; - let kernelInput: TransitionTokenValues | null = null; + // type1 tokens are a single f64 (x) — stride 8 bytes. + let lambdaSeen: { source: number; guard: number } | null = null; const simulation = makeSimulation({ places: [ makePlace("p1", "Source", "type1"), @@ -308,25 +283,24 @@ describe("computePossibleTransition", () => { ], transitions: [transition], types: [type1], - lambdaFns: new Map([ + lambdaFns: new Map([ [ "t1", - (input) => { - lambdaInput = input; + (f64, _u64, _u8, placeBases, indices) => { + lambdaSeen = { + source: f64[(placeBases[0]! + indices[0]! * 8) / 8]!, + guard: f64[(placeBases[1]! + indices[1]! * 8) / 8]!, + }; return true; }, ], ]), - transitionKernelFns: new Map([ + kernelFns: new Map([ [ "t1", - (input) => { - kernelInput = input; - const guardToken = input.Guard?.[0]; - if (guardToken?.x === undefined) { - throw new Error("Expected read arc token"); - } - return { Target: [{ x: guardToken.x }] }; + (f64, _u64, _u8, placeBases, indices, outF64) => { + // Forward the read arc (Guard) token's x to the output. + outF64[0] = f64[(placeBases[1]! + indices[1]! * 8) / 8]!; }, ], ]), @@ -345,14 +319,7 @@ describe("computePossibleTransition", () => { const result = computePossibleTransition(frame, simulation, "t1", 42); expect(result).not.toBeNull(); - expect(lambdaInput).toMatchObject({ - Source: [{ x: 3.0 }], - Guard: [{ x: 7.0 }], - }); - expect(kernelInput).toMatchObject({ - Source: [{ x: 3.0 }], - Guard: [{ x: 7.0 }], - }); + expect(lambdaSeen).toEqual({ source: 3.0, guard: 7.0 }); expect(result!.remove).toEqual({ p1: new Set([0]) }); expect(Object.keys(result!.add)).toEqual(["p3"]); expect( @@ -365,8 +332,6 @@ describe("computePossibleTransition", () => { id: "t1", inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], outputArcs: [{ placeId: "p2", weight: 1 }], - lambdaCode: "return 10.0;", - transitionKernelCode: "return [[[2.0]]];", }); const simulation = makeSimulation({ places: [ @@ -376,8 +341,13 @@ describe("computePossibleTransition", () => { transitions: [transition], types: [type1], lambdaFns: new Map([["t1", () => 10.0]]), - transitionKernelFns: new Map([ - ["t1", () => ({ "Place 2": [{ x: 2.0 }] })], + kernelFns: new Map([ + [ + "t1", + (_f64, _u64, _u8, _placeBases, _indices, outF64) => { + outF64[0] = 2.0; + }, + ], ]), }); const frame = makeTestFrame({ @@ -402,7 +372,7 @@ describe("computePossibleTransition", () => { expect(result?.newRngState).toBeTypeOf("number"); }); - it("decodes typed input tokens and encodes typed output tokens", () => { + it("reads typed input tokens and encodes typed output tokens", () => { const typedColor: Color = { id: "typed", name: "Typed", @@ -414,12 +384,13 @@ describe("computePossibleTransition", () => { { elementId: "active", name: "active", type: "boolean" }, ], }; + // Packed layout: amount(f64)@0, count(f64)@8, active(u8)@16 — stride 24. const transition = makeTransition({ id: "t1", inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], outputArcs: [{ placeId: "p2", weight: 1 }], }); - let lambdaInput: unknown; + let lambdaSeen: unknown; const simulation = makeSimulation({ places: [ makePlace("p1", "Source", typedColor.id), @@ -427,27 +398,28 @@ describe("computePossibleTransition", () => { ], transitions: [transition], types: [typedColor], - lambdaFns: new Map([ + lambdaFns: new Map([ [ "t1", - (tokens) => { - lambdaInput = tokens; + (f64, _u64, u8, placeBases, indices) => { + const base = placeBases[0]! + indices[0]! * 24; + lambdaSeen = { + amount: f64[base / 8], + count: Math.round(f64[(base + 8) / 8]!), + active: u8[base + 16] !== 0, + }; return 10.0; }, ], ]), - transitionKernelFns: new Map([ + kernelFns: new Map([ [ "t1", - () => ({ - Target: [ - { - amount: 2.5, - count: 3.6, - active: false, - }, - ], - }), + (_f64, _u64, _u8, _placeBases, _indices, outF64, _outU64, outU8) => { + outF64[0] = 2.5; + outF64[1] = Math.round(3.6); // integer attributes are pre-rounded + outU8[16] = 0; + }, ], ]), }); @@ -466,14 +438,10 @@ describe("computePossibleTransition", () => { const result = computePossibleTransition(frame, simulation, "t1", 42); - expect(lambdaInput).toEqual({ - Source: [ - { - amount: 1.25, - count: 3, - active: true, - }, - ], + expect(lambdaSeen).toEqual({ + amount: 1.25, + count: 3, + active: true, }); expect(result).toMatchObject({ remove: { p1: new Set([0]) }, @@ -496,22 +464,37 @@ describe("computePossibleTransition", () => { { elementId: "x", name: "x", type: "real" }, ], }; + const uuidPlaces = [ + makePlace("p1", "Source", uuidColor.id), + makePlace("p2", "Target", uuidColor.id), + ]; - const makeUuidSimulation = (kernelFn: TransitionKernelFn) => { - const transition = makeTransition({ + const makeUuidTransition = (transitionKernelCode: string) => + makeTransition({ id: "t1", inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], outputArcs: [{ placeId: "p2", weight: 1 }], + transitionKernelCode, }); + + const makeUuidSimulation = (kernelCode: string) => { + const transition = makeUuidTransition(kernelCode); return makeSimulation({ - places: [ - makePlace("p1", "Source", uuidColor.id), - makePlace("p2", "Target", uuidColor.id), - ], + places: uuidPlaces, transitions: [transition], types: [uuidColor], lambdaFns: new Map([["t1", () => 10.0]]), - transitionKernelFns: new Map([["t1", kernelFn]]), + kernelFns: new Map([ + [ + "t1", + compileKernelFn({ + places: uuidPlaces, + types: [uuidColor], + transition, + stringPool: new StringPool(), + }), + ], + ]), }); }; @@ -534,7 +517,9 @@ describe("computePossibleTransition", () => { ) => decodeTokenBlock(uuidColor.elements, result!.add.p2![0]!); it("auto-generates a v4 uuid deterministically per seed when omitted", () => { - const simulation = makeUuidSimulation(() => ({ Target: [{ x: 2.0 }] })); + const simulation = makeUuidSimulation( + `export default TransitionKernel(() => ({ Target: [{ x: 2.0 }] }));`, + ); const first = computePossibleTransition( makeUuidFrame(), @@ -564,28 +549,23 @@ describe("computePossibleTransition", () => { /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, ); // The auto-generation consumed RNG state beyond the firing draw. - expect(first!.newRngState).not.toBe( - computePossibleTransition( - makeUuidFrame(), - makeUuidSimulation(() => ({ Target: [{ id: 1n, x: 2.0 }] })), - "t1", - 42, - )!.newRngState, - ); + expect(first!.newRngState).not.toBe(nextRandom(42)[1]); }); it("resolves the Uuid.generate() sentinel with the same seeded draw as omission", () => { const omitted = computePossibleTransition( makeUuidFrame(), - makeUuidSimulation(() => ({ Target: [{ x: 2.0 }] })), + makeUuidSimulation( + `export default TransitionKernel(() => ({ Target: [{ x: 2.0 }] }));`, + ), "t1", 42, ); const sentinel = computePossibleTransition( makeUuidFrame(), - makeUuidSimulation(() => ({ - Target: [{ id: { __petrinautUuid: "generate" }, x: 2.0 }], - })), + makeUuidSimulation( + `export default TransitionKernel(() => ({ Target: [{ id: Uuid.generate(), x: 2.0 }] }));`, + ), "t1", 42, ); @@ -598,11 +578,9 @@ describe("computePossibleTransition", () => { const run = () => computePossibleTransition( makeUuidFrame(), - makeUuidSimulation(() => ({ - Target: [ - { id: { __petrinautUuid: "from", value: "order-1" }, x: 2.0 }, - ], - })), + makeUuidSimulation( + `export default TransitionKernel(() => ({ Target: [{ id: Uuid.from("order-1"), x: 2.0 }] }));`, + ), "t1", 42, ); @@ -618,9 +596,9 @@ describe("computePossibleTransition", () => { it("forwards an input token's uuid bigint unchanged", () => { const result = computePossibleTransition( makeUuidFrame(), - makeUuidSimulation((input) => ({ - Target: [{ id: input.Source![0]!.id, x: 3.0 }], - })), + makeUuidSimulation( + `export default TransitionKernel((input) => ({ Target: [{ id: input.Source[0].id, x: 3.0 }] }));`, + ), "t1", 42, ); @@ -628,20 +606,25 @@ describe("computePossibleTransition", () => { expect(firstAddedToken(result)).toEqual({ id: inputUuid, x: 3.0 }); }); - it("throws when a Distribution is produced for a uuid element", () => { - const distribution = { - __brand: "distribution", - type: "uniform", - min: 0, - max: 1, - } as const; - const simulation = makeUuidSimulation(() => ({ - Target: [{ id: distribution as never, x: 2.0 }], - })); - - expect(() => - computePossibleTransition(makeUuidFrame(), simulation, "t1", 42), - ).toThrow("produced a distribution for discrete element id"); + it("rejects a Distribution for a uuid element at compile time", () => { + const { failures } = compileHirArtifacts( + makeMiniSdcpn( + uuidPlaces, + [uuidColor], + makeUuidTransition( + `export default TransitionKernel(() => ({ Target: [{ id: Distribution.Uniform(0, 1), x: 2.0 }] }));`, + ), + ), + ); + + expect( + failures.map((failure) => `${failure.itemType}:${failure.itemId}`), + ).toEqual(["transition-kernel:t1"]); + expect( + failures[0]!.diagnostics.map((diagnostic) => diagnostic.message), + ).toEqual( + expect.arrayContaining([expect.stringMatching(/discrete \(uuid\)/)]), + ); }); }); @@ -656,24 +639,39 @@ describe("computePossibleTransition", () => { { elementId: "x", name: "x", type: "real" }, ], }; + const stringPlaces = [ + makePlace("p1", "Source", stringColor.id), + makePlace("p2", "Target", stringColor.id), + ]; - const makeStringSetup = (kernelFn: TransitionKernelFn) => { - const transition = makeTransition({ + const makeStringTransition = (transitionKernelCode: string) => + makeTransition({ id: "t1", inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], outputArcs: [{ placeId: "p2", weight: 1 }], + transitionKernelCode, }); + + const makeStringSetup = (kernelCode: string) => { + const transition = makeStringTransition(kernelCode); const stringPool = new StringPool(); const simulation = { ...makeSimulation({ - places: [ - makePlace("p1", "Source", stringColor.id), - makePlace("p2", "Target", stringColor.id), - ], + places: stringPlaces, transitions: [transition], types: [stringColor], lambdaFns: new Map([["t1", () => 10.0]]), - transitionKernelFns: new Map([["t1", kernelFn]]), + kernelFns: new Map([ + [ + "t1", + compileKernelFn({ + places: stringPlaces, + types: [stringColor], + transition, + stringPool, + }), + ], + ]), }), stringPool, }; @@ -698,9 +696,9 @@ describe("computePossibleTransition", () => { decodeTokenBlock(stringColor.elements, result!.add.p2![0]!, stringPool); it("interns equal output strings once — identical buffer bytes for equal values", () => { - const { simulation, frame, stringPool } = makeStringSetup(() => ({ - Target: [{ label: "shipped", x: 2.0 }], - })); + const { simulation, frame, stringPool } = makeStringSetup( + `export default TransitionKernel(() => ({ Target: [{ label: "shipped", x: 2.0 }] }));`, + ); const first = computePossibleTransition(frame, simulation, "t1", 42); expect(firstAddedToken(first, stringPool)).toEqual({ @@ -716,9 +714,9 @@ describe("computePossibleTransition", () => { }); it("forwards an input token's string unchanged and reuses its pool id", () => { - const { simulation, frame, stringPool } = makeStringSetup((input) => ({ - Target: [{ label: input.Source![0]!.label, x: 3.0 }], - })); + const { simulation, frame, stringPool } = makeStringSetup( + `export default TransitionKernel((input) => ({ Target: [{ label: input.Source[0].label, x: 3.0 }] }));`, + ); const result = computePossibleTransition(frame, simulation, "t1", 42); @@ -730,50 +728,65 @@ describe("computePossibleTransition", () => { expect(stringPool.size).toBe(2); }); - it('defaults missing string values to "" and stringifies numbers', () => { - const missing = makeStringSetup(() => ({ Target: [{ x: 2.0 }] })); - expect( - firstAddedToken( - computePossibleTransition( - missing.frame, - missing.simulation, - "t1", - 42, + it("rejects omitted or non-string values for string elements at compile time", () => { + // Omitting a string attribute is a compile failure (no "" default). + const omitted = compileHirArtifacts( + makeMiniSdcpn( + stringPlaces, + [stringColor], + makeStringTransition( + `export default TransitionKernel(() => ({ Target: [{ x: 2.0 }] }));`, ), - missing.stringPool, ), - ).toEqual({ label: "", x: 2.0 }); - - const numeric = makeStringSetup(() => ({ - Target: [{ label: 42 as never, x: 2.0 }], - })); + ); expect( - firstAddedToken( - computePossibleTransition( - numeric.frame, - numeric.simulation, - "t1", - 42, + omitted.failures.map( + (failure) => `${failure.itemType}:${failure.itemId}`, + ), + ).toEqual(["transition-kernel:t1"]); + expect(omitted.failures[0]!.diagnostics[0]!.message).toMatch( + /missing the `label` attribute/, + ); + + // Numbers are not stringified implicitly either. + const numeric = compileHirArtifacts( + makeMiniSdcpn( + stringPlaces, + [stringColor], + makeStringTransition( + `export default TransitionKernel(() => ({ Target: [{ label: 42, x: 2.0 }] }));`, ), - numeric.stringPool, ), - ).toEqual({ label: "42", x: 2.0 }); + ); + expect( + numeric.failures.map( + (failure) => `${failure.itemType}:${failure.itemId}`, + ), + ).toEqual(["transition-kernel:t1"]); + expect(numeric.failures[0]!.diagnostics[0]!.message).toMatch( + /`label` is a string attribute/, + ); }); - it("throws when a Distribution is produced for a string element", () => { - const distribution = { - __brand: "distribution", - type: "uniform", - min: 0, - max: 1, - } as const; - const { simulation, frame } = makeStringSetup(() => ({ - Target: [{ label: distribution as never, x: 2.0 }], - })); - - expect(() => - computePossibleTransition(frame, simulation, "t1", 42), - ).toThrow("produced a distribution for discrete element label"); + it("rejects a Distribution for a string element at compile time", () => { + const { failures } = compileHirArtifacts( + makeMiniSdcpn( + stringPlaces, + [stringColor], + makeStringTransition( + `export default TransitionKernel(() => ({ Target: [{ label: Distribution.Uniform(0, 1), x: 2.0 }] }));`, + ), + ), + ); + + expect( + failures.map((failure) => `${failure.itemType}:${failure.itemId}`), + ).toEqual(["transition-kernel:t1"]); + expect( + failures[0]!.diagnostics.map((diagnostic) => diagnostic.message), + ).toEqual( + expect.arrayContaining([expect.stringMatching(/discrete \(string\)/)]), + ); }); }); }); 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 e504598a163..743825f71c9 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/compute-possible-transition.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/compute-possible-transition.ts @@ -1,17 +1,16 @@ import { SDCPNItemError } from "../../errors"; import { materializeEngineFrame } from "../frames/internal-frame"; -import { encodeKernelOutputToken } from "./encode-kernel-token"; +import { + executeBufferKernel, + fillPlaceBases, + fillTokenIndices, +} from "./buffer-transition"; import { enumerateWeightedMarkingIndicesGenerator } from "./enumerate-weighted-markings"; import { nextRandom } from "./seeded-rng"; -import { createTokenRegionViews, readTokenRecord } from "./token-layout"; -import { describeTokenValuesForError } from "./token-values"; +import { createTokenRegionViews } from "./token-layout"; import type { ID } from "../../types/sdcpn"; -import type { - EngineFrame, - SimulationInstance, - TransitionTokenValues, -} from "./types"; +import type { EngineFrame, SimulationInstance } from "./types"; type PlaceID = ID; @@ -104,39 +103,22 @@ export function computePossibleTransition( inputPlacesWithTokenValues, ); - for (const tokenCombinationIndices of tokensCombinations) { - // Expensive: get token values from global buffer - // And transform them for lambda function input. - // Convert to object format with place names as keys - const tokenCombinationValues: TransitionTokenValues = {}; - - for (const [ - placeIndex, - placeTokenIndices, - ] of tokenCombinationIndices.entries()) { - const inputPlace = inputPlacesWithTokenValues[placeIndex]!; - const placeByteOffset = inputPlace.byteOffset; - const strideBytes = inputPlace.strideBytes; - - const tokenLayout = inputPlace.tokenLayout; - if (!tokenLayout) { - throw new SDCPNItemError( - `Place \`${inputPlace.placeName}\` has no type defined`, - inputPlace.placeId, - ); - } + // The compiled buffer-ABI lambda reads token attributes at packed-struct + // byte offsets straight from the shared views — no per-combination record + // decoding. Place base offsets don't change across combinations. + if (!fillPlaceBases(transition.placeBases, inputPlacesWithTokenValues)) { + throw new SDCPNItemError( + `The compiled program for transition \`${transition.name}\` does not match the net (input arc count changed). Recompile the artifacts from the current net.`, + transition.id, + ); + } - // Convert tokens for this place to objects with named dimensions - const placeTokens = placeTokenIndices.map((tokenIndexInPlace) => - readTokenRecord( - tokenLayout, - tokenViews, - placeByteOffset + tokenIndexInPlace * strideBytes, - simulation.stringPool, - ), + for (const tokenCombinationIndices of tokensCombinations) { + if (!fillTokenIndices(transition.indices, tokenCombinationIndices)) { + throw new SDCPNItemError( + `The compiled program for transition \`${transition.name}\` does not match the net (input token slot count changed). Recompile the artifacts from the current net.`, + transition.id, ); - - tokenCombinationValues[inputPlace.placeName] = placeTokens; } // Approximate by just multiplying by elapsed time since last transition, @@ -145,12 +127,18 @@ export function computePossibleTransition( // which should be reordered in case of new tokens arriving. let lambdaResult: ReturnType; try { - lambdaResult = transition.lambdaFn(tokenCombinationValues); + lambdaResult = transition.lambdaFn( + tokenViews.f64, + tokenViews.u64, + tokenViews.u8, + transition.placeBases, + transition.indices, + ); } catch (err) { throw new SDCPNItemError( - `Error while executing lambda function for transition \`${transition.name}\`:\n\n${ - (err as Error).message - }\n\nInput:\n${describeTokenValuesForError(tokenCombinationValues)}`, + `Error while executing lambda function for transition \`${ + transition.name + }\`:\n\n${(err as Error).message}`, transition.id, ); } @@ -168,77 +156,40 @@ export function computePossibleTransition( // Find the first combination of tokens where e^(-lambda) < U1 // We should normally find the minimum for all possibilities, but we try to reduce as much as we can here. if (Math.exp(-lambdaValue) <= U1) { - let transitionKernelOutput: ReturnType< - typeof transition.transitionKernelFn - >; - try { - // Transition fires! - // Return result of the transition kernel as is (no stochasticity for now, only one result) - transitionKernelOutput = transition.transitionKernelFn( - tokenCombinationValues, - ); - } catch (err) { - throw new SDCPNItemError( - `Error while executing transition kernel for transition \`${transition.name}\`:\n\n${ - (err as Error).message - }\n\nInput:\n${describeTokenValuesForError(tokenCombinationValues)}`, - transition.id, - ); - } - - // Convert transition kernel output back to place-indexed format - // The kernel returns { PlaceName: [{ x: 0, y: 0 }, ...], ... } - // We need to convert this to place IDs and pack each token into its - // stride-sized byte block. - // Distribution values are sampled here, advancing the RNG state. - const addMap: Record = {}; + // Transition fires! The compiled kernel writes output tokens into the + // transition's staging bytes; Distribution/uuid values are resolved + // through the kernel sink, advancing the RNG state. + let addMap: Record; let currentRngState = newRngState; - for (const outputPlace of transition.outputPlaces) { - const outputPlaceState = snapshot.places[outputPlace.placeId]; - if (!outputPlaceState) { - throw new Error( - `Output place with ID ${outputPlace.placeId} not found in frame`, - ); - } - - // If place has no type, create n empty blocks where n is the arc weight - if (!outputPlace.tokenLayout) { + if (transition.kernelFn === null) { + // No colored output places — every output gets `weight` empty blocks. + addMap = {}; + for (const outputPlace of transition.outputPlaces) { addMap[outputPlace.placeId] = Array.from( { length: outputPlace.weight }, () => EMPTY_TOKEN_BYTES, ); - continue; } - - const outputTokens = transitionKernelOutput[outputPlace.placeName]; - - if (!outputTokens) { - throw new SDCPNItemError( - `Transition kernel for transition \`${transition.name}\` did not return tokens for place "${outputPlace.placeName}"`, - transition.id, - ); - } - - // Resolve Distribution samples and uuid values using the RNG (in - // element declaration order), then pack each token into a - // stride-sized byte block. - const tokenBlocks: Uint8Array[] = []; - for (const token of outputTokens) { - const { bytes, nextRngState } = encodeKernelOutputToken({ - token, - elements: outputPlace.elements ?? [], - tokenLayout: outputPlace.tokenLayout, - rngState: currentRngState, - transitionId: transition.id, - placeName: outputPlace.placeName, - stringPool: simulation.stringPool, + } else { + try { + const { add, newRngState: rngAfterKernel } = executeBufferKernel({ + transition, + views: tokenViews, + rngState: newRngState, }); - currentRngState = nextRngState; - tokenBlocks.push(bytes); + addMap = add; + currentRngState = rngAfterKernel; + } catch (err) { + throw err instanceof SDCPNItemError + ? err + : new SDCPNItemError( + `Error while executing transition kernel for transition \`${ + transition.name + }\`:\n\n${(err as Error).message}`, + transition.id, + ); } - - addMap[outputPlace.placeId] = tokenBlocks; } return { diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/encode-kernel-token.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/encode-kernel-token.ts deleted file mode 100644 index 0e2ef3a88f4..00000000000 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/encode-kernel-token.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { isDistribution } from "../authoring/user-code/distribution"; -import { isUuidSentinel } from "../authoring/user-code/uuid-runtime"; -import { sampleDistribution } from "./sample-distribution"; -import { encodeTokenValuesToBytes } from "./token-layout"; -import { encodeTokenAttributeValue } from "./token-values"; -import { generateUuidFromRng, toUuid } from "./uuid"; - -import type { Color } from "../../types/sdcpn"; -import type { StringPoolWriter, TokenSlotLayout } from "./token-layout"; -import type { TransitionKernelOutput } from "./types"; - -type ColorElement = Color["elements"][number]; - -type KernelOutputToken = TransitionKernelOutput[string][number]; - -/** - * Resolves one transition kernel output token into a stride-sized byte block, - * consuming RNG state where needed. Values are resolved in element - * declaration order so RNG consumption stays deterministic per seed: - * - * - `Distribution` values are sampled (only valid for `real` elements — - * discrete elements, including `uuid`, throw). - * - `uuid` elements: omitted (`undefined`) or `Uuid.generate()` draws a fresh - * UUID from the seeded RNG; `Uuid.from(value)` and plain values are coerced - * via the total `toUuid` conversion. Forwarding an input token's uuid is - * plain bigint pass-through. - * - `string` elements: the value stringifies (`undefined`/`null` → `""`) and - * is interned into the run's `stringPool`; the buffer stores the pool ID. - * - Everything else goes through the shared number-slot codec. - */ -export function encodeKernelOutputToken({ - token, - elements, - tokenLayout, - rngState, - transitionId, - placeName, - stringPool, -}: { - token: KernelOutputToken; - elements: readonly ColorElement[]; - tokenLayout: TokenSlotLayout; - rngState: number; - transitionId: string; - placeName: string; - stringPool: StringPoolWriter; -}): { bytes: Uint8Array; nextRngState: number } { - let currentRngState = rngState; - const encodedByName: Record = {}; - - for (const element of elements) { - const raw = token[element.name]; - - if (isDistribution(raw)) { - if (element.type !== "real") { - throw new Error( - `Transition ${transitionId} produced a distribution for discrete element ${element.name}.`, - ); - } - const [sampled, nextRng] = sampleDistribution(raw, currentRngState); - currentRngState = nextRng; - encodedByName[element.name] = sampled; - continue; - } - - if (element.type === "string") { - // Untyped user code may hand back anything, including null. - const rawValue: unknown = raw; - const text = - rawValue === undefined || rawValue === null - ? "" - : typeof rawValue === "string" - ? rawValue - : // eslint-disable-next-line typescript/no-base-to-string -- intentional: total, deterministic stringification - String(rawValue); - encodedByName[element.name] = BigInt(stringPool.intern(text)); - continue; - } - - if (element.type === "uuid") { - if ( - raw === undefined || - (isUuidSentinel(raw) && raw.__petrinautUuid === "generate") - ) { - const [generated, nextRng] = generateUuidFromRng(currentRngState); - currentRngState = nextRng; - encodedByName[element.name] = generated; - } else if (isUuidSentinel(raw)) { - encodedByName[element.name] = toUuid(raw.value); - } else { - encodedByName[element.name] = toUuid(raw); - } - continue; - } - - encodedByName[element.name] = encodeTokenAttributeValue( - element, - raw, - `Transition ${transitionId} output ${placeName}.${element.name}`, - ); - } - - return { - bytes: encodeTokenValuesToBytes(tokenLayout, encodedByName), - nextRngState: currentRngState, - }; -} diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/execute-transitions.test.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/execute-transitions.test.ts index 81ec85ad87a..efae265035c 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/execute-transitions.test.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/execute-transitions.test.ts @@ -1,27 +1,27 @@ +/* eslint-disable no-param-reassign -- buffer-ABI kernel mocks write into the + staging views they receive */ import { describe, expect, it } from "vitest"; -import { getArcEndpointPlaceId } from "../../arc-endpoints"; import { createEngineFrameLayout, materializeEngineFrame, type EngineFrameSnapshot, } from "../frames/internal-frame"; +import { makeCompiledTransition } from "./compiled-transition.test-helpers"; import { executeTransitions as executeEngineTransitions } from "./execute-transitions"; import { StringPool } from "./string-pool"; -import { computeTokenSlotLayout } from "./token-layout"; import { decodePlaceTokens, makeTestFrame, type TestFrame, } from "./token-layout.test-helpers"; -import type { Color, Place, Transition } from "../../types/sdcpn"; import type { - CompiledTransition, - LambdaFn, - SimulationInstance, - TransitionKernelFn, -} from "./types"; + HirCompiledBufferKernel, + HirCompiledBufferLambda, +} from "../../hir-runtime"; +import type { Color, Place, Transition } from "../../types/sdcpn"; +import type { SimulationInstance } from "./types"; const type1: Color = { id: "type1", @@ -67,94 +67,26 @@ function makeTransition( return { name: "Transition", lambdaType: "stochastic", - lambdaCode: "return 1.0;", - transitionKernelCode: "return {};", + lambdaCode: "", + transitionKernelCode: "", x: 0, y: 0, ...transition, }; } -function makeCompiledTransitions({ - places, - transitions, - types, - lambdaFns, - transitionKernelFns, -}: { - places: Place[]; - transitions: Transition[]; - types: Color[]; - lambdaFns: ReadonlyMap; - transitionKernelFns: ReadonlyMap; -}): Map { - const placesMap = new Map(places.map((place) => [place.id, place])); - const typesMap = new Map(types.map((type) => [type.id, type])); - const getElements = (placeId: string) => { - const place = placesMap.get(placeId); - if (!place?.colorId) { - return null; - } - - return typesMap.get(place.colorId)?.elements ?? null; - }; - - return new Map( - transitions.map((transition) => { - const lambdaFn = lambdaFns.get(transition.id); - const transitionKernelFn = transitionKernelFns.get(transition.id); - if (!lambdaFn || !transitionKernelFn) { - throw new Error(`Missing compiled functions for ${transition.id}`); - } - - return [ - transition.id, - { - id: transition.id, - name: transition.name, - inputPlaces: transition.inputArcs.map((arc) => { - const placeId = getArcEndpointPlaceId(arc)!; - const elements = getElements(placeId); - return { - placeId, - placeName: placesMap.get(placeId)?.name ?? placeId, - weight: arc.weight, - arcType: arc.type, - elements, - tokenLayout: elements ? computeTokenSlotLayout(elements) : null, - }; - }), - outputPlaces: transition.outputArcs.map((arc) => { - const placeId = getArcEndpointPlaceId(arc)!; - const elements = getElements(placeId); - return { - placeId, - placeName: placesMap.get(placeId)?.name ?? placeId, - weight: arc.weight, - elements, - tokenLayout: elements ? computeTokenSlotLayout(elements) : null, - }; - }), - lambdaFn, - transitionKernelFn, - }, - ]; - }), - ); -} - function makeSimulation({ places = [], transitions, types = [], lambdaFns, - transitionKernelFns, + kernelFns, }: { places?: Place[]; transitions: Transition[]; types?: Color[]; - lambdaFns: ReadonlyMap; - transitionKernelFns: ReadonlyMap; + lambdaFns: ReadonlyMap; + kernelFns?: ReadonlyMap; }): SimulationInstance { const frameLayout = createEngineFrameLayout({ places, @@ -169,13 +101,24 @@ function makeSimulation({ ), types: new Map(types.map((type) => [type.id, type])), differentialEquationFns: new Map(), - compiledTransitions: makeCompiledTransitions({ - places, - transitions, - types, - lambdaFns, - transitionKernelFns, - }), + compiledTransitions: new Map( + transitions.map((transition) => { + const lambdaFn = lambdaFns.get(transition.id); + if (!lambdaFn) { + throw new Error(`Missing compiled lambda for ${transition.id}`); + } + return [ + transition.id, + makeCompiledTransition({ + transition, + places, + types, + lambdaFn, + kernelFn: kernelFns?.get(transition.id) ?? null, + }), + ]; + }), + ), parameterValues: {}, dt: 0.1, maxTime: null, @@ -211,6 +154,15 @@ function executeTransitions( }; } +/** Buffer-ABI kernel mock writing one constant real token per output slot. */ +const constantKernel = + (...values: number[]): HirCompiledBufferKernel => + (_f64, _u64, _u8, _placeBases, _indices, outF64) => { + for (const [index, value] of values.entries()) { + outF64[index] = value; + } + }; + describe("executeTransitions", () => { it("returns the original frame when no transitions can fire", () => { const transition = makeTransition({ @@ -221,9 +173,6 @@ describe("executeTransitions", () => { const simulation = makeSimulation({ transitions: [transition], lambdaFns: new Map([["t1", () => 1.0]]), - transitionKernelFns: new Map([ - ["t1", () => ({ p2: [{ x: 1.0 }] })], - ]), }); const frame = makeTestFrame({ places: { @@ -250,8 +199,6 @@ describe("executeTransitions", () => { id: "t1", inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], outputArcs: [{ placeId: "p2", weight: 1 }], - lambdaCode: "return 10.0;", - transitionKernelCode: "return [[[2.0]]];", }); const simulation = makeSimulation({ places: [ @@ -261,9 +208,7 @@ describe("executeTransitions", () => { transitions: [transition], types: [type1], lambdaFns: new Map([["t1", () => 10.0]]), - transitionKernelFns: new Map([ - ["t1", () => ({ "Place 2": [{ x: 2.0 }] })], - ]), + kernelFns: new Map([["t1", constantKernel(2.0)]]), }); const frame = makeTestFrame({ places: { @@ -303,8 +248,6 @@ describe("executeTransitions", () => { ], outputArcs: [{ placeId: "p3", weight: 1 }], lambdaType: "predicate", - lambdaCode: "return true;", - transitionKernelCode: "return { Target: input.Guard };", }); const simulation = makeSimulation({ places: [ @@ -315,15 +258,12 @@ describe("executeTransitions", () => { transitions: [transition], types: [type1], lambdaFns: new Map([["t1", () => true]]), - transitionKernelFns: new Map([ + kernelFns: new Map([ [ "t1", - (input) => { - const guardToken = input.Guard?.[0]; - if (guardToken?.x === undefined) { - throw new Error("Expected read arc token"); - } - return { Target: [{ x: guardToken.x }] }; + (f64, _u64, _u8, placeBases, indices, outF64) => { + // Forward the read arc (Guard) token's x — type1 stride is 8. + outF64[0] = f64[(placeBases[1]! + indices[1]! * 8) / 8]!; }, ], ]), @@ -365,15 +305,11 @@ describe("executeTransitions", () => { id: "t1", inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], outputArcs: [{ placeId: "p2", weight: 1 }], - lambdaCode: "return 10.0;", - transitionKernelCode: "return [[[5.0]]];", }), makeTransition({ id: "t2", inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], outputArcs: [{ placeId: "p3", weight: 1 }], - lambdaCode: "return 10.0;", - transitionKernelCode: "return [[[10.0]]];", }), ]; const simulation = makeSimulation({ @@ -388,9 +324,9 @@ describe("executeTransitions", () => { ["t1", () => 10.0], ["t2", () => 10.0], ]), - transitionKernelFns: new Map([ - ["t1", () => ({ "Place 2": [{ x: 5.0 }] })], - ["t2", () => ({ "Place 3": [{ x: 10.0 }] })], + kernelFns: new Map([ + ["t1", constantKernel(5.0)], + ["t2", constantKernel(10.0)], ]), }); const frame = makeTestFrame({ @@ -427,8 +363,6 @@ describe("executeTransitions", () => { id: "t1", inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], outputArcs: [{ placeId: "p2", weight: 1 }], - lambdaCode: "return 10.0;", - transitionKernelCode: "return [[[3.0, 4.0]]];", }); const simulation = makeSimulation({ places: [ @@ -438,9 +372,7 @@ describe("executeTransitions", () => { transitions: [transition], types: [type2], lambdaFns: new Map([["t1", () => 10.0]]), - transitionKernelFns: new Map([ - ["t1", () => ({ "Place 2": [{ x: 3.0, y: 4.0 }] })], - ]), + kernelFns: new Map([["t1", constantKernel(3.0, 4.0)]]), }); const frame = makeTestFrame({ places: { @@ -472,15 +404,11 @@ describe("executeTransitions", () => { id: "t1", inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], outputArcs: [{ placeId: "p2", weight: 1 }], - lambdaCode: "return 10.0;", - transitionKernelCode: "return [[[2.0]]];", }), makeTransition({ id: "t2", inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], outputArcs: [{ placeId: "p2", weight: 1 }], - lambdaCode: "return 0.001;", - transitionKernelCode: "return [[[3.0]]];", }), ]; const simulation = makeSimulation({ @@ -494,9 +422,9 @@ describe("executeTransitions", () => { ["t1", () => 10.0], ["t2", () => 0.001], ]), - transitionKernelFns: new Map([ - ["t1", () => ({ "Place 2": [{ x: 2.0 }] })], - ["t2", () => ({ "Place 2": [{ x: 3.0 }] })], + kernelFns: new Map([ + ["t1", constantKernel(2.0)], + ["t2", constantKernel(3.0)], ]), }); const frame = makeTestFrame({ diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/token-values.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/token-values.ts index 0a674646f1e..0245f4c58e3 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/token-values.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/token-values.ts @@ -110,8 +110,7 @@ export function decodeTokenAttributeValue( * the two-lane writer in `token-layout.ts`). * * `string` elements are not encodable without a `StringPool` and throw here — - * callers with string fields intern first (`encodeTokenToBytes` / - * `encodeKernelOutputToken`). + * callers with string fields intern first (`encodeTokenToBytes`). */ export function encodeTokenAttributeValue( element: ColorElement, diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/types.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/types.ts index 99f7c8a3564..b2b6e68089a 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/types.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/types.ts @@ -6,21 +6,22 @@ */ import type { PetrinautExtensionSettings } from "../../extensions"; +import type { + HirArtifacts, + HirCompiledBufferKernel, + HirCompiledBufferLambda, +} from "../../hir-runtime"; import type { Color, InputArcType, Place, SDCPN, - TokenAttributeValue, - TokenRecord, Transition, } from "../../types/sdcpn"; import type { InitialMarking } from "../api"; -import type { RuntimeDistribution } from "../authoring/user-code/distribution"; -import type { UuidSentinel } from "../authoring/user-code/uuid-runtime"; import type { EngineFrame, EngineFrameLayout } from "../frames/internal-frame"; import type { StringPool } from "./string-pool"; -import type { TokenSlotLayout } from "./token-layout"; +import type { TokenRegionViews, TokenSlotLayout } from "./token-layout"; /** * Runtime parameter values used during simulation execution. @@ -45,40 +46,6 @@ export type DifferentialEquationFn = ( numberOfTokens: number, ) => Float64Array; -export type TransitionTokenValues = Record; -/** - * Kernel output tokens keyed by output place name. `uuid` attributes may be - * omitted (`undefined` — the engine auto-generates a UUID from the seeded - * RNG) or produced via the `Uuid.generate()` / `Uuid.from(value)` sentinels. - */ -export type TransitionKernelOutput = Record< - string, - Record< - string, - TokenAttributeValue | RuntimeDistribution | UuidSentinel | undefined - >[] ->; - -/** - * Engine-facing lambda function for transition firing probability. - * - * Runtime parameter values are already bound by `buildSimulation`. - * - * Returns a rate (number) for stochastic transitions or a boolean for predicate transitions. - */ -export type LambdaFn = (tokenValues: TransitionTokenValues) => number | boolean; - -/** - * Engine-facing transition kernel function for token generation. - * - * Runtime parameter values are already bound by `buildSimulation`. - * - * Computes the output tokens to create when a transition fires. - */ -export type TransitionKernelFn = ( - tokenValues: TransitionTokenValues, -) => TransitionKernelOutput; - export type CompiledTransitionPlace = { placeId: string; placeName: string; @@ -93,13 +60,33 @@ export type CompiledTransitionInputPlace = CompiledTransitionPlace & { arcType: InputArcType; }; +/** + * One transition, compiled to buffer-ABI HIR programs plus reusable scratch. + * The scratch arrays are shared across evaluations — the engine is + * single-threaded per simulation instance. + */ export type CompiledTransition = { id: string; name: string; inputPlaces: readonly CompiledTransitionInputPlace[]; outputPlaces: readonly CompiledTransitionPlace[]; - lambdaFn: LambdaFn; - transitionKernelFn: TransitionKernelFn; + /** Buffer-ABI lambda `(f64, u64, u8, placeBases, indices) => number | + * boolean` (token format v2 packed structs); parameters/pool pre-bound. */ + lambdaFn: HirCompiledBufferLambda; + /** Buffer-ABI kernel writing into `kernelStaging`, or null when the + * transition has no colored output places. */ + kernelFn: HirCompiledBufferKernel | null; + /** Reusable scratch (single-threaded per instance): one base BYTE offset + * per colored non-inhibitor input arc, in arc order. */ + placeBases: Int32Array; + /** Reusable scratch: one selected token index per input token slot (sum of + * those arcs' weights — see `hir/surface-context.ts` slot layout). */ + indices: Int32Array; + /** Reusable kernel output staging: colored output arcs place-major, tokens + * back-to-back (`strideBytes` each). */ + kernelStaging: Uint8Array; + /** f64/u64/u8 views over `kernelStaging` (see `createTokenRegionViews`). */ + kernelStagingViews: TokenRegionViews; }; /** @@ -120,6 +107,13 @@ export type SimulationInput = { dt: number; /** Maximum simulation time (immutable once set). Null means no limit. */ maxTime: number | null; + /** + * Optional precompiled HIR artifacts (see `compileHirArtifacts`). When an + * item has an artifact it is instantiated directly. Items with required user + * code and no artifact fail to build. Artifacts must be produced from the + * same SDCPN snapshot. + */ + hirArtifacts?: HirArtifacts; }; /** diff --git a/libs/@hashintel/petrinaut-core/src/simulation/frames/frame-reader.ts b/libs/@hashintel/petrinaut-core/src/simulation/frames/frame-reader.ts index d5558c3a3ea..c5f7bf5cb5d 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/frames/frame-reader.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/frames/frame-reader.ts @@ -26,7 +26,16 @@ function createSimulationFrameReader( number, time, getPlaceTokenCount, - getPlaceTokens(place) { + getRawView() { + return { + ...frameView.tokenViews, + placeCounts: frameView.placeCounts, + placeOffsets: frameView.placeByteOffsets, + placeIndexById: layout.placeIndexById, + ...(stringPool ? { stringPool } : {}), + }; + }, + getPlaceTokens(place, _color) { const placeState = frameView.getPlaceState(place.id); if (!placeState) { return []; diff --git a/libs/@hashintel/petrinaut-core/src/simulation/frames/hir-metric.ts b/libs/@hashintel/petrinaut-core/src/simulation/frames/hir-metric.ts new file mode 100644 index 00000000000..046244a2863 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/simulation/frames/hir-metric.ts @@ -0,0 +1,84 @@ +import { + instantiateHirMetric, + type HirCompiledMetric, + type HirMetricArtifact, +} from "../../hir/instantiate"; + +import type { Place } from "../../types/sdcpn"; +import type { SimulationFrameReader } from "../api"; + +/** + * Binds a compiled HIR metric artifact to frame readers. + * + * The returned evaluator resolves the artifact's referenced place names to + * frame place indices once (on the first frame — the layout is stable per + * simulation/experiment), instantiates the program once, and then runs it + * against each frame's raw buffer view. Throws (with the metric label) when + * a referenced place does not exist, when the frame source exposes no raw + * buffer access, or when the program returns a non-finite value — mirroring + * the legacy sandboxed-metric error semantics. + */ +export function createHirMetricEvaluator(args: { + /** Display name used in error messages (metric label or name). */ + metricName: string; + artifact: HirMetricArtifact; + /** Root-net places, used to resolve place display names to ids. */ + places: readonly Pick[]; +}): (frame: SimulationFrameReader) => number { + const { metricName, artifact, places } = args; + // Last place wins for duplicate names, matching the HIR metric context. + const placeIdByName = new Map(places.map((place) => [place.name, place.id])); + + let program: HirCompiledMetric | null = null; + let currentPool: { get(id: number): string } | null = null; + // The per-run string pool can differ between frames (Monte-Carlo runs own + // one each), so the program binds this stable adapter instead. + const poolAdapter = { + get: (id: number): string => currentPool?.get(id) ?? "", + }; + + return (frame) => { + const raw = frame.getRawView?.(); + if (!raw) { + throw new Error( + `Metric "${metricName}" cannot run here — this frame source does not expose raw buffer access.`, + ); + } + + if (!program) { + const placeIndices = new Int32Array(artifact.placeNames.length); + for (const [ordinal, placeName] of artifact.placeNames.entries()) { + const placeId = placeIdByName.get(placeName); + const placeIndex = + placeId === undefined ? undefined : raw.placeIndexById.get(placeId); + if (placeIndex === undefined) { + throw new Error( + `Metric "${metricName}" reads place "${placeName}", which does not exist in this simulation.`, + ); + } + placeIndices[ordinal] = placeIndex; + } + program = instantiateHirMetric( + artifact.source, + placeIndices, + poolAdapter, + ); + } + + currentPool = raw.stringPool ?? null; + const result = program( + raw.f64, + raw.u64, + raw.u8, + raw.placeCounts, + raw.placeOffsets, + ); + currentPool = null; + if (typeof result !== "number" || !Number.isFinite(result)) { + throw new Error( + `Metric "${metricName}" returned ${String(result)}, expected a finite number.`, + ); + } + return result; + }; +} diff --git a/libs/@hashintel/petrinaut-core/src/simulation/frames/internal-frame.ts b/libs/@hashintel/petrinaut-core/src/simulation/frames/internal-frame.ts index 88d4288a041..7bd27816fe3 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/frames/internal-frame.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/frames/internal-frame.ts @@ -66,6 +66,10 @@ export type EngineFrameView = { tokenBytes: Uint8Array; /** Shared f64/u64/u8 views over the whole token region (region offset/length are 8-aligned). */ tokenViews: TokenRegionViews; + /** Dense per-place token counts, indexed by frame place index. */ + placeCounts: Uint32Array; + /** Dense per-place byte offsets into the token region. */ + placeByteOffsets: Uint32Array; getPlaceState(placeId: ID): EngineFramePlaceState | null; getPlaceEntries(): [ID, EngineFramePlaceState][]; getTransitionState(transitionId: ID): SimulationTransitionState | null; @@ -466,6 +470,8 @@ export function readEngineFrame( return { tokenBytes, tokenViews, + placeCounts, + placeByteOffsets: placeValueOffsets, getPlaceState, getPlaceEntries, getTransitionState, diff --git a/libs/@hashintel/petrinaut-core/src/simulation/frames/metric-state.ts b/libs/@hashintel/petrinaut-core/src/simulation/frames/metric-state.ts deleted file mode 100644 index f2ec6325f60..00000000000 --- a/libs/@hashintel/petrinaut-core/src/simulation/frames/metric-state.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { Place } from "../../types/sdcpn"; -import type { SimulationFrameReader } from "../api"; -import type { MetricState } from "../authoring/metric/compile-metric"; - -/** - * Reshape a simulation frame reader into the `MetricState` shape exposed to - * compiled metric functions. Place state is keyed by place **name** so author - * code can read e.g. `state.places.Infected.count`. - */ -export function buildMetricState( - frame: SimulationFrameReader, - places: Place[], -): MetricState { - const placesByName: Record = {}; - - for (const place of places) { - placesByName[place.name] = { - count: frame.getPlaceTokenCount(place.id), - tokens: frame.getPlaceTokens(place), - }; - } - - return { places: placesByName }; -} diff --git a/libs/@hashintel/petrinaut-core/src/simulation/index.ts b/libs/@hashintel/petrinaut-core/src/simulation/index.ts index dcc2d86c66a..71f1cd82472 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/index.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/index.ts @@ -9,6 +9,7 @@ export type { SimulationConfig, SimulationErrorEvent, SimulationEvent, + SimulationFrameRawView, SimulationFrameReader, SimulationFrameState, SimulationFrameSummary, diff --git a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/frame-reader.ts b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/frame-reader.ts index 29c9e725de8..cbd232f3c92 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/frame-reader.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/frame-reader.ts @@ -1,6 +1,6 @@ import { readTokenRecord } from "../engine/token-layout"; -import type { Place, TokenRecord } from "../../types/sdcpn"; +import type { TokenRecord } from "../../types/sdcpn"; import type { SimulationFrameReader, SimulationFrameState } from "../api"; import type { MonteCarloRunState } from "./internal-types"; @@ -21,7 +21,16 @@ export function createMonteCarloFrameReader( number: run.frameNumber, time: run.frameNumber * simulation.dt, getPlaceTokenCount, - getPlaceTokens(place: Place) { + getRawView() { + return { + ...currentFrame.tokenViews, + placeCounts: currentFrame.placeCounts, + placeOffsets: currentFrame.placeOffsets, + placeIndexById: frameLayout.placeIndexById, + stringPool: simulation.stringPool, + }; + }, + getPlaceTokens(place) { const placeIndex = frameLayout.placeIndexById.get(place.id); if (placeIndex === undefined) { return []; diff --git a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/metrics/specs.ts b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/metrics/specs.ts index f05e90d491c..936d2f2b990 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/metrics/specs.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/metrics/specs.ts @@ -1,7 +1,6 @@ -import { compileMetric } from "../../authoring/metric/compile-metric"; -import { buildMetricState } from "../../frames/metric-state"; +import { createHirMetricEvaluator } from "../../frames/hir-metric"; -import type { Metric, SDCPN } from "../../../types/sdcpn"; +import type { SDCPN } from "../../../types/sdcpn"; import type { MonteCarloMetricSpec, MonteCarloMetricSpecBase, @@ -31,20 +30,21 @@ function createExpressionMetricConfig( spec: Extract, sdcpn: SDCPN, ): MonteCarloUserDefinedMetricConfig { - const metric: Metric = { - id: spec.id, - name: spec.label, - code: spec.code, - }; - const compiled = compileMetric(metric); - - if (!compiled.ok) { - throw new Error(compiled.error); + // Expression metrics run exclusively as HIR-compiled buffer programs. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- guards specs built before artifact threading (e.g. persisted configs) + if (!spec.artifact) { + throw new Error( + `Metric "${spec.label}" has no compiled artifact — expression metrics must be compiled through the HIR before starting an experiment.`, + ); } - return applyMetricSpecBase(spec, ({ frame }) => - compiled.fn(buildMetricState(frame, sdcpn.places)), - ); + const evaluate = createHirMetricEvaluator({ + metricName: spec.label, + artifact: spec.artifact, + places: sdcpn.places, + }); + + return applyMetricSpecBase(spec, ({ frame }) => evaluate(frame)); } export function createMonteCarloUserDefinedMetricConfigsFromSpecs( diff --git a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/metrics/types.ts b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/metrics/types.ts index 2405680ce0a..b89bf67c520 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/metrics/types.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/metrics/types.ts @@ -1,3 +1,4 @@ +import type { HirMetricArtifact } from "../../../hir-runtime"; import type { SimulationFrameReader } from "../../api"; export type MonteCarloMetricRunStatus = @@ -148,10 +149,16 @@ export type MonteCarloTransitionFiringCountMetricSpec = export type MonteCarloExpressionMetricSpec = MonteCarloMetricSpecBase & { kind: "expression"; /** - * Function body invoked with the same `state` object as persisted timeline - * metrics. It must `return` a finite number. + * Function body over the same `state` object as persisted timeline + * metrics. It must `return` a finite number. Kept for display/persistence; + * execution uses only `artifact`. */ code: string; + /** + * HIR-compiled buffer program for `code` (from `compileHirArtifacts`). + * This is the only execution path — specs without an artifact cannot run. + */ + artifact: HirMetricArtifact; }; export type MonteCarloMetricSpec = diff --git a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/monte-carlo-simulator.test.ts b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/monte-carlo-simulator.test.ts index e44ba829264..220d20cf888 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/monte-carlo-simulator.test.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/monte-carlo-simulator.test.ts @@ -1,9 +1,27 @@ import { describe, expect, it } from "vitest"; -import { createMonteCarloUserDefinedMetric } from "./metrics"; -import { createMonteCarloSimulator } from "./monte-carlo-simulator"; +import { compileHirArtifacts } from "../../hir"; +import { + createMonteCarloUserDefinedMetric, + createMonteCarloUserDefinedMetricConfigsFromSpecs, +} from "./metrics"; +import { createMonteCarloSimulator as createMonteCarloSimulatorRaw } from "./monte-carlo-simulator"; import type { SDCPN } from "../../types/sdcpn"; +import type { MonteCarloSimulatorConfig } from "./types"; + +/** createMonteCarloSimulator with HIR artifacts compiled from the config's + * SDCPN (the engine no longer compiles user code itself). */ +function createMonteCarloSimulator( + config: MonteCarloSimulatorConfig, +): ReturnType { + return createMonteCarloSimulatorRaw({ + ...config, + hirArtifacts: + config.hirArtifacts ?? + compileHirArtifacts(config.sdcpn, config.extensions).artifacts, + }); +} const sdcpn: SDCPN = { types: [ @@ -464,4 +482,112 @@ describe("MonteCarloSimulator", () => { expect(run.placeTokenCounts).toMatchObject({ pending: 0, done: 1 }); expect(shippedCountMetric.getLatestFrame()).toMatchObject({ value: 1 }); }); + + it("runs HIR-compiled expression metrics against the raw frame buffers", () => { + const metricCode = `const done = state.places.Done.tokens; +const backlog = state.places.Pending.tokens.concat(done); +if (backlog.length === 0) return -1; +return done.reduce( + (sum, order) => order.status === "shipped" ? sum + order.value : sum, + 0, +) + backlog.length;`; + + const expressionSdcpn: SDCPN = { + types: [ + { + id: "type-order", + name: "Order", + iconSlug: "circle", + displayColor: "#00FF00", + elements: [ + { elementId: "status", name: "status", type: "string" }, + { elementId: "value", name: "value", type: "real" }, + ], + }, + ], + places: [ + { + id: "pending", + name: "Pending", + colorId: "type-order", + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + { + id: "done", + name: "Done", + colorId: "type-order", + dynamicsEnabled: false, + differentialEquationId: null, + x: 100, + y: 0, + }, + ], + transitions: [ + { + id: "ship", + name: "Ship", + inputArcs: [{ placeId: "pending", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "done", weight: 1 }], + lambdaType: "predicate", + lambdaCode: + 'export default Lambda((input) => input.Pending[0].status === "queued");', + transitionKernelCode: + 'export default TransitionKernel((input) => ({ Done: [{ status: "shipped", value: input.Pending[0].value }] }));', + x: 50, + y: 0, + }, + ], + differentialEquations: [], + parameters: [], + metrics: [ + { id: "shipped-value", name: "Shipped value", code: metricCode }, + ], + }; + + const { artifacts, failures } = compileHirArtifacts(expressionSdcpn); + expect(failures).toEqual([]); + const artifact = artifacts.metrics["shipped-value"]; + expect(artifact).toBeDefined(); + + const [config] = createMonteCarloUserDefinedMetricConfigsFromSpecs( + [ + { + id: "shipped-value", + label: "Shipped value", + kind: "expression", + code: metricCode, + artifact: artifact!, + sampleRuns: "all", + aggregateRuns: "last", + aggregateTime: "none", + }, + ], + expressionSdcpn, + ); + const metric = createMonteCarloUserDefinedMetric(config!); + + const simulator = createMonteCarloSimulator({ + sdcpn: expressionSdcpn, + hirArtifacts: artifacts, + runCount: 2, + initialMarking: { + pending: [{ status: "queued", value: 7 }], + }, + seed: 100, + dt: 1, + maxTime: 10, + metrics: [metric], + }); + + // Frame 0: nothing shipped yet — one pending order → 0 + 1. + expect(metric.getLatestFrame()).toMatchObject({ value: 1 }); + + const result = simulator.runUntilComplete({ maxBatches: 20 }); + expect(result.allFinished).toBe(true); + // Shipped order value 7 + one order total in the backlog concat. + expect(metric.getLatestFrame()).toMatchObject({ value: 8 }); + }); }); diff --git a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/run-state.ts b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/run-state.ts index f35f2135025..97624284ae8 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/run-state.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/run-state.ts @@ -95,6 +95,7 @@ export function createRunState( seed, dt: config.dt, maxTime: config.maxTime, + hirArtifacts: config.hirArtifacts, }); const initialFrame = simulation.frames[0]; if (!initialFrame) { diff --git a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/runtime/experiment.test.ts b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/runtime/experiment.test.ts index d4676ba16ad..525c81e63ba 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/runtime/experiment.test.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/runtime/experiment.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it, vi } from "vitest"; +import { compileHirArtifacts } from "../../../hir/compile"; import { createMonteCarloExperiment } from "./experiment"; +import type { HirMetricArtifact } from "../../../hir/instantiate"; import type { SDCPN } from "../../../types/sdcpn"; import type { SimulationTransport } from "../../api"; import type { MonteCarloUserDefinedMetricFrame } from "../metrics"; @@ -19,6 +21,25 @@ const empty = (): SDCPN => ({ differentialEquations: [], }); +/** Compiles a metric body to its HIR buffer artifact (expression specs are + * artifact-only at runtime). */ +function metricArtifact( + code: string, + sdcpn: SDCPN = empty(), +): HirMetricArtifact { + const { artifacts, failures } = compileHirArtifacts({ + ...sdcpn, + metrics: [{ id: "__test__", name: "test", code }], + }); + const artifact = artifacts.metrics.__test__; + if (!artifact) { + throw new Error( + `Metric did not compile: ${JSON.stringify(failures, null, 2)}`, + ); + } + return artifact; +} + function makeProgress( overrides: Partial = {}, ): MonteCarloWorkerProgress { @@ -164,6 +185,7 @@ describe("createMonteCarloExperiment", () => { label: "Constant", kind: "expression", code: "return 1;", + artifact: metricArtifact("return 1;"), }, ] as const; const promise = createMonteCarloExperiment({ @@ -272,6 +294,7 @@ describe("createMonteCarloExperiment", () => { label: "Constant", kind: "expression", code: "return 1;", + artifact: metricArtifact("return 1;"), sampleRuns: "all", aggregateRuns: "mean", aggregateTime: "none", @@ -303,6 +326,7 @@ describe("createMonteCarloExperiment", () => { label: "Constant distribution", kind: "expression", code: "return 1;", + artifact: metricArtifact("return 1;"), sampleRuns: "all", runOutput: { type: "distribution" }, }, diff --git a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/runtime/experiment.ts b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/runtime/experiment.ts index 4707570bcdb..daed29c3f20 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/runtime/experiment.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/runtime/experiment.ts @@ -7,6 +7,7 @@ import { createMonteCarloSimulator } from "../monte-carlo-simulator"; import type { AbortSignalLike } from "../../../environment"; import type { PetrinautExtensionSettings } from "../../../extensions"; +import type { HirArtifacts } from "../../../hir-runtime"; import type { EventStream } from "../../../instance"; import type { ReadableStore } from "../../../store"; import type { SDCPN } from "../../../types/sdcpn"; @@ -53,6 +54,9 @@ type CreateMonteCarloExperimentBaseConfig = { seed: number; dt: number; maxTime: number; + /** Precompiled HIR artifacts (`compileHirArtifacts`) — required for any + * dynamics/lambda/kernel user code in the net. */ + hirArtifacts?: HirArtifacts; runCount: number; batchSize?: number; signal?: AbortSignalLike; @@ -307,6 +311,7 @@ function createLocalMonteCarloExperiment( seed: config.seed, dt: config.dt, maxTime: config.maxTime, + hirArtifacts: config.hirArtifacts, runCount: config.runCount, metrics: userMetrics, }); @@ -628,6 +633,7 @@ export function createMonteCarloExperiment( seed: config.seed, dt: config.dt, maxTime: config.maxTime, + hirArtifacts: config.hirArtifacts, runCount: config.runCount, batchSize: config.batchSize, metricSpecs: "metricSpecs" in config ? config.metricSpecs : undefined, 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 63c4c0f568d..3f1dcde3b1e 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 @@ -1,15 +1,14 @@ import { SDCPNItemError } from "../../errors"; -import { encodeKernelOutputToken } from "../engine/encode-kernel-token"; +import { + executeBufferKernel, + fillPlaceBases, + fillTokenIndices, +} from "../engine/buffer-transition"; import { enumerateWeightedMarkingIndicesGenerator } from "../engine/enumerate-weighted-markings"; import { nextRandom } from "../engine/seeded-rng"; -import { readTokenRecord } from "../engine/token-layout"; -import { describeTokenValuesForError } from "../engine/token-values"; import { getPlaceIndex, getTransitionIndex } from "./layout"; -import type { - CompiledTransition, - TransitionTokenValues, -} from "../engine/types"; +import type { CompiledTransition } from "../engine/types"; import type { MonteCarloFrameBuffer } from "./frame-buffer"; import type { MonteCarloRunState, @@ -68,41 +67,38 @@ export function computeTransitionEffect( inputPlacesWithValues, ); - for (const tokenCombinationIndices of tokenCombinations) { - const tokenValues: TransitionTokenValues = {}; - - for (const [ - placeIndex, - tokenIndices, - ] of tokenCombinationIndices.entries()) { - const inputPlace = inputPlacesWithValues[placeIndex]!; - const { strideBytes, byteOffset } = inputPlace; - const tokenLayout = inputPlace.tokenLayout; - if (!tokenLayout) { - throw new SDCPNItemError( - `Place \`${inputPlace.placeName}\` has no type defined`, - inputPlace.placeId, - ); - } + // The compiled buffer-ABI lambda/kernel read token attributes at + // packed-struct byte offsets straight from the frame's shared views (see + // compute-possible-transition.ts). Place bases don't change per combination. + if (!fillPlaceBases(transition.placeBases, inputPlacesWithValues)) { + throw new SDCPNItemError( + `The compiled program for transition \`${transition.name}\` does not match the net (input arc count changed). Recompile the artifacts from the current net.`, + transition.id, + ); + } - tokenValues[inputPlace.placeName] = tokenIndices.map((tokenIndex) => - readTokenRecord( - tokenLayout, - frame.tokenViews, - byteOffset + tokenIndex * strideBytes, - run.simulation.stringPool, - ), + for (const tokenCombinationIndices of tokenCombinations) { + if (!fillTokenIndices(transition.indices, tokenCombinationIndices)) { + throw new SDCPNItemError( + `The compiled program for transition \`${transition.name}\` does not match the net (input token slot count changed). Recompile the artifacts from the current net.`, + transition.id, ); } let lambdaResult: ReturnType; try { - lambdaResult = transition.lambdaFn(tokenValues); + lambdaResult = transition.lambdaFn( + frame.tokenViews.f64, + frame.tokenViews.u64, + frame.tokenViews.u8, + transition.placeBases, + transition.indices, + ); } catch (error) { throw new SDCPNItemError( - `Error while executing lambda function for transition \`${transition.name}\`:\n\n${ - (error as Error).message - }\n\nInput:\n${describeTokenValuesForError(tokenValues)}`, + `Error while executing lambda function for transition \`${ + transition.name + }\`:\n\n${(error as Error).message}`, transition.id, ); } @@ -118,60 +114,41 @@ export function computeTransitionEffect( continue; } - let kernelOutput: ReturnType; - try { - kernelOutput = transition.transitionKernelFn(tokenValues); - } catch (error) { - throw new SDCPNItemError( - `Error while executing transition kernel for transition \`${transition.name}\`:\n\n${ - (error as Error).message - }\n\nInput:\n${describeTokenValuesForError(tokenValues)}`, - transition.id, - ); - } - - const add: Record = {}; + // The compiled kernel writes output tokens into the transition's staging + // bytes; Distribution/uuid values are resolved through the kernel sink, + // advancing the RNG state. + let add: Record; let currentRngState = candidateRngState; - for (const outputPlace of transition.outputPlaces) { - const outputPlaceIndex = getPlaceIndex(frameLayout, outputPlace.placeId); - const strideBytes = frameLayout.placeStrideBytes[outputPlaceIndex] ?? 0; - if (!outputPlace.tokenLayout) { + if (transition.kernelFn === null) { + // No colored output places — every output gets `weight` empty blocks. + add = {}; + for (const outputPlace of transition.outputPlaces) { add[outputPlace.placeId] = Array.from( { length: outputPlace.weight }, () => new Uint8Array(0), ); - continue; } - - const outputTokens = kernelOutput[outputPlace.placeName]; - if (!outputTokens) { - throw new SDCPNItemError( - `Transition kernel for transition \`${transition.name}\` did not return tokens for place "${outputPlace.placeName}"`, - transition.id, - ); - } - - const tokenBlocks: Uint8Array[] = []; - for (const token of outputTokens) { - const { bytes: block, nextRngState } = encodeKernelOutputToken({ - token, - elements: outputPlace.elements ?? [], - tokenLayout: outputPlace.tokenLayout, - rngState: currentRngState, - transitionId: transition.id, - placeName: outputPlace.placeName, - stringPool: run.simulation.stringPool, - }); - currentRngState = nextRngState; - if (block.byteLength !== strideBytes) { - throw new Error( - `Transition ${transition.id} produced a ${block.byteLength}-byte token for place ${outputPlace.placeId}, expected ${strideBytes}`, - ); - } - tokenBlocks.push(block); + } else { + try { + const { add: kernelAdd, newRngState: rngAfterKernel } = + executeBufferKernel({ + transition, + views: frame.tokenViews, + rngState: candidateRngState, + }); + add = kernelAdd; + currentRngState = rngAfterKernel; + } catch (error) { + throw error instanceof SDCPNItemError + ? error + : new SDCPNItemError( + `Error while executing transition kernel for transition \`${ + transition.name + }\`:\n\n${(error as Error).message}`, + transition.id, + ); } - add[outputPlace.placeId] = tokenBlocks; } const remove: TransitionEffect["remove"] = {}; diff --git a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/types.ts b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/types.ts index bf7f66c655e..660f6ff6522 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/types.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/types.ts @@ -1,4 +1,5 @@ import type { PetrinautExtensionSettings } from "../../extensions"; +import type { HirArtifacts } from "../../hir-runtime"; import type { SDCPN } from "../../types/sdcpn"; import type { InitialMarking } from "../api"; import type { SimulationCompletionReason } from "../engine/compute-next-frame"; @@ -16,6 +17,9 @@ export type MonteCarloRunConfig = { export type MonteCarloSimulatorConfig = { sdcpn: SDCPN; extensions?: PetrinautExtensionSettings; + /** Precompiled HIR artifacts (`compileHirArtifacts`) — required for any + * dynamics/lambda/kernel user code in the net. */ + hirArtifacts?: HirArtifacts; runCount: number; initialMarking: InitialMarking; parameterValues?: Record; diff --git a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/worker/messages.ts b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/worker/messages.ts index 9b21c6fe472..4af3a65a41b 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/worker/messages.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/worker/messages.ts @@ -1,4 +1,5 @@ import type { PetrinautExtensionSettings } from "../../../extensions"; +import type { HirArtifacts } from "../../../hir-runtime"; import type { SDCPN } from "../../../types/sdcpn"; import type { InitialMarking } from "../../api"; import type { @@ -16,6 +17,9 @@ export type MonteCarloInitMessage = { seed: number; dt: number; maxTime: number; + /** Precompiled HIR artifacts (`compileHirArtifacts`) — required for any + * dynamics/lambda/kernel user code in the net. */ + hirArtifacts?: HirArtifacts; runCount: number; batchSize?: number; metricSpecs?: readonly MonteCarloMetricSpec[]; diff --git a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/worker/monte-carlo.worker.ts b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/worker/monte-carlo.worker.ts index 76409855541..4d3e29827dc 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/worker/monte-carlo.worker.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/worker/monte-carlo.worker.ts @@ -157,6 +157,7 @@ function initialize(message: MonteCarloInitMessage): void { seed: message.seed, dt: message.dt, maxTime: message.maxTime, + hirArtifacts: message.hirArtifacts, runCount: message.runCount, metrics: userMetrics, }); diff --git a/libs/@hashintel/petrinaut-core/src/simulation/runtime/simulation.ts b/libs/@hashintel/petrinaut-core/src/simulation/runtime/simulation.ts index fbe6b352855..6d30061e430 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/runtime/simulation.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/runtime/simulation.ts @@ -261,6 +261,7 @@ export function createSimulation( seed: config.seed, dt: config.dt, maxTime: config.maxTime, + hirArtifacts: config.hirArtifacts, maxFramesAhead: config.backpressure?.maxFramesAhead, batchSize: config.backpressure?.batchSize, }); diff --git a/libs/@hashintel/petrinaut-core/src/simulation/worker/messages.ts b/libs/@hashintel/petrinaut-core/src/simulation/worker/messages.ts index 45ee1f14835..d32263a1e34 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/worker/messages.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/worker/messages.ts @@ -5,6 +5,7 @@ */ import type { PetrinautExtensionSettings } from "../../extensions"; +import type { HirArtifacts } from "../../hir-runtime"; import type { SDCPN } from "../../types/sdcpn"; import type { InitialMarking } from "../api"; import type { SimulationFramePayload } from "./frame-payload"; @@ -33,6 +34,9 @@ export type InitMessage = { dt: number; /** Maximum simulation time (immutable once set). Null means no limit. */ maxTime: number | null; + /** Precompiled HIR artifacts (`compileHirArtifacts`) — required for any + * dynamics/lambda/kernel user code in the net. */ + hirArtifacts?: HirArtifacts; /** Maximum frames the worker can compute ahead before waiting for ack (backpressure) */ maxFramesAhead?: number; /** Number of frames to compute in each batch before checking for messages */ diff --git a/libs/@hashintel/petrinaut-core/src/simulation/worker/simulation.worker.ts b/libs/@hashintel/petrinaut-core/src/simulation/worker/simulation.worker.ts index 52f01510d1c..226324dfe96 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/worker/simulation.worker.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/worker/simulation.worker.ts @@ -193,6 +193,7 @@ workerRuntime.onMessage((message) => { seed: message.seed, dt: message.dt, maxTime: message.maxTime, + hirArtifacts: message.hirArtifacts, }); // Configure backpressure from init message or use defaults diff --git a/libs/@hashintel/petrinaut-core/src/types/sdcpn.ts b/libs/@hashintel/petrinaut-core/src/types/sdcpn.ts index 16abefe56d7..99bcbcee03c 100644 --- a/libs/@hashintel/petrinaut-core/src/types/sdcpn.ts +++ b/libs/@hashintel/petrinaut-core/src/types/sdcpn.ts @@ -171,7 +171,8 @@ export type Metric = { description?: string; /** * Function body invoked with `state` in scope. Must `return` a number. - * See `MetricState` (in `simulation/compile-metric.ts`) for the input shape. + * `state.places.` exposes `count` and a typed `tokens` array per + * place; the body is compiled through the HIR (`buildMetricContext`). */ code: string; }; diff --git a/libs/@hashintel/petrinaut-core/vite.config.ts b/libs/@hashintel/petrinaut-core/vite.config.ts index df29a6553c9..23aebb484f1 100644 --- a/libs/@hashintel/petrinaut-core/vite.config.ts +++ b/libs/@hashintel/petrinaut-core/vite.config.ts @@ -13,8 +13,12 @@ export default defineConfig(({ command }) => ({ entry: { index: resolve(packageRoot, "src/index.ts"), // Dedicated edge-safe entry exposing only the AI prompt + tool schemas. - // Needed to avoid pulling in heavy and edge-incompatible deps (e.g. @babel/standalone) ai: resolve(packageRoot, "src/ai.ts"), + // HIR compiler (bundles the TypeScript frontend — heavy; used by the + // LSP worker internally and by tooling/playgrounds). + hir: resolve(packageRoot, "src/hir.ts"), + // Dependency-free instantiation of compiled HIR artifacts. + "hir-runtime": resolve(packageRoot, "src/hir-runtime.ts"), "examples/index": resolve(packageRoot, "src/examples/index.ts"), "workers/lsp": resolve(packageRoot, "src/workers/lsp.ts"), "workers/monte-carlo": resolve( @@ -28,7 +32,8 @@ export default defineConfig(({ command }) => ({ }, rolldownOptions: { external: [ - "@babel/standalone", + // Peer (optional): only the ./hir compiler entry needs it. + "typescript", "elkjs", "immer", "uuid", diff --git a/libs/@hashintel/petrinaut/docs/petri-net-extensions.md b/libs/@hashintel/petrinaut/docs/petri-net-extensions.md index 411f76167a3..9bae9a4a48c 100644 --- a/libs/@hashintel/petrinaut/docs/petri-net-extensions.md +++ b/libs/@hashintel/petrinaut/docs/petri-net-extensions.md @@ -221,8 +221,22 @@ Use read arcs when a transition needs to inspect shared state, permission tokens ## Diagnostics -The **Diagnostics** tab in the bottom panel shows TypeScript errors in your code (dynamics, firing rate, kernels, visualizers), grouped by entity. Click a diagnostic to select the relevant entity and see the error in context. +The **Diagnostics** tab in the bottom panel shows problems in your code (dynamics, firing rate, kernels, visualizers), grouped by entity. Click a diagnostic to select the relevant entity and see the problem in context. Petrinaut only reports diagnostics for code surfaces that are active for the current document and graph shape. For example, a hidden firing-time editor or a transition with no coloured outputs will not produce lambda or kernel diagnostics. -Diagnostics must be resolved before running a simulation -- pressing Play with unresolved errors opens the Diagnostics tab instead of starting the simulation. +Diagnostics come in two flavours: + +- **Errors** (red) -- TypeScript type/syntax errors, plus code that falls outside Petrinaut's supported code subset (see below). These must be resolved before running a simulation: pressing Play with unresolved errors opens the Diagnostics tab instead of starting the simulation. The status indicator in the bottom toolbar shows a red cross while errors remain. +- **Warnings and hints** (amber indicator) -- semantic advice that does not block simulation. Examples: `Math.random()` makes runs non-reproducible (prefer `Distribution.Uniform`), a firing rate that is always 0 so the transition can never fire, a `const` binding that is never used, or one distribution feeding several output attributes (they all receive the same sampled value). + +### Supported code subset + +Dynamics, firing-rate, transition-kernel and metric code is compiled by Petrinaut's own compiler, which accepts a focused expression subset of TypeScript rather than arbitrary programs: + +- `const` bindings (including destructuring like `const { a, b } = parameters` or `const [first] = input.Place`), a final `return`, and guard clauses (`if (condition) return value;`). +- Arithmetic, comparisons, boolean logic, ternaries, and `Math.*` functions. +- Token access (`input.Place[0].attr`, `.length`), `.map(...)`, `.reduce(...)` and `.concat(...)` over token arrays, and `Distribution.*` constructors (with `.map` transforms). +- In metric code, place state access via `state.places..count` and `state.places..tokens` (a metric must `return` a number). + +Loops, `let`/`var`, object spread and arbitrary function calls are rejected with an error pointing at the offending code and suggesting the idiomatic alternative. This is what lets Petrinaut analyze your model (e.g. which parameters a rate depends on) and compile it to fast code that reads token values directly from the simulation's internal buffers — metrics included, so they stay cheap even across thousands of Monte Carlo runs. Scenario code is not affected by this subset. diff --git a/libs/@hashintel/petrinaut/docs/simulation.md b/libs/@hashintel/petrinaut/docs/simulation.md index 1927de9535d..e68ca36ae94 100644 --- a/libs/@hashintel/petrinaut/docs/simulation.md +++ b/libs/@hashintel/petrinaut/docs/simulation.md @@ -59,7 +59,7 @@ Press **Play** in the bottom toolbar. The simulation: If you need multiple runs of the same configuration -- e.g. to compare a stochastic model under different conditions -- use a Monte Carlo [experiment](experiments.md). -If there are unresolved [diagnostics](petri-net-extensions.md#diagnostics) (code errors), pressing Play opens the Diagnostics tab instead of starting the simulation. Fix all errors first. +If there are unresolved error-severity [diagnostics](petri-net-extensions.md#diagnostics) (code errors), pressing Play opens the Diagnostics tab instead of starting the simulation. Fix all errors first -- warnings and hints don't block simulation. simulation-settings diff --git a/libs/@hashintel/petrinaut/src/react/experiments/context.ts b/libs/@hashintel/petrinaut/src/react/experiments/context.ts index d4bbfad73e9..46fea6e6099 100644 --- a/libs/@hashintel/petrinaut/src/react/experiments/context.ts +++ b/libs/@hashintel/petrinaut/src/react/experiments/context.ts @@ -1,6 +1,7 @@ import { createContext } from "react"; import type { + MonteCarloExpressionMetricSpec, MonteCarloMetricSpec, MonteCarloUserDefinedMetricFrame, MonteCarloWorkerProgress, @@ -13,6 +14,15 @@ export type ExperimentStatus = | "error" | "cancelled"; +/** + * Metric spec as authored by the experiment form. Expression metrics are + * provided without a compiled `artifact` — the experiments provider compiles + * them through the HIR (in the language worker) before starting the run. + */ +export type ExperimentMetricSpecInput = + | Exclude + | Omit; + export type CreateExperimentInput = { name: string; scenarioId: string | null; @@ -21,7 +31,7 @@ export type CreateExperimentInput = { seed: number; dt: number; maxTime: number; - metricSpecs: readonly MonteCarloMetricSpec[]; + metricSpecs: readonly ExperimentMetricSpecInput[]; }; export type ExperimentRecord = { @@ -36,7 +46,7 @@ export type ExperimentRecord = { maxTime: number; status: ExperimentStatus; error: string | null; - metricSpecs: readonly MonteCarloMetricSpec[]; + metricSpecs: readonly ExperimentMetricSpecInput[]; progress: MonteCarloWorkerProgress | null; metricFrames: readonly MonteCarloUserDefinedMetricFrame[]; latestMetricFramesById: Readonly< diff --git a/libs/@hashintel/petrinaut/src/react/experiments/provider.test.tsx b/libs/@hashintel/petrinaut/src/react/experiments/provider.test.tsx index 6958328e1e3..112573ab763 100644 --- a/libs/@hashintel/petrinaut/src/react/experiments/provider.test.tsx +++ b/libs/@hashintel/petrinaut/src/react/experiments/provider.test.tsx @@ -11,7 +11,9 @@ import { type SDCPN, type WorkerLike, } from "@hashintel/petrinaut-core"; +import { compileHirArtifacts } from "@hashintel/petrinaut-core/hir"; +import { LanguageClientContext } from "../lsp/context"; import { NotificationsContext, type AddNotificationInput, @@ -143,6 +145,25 @@ const sdcpnContextValue: SDCPNContextValue = { getItemType: () => null, }; +/** + * Overrides the (no-op) default language client so experiment expression + * metrics compile for real — the provider requires HIR metric artifacts. + */ +const LanguageClientOverride = ({ children }: React.PropsWithChildren) => { + const value = use(LanguageClientContext); + return ( + + Promise.resolve(compileHirArtifacts(sdcpn, extensions)), + }} + > + {children} + + ); +}; + const ExperimentsContextConsumer = ({ onContextValue, }: { @@ -169,16 +190,18 @@ const TestWrapper = ({ }} > - - worker as WorkerLike< - MonteCarloToWorkerMessage, - MonteCarloToMainMessage - > - } - > - - + + + worker as WorkerLike< + MonteCarloToWorkerMessage, + MonteCarloToMainMessage + > + } + > + + + ); diff --git a/libs/@hashintel/petrinaut/src/react/experiments/provider.tsx b/libs/@hashintel/petrinaut/src/react/experiments/provider.tsx index 6c5b298629e..e93d0e78ef4 100644 --- a/libs/@hashintel/petrinaut/src/react/experiments/provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/experiments/provider.tsx @@ -16,6 +16,7 @@ import { createMonteCarloWorker } from "@hashintel/petrinaut-core/workers/monte- import { useBlockWindowClose } from "../hooks/use-block-window-close"; import { useLatest } from "../hooks/use-latest"; import { useStableCallback } from "../hooks/use-stable-callback"; +import { LanguageClientContext } from "../lsp/context"; import { NotificationsContext } from "../notifications/context"; import { SDCPNContext } from "../state/sdcpn-context"; import { @@ -163,6 +164,7 @@ export const ExperimentsProvider: React.FC = ({ workerFactory, }) => { const { extensions, petriNetDefinition } = use(SDCPNContext); + const { requestHirArtifacts } = use(LanguageClientContext); const { addNotification } = use(NotificationsContext); const petriNetDefinitionRef = useLatest(petriNetDefinition); const extensionsRef = useLatest(extensions); @@ -360,22 +362,65 @@ export const ExperimentsProvider: React.FC = ({ pendingRegistrationsRef.current.set(experimentId, { abortController }); const initializeExperiment = async () => { - const experimentConfigBase = { - sdcpn: experimentSdcpn, - extensions: extensionsRef.current, - initialMarking, - parameterValues, - seed: input.seed, - dt: input.dt, - maxTime: input.maxTime, - runCount: input.runCount, - }; - try { + // Compile the net's user code to HIR artifacts in the language + // worker — the simulation engine has no compiler of its own. The + // experiment's expression metrics are compiled alongside by + // substituting them for the model's metrics. + const expressionSpecs = input.metricSpecs.filter( + (spec) => spec.kind === "expression", + ); + const { artifacts, failures } = await requestHirArtifacts( + { + ...experimentSdcpn, + metrics: expressionSpecs.map((spec) => ({ + id: spec.id, + name: spec.label, + code: spec.code, + })), + }, + extensionsRef.current, + ); + + const metricSpecs = input.metricSpecs.map((spec) => { + if (spec.kind !== "expression") { + return spec; + } + const artifact = artifacts.metrics[spec.id]; + if (!artifact) { + const diagnostics = failures + .filter( + (failure) => + failure.itemType === "metric" && failure.itemId === spec.id, + ) + .flatMap((failure) => + failure.diagnostics.map((diagnostic) => diagnostic.message), + ); + throw new Error( + `Metric "${spec.label}" did not compile${ + diagnostics.length > 0 ? `: ${diagnostics.join("; ")}` : "" + }`, + ); + } + return { ...spec, artifact }; + }); + + const experimentConfigBase = { + sdcpn: experimentSdcpn, + extensions: extensionsRef.current, + initialMarking, + parameterValues, + seed: input.seed, + dt: input.dt, + maxTime: input.maxTime, + hirArtifacts: artifacts, + runCount: input.runCount, + }; + const handle = await createMonteCarloExperiment({ ...experimentConfigBase, createWorker: workerFactoryRef.current, - metricSpecs: input.metricSpecs, + metricSpecs, signal: abortController.signal, }); diff --git a/libs/@hashintel/petrinaut/src/react/lsp/context.ts b/libs/@hashintel/petrinaut/src/react/lsp/context.ts index a7941800f1e..ee3aefbf357 100644 --- a/libs/@hashintel/petrinaut/src/react/lsp/context.ts +++ b/libs/@hashintel/petrinaut/src/react/lsp/context.ts @@ -4,8 +4,11 @@ import type { CompletionList, Diagnostic, DocumentUri, + HirCompileResult, Hover, + PetrinautExtensionSettings, Position, + SDCPN, SignatureHelp, } from "@hashintel/petrinaut-core"; import type { @@ -18,6 +21,12 @@ export interface LanguageClientContextValue { diagnosticsByUri: Map; /** Total number of diagnostics across all documents. */ totalDiagnosticsCount: number; + /** + * Error-severity diagnostics only. Warnings/hints (e.g. HIR semantic + * lints) are included in `totalDiagnosticsCount` but not here — use this + * to decide whether the net can be simulated. + */ + errorDiagnosticsCount: number; /** Notify the server that a document's content changed. */ notifyDocumentChanged: (uri: DocumentUri, text: string) => void; /** Request completions at a position within a document. */ @@ -32,6 +41,15 @@ export interface LanguageClientContextValue { uri: DocumentUri, position: Position, ) => Promise; + /** + * Compile the SDCPN's user code to HIR artifacts (in the language worker). + * Required before starting simulations/experiments — the engine has no + * compiler of its own. + */ + requestHirArtifacts: ( + sdcpn: SDCPN, + extensions?: PetrinautExtensionSettings, + ) => Promise; /** Initialize a temporary scenario editing session. */ initializeScenarioSession: (params: ScenarioSessionParams) => void; /** Update a scenario editing session. */ @@ -49,10 +67,22 @@ export interface LanguageClientContextValue { const DEFAULT_CONTEXT_VALUE: LanguageClientContextValue = { diagnosticsByUri: new Map(), totalDiagnosticsCount: 0, + errorDiagnosticsCount: 0, notifyDocumentChanged: () => {}, requestCompletion: () => Promise.resolve({ isIncomplete: false, items: [] }), requestHover: () => Promise.resolve(null), requestSignatureHelp: () => Promise.resolve(null), + requestHirArtifacts: () => + Promise.resolve({ + artifacts: { + version: 3, + dynamics: {}, + lambdas: {}, + kernels: {}, + metrics: {}, + }, + failures: [], + }), initializeScenarioSession: () => {}, updateScenarioSession: () => {}, killScenarioSession: () => {}, diff --git a/libs/@hashintel/petrinaut/src/react/lsp/provider.tsx b/libs/@hashintel/petrinaut/src/react/lsp/provider.tsx index c9b8694d77a..0cf2e64836c 100644 --- a/libs/@hashintel/petrinaut/src/react/lsp/provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/lsp/provider.tsx @@ -17,6 +17,7 @@ import { LanguageClientContext } from "./context"; const EMPTY_DIAGNOSTICS_SNAPSHOT: DiagnosticsSnapshot = { byUri: new Map(), total: 0, + errorCount: 0, }; const EMPTY_DIAGNOSTICS_STORE: ReadableStore = { @@ -81,9 +82,11 @@ export const LanguageClientProvider: React.FC<{ // Subscribe to diagnostics from the client. Use an empty fallback store // before the client is created so hook order stays stable. - const { byUri: diagnosticsByUri, total: totalDiagnosticsCount } = useStore( - client?.diagnostics ?? EMPTY_DIAGNOSTICS_STORE, - ); + const { + byUri: diagnosticsByUri, + total: totalDiagnosticsCount, + errorCount: errorDiagnosticsCount, + } = useStore(client?.diagnostics ?? EMPTY_DIAGNOSTICS_STORE); // Before the client lands (StrictMode's first effect cycle gets cleaned // up before the worker is wired), fall through to LanguageClientContext's @@ -96,10 +99,12 @@ export const LanguageClientProvider: React.FC<{ const value = { diagnosticsByUri, totalDiagnosticsCount, + errorDiagnosticsCount, notifyDocumentChanged: client.notifyDocumentChanged, requestCompletion: client.requestCompletion, requestHover: client.requestHover, requestSignatureHelp: client.requestSignatureHelp, + requestHirArtifacts: client.requestHirArtifacts, initializeScenarioSession: client.initializeScenarioSession, updateScenarioSession: client.updateScenarioSession, killScenarioSession: client.killScenarioSession, diff --git a/libs/@hashintel/petrinaut/src/react/simulation/provider.tsx b/libs/@hashintel/petrinaut/src/react/simulation/provider.tsx index ec060032cee..4a7aa4c80b0 100644 --- a/libs/@hashintel/petrinaut/src/react/simulation/provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/simulation/provider.tsx @@ -15,6 +15,7 @@ import { createSimulationWorker } from "@hashintel/petrinaut-core/workers/simula import { deriveDefaultParameterValues } from "../hooks/use-default-parameter-values"; import { useLatest } from "../hooks/use-latest"; import { useStableCallback } from "../hooks/use-stable-callback"; +import { LanguageClientContext } from "../lsp/context"; import { NotificationsContext } from "../notifications/context"; import { SDCPNContext } from "../state/sdcpn-context"; import { useStore } from "../use-store"; @@ -159,6 +160,7 @@ export const SimulationProvider: React.FC = ({ workerFactory, }) => { const sdcpnContext = use(SDCPNContext); + const { requestHirArtifacts } = use(LanguageClientContext); const { extensions, petriNetDefinition } = sdcpnContext; const { addNotification } = use(NotificationsContext); @@ -374,9 +376,16 @@ export const SimulationProvider: React.FC = ({ let sim: Simulation; try { + // Compile the net's user code to HIR artifacts in the language worker — + // the simulation engine has no compiler of its own. + const { artifacts } = await requestHirArtifacts( + simulationSdcpn, + extensionsRef.current, + ); sim = await createSimulation({ sdcpn: simulationSdcpn, extensions: extensionsRef.current, + hirArtifacts: artifacts, // eslint-disable-next-line no-use-before-define -- closure; ref is defined later in render initialMarking: effectiveInitialMarkingRef.current, // eslint-disable-next-line no-use-before-define -- closure; ref is defined later in render diff --git a/libs/@hashintel/petrinaut/src/ui/dev/token-encoding-playground/token-encoding-playground.tsx b/libs/@hashintel/petrinaut/src/ui/dev/token-encoding-playground/token-encoding-playground.tsx index 326f2b4ac49..b78e8a0a5b2 100644 --- a/libs/@hashintel/petrinaut/src/ui/dev/token-encoding-playground/token-encoding-playground.tsx +++ b/libs/@hashintel/petrinaut/src/ui/dev/token-encoding-playground/token-encoding-playground.tsx @@ -1,7 +1,6 @@ import { Suspense, use, useEffect, useState } from "react"; import { css } from "@hashintel/ds-helpers/css"; -import { compileUserCode } from "@hashintel/petrinaut-core"; import { SegmentGroup } from "../../components/segment-group"; import { CodeEditor } from "../../monaco/code-editor"; @@ -49,8 +48,19 @@ function evaluate( ): EvaluationResult { try { const layout = computePlaygroundTokenLayout(dimensions); - // Same pipeline user code takes in the product (Babel TS-strip + eval). - const create = compileUserCode<[]>(code, "Token"); + // Dev-only evaluation: the product pipeline compiles through the HIR; + // this playground accepts plain-JS `Token(() => ({ ... }))` snippets. + // eslint-disable-next-line no-new-func, @typescript-eslint/no-implied-eval, @typescript-eslint/no-unsafe-call + const create = new Function( + `"use strict"; + const Token = (fn) => fn; + let __default; + ${code.replace(/export\s+default\s+/, "__default = ")} + return __default;`, + )() as () => unknown; + if (typeof create !== "function") { + throw new Error("Expected `export default Token(() => ({ ... }))`"); + } const raw: unknown = create(); if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { throw new Error("Token(() => …) must return an object"); diff --git a/libs/@hashintel/petrinaut/src/ui/hir-playground.stories.tsx b/libs/@hashintel/petrinaut/src/ui/hir-playground.stories.tsx new file mode 100644 index 00000000000..ffced2aac72 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/hir-playground.stories.tsx @@ -0,0 +1,607 @@ +import { useState } from "react"; + +import { css } from "@hashintel/ds-helpers/css"; +import { + emitBufferDynamicsJs, + emitBufferKernelJs, + emitBufferLambdaJs, + emitBufferMetricJs, + emitUserFunctionJs, + formatHirType, + lintHirUserCode, +} from "@hashintel/petrinaut-core/hir"; + +import { CodeEditor } from "./monaco/code-editor"; +import { MonacoProvider } from "./monaco/provider"; + +import type { + HirAnalysis, + HirDiagnostic, + HirFunction, + HirSurfaceContext, + HirSurfaceKind, + HirTokenElementInfo, + HirTypecheckResult, +} from "@hashintel/petrinaut-core/hir"; +import type { Meta, StoryObj } from "@storybook/react-vite"; + +// -- Playground schema (hand-editable, derives the HirSurfaceContext) --------- + +type PlaygroundPlace = { + name: string; + tokenCount: number; + elements: HirTokenElementInfo[]; +}; + +type PlaygroundSchema = { + parameters: { name: string; type: "real" | "integer" | "boolean" }[]; + /** Attributes of the colour a dynamics equation is attached to. */ + elements: HirTokenElementInfo[]; + /** Colored input places, in arc order (lambda/kernel). */ + inputPlaces: PlaygroundPlace[]; + /** Colored output places, in arc order (kernel). */ + outputPlaces: PlaygroundPlace[]; + lambdaType: "stochastic" | "predicate"; + stochasticity: boolean; +}; + +function toArcSlots(places: PlaygroundPlace[]) { + let slotStart = 0; + return places.map((place, index) => { + const slot = { + name: place.name, + colorId: `color-${index}`, + elements: place.elements, + tokenCount: place.tokenCount, + slotStart, + }; + slotStart += place.tokenCount; + return slot; + }); +} + +function toContext( + surface: HirSurfaceKind, + schema: PlaygroundSchema, +): HirSurfaceContext { + switch (surface) { + case "dynamics": + return { + surface, + parameters: schema.parameters, + elements: schema.elements, + }; + case "lambda": + return { + surface, + parameters: schema.parameters, + inputPlaces: toArcSlots(schema.inputPlaces), + inputSlots: toArcSlots(schema.inputPlaces), + lambdaType: schema.lambdaType, + }; + case "kernel": + return { + surface, + parameters: schema.parameters, + inputPlaces: toArcSlots(schema.inputPlaces), + inputSlots: toArcSlots(schema.inputPlaces), + outputPlaces: toArcSlots(schema.outputPlaces), + outputSlots: toArcSlots(schema.outputPlaces), + stochasticity: schema.stochasticity, + }; + case "metric": + return { + surface, + parameters: [], + places: [...schema.inputPlaces, ...schema.outputPlaces].map( + (place) => ({ name: place.name, elements: place.elements }), + ), + }; + } +} + +// -- Presets ------------------------------------------------------------------- + +const DEFAULT_SCHEMA: PlaygroundSchema = { + parameters: [ + { name: "k", type: "real" }, + { name: "rate", type: "real" }, + { name: "sigma", type: "real" }, + { name: "threshold", type: "real" }, + ], + elements: [ + { name: "x", type: "real" }, + { name: "v", type: "real" }, + { name: "generation", type: "integer" }, + { name: "alive", type: "boolean" }, + ], + inputPlaces: [ + { + name: "Pool", + tokenCount: 2, + elements: [ + { name: "x", type: "real" }, + { name: "v", type: "real" }, + { name: "alive", type: "boolean" }, + ], + }, + { + name: "Fuel", + tokenCount: 1, + elements: [{ name: "level", type: "real" }], + }, + ], + outputPlaces: [ + { + name: "Out", + tokenCount: 1, + elements: [ + { name: "x", type: "real" }, + { name: "generation", type: "integer" }, + { name: "alive", type: "boolean" }, + ], + }, + ], + lambdaType: "stochastic", + stochasticity: true, +}; + +const CODE_PRESETS: Record = { + dynamics: `export default Dynamics((tokens, parameters) => { + const stiffness = parameters.k; + + return tokens.map(({ x, v, alive }) => { + return { + x: alive ? v : 0, + v: -stiffness * x, + }; + }); +}); +`, + lambda: `export default Lambda((input, parameters) => { + const { rate, threshold } = parameters; + const { x, alive } = input.Pool[0]; + + if (!alive) return 0; + if (input.Fuel[0].level < threshold) return 0; + + return rate * Math.max(0.1, x); +}); +`, + kernel: `export default TransitionKernel((input, parameters) => { + // One draw shared by two attributes (same sample at fire time). + const noise = Distribution.Gaussian(0, parameters.sigma); + + return { + Out: [{ + x: noise.map((value) => input.Pool[0].x + value), + generation: input.Pool[0].v > 0 ? 1 : 0, + alive: input.Pool[0].alive, + }], + }; +}); +`, + metric: `const pool = state.places.Pool.tokens; +if (pool.length === 0) return 0; +return pool.reduce((sum, token) => sum + token.x, 0) / pool.length; +`, +}; + +// -- The pipeline result (recomputed every render — lowering is ~1ms) ---------- + +type PipelineResult = { + context: HirSurfaceContext | null; + schemaError: string | null; + fn: HirFunction | null; + diagnostics: HirDiagnostic[]; + typecheck: HirTypecheckResult | null; + analysis: HirAnalysis | null; + objectJs: string | null; + bufferJs: string | null; + bufferBailed: boolean; +}; + +function emitBuffer( + fn: HirFunction, + context: HirSurfaceContext, +): string | null { + switch (context.surface) { + case "dynamics": + return emitBufferDynamicsJs(fn, context.elements); + case "lambda": { + const program = emitBufferLambdaJs(fn, context); + return program + ? `// inputSlotCount: ${program.inputSlotCount}\n${program.source}` + : null; + } + case "kernel": { + const program = emitBufferKernelJs(fn, context); + return program + ? `// inputSlotCount: ${program.inputSlotCount}, outputByteCount: ${program.outputByteCount}\n${program.source}` + : null; + } + case "metric": { + const program = emitBufferMetricJs(fn, context); + return program + ? `// placeNames: ${program.placeNames.join(", ")}\n${program.source}` + : null; + } + } +} + +function runPipeline( + surface: HirSurfaceKind, + code: string, + schemaJson: string, +): PipelineResult { + let context: HirSurfaceContext | null = null; + let schemaError: string | null = null; + try { + const schema = JSON.parse(schemaJson) as PlaygroundSchema; + context = toContext(surface, schema); + } catch (error) { + schemaError = error instanceof Error ? error.message : String(error); + } + + const empty: PipelineResult = { + context, + schemaError, + fn: null, + diagnostics: [], + typecheck: null, + analysis: null, + objectJs: null, + bufferJs: null, + bufferBailed: false, + }; + if (!context) { + return empty; + } + + // One call runs the full pipeline: lowering, typecheck, analyses, lints. + const lint = lintHirUserCode(code, context); + if (!lint.fn) { + return { ...empty, diagnostics: lint.diagnostics }; + } + + let objectJs: string | null = null; + let bufferJs: string | null = null; + try { + objectJs = emitUserFunctionJs(lint.fn); + bufferJs = emitBuffer(lint.fn, context); + } catch (error) { + schemaError = error instanceof Error ? error.message : String(error); + } + + return { + context, + schemaError, + fn: lint.fn, + diagnostics: lint.diagnostics, + typecheck: lint.typecheck ?? null, + analysis: lint.analysis ?? null, + objectJs, + bufferJs, + bufferBailed: bufferJs === null, + }; +} + +// -- Styles --------------------------------------------------------------------- + +const pageStyle = css({ + display: "grid", + gridTemplateColumns: "[1fr 1fr]", + gap: "[16px]", + width: "full", + fontFamily: "body", +}); + +const columnStyle = css({ + display: "flex", + flexDirection: "column", + gap: "[12px]", + minWidth: "[0]", +}); + +const panelStyle = css({ + borderWidth: "[1px]", + borderStyle: "solid", + borderColor: "neutral.bd.subtle", + borderRadius: "lg", + overflow: "hidden", +}); + +const panelTitleStyle = css({ + fontSize: "xs", + fontWeight: "semibold", + color: "neutral.s80", + textTransform: "uppercase", + letterSpacing: "wide", + padding: "[6px 10px]", + backgroundColor: "neutral.s10", + borderBottomWidth: "[1px]", + borderBottomStyle: "solid", + borderBottomColor: "neutral.bd.subtle", +}); + +const preStyle = css({ + margin: "[0]", + padding: "[10px]", + fontSize: "xs", + fontFamily: "mono", + lineHeight: "[1.5]", + whiteSpace: "pre-wrap", + wordBreak: "break-word", + maxHeight: "[320px]", + overflow: "auto", +}); + +const surfaceBarStyle = css({ + display: "flex", + gap: "[6px]", +}); + +const surfaceButtonStyle = css({ + fontSize: "sm", + padding: "[4px 12px]", + borderRadius: "md", + borderWidth: "[1px]", + borderStyle: "solid", + borderColor: "neutral.bd.subtle", + cursor: "pointer", + backgroundColor: "[transparent]", + '&[data-active="true"]': { + backgroundColor: "neutral.s90", + color: "neutral.s10", + }, +}); + +const diagnosticStyle = css({ + display: "flex", + gap: "[8px]", + alignItems: "baseline", + padding: "[6px 10px]", + fontSize: "xs", + fontFamily: "mono", + borderBottomWidth: "[1px]", + borderBottomStyle: "solid", + borderBottomColor: "neutral.bd.subtle", +}); + +const severityStyle = css({ + fontWeight: "semibold", + textTransform: "uppercase", + fontSize: "[10px]", + '&[data-severity="error"]': { color: "[#dc2626]" }, + '&[data-severity="warning"]': { color: "[#d97706]" }, + '&[data-severity="info"]': { color: "[#2563eb]" }, + '&[data-severity="hint"]': { color: "neutral.s70" }, +}); + +const schemaTextareaStyle = css({ + width: "full", + minHeight: "[180px]", + fontFamily: "mono", + fontSize: "xs", + padding: "[10px]", + border: "none", + outline: "none", + resize: "vertical", +}); + +const okStyle = css({ + padding: "[10px]", + fontSize: "xs", + color: "[#16a34a]", +}); + +// -- Components ------------------------------------------------------------------- + +const Panel = ({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) => ( +
+
{title}
+ {children} +
+); + +const Json = ({ value }: { value: unknown }) => ( +
{JSON.stringify(value, null, 2)}
+); + +const DiagnosticsPanel = ({ + diagnostics, + code, +}: { + diagnostics: HirDiagnostic[]; + code: string; +}) => ( + + {diagnostics.length === 0 ? ( +
No diagnostics — compiles cleanly.
+ ) : ( + diagnostics.map((diagnostic) => ( +
+ + {diagnostic.severity} + + + {diagnostic.code} {diagnostic.message} +
+ + @{diagnostic.span.start}.. + {diagnostic.span.start + diagnostic.span.length}:{" "} + {JSON.stringify( + code.slice( + diagnostic.span.start, + diagnostic.span.start + Math.min(diagnostic.span.length, 80), + ), + )} + +
+
+ )) + )} +
+); + +const HirPlayground = () => { + const [surface, setSurface] = useState("kernel"); + const [codeBySurface, setCodeBySurface] = useState(CODE_PRESETS); + const [schemaJson, setSchemaJson] = useState( + JSON.stringify(DEFAULT_SCHEMA, null, 2), + ); + + const code = codeBySurface[surface]; + const result = runPipeline(surface, code, schemaJson); + + return ( +
+ {/* Left column — inputs */} +
+
+ {(["dynamics", "lambda", "kernel", "metric"] as const).map( + (candidate) => ( + + ), + )} +
+ + + + setCodeBySurface((previous) => ({ + ...previous, + [surface]: next ?? "", + })) + } + /> + + + +