Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
a5c8afd
Carry the lowered metric HIR on its artifact and key the GPU setup ca…
kube Sep 10, 2026
c60eed1
Sample frame 0 on the device before the first step
kube Sep 10, 2026
1af00d6
Bin every GPU metric through one f32 window with integer and real dom…
kube Sep 10, 2026
51009ee
Translate metric HIR to WGSL with token spans, loop reduces and the m…
kube Sep 10, 2026
3093fb6
Admit translatable expression metrics through the GPU gate and report…
kube Sep 10, 2026
2d314a2
Let the editor's GPU switch follow the compilation report for express…
kube Sep 11, 2026
f15c2e3
Document GPU expression metrics and the f32 histogram window
kube Sep 11, 2026
9c535b6
Close the review gaps in the GPU metric commits
kube Sep 11, 2026
910eae5
Describe the extra histogram row in the sizing page and stop exportin…
kube Sep 11, 2026
8bb6998
Report a metric's non-finite samples from the probe prefix instead of…
kube Sep 12, 2026
23ff898
Hand a GPU attempt back as soon as a metric halts a run and report th…
kube Sep 12, 2026
3816a6c
Report a GPU probe a metric halted before sizing its slabs and keep i…
kube Sep 12, 2026
8e77708
Share the objective-history chart and give it a style, a pinned x edg…
kube Sep 11, 2026
a9ccbee
Draw every sweep study's objective by step under the Parameters sliders
kube Sep 11, 2026
116f23b
Close the review gaps in the sweep objective strip
kube Sep 11, 2026
ba81b28
Drop the changeset: the objective strip ships behind experimental flags
kube Sep 11, 2026
b80a579
Give the drawer test's fake optimizer the studies list the strip reads
kube Sep 11, 2026
1313136
Point the results band's path mention at shared/results-model.ts
kube Sep 11, 2026
441d37d
Summarise the sweep strip from the last counted study, test two studi…
kube Sep 11, 2026
7ff0046
Break the sweep's best-so-far line at each divider and give a sweep w…
kube Sep 12, 2026
76d4a9e
Say the sweep's objective strip has no steps run instead of waiting w…
kube Sep 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from "../simulation/monte-carlo";
import { analyzeCompilation } from "../webgpu/compilation-report";
import { assessGpuEligibility } from "../webgpu/eligibility";
import { tryTranslateMetric } from "../webgpu/try-translate-metric";
import { vaccinationCampaign } from "./vaccination-campaign";

import type { CompiledScenarioResult } from "../simulation/authoring/scenario/compile-scenario";
Expand Down Expand Up @@ -126,6 +127,39 @@ describe("Vaccination Campaign", () => {
).toStrictEqual(["gpu-ready", "gpu-ready"]);
});

it("compiles to a GPU shader with the Total cost expression objective", () => {
// The optimization stories minimise this metric; it prices counts by
// parameters, so its samples are real and the GPU bins them to a
// calibrated window.
const artifact = artifacts.metrics[totalCost.id]!;
expect(
tryTranslateMetric({ sdcpn: petriNetDefinition, hir: artifact.hir! }),
).toStrictEqual({ translatable: true, integer: false });

const report = analyzeCompilation({
sdcpn: petriNetDefinition,
artifacts,
metricSpecs: [
{
kind: "expression",
id: totalCost.id,
label: totalCost.name,
code: totalCost.code,
artifact,
},
],
});

expect(report.gpuReady).toBe(true);
expect(report.metricFailure).toBeNull();
expect(report.shaderFailure).toBeNull();
expect(
report.items.find(
(item) => item.kind === "metric" && item.itemId === totalCost.id,
)?.status,
).toBe("gpu-ready");
});

it("seeds the Winter wave from the coverage and the initial cases", () => {
const result = compile();

Expand Down
4 changes: 4 additions & 0 deletions libs/@hashintel/petrinaut-core/src/hir/BUFFER_ABI.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@ 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`.

Compiled with `includeHir`, `HirMetricArtifact.hir` carries the lowered tree
the program was emitted from, like the lambda, kernel and dynamics artifacts.
The buffer program never reads it; it is there for the WebGPU backend.

## Artifact validation

Artifacts are `version: 4` and carry a fingerprint of the sanitized SDCPN and
Expand Down
4 changes: 4 additions & 0 deletions libs/@hashintel/petrinaut-core/src/hir/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@ outside the supported HIR subset is a blocking diagnostic.
}
```

With `includeHir`, every artifact also carries the lowered `hir` tree it was
emitted from, so the WebGPU backend can generate a shader without lowering the
net in the browser. Metric artifacts carry it too, for the same consumer.

The engine validates the artifact version and compilation-input fingerprint
before running, then checks per-program metadata. Missing or stale artifacts
produce errors instead of falling back to runtime compilation.
Expand Down
10 changes: 10 additions & 0 deletions libs/@hashintel/petrinaut-core/src/hir/artifacts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,16 @@ describe("compileHirArtifacts", () => {
`);
});

it("carries the metric HIR only when includeHir is set", () => {
const withHir = compileHirArtifacts(sdcpn, undefined, {
includeHir: true,
}).artifacts.metrics["done-count"]!.hir;
expect(withHir?.surface).toBe("metric");
expect(withHir?.params[0]?.name).toBe("state");

expect(compile().metrics["done-count"]!.hir).toBeUndefined();
});

it("matches the object reference emitter for a representative lambda", () => {
const artifacts = compile();
const pool = new StringPool();
Expand Down
1 change: 1 addition & 0 deletions libs/@hashintel/petrinaut-core/src/hir/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,7 @@ export function compileHirArtifacts(
artifacts.metrics[metric.id] = {
source: program.source,
placeNames: program.placeNames,
...(options.includeHir ? { hir: item.fn } : {}),
};
}

Expand Down
2 changes: 2 additions & 0 deletions libs/@hashintel/petrinaut-core/src/hir/instantiate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ export type HirMetricArtifact = {
source: string;
/** Places referenced by the program, in `__places` ordinal order. */
placeNames: string[];
/** The lowered HIR the program was emitted from — see `HirLambdaArtifact.hir`. */
hir?: HirFunction;
};

/**
Expand Down
10 changes: 3 additions & 7 deletions libs/@hashintel/petrinaut-core/src/webgpu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,9 @@
* the compiled artifacts (`webgpu/hir-from-artifacts.ts`), so nothing here
* touches the TypeScript frontend.
*
* Only the surface the app consumes is exported: the backend factory, the
* compilation report the editor renders, and the metric-spec gate the
* experiment drawer applies. Everything else in `webgpu/` is internal; tests
* import it by relative path.
* Only the surface the app consumes is exported: the backend factory and the
* compilation report the editor renders. Everything else in `webgpu/` is
* internal; tests import it by relative path.
*
* "The WebGPU backend" in
* `libs/@local/petrinaut-arch-docs/content/simulation/performance.mdx` covers
Expand All @@ -34,6 +33,3 @@ export type {
CompilationItemStatus,
CompilationReport,
} from "./webgpu/compilation-report";

export { toGpuMetricSpecs } from "./webgpu/gpu-metric-frames";
export type { GpuMetricSpec } from "./webgpu/compile-net-shader";
164 changes: 163 additions & 1 deletion libs/@hashintel/petrinaut-core/src/webgpu/compilation-report.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
summarizeGpuUnavailability,
} from "./compilation-report";

import type { MonteCarloExpressionMetricSpec } from "../simulation/monte-carlo/metrics";
import type { SDCPN } from "../types/sdcpn";

function analyze(sdcpn: SDCPN) {
Expand All @@ -22,6 +23,26 @@ function analyze(sdcpn: SDCPN) {
return analyzeCompilation({ sdcpn, artifacts });
}

/** One of the net's model metrics as the expression spec an experiment sends. */
function modelMetricSpec(
sdcpn: SDCPN,
metricId: string,
): MonteCarloExpressionMetricSpec {
const metric = sdcpn.metrics?.find((candidate) => candidate.id === metricId);
const artifact = compileHirArtifacts(sdcpn, undefined, { includeHir: true })
.artifacts.metrics[metricId];
if (metric === undefined || artifact === undefined) {
throw new Error(`metric ${metricId} is not on the net or did not compile`);
}
return {
kind: "expression",
id: metric.id,
label: metric.name,
code: metric.code,
artifact,
};
}

const satellites = probabilisticSatellitesSDCPN.petriNetDefinition;

describe("gpu-ready shipped examples", () => {
Expand Down Expand Up @@ -254,6 +275,118 @@ describe("analyzeCompilation", () => {
expect(crash?.hirNodeCount).toBeGreaterThan(8);
});

it("classifies every bundled example's model metrics", () => {
// One `metric` row per model metric, whether or not an experiment
// measures it. Deliberately exhaustive over the examples namespace, like
// the readiness matrix: a metric added to an example fails here until its
// GPU verdict is recorded. Every translatable metric on a GPU-ready net is
// `gpu-ready`; the two `.concat` averages are `cpu-only` with their own
// reason; Production Machines' other metrics are `cpu-only` because the
// net's shader fails, which is a different sentence.
const statuses = Object.fromEntries(
Object.entries(allExamples).map(([name, example]) => {
const definition = (example as { petriNetDefinition: SDCPN })
.petriNetDefinition;
const rows = analyze(definition).items.filter(
(item) => item.kind === "metric",
);
expect(rows.map((row) => row.itemId).sort(), name).toStrictEqual(
(definition.metrics ?? []).map((metric) => metric.id).sort(),
);
return [
name,
Object.fromEntries(rows.map((row) => [row.itemId, row.status])),
];
}),
);
expect(statuses).toStrictEqual({
productionMachines: {
metric__good_products: "cpu-only",
metric__defective_products: "cpu-only",
metric__yield: "cpu-only",
metric__machines_down: "cpu-only",
metric__average_machine_damage: "cpu-only",
},
deploymentPipelineSDCPN: {
metric__successful_deployments: "gpu-ready",
metric__failed_deployments: "gpu-ready",
metric__release_queue_length: "gpu-ready",
metric__active_incidents: "gpu-ready",
metric__deployment_gate_blocked: "gpu-ready",
metric__failure_share: "gpu-ready",
},
probabilisticSatellitesSDCPN: {
metric__satellites_in_orbit: "gpu-ready",
metric__debris: "gpu-ready",
metric__average_orbital_radius: "gpu-ready",
metric__average_orbital_speed: "gpu-ready",
},
sirModel: { metric__infected_fraction: "gpu-ready" },
cafeQueue: {},
dronePatrol: {},
supplyChainWithDisruption: {
metric_service_level: "gpu-ready",
metric_customer_pressure: "gpu-ready",
metric_stock_position: "gpu-ready",
metric_inbound_pipeline: "gpu-ready",
metric_average_inbound_risk: "gpu-ready",
metric_factory_available: "gpu-ready",
metric_scrap_rate: "gpu-ready",
metric_supplier_outages: "gpu-ready",
metric_average_order_age: "cpu-only",
},
supplyChainProfit: {
metric_service_level: "gpu-ready",
metric_profit: "gpu-ready",
},
vaccinationCampaign: {
metric__total_cost: "gpu-ready",
metric__infected: "gpu-ready",
metric__attack_rate: "gpu-ready",
},
});

const production = analyze(
allExamples.productionMachines.petriNetDefinition,
);
const productionRows = production.items.filter(
(item) => item.kind === "metric",
);
for (const row of productionRows) {
if (row.itemId === "metric__average_machine_damage") {
expect(row.detail).toMatch(/Cannot be translated to WGSL: .*concat/);
} else {
expect(row.detail, row.itemId).toBe(production.shaderFailure);
}
expect(row.hirNodeCount).toBeGreaterThan(0);
}
const orderAge = analyze(
allExamples.supplyChainWithDisruption.petriNetDefinition,
).items.find((item) => item.itemId === "metric_average_order_age");
expect(orderAge?.detail).toMatch(/Cannot be translated to WGSL: .*concat/);
});

it("compiles the shader with the experiment's metrics", () => {
// Without specs no metric is emitted; with SIR's own metric as an
// expression spec the sample block is in the WGSL, so `wgsl` and
// `shaderFailure` cover metric emission and not only the net's code.
const sdcpn = sirModel.petriNetDefinition;
expect(analyze(sdcpn).wgsl).not.toContain("let v0: f32");

const { artifacts } = compileHirArtifacts(sdcpn, undefined, {
includeHir: true,
});
const report = analyzeCompilation({
sdcpn,
artifacts,
metricSpecs: [modelMetricSpec(sdcpn, "metric__infected_fraction")],
});

expect(report.gpuReady).toBe(true);
expect(report.metricFailure).toBeNull();
expect(report.wgsl).toContain("let v0: f32 = select(");
});

it("reports metric shapes the GPU histogram cannot serve", () => {
const sdcpn = sirModel.petriNetDefinition;
const { artifacts } = compileHirArtifacts(sdcpn, undefined, {
Expand Down Expand Up @@ -286,8 +419,37 @@ describe("analyzeCompilation", () => {
},
],
});
expect(withFiringCount.metricFailure).not.toBeNull();
expect(withFiringCount.metricFailure).toMatch(/transition firings/);
expect(withFiringCount.gpuReady).toBe(false);

const infectedFraction = modelMetricSpec(
sdcpn,
"metric__infected_fraction",
);
const { hir: _stripped, ...artifactWithoutHir } = infectedFraction.artifact;
const withoutHir = analyzeCompilation({
sdcpn,
artifacts,
metricSpecs: [{ ...infectedFraction, artifact: artifactWithoutHir }],
});
expect(withoutHir.metricFailure).toMatch(/HIR tree/);
expect(withoutHir.gpuReady).toBe(false);
});

it("names the construct that keeps an expression metric on the CPU", () => {
const sdcpn = allExamples.productionMachines.petriNetDefinition;
const { artifacts } = compileHirArtifacts(sdcpn, undefined, {
includeHir: true,
});
const report = analyzeCompilation({
sdcpn,
artifacts,
metricSpecs: [modelMetricSpec(sdcpn, "metric__average_machine_damage")],
});

expect(report.metricFailure).toMatch(
/Metric "Average machine damage" cannot be translated to WGSL: .*concat.*\.$/,
);
});

it("does not run the metric gate when no metrics are given", () => {
Expand Down
Loading
Loading