diff --git a/.changeset/petrinaut-hir-compiler.md b/.changeset/petrinaut-hir-compiler.md new file mode 100644 index 00000000000..e05f5e338d3 --- /dev/null +++ b/.changeset/petrinaut-hir-compiler.md @@ -0,0 +1,10 @@ +--- +"@hashintel/petrinaut": patch +"@hashintel/petrinaut-core": minor +--- + +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..087b6916c9c --- /dev/null +++ b/libs/@hashintel/petrinaut-core/benches/bench.mjs @@ -0,0 +1,517 @@ +/* eslint-disable no-console, typescript/no-unsafe-assignment, typescript/no-unsafe-call -- This cross-version CLI intentionally feature-detects dynamically imported package APIs and reports results to stdout. */ +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 onlyIndex = ONLY === null ? null : Number(ONLY); +if ( + onlyIndex !== null && + (!Number.isInteger(onlyIndex) || + onlyIndex < 0 || + onlyIndex >= SCENARIOS.length) +) { + throw new Error( + `--only must be an integer from 0 to ${SCENARIOS.length - 1}; received ${JSON.stringify(ONLY)}`, + ); +} +const selected = onlyIndex === null ? SCENARIOS : [SCENARIOS[onlyIndex]]; +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/dependency-cruiser.tsconfig.json b/libs/@hashintel/petrinaut-core/dependency-cruiser.tsconfig.json new file mode 100644 index 00000000000..cbff533fc46 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/dependency-cruiser.tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "baseUrl": "../../../..", + "paths": { + "@hashintel/petrinaut-core": [ + "libs/@hashintel/petrinaut-core/src/index.ts" + ], + "@hashintel/petrinaut-core/examples": [ + "libs/@hashintel/petrinaut-core/src/examples/index.ts" + ], + "@hashintel/petrinaut-core/hir": [ + "libs/@hashintel/petrinaut-core/src/hir.ts" + ], + "@hashintel/petrinaut-core/hir-runtime": [ + "libs/@hashintel/petrinaut-core/src/hir-runtime.ts" + ], + "@hashintel/petrinaut-core/workers/lsp": [ + "libs/@hashintel/petrinaut-core/src/workers/lsp.ts" + ], + "@hashintel/petrinaut-core/workers/monte-carlo": [ + "libs/@hashintel/petrinaut-core/src/workers/monte-carlo.ts" + ], + "@hashintel/petrinaut-core/workers/simulation": [ + "libs/@hashintel/petrinaut-core/src/workers/simulation.ts" + ] + } + }, + "include": ["src", "../petrinaut/src"] +} diff --git a/libs/@hashintel/petrinaut-core/docs/architecture/authoring.html b/libs/@hashintel/petrinaut-core/docs/architecture/authoring.html index 5d6d9d47e06..ae048fa3b0a 100644 --- a/libs/@hashintel/petrinaut-core/docs/architecture/authoring.html +++ b/libs/@hashintel/petrinaut-core/docs/architecture/authoring.html @@ -17,10 +17,9 @@
- authoring/ — turns user-authored strings (TypeScript modules,
- expressions, function bodies) into plain JS functions the engine can call.
- Nothing here is a real security boundary: user code runs in the same
- realm, hardened against accidents, not attackers.
+ The HIR pipeline turns user-authored TypeScript into checked,
+ source-spanned buffer programs. Scenario code retains a separate
+ same-realm compiler hardened against accidents, not attackers.
InitialMarking for
- scenarios, fn(state) for metrics)
+ Versioned HIR artifacts for runtime code; InitialMarking
+ for scenarios
init) · Main thread
- (scenario, quick-sim metrics) · MC worker (experiment metrics)
+ LSP worker (HIR compilation) · simulation workers (buffer programs) ·
+ main thread (scenarios and timeline metric evaluation)
- user-code/compile-user-code.ts handles the three engine
- surfaces that are authored as modules (export default Wrapper(fn)):
+ Dynamics, lambdas, kernels, and metrics all compile through the HIR. The
+ first three are authored as export default Wrapper(fn)
+ modules; metrics are authored as function bodies.
new Function(...)()
- Same-realm plain JS closure, handed to the engine
+ Instantiate in runtime
+ Validate v4 fingerprint, then bind parameters, pool, and ABI
+ views
Color_*,
Parameters, Dynamics,
TransitionKernel… declarations that Monaco checks against, so
- editor diagnostics and runtime behaviour must be kept in sync by hand.
+ editor diagnostics and runtime artifacts are produced from the same HIR
+ checks and emitters.
Lambda(fn)buildSimulation → compileUserCodecompileHirArtifacts in the LSP worker(input: {PlaceName: TokenRecord[]}, params) → boolean |
- rate
+ (f64, u64, u8, placeBases, indices) → boolean | rate
TransitionKernel(fn)buildSimulationcompileHirArtifacts in the LSP worker(input, params) → {OutputPlace: token[]} — values may
- be Distribution objects, sampled later by the engine
+ (views, placeBases, indices, outputViews, sink) → void
Dynamics(fn)buildSimulation (only for colours with a real element)
+ compileHirArtifacts (colours with a real element)
(tokens: TokenRecord[], params) → derivative[] (real
- elements only; others zeroed)
+ (placeBytes, tokenCount) → Float64Array
authoring/metric/compile-metric.tscompileHirArtifacts in the LSP worker(state: {places: {[name]: {count, tokens}}}) → finite
- number(f64, u64, u8, placeCounts, placeOffsets) → finite number
— NaN/∞ throw
SimulationFrameReader into
- state.places[name] = {count, tokens} (keyed by place
- name, tokens decoded).Bind referenced place ordinals and the per-run string pool.
authoring/sandbox.ts)authoring/sandbox.ts)var window, document, globalThis, self, fetch, XMLHttpRequest,
importScripts, Function, setTimeout, setInterval, queueMicrotaskSandboxing (authoring/sandbox.ts)
- user-code/distribution.ts injects a tiny runtime providing
- Distribution.Gaussian / Uniform / Lognormal. Kernels don’t
- sample — they return distribution objects; the engine samples
- each one once per token with the seeded RNG
- (sample-distribution.ts), so chained
- .map(fn) transforms share a single draw and runs stay
+ HIR-emitted kernels construct small runtime values for
+ Distribution.Gaussian / Uniform / Lognormal. Kernels defer
+ sampling through the ABI sink; the engine samples each value once per
+ token with the seeded RNG (sample-distribution.ts), so
+ chained .map(fn) transforms share a single draw and runs stay
reproducible. When stochasticity is disabled, the runtime isn’t injected
and plain values are required.
export default Wrapper(…)) —
- an AST check via the already-present Babel pass would be stricter. (2)
- Compiled functions receive decoded TokenRecord objects;
- compiling to buffer-level accessors (the “IR” idea in the Monte Carlo
- README) would remove per-firing object churn. (3) The runtime/LSP contract
- (shapes, availability rules) is duplicated between
- generate-virtual-files.ts, ai.ts prose, and the
- compilers — a single source of truth would prevent drift.
+ Compatibility seam. HIR artifacts contain a version and
+ fingerprint of their sanitized compilation input. Any schema, code, or
+ extension change requires recompilation before the engine will run them.