diff --git a/.changeset/fe-1121-uuid-token-dimension-type.md b/.changeset/fe-1121-uuid-token-dimension-type.md new file mode 100644 index 00000000000..d3a64f26772 --- /dev/null +++ b/.changeset/fe-1121-uuid-token-dimension-type.md @@ -0,0 +1,6 @@ +--- +"@hashintel/petrinaut": minor +"@hashintel/petrinaut-core": minor +--- + +Add the `uuid` token dimension type: 128-bit RFC 4122 identifiers represented as `bigint` at runtime, stored as two little-endian 64-bit lanes in frame buffers, and as canonical lowercase strings at rest. Kernel outputs may omit uuid fields (auto-generated deterministically from the seeded simulation RNG) or use the new `Uuid.generate()` / `Uuid.from(value)` helpers; non-UUID inputs convert deterministically via UUIDv5. The initial-state and scenario spreadsheets gain UUID columns. diff --git a/libs/@hashintel/petrinaut-core/docs/architecture/engine.html b/libs/@hashintel/petrinaut-core/docs/architecture/engine.html index ac081850954..850c74b885b 100644 --- a/libs/@hashintel/petrinaut-core/docs/architecture/engine.html +++ b/libs/@hashintel/petrinaut-core/docs/architecture/engine.html @@ -696,14 +696,15 @@

Token memory layout — packed structs

- uuid / 128-bit (FE-1121, future) + uuid (128-bit, FE-1121) u64 × 2 — 16 B - Not implemented yet. Will read via a shared - BigUint64Array (~28 ns combine to one - bigint; lane-compare without combining for equality, - ~4× faster). Never route the lanes through - number (NaN-payload hazard). + Two little-endian lanes read via the shared + BigUint64Array view, combined to one + bigint at the boundary (~28 ns; lane-compare without + combining for equality, ~4× faster). Never routed through + number (NaN-payload hazard). Kernel outputs are + optional — omitted values auto-generate from the seeded RNG. diff --git a/libs/@hashintel/petrinaut-core/src/ai.ts b/libs/@hashintel/petrinaut-core/src/ai.ts index a94c84fbcbc..a3182a2973e 100644 --- a/libs/@hashintel/petrinaut-core/src/ai.ts +++ b/libs/@hashintel/petrinaut-core/src/ai.ts @@ -269,12 +269,12 @@ Validate every code-writing change. After any tool call that writes code — lam Place names are part of the code surface: lambdas/kernels read \`input.PlaceName\`, metrics read \`state.places.PlaceName.count\`, and scenario code-mode initial state keys are place names. Renaming a place via \`updatePlace\` requires updating every dependent lambda, kernel, dynamics, metric, visualizer, and scenario in the same batch — otherwise you will silently break references. Code-surface cheatsheet (exact shapes expected by the runtime): -- Transition lambda (\`transition.lambdaCode\`): \`export default Lambda((input, parameters) => …)\`. Available when stochasticity is enabled OR when colours are enabled and the transition has at least one standard or read input arc from a coloured place. \`input.PlaceName\` is a tuple sized to the input arc weight for coloured standard and read input arcs; token attributes are typed by the colour element: real/integer → number, boolean → boolean. Read arcs expose tokens in \`input\` but do not consume them when the transition fires. Inhibitor arcs and uncoloured input places are NOT in \`input\`. Predicate → boolean; stochastic → non-negative rate in firings per simulation second (0 disables, Infinity always fires). Must be deterministic. If unavailable or empty, the runtime uses true for predicate-style transitions and Infinity for stochastic-style transitions. -- Transition kernel (\`transition.transitionKernelCode\`): \`export default TransitionKernel((input, parameters) => …)\`. Available only for transitions with coloured output places. Return \`{ OutputPlaceName: [token, …] }\` sized to the output arc weight. Include only coloured output places; uncoloured output places are auto-populated. Output values must match element types: real/integer use numbers, boolean uses booleans. When stochasticity is enabled, real attributes may use \`Distribution.Gaussian(mean, sd)\` / \`Distribution.Uniform(min, max)\` / \`Distribution.Lognormal(mu, sigma)\` (never integer/boolean attributes), and chained \`.map(fn)\` on the same distribution shares one draw. When stochasticity is disabled, kernel outputs must use plain values only. Leave empty when no coloured outputs exist. -- Differential equation (\`differentialEquation.code\`): \`export default Dynamics((tokens, parameters) => …)\`. \`tokens\` is THIS place's tokens only. Return an array of the same length whose entries provide derivatives for real-valued elements only (i.e. dx/dt, not the new value); integer and boolean elements are discrete and remain unchanged by dynamics. The equation's \`colorId\` MUST match every referencing place's \`colorId\`. +- Transition lambda (\`transition.lambdaCode\`): \`export default Lambda((input, parameters) => …)\`. Available when stochasticity is enabled OR when colours are enabled and the transition has at least one standard or read input arc from a coloured place. \`input.PlaceName\` is a tuple sized to the input arc weight for coloured standard and read input arcs; token attributes are typed by the colour element: real/integer → number, boolean → boolean, uuid → bigint. Read arcs expose tokens in \`input\` but do not consume them when the transition fires. Inhibitor arcs and uncoloured input places are NOT in \`input\`. Predicate → boolean; stochastic → non-negative rate in firings per simulation second (0 disables, Infinity always fires). Must be deterministic. If unavailable or empty, the runtime uses true for predicate-style transitions and Infinity for stochastic-style transitions. +- Transition kernel (\`transition.transitionKernelCode\`): \`export default TransitionKernel((input, parameters) => …)\`. Available only for transitions with coloured output places. Return \`{ OutputPlaceName: [token, …] }\` sized to the output arc weight. Include only coloured output places; uncoloured output places are auto-populated. Output values must match element types: real/integer use numbers, boolean uses booleans. uuid attributes are OPTIONAL in output tokens: omit them to auto-generate a fresh UUID deterministically from the seeded simulation RNG, use \`Uuid.generate()\` for an explicit fresh UUID, \`Uuid.from(value)\` to derive one from any value, or forward an input token's uuid bigint unchanged; plain non-UUID values (numbers, arbitrary strings) are converted deterministically via UUIDv5. When stochasticity is enabled, real attributes may use \`Distribution.Gaussian(mean, sd)\` / \`Distribution.Uniform(min, max)\` / \`Distribution.Lognormal(mu, sigma)\` (never integer/boolean/uuid attributes), and chained \`.map(fn)\` on the same distribution shares one draw. When stochasticity is disabled, kernel outputs must use plain values only. Leave empty when no coloured outputs exist. +- Differential equation (\`differentialEquation.code\`): \`export default Dynamics((tokens, parameters) => …)\`. \`tokens\` is THIS place's tokens only. Return an array of the same length whose entries provide derivatives for real-valued elements only (i.e. dx/dt, not the new value); integer, boolean, and uuid elements are discrete and remain unchanged by dynamics. The equation's \`colorId\` MUST match every referencing place's \`colorId\`. - Place visualizer (\`place.visualizerCode\`): \`export default Visualization(({ tokens, parameters }) => )\`. Classic React runtime — do NOT import React, do NOT use \`<>…\` fragments, do NOT use hooks. Convention: return a sized \`\`. - Metric (\`metric.code\`): a plain function body — NOT a module, no \`export default\`, no wrapper. The only variable in scope is \`state\`. Must \`return\` a finite number. Example: \`return state.places.Infected.count / (state.places.Susceptible.count + state.places.Infected.count + state.places.Recovered.count);\`. \`parameters\` and \`scenario\` are NOT available inside metrics. -- Scenario per_place initial state: \`content\` keys are place IDs; uncoloured values are expressions with \`parameters\` and \`scenario\` in scope; coloured values are row arrays in colour element order using numbers and booleans. +- Scenario per_place initial state: \`content\` keys are place IDs; uncoloured values are expressions with \`parameters\` and \`scenario\` in scope; coloured values are row arrays in colour element order using numbers and booleans; uuid columns accept UUID strings (any other text converts deterministically to a UUID via UUIDv5). - Scenario code-mode initial state: function body returning \`{ PlaceName: tokens }\` keyed by NAME (asymmetric with per_place IDs); unknown names are silently dropped. - Parameter access in any code surface: use \`parameters.\` where \`\` is the parameter's lower_snake_case \`variableName\` value (e.g. \`parameters.crash_threshold\`, never \`parameters.crashThreshold\`). diff --git a/libs/@hashintel/petrinaut-core/src/default-codes.ts b/libs/@hashintel/petrinaut-core/src/default-codes.ts index 09c315fae21..640a8c3fa7f 100644 --- a/libs/@hashintel/petrinaut-core/src/default-codes.ts +++ b/libs/@hashintel/petrinaut-core/src/default-codes.ts @@ -9,6 +9,8 @@ const defaultTokenAttributeSource = ( case "integer": case "real": return "0"; + case "uuid": + return "Uuid.generate()"; } }; diff --git a/libs/@hashintel/petrinaut-core/src/index.ts b/libs/@hashintel/petrinaut-core/src/index.ts index caf9be1048d..d0eb3a58cc9 100644 --- a/libs/@hashintel/petrinaut-core/src/index.ts +++ b/libs/@hashintel/petrinaut-core/src/index.ts @@ -343,8 +343,16 @@ export { readTokenRecord, type PhysicalKind, type TokenLayoutField, + type TokenRegionViews, type TokenSlotLayout, } from "./simulation/engine/token-layout"; +export { + formatUuid, + NIL_UUID, + parseUuid, + PETRINAUT_UUID_NAMESPACE, + toUuid, +} from "./simulation/engine/uuid"; export { compileUserCode } from "./simulation/authoring/user-code/compile-user-code"; export { displayNameSchema, diff --git a/libs/@hashintel/petrinaut-core/src/lsp/lib/checker.test.ts b/libs/@hashintel/petrinaut-core/src/lsp/lib/checker.test.ts index f3d0ba0080e..7492f7cf2f4 100644 --- a/libs/@hashintel/petrinaut-core/src/lsp/lib/checker.test.ts +++ b/libs/@hashintel/petrinaut-core/src/lsp/lib/checker.test.ts @@ -991,6 +991,131 @@ describe("checkSDCPN", () => { }); }); + describe("UUID elements", () => { + const uuidTypes = [ + { + id: "color1", + elements: [ + { name: "id", type: "uuid" as const }, + { name: "x", type: "real" as const }, + ], + }, + ]; + const uuidPlaces = [ + { id: "place1", name: "Source", colorId: "color1" }, + { id: "place2", name: "Target", colorId: "color1" }, + ]; + const uuidKernel = (body: string) => + createSDCPN({ + types: uuidTypes, + places: uuidPlaces, + transitions: [ + { + id: "t1", + inputArcs: [{ placeId: "place1", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "place2", weight: 1 }], + transitionKernelCode: `export default TransitionKernel((input, parameters) => { + return { Target: [${body}] }; + });`, + }, + ], + }); + + it("types input uuid attributes as bigint (=== comparison is valid)", () => { + const sdcpn = createSDCPN({ + types: uuidTypes, + places: uuidPlaces, + transitions: [ + { + id: "t1", + lambdaType: "predicate", + inputArcs: [{ placeId: "place1", weight: 2, type: "standard" }], + outputArcs: [{ placeId: "place2", weight: 1 }], + lambdaCode: `export default Lambda((input, parameters) => { + return input.Source[0].id === input.Source[1].id; + });`, + transitionKernelCode: `export default TransitionKernel((input, parameters) => { + return { Target: [input.Source[0]] }; + });`, + }, + ], + }); + + const result = check(sdcpn); + + expect(result.isValid).toBe(true); + expect(result.itemDiagnostics).toHaveLength(0); + }); + + it("rejects arithmetic mixing an input uuid bigint with a number", () => { + const sdcpn = createSDCPN({ + types: uuidTypes, + places: uuidPlaces, + transitions: [ + { + id: "t1", + lambdaType: "predicate", + inputArcs: [{ placeId: "place1", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "place2", weight: 1 }], + lambdaCode: `export default Lambda((input, parameters) => { + return input.Source[0].id * 2 > 0; + });`, + transitionKernelCode: `export default TransitionKernel((input, parameters) => { + return { Target: [input.Source[0]] }; + });`, + }, + ], + }); + + const result = check(sdcpn); + + expect(result.isValid).toBe(false); + expect(result.itemDiagnostics[0]?.itemType).toBe("transition-lambda"); + }); + + it("accepts kernel outputs with omitted uuid, Uuid.generate(), and strings", () => { + const omitted = check(uuidKernel(`{ x: 1 }`)); + expect(omitted.isValid).toBe(true); + expect(omitted.itemDiagnostics).toHaveLength(0); + + const generated = check(uuidKernel(`{ id: Uuid.generate(), x: 1 }`)); + expect(generated.isValid).toBe(true); + expect(generated.itemDiagnostics).toHaveLength(0); + + const fromString = check(uuidKernel(`{ id: "order-1", x: 1 }`)); + expect(fromString.isValid).toBe(true); + expect(fromString.itemDiagnostics).toHaveLength(0); + + const forwarded = check( + uuidKernel(`{ id: input.Source[0].id, x: input.Source[0].x }`), + ); + expect(forwarded.isValid).toBe(true); + expect(forwarded.itemDiagnostics).toHaveLength(0); + }); + + it("accepts Uuid helpers even when stochasticity is disabled", () => { + const result = check(uuidKernel(`{ id: Uuid.generate(), x: 1 }`), { + colors: true, + stochasticity: false, + dynamics: true, + parameters: true, + subnets: true, + }); + + expect(result.isValid).toBe(true); + expect(result.itemDiagnostics).toHaveLength(0); + }); + + it("rejects Distribution values on uuid attributes", () => { + const result = check( + uuidKernel(`{ id: Distribution.Uniform(0, 1), x: 1 }`), + ); + + expect(result.isValid).toBe(false); + expect(result.itemDiagnostics[0]?.itemType).toBe("transition-kernel"); + }); + }); + describe("Multiple errors", () => { it("reports errors from multiple items", () => { // GIVEN - SDCPN with errors in both a differential equation and a transition diff --git a/libs/@hashintel/petrinaut-core/src/lsp/lib/generate-virtual-files.ts b/libs/@hashintel/petrinaut-core/src/lsp/lib/generate-virtual-files.ts index e77ebca0d2f..49aa41ec450 100644 --- a/libs/@hashintel/petrinaut-core/src/lsp/lib/generate-virtual-files.ts +++ b/libs/@hashintel/petrinaut-core/src/lsp/lib/generate-virtual-files.ts @@ -34,6 +34,9 @@ function toTsType(type: ColorElementType | "ratio"): string { if (type === "boolean") { return "boolean"; } + if (type === "uuid") { + return "bigint"; + } return "number"; } @@ -59,22 +62,31 @@ function toDynamicsDerivativeType(color: Color): string { } /** - * Kernel output token type when stochasticity is enabled. Only continuous - * (`real`) attributes may be produced as a `Distribution`; discrete - * attributes (`integer`, `boolean`) must be plain values — the generic - * `Probabilistic` mapped type could not distinguish integer from real - * (both are `number`), so the type is generated per element instead. + * Kernel output token type, generated per element: + * + * - `real` attributes may additionally be produced as a `Distribution` when + * stochasticity is enabled — the generic `Probabilistic` mapped type + * could not distinguish integer from real (both are `number`), so the type + * is generated per element instead. + * - `uuid` attributes are OPTIONAL (omitted values are auto-generated from + * the seeded simulation RNG) and also accept UUID strings and the + * `Uuid.generate()` / `Uuid.from(value)` sentinels. + * - Other discrete attributes (`integer`, `boolean`) must be plain values. */ -function toStochasticOutputTokenType(color: Color): string { +function toKernelOutputTokenType( + color: Color, + stochasticityEnabled: boolean, +): string { const properties = color.elements - .map( - (element) => - ` ${element.name}: ${ - element.type === "real" - ? "number | Distribution" - : toTsType(element.type) - };`, - ) + .map((element) => { + if (element.type === "uuid") { + return ` ${element.name}?: bigint | string | PetrinautUuid;`; + } + if (element.type === "real" && stochasticityEnabled) { + return ` ${element.name}: number | Distribution;`; + } + return ` ${element.name}: ${toTsType(element.type)};`; + }) .join("\n"); return properties.length > 0 @@ -132,18 +144,27 @@ export function generateVirtualFiles( ): Map { const files = new Map(); - // Generate global SDCPN library definitions + // Generate global SDCPN library definitions. The Uuid helper is always + // available (uuid token attributes exist regardless of stochasticity); + // Distribution declarations stay gated on the stochasticity extension. files.set(getItemFilePath("sdcpn-lib-defs"), { - content: extensions.stochasticity - ? [ - `type Distribution = { map(fn: (value: number) => number): Distribution };`, - `declare namespace Distribution {`, - ` function Gaussian(mean: number, deviation: number): Distribution;`, - ` function Uniform(min: number, max: number): Distribution;`, - ` function Lognormal(mu: number, sigma: number): Distribution;`, - `}`, - ].join("\n") - : "", + content: [ + ...(extensions.stochasticity + ? [ + `type Distribution = { map(fn: (value: number) => number): Distribution };`, + `declare namespace Distribution {`, + ` function Gaussian(mean: number, deviation: number): Distribution;`, + ` function Uniform(min: number, max: number): Distribution;`, + ` function Lognormal(mu: number, sigma: number): Distribution;`, + `}`, + ] + : []), + `type PetrinautUuid = { readonly __petrinautUuid: "generate" | "from" };`, + `declare namespace Uuid {`, + ` function generate(): PetrinautUuid;`, + ` function from(value: unknown): PetrinautUuid;`, + `}`, + ].join("\n"), }); // Build lookup maps for places and types. @@ -293,7 +314,8 @@ export function generateVirtualFiles( // Build output type: { [placeName]: [Token, Token, ...] } based on output arcs. const outputTypeImports: string[] = []; const outputTypeProperties: string[] = []; - // Per-colour stochastic output token types (Distribution on real only). + // Per-colour kernel output token types (optional uuid; Distribution on + // real only when stochasticity is enabled). const outputTokenTypeAliases = new Map(); for (const arc of transition.outputArcs) { @@ -319,19 +341,15 @@ export function generateVirtualFiles( outputTypeImports.push(importStatement); } const tokenTuple = Array.from({ length: arc.weight }) - .fill( - extensions.stochasticity - ? `Output_${sanitizedColorId}` - : `Color_${sanitizedColorId}`, - ) + .fill(`Output_${sanitizedColorId}`) .join(", "); - if ( - extensions.stochasticity && - !outputTokenTypeAliases.has(sanitizedColorId) - ) { + if (!outputTokenTypeAliases.has(sanitizedColorId)) { outputTokenTypeAliases.set( sanitizedColorId, - `type Output_${sanitizedColorId} = ${toStochasticOutputTokenType(color)};`, + `type Output_${sanitizedColorId} = ${toKernelOutputTokenType( + color, + extensions.stochasticity, + )};`, ); } const placeDisplayName = getPlaceDisplayNameForArc(arc, sdcpn); @@ -621,7 +639,7 @@ export function generateMetricSessionFiles( const placesType = placeStateProperties.length > 0 ? `{\n${placeStateProperties.join("\n")}\n}` - : "Record[] }>"; + : "Record[] }>"; // defs file (kept separate so updates only invalidate code on real changes) const defsPath = getItemFilePath("metric-session-defs", { sessionId }); diff --git a/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts b/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts index 22e9b7a5821..0ae140f8012 100644 --- a/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts +++ b/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts @@ -159,9 +159,9 @@ export const colorElementSchema = z description: "Token attribute identifier used DIRECTLY in code. Lambdas, kernels, dynamics, visualizers, and metrics destructure tokens as `{ }`, so this must be a valid JavaScript identifier (e.g. `machine_damage_ratio`, `x`, `velocity`). Spaces, hyphens, and leading digits will break user code that references the attribute; prefer lower_snake_case for consistency with parameter naming.", }), - type: z.enum(["real", "integer", "boolean"]).meta({ + type: z.enum(["real", "integer", "boolean", "uuid"]).meta({ description: - "`real` is continuous and may be updated by dynamics. `integer` and `boolean` are discrete token attributes updated by transition kernels. `integer` values are stored as Float64 and rounded on read/write: they are exact only within ±2^53 (±9,007,199,254,740,992); values beyond that lose precision silently.", + "`real` is continuous and may be updated by dynamics. `integer`, `boolean`, and `uuid` are discrete token attributes updated by transition kernels. `integer` values are stored as Float64 and rounded on read/write: they are exact only within ±2^53 (±9,007,199,254,740,992); values beyond that lose precision silently. `uuid` is a 128-bit RFC 4122 identifier: runtime code sees it as a `bigint`, frame buffers store it as two little-endian 64-bit lanes, and at-rest data (documents, scenarios) uses canonical lowercase strings. `uuid` fields are OPTIONAL in kernel outputs — omitted values are auto-generated deterministically from the seeded simulation RNG — and non-UUID inputs are converted deterministically via UUIDv5.", }), }) .meta({ diff --git a/libs/@hashintel/petrinaut-core/src/schemas/scenario-schema.ts b/libs/@hashintel/petrinaut-core/src/schemas/scenario-schema.ts index adde321d4c7..d64f0386a10 100644 --- a/libs/@hashintel/petrinaut-core/src/schemas/scenario-schema.ts +++ b/libs/@hashintel/petrinaut-core/src/schemas/scenario-schema.ts @@ -6,7 +6,13 @@ import { idSchema } from "./entity-schemas"; import type { Scenario } from "../types/sdcpn"; const SNAKE_CASE_RE = /^[a-z][a-z0-9_]*$/; -const tokenAttributeValueSchema = z.union([z.number(), z.boolean()]); +// Strings supply `uuid` element values (canonical UUID strings pass through; +// any other text converts deterministically via UUIDv5 at compile time). +const tokenAttributeValueSchema = z.union([ + z.number(), + z.boolean(), + z.string(), +]); export const scenarioParameterSchema = z .strictObject({ diff --git a/libs/@hashintel/petrinaut-core/src/simulation/api.ts b/libs/@hashintel/petrinaut-core/src/simulation/api.ts index b594c90de83..6436a759648 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/api.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/api.ts @@ -30,16 +30,26 @@ export interface SimulationTransport { export type WorkerFactory = WorkerFactoryLike; +/** + * One token attribute value in an initial marking. In addition to runtime + * token values, `uuid` attributes may be supplied as canonical UUID strings + * (the JSON-serializable at-rest form) — the simulator coerces both. + */ +export type InitialTokenAttributeValue = number | boolean | bigint | string; + /** * Initial token distribution for starting a simulation. * - * This is intentionally JSON-serializable. The simulator is responsible for + * This is intentionally JSON-serializable (supply `uuid` attributes as + * strings, not bigints, when serializing). The simulator is responsible for * converting it into its internal packed frame representation. * * - Uncolored places use a token count. * - Colored places use one record per token, keyed by color element name. */ -export type InitialPlaceMarking = number | TokenRecord[]; +export type InitialPlaceMarking = + | number + | Record[]; export type InitialMarking = Record; /** diff --git a/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/compile-scenario.test.ts b/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/compile-scenario.test.ts index ab5fdffdbc6..35920930f8c 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/compile-scenario.test.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/compile-scenario.test.ts @@ -551,6 +551,74 @@ describe("compileScenario", () => { }); }); + describe("uuid elements", () => { + const uuidType: Color = { + id: "type1", + name: "Typed entity", + iconSlug: "circle", + displayColor: "#000000", + elements: [ + { elementId: "id", name: "id", type: "uuid" }, + { elementId: "x", name: "x", type: "real" }, + ], + }; + + const compileRows = (rows: (number | boolean | string)[][]) => + compileScenario( + scenario({ + initialState: { type: "per_place", content: { place1: rows } }, + }), + [], + [place("place1", "Place 1", "type1")], + [uuidType], + ); + + const firstTokenId = (result: ReturnType): unknown => { + if (!result.ok) { + throw new Error("Expected scenario to compile"); + } + return ( + result.result.initialState.place1 as Record[] + )[0]!.id; + }; + + it("normalizes uuid rows to canonical lowercase strings", () => { + const result = compileRows([["0F9A3B5C-7D1E-4A2B-8C3D-4E5F6A7B8C9D", 1]]); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.result.initialState.place1).toEqual([ + { id: "0f9a3b5c-7d1e-4a2b-8c3d-4e5f6a7b8c9d", x: 1 }, + ]); + // JSON-serializable at rest. + expect(() => JSON.stringify(result.result.initialState)).not.toThrow(); + } + }); + + it("converts arbitrary text to a stable UUIDv5 string", () => { + const firstId = firstTokenId(compileRows([["order-1", 1]])); + const secondId = firstTokenId(compileRows([["order-1", 2]])); + const differentId = firstTokenId(compileRows([["order-2", 1]])); + + expect(firstId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + expect(secondId).toBe(firstId); + expect(differentId).not.toBe(firstId); + }); + + it("defaults missing uuid columns to the nil uuid string", () => { + const result = compileRows([[]]); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.result.initialState.place1).toEqual([ + { id: "00000000-0000-0000-0000-000000000000", x: 0 }, + ]); + } + }); + }); + describe("evaluation order", () => { it("initial state sees overridden parameter values", () => { const result = compileScenario( diff --git a/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/compile-scenario.ts b/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/compile-scenario.ts index 3482fac86b5..1e3fb77133c 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/compile-scenario.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/compile-scenario.ts @@ -1,15 +1,13 @@ import { coerceTokenRecord } from "../../engine/token-values"; +import { formatUuid } from "../../engine/uuid"; import { runSandboxed, SHADOWED_GLOBALS } from "../sandbox"; +import type { Color, Parameter, Place, Scenario } from "../../../types/sdcpn"; import type { - Color, - Parameter, - Place, - Scenario, - TokenAttributeValue, - TokenRecord, -} from "../../../types/sdcpn"; -import type { InitialMarking, InitialPlaceMarking } from "../../api"; + InitialMarking, + InitialPlaceMarking, + InitialTokenAttributeValue, +} from "../../api"; // -- Result types ------------------------------------------------------------- @@ -93,23 +91,50 @@ function evaluateExpression( ); } +type MarkingTokenRecord = Record; + +/** + * Coerces one raw token source through the runtime codec, then converts uuid + * attributes back to canonical lowercase strings so the compiled initial + * state stays JSON-serializable. Arbitrary uuid inputs (free text, numbers) + * are normalized deterministically via `toUuid` inside `coerceTokenRecord`. + */ +function compileTokenRecord( + source: Record, + elements: Color["elements"], +): MarkingTokenRecord { + const coerced = coerceTokenRecord( + source, + elements, + "Scenario initial state token", + ); + const token: MarkingTokenRecord = { ...coerced }; + for (const element of elements) { + if (element.type === "uuid") { + const value = coerced[element.name]; + token[element.name] = formatUuid(typeof value === "bigint" ? value : 0n); + } + } + return token; +} + function tokenRecordsFromRows( - rows: TokenAttributeValue[][], + rows: readonly (number | boolean | string)[][], elements: Color["elements"], -): TokenRecord[] { +): MarkingTokenRecord[] { return rows.map((row) => { const token: Record = {}; for (let i = 0; i < elements.length; i++) { token[elements[i]!.name] = row[i]; } - return coerceTokenRecord(token, elements, "Scenario initial state token"); + return compileTokenRecord(token, elements); }); } function normalizeTokenRecords( tokens: unknown[], elements: Color["elements"], -): TokenRecord[] { +): MarkingTokenRecord[] { return tokens.flatMap((rawToken) => { if ( typeof rawToken !== "object" || @@ -120,9 +145,7 @@ function normalizeTokenRecords( } const source = rawToken as Record; - return [ - coerceTokenRecord(source, elements, "Scenario initial state token"), - ]; + return [compileTokenRecord(source, elements)]; }); } diff --git a/libs/@hashintel/petrinaut-core/src/simulation/authoring/user-code/compile-user-code.test.ts b/libs/@hashintel/petrinaut-core/src/simulation/authoring/user-code/compile-user-code.test.ts index c134eeadb3c..d708c604be6 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/authoring/user-code/compile-user-code.test.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/authoring/user-code/compile-user-code.test.ts @@ -338,4 +338,31 @@ describe("compileUserCode", () => { ]); }); }); + + describe("Uuid runtime injection", () => { + it("exposes Uuid.generate() and Uuid.from() sentinels", () => { + const code = ` + export default TransitionKernel(() => { + return { generated: Uuid.generate(), derived: Uuid.from("order-1") }; + }); + `; + const fn = compileUserCode(code, "TransitionKernel"); + expect(fn()).toEqual({ + generated: { __petrinautUuid: "generate" }, + derived: { __petrinautUuid: "from", value: "order-1" }, + }); + }); + + it("keeps Uuid available when Distribution is disabled", () => { + const code = ` + export default TransitionKernel(() => { + return Uuid.generate(); + }); + `; + const fn = compileUserCode(code, "TransitionKernel", { + enableDistribution: false, + }); + expect(fn()).toEqual({ __petrinautUuid: "generate" }); + }); + }); }); diff --git a/libs/@hashintel/petrinaut-core/src/simulation/authoring/user-code/compile-user-code.ts b/libs/@hashintel/petrinaut-core/src/simulation/authoring/user-code/compile-user-code.ts index 50d65c470c5..30ae6749a54 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/authoring/user-code/compile-user-code.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/authoring/user-code/compile-user-code.ts @@ -1,6 +1,7 @@ import * as Babel from "@babel/standalone"; import { distributionRuntimeCode } from "./distribution"; +import { uuidRuntimeCode } from "./uuid-runtime"; type CompileUserCodeOptions = { /** @@ -90,6 +91,7 @@ export function compileUserCode( // Create an executable module-like environment const executableCode = ` ${options.enableDistribution === false ? "" : distributionRuntimeCode} + ${uuidRuntimeCode} ${mockConstructor} let __default_export__; ${sanitizedCode.replace(/export\s+default\s+/, "__default_export__ = ")} diff --git a/libs/@hashintel/petrinaut-core/src/simulation/authoring/user-code/uuid-runtime.ts b/libs/@hashintel/petrinaut-core/src/simulation/authoring/user-code/uuid-runtime.ts new file mode 100644 index 00000000000..aeb35a5bc21 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/simulation/authoring/user-code/uuid-runtime.ts @@ -0,0 +1,40 @@ +/** + * Sentinel values produced by the `Uuid` helper inside user code. They are + * resolved during transition kernel output encoding: + * + * - `Uuid.generate()` → a fresh UUID drawn from the seeded simulation RNG + * (same draw as an omitted uuid output field). + * - `Uuid.from(value)` → the total `toUuid` conversion of `value` (valid UUID + * strings parse; anything else maps deterministically via UUIDv5). + */ +export type UuidSentinel = + | { __petrinautUuid: "generate" } + | { __petrinautUuid: "from"; value: unknown }; + +/** + * Checks if a value is a `Uuid.generate()` / `Uuid.from(value)` sentinel. + */ +export function isUuidSentinel(value: unknown): value is UuidSentinel { + if (typeof value !== "object" || value === null) { + return false; + } + const tag = (value as Record).__petrinautUuid; + return tag === "generate" || tag === "from"; +} + +/** + * JavaScript source code that defines the `Uuid` namespace at runtime. + * Injected unconditionally into the compiled user code execution context + * (unlike `Distribution`, which is gated on the stochasticity extension) so + * uuid token attributes can always be produced. + */ +export const uuidRuntimeCode = ` + var Uuid = { + generate: function() { + return { __petrinautUuid: "generate" }; + }, + from: function(value) { + return { __petrinautUuid: "from", value: value }; + } + }; +`; diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/build-simulation.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/build-simulation.ts index d3d00ef1316..38481b41d15 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/build-simulation.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/build-simulation.ts @@ -183,7 +183,7 @@ function createDifferentialEquationFn({ ); } - const { f64, u8 } = createTokenRegionViews( + const views = createTokenRegionViews( placeBytes.buffer, placeBytes.byteOffset, placeBytes.byteLength, @@ -192,7 +192,7 @@ function createDifferentialEquationFn({ const inputTokens: TokenRecord[] = []; for (let tokenIndex = 0; tokenIndex < numberOfTokens; tokenIndex++) { inputTokens.push( - readTokenRecord(tokenLayout, f64, u8, tokenIndex * strideBytes), + readTokenRecord(tokenLayout, views, tokenIndex * strideBytes), ); } diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/compute-next-frame.test.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/compute-next-frame.test.ts index 37739d5c531..76189db8692 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/compute-next-frame.test.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/compute-next-frame.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { buildSimulation } from "./build-simulation"; import { computeNextFrame } from "./compute-next-frame"; import { decodePlaceTokens } from "./token-layout.test-helpers"; +import { parseUuid } from "./uuid"; import type { SDCPN } from "../../types/sdcpn"; @@ -96,6 +97,128 @@ describe("computeNextFrame", () => { ]); }); + it("preserves uuid lanes through dynamics, firing, compaction, and frame reads", () => { + // The NaN-payload uuid would be corrupted by any accidental routing of + // its lanes through a Float64Array (NaN canonicalization). + const nanPayloadUuid = "ffffffff-ffff-4fff-bfff-ffffffffffff"; + const otherUuid = "0f9a3b5c-7d1e-4a2b-8c3d-4e5f6a7b8c9d"; + + const sdcpn: SDCPN = { + types: [ + { + id: "type1", + name: "Type 1", + iconSlug: "circle", + displayColor: "#000000", + elements: [ + { elementId: "elem1", name: "id", type: "uuid" }, + { elementId: "elem2", name: "x", type: "real" }, + ], + }, + ], + differentialEquations: [ + { + id: "diffeq1", + name: "Differential Equation 1", + colorId: "type1", + code: "export default Dynamics((tokens, parameters) => { return tokens.map(token => ({ x: 1 })); });", + }, + ], + parameters: [], + places: [ + { + id: "p1", + name: "Source", + colorId: "type1", + dynamicsEnabled: true, + differentialEquationId: "diffeq1", + x: 0, + y: 0, + }, + { + id: "p2", + name: "Target", + colorId: "type1", + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + ], + transitions: [ + { + id: "t1", + name: "Transition 1", + inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "p2", weight: 1 }], + lambdaType: "stochastic", + lambdaCode: + "export default Lambda((tokens, parameters) => { return Infinity; });", + transitionKernelCode: + // Forward the consumed token's uuid unchanged (bigint pass-through). + "export default TransitionKernel((input, parameters) => { return { Target: [{ id: input.Source[0].id, x: input.Source[0].x }] }; });", + x: 100, + y: 0, + }, + ], + }; + + // Initial marking supplies uuids as at-rest strings. + const simulation = buildSimulation({ + sdcpn, + initialMarking: { + p1: [ + { id: nanPayloadUuid, x: 1.0 }, + { id: otherUuid, x: 2.0 }, + ], + }, + parameterValues: {}, + seed: 42, + dt: 0.1, + maxTime: null, + }); + + // Marking pack round-trip before any stepping. + expect( + decodePlaceTokens(simulation.frameLayout, simulation.frames[0]!, "p1"), + ).toEqual([ + { id: parseUuid(nanPayloadUuid), x: 1.0 }, + { id: parseUuid(otherUuid), x: 2.0 }, + ]); + + // Step until the transition fires: each step integrates dynamics and + // copies the frame; the firing step consumes one token (removal + + // compaction). Elapsed time is 0 on the first step, so the Infinity-rate + // transition fires on the second. + let result = computeNextFrame(simulation); + result = computeNextFrame(result.simulation); + expect(result.transitionFired).toBe(true); + + const lastFrame = + result.simulation.frames[result.simulation.currentFrameNumber]!; + const sourceTokens = decodePlaceTokens( + result.simulation.frameLayout, + lastFrame, + "p1", + ); + const targetTokens = decodePlaceTokens( + result.simulation.frameLayout, + lastFrame, + "p2", + ); + + expect(sourceTokens).toHaveLength(1); + expect(targetTokens).toHaveLength(1); + + // Both uuids survive intact, whichever token was consumed. + const survivingIds = [sourceTokens[0]!.id, targetTokens[0]!.id]; + expect(survivingIds).toContain(parseUuid(nanPayloadUuid)); + expect(survivingIds).toContain(parseUuid(otherUuid)); + + // Dynamics only touched the real field on the remaining source token. + expect(sourceTokens[0]!.x).toBeTypeOf("number"); + }); + it("should skip dynamics for places without type", () => { // GIVEN a place without a type const sdcpn: SDCPN = { diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/compute-possible-transition.test.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/compute-possible-transition.test.ts index af2f719db8f..75ff9e6d0ee 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/compute-possible-transition.test.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/compute-possible-transition.test.ts @@ -3,12 +3,14 @@ import { describe, expect, it } from "vitest"; import { getArcEndpointPlaceId } from "../../arc-endpoints"; import { createEngineFrameLayout } from "../frames/internal-frame"; import { computePossibleTransition as computePossibleTransitionImpl } from "./compute-possible-transition"; +import { nextRandom } from "./seeded-rng"; import { computeTokenSlotLayout } from "./token-layout"; import { decodeTokenBlock, makeTestFrame, type TestFrame, } from "./token-layout.test-helpers"; +import { formatUuid, parseUuid, toUuid } from "./uuid"; import type { Color, Place, Transition } from "../../types/sdcpn"; import type { @@ -480,4 +482,164 @@ describe("computePossibleTransition", () => { ), ).toEqual([{ amount: 2.5, count: 4, active: false }]); }); + + describe("uuid kernel outputs", () => { + const uuidColor: Color = { + id: "uuidColor", + name: "UuidColor", + iconSlug: "circle", + displayColor: "#FF0000", + elements: [ + { elementId: "id", name: "id", type: "uuid" }, + { elementId: "x", name: "x", type: "real" }, + ], + }; + + const makeUuidSimulation = (kernelFn: TransitionKernelFn) => { + const transition = makeTransition({ + id: "t1", + inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "p2", weight: 1 }], + }); + return makeSimulation({ + places: [ + makePlace("p1", "Source", uuidColor.id), + makePlace("p2", "Target", uuidColor.id), + ], + transitions: [transition], + types: [uuidColor], + lambdaFns: new Map([["t1", () => 10.0]]), + transitionKernelFns: new Map([["t1", kernelFn]]), + }); + }; + + const inputUuid = parseUuid("0f9a3b5c-7d1e-4a2b-8c3d-4e5f6a7b8c9d"); + + const makeUuidFrame = () => + makeTestFrame({ + places: { + p1: { + elements: uuidColor.elements, + tokens: [{ id: inputUuid, x: 1.0 }], + }, + p2: { elements: uuidColor.elements, tokens: [] }, + }, + transitions: { t1: transitionState() }, + }); + + const firstAddedToken = ( + result: ReturnType, + ) => decodeTokenBlock(uuidColor.elements, result!.add.p2![0]!); + + it("auto-generates a v4 uuid deterministically per seed when omitted", () => { + const simulation = makeUuidSimulation(() => ({ Target: [{ x: 2.0 }] })); + + const first = computePossibleTransition( + makeUuidFrame(), + simulation, + "t1", + 42, + ); + const second = computePossibleTransition( + makeUuidFrame(), + simulation, + "t1", + 42, + ); + const differentSeed = computePossibleTransition( + makeUuidFrame(), + simulation, + "t1", + 43, + ); + + const generated = firstAddedToken(first).id as bigint; + expect(generated).not.toBe(0n); + expect(firstAddedToken(second).id).toBe(generated); + expect(firstAddedToken(differentSeed).id).not.toBe(generated); + // Version/variant bits of the auto-generated value. + expect(formatUuid(generated)).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + // The auto-generation consumed RNG state beyond the firing draw. + expect(first!.newRngState).not.toBe( + computePossibleTransition( + makeUuidFrame(), + makeUuidSimulation(() => ({ Target: [{ id: 1n, x: 2.0 }] })), + "t1", + 42, + )!.newRngState, + ); + }); + + it("resolves the Uuid.generate() sentinel with the same seeded draw as omission", () => { + const omitted = computePossibleTransition( + makeUuidFrame(), + makeUuidSimulation(() => ({ Target: [{ x: 2.0 }] })), + "t1", + 42, + ); + const sentinel = computePossibleTransition( + makeUuidFrame(), + makeUuidSimulation(() => ({ + Target: [{ id: { __petrinautUuid: "generate" }, x: 2.0 }], + })), + "t1", + 42, + ); + + expect(firstAddedToken(sentinel).id).toBe(firstAddedToken(omitted).id); + expect(sentinel!.newRngState).toBe(omitted!.newRngState); + }); + + it("resolves Uuid.from(value) deterministically without consuming RNG", () => { + const run = () => + computePossibleTransition( + makeUuidFrame(), + makeUuidSimulation(() => ({ + Target: [ + { id: { __petrinautUuid: "from", value: "order-1" }, x: 2.0 }, + ], + })), + "t1", + 42, + ); + + const first = run(); + const second = run(); + expect(firstAddedToken(first).id).toBe(toUuid("order-1")); + expect(firstAddedToken(second).id).toBe(toUuid("order-1")); + // Only the firing draw consumed RNG state. + expect(first!.newRngState).toBe(nextRandom(42)[1]); + }); + + it("forwards an input token's uuid bigint unchanged", () => { + const result = computePossibleTransition( + makeUuidFrame(), + makeUuidSimulation((input) => ({ + Target: [{ id: input.Source![0]!.id, x: 3.0 }], + })), + "t1", + 42, + ); + + expect(firstAddedToken(result)).toEqual({ id: inputUuid, x: 3.0 }); + }); + + it("throws when a Distribution is produced for a uuid element", () => { + const distribution = { + __brand: "distribution", + type: "uniform", + min: 0, + max: 1, + } as const; + const simulation = makeUuidSimulation(() => ({ + Target: [{ id: distribution as never, x: 2.0 }], + })); + + expect(() => + computePossibleTransition(makeUuidFrame(), simulation, "t1", 42), + ).toThrow("produced a distribution for discrete element id"); + }); + }); }); diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/compute-possible-transition.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/compute-possible-transition.ts index e62298118dc..517072158c3 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/compute-possible-transition.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/compute-possible-transition.ts @@ -1,15 +1,10 @@ import { SDCPNItemError } from "../../errors"; -import { isDistribution } from "../authoring/user-code/distribution"; import { materializeEngineFrame } from "../frames/internal-frame"; +import { encodeKernelOutputToken } from "./encode-kernel-token"; import { enumerateWeightedMarkingIndicesGenerator } from "./enumerate-weighted-markings"; -import { sampleDistribution } from "./sample-distribution"; import { nextRandom } from "./seeded-rng"; -import { - createTokenRegionViews, - encodeTokenValuesToBytes, - readTokenRecord, -} from "./token-layout"; -import { encodeTokenAttributeValue } from "./token-values"; +import { createTokenRegionViews, readTokenRecord } from "./token-layout"; +import { describeTokenValuesForError } from "./token-values"; import type { ID } from "../../types/sdcpn"; import type { @@ -135,8 +130,7 @@ export function computePossibleTransition( const placeTokens = placeTokenIndices.map((tokenIndexInPlace) => readTokenRecord( tokenLayout, - tokenViews.f64, - tokenViews.u8, + tokenViews, placeByteOffset + tokenIndexInPlace * strideBytes, ), ); @@ -155,7 +149,7 @@ export function computePossibleTransition( throw new SDCPNItemError( `Error while executing lambda function for transition \`${transition.name}\`:\n\n${ (err as Error).message - }\n\nInput:\n${JSON.stringify(tokenCombinationValues, null, 2)}`, + }\n\nInput:\n${describeTokenValuesForError(tokenCombinationValues)}`, transition.id, ); } @@ -186,7 +180,7 @@ export function computePossibleTransition( throw new SDCPNItemError( `Error while executing transition kernel for transition \`${transition.name}\`:\n\n${ (err as Error).message - }\n\nInput:\n${JSON.stringify(tokenCombinationValues, null, 2)}`, + }\n\nInput:\n${describeTokenValuesForError(tokenCombinationValues)}`, transition.id, ); } @@ -225,36 +219,21 @@ export function computePossibleTransition( ); } - // Sample any Distribution values using the RNG (in element - // declaration order), encode each value, then pack the token into a + // Resolve Distribution samples and uuid values using the RNG (in + // element declaration order), then pack each token into a // stride-sized byte block. const tokenBlocks: Uint8Array[] = []; for (const token of outputTokens) { - const encodedByName: Record = {}; - for (const element of outputPlace.elements ?? []) { - let raw = token[element.name]; - if (isDistribution(raw)) { - if (element.type !== "real") { - throw new Error( - `Transition ${transition.id} produced a distribution for discrete element ${element.name}.`, - ); - } - const [sampled, nextRng] = sampleDistribution( - raw, - currentRngState, - ); - currentRngState = nextRng; - raw = sampled; - } - encodedByName[element.name] = encodeTokenAttributeValue( - element, - raw, - `Transition ${transition.id} output ${outputPlace.placeName}.${element.name}`, - ); - } - tokenBlocks.push( - encodeTokenValuesToBytes(outputPlace.tokenLayout, encodedByName), - ); + const { bytes, nextRngState } = encodeKernelOutputToken({ + token, + elements: outputPlace.elements ?? [], + tokenLayout: outputPlace.tokenLayout, + rngState: currentRngState, + transitionId: transition.id, + placeName: outputPlace.placeName, + }); + currentRngState = nextRngState; + tokenBlocks.push(bytes); } addMap[outputPlace.placeId] = tokenBlocks; diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/encode-kernel-token.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/encode-kernel-token.ts new file mode 100644 index 00000000000..0001be9605f --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/encode-kernel-token.ts @@ -0,0 +1,89 @@ +import { isDistribution } from "../authoring/user-code/distribution"; +import { isUuidSentinel } from "../authoring/user-code/uuid-runtime"; +import { sampleDistribution } from "./sample-distribution"; +import { encodeTokenValuesToBytes } from "./token-layout"; +import { encodeTokenAttributeValue } from "./token-values"; +import { generateUuidFromRng, toUuid } from "./uuid"; + +import type { Color } from "../../types/sdcpn"; +import type { TokenSlotLayout } from "./token-layout"; +import type { TransitionKernelOutput } from "./types"; + +type ColorElement = Color["elements"][number]; + +type KernelOutputToken = TransitionKernelOutput[string][number]; + +/** + * Resolves one transition kernel output token into a stride-sized byte block, + * consuming RNG state where needed. Values are resolved in element + * declaration order so RNG consumption stays deterministic per seed: + * + * - `Distribution` values are sampled (only valid for `real` elements — + * discrete elements, including `uuid`, throw). + * - `uuid` elements: omitted (`undefined`) or `Uuid.generate()` draws a fresh + * UUID from the seeded RNG; `Uuid.from(value)` and plain values are coerced + * via the total `toUuid` conversion. Forwarding an input token's uuid is + * plain bigint pass-through. + * - Everything else goes through the shared number-slot codec. + */ +export function encodeKernelOutputToken({ + token, + elements, + tokenLayout, + rngState, + transitionId, + placeName, +}: { + token: KernelOutputToken; + elements: readonly ColorElement[]; + tokenLayout: TokenSlotLayout; + rngState: number; + transitionId: string; + placeName: string; +}): { bytes: Uint8Array; nextRngState: number } { + let currentRngState = rngState; + const encodedByName: Record = {}; + + for (const element of elements) { + const raw = token[element.name]; + + if (isDistribution(raw)) { + if (element.type !== "real") { + throw new Error( + `Transition ${transitionId} produced a distribution for discrete element ${element.name}.`, + ); + } + const [sampled, nextRng] = sampleDistribution(raw, currentRngState); + currentRngState = nextRng; + encodedByName[element.name] = sampled; + continue; + } + + if (element.type === "uuid") { + if ( + raw === undefined || + (isUuidSentinel(raw) && raw.__petrinautUuid === "generate") + ) { + const [generated, nextRng] = generateUuidFromRng(currentRngState); + currentRngState = nextRng; + encodedByName[element.name] = generated; + } else if (isUuidSentinel(raw)) { + encodedByName[element.name] = toUuid(raw.value); + } else { + encodedByName[element.name] = toUuid(raw); + } + continue; + } + + encodedByName[element.name] = encodeTokenAttributeValue( + element, + raw, + `Transition ${transitionId} output ${placeName}.${element.name}`, + ); + } + + return { + bytes: encodeTokenValuesToBytes(tokenLayout, encodedByName), + nextRngState: currentRngState, + }; +} diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/token-layout.test-helpers.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/token-layout.test-helpers.ts index b8d4dc8ab48..8b459e3ac15 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/token-layout.test-helpers.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/token-layout.test-helpers.ts @@ -61,12 +61,12 @@ export function decodeTokenBlock( block: Uint8Array, ): TokenRecord { const layout = computeTokenSlotLayout(elements); - const { f64, u8 } = createTokenRegionViews( + const views = createTokenRegionViews( block.buffer, block.byteOffset, block.byteLength, ); - return readTokenRecord(layout, f64, u8, 0); + return readTokenRecord(layout, views, 0); } /** Decodes all of one place's tokens from a frame. */ @@ -89,8 +89,7 @@ export function decodePlaceTokens( tokens.push( readTokenRecord( tokenLayout, - view.tokenF64, - view.tokenBytes, + view.tokenViews, placeState.byteOffset + tokenIndex * placeState.strideBytes, ), ); diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/token-layout.test.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/token-layout.test.ts index 1419e7611ba..dc370b74219 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/token-layout.test.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/token-layout.test.ts @@ -7,6 +7,7 @@ import { encodeTokenValuesToBytes, readTokenRecord, } from "./token-layout"; +import { formatUuid, parseUuid } from "./uuid"; import type { Color } from "../../types/sdcpn"; @@ -76,6 +77,33 @@ describe("computeTokenSlotLayout", () => { expect(layout.paddingRanges).toEqual([]); expect(layout.realFieldF64Offsets).toEqual([0, 1]); }); + + it("lays out 16-byte u64x2 uuid fields among 8-aligned fields in declaration order", () => { + const layout = computeTokenSlotLayout([ + element("active", "boolean"), + element("id", "uuid"), + element("x", "real"), + element("owner", "uuid"), + ]); + + expect( + layout.fields.map((field) => [ + field.element.name, + field.kind, + field.byteOffset, + field.byteSize, + ]), + ).toEqual([ + ["id", "u64x2", 0, 16], + ["x", "f64", 16, 8], + ["owner", "u64x2", 24, 16], + ["active", "u8", 40, 1], + ]); + expect(layout.strideBytes).toBe(48); + expect(layout.paddingRanges).toEqual([{ start: 41, end: 48 }]); + // Real-field f64 offsets skip uuid lanes. + expect(layout.realFieldF64Offsets).toEqual([2]); + }); }); describe("encode/decode round trip", () => { @@ -97,12 +125,12 @@ describe("encode/decode round trip", () => { // Boolean is stored as one byte at its packed offset. expect(bytes[16]).toBe(1); - const { f64, u8 } = createTokenRegionViews( + const views = createTokenRegionViews( bytes.buffer, bytes.byteOffset, bytes.byteLength, ); - expect(readTokenRecord(layout, f64, u8, 0)).toEqual({ + expect(readTokenRecord(layout, views, 0)).toEqual({ amount: 1.25, count: 3, active: true, @@ -111,14 +139,14 @@ describe("encode/decode round trip", () => { it("stores false booleans as 0 and defaults missing values", () => { const bytes = encodeTokenToBytes(layout, { amount: -2 }, "Test"); - const { f64, u8 } = createTokenRegionViews( + const views = createTokenRegionViews( bytes.buffer, bytes.byteOffset, bytes.byteLength, ); expect(bytes[16]).toBe(0); - expect(readTokenRecord(layout, f64, u8, 0)).toEqual({ + expect(readTokenRecord(layout, views, 0)).toEqual({ amount: -2, count: 0, active: false, @@ -131,13 +159,13 @@ describe("encode/decode round trip", () => { count: 4, active: 1, }); - const { f64, u8 } = createTokenRegionViews( + const views = createTokenRegionViews( bytes.buffer, bytes.byteOffset, bytes.byteLength, ); - expect(readTokenRecord(layout, f64, u8, 0)).toEqual({ + expect(readTokenRecord(layout, views, 0)).toEqual({ amount: 0.5, count: 4, active: true, @@ -155,12 +183,12 @@ describe("encode/decode round trip", () => { layout.strideBytes, ); - const { f64, u8 } = createTokenRegionViews( + const views = createTokenRegionViews( region.buffer, region.byteOffset, region.byteLength, ); - expect(readTokenRecord(layout, f64, u8, layout.strideBytes)).toEqual({ + expect(readTokenRecord(layout, views, layout.strideBytes)).toEqual({ amount: 2, count: 5, active: true, @@ -168,6 +196,115 @@ describe("encode/decode round trip", () => { }); }); +describe("uuid u64x2 lanes", () => { + const elements = [ + element("id", "uuid"), + element("x", "real"), + element("active", "boolean"), + ]; + const layout = computeTokenSlotLayout(elements); + const canonical = "0f9a3b5c-7d1e-4a2b-8c3d-4e5f6a7b8c9d"; + const canonicalValue = parseUuid(canonical); + + it("stores the lo lane at the field offset and the hi lane at +8, little-endian", () => { + const bytes = encodeTokenToBytes( + layout, + { id: canonicalValue, x: 0, active: false }, + "Test", + ); + + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + /* eslint-disable no-bitwise -- lane inspection */ + const expectedLo = canonicalValue & 0xffffffffffffffffn; + const expectedHi = canonicalValue >> 64n; + /* eslint-enable no-bitwise */ + expect(view.getBigUint64(0, true)).toBe(expectedLo); + expect(view.getBigUint64(8, true)).toBe(expectedHi); + }); + + it("round-trips bigint and string uuid values", () => { + for (const idValue of [canonicalValue, canonical]) { + const bytes = encodeTokenToBytes( + layout, + { id: idValue, x: 1.5, active: true }, + "Test", + ); + const views = createTokenRegionViews( + bytes.buffer, + bytes.byteOffset, + bytes.byteLength, + ); + expect(readTokenRecord(layout, views, 0)).toEqual({ + id: canonicalValue, + x: 1.5, + active: true, + }); + } + }); + + it("round-trips a NaN-payload uuid without float canonicalization", () => { + // If either lane went through a Float64Array this payload would be + // silently rewritten to the canonical NaN bit pattern. + const nanPayload = parseUuid("ffffffff-ffff-4fff-bfff-ffffffffffff"); + const bytes = encodeTokenValuesToBytes(layout, { + id: nanPayload, + x: 2, + active: 1, + }); + const views = createTokenRegionViews( + bytes.buffer, + bytes.byteOffset, + bytes.byteLength, + ); + + const record = readTokenRecord(layout, views, 0); + expect(record.id).toBe(nanPayload); + expect(formatUuid(record.id as bigint)).toBe( + "ffffffff-ffff-4fff-bfff-ffffffffffff", + ); + }); + + it("defaults missing uuid values to the nil uuid in pre-encoded packing", () => { + const bytes = encodeTokenValuesToBytes(layout, { x: 1, active: 0 }); + const views = createTokenRegionViews( + bytes.buffer, + bytes.byteOffset, + bytes.byteLength, + ); + + expect(readTokenRecord(layout, views, 0)).toEqual({ + id: 0n, + x: 1, + active: false, + }); + }); + + it("reads uuid lanes at non-zero token byte offsets", () => { + const first = parseUuid("11111111-2222-4333-8444-555555555555"); + const second = parseUuid("aaaaaaaa-bbbb-4ccc-9ddd-eeeeeeeeeeee"); + const region = new Uint8Array(2 * layout.strideBytes); + region.set( + encodeTokenToBytes(layout, { id: first, x: 1, active: false }, "T"), + 0, + ); + region.set( + encodeTokenToBytes(layout, { id: second, x: 2, active: true }, "T"), + layout.strideBytes, + ); + + const views = createTokenRegionViews( + region.buffer, + region.byteOffset, + region.byteLength, + ); + expect(readTokenRecord(layout, views, layout.strideBytes)).toEqual({ + id: second, + x: 2, + active: true, + }); + }); +}); + describe("createTokenRegionViews", () => { it("rejects unaligned offsets and lengths", () => { const buffer = new ArrayBuffer(32); diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/token-layout.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/token-layout.ts index c957440559d..843c9a6c4c2 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/token-layout.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/token-layout.ts @@ -2,6 +2,7 @@ import { decodeTokenAttributeValue, encodeTokenAttributeValue, } from "./token-values"; +import { NIL_UUID, toUuid } from "./uuid"; import type { Color, ColorElementType, TokenRecord } from "../../types/sdcpn"; @@ -13,8 +14,10 @@ type ColorElement = Color["elements"][number]; * * - `f64`: 8 bytes, 8-byte aligned (`real` and `integer` elements). * - `u8`: 1 byte, 1-byte aligned (`boolean` elements). + * - `u64x2`: 16 bytes, 8-byte aligned (`uuid` elements — two little-endian + * 64-bit lanes: `lo` at the field's byteOffset, `hi` at +8). */ -export type PhysicalKind = "f64" | "u8"; +export type PhysicalKind = "f64" | "u8" | "u64x2"; export type TokenLayoutField = { element: ColorElement; @@ -33,8 +36,9 @@ export type TokenLayoutField = { * stride is rounded up to 8 bytes so consecutive tokens keep f64 fields * 8-aligned. Because the stride is a multiple of 8 and token regions start at * 8-aligned offsets, all f64 fields are addressable through a shared - * `Float64Array` view and all u8 fields through a `Uint8Array` view — no - * `DataView` is needed in hot paths. + * `Float64Array` view, all uuid lanes through a shared `BigUint64Array` + * view, and all u8 fields through a `Uint8Array` view — no `DataView` is + * needed in hot paths. */ export type TokenSlotLayout = { /** sizeof(token) — total bytes per token, including padding. 0 when empty. */ @@ -55,6 +59,10 @@ type PhysicalType = { kind: PhysicalKind; byteSize: number; align: number }; const PHYSICAL_TYPES: Record = { f64: { kind: "f64", byteSize: 8, align: 8 }, u8: { kind: "u8", byteSize: 1, align: 1 }, + // 16 bytes but only 8-byte alignment — deliberate: JS has no 128-bit load, + // uuid lanes are always read/written as two 64-bit BigUint64Array elements, + // so 8-byte alignment is all the views require. + u64x2: { kind: "u64x2", byteSize: 16, align: 8 }, }; function physicalTypeFor(elementType: ColorElementType): PhysicalType { @@ -64,6 +72,8 @@ function physicalTypeFor(elementType: ColorElementType): PhysicalType { case "integer": case "real": return PHYSICAL_TYPES.f64; + case "uuid": + return PHYSICAL_TYPES.u64x2; } } @@ -121,10 +131,12 @@ export function computeTokenSlotLayout( export type TokenRegionViews = { f64: Float64Array; u8: Uint8Array; + /** 64-bit lane view for `u64x2` (uuid) fields. */ + u64: BigUint64Array; }; /** - * Creates the shared f64/u8 views over one token byte region. + * Creates the shared f64/u64/u8 views over one token byte region. * * The region must start at an 8-aligned byte offset and span a multiple of 8 * bytes — both invariants hold for engine frame token regions because place @@ -149,6 +161,7 @@ export function createTokenRegionViews( return { f64: new Float64Array(buffer, byteOffset, byteLength / 8), u8: new Uint8Array(buffer, byteOffset, byteLength), + u64: new BigUint64Array(buffer, byteOffset, byteLength / 8), }; } @@ -170,17 +183,27 @@ function assertTokenAligned(tokenByteOffset: number): void { /** * Decodes one token starting at `tokenByteOffset` (relative to the start of - * the viewed region) into a logical record, using the shared value codec. + * the viewed region) into a logical record. Number-slot kinds (`f64`, `u8`) + * go through the shared value codec; `u64x2` (uuid) lanes are assembled here + * directly — `decodeTokenAttributeValue` never sees uuid elements. */ export function readTokenRecord( layout: TokenSlotLayout, - f64: Float64Array, - u8: Uint8Array, + views: TokenRegionViews, tokenByteOffset: number, ): TokenRecord { assertTokenAligned(tokenByteOffset); + const { f64, u8, u64 } = views; const token: TokenRecord = {}; for (const field of layout.fields) { + if (field.kind === "u64x2") { + const laneIndex = (tokenByteOffset + field.byteOffset) / 8; + const lo = u64[laneIndex] ?? 0n; + const hi = u64[laneIndex + 1] ?? 0n; + // eslint-disable-next-line no-bitwise -- lane assembly + token[field.element.name] = (hi << 64n) | lo; + continue; + } const encodedValue = field.kind === "f64" ? (f64[(tokenByteOffset + field.byteOffset) / 8] ?? 0) @@ -197,34 +220,45 @@ export function readTokenRecord( * Writes one already-encoded slot value (see `encodeTokenAttributeValue`) * into a token's field. `tokenByteOffset` is relative to the start of the * viewed region. + * + * `u64x2` (uuid) fields take the value as a pre-coerced bigint (anything + * else is coerced via `toUuid`) and write both little-endian 64-bit lanes. */ export function writeTokenValue( field: TokenLayoutField, - f64: Float64Array, - u8: Uint8Array, + views: TokenRegionViews, tokenByteOffset: number, - encodedSlotValue: number, + encodedSlotValue: number | bigint, ): void { assertTokenAligned(tokenByteOffset); - /* eslint-disable no-param-reassign -- writing through shared token region views is the point of this helper */ - if (field.kind === "f64") { - f64[(tokenByteOffset + field.byteOffset) / 8] = encodedSlotValue; + const { f64, u8, u64 } = views; + /* eslint-disable no-bitwise -- lane splitting is the point of this helper */ + if (field.kind === "u64x2") { + // toUuid unconditionally: it passes in-range bigints through, and an + // out-of-range bigint would otherwise wrap silently in the lane writes. + const uuidValue = toUuid(encodedSlotValue); + const laneIndex = (tokenByteOffset + field.byteOffset) / 8; + u64[laneIndex] = uuidValue & 0xffffffffffffffffn; + u64[laneIndex + 1] = uuidValue >> 64n; + } else if (field.kind === "f64") { + f64[(tokenByteOffset + field.byteOffset) / 8] = Number(encodedSlotValue); } else { - u8[tokenByteOffset + field.byteOffset] = encodedSlotValue; + u8[tokenByteOffset + field.byteOffset] = Number(encodedSlotValue); } - /* eslint-enable no-param-reassign */ + /* eslint-enable no-bitwise */ } /** * Encodes pre-sampled, already-encoded slot values (keyed by element name) * into a fresh stride-sized byte block. Used by transition kernels, which - * sample distribution values in element declaration order before packing. + * resolve distribution/uuid values in element declaration order before + * packing. uuid values must already be bigints. */ export function encodeTokenValuesToBytes( layout: TokenSlotLayout, - encodedValuesByName: Readonly>, + encodedValuesByName: Readonly>, ): Uint8Array { - const { f64, u8 } = createTokenRegionViews( + const views = createTokenRegionViews( new ArrayBuffer(layout.strideBytes), 0, layout.strideBytes, @@ -232,13 +266,13 @@ export function encodeTokenValuesToBytes( for (const field of layout.fields) { writeTokenValue( field, - f64, - u8, + views, 0, - encodedValuesByName[field.element.name] ?? 0, + encodedValuesByName[field.element.name] ?? + (field.kind === "u64x2" ? NIL_UUID : 0), ); } - return u8; + return views.u8; } /** @@ -249,7 +283,7 @@ export function encodeTokenToBytes( record: Record, context: string, ): Uint8Array { - const { f64, u8 } = createTokenRegionViews( + const views = createTokenRegionViews( new ArrayBuffer(layout.strideBytes), 0, layout.strideBytes, @@ -260,7 +294,7 @@ export function encodeTokenToBytes( record[field.element.name], `${context}.${field.element.name}`, ); - writeTokenValue(field, f64, u8, 0, encodedValue); + writeTokenValue(field, views, 0, encodedValue); } - return u8; + return views.u8; } diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/token-values.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/token-values.ts index 5f1191ac6cb..619cc299d49 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/token-values.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/token-values.ts @@ -1,3 +1,5 @@ +import { formatUuid, NIL_UUID, toUuid } from "./uuid"; + import type { Color, ColorElementType, @@ -7,6 +9,21 @@ import type { type ColorElement = Color["elements"][number]; +/** + * JSON-serializes token values for diagnostic messages. `uuid` attributes + * are bigints, which plain `JSON.stringify` rejects with a TypeError — an + * error path that throws while formatting would mask the original kernel or + * lambda error, so bigints render as canonical UUID strings instead. + */ +export function describeTokenValuesForError(values: unknown): string { + return JSON.stringify( + values, + (_key, value: unknown) => + typeof value === "bigint" ? formatUuid(value) : value, + 2, + ); +} + export function defaultTokenAttributeValue( type: ColorElementType, ): TokenAttributeValue { @@ -16,6 +33,8 @@ export function defaultTokenAttributeValue( case "integer": case "real": return 0; + case "uuid": + return NIL_UUID; } } @@ -59,6 +78,10 @@ export function coerceTokenAttributeValue( return Math.round(coerceNumber(rawValue, context)); case "boolean": return coerceBoolean(rawValue, context); + case "uuid": + // Total conversion: bigints and UUID strings pass through/parse, and + // any other value maps deterministically to a UUIDv5 — never throws. + return toUuid(rawValue); } } @@ -78,6 +101,15 @@ export function coerceTokenRecord( return token; } +/** + * Decodes one number-slot (`f64` / `u8`) buffer value back into a logical + * token attribute value. + * + * `uuid` elements never reach this codec: their two 64-bit lanes are + * assembled/split directly in `token-layout.ts` (`readTokenRecord` / + * `writeTokenValue`), so the `uuid` arm here only keeps the switch + * exhaustive over `ColorElementType`. + */ export function decodeTokenAttributeValue( element: ColorElement, encodedValue: number, @@ -89,22 +121,32 @@ export function decodeTokenAttributeValue( return Math.round(encodedValue); case "boolean": return encodedValue !== 0; + case "uuid": + throw new Error( + `decodeTokenAttributeValue received uuid element "${element.name}"; uuid lanes are decoded in token-layout.ts`, + ); } } /** - * Encodes a token attribute value into the numeric frame buffer - * representation (booleans are stored as 0/1, integers are rounded). + * Encodes a token attribute value into its frame buffer slot representation + * (booleans are stored as 0/1, integers are rounded, uuids stay bigints for + * the two-lane writer in `token-layout.ts`). */ export function encodeTokenAttributeValue( element: ColorElement, value: unknown, context: string, -): number { +): number | bigint { const coerced = coerceTokenAttributeValue(element, value, context); return typeof coerced === "boolean" ? (coerced ? 1 : 0) : coerced; } +/** + * Decodes a token from one number per element (legacy numeric layout used by + * external callers). Colours with `uuid` elements are not representable in + * this form — use `readTokenRecord` from `token-layout.ts` instead. + */ export function decodeTokenRecord( elements: readonly ColorElement[], encodedValues: ArrayLike, diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/types.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/types.ts index 05007a46ace..51ad19b5a17 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/types.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/types.ts @@ -17,6 +17,7 @@ import type { } from "../../types/sdcpn"; import type { InitialMarking } from "../api"; import type { RuntimeDistribution } from "../authoring/user-code/distribution"; +import type { UuidSentinel } from "../authoring/user-code/uuid-runtime"; import type { EngineFrame, EngineFrameLayout } from "../frames/internal-frame"; import type { TokenSlotLayout } from "./token-layout"; @@ -44,9 +45,17 @@ export type DifferentialEquationFn = ( ) => Float64Array; export type TransitionTokenValues = Record; +/** + * Kernel output tokens keyed by output place name. `uuid` attributes may be + * omitted (`undefined` — the engine auto-generates a UUID from the seeded + * RNG) or produced via the `Uuid.generate()` / `Uuid.from(value)` sentinels. + */ export type TransitionKernelOutput = Record< string, - Record[] + Record< + string, + TokenAttributeValue | RuntimeDistribution | UuidSentinel | undefined + >[] >; /** diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/uuid.test.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/uuid.test.ts new file mode 100644 index 00000000000..6f879e42e07 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/uuid.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from "vitest"; + +import { + formatUuid, + generateUuidFromRng, + isUuidString, + NIL_UUID, + parseUuid, + toUuid, +} from "./uuid"; + +const CANONICAL = "0f9a3b5c-7d1e-4a2b-8c3d-4e5f6a7b8c9d"; + +describe("parseUuid / formatUuid", () => { + it("round-trips a canonical lowercase uuid string", () => { + const value = parseUuid(CANONICAL); + expect(formatUuid(value)).toBe(CANONICAL); + }); + + it("parses uppercase input and formats back to lowercase", () => { + const value = parseUuid(CANONICAL.toUpperCase()); + expect(value).toBe(parseUuid(CANONICAL)); + expect(formatUuid(value)).toBe(CANONICAL); + }); + + it("formats the nil uuid with full zero padding", () => { + expect(formatUuid(NIL_UUID)).toBe("00000000-0000-0000-0000-000000000000"); + }); + + it("round-trips the maximum 128-bit value", () => { + const max = 2n ** 128n - 1n; + expect(parseUuid(formatUuid(max))).toBe(max); + }); + + it("throws on malformed strings", () => { + expect(() => parseUuid("not-a-uuid")).toThrow("Invalid UUID string"); + expect(() => parseUuid(CANONICAL.replaceAll("-", ""))).toThrow( + "Invalid UUID string", + ); + }); +}); + +describe("isUuidString", () => { + it("accepts hyphenated uuid strings of any case", () => { + expect(isUuidString(CANONICAL)).toBe(true); + expect(isUuidString(CANONICAL.toUpperCase())).toBe(true); + }); + + it("rejects non-uuid values", () => { + expect(isUuidString("order-1")).toBe(false); + expect(isUuidString(42)).toBe(false); + expect(isUuidString(undefined)).toBe(false); + }); +}); + +describe("toUuid", () => { + it("passes in-range bigints through unchanged", () => { + const value = parseUuid(CANONICAL); + expect(toUuid(value)).toBe(value); + expect(toUuid(0n)).toBe(0n); + expect(toUuid(2n ** 128n - 1n)).toBe(2n ** 128n - 1n); + }); + + it("parses valid uuid strings (any case)", () => { + expect(toUuid(CANONICAL)).toBe(parseUuid(CANONICAL)); + expect(toUuid(CANONICAL.toUpperCase())).toBe(parseUuid(CANONICAL)); + }); + + it("maps undefined and null to the nil uuid", () => { + expect(toUuid(undefined)).toBe(NIL_UUID); + expect(toUuid(null)).toBe(NIL_UUID); + }); + + it("converts arbitrary strings to a stable UUIDv5", () => { + const first = toUuid("order-1"); + expect(first).toBe(toUuid("order-1")); + expect(first).not.toBe(toUuid("order-2")); + expect(first).not.toBe(NIL_UUID); + // v5 version nibble and RFC 4122 variant bits. + // eslint-disable-next-line no-bitwise -- bit inspection + expect((first >> 76n) & 0xfn).toBe(5n); + // eslint-disable-next-line no-bitwise -- bit inspection + expect((first >> 62n) & 0x3n).toBe(2n); + }); + + it("converts numbers via String(value), matching the equivalent string", () => { + expect(toUuid(42)).toBe(toUuid("42")); + expect(toUuid(42)).toBe(toUuid(42)); + }); + + it("converts out-of-range and negative bigints via String(value)", () => { + expect(toUuid(2n ** 128n)).toBe(toUuid(String(2n ** 128n))); + expect(toUuid(-1n)).toBe(toUuid("-1")); + }); + + it("never throws and always yields an in-range value", () => { + for (const input of [true, {}, [], Number.NaN, "💥", ""]) { + const value = toUuid(input); + expect(value).toBeGreaterThanOrEqual(0n); + expect(value).toBeLessThan(2n ** 128n); + } + }); +}); + +describe("generateUuidFromRng", () => { + it("is deterministic for the same rng state and advances the state", () => { + const [first, stateAfterFirst] = generateUuidFromRng(42); + const [again] = generateUuidFromRng(42); + expect(again).toBe(first); + expect(stateAfterFirst).not.toBe(42); + + const [second] = generateUuidFromRng(stateAfterFirst); + expect(second).not.toBe(first); + }); + + it("forces the v4 version nibble and RFC 4122 variant bits", () => { + let state = 7; + for (let draw = 0; draw < 16; draw++) { + const [uuid, nextState] = generateUuidFromRng(state); + state = nextState; + // eslint-disable-next-line no-bitwise -- bit inspection + expect((uuid >> 76n) & 0xfn).toBe(4n); + // eslint-disable-next-line no-bitwise -- bit inspection + expect((uuid >> 62n) & 0x3n).toBe(2n); + expect(uuid).toBeGreaterThanOrEqual(0n); + expect(uuid).toBeLessThan(2n ** 128n); + // The canonical string form matches the v4 shape. + expect(formatUuid(uuid)).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + } + }); +}); diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/uuid.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/uuid.ts new file mode 100644 index 00000000000..0a2b802cacb --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/uuid.ts @@ -0,0 +1,117 @@ +import { v5 as uuidv5 } from "uuid"; + +import { nextRandom } from "./seeded-rng"; + +/** + * Runtime representation of `uuid` token elements: one `bigint` holding the + * full 128-bit RFC 4122 value (0 ≤ v < 2^128). At rest (documents, scenario + * JSON) uuid values are canonical lowercase 36-character strings; in frame + * buffers they are stored as two little-endian 64-bit lanes (see + * `token-layout.ts`). + */ + +/** The nil UUID (`00000000-0000-0000-0000-000000000000`). */ +export const NIL_UUID = 0n; + +/** + * Fixed UUIDv5 namespace under which non-UUID inputs (numbers, arbitrary + * strings, …) are deterministically converted to UUIDs via `toUuid`. + * + * This value MUST NEVER change: converted UUIDs are persisted in documents + * and simulation results, and changing the namespace would silently remap + * every previously derived identifier. + */ +export const PETRINAUT_UUID_NAMESPACE = "6d5b6d5e-3c1b-4f7e-9c39-4b6f1e2a8d90"; + +const UUID_STRING_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +const UUID_MAX = 2n ** 128n; // 2^128 + +/** Whether `value` is a 36-character hyphenated UUID string (any case). */ +export function isUuidString(value: unknown): value is string { + return typeof value === "string" && UUID_STRING_RE.test(value); +} + +/** + * Parses a canonical 36-character UUID string (any case) into its 128-bit + * bigint value. Throws on malformed input — use `toUuid` for a total + * conversion. + */ +export function parseUuid(value: string): bigint { + if (!UUID_STRING_RE.test(value)) { + throw new Error(`Invalid UUID string: ${value}`); + } + return BigInt(`0x${value.replaceAll("-", "")}`); +} + +/** + * Total coercion of any value to a 128-bit uuid bigint. Never throws: + * + * - in-range `bigint` (0 ≤ v < 2^128) → itself; + * - valid UUID string (any case) → parsed value; + * - `undefined` / `null` → the nil UUID (`0n`); + * - anything else (numbers, arbitrary strings, out-of-range bigints, …) → + * the UUIDv5 of `String(value)` under {@link PETRINAUT_UUID_NAMESPACE}, + * so the same input always maps to the same UUID. + */ +export function toUuid(value: unknown): bigint { + if (typeof value === "bigint" && value >= 0n && value < UUID_MAX) { + return value; + } + if (isUuidString(value)) { + return parseUuid(value); + } + if (value === undefined || value === null) { + return NIL_UUID; + } + // eslint-disable-next-line typescript/no-base-to-string -- intentional: non-primitives map through their default stringification, keeping the conversion total and deterministic + return parseUuid(uuidv5(String(value), PETRINAUT_UUID_NAMESPACE)); +} + +/** + * Formats a 128-bit bigint as the canonical lowercase 36-character UUID + * string (padded 32 hex digits + hyphens). Total: an out-of-range bigint + * (negative or ≥ 2^128) is first canonicalized through {@link toUuid} — the + * same deterministic mapping every other non-uuid input takes — rather than + * rendering a malformed string. + */ +export function formatUuid(value: bigint): string { + const canonical = value >= 0n && value < UUID_MAX ? value : toUuid(value); + const hex = canonical.toString(16).padStart(32, "0"); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +/** + * Generates a v4-shaped UUID from the seeded simulation RNG (never + * `crypto.randomUUID` — simulation runs must stay deterministic per seed). + * + * Draws eight values from `nextRandom`, takes the top 16 bits of each, + * assembles them MSB-first into a 128-bit bigint, then forces the version (4) + * and RFC 4122 variant bits. + */ +export function generateUuidFromRng( + rngState: number, +): [uuid: bigint, nextRngState: number] { + let state = rngState; + let b = 0n; + // The seeded LCG's low bits are unreliable: `LCG_A * state` exceeds 2^53 + // for large states, so each draw's low ~8 bits are float-precision + // artifacts (visibly zero in generated IDs). Only the top 16 bits of each + // draw are well-mixed and precision-safe, so a UUID takes eight 16-bit + // draws instead of four 32-bit ones. + for (let draw = 0; draw < 8; draw++) { + const [value, nextState] = nextRandom(state); + state = nextState; + const word = Math.floor(value * 0x1_0000); + // eslint-disable-next-line no-bitwise -- assembling the 128-bit value + b = (b << 16n) | BigInt(word); + } + /* eslint-disable no-bitwise -- version/variant bit surgery */ + // Version nibble (bits 76–79) := 4. + b = (b & ~(0xfn << 76n)) | (0x4n << 76n); + // Variant bits (bits 62–63) := 0b10 (RFC 4122). + b = (b & ~(0x3n << 62n)) | (0x2n << 62n); + /* eslint-enable no-bitwise */ + return [b, state]; +} diff --git a/libs/@hashintel/petrinaut-core/src/simulation/frames/frame-reader.ts b/libs/@hashintel/petrinaut-core/src/simulation/frames/frame-reader.ts index 657a50663dc..557403b374e 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/frames/frame-reader.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/frames/frame-reader.ts @@ -43,8 +43,7 @@ function createSimulationFrameReader( tokens.push( readTokenRecord( tokenLayout, - frameView.tokenF64, - frameView.tokenBytes, + frameView.tokenViews, byteOffset + tokenIndex * strideBytes, ), ); diff --git a/libs/@hashintel/petrinaut-core/src/simulation/frames/internal-frame.ts b/libs/@hashintel/petrinaut-core/src/simulation/frames/internal-frame.ts index 85f3d9603bd..88d4288a041 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/frames/internal-frame.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/frames/internal-frame.ts @@ -1,5 +1,7 @@ import { computeTokenSlotLayout, + createTokenRegionViews, + type TokenRegionViews, type TokenSlotLayout, } from "../engine/token-layout"; @@ -62,8 +64,8 @@ type EngineFrameHeader = { export type EngineFrameView = { /** The whole token byte region. */ tokenBytes: Uint8Array; - /** f64 view over the whole token region (region offset/length are 8-aligned). */ - tokenF64: Float64Array; + /** Shared f64/u64/u8 views over the whole token region (region offset/length are 8-aligned). */ + tokenViews: TokenRegionViews; getPlaceState(placeId: ID): EngineFramePlaceState | null; getPlaceEntries(): [ID, EngineFramePlaceState][]; getTransitionState(transitionId: ID): SimulationTransitionState | null; @@ -417,16 +419,12 @@ export function readEngineFrame( header.transitionFiredFlagsOffset, header.transitionCount, ); - const tokenBytes = new Uint8Array( + const tokenViews = createTokenRegionViews( frame, header.tokenValuesOffset, header.tokenByteLength, ); - const tokenF64 = new Float64Array( - frame, - header.tokenValuesOffset, - header.tokenByteLength / 8, - ); + const tokenBytes = tokenViews.u8; const getPlaceState = (placeId: ID): EngineFramePlaceState | null => { const index = layout.placeIndexById.get(placeId); @@ -467,7 +465,7 @@ export function readEngineFrame( return { tokenBytes, - tokenF64, + tokenViews, getPlaceState, getPlaceEntries, getTransitionState, diff --git a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/frame-buffer.ts b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/frame-buffer.ts index bc9a7c47011..a52cdc767db 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/frame-buffer.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/frame-buffer.ts @@ -1,4 +1,9 @@ /* eslint-disable no-param-reassign -- Monte Carlo frame buffers are mutable by design. */ +import { + createTokenRegionViews, + type TokenRegionViews, +} from "../engine/token-layout"; + import type { EngineFrameLayout, EngineFrameView, @@ -19,8 +24,8 @@ export type MonteCarloFrameBuffer = { transitionFiredFlags: Uint8Array; /** u8 view over the whole token region capacity. */ tokenBytes: Uint8Array; - /** f64 view over the whole token region capacity. */ - tokenF64: Float64Array; + /** Shared f64/u64/u8 views over the whole token region capacity. */ + tokenViews: TokenRegionViews; }; const alignTo = (value: number, alignment: number): number => @@ -90,10 +95,10 @@ function createViews( transitionCount, ), tokenBytes: new Uint8Array(buffer, tokenValuesOffset, tokenByteCapacity), - tokenF64: new Float64Array( + tokenViews: createTokenRegionViews( buffer, tokenValuesOffset, - tokenByteCapacity / 8, + tokenByteCapacity, ), }; } diff --git a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/frame-reader.ts b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/frame-reader.ts index 8ec0da0f980..35bb6291984 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/frame-reader.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/frame-reader.ts @@ -39,8 +39,7 @@ export function createMonteCarloFrameReader( tokens.push( readTokenRecord( tokenLayout, - currentFrame.tokenF64, - currentFrame.tokenBytes, + currentFrame.tokenViews, byteOffset + tokenIndex * tokenLayout.strideBytes, ), ); diff --git a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/transition-effect.ts b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/transition-effect.ts index 15effeef66d..34b7263e2ec 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/transition-effect.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/transition-effect.ts @@ -1,13 +1,9 @@ import { SDCPNItemError } from "../../errors"; -import { isDistribution } from "../authoring/user-code/distribution"; +import { encodeKernelOutputToken } from "../engine/encode-kernel-token"; import { enumerateWeightedMarkingIndicesGenerator } from "../engine/enumerate-weighted-markings"; -import { sampleDistribution } from "../engine/sample-distribution"; import { nextRandom } from "../engine/seeded-rng"; -import { - encodeTokenValuesToBytes, - readTokenRecord, -} from "../engine/token-layout"; -import { encodeTokenAttributeValue } from "../engine/token-values"; +import { readTokenRecord } from "../engine/token-layout"; +import { describeTokenValuesForError } from "../engine/token-values"; import { getPlaceIndex, getTransitionIndex } from "./layout"; import type { @@ -92,8 +88,7 @@ export function computeTransitionEffect( tokenValues[inputPlace.placeName] = tokenIndices.map((tokenIndex) => readTokenRecord( tokenLayout, - frame.tokenF64, - frame.tokenBytes, + frame.tokenViews, byteOffset + tokenIndex * strideBytes, ), ); @@ -106,7 +101,7 @@ export function computeTransitionEffect( throw new SDCPNItemError( `Error while executing lambda function for transition \`${transition.name}\`:\n\n${ (error as Error).message - }\n\nInput:\n${JSON.stringify(tokenValues, null, 2)}`, + }\n\nInput:\n${describeTokenValuesForError(tokenValues)}`, transition.id, ); } @@ -129,7 +124,7 @@ export function computeTransitionEffect( throw new SDCPNItemError( `Error while executing transition kernel for transition \`${transition.name}\`:\n\n${ (error as Error).message - }\n\nInput:\n${JSON.stringify(tokenValues, null, 2)}`, + }\n\nInput:\n${describeTokenValuesForError(tokenValues)}`, transition.id, ); } @@ -158,33 +153,15 @@ export function computeTransitionEffect( const tokenBlocks: Uint8Array[] = []; for (const token of outputTokens) { - const encodedByName: Record = {}; - for (const element of outputPlace.elements ?? []) { - let rawValue = token[element.name]; - if (isDistribution(rawValue)) { - if (element.type !== "real") { - throw new Error( - `Transition ${transition.id} produced a distribution for discrete element ${element.name}.`, - ); - } - const [sampled, nextRngState] = sampleDistribution( - rawValue, - currentRngState, - ); - currentRngState = nextRngState; - rawValue = sampled; - } - encodedByName[element.name] = encodeTokenAttributeValue( - element, - rawValue, - `Transition ${transition.id} output ${outputPlace.placeName}.${element.name}`, - ); - } - - const block = encodeTokenValuesToBytes( - outputPlace.tokenLayout, - encodedByName, - ); + const { bytes: block, nextRngState } = encodeKernelOutputToken({ + token, + elements: outputPlace.elements ?? [], + tokenLayout: outputPlace.tokenLayout, + rngState: currentRngState, + transitionId: transition.id, + placeName: outputPlace.placeName, + }); + currentRngState = nextRngState; if (block.byteLength !== strideBytes) { throw new Error( `Transition ${transition.id} produced a ${block.byteLength}-byte token for place ${outputPlace.placeId}, expected ${strideBytes}`, diff --git a/libs/@hashintel/petrinaut-core/src/types/sdcpn.ts b/libs/@hashintel/petrinaut-core/src/types/sdcpn.ts index 688566bdad3..92ed51da3ec 100644 --- a/libs/@hashintel/petrinaut-core/src/types/sdcpn.ts +++ b/libs/@hashintel/petrinaut-core/src/types/sdcpn.ts @@ -1,8 +1,13 @@ export type ID = string; -export type ColorElementType = "real" | "integer" | "boolean"; +export type ColorElementType = "real" | "integer" | "boolean" | "uuid"; -export type TokenAttributeValue = number | boolean; +/** + * Runtime value of one token attribute. `uuid` elements are represented as a + * single `bigint` (0 ≤ v < 2^128) at runtime; at rest (documents, scenario + * JSON) they are stored as canonical lowercase 36-character strings. + */ +export type TokenAttributeValue = number | boolean | bigint; export type TokenRecord = Record; @@ -133,10 +138,12 @@ export type Scenario = { /** * Per-place initial state. Values are either: * - `string`: expression for uncolored places (evaluates to token count) - * - `TokenAttributeValue[][]`: token data for colored places (rows × elements) + * - token data rows for colored places (rows × elements). Row values + * are numbers/booleans; `uuid` columns are stored as canonical + * lowercase UUID strings so documents stay JSON-serializable. */ type: "per_place"; - content: Record; + content: Record; } | { /** Single code block that returns the full initial state object. */ diff --git a/libs/@hashintel/petrinaut/docs/petri-net-extensions.md b/libs/@hashintel/petrinaut/docs/petri-net-extensions.md index d2829b9e250..1894016caeb 100644 --- a/libs/@hashintel/petrinaut/docs/petri-net-extensions.md +++ b/libs/@hashintel/petrinaut/docs/petri-net-extensions.md @@ -10,13 +10,14 @@ By default, places hold **untyped tokens** -- they only track a count. Tokens ar To give tokens structure, assign a **type** to a place. Each token then carries named dimensions (e.g. `x`, `y`, `velocity`), enabling dynamics, visualization, and data-dependent transition logic. -Each dimension has one of three value types, chosen with the dropdown next to the dimension's name: +Each dimension has one of four value types, chosen with the dropdown next to the dimension's name: - **Real** -- a continuous number. This is the only kind of dimension that differential equations (dynamics) can update. - **Integer** -- a whole number. Values are rounded when stored, and are exact up to ±2^53 (about 9 quadrillion); beyond that they lose precision. - **Boolean** -- `true` or `false`. +- **UUID** -- a 128-bit identifier, useful for tracking individual entities across places. In code a UUID dimension is a `bigint`. In transition kernel outputs a UUID dimension is optional: leave it out and the engine generates a fresh UUID automatically (deterministically per simulation seed), or set it explicitly with `Uuid.generate()`, `Uuid.from(value)`, a UUID string, or an input token's UUID (to carry an identity through the transition). Any non-UUID value you supply (a number, or free text like `order-1`) is converted deterministically to a UUID, so the same input always yields the same identifier. -Integer and Boolean dimensions are **discrete**: their values only change when a transition fires (via a transition kernel), never through dynamics. +Integer, Boolean, and UUID dimensions are **discrete**: their values only change when a transition fires (via a transition kernel), never through dynamics. **To create a type:** @@ -117,7 +118,7 @@ export default TransitionKernel((tokensByPlace, parameters) => { }); ``` -`tokensByPlace` is keyed by **place name**. Each value is a tuple of token objects -- one entry per token consumed from that arc, sized to the arc weight. The return value is keyed by **output place name**, each containing an array of token objects to produce sized to the output arc weight. Output values must match each dimension's type: numbers for Real and Integer dimensions (Integer values are rounded), `true`/`false` for Boolean dimensions. Transition kernels are the only place discrete (Integer/Boolean) dimensions get new values. +`tokensByPlace` is keyed by **place name**. Each value is a tuple of token objects -- one entry per token consumed from that arc, sized to the arc weight. The return value is keyed by **output place name**, each containing an array of token objects to produce sized to the output arc weight. Output values must match each dimension's type: numbers for Real and Integer dimensions (Integer values are rounded), `true`/`false` for Boolean dimensions, and for UUID dimensions either nothing at all (omit the field to auto-generate a fresh identifier from the simulation seed), `Uuid.generate()`, `Uuid.from(value)`, a UUID string, or a forwarded input token's UUID. Transition kernels are the only place discrete (Integer/Boolean/UUID) dimensions get new values. Two important asymmetries: @@ -130,7 +131,7 @@ Use the menu in the code editor header to **Load default template** for a starti ### Distributions -Kernel output values are plain numbers or booleans. When stochasticity is enabled for the document, values for **Real** dimensions can also be `Distribution` objects for stochastic output (discrete dimensions -- Integer and Boolean -- always take plain values): +Kernel output values are plain numbers or booleans. When stochasticity is enabled for the document, values for **Real** dimensions can also be `Distribution` objects for stochastic output (discrete dimensions -- Integer, Boolean, and UUID -- always take plain values): - `Distribution.Gaussian(mean, standardDeviation)` - `Distribution.Uniform(min, max)` diff --git a/libs/@hashintel/petrinaut/docs/scenarios.md b/libs/@hashintel/petrinaut/docs/scenarios.md index bf3ab6d546c..227528ae8f1 100644 --- a/libs/@hashintel/petrinaut/docs/scenarios.md +++ b/libs/@hashintel/petrinaut/docs/scenarios.md @@ -51,7 +51,7 @@ You see a row per place. Which places appear depends on the **Show all places** What you enter per place depends on whether it has a type: - **Uncoloured places**: a single-line TypeScript expression that evaluates to the token count. The result is rounded down and clamped to `>= 0`. You can reference `parameters.` and `scenario.`. Empty/missing means zero tokens. -- **Coloured places**: a small spreadsheet, one row per token, one column per element of the place's type. Cell values are literal numbers; expressions are not supported in the spreadsheet. +- **Coloured places**: a small spreadsheet, one row per token, one column per element of the place's type. Cell values are literal numbers; expressions are not supported in the spreadsheet. UUID columns accept any text: a UUID string is used as-is, and any other text (e.g. `order-1`) is converted deterministically to a UUID, so the same text always produces the same identifier. ### Code mode (Define as code) diff --git a/libs/@hashintel/petrinaut/docs/simulation.md b/libs/@hashintel/petrinaut/docs/simulation.md index c85e9b24fa1..1927de9535d 100644 --- a/libs/@hashintel/petrinaut/docs/simulation.md +++ b/libs/@hashintel/petrinaut/docs/simulation.md @@ -9,7 +9,7 @@ Before running a simulation, set the **initial marking** -- the starting tokens Select a place and open the **State** sub-view in its properties: - **Untyped places** -- set a token count (integer). -- **Typed places** -- define individual tokens with values for each dimension in a spreadsheet editor. Add a row to create a new token. +- **Typed places** -- define individual tokens with values for each dimension in a spreadsheet editor. Add a row to create a new token. UUID dimensions show a shortened identifier (hover for the full value); when editing, enter a UUID string or any free text -- non-UUID text is converted deterministically to a UUID. initial-states diff --git a/libs/@hashintel/petrinaut/src/ui/components/spreadsheet.stories.tsx b/libs/@hashintel/petrinaut/src/ui/components/spreadsheet.stories.tsx index 0570b819da6..473c4cfc623 100644 --- a/libs/@hashintel/petrinaut/src/ui/components/spreadsheet.stories.tsx +++ b/libs/@hashintel/petrinaut/src/ui/components/spreadsheet.stories.tsx @@ -28,6 +28,19 @@ const SAMPLE_DATA: SpreadsheetCellValue[][] = [ [7, 8, 9], ]; +const TYPED_COLUMNS: SpreadsheetColumn[] = [ + { id: "amount", name: "amount", type: "real" }, + { id: "count", name: "count", type: "integer" }, + { id: "active", name: "active", type: "boolean" }, + { id: "id", name: "id", type: "uuid" }, +]; + +const TYPED_DATA: SpreadsheetCellValue[][] = [ + [1.25, 3, true, 0x45f588b605384fc992071ddfd7f65b64n], + [0.5, 1, false, 0xc0ffee0012344abc8defdeadbeef0042n], + [9.75, 7, true, 0n], +]; + const Container = ({ children }: { children: React.ReactNode }) => (
{children}
); @@ -71,6 +84,18 @@ export const ReadOnly: Story = { ), }; +export const TypedColumns: Story = { + name: "Typed columns (real / integer / boolean / uuid)", + render: () => ( +
+ +
+ ), +}; + export const Empty: Story = { name: "Empty (phantom row only)", render: () => , diff --git a/libs/@hashintel/petrinaut/src/ui/components/spreadsheet.tsx b/libs/@hashintel/petrinaut/src/ui/components/spreadsheet.tsx index c720e0c328f..22649e621a9 100644 --- a/libs/@hashintel/petrinaut/src/ui/components/spreadsheet.tsx +++ b/libs/@hashintel/petrinaut/src/ui/components/spreadsheet.tsx @@ -1,15 +1,19 @@ import { useRef, useState } from "react"; import { css, cva } from "@hashintel/ds-helpers/css"; -import { defaultTokenAttributeValue } from "@hashintel/petrinaut-core"; +import { + defaultTokenAttributeValue, + formatUuid, + toUuid, +} from "@hashintel/petrinaut-core"; export interface SpreadsheetColumn { id: string; name: string; - type?: "real" | "integer" | "boolean"; + type?: "real" | "integer" | "boolean" | "uuid"; } -export type SpreadsheetCellValue = number | boolean; +export type SpreadsheetCellValue = number | boolean | bigint; export interface SpreadsheetProps { columns: SpreadsheetColumn[]; @@ -122,6 +126,7 @@ const rowNumberCellStyle = cva({ const cellContainerStyle = cva({ base: { + position: "relative", borderBottom: "[1px solid {colors.neutral.a05}]", padding: "0", height: "[28px]", @@ -191,6 +196,41 @@ const booleanCellStyle = css({ margin: "0", }); +/** + * Selected uuid cells expand to the full canonical string, spilling over the + * neighbouring cells (spreadsheet-style overflow). Pointer events pass + * through so double-click still opens the editor underneath. Cells in the + * right half of the table spill leftwards so the overlay is not clipped by + * the scroll container's edge. + */ +const uuidExpandedOverlayStyle = cva({ + base: { + position: "absolute", + top: "[0]", + height: "[28px]", + display: "flex", + alignItems: "center", + padding: "[4px 8px]", + fontFamily: "mono", + fontSize: "xs", + whiteSpace: "nowrap", + width: "[max-content]", + minWidth: "[100%]", + // Opaque: the overlay covers neighbouring cell content while expanded. + backgroundColor: "neutral.s00", + outline: "[2px solid {colors.blue.s50}]", + outlineOffset: "[-2px]", + zIndex: "[2]", + pointerEvents: "none", + }, + variants: { + anchor: { + left: { left: "[0]" }, + right: { right: "[0]", justifyContent: "flex-end" }, + }, + }, +}); + const getDefaultCellValue = ( column: SpreadsheetColumn | undefined, ): SpreadsheetCellValue => @@ -198,6 +238,34 @@ const getDefaultCellValue = ( const formatCellValue = (value: SpreadsheetCellValue): string => String(value); +const toCanonicalUuidString = (value: SpreadsheetCellValue): string => + formatUuid(typeof value === "bigint" ? value : toUuid(value)); + +/** Full-fidelity text used to prefill the cell editor. */ +const getCellEditText = ( + column: SpreadsheetColumn | undefined, + value: SpreadsheetCellValue, +): string => + column?.type === "uuid" + ? toCanonicalUuidString(value) + : formatCellValue(value); + +/** Compact text shown in non-editing cells (uuids are truncated). */ +const getCellDisplayText = ( + column: SpreadsheetColumn | undefined, + value: SpreadsheetCellValue, +): string => + column?.type === "uuid" + ? `${toCanonicalUuidString(value).slice(0, 8)}…` + : formatCellValue(value); + +/** Hover tooltip — the full canonical uuid string for uuid cells. */ +const getCellTitle = ( + column: SpreadsheetColumn | undefined, + value: SpreadsheetCellValue, +): string | undefined => + column?.type === "uuid" ? toCanonicalUuidString(value) : undefined; + const parseCellValue = ( column: SpreadsheetColumn | undefined, rawValue: string, @@ -209,6 +277,12 @@ const parseCellValue = ( } case "integer": return Math.round(Number.parseFloat(rawValue) || 0); + case "uuid": { + // Valid UUID strings parse; anything else converts deterministically + // via UUIDv5. Clearing the cell resets to the nil uuid. + const trimmed = rawValue.trim(); + return trimmed === "" ? 0n : toUuid(trimmed); + } case "real": default: return Number.parseFloat(rawValue) || 0; @@ -532,7 +606,8 @@ export const Spreadsheet: React.FC = ({ event.preventDefault(); setEditingCell({ row, col }); setEditingValue( - formatCellValue( + getCellEditText( + columns[col], tableData[row]?.[col] ?? getDefaultCellValue(columns[col]), ), ); @@ -703,17 +778,37 @@ export const Spreadsheet: React.FC = ({ style={{ width: `${columnWidth}%` }} > {isReadOnly ? ( -
- {isPhantomRow ? "" : formatCellValue(value)} +
+ {isPhantomRow + ? "" + : getCellDisplayText(columns[colIndex], value)}
) : isEditing ? ( @@ -805,10 +900,39 @@ export const Spreadsheet: React.FC = ({ handleKeyDown(event, rowIndex, colIndex) } className={cellButtonStyle({ isFocused })} + title={ + isPhantomRow + ? undefined + : getCellTitle(columns[colIndex], value) + } + // Screen readers announce the truncated text; + // uuid cells need the full canonical string. + aria-label={ + isPhantomRow + ? undefined + : getCellTitle(columns[colIndex], value) + } > - {isPhantomRow ? "" : formatCellValue(value)} + {isPhantomRow + ? "" + : getCellDisplayText(columns[colIndex], value)}
)} + {!isReadOnly && + !isEditing && + !isPhantomRow && + isFocused && + columns[colIndex]?.type === "uuid" ? ( + = colCount / 2 ? "right" : "left", + })} + aria-hidden + > + {toCanonicalUuidString(value)} + + ) : null} ); })} diff --git a/libs/@hashintel/petrinaut/src/ui/dev/token-encoding-playground/dimension-editor.tsx b/libs/@hashintel/petrinaut/src/ui/dev/token-encoding-playground/dimension-editor.tsx index 38fb259e1b2..81a40bc2fdd 100644 --- a/libs/@hashintel/petrinaut/src/ui/dev/token-encoding-playground/dimension-editor.tsx +++ b/libs/@hashintel/petrinaut/src/ui/dev/token-encoding-playground/dimension-editor.tsx @@ -14,6 +14,7 @@ const typeOptions: SelectItem[] = [ { value: "real", text: "Real" }, { value: "integer", text: "Integer" }, { value: "boolean", text: "Boolean" }, + { value: "uuid", text: "UUID" }, ]; const listStyle = css({ diff --git a/libs/@hashintel/petrinaut/src/ui/dev/token-encoding-playground/physical-layout.test.ts b/libs/@hashintel/petrinaut/src/ui/dev/token-encoding-playground/physical-layout.test.ts index 5785cf40f86..bb50b73b5d5 100644 --- a/libs/@hashintel/petrinaut/src/ui/dev/token-encoding-playground/physical-layout.test.ts +++ b/libs/@hashintel/petrinaut/src/ui/dev/token-encoding-playground/physical-layout.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; +import { parseUuid } from "@hashintel/petrinaut-core"; + import { computePlaygroundTokenLayout, decodeToken, @@ -88,3 +90,52 @@ describe("bit inspection", () => { expect(getFieldHex(buffer, layout.fields[0]!)).toBe("0xc000000000000000"); }); }); + +describe("uuid (u64x2) fields", () => { + const CANONICAL = "0f9a3b5c-7d1e-4a2b-8c3d-4e5f6a7b8c9d"; + + it("lays out 16-byte uuid fields and round-trips bigints", () => { + const layout = computePlaygroundTokenLayout([ + { name: "id", type: "uuid" }, + { name: "x", type: "real" }, + ]); + + expect( + layout.fields.map((field) => [ + field.element.name, + field.kind, + field.byteOffset, + field.byteSize, + ]), + ).toEqual([ + ["id", "u64x2", 0, 16], + ["x", "f64", 16, 8], + ]); + expect(layout.strideBytes).toBe(24); + + const { stored, decoded } = encodeToken(layout, { + id: CANONICAL, + x: 1.5, + }); + expect(stored).toEqual({ id: parseUuid(CANONICAL), x: 1.5 }); + expect(decoded).toEqual({ id: parseUuid(CANONICAL), x: 1.5 }); + }); + + it("exposes 128 logical bits MSB-first (hi lane's MSB first) and full hex", () => { + const layout = computePlaygroundTokenLayout([{ name: "id", type: "uuid" }]); + const { buffer } = encodeToken(layout, { id: CANONICAL }); + const field = layout.fields[0]!; + + const bits = getFieldBits(buffer, field); + expect(bits).toHaveLength(128); + // MSB of the value is the top bit of the hi lane: 0x0f… starts 00001111. + expect(bits.slice(0, 8).join("")).toBe("00001111"); + expect(getFieldHex(buffer, field)).toBe( + `0x${CANONICAL.replaceAll("-", "")}`, + ); + // Memory layout: lo lane little-endian at offset 0, hi lane at +8. + const bytes = new Uint8Array(buffer, field.byteOffset, 16); + expect(bytes[0]).toBe(0x9d); // lo lane LSB + expect(bytes[15]).toBe(0x0f); // hi lane MSB + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/dev/token-encoding-playground/physical-layout.ts b/libs/@hashintel/petrinaut/src/ui/dev/token-encoding-playground/physical-layout.ts index a7403272d02..dc3bad3a3ad 100644 --- a/libs/@hashintel/petrinaut/src/ui/dev/token-encoding-playground/physical-layout.ts +++ b/libs/@hashintel/petrinaut/src/ui/dev/token-encoding-playground/physical-layout.ts @@ -54,8 +54,8 @@ export function decodeToken( if (layout.strideBytes === 0) { return {}; } - const { f64, u8 } = createTokenRegionViews(buffer, 0, layout.strideBytes); - return readTokenRecord(layout, f64, u8, 0); + const views = createTokenRegionViews(buffer, 0, layout.strideBytes); + return readTokenRecord(layout, views, 0); } /** @@ -86,7 +86,9 @@ export function encodeToken( /** * Returns one field's bits most-significant first (the "logical" reading * order: for f64 that is sign, exponent, mantissa). Multi-byte values are - * stored little-endian, so the byte walk is reversed. + * stored little-endian, so the byte walk is reversed. For 16-byte `u64x2` + * uuid fields (hi lane stored at +8) the reversal starts with the hi lane's + * most-significant byte, i.e. the logical 128-bit MSB. */ export function getFieldBits( buffer: ArrayBuffer, diff --git a/libs/@hashintel/petrinaut/src/ui/dev/token-encoding-playground/playground-monaco.ts b/libs/@hashintel/petrinaut/src/ui/dev/token-encoding-playground/playground-monaco.ts index adc4ec4a7b5..7cb3fbf7a17 100644 --- a/libs/@hashintel/petrinaut/src/ui/dev/token-encoding-playground/playground-monaco.ts +++ b/libs/@hashintel/petrinaut/src/ui/dev/token-encoding-playground/playground-monaco.ts @@ -167,7 +167,9 @@ export function generateTokenDefs( .filter((dimension) => IDENTIFIER_RE.test(dimension.name)) .map( (dimension) => - ` ${dimension.name}: ${dimension.type === "boolean" ? "boolean" : "number"};`, + // uuid accepts strings too — the encoder coerces via `toUuid`, so the + // playground types match what it actually accepts. + ` ${dimension.name}: ${dimension.type === "boolean" ? "boolean" : dimension.type === "uuid" ? "bigint | string" : "number"};`, ); return [ diff --git a/libs/@hashintel/petrinaut/src/ui/dev/token-encoding-playground/token-memory-view.tsx b/libs/@hashintel/petrinaut/src/ui/dev/token-encoding-playground/token-memory-view.tsx index f11f3bab9de..d2c349462b5 100644 --- a/libs/@hashintel/petrinaut/src/ui/dev/token-encoding-playground/token-memory-view.tsx +++ b/libs/@hashintel/petrinaut/src/ui/dev/token-encoding-playground/token-memory-view.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { css, cva } from "@hashintel/ds-helpers/css"; +import { formatUuid } from "@hashintel/petrinaut-core"; import { f64BitPart } from "./physical-layout"; @@ -35,6 +36,8 @@ const bitLightness = ( field: TokenLayoutField, msbFirstIndex: number, ): number => { + // Only f64 fields get IEEE-754 anatomy shading; u8 and u64x2 (uuid) fields + // are plain integers, rendered with one uniform lightness. if (field.kind !== "f64") { return 78; } @@ -100,7 +103,10 @@ function buildSlotGroups( const msbFirstIndex = size * 8 - 1 - lsbFirstIndex; bits.push({ value, lightness: bitLightness(field, msbFirstIndex) }); } - return { label: `b${byteIndex}`, bits }; + // uuid fields are two 64-bit lanes: lo at the field offset, hi at +8. + const lane = + field.kind === "u64x2" ? (byteWithinField < 8 ? " lo" : " hi") : ""; + return { label: `b${byteIndex}${lane}`, bits }; }), }); } @@ -196,7 +202,7 @@ const byteRowStyle = css({ }); const byteLabelStyle = css({ - width: "[26px]", + width: "[44px]", textAlign: "right", fontFamily: "mono", fontSize: "[9px]", @@ -267,7 +273,8 @@ const formatValue = (value: unknown): string => { return Object.is(value, -0) ? "-0" : String(value); } if (typeof value === "bigint") { - return `${value}n`; + // uuid values are bigints at runtime; show the canonical string form. + return formatUuid(value); } if (value === undefined) { return "undefined"; diff --git a/libs/@hashintel/petrinaut/src/ui/lib/compile-visualizer.ts b/libs/@hashintel/petrinaut/src/ui/lib/compile-visualizer.ts index d6522f16980..3a4c41b4635 100644 --- a/libs/@hashintel/petrinaut/src/ui/lib/compile-visualizer.ts +++ b/libs/@hashintel/petrinaut/src/ui/lib/compile-visualizer.ts @@ -2,7 +2,7 @@ import * as Babel from "@babel/standalone"; import { createElement, type ReactElement } from "react"; type VisualizerProps = { - tokens: Record[]; + tokens: Record[]; parameters: Record; }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-initial-state/initial-state-editor.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-initial-state/initial-state-editor.tsx index 04c2cdaf914..5f38ec33adf 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-initial-state/initial-state-editor.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-initial-state/initial-state-editor.tsx @@ -1,6 +1,6 @@ import { use, useMemo } from "react"; -import { defaultTokenAttributeValue } from "@hashintel/petrinaut-core"; +import { defaultTokenAttributeValue, toUuid } from "@hashintel/petrinaut-core"; import { PlaybackContext } from "../../../../../../../../react/playback/context"; import { SimulationContext } from "../../../../../../../../react/simulation/context"; @@ -15,6 +15,36 @@ import type { Color, Place, TokenRecord } from "@hashintel/petrinaut-core"; const getDefaultValue = (column: SpreadsheetColumn): SpreadsheetCellValue => column.type ? defaultTokenAttributeValue(column.type) : 0; +/** + * Converts one stored marking value to a spreadsheet cell value. uuid columns + * may hold at-rest strings — parse them to bigints for the spreadsheet. + */ +const toCellValue = ( + column: SpreadsheetColumn, + raw: number | boolean | bigint | string | undefined, +): SpreadsheetCellValue => { + if (raw === undefined) { + return getDefaultValue(column); + } + if (column.type === "uuid") { + return toUuid(raw); + } + // Mirror the engine's coercion (parseCellValue in spreadsheet.tsx): + // integers round, booleans accept "true"/"1" — so the display never + // disagrees with what the run will use. + if (typeof raw === "string") { + if (column.type === "boolean") { + const normalized = raw.trim().toLowerCase(); + return normalized === "true" || normalized === "1"; + } + const parsed = Number.parseFloat(raw) || 0; + return column.type === "integer" ? Math.round(parsed) : parsed; + } + return column.type === "integer" && typeof raw === "number" + ? Math.round(raw) + : raw; +}; + /** * InitialStateEditor - A component for editing initial tokens in a place * Stores data in SimulationStore, not in the Place definition @@ -65,7 +95,7 @@ export const InitialStateEditor: React.FC = ({ } return marking.map((token) => - columns.map((column) => token[column.name] ?? getDefaultValue(column)), + columns.map((column) => toCellValue(column, token[column.name])), ); }, [hasSimulation, currentFrameReader, place, columns, initialMarking]); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/type-properties/subviews/main.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/type-properties/subviews/main.tsx index 13ae9364ca2..44a56d7f25f 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/type-properties/subviews/main.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/type-properties/subviews/main.tsx @@ -146,6 +146,7 @@ const typeOptions: SelectItem[] = [ { value: "real", text: "Real" }, { value: "integer", text: "Integer" }, { value: "boolean", text: "Boolean" }, + { value: "uuid", text: "UUID" }, ]; const slugifyToIdentifier = (input: string): string => { diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/scenarios/create-scenario-drawer.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/scenarios/create-scenario-drawer.tsx index 047d1ebf2c8..a9decd3f1dc 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/scenarios/create-scenario-drawer.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/scenarios/create-scenario-drawer.tsx @@ -16,7 +16,10 @@ import { } from "./scenario-form"; import { EMPTY_SCENARIO_FORM_STATE } from "./scenario-form-defaults"; import { summarizeScenarioLspErrors } from "./scenario-lsp"; -import { buildScenarioFromFormState } from "./scenario-mapping"; +import { + buildScenarioFromFormState, + type ScenarioTokenRowContext, +} from "./scenario-mapping"; // -- Footer (subscribes to form + LSP state for submit gating) ---------------- @@ -117,10 +120,19 @@ export const CreateScenarioDrawer = ({ (petriNetDefinition.scenarios ?? []).map((s) => s.name), ); + const tokenRowContext: ScenarioTokenRowContext = { + places: petriNetDefinition.places, + typesById: new Map(petriNetDefinition.types.map((type) => [type.id, type])), + }; + const form = useScenarioForm( EMPTY_SCENARIO_FORM_STATE, (value, ctx) => { - const scenario = buildScenarioFromFormState(value, crypto.randomUUID()); + const scenario = buildScenarioFromFormState( + value, + crypto.randomUUID(), + tokenRowContext, + ); // Final structural validation against the persistence schema. const result = scenarioSchema.safeParse(scenario); if (!result.success) { diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/scenarios/scenario-mapping.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/scenarios/scenario-mapping.ts index bedc59bd581..627002ddef9 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/scenarios/scenario-mapping.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/scenarios/scenario-mapping.ts @@ -1,17 +1,103 @@ +import { formatUuid, toUuid } from "@hashintel/petrinaut-core"; + +import type { SpreadsheetCellValue } from "../../../../../components/spreadsheet"; import type { ScenarioFormState } from "./scenario-form"; -import type { Scenario } from "@hashintel/petrinaut-core"; +import type { Color, Place, Scenario } from "@hashintel/petrinaut-core"; + +/** Row values as persisted in scenario documents (uuids are strings). */ +type ScenarioTokenRow = (number | boolean | string)[]; + +/** + * Context needed to translate coloured token rows between their at-rest form + * (uuid columns as canonical strings) and the spreadsheet's runtime form + * (uuid columns as bigints). + */ +export interface ScenarioTokenRowContext { + places: Place[]; + typesById: Map; +} + +function getPlaceElements( + context: ScenarioTokenRowContext, + placeId: string, +): Color["elements"] | undefined { + const place = context.places.find((p) => p.id === placeId); + const color = place?.colorId + ? context.typesById.get(place.colorId) + : undefined; + return color?.elements; +} + +/** + * Converts persisted scenario rows into spreadsheet cell values: uuid columns + * parse (or deterministically convert) to bigints; other columns pass + * through. + */ +export function scenarioRowsToSpreadsheetData( + rows: ScenarioTokenRow[], + elements: Color["elements"] | undefined, +): SpreadsheetCellValue[][] { + return rows.map((row) => + row.map((cell, columnIndex): SpreadsheetCellValue => { + const element = elements?.[columnIndex]; + if (element?.type === "uuid") { + return toUuid(cell); + } + // Non-uuid columns are numbers/booleans at rest; coerce any stray + // strings so the spreadsheet never sees them. Mirror the engine's + // coercion (parseCellValue in spreadsheet.tsx): integers round, + // booleans accept "true"/"1". + if (typeof cell === "string") { + if (element?.type === "boolean") { + const normalized = cell.trim().toLowerCase(); + return normalized === "true" || normalized === "1"; + } + const parsed = Number.parseFloat(cell) || 0; + return element?.type === "integer" ? Math.round(parsed) : parsed; + } + if (element?.type === "integer" && typeof cell === "number") { + return Math.round(cell); + } + return cell; + }), + ); +} + +/** + * Converts spreadsheet cell values back into the persisted (JSON-safe) row + * form: bigint uuid values become canonical lowercase strings. + */ +export function spreadsheetDataToScenarioRows( + data: SpreadsheetCellValue[][], + elements: Color["elements"] | undefined, +): ScenarioTokenRow[] { + return data.map((row) => + row.map((cell, columnIndex): number | boolean | string => { + if ( + elements?.[columnIndex]?.type === "uuid" || + typeof cell === "bigint" + ) { + return formatUuid(typeof cell === "bigint" ? cell : toUuid(cell)); + } + return cell; + }), + ); +} /** * Build a `Scenario` from the form state. Drops the draft `_key` field used - * for stable React keys in the parameter list. + * for stable React keys in the parameter list, and serializes uuid spreadsheet + * values (bigints) to canonical strings so the scenario stays JSON-safe. * * @param state - the form state * @param id - the scenario id (use a new UUID for new scenarios, the existing * scenario's id when updating) + * @param context - places and colour types, used to type token row columns */ export function buildScenarioFromFormState( state: ScenarioFormState, id: string, + context: ScenarioTokenRowContext, ): Scenario { return { id, @@ -28,9 +114,45 @@ export function buildScenarioFromFormState( content: { // Uncolored places: expression strings (token count) ...state.initialTokenCounts, - // Colored places: rows × elements, stored directly - ...state.initialTokenData, + // Colored places: rows × elements, uuids serialized to strings + ...Object.fromEntries( + Object.entries(state.initialTokenData).map(([placeId, rows]) => [ + placeId, + spreadsheetDataToScenarioRows( + rows, + getPlaceElements(context, placeId), + ), + ]), + ), }, }, }; } + +/** + * Extracts the coloured token rows from a persisted scenario into spreadsheet + * form (uuid strings parsed to bigints). + */ +export function buildSpreadsheetDataFromScenario( + scenario: Scenario, + context: ScenarioTokenRowContext, +): Record { + if (scenario.initialState.type !== "per_place") { + return {}; + } + return Object.fromEntries( + Object.entries(scenario.initialState.content).flatMap(([placeId, value]) => + Array.isArray(value) + ? [ + [ + placeId, + scenarioRowsToSpreadsheetData( + value, + getPlaceElements(context, placeId), + ), + ], + ] + : [], + ), + ); +} diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/scenarios/view-scenario-drawer.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/scenarios/view-scenario-drawer.tsx index e7b4b0fba47..e2beecd8eb7 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/scenarios/view-scenario-drawer.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/scenarios/view-scenario-drawer.tsx @@ -6,7 +6,6 @@ import { scenarioSchema, type Color, type Scenario, - type TokenAttributeValue, } from "@hashintel/petrinaut-core"; import { usePetrinautMutations } from "../../../../../../react"; @@ -21,13 +20,20 @@ import { useScenarioForm, } from "./scenario-form"; import { summarizeScenarioLspErrors } from "./scenario-lsp"; -import { buildScenarioFromFormState } from "./scenario-mapping"; +import { + buildScenarioFromFormState, + buildSpreadsheetDataFromScenario, + type ScenarioTokenRowContext, +} from "./scenario-mapping"; // -- Defaults ----------------------------------------------------------------- let nextKey = 0; -function buildDefaultsFromScenario(scenario: Scenario): ScenarioFormState { +function buildDefaultsFromScenario( + scenario: Scenario, + context: ScenarioTokenRowContext, +): ScenarioFormState { return { name: scenario.name, description: scenario.description ?? "", @@ -45,15 +51,9 @@ function buildDefaultsFromScenario(scenario: Scenario): ScenarioFormState { ), ) as Record) : {}, - initialTokenData: - scenario.initialState.type === "per_place" - ? (Object.fromEntries( - Object.entries(scenario.initialState.content).filter( - (entry): entry is [string, TokenAttributeValue[][]] => - Array.isArray(entry[1]), - ), - ) as Record) - : {}, + // Coloured token rows, with uuid strings parsed to bigints for the + // spreadsheet. + initialTokenData: buildSpreadsheetDataFromScenario(scenario, context), showAllPlaces: false, initialStateAsCode: scenario.initialState.type === "code", initialStateCode: @@ -146,12 +146,21 @@ const ViewScenarioContent = ({ .map((s) => s.name), ); + const tokenRowContext: ScenarioTokenRowContext = { + places: petriNetDefinition.places, + typesById, + }; + // Build defaults once from the scenario prop (component remounts via `key` // when scenario.id changes, so this is effectively re-evaluated on switch). const form = useScenarioForm( - buildDefaultsFromScenario(scenario), + buildDefaultsFromScenario(scenario, tokenRowContext), (value) => { - const updated = buildScenarioFromFormState(value, scenario.id); + const updated = buildScenarioFromFormState( + value, + scenario.id, + tokenRowContext, + ); const result = scenarioSchema.safeParse(updated); if (!result.success) { return; diff --git a/libs/@hashintel/petrinaut/src/ui/views/shared/place-state-visualization.tsx b/libs/@hashintel/petrinaut/src/ui/views/shared/place-state-visualization.tsx index c17d5580fde..7eb74cb4c10 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/shared/place-state-visualization.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/shared/place-state-visualization.tsx @@ -1,6 +1,10 @@ import { use, useMemo } from "react"; import { css } from "@hashintel/ds-helpers/css"; +import { + coerceTokenAttributeValue, + defaultTokenAttributeValue, +} from "@hashintel/petrinaut-core"; import { ExecutionFrameSourceContext } from "../../../react/execution-frame/context"; import { @@ -74,16 +78,25 @@ export const PlaceStateVisualization: React.FC< } else { const marking = initialMarking[place.id]; if (Array.isArray(marking) && marking.length > 0) { - // Stored records may predate schema edits (added dimensions), so fill - // missing keys with each element's default — visualizer code should - // see 0/false, not undefined. + // Marking records may hold uuid values as at-rest strings; coerce them + // to runtime token values (uuid → bigint) as the engine would. Total + // per element: a stored value that no longer converts (e.g. legacy + // data predating a schema edit) falls back to the element's default + // instead of crashing the panel. for (const token of marking) { - const filled: TokenRecord = {}; + const coerced: TokenRecord = {}; for (const element of placeType.elements) { - filled[element.name] = - token[element.name] ?? (element.type === "boolean" ? false : 0); + try { + coerced[element.name] = coerceTokenAttributeValue( + element, + token[element.name], + `Initial marking for place ${place.name}`, + ); + } catch { + coerced[element.name] = defaultTokenAttributeValue(element.type); + } } - tokens.push(filled); + tokens.push(coerced); } }