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.
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 }) => (