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

Filter by extension

Filter by extension

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

Add Metrics: user-authored functions over simulation state that produce a single number per frame, plotted via a new metric picker in the simulation timeline header
16 changes: 15 additions & 1 deletion libs/@hashintel/petrinaut/src/components/select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,10 @@ const itemStyle = css({
cursor: "pointer",
outline: "none",
color: "neutral.fg.body",
// Allow the inner text (a flex child) to shrink below its intrinsic
// content size so `text-overflow: ellipsis` on the label kicks in instead
// of the label wrapping or overflowing the dropdown container.
minWidth: "[0]",
"&[data-highlighted]": {
backgroundColor: "neutral.bg.min.hover",
},
Expand All @@ -166,6 +170,14 @@ const itemStyle = css({
},
});

const itemTextStyle = css({
flex: "[1]",
minWidth: "[0]",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
});

// -- Types --------------------------------------------------------------------

export interface SelectOption {
Expand Down Expand Up @@ -297,7 +309,9 @@ const SelectBase: React.FC<SelectBaseProps> = ({
{renderItem ? (
renderItem(item)
) : (
<ArkSelect.ItemText>{item.label}</ArkSelect.ItemText>
<ArkSelect.ItemText className={itemTextStyle}>
{item.label}
</ArkSelect.ItemText>
)}
</ArkSelect.Item>
))}
Expand Down
12 changes: 12 additions & 0 deletions libs/@hashintel/petrinaut/src/core/schemas/metric-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { z } from "zod";

import type { Metric } from "../types/sdcpn";

export const metricSchema = z.object({
id: z.string().min(1),
name: z.string().min(1, "Metric name is required"),
description: z.string().optional(),
code: z.string(),
}) satisfies z.ZodType<Metric>;

export type MetricSchema = typeof metricSchema;
17 changes: 17 additions & 0 deletions libs/@hashintel/petrinaut/src/core/types/sdcpn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,13 +112,30 @@ export type Scenario = {
};
};

/**
* A metric is a user-authored function that takes the current simulation state
* (places with token counts and, for colored places, named token attributes)
* and returns a single number to be plotted over time on the timeline chart.
*/
export type Metric = {
id: ID;
name: string;
description?: string;
/**
* Function body invoked with `state` in scope. Must `return` a number.
* See `MetricState` (in `simulation/compile-metric.ts`) for the input shape.
*/
code: string;
};

export type SDCPN = {
places: Place[];
transitions: Transition[];
types: Color[];
differentialEquations: DifferentialEquation[];
parameters: Parameter[];
scenarios?: Scenario[];
metrics?: Metric[];
};

export type MinimalNetMetadata = {
Expand Down
14 changes: 14 additions & 0 deletions libs/@hashintel/petrinaut/src/examples/sir-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,5 +156,19 @@ export const sirModel: { title: string; petriNetDefinition: SDCPN } = {
},
},
],
metrics: [
{
id: "metric__infected_fraction",
name: "Infected Fraction",
description: "Share of the population currently infected.",
code: [
"const s = state.places.Susceptible?.count ?? 0;",
"const i = state.places.Infected?.count ?? 0;",
"const r = state.places.Recovered?.count ?? 0;",
"const total = s + i + r;",
"return total === 0 ? 0 : i / total;",
].join("\n"),
},
],
},
};
8 changes: 8 additions & 0 deletions libs/@hashintel/petrinaut/src/file-format/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,13 +95,21 @@ const scenarioSchema = z.object({
initialState: initialStateSchema.default({ type: "per_place", content: {} }),
});

const metricSchema = z.object({
id: z.string(),
name: z.string(),
description: z.string().optional(),
code: z.string().default(""),
});

const sdcpnSchema = z.object({
places: z.array(placeSchema),
transitions: z.array(transitionSchema),
types: z.array(colorSchema).default([]),
differentialEquations: z.array(differentialEquationSchema).default([]),
parameters: z.array(parameterSchema).default([]),
scenarios: z.array(scenarioSchema).default([]),
metrics: z.array(metricSchema).default([]),
});

const fileMetaSchema = z.object({
Expand Down
104 changes: 104 additions & 0 deletions libs/@hashintel/petrinaut/src/simulation/compile-metric.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { describe, expect, it } from "vitest";

import type { Metric } from "../core/types/sdcpn";
import { compileMetric, type MetricState } from "./compile-metric";

const metric = (overrides: Partial<Metric> = {}): Metric => ({
id: "m1",
name: "Test",
code: "return 0;",
...overrides,
});

const state = (overrides?: Partial<MetricState>): 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.<name>.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("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);
}
});
});
117 changes: 117 additions & 0 deletions libs/@hashintel/petrinaut/src/simulation/compile-metric.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import type { Metric } from "../core/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: Record<string, number>[];
}

/**
* 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<string, MetricPlaceState>;
}

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. Mirrors the helper in `compile-scenario.ts`.
*/
function createSafeState(state: MetricState): MetricState {
const places = Object.create(null) as Record<string, MetricPlaceState>;
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;
}

/**
* Globals shadowed inside the metric function body β€” declared as `var` so they
* become `undefined` in scope, preventing the expression from accessing
* browser/environment APIs. Mirrors the list in `compile-scenario.ts`.
*/
const SHADOWED_GLOBALS = [
"window",
"document",
"globalThis",
"self",
"fetch",
"XMLHttpRequest",
"importScripts",
// `eval` cannot be shadowed via `var` in strict mode (SyntaxError).
// Mitigated by shadowing `Function` (blocks eval construction) and
// `globalThis` (blocks globalThis.eval).
"Function",
"setTimeout",
"setInterval",
"queueMicrotask",
].join(",");

// -- 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 and the `state`
* argument frozen with no prototype chain.
*
* 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 = 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 };
}
Loading
Loading