diff --git a/libs/@hashintel/petrinaut/src/ui/dev/constraint-prototypes/README.md b/libs/@hashintel/petrinaut/src/ui/dev/constraint-prototypes/README.md new file mode 100644 index 00000000000..699a6573f0f --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/dev/constraint-prototypes/README.md @@ -0,0 +1,64 @@ +# Constraint authoring prototypes (FE-1556) + +Playable Storybook explorations of how optimization constraints get +defined, building on FE-1518's boolean-expression groundwork and the +FE-1282/FE-1339 RFC. Run Storybook and open **Dev / Constraint +Prototypes**. Nothing in this folder ships: the stories run against a toy +cooling-tank model with their own small expression evaluator, kept +syntax-compatible with the product surface (product expressions add the +`scenario.*` namespaces; the toy model exposes bare names). + +Two constraint kinds, with different goals: + +- **Parameter constraints** exist for the sampler. The goal is to draw + from the safe region directly — shape the sampling space — rather than + prune most draws after the fact. +- **State constraints** monitor the run. The goal is a margin: a signed + robustness value that feeds the objective as a continuous multiplier, + ~1 inside the safe region and dropping progressively to zero as the + violation deepens, so the sampler keeps a gradient toward safety. + +## The five prototypes + +1. **Predicate with a derived margin** — constraints stay the boolean + expressions FE-1518 ships; margins, robustness, and the smooth penalty + are derived (comparison slack, `&&` = min, worst step). Includes the + masking comparison: min across constraints vs mean-of-violations + (the AGM lesson — one deep violation hides all other progress from + the sampler). +2. **Margin-first** — the user authors the margin expression itself + (`80 - temperature`, a number that must stay ≥ 0), with the RFC's + canonical rewrite offered when they type a comparison instead, and the + normalisation scale as an explicit authoring control. +3. **Sentence builder** — structured pickers (scope · metric · direction + · bound · across-runs quorum) that compile to the same expressions, + judged over 24 seeded runs; the across-runs quorum is the RFC's chance + constraint (CH1) made visible. +4. **Parameter sampling playground** — one predicate, four sampling + strategies side by side (uniform / rejection / soft-learning / + by-construction), with the router that classifies each `&&` conjunct: + bounds fold into the box, `a <= b` becomes an ordering transform, the + affine part is walked as a polytope (hit-and-run), nonlinear leftovers + reject. Draw counts make the "shape, don't prune" argument concrete. +5. **Temporal operators** — the extension, not the base: `always`, + `eventually`, `during`, `within`, `until`, `atEnd` as ordinary + functions in the same grammar, with STL quantitative semantics and an + optional logsumexp smoothing temperature ("Smooth Operator", Pant et + al. 2017) that trades exactness (±ln(m)·T) for differentiability. + +## How the pieces map to a real implementation + +- `expr.ts` `marginOf` mirrors the Python HIR evaluator's `margin()` + already on FE-1518 (comparison slack, min/max composition) — a + TypeScript twin over real HIR would replace it. +- `robustness.ts` is the STL layer: per-step margins collapse over the + trace; the same recursion works over HIR. Discrete-time, closed + windows in simulated time units; `until`'s hold is a strict prefix. +- `sampling.ts` `planConjuncts` is the automatic router the research + supports (Ax parses linear constraint strings the same way): a + declarative predicate compiles per conjunct into bound-folding, + ordering transforms, a polytope walk, or rejection — with the soft + margin channel (Optuna's `constraints_func`) always layered on top. +- `penaltyMultiplier` is the "objective multiplier that drops + continuously to zero" — exponential (exactly 1 inside), logistic + (discounts near-boundary satisfaction), and hard (for contrast). diff --git a/libs/@hashintel/petrinaut/src/ui/dev/constraint-prototypes/charts.tsx b/libs/@hashintel/petrinaut/src/ui/dev/constraint-prototypes/charts.tsx new file mode 100644 index 00000000000..fd87d6e13e3 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/dev/constraint-prototypes/charts.tsx @@ -0,0 +1,372 @@ +/** + * The prototypes' plotting kit: small pure-SVG charts (a trace with a safe + * band, a per-step margin strip, a function curve, a 2D sample scatter over + * a feasibility heatmap). Props in, SVG out — no effects, no libraries, so + * every chart re-renders synchronously as the user drags a slider. + */ + +import { css } from "@hashintel/ds-helpers/css"; + +const SAFE_FILL = "rgb(34 197 94 / 0.12)"; +const VIOLATION_FILL = "rgb(239 68 68 / 0.16)"; +const GRID_STROKE = "rgb(100 116 139 / 0.25)"; +const SERIES_COLORS = ["#2563eb", "#9333ea", "#0d9488", "#d97706"]; + +const frameStyle = css({ + border: "1px solid", + borderColor: "neutral.a45", + borderRadius: "sm", + backgroundColor: "neutral.s00", + display: "block", + maxWidth: "full", +}); + +const axisLabelStyle = { + fontSize: 9, + fill: "rgb(100 116 139)", + fontFamily: "ui-sans-serif, system-ui", +} as const; + +function domainOf(values: number[]): [number, number] { + const finite = values.filter((value) => Number.isFinite(value)); + if (finite.length === 0) { + return [0, 1]; + } + const min = Math.min(...finite); + const max = Math.max(...finite); + const pad = (max - min || 1) * 0.08; + return [min - pad, max + pad]; +} + +function formatTick(value: number): string { + if (Math.abs(value) >= 100) { + return value.toFixed(0); + } + return value.toFixed(1); +} + +export type TraceSeries = { + name: string; + values: readonly number[]; + color?: string; +}; + +/** + * A run over time: one line per series, an optional shaded safe band for + * the constrained quantity, and red column washes on violating steps. + */ +export const TracePlot = ({ + times, + series, + band, + violations, + width = 560, + height = 180, +}: { + times: readonly number[]; + series: readonly TraceSeries[]; + /** Safe interval for the first series' units; either edge optional. */ + band?: { min?: number; max?: number }; + /** Per-step violation flags, painted as column washes. */ + violations?: readonly boolean[]; + width?: number; + height?: number; +}) => { + const [tMin, tMax] = [times[0] ?? 0, times[times.length - 1] ?? 1]; + const [yMin, yMax] = domainOf([ + ...series.flatMap((entry) => [...entry.values]), + ...(band?.min !== undefined ? [band.min] : []), + ...(band?.max !== undefined ? [band.max] : []), + ]); + const x = (time: number) => + 8 + ((time - tMin) / (tMax - tMin || 1)) * (width - 16); + const y = (value: number) => + height - 16 - ((value - yMin) / (yMax - yMin || 1)) * (height - 28); + + const bandTop = y(band?.max ?? yMax); + const bandBottom = y(band?.min ?? yMin); + + return ( + + {band ? ( + + ) : null} + {violations?.map((violated, index) => + violated ? ( + + ) : null, + )} + {[yMin, (yMin + yMax) / 2, yMax].map((tick) => ( + + + + {formatTick(tick)} + + + ))} + {series.map((entry, index) => ( + `${x(times[step]!)},${y(value)}`) + .join(" ")} + /> + ))} + {series.map((entry, index) => ( + + {entry.name} + + ))} + + ); +}; + +/** Per-step margins as a signed strip: green above zero, red below. */ +export const MarginStrip = ({ + values, + width = 560, + height = 56, +}: { + values: readonly number[]; + width?: number; + height?: number; +}) => { + const magnitude = Math.max( + 1e-9, + ...values.map((value) => (Number.isFinite(value) ? Math.abs(value) : 0)), + ); + const mid = height / 2; + const barWidth = (width - 16) / Math.max(1, values.length); + return ( + + + {values.map((value, index) => { + const clamped = Number.isFinite(value) + ? value + : Math.sign(value) * magnitude; + const extent = (Math.abs(clamped) / magnitude) * (mid - 6); + return ( + = 0 ? mid - extent : mid} + height={Math.max(0.5, extent)} + fill={ + clamped >= 0 ? "rgb(34 197 94 / 0.7)" : "rgb(239 68 68 / 0.8)" + } + /> + ); + })} + + margin per step (green = satisfied) + + + ); +}; + +/** A function curve over a domain with an optional marker point. */ +export const CurvePlot = ({ + domain, + fn, + marker, + label, + width = 280, + height = 140, +}: { + domain: [number, number]; + fn: (x: number) => number; + marker?: number; + label?: string; + width?: number; + height?: number; +}) => { + const samples = 120; + const xs = Array.from( + { length: samples + 1 }, + (_, index) => domain[0] + ((domain[1] - domain[0]) * index) / samples, + ); + const values = xs.map(fn); + const [yMin, yMax] = domainOf([...values, 0, 1]); + const x = (value: number) => + 8 + ((value - domain[0]) / (domain[1] - domain[0] || 1)) * (width - 16); + const y = (value: number) => + height - 16 - ((value - yMin) / (yMax - yMin || 1)) * (height - 24); + return ( + + + + `${x(value)},${y(values[index]!)}`) + .join(" ")} + /> + {marker !== undefined ? ( + + ) : null} + {label ? ( + + {label} + + ) : null} + + ); +}; + +/** + * 2D samples over a coarse feasibility grid: the region test paints safe + * cells green, sample points are dots (violating ones red). + */ +export const ScatterPlot = ({ + xName, + yName, + xDomain, + yDomain, + points, + regionTest, + title, + size = 240, +}: { + xName: string; + yName: string; + xDomain: [number, number]; + yDomain: [number, number]; + points: readonly { x: number; y: number; ok: boolean }[]; + regionTest?: (x: number, y: number) => boolean; + title?: string; + size?: number; +}) => { + const cells = 24; + const x = (value: number) => + 26 + ((value - xDomain[0]) / (xDomain[1] - xDomain[0] || 1)) * (size - 34); + const y = (value: number) => + size - + 22 - + ((value - yDomain[0]) / (yDomain[1] - yDomain[0] || 1)) * (size - 34); + const cellsFlat: { cx: number; cy: number; ok: boolean }[] = []; + if (regionTest) { + for (let column = 0; column < cells; column += 1) { + for (let row = 0; row < cells; row += 1) { + const px = + xDomain[0] + ((column + 0.5) / cells) * (xDomain[1] - xDomain[0]); + const py = + yDomain[0] + ((row + 0.5) / cells) * (yDomain[1] - yDomain[0]); + cellsFlat.push({ cx: px, cy: py, ok: regionTest(px, py) }); + } + } + } + const cellWidth = (size - 34) / cells; + const cellHeight = (size - 34) / cells; + return ( + + {cellsFlat.map((cell) => + cell.ok ? ( + + ) : null, + )} + {points.map((point, index) => ( + + ))} + {title ? ( + + {title} + + ) : null} + + {xName} + + + {yName} + + + ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/dev/constraint-prototypes/constraint-prototypes.stories.tsx b/libs/@hashintel/petrinaut/src/ui/dev/constraint-prototypes/constraint-prototypes.stories.tsx new file mode 100644 index 00000000000..de44a7304c4 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/dev/constraint-prototypes/constraint-prototypes.stories.tsx @@ -0,0 +1,76 @@ +import { MonacoProvider } from "../../monaco/provider"; +import { MarginFirstPrototype } from "./prototype-margin-first"; +import { PredicatePrototype } from "./prototype-predicate"; +import { SamplingPrototype } from "./prototype-sampling"; +import { SentencePrototype } from "./prototype-sentence"; +import { TemporalPrototype } from "./prototype-temporal"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; + +/** + * Playable explorations of how optimization constraints get DEFINED, per + * FE-1556 — building on FE-1518's boolean-expression groundwork and the + * FE-1282/FE-1339 RFC. Two constraint kinds, two goals: + * + * - **parameter constraints** shape the sampling space (draw from the safe + * region rather than pruning bad draws) — prototype 4; + * - **state constraints** monitor the run, with a margin-based robustness + * that feeds the objective as a smooth multiplier dropping to zero + * outside the safe region — prototypes 1, 2, and 5; + * - prototype 3 is the structured-authoring alternative that compiles to + * the same expressions. + * + * Everything here runs against a toy cooling-tank model (no engine + * involvement) and lives outside the shipped bundle. + */ +const meta = { + title: "Dev / Constraint Prototypes", + parameters: { + layout: "padded", + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Predicate: Story = { + name: "1 · Predicate with derived margin", + render: () => ( + + + + ), +}; + +export const MarginFirst: Story = { + name: "2 · Margin-first", + render: () => ( + + + + ), +}; + +export const SentenceBuilder: Story = { + name: "3 · Sentence builder", + render: () => , +}; + +export const SamplingPlayground: Story = { + name: "4 · Parameter sampling playground", + render: () => ( + + + + ), +}; + +export const TemporalOperators: Story = { + name: "5 · Temporal operators (extension)", + render: () => ( + + + + ), +}; diff --git a/libs/@hashintel/petrinaut/src/ui/dev/constraint-prototypes/expr.test.ts b/libs/@hashintel/petrinaut/src/ui/dev/constraint-prototypes/expr.test.ts new file mode 100644 index 00000000000..29c2b619f87 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/dev/constraint-prototypes/expr.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest"; + +import { + canonicalMarginExpr, + conjunctsOf, + evaluateExpression, + linearFormOf, + marginOf, + parseExpression, + printExpression, +} from "./expr"; + +const env = (values: Record) => + new Map(Object.entries(values)); + +const evaluate = (source: string, values: Record) => + evaluateExpression(parseExpression(source), env(values)); + +const margin = (source: string, values: Record) => + marginOf(parseExpression(source), env(values)); + +describe("parseExpression / evaluateExpression", () => { + it("respects arithmetic precedence and parentheses", () => { + expect(evaluate("1 + 2 * 3", {})).toBe(7); + expect(evaluate("(1 + 2) * 3", {})).toBe(9); + expect(evaluate("-2 * 3", {})).toBe(-6); + expect(evaluate("10 % 4 + 2e1", {})).toBe(22); + }); + + it("resolves dotted identifiers from the environment", () => { + expect( + evaluate("scenario.a + parameters.b", { + "scenario.a": 2, + "parameters.b": 40, + }), + ).toBe(42); + }); + + it("evaluates comparisons, logic, and ternaries", () => { + expect(evaluate("1 < 2 && 3 >= 3", {})).toBe(true); + expect(evaluate("1 == 2 || false", {})).toBe(false); + expect(evaluate("!(x > 5) ? 10 : 20", { x: 7 })).toBe(20); + }); + + it("evaluates math calls", () => { + expect(evaluate("min(3, max(1, 2))", {})).toBe(2); + expect(evaluate("abs(-4) + sqrt(9)", {})).toBe(7); + }); + + it("rejects unknown names, functions, and trailing junk", () => { + expect(() => evaluate("mystery", {})).toThrow('Unknown name "mystery"'); + expect(() => evaluate("shrug(1)", {})).toThrow('Unknown function "shrug"'); + expect(() => parseExpression("1 + ")).toThrow(); + expect(() => parseExpression("1 2")).toThrow(/trailing/); + }); + + it("round-trips through printExpression", () => { + for (const source of [ + "a + b * c", + "(a + b) * c", + "a < b && (c || !d)", + "x > 5 ? 1 : 0", + "min(a, b - 1)", + ]) { + const printed = printExpression(parseExpression(source)); + expect(printExpression(parseExpression(printed))).toBe(printed); + } + }); +}); + +describe("marginOf", () => { + it("gives signed slack for comparisons", () => { + expect(margin("temperature < 80", { temperature: 75 })).toBe(5); + expect(margin("temperature < 80", { temperature: 85 })).toBe(-5); + expect(margin("throughput >= 100", { throughput: 130 })).toBe(30); + }); + + it("composes && as min and || as max", () => { + const values = { a: 3, b: 10 }; + expect(margin("a > 0 && b < 12", values)).toBe(2); + expect(margin("a > 5 || b < 12", values)).toBe(2); + }); + + it("negation flips the sign", () => { + expect(margin("!(temperature < 80)", { temperature: 75 })).toBe(-5); + }); + + it("equality margins follow the Python evaluator", () => { + expect(margin("a == 4", { a: 6 })).toBe(-2); + expect(margin("a != 4", { a: 6 })).toBe(2); + expect(margin("flag == true", { flag: true })).toBe(Infinity); + }); + + it("agrees in sign with boolean evaluation", () => { + const cases: [string, Record][] = [ + ["a + b < 10", { a: 3, b: 4 }], + ["a + b < 10", { a: 8, b: 4 }], + ["a > 1 && b > 1 || a < 0", { a: 2, b: 0.5 }], + ]; + for (const [source, values] of cases) { + const holds = evaluate(source, values) === true; + expect(margin(source, values) >= 0).toBe(holds); + } + }); +}); + +describe("linearFormOf", () => { + const names = new Set(["x", "y"]); + + it("extracts affine coefficients", () => { + const form = linearFormOf(parseExpression("2 * x - y / 2 + 3"), names)!; + expect(form.constant).toBe(3); + expect(form.coefficients.get("x")).toBe(2); + expect(form.coefficients.get("y")).toBe(-0.5); + }); + + it("returns null for non-affine expressions", () => { + expect(linearFormOf(parseExpression("x * y"), names)).toBeNull(); + expect(linearFormOf(parseExpression("1 / x"), names)).toBeNull(); + expect(linearFormOf(parseExpression("sqrt(x)"), names)).toBeNull(); + }); +}); + +describe("conjunctsOf / canonicalMarginExpr", () => { + it("splits top-level conjunctions only", () => { + const parts = conjunctsOf( + parseExpression("a < 1 && (b > 2 || c > 3) && d <= 4"), + ); + expect(parts.map(printExpression)).toEqual([ + "a < 1", + "b > 2 || c > 3", + "d <= 4", + ]); + }); + + it("rewrites comparisons into margin >= 0 form", () => { + const rewritten = canonicalMarginExpr(parseExpression("makespan <= 8"))!; + expect(printExpression(rewritten)).toBe("8 - makespan"); + expect(canonicalMarginExpr(parseExpression("a < 1 || b < 2"))).toBeNull(); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/dev/constraint-prototypes/expr.ts b/libs/@hashintel/petrinaut/src/ui/dev/constraint-prototypes/expr.ts new file mode 100644 index 00000000000..dc5075e0903 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/dev/constraint-prototypes/expr.ts @@ -0,0 +1,570 @@ +/** + * The expression core the constraint prototypes share: a tiny parser and + * evaluator for the arithmetic/boolean subset the constraint surfaces use, + * plus the two analyses the prototypes demonstrate — the signed margin of a + * boolean expression (the robustness value the RFC calls `g`) and the + * linear form of a numeric expression (what routes a parameter constraint + * to a sampling mechanism). + * + * Prototype-only: the product lowers TypeScript to HIR through the language + * worker; this module exists so the Storybook prototypes can re-evaluate + * thousands of points per frame synchronously. The grammar deliberately + * matches the product surface (dotted identifiers such as + * `scenario.flow_rate`, `&&`/`||`, comparisons, ternaries, `min`/`max`…), + * so anything authored here lowers through the real pipeline unchanged. + */ + +export type ExprNode = + | { kind: "number"; value: number } + | { kind: "boolean"; value: boolean } + | { kind: "ident"; name: string } + | { kind: "unary"; op: "!" | "-"; operand: ExprNode } + | { kind: "binary"; op: BinaryOp; left: ExprNode; right: ExprNode } + | { + kind: "cond"; + condition: ExprNode; + whenTrue: ExprNode; + whenFalse: ExprNode; + } + | { kind: "call"; name: string; args: ExprNode[] }; + +export type BinaryOp = + | "+" + | "-" + | "*" + | "/" + | "%" + | "<" + | "<=" + | ">" + | ">=" + | "==" + | "!=" + | "&&" + | "||"; + +export class ExprError extends Error { + readonly index: number; + + constructor(message: string, index: number) { + super(message); + this.name = "ExprError"; + this.index = index; + } +} + +type Token = + | { kind: "number"; value: number; index: number } + | { kind: "ident"; name: string; index: number } + | { kind: "punct"; text: string; index: number }; + +const PUNCTUATION = [ + "&&", + "||", + "<=", + ">=", + "==", + "!=", + "(", + ")", + ",", + "?", + ":", + "!", + "<", + ">", + "+", + "-", + "*", + "/", + "%", +]; + +function tokenize(source: string): Token[] { + const tokens: Token[] = []; + let index = 0; + outer: while (index < source.length) { + const char = source[index]!; + if (/\s/.test(char)) { + index += 1; + continue; + } + if ( + /[0-9]/.test(char) || + (char === "." && /[0-9]/.test(source[index + 1] ?? "")) + ) { + const match = /^[0-9]*\.?[0-9]+(?:[eE][+-]?[0-9]+)?/.exec( + source.slice(index), + )!; + tokens.push({ kind: "number", value: Number(match[0]), index }); + index += match[0].length; + continue; + } + if (/[A-Za-z_]/.test(char)) { + const match = + /^[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)*/.exec( + source.slice(index), + )!; + tokens.push({ kind: "ident", name: match[0], index }); + index += match[0].length; + continue; + } + for (const punct of PUNCTUATION) { + if (source.startsWith(punct, index)) { + tokens.push({ kind: "punct", text: punct, index }); + index += punct.length; + continue outer; + } + } + throw new ExprError(`Unexpected character "${char}"`, index); + } + return tokens; +} + +/** Binding power per binary operator; higher binds tighter. */ +const BINDING_POWER: Record = { + "||": 1, + "&&": 2, + "==": 3, + "!=": 3, + "<": 4, + "<=": 4, + ">": 4, + ">=": 4, + "+": 5, + "-": 5, + "*": 6, + "/": 6, + "%": 6, +}; + +export function parseExpression(source: string): ExprNode { + const tokens = tokenize(source); + let position = 0; + + const peek = () => tokens[position]; + const take = () => tokens[position++]; + const expect = (text: string) => { + const token = take(); + if (token?.kind !== "punct" || token.text !== text) { + throw new ExprError(`Expected "${text}"`, token?.index ?? source.length); + } + }; + + function parsePrimary(): ExprNode { + const token = take(); + if (!token) { + throw new ExprError("Unexpected end of expression", source.length); + } + if (token.kind === "number") { + return { kind: "number", value: token.value }; + } + if (token.kind === "ident") { + if (token.name === "true" || token.name === "false") { + return { kind: "boolean", value: token.name === "true" }; + } + const next = peek(); + if (next?.kind === "punct" && next.text === "(") { + take(); + const args: ExprNode[] = []; + const closing = peek(); + if (!(closing?.kind === "punct" && closing.text === ")")) { + for (;;) { + // eslint-disable-next-line no-use-before-define -- mutual recursion + args.push(parseTernary()); + const separator = peek(); + if (separator?.kind === "punct" && separator.text === ",") { + take(); + continue; + } + break; + } + } + expect(")"); + return { kind: "call", name: token.name, args }; + } + return { kind: "ident", name: token.name }; + } + if (token.text === "(") { + // eslint-disable-next-line no-use-before-define -- mutual recursion + const inner = parseTernary(); + expect(")"); + return inner; + } + if (token.text === "!" || token.text === "-") { + return { kind: "unary", op: token.text, operand: parsePrimary() }; + } + throw new ExprError(`Unexpected "${token.text}"`, token.index); + } + + function parseBinary(minPower: number): ExprNode { + let left = parsePrimary(); + for (;;) { + const token = peek(); + if (token?.kind !== "punct") { + break; + } + if (!(token.text in BINDING_POWER)) { + break; + } + const power = BINDING_POWER[token.text as BinaryOp]; + if (power < minPower) { + break; + } + take(); + const right = parseBinary(power + 1); + left = { kind: "binary", op: token.text as BinaryOp, left, right }; + } + return left; + } + + function parseTernary(): ExprNode { + const condition = parseBinary(1); + const token = peek(); + if (token?.kind === "punct" && token.text === "?") { + take(); + const then = parseTernary(); + expect(":"); + const otherwise = parseTernary(); + return { kind: "cond", condition, whenTrue: then, whenFalse: otherwise }; + } + return condition; + } + + const root = parseTernary(); + const trailing = tokens[position]; + if (trailing) { + throw new ExprError( + `Unexpected trailing "${source.slice(trailing.index)}"`, + trailing.index, + ); + } + return root; +} + +/** Values an expression evaluates against, keyed by full dotted name. */ +export type ExprEnv = ReadonlyMap; + +function truthy(value: number | boolean): boolean { + return value !== false && value !== 0; +} + +function asNumber(value: number | boolean): number { + return typeof value === "number" ? value : value ? 1 : 0; +} + +const MATH_FUNCTIONS: Record number> = { + min: Math.min, + max: Math.max, + abs: Math.abs, + sqrt: Math.sqrt, + exp: Math.exp, + log: Math.log, + floor: Math.floor, + ceil: Math.ceil, + round: Math.round, + pow: Math.pow, +}; + +function evaluateBinary( + node: ExprNode & { kind: "binary" }, + env: ExprEnv, +): number | boolean { + if (node.op === "&&") { + // eslint-disable-next-line no-use-before-define -- mutual recursion + return truthy(evaluateExpression(node.left, env)) + ? // eslint-disable-next-line no-use-before-define -- mutual recursion + evaluateExpression(node.right, env) + : false; + } + if (node.op === "||") { + // eslint-disable-next-line no-use-before-define -- mutual recursion + const left = evaluateExpression(node.left, env); + // eslint-disable-next-line no-use-before-define -- mutual recursion + return truthy(left) ? left : evaluateExpression(node.right, env); + } + // eslint-disable-next-line no-use-before-define -- mutual recursion + const left = evaluateExpression(node.left, env); + // eslint-disable-next-line no-use-before-define -- mutual recursion + const right = evaluateExpression(node.right, env); + switch (node.op) { + case "+": + return asNumber(left) + asNumber(right); + case "-": + return asNumber(left) - asNumber(right); + case "*": + return asNumber(left) * asNumber(right); + case "/": + return asNumber(left) / asNumber(right); + case "%": + return asNumber(left) % asNumber(right); + case "<": + return asNumber(left) < asNumber(right); + case "<=": + return asNumber(left) <= asNumber(right); + case ">": + return asNumber(left) > asNumber(right); + case ">=": + return asNumber(left) >= asNumber(right); + case "==": + return left === right; + case "!=": + return left !== right; + } +} + +export function evaluateExpression( + node: ExprNode, + env: ExprEnv, +): number | boolean { + switch (node.kind) { + case "number": + case "boolean": + return node.value; + case "ident": { + const value = env.get(node.name); + if (value === undefined) { + throw new ExprError(`Unknown name "${node.name}"`, 0); + } + return value; + } + case "unary": { + const operand = evaluateExpression(node.operand, env); + return node.op === "!" ? !truthy(operand) : -asNumber(operand); + } + case "binary": + return evaluateBinary(node, env); + case "cond": + return truthy(evaluateExpression(node.condition, env)) + ? evaluateExpression(node.whenTrue, env) + : evaluateExpression(node.whenFalse, env); + case "call": { + const fn = MATH_FUNCTIONS[node.name]; + if (!fn) { + throw new ExprError(`Unknown function "${node.name}"`, 0); + } + return fn( + ...node.args.map((argument) => + asNumber(evaluateExpression(argument, env)), + ), + ); + } + } +} + +/** + * The signed margin (robustness) of a boolean expression: `>= 0` iff it + * holds, with magnitude measuring distance to the boundary. Comparisons + * yield signed slack, `&&` takes the `min`, `||` the `max`, `!` negates, + * and a plain boolean is `±Infinity` (no boundary to measure) — the same + * rules the Python evaluator applies to constraint HIR. + */ +export function marginOf(node: ExprNode, env: ExprEnv): number { + switch (node.kind) { + case "boolean": + return node.value ? Infinity : -Infinity; + case "number": + return truthy(node.value) ? Infinity : -Infinity; + case "ident": + return truthy(evaluateExpression(node, env)) ? Infinity : -Infinity; + case "unary": + if (node.op === "!") { + return -marginOf(node.operand, env); + } + return truthy(evaluateExpression(node, env)) ? Infinity : -Infinity; + case "binary": + switch (node.op) { + case "&&": + return Math.min(marginOf(node.left, env), marginOf(node.right, env)); + case "||": + return Math.max(marginOf(node.left, env), marginOf(node.right, env)); + case "<": + case "<=": + return ( + asNumber(evaluateExpression(node.right, env)) - + asNumber(evaluateExpression(node.left, env)) + ); + case ">": + case ">=": + return ( + asNumber(evaluateExpression(node.left, env)) - + asNumber(evaluateExpression(node.right, env)) + ); + case "==": { + const left = evaluateExpression(node.left, env); + const right = evaluateExpression(node.right, env); + if (typeof left === "boolean" || typeof right === "boolean") { + return left === right ? Infinity : -Infinity; + } + return -Math.abs(left - right); + } + case "!=": { + const left = evaluateExpression(node.left, env); + const right = evaluateExpression(node.right, env); + if (typeof left === "boolean" || typeof right === "boolean") { + return left !== right ? Infinity : -Infinity; + } + return Math.abs(left - right); + } + default: + return truthy(evaluateExpression(node, env)) ? Infinity : -Infinity; + } + case "cond": + return truthy(evaluateExpression(node.condition, env)) + ? marginOf(node.whenTrue, env) + : marginOf(node.whenFalse, env); + case "call": + return truthy(evaluateExpression(node, env)) ? Infinity : -Infinity; + } +} + +/** A numeric expression as `constant + Σ coefficient · name`, when it is one. */ +export type LinearForm = { + constant: number; + coefficients: ReadonlyMap; +}; + +function scaleLinear(form: LinearForm, factor: number): LinearForm { + return { + constant: form.constant * factor, + coefficients: new Map( + [...form.coefficients].map(([name, value]) => [name, value * factor]), + ), + }; +} + +function addLinear(left: LinearForm, right: LinearForm): LinearForm { + const coefficients = new Map(left.coefficients); + for (const [name, value] of right.coefficients) { + coefficients.set(name, (coefficients.get(name) ?? 0) + value); + } + return { constant: left.constant + right.constant, coefficients }; +} + +/** + * Extracts the linear form of a numeric expression over the given names, or + * `null` when the expression is not affine in them (a product of two + * variables, a call, a variable divisor…). What a parameter constraint's + * routing decision keys on: affine conjuncts go to bound-folding, ordering + * transforms, or the polytope walk; anything else falls back to rejection. + */ +export function linearFormOf( + node: ExprNode, + variables: ReadonlySet, +): LinearForm | null { + switch (node.kind) { + case "number": + return { constant: node.value, coefficients: new Map() }; + case "boolean": + return null; + case "ident": + if (variables.has(node.name)) { + return { constant: 0, coefficients: new Map([[node.name, 1]]) }; + } + return null; + case "unary": { + if (node.op !== "-") { + return null; + } + const operand = linearFormOf(node.operand, variables); + return operand && scaleLinear(operand, -1); + } + case "binary": { + if (node.op === "+" || node.op === "-") { + const left = linearFormOf(node.left, variables); + const right = linearFormOf(node.right, variables); + if (!left || !right) { + return null; + } + return addLinear(left, scaleLinear(right, node.op === "-" ? -1 : 1)); + } + if (node.op === "*") { + const left = linearFormOf(node.left, variables); + const right = linearFormOf(node.right, variables); + if (!left || !right) { + return null; + } + if (left.coefficients.size === 0) { + return scaleLinear(right, left.constant); + } + if (right.coefficients.size === 0) { + return scaleLinear(left, right.constant); + } + return null; + } + if (node.op === "/") { + const left = linearFormOf(node.left, variables); + const right = linearFormOf(node.right, variables); + if (!left || !right || right.coefficients.size > 0) { + return null; + } + return scaleLinear(left, 1 / right.constant); + } + return null; + } + case "cond": + case "call": + return null; + } +} + +/** + * Splits a boolean expression into its top-level `&&` conjuncts — the unit + * the sampling router classifies one at a time. + */ +export function conjunctsOf(node: ExprNode): ExprNode[] { + if (node.kind === "binary" && node.op === "&&") { + return [...conjunctsOf(node.left), ...conjunctsOf(node.right)]; + } + return [node]; +} + +/** + * A comparison conjunct rewritten to canonical `margin >= 0` form: the + * margin expression whose sign decides satisfaction. `null` for conjuncts + * that are not a single comparison (disjunctions, bare booleans). + */ +export function canonicalMarginExpr(node: ExprNode): ExprNode | null { + if (node.kind !== "binary") { + return null; + } + switch (node.op) { + case "<": + case "<=": + return { kind: "binary", op: "-", left: node.right, right: node.left }; + case ">": + case ">=": + return { kind: "binary", op: "-", left: node.left, right: node.right }; + default: + return null; + } +} + +function printWithPower(node: ExprNode, minPower: number): string { + switch (node.kind) { + case "number": + return String(node.value); + case "boolean": + return String(node.value); + case "ident": + return node.name; + case "unary": + return `${node.op}${printWithPower(node.operand, 7)}`; + case "binary": { + const power = BINDING_POWER[node.op]; + const text = `${printWithPower(node.left, power)} ${node.op} ${printWithPower(node.right, power + 1)}`; + return power < minPower ? `(${text})` : text; + } + case "cond": { + const text = `${printWithPower(node.condition, 1)} ? ${printWithPower(node.whenTrue, 0)} : ${printWithPower(node.whenFalse, 0)}`; + return minPower > 0 ? `(${text})` : text; + } + case "call": + return `${node.name}(${node.args.map((argument) => printWithPower(argument, 0)).join(", ")})`; + } +} + +/** Prints an AST back to source, minimally parenthesized. */ +export function printExpression(node: ExprNode): string { + return printWithPower(node, 0); +} diff --git a/libs/@hashintel/petrinaut/src/ui/dev/constraint-prototypes/prototype-margin-first.tsx b/libs/@hashintel/petrinaut/src/ui/dev/constraint-prototypes/prototype-margin-first.tsx new file mode 100644 index 00000000000..57f63b34021 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/dev/constraint-prototypes/prototype-margin-first.tsx @@ -0,0 +1,208 @@ +/** + * Prototype 2 — "Margin-first". The user authors the margin expression + * itself — a NUMBER that must stay `>= 0` — instead of a boolean predicate. + * The RFC's canonical form becomes the authoring surface: `80 - + * temperature` rather than `temperature < 80`. Typing a comparison anyway + * is fine — the rewrite chip shows the canonical margin and one click + * adopts it. The gain is that "distance to the boundary" is explicit and + * the user controls its units and scale; the cost is asking users to think + * in slack. + */ + +import { useState } from "react"; + +import { CurvePlot, MarginStrip, TracePlot } from "./charts"; +import { + canonicalMarginExpr, + evaluateExpression, + printExpression, +} from "./expr"; +import { penaltyMultiplier } from "./robustness"; +import { + ConstraintInput, + parseConstraint, + PrototypeShell, + Row, + Section, + Slider, + Stat, +} from "./shell"; +import { + simulateToyRun, + TOY_DEFAULTS, + TOY_PARAMETERS, + toyObjective, +} from "./toy-model"; + +import type { ExprNode } from "./expr"; +import type { PenaltyKind } from "./robustness"; + +const EXPLAINER = `Here the constraint IS the margin: a numeric expression that must stay at or above zero. "Temperature stays below 80" is authored as 80 - temperature — the number of degrees to spare, at every step. Satisfaction, violation depth, and the objective multiplier all read directly off the value the user wrote, so nothing is implicit. + +The reference scale divides the margin before scoring, which is what makes two constraints with different units (degrees vs bar) comparable when they are combined — the RFC's normalisation step, surfaced as an authoring control. Type a boolean comparison instead and the chip offers its canonical margin rewrite.`; + +/** Margin authoring wants numbers; flag boolean-shaped roots. */ +function isBooleanShaped(parsed: { ok: true; node: ExprNode }): boolean { + const node = parsed.node; + return ( + node.kind === "boolean" || + (node.kind === "binary" && + ["<", "<=", ">", ">=", "==", "!=", "&&", "||"].includes(node.op)) || + (node.kind === "unary" && node.op === "!") + ); +} + +export const MarginFirstPrototype = () => { + const [source, setSource] = useState("80 - temperature"); + const [scale, setScale] = useState(80); + const [parameters, setParameters] = useState(TOY_DEFAULTS); + const [width, setWidth] = useState(0.15); + const [kind, setKind] = useState("exponential"); + + const trace = simulateToyRun(parameters); + const parsed = parseConstraint(source); + + const rewrite = + parsed.ok && isBooleanShaped(parsed) + ? canonicalMarginExpr(parsed.node) + : null; + + const margins = + parsed.ok && !isBooleanShaped(parsed) + ? trace.steps.map((step) => { + const value = evaluateExpression(parsed.node, step); + return typeof value === "number" ? value : value ? 1 : 0; + }) + : null; + const normalized = + margins === null + ? null + : margins.map((value) => value / Math.max(scale, 1e-9)); + const robustness = normalized === null ? null : Math.min(...normalized); + const objective = toyObjective(trace); + const multiplier = + robustness === null ? 1 : penaltyMultiplier(robustness, width, kind); + + return ( + +
+ = 0 ? "good" : "bad"} + /> + ) : undefined + } + /> + {rewrite ? ( + + + + + ) : null} + + + +
+ +
+ + {TOY_PARAMETERS.map((spec) => ( + + setParameters({ ...parameters, [spec.name]: value }) + } + /> + ))} + +
+ + {parsed.ok && normalized ? ( +
+ value < 0)} + /> + +
+ ) : null} + +
+ + + + + + penaltyMultiplier(margin, width, kind)} + marker={robustness ?? undefined} + label="objective multiplier vs normalised margin" + /> +
+ + + + + + 0.5 ? "good" : "bad"} + /> + +
+
+
+
+ ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/dev/constraint-prototypes/prototype-predicate.tsx b/libs/@hashintel/petrinaut/src/ui/dev/constraint-prototypes/prototype-predicate.tsx new file mode 100644 index 00000000000..50ff11a8c76 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/dev/constraint-prototypes/prototype-predicate.tsx @@ -0,0 +1,259 @@ +/** + * Prototype 1 — "Predicate with a derived margin". The user writes ordinary + * boolean expressions (what FE-1518 already ships); everything continuous + * is DERIVED: each comparison's signed slack becomes the margin, `&&`/`||` + * compose as min/max, the run's robustness is the worst step, and a smooth + * multiplier turns that robustness into the objective factor. Nothing new + * to author — the cost is that the derivation is implicit. + */ + +import { useState } from "react"; + +import { CurvePlot, MarginStrip, TracePlot } from "./charts"; +import { marginOf } from "./expr"; +import { penaltyMultiplier, stepValues, traceRobustness } from "./robustness"; +import { + ConstraintInput, + parseConstraint, + PrototypeShell, + Row, + Section, + Slider, + Stat, +} from "./shell"; +import { + simulateToyRun, + TOY_DEFAULTS, + TOY_PARAMETERS, + toyObjective, +} from "./toy-model"; + +import type { ExprNode } from "./expr"; +import type { PenaltyKind } from "./robustness"; + +const EXPLAINER = `State constraints stay plain boolean expressions — the same surface the optimization drawer already has. The continuous part is derived, never authored: a comparison's signed slack is its margin, && takes the worst one, and the run's robustness is the worst step. The multiplier below is what the optimizer would multiply the objective by: 1 while the run stays safe, decaying smoothly the deeper the violation, so "how badly it failed" stays visible to the sampler. + +Drag the parameters to push the run in and out of the safe region. "Worst constraint" aggregation is the classical min — note how one deeply-violated constraint completely masks progress on the other; "mean violation" keeps both visible.`; + +const DEFAULT_CONSTRAINTS = ["temperature < 80", "pressure < 4.5"]; + +function meanViolation(margins: readonly number[]): number { + const finite = margins.map((value) => + Number.isFinite(value) ? value : Math.sign(value) * 1e6, + ); + if (finite.every((value) => value >= 0)) { + return Math.min(...finite); + } + const violations = finite.filter((value) => value < 0); + return violations.reduce((sum, value) => sum + value, 0) / margins.length; +} + +const probeCache = new Map(); + +function firstNumericProbe(name: string): ExprNode { + const cached = probeCache.get(name); + if (cached) { + return cached; + } + const node: ExprNode = { kind: "ident", name }; + probeCache.set(name, node); + return node; +} + +function formatMargin(value: number): string { + if (!Number.isFinite(value)) { + return value > 0 ? "∞" : "−∞"; + } + return value.toFixed(2); +} + +export const PredicatePrototype = () => { + const [sources, setSources] = useState(DEFAULT_CONSTRAINTS); + const [parameters, setParameters] = useState(TOY_DEFAULTS); + const [width, setWidth] = useState(10); + const [kind, setKind] = useState("exponential"); + const [aggregation, setAggregation] = useState<"worst" | "meanViolation">( + "worst", + ); + + const trace = simulateToyRun(parameters); + const parsed = sources.map(parseConstraint); + const nodes = parsed.flatMap((entry) => (entry.ok ? [entry.node] : [])); + + const robustnessPer = nodes.map((node) => traceRobustness(node, trace)); + const robustness = + robustnessPer.length === 0 + ? Infinity + : aggregation === "worst" + ? Math.min(...robustnessPer) + : meanViolation(robustnessPer); + const multiplier = penaltyMultiplier(robustness, width, kind); + const objective = toyObjective(trace); + + const combinedStepMargin = (stepIndex: number): number => { + const margins = nodes.map((node) => + marginOf(node, trace.steps[stepIndex]!), + ); + if (margins.length === 0) { + return Infinity; + } + return aggregation === "worst" + ? Math.min(...margins) + : meanViolation(margins); + }; + const stepMarginValues = trace.steps.map((_, index) => + combinedStepMargin(index), + ); + + return ( + +
+ {sources.map((source, index) => ( + + setSources(sources.map((old, at) => (at === index ? next : old))) + } + error={ + parsed[index]!.ok + ? undefined + : (parsed[index] as { error: string }).error + } + after={ + parsed[index]!.ok ? ( + = 0 + ? "good" + : "bad" + } + /> + ) : undefined + } + /> + ))} +
+ +
+ + {TOY_PARAMETERS.map((spec) => ( + + setParameters({ ...parameters, [spec.name]: value }) + } + /> + ))} + +
+ +
+ value * 10, + ), + }, + { + name: "throughput", + values: stepValues(firstNumericProbe("throughput"), trace), + }, + ]} + violations={stepMarginValues.map((value) => value < 0)} + /> + +
+ +
+ + + + + + + penaltyMultiplier(margin, width, kind)} + marker={Number.isFinite(robustness) ? robustness : undefined} + label="objective multiplier vs run robustness" + /> +
+ + = 0 ? "good" : "bad"} + /> + + + + + 0.5 ? "good" : "bad"} + /> + +
+
+
+
+ ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/dev/constraint-prototypes/prototype-sampling.tsx b/libs/@hashintel/petrinaut/src/ui/dev/constraint-prototypes/prototype-sampling.tsx new file mode 100644 index 00000000000..ba56f167062 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/dev/constraint-prototypes/prototype-sampling.tsx @@ -0,0 +1,241 @@ +/** + * Prototype 4 — "Parameter sampling playground". Parameter constraints are + * about the sampler, not the run: the same declarative predicate is fed to + * four strategies side by side, and the scatter plots make the difference + * tangible — pruning leaks or burns budget, construction samples the safe + * region directly. The router table shows how each `&&` conjunct is + * compiled: single-parameter bounds fold into the box, `a <= b` becomes an + * ordering transform, other affine comparisons join a polytope walked with + * hit-and-run, and anything nonlinear falls back to rejection. + */ + +import { useState } from "react"; + +import { ScatterPlot } from "./charts"; +import { + createRng, + estimateFeasibleFraction, + planConjuncts, + sampleByConstruction, + sampleRejection, + sampleSoftLearning, + sampleUniform, + satisfies, +} from "./sampling"; +import { + ConstraintInput, + parseConstraint, + PrototypeShell, + Row, + Section, + Slider, + Stat, +} from "./shell"; +import { TOY_PARAMETERS } from "./toy-model"; + +import type { SamplingResult } from "./sampling"; + +const EXPLAINER = `One predicate over the searched parameters, four ways for the sampler to respect it. Uniform ignores it (the baseline every "prune afterwards" scheme starts from). Rejection redraws until feasible — clean but the attempt count is the bill, and it explodes as the feasible fraction shrinks. Soft is what Optuna's constraints_func gives: the sampler learns to prefer the region but keeps leaking infeasible trials. Construction routes each conjunct to a mechanism that cannot miss — bounds fold into the box, orderings become a gap transform, the affine part is walked as a polytope — and only nonlinear leftovers still reject. + +Tighten the constraint and watch rejection's attempts climb while construction stays flat: that is the "shape the space, don't prune it" argument in one picture. The scatter shows the projection onto two chosen parameters; the other parameters are sampled too, and the shading marks the slice through the projection plane at their box midpoints.`; + +const DEFAULT_SOURCE = + "flow_rate <= 2 * cooling_power && flow_rate + cooling_power <= 10 && batch_size <= 30"; + +const STRATEGIES = ["uniform", "rejection", "soft", "construction"] as const; + +type Strategy = (typeof STRATEGIES)[number]; + +const STRATEGY_LABELS: Record = { + uniform: "Uniform (ignore it)", + rejection: "Rejection (prune + redraw)", + soft: "Soft (learned preference)", + construction: "Construction (transform + walk)", +}; + +export const SamplingPrototype = () => { + const [source, setSource] = useState(DEFAULT_SOURCE); + const [count, setCount] = useState(400); + const [seed, setSeed] = useState(7); + const [xName, setXName] = useState("cooling_power"); + const [yName, setYName] = useState("flow_rate"); + + const parsed = parseConstraint(source); + + const xSpec = TOY_PARAMETERS.find((spec) => spec.name === xName)!; + const ySpec = TOY_PARAMETERS.find((spec) => spec.name === yName)!; + + const plan = parsed.ok ? planConjuncts(parsed.node, TOY_PARAMETERS) : []; + const fraction = parsed.ok + ? estimateFeasibleFraction( + TOY_PARAMETERS, + parsed.node, + 4000, + createRng(seed), + ) + : 0; + + const results: Record | null = parsed.ok + ? { + uniform: sampleUniform( + TOY_PARAMETERS, + parsed.node, + count, + createRng(seed), + ), + rejection: sampleRejection( + TOY_PARAMETERS, + parsed.node, + count, + createRng(seed), + ), + soft: sampleSoftLearning( + TOY_PARAMETERS, + parsed.node, + count, + createRng(seed), + ), + construction: sampleByConstruction( + TOY_PARAMETERS, + plan, + count, + createRng(seed), + ), + } + : null; + + const midpoints = new Map( + TOY_PARAMETERS.map((spec) => [spec.name, (spec.min + spec.max) / 2]), + ); + + return ( + +
+ 0.05 ? "good" : "bad"} + /> + ) : undefined + } + /> + + {plan.map((conjunct, index) => ( + + ))} + +
+ +
+ + + + + + + {results && parsed.ok ? ( + <> + + {STRATEGIES.map((strategy) => ( + ({ + x: point[xName]!, + y: point[yName]!, + ok: satisfies(parsed.node, point), + }))} + regionTest={(x, y) => { + const probe: Record = {}; + for (const [name, value] of midpoints) { + probe[name] = value; + } + probe[xName] = x; + probe[yName] = y; + return satisfies(parsed.node, probe); + }} + /> + ))} + + + {STRATEGIES.map((strategy) => { + const result = results[strategy]; + return ( + = Math.min(count, 1) + ? "good" + : undefined + } + /> + ); + })} + + + ) : null} +
+
+ ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/dev/constraint-prototypes/prototype-sentence.tsx b/libs/@hashintel/petrinaut/src/ui/dev/constraint-prototypes/prototype-sentence.tsx new file mode 100644 index 00000000000..c1965f9c269 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/dev/constraint-prototypes/prototype-sentence.tsx @@ -0,0 +1,293 @@ +/** + * Prototype 3 — "Sentence builder". The RFC's non-functional requirement + * one, taken literally: defining a constraint feels like filling in a + * sentence. Structured pickers (scope · quantity · direction · bound · + * across runs) compile to the same expression the editor-first prototypes + * accept — the code line underneath is live, and "eject" hands the sentence + * over to free-form editing. No expression grammar to learn, no banned + * vocabulary on screen; the ceiling is whatever the pickers offer. + * + * The "across runs" column answers the RFC's stochastic question (CH1): a + * constraint over a stochastic net is judged per seeded run, and the + * sentence quantifies over them — every run, or at least N% of runs. + */ + +import { useState } from "react"; + +import { printExpression } from "./expr"; +import { traceRobustness } from "./robustness"; +import { + parseConstraint, + PrototypeShell, + Row, + Section, + Slider, + Stat, +} from "./shell"; +import { + simulateToyRun, + TOY_DEFAULTS, + TOY_METRICS, + TOY_PARAMETERS, +} from "./toy-model"; + +import type { ToyMetric } from "./toy-model"; + +const EXPLAINER = `Every row reads as a sentence: WHEN · WHAT · HOW · BOUND · ACROSS RUNS. The compiled expression underneath is the shared representation — the same string the expression-first prototypes accept, so the two surfaces are one feature with two entry points, not two features. + +The run quantifier is the stochastic half of the story: the same sentence is judged on 24 seeded runs, and "in at least 90% of runs" is a chance constraint — satisfied when enough runs hold, with the satisfaction rate shown either way.`; + +const SCOPES = [ + { id: "always", label: "At every moment" }, + { id: "eventually", label: "At some moment" }, + { id: "atEnd", label: "By the end of the run" }, + { id: "during", label: "At every moment between…" }, + { id: "within", label: "At some moment between…" }, +] as const; + +type ScopeId = (typeof SCOPES)[number]["id"]; + +const DIRECTIONS = [ + { id: "below", label: "stays below", op: "<" }, + { id: "above", label: "stays above", op: ">" }, +] as const; + +type DirectionId = (typeof DIRECTIONS)[number]["id"]; + +type Sentence = { + scope: ScopeId; + from: number; + to: number; + metric: ToyMetric; + direction: DirectionId; + bound: number; + /** Runs that must satisfy the sentence, as a fraction; 1 = every run. */ + quorum: number; +}; + +const DEFAULT_SENTENCES: Sentence[] = [ + { + scope: "always", + from: 0, + to: 60, + metric: "temperature", + direction: "below", + bound: 80, + quorum: 1, + }, + { + scope: "atEnd", + from: 0, + to: 60, + metric: "throughput", + direction: "above", + bound: 4, + quorum: 0.9, + }, +]; + +const RUN_COUNT = 24; + +function compileSentence(sentence: Sentence): string { + const comparison = `${sentence.metric} ${ + DIRECTIONS.find((direction) => direction.id === sentence.direction)!.op + } ${sentence.bound}`; + switch (sentence.scope) { + case "always": + return comparison; + case "eventually": + return `eventually(${comparison})`; + case "atEnd": + return `atEnd(${comparison})`; + case "during": + return `during(${sentence.from}, ${sentence.to}, ${comparison})`; + case "within": + return `within(${sentence.from}, ${sentence.to}, ${comparison})`; + } +} + +export const SentencePrototype = () => { + const [sentences, setSentences] = useState(DEFAULT_SENTENCES); + const [parameters, setParameters] = useState(TOY_DEFAULTS); + + const traces = Array.from({ length: RUN_COUNT }, (_, seed) => + simulateToyRun(parameters, seed + 1), + ); + + const update = (index: number, patch: Partial) => { + setSentences( + sentences.map((sentence, at) => + at === index ? { ...sentence, ...patch } : sentence, + ), + ); + }; + + return ( + +
+ {sentences.map((sentence, index) => { + const source = compileSentence(sentence); + const parsed = parseConstraint(source); + const perRun = parsed.ok + ? traces.map((trace) => traceRobustness(parsed.node, trace)) + : []; + const satisfiedRuns = perRun.filter((value) => value >= 0).length; + const rate = perRun.length === 0 ? 0 : satisfiedRuns / perRun.length; + const holds = rate >= sentence.quorum - 1e-9; + const windowed = + sentence.scope === "during" || sentence.scope === "within"; + return ( + // eslint-disable-next-line react/no-array-index-key -- fixed slots +
+ + + {windowed ? ( + <> + + update(index, { from: Number(event.target.value) }) + } + /> + + + update(index, { to: Number(event.target.value) }) + } + /> + + ) : null} + + + + update(index, { bound: Number(event.target.value) }) + } + /> + + + + + + {parsed.ok ? printExpression(parsed.node) : source} + + 0 && Math.min(...perRun) >= 0 + ? "good" + : "bad" + } + /> + +
+ ); + })} + + + {sentences.length > 1 ? ( + + ) : null} + +
+ +
+ + {TOY_PARAMETERS.map((spec) => ( + + setParameters({ ...parameters, [spec.name]: value }) + } + /> + ))} + +
+
+ ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/dev/constraint-prototypes/prototype-temporal.tsx b/libs/@hashintel/petrinaut/src/ui/dev/constraint-prototypes/prototype-temporal.tsx new file mode 100644 index 00000000000..206bfbb0c96 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/dev/constraint-prototypes/prototype-temporal.tsx @@ -0,0 +1,190 @@ +/** + * Prototype 5 — "Temporal operators" (the extension, not the base). The + * expression grammar of prototype 1 plus five functions — `always`, + * `eventually`, `during(t1, t2, …)`, `within(t1, t2, …)`, `until(a, b)`, + * `atEnd(…)` — with STL quantitative semantics: the whole formula still + * collapses to one signed robustness number, so the margin/penalty pipeline + * is unchanged. A temperature slider swaps the exact min/max collapse for + * the smooth logsumexp variant ("Smooth Operator", Pant et al. 2017) to + * show what a gradient-friendly robustness looks like — and how it + * deliberately trades soundness (an under/over-approximation band of + * ±ln(m)·T) for smoothness. + */ + +import { useState } from "react"; + +import { MarginStrip, TracePlot } from "./charts"; +import { + stepMargins, + stepValues, + traceRobustness, + usesTemporalOperators, +} from "./robustness"; +import { + ConstraintInput, + parseConstraint, + PrototypeShell, + Row, + Section, + Slider, + Stat, +} from "./shell"; +import { simulateToyRun, TOY_DEFAULTS, TOY_PARAMETERS } from "./toy-model"; + +import type { ExprNode } from "./expr"; + +const EXPLAINER = `Temporal logic enters as ordinary functions in the same expression grammar — no second language. A plain predicate still means "at every moment" (the safety reading); wrapping it changes the quantifier. The whole formula reduces to one signed robustness number with STL's quantitative semantics, so everything downstream (penalty multiplier, reporting, optimization) is identical to the non-temporal prototypes — which is exactly the argument for shipping temporal operators as an extension rather than a separate constraint kind. + +Time bounds are in simulated time units. The smoothing slider replaces min/max with a logsumexp soft version: robustness stops being exact (it can under- or over-state by up to ln(m)·T) but becomes differentiable everywhere, which is what gradient-based tooling wants. At 0 the semantics are exact.`; + +const EXAMPLES = [ + "during(20, 40, temperature < 85)", + "eventually(throughput > 6) && temperature < 90", + "until(pressure < 4, throughput > 5)", + "atEnd(throughput > 4)", + "within(0, 30, temperature > 60)", +]; + +export const TemporalPrototype = () => { + const [source, setSource] = useState(EXAMPLES[0]!); + const [parameters, setParameters] = useState(TOY_DEFAULTS); + const [smoothing, setSmoothing] = useState(0); + + const trace = simulateToyRun(parameters); + const parsed = parseConstraint(source); + + let exact: number | null = null; + let smooth: number | null = null; + let evaluationError: string | undefined; + let node: ExprNode | null = null; + if (parsed.ok) { + node = parsed.node; + try { + exact = traceRobustness(node, trace); + smooth = + smoothing > 0 + ? traceRobustness(node, trace, { temperature: smoothing }) + : exact; + } catch (error) { + evaluationError = error instanceof Error ? error.message : String(error); + } + } + + const temporal = node !== null && usesTemporalOperators(node); + const pointwiseMargins = + node !== null && !temporal && evaluationError === undefined + ? stepMargins(node, trace) + : null; + + return ( + +
+ = 0 ? "good" : "bad"} + /> + ) : undefined + } + /> + + {EXAMPLES.map((example) => ( + + ))} + +
+ +
+ + {TOY_PARAMETERS.map((spec) => ( + + setParameters({ ...parameters, [spec.name]: value }) + } + /> + ))} + +
+ +
+ value * 10), + }, + { + name: "throughput", + values: stepValues({ kind: "ident", name: "throughput" }, trace), + }, + ]} + violations={pointwiseMargins?.map((value) => value < 0)} + /> + {pointwiseMargins ? : null} +
+ +
+ + + = 0 ? "good" : "bad"} + /> + 0 + ? `smooth (T=${smoothing.toFixed(1)})` + : "smooth (off)" + } + value={smooth === null ? "—" : smooth.toFixed(3)} + /> + + +
+
+ ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/dev/constraint-prototypes/robustness.test.ts b/libs/@hashintel/petrinaut/src/ui/dev/constraint-prototypes/robustness.test.ts new file mode 100644 index 00000000000..b5f23d15d84 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/dev/constraint-prototypes/robustness.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; + +import { parseExpression } from "./expr"; +import { + penaltyMultiplier, + softMax, + softMin, + stepMargins, + traceRobustness, + usesTemporalOperators, +} from "./robustness"; +import { simulateToyRun, toyObjective, TOY_DEFAULTS } from "./toy-model"; + +import type { Trace } from "./robustness"; + +const traceOf = (temperatures: number[]): Trace => ({ + times: temperatures.map((_, index) => index), + steps: temperatures.map( + (temperature) => new Map([["temperature", temperature]]), + ), +}); + +describe("traceRobustness", () => { + it("reads a plain predicate as an implicit always", () => { + const node = parseExpression("temperature < 80"); + expect(traceRobustness(node, traceOf([70, 75, 78]))).toBe(2); + expect(traceRobustness(node, traceOf([70, 85, 60]))).toBe(-5); + }); + + it("supports eventually as the max over the run", () => { + const node = parseExpression("eventually(temperature > 76)"); + expect(traceRobustness(node, traceOf([70, 75, 60]))).toBe(-1); + expect(traceRobustness(node, traceOf([70, 79, 60]))).toBe(3); + }); + + it("applies windows in trace time units", () => { + const node = parseExpression("during(0, 1, temperature < 80)"); + // Only the first two steps fall in [0, 1]: the later 90 is outside. + expect(traceRobustness(node, traceOf([70, 75, 90]))).toBe(5); + }); + + it("scores until as held-prefix against release", () => { + const node = parseExpression("until(temperature < 80, temperature > 100)"); + expect(traceRobustness(node, traceOf([70, 75, 110]))).toBeGreaterThan(0); + expect(traceRobustness(node, traceOf([70, 95, 110]))).toBeLessThan(0); + }); + + it("detects temporal operators", () => { + expect(usesTemporalOperators(parseExpression("always(a < 1)"))).toBe(true); + expect(usesTemporalOperators(parseExpression("min(a, 1) < 2"))).toBe(false); + }); + + it("smooth robustness under-approximates always and converges", () => { + const node = parseExpression("temperature < 80"); + const trace = traceOf([70, 75, 78]); + const exact = traceRobustness(node, trace); + const smooth = traceRobustness(node, trace, { temperature: 1 }); + expect(smooth).toBeLessThanOrEqual(exact); + const sharper = traceRobustness(node, trace, { temperature: 0.05 }); + expect(Math.abs(sharper - exact)).toBeLessThan(0.2); + }); +}); + +describe("softMin / softMax", () => { + it("bracket the exact extrema within ln(m)·T", () => { + const values = [3, 5, -2, 0.5]; + const temperature = 0.7; + const bound = Math.log(values.length) * temperature; + expect(softMin(values, temperature)).toBeLessThanOrEqual(-2); + expect(softMin(values, temperature)).toBeGreaterThanOrEqual(-2 - bound); + expect(softMax(values, temperature)).toBeGreaterThanOrEqual(5); + expect(softMax(values, temperature)).toBeLessThanOrEqual(5 + bound); + }); +}); + +describe("penaltyMultiplier", () => { + it("is 1 inside and decays smoothly outside for the exponential kind", () => { + expect(penaltyMultiplier(3, 5, "exponential")).toBe(1); + expect(penaltyMultiplier(0, 5, "exponential")).toBe(1); + const shallow = penaltyMultiplier(-1, 5, "exponential"); + const deep = penaltyMultiplier(-10, 5, "exponential"); + expect(shallow).toBeGreaterThan(deep); + expect(deep).toBeGreaterThan(0); + }); + + it("hard is the step function", () => { + expect(penaltyMultiplier(0.01, 5, "hard")).toBe(1); + expect(penaltyMultiplier(-0.01, 5, "hard")).toBe(0); + }); + + it("logistic is monotone and crosses 1/2 at the boundary", () => { + expect(penaltyMultiplier(0, 5, "logistic")).toBeCloseTo(0.5); + expect(penaltyMultiplier(5, 5, "logistic")).toBeGreaterThan(0.9); + expect(penaltyMultiplier(-5, 5, "logistic")).toBeLessThan(0.1); + }); +}); + +describe("toy model", () => { + it("is deterministic under a seed and exposes metrics plus parameters", () => { + const first = simulateToyRun(TOY_DEFAULTS, 7); + const second = simulateToyRun(TOY_DEFAULTS, 7); + expect(toyObjective(first)).toBe(toyObjective(second)); + const step = first.steps[10]!; + expect(step.get("temperature")).toBeTypeOf("number"); + expect(step.get("flow_rate")).toBe(TOY_DEFAULTS.flow_rate); + }); + + it("hotter runs violate a temperature ceiling that cool runs hold", () => { + const cool = simulateToyRun({ + ...TOY_DEFAULTS, + flow_rate: 2, + cooling_power: 7, + }); + const hot = simulateToyRun({ + ...TOY_DEFAULTS, + flow_rate: 10, + cooling_power: 0, + }); + const node = parseExpression("temperature < 80"); + expect(traceRobustness(node, cool)).toBeGreaterThan(0); + expect(traceRobustness(node, hot)).toBeLessThan(0); + expect(Math.min(...stepMargins(node, hot))).toBeLessThan(0); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/dev/constraint-prototypes/robustness.ts b/libs/@hashintel/petrinaut/src/ui/dev/constraint-prototypes/robustness.ts new file mode 100644 index 00000000000..f16f8a031d2 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/dev/constraint-prototypes/robustness.ts @@ -0,0 +1,349 @@ +/** + * Trace-level robustness for the constraint prototypes: how a state + * constraint's per-step margin collapses over a run, how temporal operators + * (`always`, `eventually`, `during`, `within`, `until`) reshape that + * collapse, and how a signed margin becomes the continuous objective + * multiplier Chris asked for — a smooth factor that is ~1 safely inside the + * region and drops progressively to 0 outside it. + * + * The classic STL robustness semantics are min/max compositions; the smooth + * variants replace them with a temperature-controlled logsumexp (soft-min / + * soft-max), following "Smooth Operator" (Pant, Abbas, Mangharam 2017). + */ + +import { evaluateExpression, marginOf, parseExpression } from "./expr"; + +import type { ExprEnv, ExprNode } from "./expr"; + +/** One recorded run: `steps[t]` is the environment at time index `t`. */ +export type Trace = { + /** Simulated time per step, strictly increasing. */ + times: readonly number[]; + /** Per-step values, keyed like an `ExprEnv` (metric and parameter names). */ + steps: readonly ExprEnv[]; +}; + +export const TEMPORAL_FUNCTIONS = [ + "always", + "eventually", + "during", + "within", + "until", + "atEnd", +] as const; + +export type TemporalFunction = (typeof TEMPORAL_FUNCTIONS)[number]; + +function isTemporal(name: string): name is TemporalFunction { + return (TEMPORAL_FUNCTIONS as readonly string[]).includes(name); +} + +/** Whether the expression contains any temporal operator call. */ +export function usesTemporalOperators(node: ExprNode): boolean { + switch (node.kind) { + case "number": + case "boolean": + case "ident": + return false; + case "unary": + return usesTemporalOperators(node.operand); + case "binary": + return ( + usesTemporalOperators(node.left) || usesTemporalOperators(node.right) + ); + case "cond": + return ( + usesTemporalOperators(node.condition) || + usesTemporalOperators(node.whenTrue) || + usesTemporalOperators(node.whenFalse) + ); + case "call": + return ( + isTemporal(node.name) || + node.args.some((argument) => usesTemporalOperators(argument)) + ); + } +} + +export type RobustnessOptions = { + /** + * Softness temperature for the min/max collapses. `0` (the default) is + * the exact semantics; a positive value substitutes logsumexp soft-min / + * soft-max, trading soundness for a gradient everywhere. + */ + temperature?: number; +}; + +function logSumExp(values: readonly number[]): number { + const highest = Math.max(...values); + if (!Number.isFinite(highest)) { + return highest; + } + const sum = values.reduce( + (accumulator, value) => accumulator + Math.exp(value - highest), + 0, + ); + return highest + Math.log(sum); +} + +/** + * Soft minimum: exact `min` at temperature 0, `-T · logsumexp(-v/T)` + * otherwise — an under-approximation of `min`, so a positive soft value + * does not certify satisfaction, but it is differentiable everywhere. + */ +export function softMin( + values: readonly number[], + temperature: number, +): number { + if (temperature <= 0) { + return Math.min(...values); + } + return -logSumExp(values.map((value) => -value / temperature)) * temperature; +} + +/** Soft maximum: the dual over-approximation of `max`. */ +export function softMax( + values: readonly number[], + temperature: number, +): number { + if (temperature <= 0) { + return Math.max(...values); + } + return logSumExp(values.map((value) => value / temperature)) * temperature; +} + +function range(from: number, to: number): number[] { + const values: number[] = []; + for (let index = from; index <= to; index += 1) { + values.push(index); + } + return values; +} + +function collapse( + values: number[], + mode: "min" | "max", + temperature: number, +): number { + if (values.length === 0) { + // An empty window: `always` over nothing is vacuously satisfied, + // `eventually` over nothing cannot be. + return mode === "min" ? Infinity : -Infinity; + } + return mode === "min" + ? softMin(values, temperature) + : softMax(values, temperature); +} + +function temporalArguments(node: ExprNode & { kind: "call" }): { + window: { from: number; to: number } | null; + body: ExprNode; +} { + if (node.args.length === 1) { + return { window: null, body: node.args[0]! }; + } + if (node.args.length === 3) { + const [from, to, body] = node.args as [ExprNode, ExprNode, ExprNode]; + if (from.kind !== "number" || to.kind !== "number") { + throw new Error(`${node.name}(t1, t2, expr) needs literal time bounds`); + } + return { window: { from: from.value, to: to.value }, body }; + } + throw new Error( + `${node.name}(...) takes (expr) or (t1, t2, expr), not ${node.args.length} arguments`, + ); +} + +/** Step indices whose time falls inside `start`'s window (relative bounds). */ +function stepsInWindow( + trace: Trace, + start: number, + window: { from: number; to: number } | null, +): number[] { + if (window === null) { + return range(start, trace.steps.length - 1); + } + const origin = trace.times[start]!; + const steps: number[] = []; + for (let step = start; step < trace.steps.length; step += 1) { + const offset = trace.times[step]! - origin; + if (offset >= window.from && offset <= window.to) { + steps.push(step); + } + } + return steps; +} + +/** Robustness of `node` at step `start`, STL-style. */ +function robustnessAt( + node: ExprNode, + trace: Trace, + start: number, + temperature: number, +): number { + switch (node.kind) { + case "unary": + if (node.op === "!") { + return -robustnessAt(node.operand, trace, start, temperature); + } + return marginOf(node, trace.steps[start]!); + case "binary": + if (node.op === "&&") { + return softMin( + [ + robustnessAt(node.left, trace, start, temperature), + robustnessAt(node.right, trace, start, temperature), + ], + temperature, + ); + } + if (node.op === "||") { + return softMax( + [ + robustnessAt(node.left, trace, start, temperature), + robustnessAt(node.right, trace, start, temperature), + ], + temperature, + ); + } + return marginOf(node, trace.steps[start]!); + case "call": { + if (!isTemporal(node.name)) { + return marginOf(node, trace.steps[start]!); + } + if (node.name === "atEnd") { + if (node.args.length !== 1) { + throw new Error("atEnd(expr) takes exactly one condition"); + } + return robustnessAt( + node.args[0]!, + trace, + trace.steps.length - 1, + temperature, + ); + } + if (node.name === "until") { + if (node.args.length !== 2) { + throw new Error("until(a, b) takes exactly two conditions"); + } + const [hold, release] = node.args as [ExprNode, ExprNode]; + // sup over release points of min(release robustness, held strict + // prefix) — the hold is not required at the release step itself. + const candidates: number[] = []; + for (let step = start; step < trace.steps.length; step += 1) { + const releaseValue = robustnessAt(release, trace, step, temperature); + const heldPrefix = collapse( + range(start, step - 1).map((prefixStep) => + robustnessAt(hold, trace, prefixStep, temperature), + ), + "min", + temperature, + ); + candidates.push(Math.min(releaseValue, heldPrefix)); + } + return collapse(candidates, "max", temperature); + } + const { window, body } = temporalArguments(node); + const steps = stepsInWindow(trace, start, window); + const values = steps.map((step) => + robustnessAt(body, trace, step, temperature), + ); + const mode = + node.name === "always" || node.name === "during" ? "min" : "max"; + return collapse(values, mode, temperature); + } + default: + return marginOf(node, trace.steps[start]!); + } +} + +/** + * Quantitative robustness of a constraint over one whole trace: `>= 0` iff + * the trace satisfies it, magnitude measuring how comfortably. + * + * A constraint with no temporal operator is treated as an implicit + * `always(...)` — the invariant reading, matching "stays in the safe space + * during simulation". Temporal calls take the sub-expression last, with + * optional leading time bounds: + * + * - `always(expr)` / `always(t1, t2, expr)` — min over the (windowed) steps + * - `eventually(expr)` / `eventually(t1, t2, expr)` — max + * - `during(t1, t2, expr)` — alias of windowed `always` + * - `within(t1, t2, expr)` — alias of windowed `eventually` + * - `until(a, b)` — `a` must hold until `b` does + * - `atEnd(expr)` — the margin at the final step (the RFC's "in the end") + */ +export function traceRobustness( + node: ExprNode, + trace: Trace, + options: RobustnessOptions = {}, +): number { + if (!usesTemporalOperators(node)) { + return collapse( + trace.steps.map((step) => marginOf(node, step)), + "min", + options.temperature ?? 0, + ); + } + return robustnessAt(node, trace, 0, options.temperature ?? 0); +} + +export const PENALTY_KINDS = ["exponential", "logistic", "hard"] as const; + +export type PenaltyKind = (typeof PENALTY_KINDS)[number]; + +/** + * The objective multiplier a margin produces: ~1 while safely inside the + * region, dropping continuously to 0 as the violation deepens. `width` is + * the tolerance band in the margin's own units — the "how far out is fully + * bad" scale. + * + * - `exponential` — 1 inside, `exp(margin / width)` outside: exactly 1 for + * any satisfied margin, smooth decay past the boundary. + * - `logistic` — `1 / (1 + exp(-4 · margin / width))`: symmetric S-curve + * that already discounts near-boundary satisfaction (a graded preference + * for staying clear of the edge). + * - `hard` — the step function, for contrast with what pruning would do. + */ +export function penaltyMultiplier( + margin: number, + width: number, + kind: PenaltyKind, +): number { + const scale = Math.max(width, 1e-9); + switch (kind) { + case "exponential": + return margin >= 0 ? 1 : Math.exp(margin / scale); + case "logistic": + return 1 / (1 + Math.exp((-4 * margin) / scale)); + case "hard": + return margin >= 0 ? 1 : 0; + } +} + +/** Convenience: parse once, robustness per call. Throws `ExprError`. */ +export function compileConstraint(source: string): { + node: ExprNode; + robustness: (trace: Trace, options?: RobustnessOptions) => number; + marginAtStep: (env: ExprEnv) => number; +} { + const node = parseExpression(source); + return { + node, + robustness: (trace, options) => traceRobustness(node, trace, options), + marginAtStep: (env) => marginOf(node, env), + }; +} + +/** Per-step margins for plotting a constraint against a trace. */ +export function stepMargins(node: ExprNode, trace: Trace): number[] { + return trace.steps.map((step) => marginOf(node, step)); +} + +/** Per-step raw values of a numeric expression, for plotting. */ +export function stepValues(node: ExprNode, trace: Trace): number[] { + return trace.steps.map((step) => { + const value = evaluateExpression(node, step); + return typeof value === "number" ? value : value ? 1 : 0; + }); +} diff --git a/libs/@hashintel/petrinaut/src/ui/dev/constraint-prototypes/sampling.test.ts b/libs/@hashintel/petrinaut/src/ui/dev/constraint-prototypes/sampling.test.ts new file mode 100644 index 00000000000..f920ea1d124 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/dev/constraint-prototypes/sampling.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from "vitest"; + +import { parseExpression } from "./expr"; +import { + createHitAndRun, + createRng, + estimateFeasibleFraction, + planConjuncts, + sampleByConstruction, + sampleRejection, + sampleSoftLearning, + sampleUniform, + satisfies, +} from "./sampling"; + +import type { ParameterSpec } from "./sampling"; + +const UNIT_SQUARE: ParameterSpec[] = [ + { name: "x", min: 0, max: 1 }, + { name: "y", min: 0, max: 1 }, +]; + +describe("createRng", () => { + it("is deterministic and in [0, 1)", () => { + const a = createRng(42); + const b = createRng(42); + for (let index = 0; index < 100; index += 1) { + const value = a(); + expect(value).toBe(b()); + expect(value).toBeGreaterThanOrEqual(0); + expect(value).toBeLessThan(1); + } + }); +}); + +describe("estimateFeasibleFraction", () => { + it("estimates the half-square within a few percent", () => { + const fraction = estimateFeasibleFraction( + UNIT_SQUARE, + parseExpression("x + y <= 1"), + 4000, + createRng(1), + ); + expect(Math.abs(fraction - 0.5)).toBeLessThan(0.05); + }); +}); + +describe("planConjuncts", () => { + const parameters: ParameterSpec[] = [ + { name: "a", min: 0, max: 10 }, + { name: "b", min: 0, max: 10 }, + { name: "c", min: 0, max: 10 }, + ]; + + it("routes each conjunct to its mechanism", () => { + const plan = planConjuncts( + parseExpression("a <= 4 && a <= b && a + 2 * c <= 9 && a * b <= 5"), + parameters, + ); + expect(plan.map((conjunct) => conjunct.kind)).toEqual([ + "bound", + "ordering", + "linear", + "nonlinear", + ]); + const bound = plan[0]!; + expect(bound.kind === "bound" && bound.max).toBe(4); + const ordering = plan[1]!; + expect(ordering.kind === "ordering" && ordering.lower).toBe("a"); + expect(ordering.kind === "ordering" && ordering.upper).toBe("b"); + const linear = plan[2]!; + expect(linear.kind === "linear" && linear.bound).toBe(9); + expect(linear.kind === "linear" && linear.coefficients.get("c")).toBe(2); + }); +}); + +describe("samplers", () => { + const constraint = parseExpression("x + y <= 0.5 && x <= y"); + const count = 300; + + it("rejection returns only feasible points and reports its overhead", () => { + const result = sampleRejection( + UNIT_SQUARE, + constraint, + count, + createRng(3), + ); + expect(result.points).toHaveLength(count); + expect(result.infeasible).toBe(0); + // Feasible fraction is 1/16, so raw draws should be roughly 16 per point. + expect(result.attempts).toBeGreaterThan(count * 8); + expect(result.points.every((point) => satisfies(constraint, point))).toBe( + true, + ); + }); + + it("uniform leaks proportionally to the infeasible volume", () => { + const result = sampleUniform(UNIT_SQUARE, constraint, count, createRng(4)); + expect(result.infeasible / count).toBeGreaterThan(0.8); + }); + + it("soft learning leaks less than uniform but is not clean", () => { + const soft = sampleSoftLearning( + UNIT_SQUARE, + constraint, + count, + createRng(5), + ); + const uniform = sampleUniform(UNIT_SQUARE, constraint, count, createRng(5)); + expect(soft.infeasible).toBeLessThan(uniform.infeasible); + expect(soft.infeasible).toBeGreaterThan(0); + }); + + it("construction honours bounds, orderings, and the polytope", () => { + const parameters: ParameterSpec[] = [ + { name: "a", min: 0, max: 10 }, + { name: "b", min: 0, max: 10 }, + { name: "c", min: 0, max: 10 }, + { name: "d", min: 0, max: 10 }, + ]; + const expression = parseExpression( + "a <= 4 && a <= b && c + d <= 8 && c <= 6", + ); + const plan = planConjuncts(expression, parameters); + const result = sampleByConstruction(parameters, plan, count, createRng(6)); + expect(result.points).toHaveLength(count); + expect(result.attempts).toBe(count); + expect(result.points.every((point) => satisfies(expression, point))).toBe( + true, + ); + }); + + it("construction rejects only on the nonlinear leftovers", () => { + const parameters: ParameterSpec[] = [ + { name: "a", min: 0, max: 1 }, + { name: "b", min: 0, max: 1 }, + ]; + const expression = parseExpression("a <= b && a * b <= 0.25"); + const plan = planConjuncts(expression, parameters); + const result = sampleByConstruction(parameters, plan, count, createRng(7)); + expect(result.points.length).toBeGreaterThan(0); + expect(result.points.every((point) => satisfies(expression, point))).toBe( + true, + ); + }); +}); + +describe("createHitAndRun", () => { + it("stays inside the polytope and moves around", () => { + const walk = createHitAndRun( + UNIT_SQUARE, + [ + { + coefficients: new Map([ + ["x", 1], + ["y", 1], + ]), + bound: 0.5, + }, + ], + createRng(8), + )!; + expect(walk).not.toBeNull(); + const seen = new Set(); + for (let index = 0; index < 200; index += 1) { + const point = walk(); + expect(point.x! + point.y!).toBeLessThanOrEqual(0.5 + 1e-9); + seen.add(`${point.x!.toFixed(2)}:${point.y!.toFixed(2)}`); + } + expect(seen.size).toBeGreaterThan(50); + }); + + it("returns null when the region is empty", () => { + const walk = createHitAndRun( + UNIT_SQUARE, + [{ coefficients: new Map([["x", 1]]), bound: -1 }], + createRng(9), + ); + expect(walk).toBeNull(); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/dev/constraint-prototypes/sampling.ts b/libs/@hashintel/petrinaut/src/ui/dev/constraint-prototypes/sampling.ts new file mode 100644 index 00000000000..373d4ce5851 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/dev/constraint-prototypes/sampling.ts @@ -0,0 +1,509 @@ +/** + * Parameter-space sampling strategies for the constraint prototypes: given + * declarative constraints over the searched parameters, how does a sampler + * actually draw from the safe region? The playground compares four + * mechanisms side by side — pure rejection, a soft learning sampler, a + * router that compiles recognisable constraint shapes into feasible-by- + * construction transforms, and a hit-and-run walk over the linear + * (polytope) part — because "shape the sampling space" and "prune the bad + * draws" behave very differently at low feasible fractions. + * + * Everything is deterministic under a seed (no `Math.random`): the stories + * re-sample on every slider move and must be pure renders. + */ + +import { + canonicalMarginExpr, + conjunctsOf, + evaluateExpression, + linearFormOf, + marginOf, + parseExpression, + printExpression, +} from "./expr"; + +import type { ExprNode } from "./expr"; + +export type ParameterSpec = { + name: string; + min: number; + max: number; +}; + +export type SamplePoint = Readonly>; + +/** Deterministic 48-bit LCG in [0, 1). */ +export type Rng = () => number; + +const LCG_MODULUS = 281474976710656n; // 2^48 + +export function createRng(seed: number): Rng { + let state = BigInt(Math.floor(seed)) % LCG_MODULUS; + return () => { + state = (state * 25214903917n + 11n) % LCG_MODULUS; + return Number(state / 65536n) / 2 ** 32; + }; +} + +function toEnv(point: SamplePoint): Map { + return new Map(Object.entries(point)); +} + +export function satisfies(node: ExprNode, point: SamplePoint): boolean { + const value = evaluateExpression(node, toEnv(point)); + return value !== false && value !== 0; +} + +export function pointMargin(node: ExprNode, point: SamplePoint): number { + return marginOf(node, toEnv(point)); +} + +function dot( + coefficients: ReadonlyMap, + point: SamplePoint, +): number { + let sum = 0; + for (const [name, coefficient] of coefficients) { + sum += coefficient * (point[name] ?? 0); + } + return sum; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function uniformPoint( + parameters: readonly ParameterSpec[], + rng: Rng, +): SamplePoint { + const point: Record = {}; + for (const parameter of parameters) { + point[parameter.name] = + parameter.min + rng() * (parameter.max - parameter.min); + } + return point; +} + +/** Monte-Carlo estimate of the feasible fraction of the parameter box. */ +export function estimateFeasibleFraction( + parameters: readonly ParameterSpec[], + constraint: ExprNode, + samples: number, + rng: Rng, +): number { + let feasible = 0; + for (let index = 0; index < samples; index += 1) { + if (satisfies(constraint, uniformPoint(parameters, rng))) { + feasible += 1; + } + } + return feasible / samples; +} + +export type SamplingResult = { + /** The points a run would actually evaluate. */ + points: SamplePoint[]; + /** Raw draws spent producing them (rejection overhead shows up here). */ + attempts: number; + /** Points among `points` that violate the constraint (soft mode leaks). */ + infeasible: number; +}; + +/** Baseline: the box itself, constraint ignored. */ +export function sampleUniform( + parameters: readonly ParameterSpec[], + constraint: ExprNode, + count: number, + rng: Rng, +): SamplingResult { + const points: SamplePoint[] = []; + for (let index = 0; index < count; index += 1) { + points.push(uniformPoint(parameters, rng)); + } + return { + points, + attempts: count, + infeasible: points.filter((point) => !satisfies(constraint, point)).length, + }; +} + +/** Reject-and-redraw with a capped total budget. */ +export function sampleRejection( + parameters: readonly ParameterSpec[], + constraint: ExprNode, + count: number, + rng: Rng, + maxAttempts = count * 200, +): SamplingResult { + const points: SamplePoint[] = []; + let attempts = 0; + while (points.length < count && attempts < maxAttempts) { + attempts += 1; + const point = uniformPoint(parameters, rng); + if (satisfies(constraint, point)) { + points.push(point); + } + } + return { points, attempts, infeasible: 0 }; +} + +/** + * A miniature of what a margin-aware soft sampler (Optuna's + * `constraints_func` consumers) does over trials: early draws come from the + * whole box, later draws mix in Gaussian jitter around the most feasible + * archive points, so density migrates toward the safe region without ever + * guaranteeing it. Infeasible points still occur — that is the point. + */ +export function sampleSoftLearning( + parameters: readonly ParameterSpec[], + constraint: ExprNode, + count: number, + rng: Rng, +): SamplingResult { + const points: SamplePoint[] = []; + const archive: { point: SamplePoint; margin: number }[] = []; + for (let index = 0; index < count; index += 1) { + const explore = index < count * 0.2 || rng() < 0.3 || archive.length === 0; + let point: SamplePoint; + if (explore) { + point = uniformPoint(parameters, rng); + } else { + const ranked = [...archive].sort((a, b) => b.margin - a.margin); + const eliteCount = Math.max(1, Math.floor(ranked.length * 0.3)); + const anchor = ranked[Math.floor(rng() * eliteCount)]!.point; + const jittered: Record = {}; + for (const parameter of parameters) { + const spread = (parameter.max - parameter.min) * 0.12; + const jitter = (rng() + rng() + rng() - 1.5) * spread; + jittered[parameter.name] = clamp( + anchor[parameter.name]! + jitter, + parameter.min, + parameter.max, + ); + } + point = jittered; + } + archive.push({ point, margin: pointMargin(constraint, point) }); + points.push(point); + } + return { + points, + attempts: count, + infeasible: points.filter((point) => !satisfies(constraint, point)).length, + }; +} + +// -- Routing: constraint shapes to construction mechanisms ----------------- + +export type ConjunctPlan = + | { + kind: "bound"; + parameter: string; + /** The folded interval this conjunct tightens the parameter to. */ + min: number; + max: number; + source: string; + } + | { + kind: "ordering"; + lower: string; + upper: string; + source: string; + } + | { + kind: "linear"; + coefficients: ReadonlyMap; + /** `Σ coefficient · x <= bound`. */ + bound: number; + source: string; + } + | { kind: "nonlinear"; source: string }; + +/** + * Classifies each top-level `&&` conjunct of a constraint into the + * mechanism that can honour it by construction: a single-parameter bound + * folds into the box, `a <= b` becomes an ordering transform, any other + * affine comparison joins the polytope, and everything else is nonlinear + * (rejection territory). + */ +export function planConjuncts( + constraint: ExprNode, + parameters: readonly ParameterSpec[], +): ConjunctPlan[] { + const names = new Set(parameters.map((parameter) => parameter.name)); + return conjunctsOf(constraint).map((conjunct) => { + const source = printExpression(conjunct); + const margin = canonicalMarginExpr(conjunct); + const linear = margin && linearFormOf(margin, names); + if (!linear) { + return { kind: "nonlinear", source }; + } + const entries = [...linear.coefficients].filter(([, value]) => value !== 0); + if (entries.length === 1) { + const [name, coefficient] = entries[0]!; + // coefficient · x + constant >= 0 + const boundary = -linear.constant / coefficient; + const spec = parameters.find((parameter) => parameter.name === name)!; + return coefficient > 0 + ? { + kind: "bound", + parameter: name, + min: boundary, + max: spec.max, + source, + } + : { + kind: "bound", + parameter: name, + min: spec.min, + max: boundary, + source, + }; + } + if ( + entries.length === 2 && + linear.constant === 0 && + Math.abs(entries[0]![1] + entries[1]![1]) < 1e-12 && + Math.abs(Math.abs(entries[0]![1]) - 1) < 1e-12 + ) { + const [first, second] = entries as [[string, number], [string, number]]; + const upper = first[1] > 0 ? first[0] : second[0]; + const lower = first[1] > 0 ? second[0] : first[0]; + return { kind: "ordering", lower, upper, source }; + } + // margin >= 0 is Σ c·x + k >= 0, i.e. Σ (-c)·x <= k. + return { + kind: "linear", + coefficients: new Map(entries.map(([name, value]) => [name, -value])), + bound: linear.constant, + source, + }; + }); +} + +const parsedCache = new Map(); + +function parseCache(source: string): ExprNode { + const cached = parsedCache.get(source); + if (cached) { + return cached; + } + const node = parseExpression(source); + parsedCache.set(source, node); + return node; +} + +type HalfSpace = { + /** `Σ coefficient · x <= bound`. */ + coefficients: ReadonlyMap; + bound: number; +}; + +/** + * Hit-and-run over the polytope `{box ∩ halfspaces}`: from an interior + * point, pick a random direction, intersect the line with every face, and + * jump uniformly along the feasible segment. Returns `null` when no + * interior start is found within budget. + */ +export function createHitAndRun( + parameters: readonly ParameterSpec[], + halfSpaces: readonly HalfSpace[], + rng: Rng, + burnIn = 32, +): (() => SamplePoint) | null { + const inside = (point: SamplePoint) => + halfSpaces.every( + ({ coefficients, bound }) => dot(coefficients, point) <= bound + 1e-12, + ); + + let current: Record | null = null; + for (let attempt = 0; attempt < 2000; attempt += 1) { + const candidate = uniformPoint(parameters, rng) as Record; + if (inside(candidate)) { + current = candidate; + break; + } + } + if (!current) { + return null; + } + + const step = (): SamplePoint => { + const direction: Record = {}; + let norm = 0; + for (const parameter of parameters) { + const gaussian = rng() + rng() + rng() + rng() - 2; + direction[parameter.name] = gaussian; + norm += gaussian * gaussian; + } + norm = Math.sqrt(norm) || 1; + + // The feasible segment current + t·direction within box and halfspaces. + let lower = -Infinity; + let upper = Infinity; + for (const parameter of parameters) { + const velocity = direction[parameter.name]! / norm; + if (Math.abs(velocity) < 1e-15) { + continue; + } + const toMin = (parameter.min - current![parameter.name]!) / velocity; + const toMax = (parameter.max - current![parameter.name]!) / velocity; + lower = Math.max(lower, Math.min(toMin, toMax)); + upper = Math.min(upper, Math.max(toMin, toMax)); + } + for (const { coefficients, bound } of halfSpaces) { + const position = dot(coefficients, current!); + let velocity = 0; + for (const [name, coefficient] of coefficients) { + velocity += coefficient * (direction[name]! / norm); + } + if (Math.abs(velocity) < 1e-15) { + continue; + } + const distance = (bound - position) / velocity; + if (velocity > 0) { + upper = Math.min(upper, distance); + } else { + lower = Math.max(lower, distance); + } + } + if (!(lower <= upper)) { + return { ...current! }; + } + const jump = lower + rng() * (upper - lower); + for (const parameter of parameters) { + current![parameter.name] = clamp( + current![parameter.name]! + (direction[parameter.name]! / norm) * jump, + parameter.min, + parameter.max, + ); + } + return { ...current! }; + }; + + for (let index = 0; index < burnIn; index += 1) { + step(); + } + return step; +} + +/** + * Draws every point feasible by construction, per the plan: bounds tighten + * the box, orderings sample a scaled gap (`upper = lower + t · headroom`), + * the linear system is walked with hit-and-run, and nonlinear conjuncts + * fall back to rejection inside the loop. Returns `points` possibly shorter + * than `count` when the nonlinear leftovers reject too much. + */ +export function sampleByConstruction( + parameters: readonly ParameterSpec[], + plan: readonly ConjunctPlan[], + count: number, + rng: Rng, + maxAttempts = count * 200, +): SamplingResult { + const box = new Map( + parameters.map((parameter) => [ + parameter.name, + { min: parameter.min, max: parameter.max }, + ]), + ); + for (const conjunct of plan) { + if (conjunct.kind === "bound") { + const interval = box.get(conjunct.parameter); + if (interval) { + interval.min = Math.max(interval.min, conjunct.min); + interval.max = Math.min(interval.max, conjunct.max); + } + } + } + const orderings = plan.filter( + (conjunct): conjunct is ConjunctPlan & { kind: "ordering" } => + conjunct.kind === "ordering", + ); + const linears = plan.filter( + (conjunct): conjunct is ConjunctPlan & { kind: "linear" } => + conjunct.kind === "linear", + ); + const nonlinears = plan.filter((conjunct) => conjunct.kind === "nonlinear"); + + const orderedNames = new Set( + orderings.flatMap((ordering) => [ordering.lower, ordering.upper]), + ); + const linearNames = new Set( + linears.flatMap((linear) => [...linear.coefficients.keys()]), + ); + + // The walk runs over the bound-folded box, so `c <= 6` style conjuncts + // tighten the polytope's box faces too. + const foldedParameters = parameters.map((parameter) => { + const interval = box.get(parameter.name)!; + return { name: parameter.name, min: interval.min, max: interval.max }; + }); + const walk = + linears.length > 0 + ? createHitAndRun( + foldedParameters.filter((parameter) => + linearNames.has(parameter.name), + ), + linears.map((linear) => ({ + coefficients: linear.coefficients, + bound: linear.bound, + })), + rng, + ) + : null; + if (linears.length > 0 && walk === null) { + // No interior point found: the linear system is (near-)infeasible. + return { points: [], attempts: 0, infeasible: 0 }; + } + + const points: SamplePoint[] = []; + let attempts = 0; + while (points.length < count && attempts < maxAttempts) { + attempts += 1; + const point: Record = {}; + const walked = walk?.(); + for (const parameter of parameters) { + if (walked && linearNames.has(parameter.name)) { + point[parameter.name] = walked[parameter.name]!; + continue; + } + if (orderedNames.has(parameter.name)) { + continue; // Filled by the ordering pass below. + } + const interval = box.get(parameter.name)!; + if (interval.min > interval.max) { + return { points, attempts, infeasible: 0 }; + } + point[parameter.name] = + interval.min + rng() * (interval.max - interval.min); + } + for (const ordering of orderings) { + const lowerInterval = box.get(ordering.lower)!; + const upperInterval = box.get(ordering.upper)!; + const lower = + point[ordering.lower] ?? + lowerInterval.min + rng() * (lowerInterval.max - lowerInterval.min); + point[ordering.lower] = lower; + const floor = Math.max(lower, upperInterval.min); + if (floor > upperInterval.max) { + break; + } + point[ordering.upper] = floor + rng() * (upperInterval.max - floor); + } + if (parameters.some((parameter) => point[parameter.name] === undefined)) { + continue; + } + if ( + nonlinears.length > 0 && + !nonlinears.every((conjunct) => + satisfies(parseCache(conjunct.source), point), + ) + ) { + continue; + } + points.push(point); + } + return { points, attempts, infeasible: 0 }; +} diff --git a/libs/@hashintel/petrinaut/src/ui/dev/constraint-prototypes/shell.tsx b/libs/@hashintel/petrinaut/src/ui/dev/constraint-prototypes/shell.tsx new file mode 100644 index 00000000000..611da06d45e --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/dev/constraint-prototypes/shell.tsx @@ -0,0 +1,252 @@ +/** + * Shared chrome for the constraint prototypes: the page shell with an + * explainer, labelled control rows, sliders, stat chips, and the constraint + * input (a single-line Monaco editor with parse feedback and a live margin + * badge). Everything is controlled by the hosting story. + */ + +import { css, cx } from "@hashintel/ds-helpers/css"; + +import { CodeEditor } from "../../monaco/code-editor"; +import { ExprError, parseExpression } from "./expr"; + +import type { ExprNode } from "./expr"; + +const shellStyle = css({ + display: "flex", + flexDirection: "column", + gap: "4", + maxWidth: "[880px]", + color: "neutral.s110", +}); + +const titleStyle = css({ + fontSize: "lg", + fontWeight: "semibold", +}); + +const explainerStyle = css({ + fontSize: "sm", + color: "neutral.s90", + lineHeight: "[1.5]", + maxWidth: "[72ch]", + whiteSpace: "pre-line", +}); + +const sectionTitleStyle = css({ + fontSize: "xs", + fontWeight: "semibold", + textTransform: "uppercase", + letterSpacing: "wide", + color: "neutral.s90", + marginTop: "2", +}); + +const rowStyle = css({ + display: "flex", + alignItems: "center", + gap: "3", + flexWrap: "wrap", +}); + +const columnStyle = css({ + display: "flex", + flexDirection: "column", + gap: "2", +}); + +export const PrototypeShell = ({ + title, + explainer, + children, +}: { + title: string; + explainer: string; + children: React.ReactNode; +}) => ( +
+
{title}
+

{explainer}

+ {children} +
+); + +export const Section = ({ + title, + children, +}: { + title: string; + children: React.ReactNode; +}) => ( +
+
{title}
+ {children} +
+); + +export const Row = ({ children }: { children: React.ReactNode }) => ( +
{children}
+); + +const sliderLabelStyle = css({ + display: "flex", + alignItems: "center", + gap: "2", + fontSize: "xs", + color: "neutral.s90", +}); + +const sliderValueStyle = css({ + fontFamily: "mono", + fontSize: "xs", + color: "neutral.s110", + minWidth: "[44px]", + textAlign: "right", +}); + +function formatValue(value: number): string { + return Math.abs(value) >= 100 + ? value.toFixed(0) + : String(Math.round(value * 100) / 100); +} + +export const Slider = ({ + label, + value, + min, + max, + step, + onChange, +}: { + label: string; + value: number; + min: number; + max: number; + step?: number; + onChange: (value: number) => void; +}) => ( + +); + +const statStyle = css({ + display: "inline-flex", + alignItems: "baseline", + gap: "1.5", + paddingX: "2", + paddingY: "1", + borderRadius: "sm", + border: "1px solid", + borderColor: "neutral.a45", + backgroundColor: "neutral.s05", + fontSize: "xs", + color: "neutral.s90", +}); + +const statValueStyle = css({ + fontFamily: "mono", + fontSize: "sm", + color: "neutral.s110", +}); + +const goodStyle = css({ color: "green.s100!" }); +const badStyle = css({ color: "red.s105!" }); + +export const Stat = ({ + label, + value, + tone, +}: { + label: string; + value: string; + tone?: "good" | "bad"; +}) => ( + + {label} + + {value} + + +); + +const editorRowStyle = css({ + display: "flex", + alignItems: "center", + gap: "2", + "& > :first-child": { + flex: "[1]", + minWidth: "0", + }, +}); + +const errorStyle = css({ + fontSize: "xs", + color: "red.s105", + fontFamily: "mono", +}); + +export type ParsedConstraint = + | { ok: true; node: ExprNode } + | { ok: false; error: string }; + +/** Parses a source string into a prototype constraint, error as data. */ +export function parseConstraint(source: string): ParsedConstraint { + try { + return { ok: true, node: parseExpression(source) }; + } catch (error) { + return { + ok: false, + error: error instanceof ExprError ? error.message : String(error), + }; + } +} + +/** + * The prototypes' expression input: a single-line editor, a slot for the + * live verdict beside it, and the parse error underneath. + */ +export const ConstraintInput = ({ + path, + value, + onChange, + error, + after, +}: { + /** Unique Monaco model path for this input. */ + path: string; + value: string; + onChange: (value: string) => void; + error?: string; + after?: React.ReactNode; +}) => ( +
+
+ onChange(next ?? "")} + /> + {after} +
+ {error === undefined ? null :
{error}
} +
+); diff --git a/libs/@hashintel/petrinaut/src/ui/dev/constraint-prototypes/toy-model.ts b/libs/@hashintel/petrinaut/src/ui/dev/constraint-prototypes/toy-model.ts new file mode 100644 index 00000000000..2c44d33603e --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/dev/constraint-prototypes/toy-model.ts @@ -0,0 +1,110 @@ +/** + * The toy system every constraint prototype runs against: a cooling-tank + * process with four searched parameters and three observable metrics + * (`temperature`, `pressure`, `throughput`) over a simulated run. Small + * enough to re-simulate on every slider move, rich enough that state + * constraints have real excursions to catch: pushing `flow_rate` up raises + * throughput but heats the tank; `cooling_power` fights the heating with a + * lag; a mid-run demand spike stresses whatever margin is left. + * + * Deterministic under a seed — stories re-simulate during render. + */ + +import { createRng } from "./sampling"; + +import type { Trace } from "./robustness"; +import type { ParameterSpec, SamplePoint } from "./sampling"; + +export const TOY_PARAMETERS: readonly ParameterSpec[] = [ + { name: "flow_rate", min: 0, max: 10 }, + { name: "cooling_power", min: 0, max: 8 }, + { name: "batch_size", min: 1, max: 50 }, + { name: "reserve_ratio", min: 0, max: 1 }, +]; + +export const TOY_DEFAULTS: SamplePoint = { + flow_rate: 6, + cooling_power: 3, + batch_size: 20, + reserve_ratio: 0.3, +}; + +export const TOY_METRICS = ["temperature", "pressure", "throughput"] as const; + +export type ToyMetric = (typeof TOY_METRICS)[number]; + +function round2(value: number): number { + return Math.round(value * 100) / 100; +} + +const STEPS = 120; +const DT = 0.5; + +/** + * Simulates one run: forward-Euler thermal dynamics plus a demand spike in + * the middle third and small seeded process noise. Each step's environment + * carries the three metrics, the elapsed `time`, and every parameter (so a + * constraint can mix state and parameters, e.g. + * `temperature < 40 + 5 * cooling_power`). + */ +export function simulateToyRun(parameters: SamplePoint, seed = 7): Trace { + const rng = createRng(seed); + const flowRate = parameters.flow_rate ?? 0; + const coolingPower = parameters.cooling_power ?? 0; + const batchSize = parameters.batch_size ?? 1; + const reserveRatio = parameters.reserve_ratio ?? 0; + + const times: number[] = []; + const steps: Map[] = []; + + let temperature = 25; + let pressure = 1; + + for (let index = 0; index < STEPS; index += 1) { + const time = index * DT; + const spike = time >= 20 && time <= 40 ? 1.6 : 1; + const demand = flowRate * spike; + const noise = (rng() - 0.5) * 0.6; + + const heating = 1.1 * demand + 0.05 * batchSize; + const cooling = 1.35 * coolingPower + 0.08 * (temperature - 25); + temperature += DT * (heating - cooling) + noise; + + const pressureTarget = + 0.8 + 0.05 * batchSize * (1 - reserveRatio) + 0.06 * demand; + pressure += DT * 0.8 * (pressureTarget - pressure) + noise * 0.05; + + const throughput = Math.max( + 0, + demand * + (1 - Math.max(0, temperature - 70) * 0.02) * + (1 - reserveRatio * 0.4), + ); + + const step = new Map(); + step.set("temperature", round2(temperature)); + step.set("pressure", round2(pressure)); + step.set("throughput", round2(throughput)); + step.set("time", time); + for (const [name, value] of Object.entries(parameters)) { + step.set(name, value); + } + times.push(time); + steps.push(step); + } + + return { times, steps }; +} + +/** + * The run's objective (mean throughput) — what the penalty multiplier gets + * applied to in the prototypes. + */ +export function toyObjective(trace: Trace): number { + let sum = 0; + for (const step of trace.steps) { + const value = step.get("throughput"); + sum += typeof value === "number" ? value : 0; + } + return round2(sum / trace.steps.length); +}