diff --git a/.changeset/h-6519-discrete-token-attribute-types.md b/.changeset/h-6519-discrete-token-attribute-types.md new file mode 100644 index 00000000000..707c5bed479 --- /dev/null +++ b/.changeset/h-6519-discrete-token-attribute-types.md @@ -0,0 +1,6 @@ +--- +"@hashintel/petrinaut": patch +"@hashintel/petrinaut-core": patch +--- + +Add discrete token attribute types to Petrinaut. diff --git a/libs/@hashintel/petrinaut-core/src/ai.ts b/libs/@hashintel/petrinaut-core/src/ai.ts index a359febdc7a..8eba9ff2018 100644 --- a/libs/@hashintel/petrinaut-core/src/ai.ts +++ b/libs/@hashintel/petrinaut-core/src/ai.ts @@ -202,12 +202,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) => …)\`. \`input.PlaceName\` is a tuple sized to the input arc weight; tokens are \`{ : number }\`. Inhibitor arcs and uncoloured input places are NOT in \`input\`. Predicate → boolean; stochastic → non-negative finite rate in firings per simulation second (0 disables, Infinity always fires). Must be deterministic. -- Transition kernel (\`transition.transitionKernelCode\`): \`export default TransitionKernel((input, parameters) => …)\`. Return \`{ OutputPlaceName: [token, …] }\` sized to the output arc weight. Include only coloured output places; uncoloured output places are auto-populated. Use \`Distribution.Gaussian(mean, sd)\` / \`Distribution.Uniform(min, max)\` / \`Distribution.Lognormal(mu, sigma)\` for stochastic attributes; chained \`.map(fn)\` on the same distribution shares one draw. Always required (use \`() => ({})\` when no coloured outputs). -- 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 are \`{ : derivative }\` (i.e. dx/dt, not the new value). The equation's \`colorId\` MUST match every referencing place's \`colorId\`. +- Transition lambda (\`transition.lambdaCode\`): \`export default Lambda((input, parameters) => …)\`. \`input.PlaceName\` is a tuple sized to the input arc weight; token attributes are typed by the colour element: real/integer → number, boolean → boolean, uuid → string. Inhibitor arcs and uncoloured input places are NOT in \`input\`. Predicate → boolean; stochastic → non-negative finite rate in firings per simulation second (0 disables, Infinity always fires). Must be deterministic. +- Transition kernel (\`transition.transitionKernelCode\`): \`export default TransitionKernel((input, parameters) => …)\`. 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 attributes may use \`Distribution.Gaussian(mean, sd)\` / \`Distribution.Uniform(min, max)\` / \`Distribution.Lognormal(mu, sigma)\`, and chained \`.map(fn)\` on the same distribution shares one draw. Always required (use \`() => ({})\` when no coloured outputs). +- 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 \`number[][]\` rows in colour element order. +- 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, booleans, and UUID strings as appropriate. - 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 7c54f5461b8..0e504fea8bd 100644 --- a/libs/@hashintel/petrinaut-core/src/default-codes.ts +++ b/libs/@hashintel/petrinaut-core/src/default-codes.ts @@ -1,5 +1,22 @@ import type { Color } from "./types/sdcpn"; +const defaultTokenAttributeSource = ( + element: Color["elements"][number], +): string => { + switch (element.type) { + case "boolean": + return "false"; + case "uuid": + return '"00000000-0000-0000-0000-000000000000"'; + case "integer": + case "real": + return "0"; + } +}; + +const defaultDerivativeSource = (element: Color["elements"][number]): string => + element.type === "real" ? "1" : "0"; + export function generateDefaultVisualizerCode(type: Color): string { return `// This function defines how to visualize the tokens in the place of type "${type.name}". // It receives the current tokens and parameters. @@ -24,22 +41,22 @@ export default Visualization(({ tokens, parameters }) => { export function generateDefaultDifferentialEquationCode(type: Color): string { return `// This function defines the differential equation for the place of type "${type.name}". -// The function receives the current tokens in all places and the parameters. -// It should return the derivative of the token value in this place. +// The function receives the current tokens in this place and the parameters. +// It should return derivatives for real-valued token attributes in this place. export default Dynamics((tokens, parameters) => { return tokens.map(({ ${type.elements.map((el) => el.name).join(", ")} }) => { // ...Do some computation with input token here if needed return { - ${type.elements.map((el) => `${el.name}: 1`).join(",\n ")} - }; // Example: all derivatives = 1 + ${type.elements.map((el) => `${el.name}: ${defaultDerivativeSource(el)}`).join(",\n ")} + }; // Example: real-valued derivatives = 1; discrete values are unchanged }); });`; } export const DEFAULT_DIFFERENTIAL_EQUATION_CODE = `// This function defines the differential equation for the place. -// The function receives the current tokens in all places and the parameters. -// It should return the derivative of the token value in this place. +// The function receives the current tokens in this place and the parameters. +// It should return derivatives for real-valued token attributes in this place. export default Dynamics((tokens, parameters) => { return tokens.map(({ x, y }) => { return { x: 1, y: 1 }; // dx/dt = 1, dy/dt = 1 @@ -95,7 +112,7 @@ export default TransitionKernel((tokensByPlace, parameters) => { ${Array.from({ length: arc.weight }) .map( () => - `{ ${arc.type.elements.map((el) => `${el.name}: 0`).join(", ")} }`, + `{ ${arc.type.elements.map((el) => `${el.name}: ${defaultTokenAttributeSource(el)}`).join(", ")} }`, ) .join(",\n ")} ],`, 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 e0b42569c86..7e44473514a 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 @@ -1,6 +1,11 @@ import { getItemFilePath } from "./file-paths"; -import type { SDCPN, ScenarioParameter } from "../../types/sdcpn"; +import type { + Color, + ColorElementType, + SDCPN, + ScenarioParameter, +} from "../../types/sdcpn"; import type { VirtualFile } from "./create-language-service-host"; /** @@ -15,8 +20,28 @@ function sanitizeColorId(colorId: string): string { /** * Maps SDCPN element types to TypeScript types */ -function toTsType(type: "real" | "integer" | "boolean" | "ratio"): string { - return type === "boolean" ? "boolean" : "number"; +function toTsType(type: ColorElementType | "ratio"): string { + if (type === "boolean") { + return "boolean"; + } + if (type === "uuid") { + return "string"; + } + return "number"; +} + +function toDynamicsDerivativeType(color: Color): string { + const properties = color.elements + .map((element) => + element.type === "real" + ? ` ${element.name}?: number;` + : ` ${element.name}?: never;`, + ) + .join("\n"); + + return properties.length > 0 + ? `{\n${properties}\n}` + : "Record"; } /** @@ -66,6 +91,7 @@ export function generateVirtualFiles(sdcpn: SDCPN): Map { // Generate files for each differential equation for (const de of sdcpn.differentialEquations) { const sanitizedColorId = de.colorId ? sanitizeColorId(de.colorId) : null; + const color = de.colorId ? colorById.get(de.colorId) : undefined; const deDefsPath = getItemFilePath("differential-equation-defs", { id: de.id, }); @@ -90,7 +116,10 @@ export function generateVirtualFiles(sdcpn: SDCPN): Map { sanitizedColorId ? `type Tokens = Array;` : `type Tokens = Array;`, - `export type Dynamics = (fn: (tokens: Tokens, parameters: Parameters) => Tokens) => void;`, + color + ? `type Derivative = ${toDynamicsDerivativeType(color)};` + : `type Derivative = Record;`, + `export type Dynamics = (fn: (tokens: Tokens, parameters: Parameters) => Derivative[]) => void;`, ].join("\n"), }); @@ -428,7 +457,7 @@ export function generateMetricSessionFiles( const { sessionId } = session; // Build per-place state types. Colored places expose typed `tokens` arrays; - // uncolored places fall back to `Record[]`. + // uncolored places fall back to an empty token-record array. const colorById = new Map(sdcpn.types.map((c) => [c.id, c])); const placeStateImports: string[] = []; const placeStateProperties: string[] = []; @@ -448,10 +477,10 @@ export function generateMetricSessionFiles( } tokensType = `Color_${sanitized}[]`; } else { - tokensType = "Record[]"; + tokensType = "Record[]"; } } else { - tokensType = "Record[]"; + tokensType = "Record[]"; } placeStateProperties.push( ` "${place.name}": { count: number; tokens: ${tokensType} };`, @@ -461,7 +490,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 1f52a74a04b..6207da72a43 100644 --- a/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts +++ b/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts @@ -94,9 +94,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: - "Primitive token attribute type. Note: the simulation buffer stores all values as Float64; `integer`/`boolean` are documentation/type-hints only, not enforced at runtime.", + "`real` is continuous and may be updated by dynamics. `integer`, `boolean`, and `uuid` are discrete token attributes updated by transition kernels.", }), }) .meta({ @@ -177,7 +177,7 @@ export const transitionSchema = z "Module: `export default TransitionKernel((input, parameters) => …)`.", "`input` and `parameters` have the same shape as the transition's lambda.", "MUST return an object keyed by OUTPUT PLACE NAME with a tuple sized to that arc's weight. Coloured output places MUST be present; uncoloured output places MUST be omitted (they are auto-populated with empty tokens).", - "Token attribute values can be plain numbers/booleans OR `Distribution.Gaussian(mean, sd)` / `Distribution.Uniform(min, max)` / `Distribution.Lognormal(mu, sigma)`; each distribution is sampled once per token, and chained `.map(fn)` calls on the same distribution share that single sample (useful for deriving multiple attributes from one draw).", + "Token attribute values must match the output type: real/integer use numbers, boolean uses booleans, uuid uses UUID strings. Real/integer attributes may also use `Distribution.Gaussian(mean, sd)` / `Distribution.Uniform(min, max)` / `Distribution.Lognormal(mu, sigma)`; each distribution is sampled once per token, and chained `.map(fn)` calls on the same distribution share that single sample.", "Always required even when no stochasticity is needed; use `export default TransitionKernel(() => ({}))` when every output place is uncoloured.", ].join(" "), }), @@ -209,7 +209,7 @@ export const colorSchema = z }), elements: z.array(colorElementSchema).meta({ description: - "Typed token attributes available on tokens of this colour/type. Element order matters: coloured initial state in scenario per_place mode supplies `number[][]` rows in this order.", + "Typed token attributes available on tokens of this colour/type. Element order matters: coloured initial state in scenario per_place mode supplies rows in this order.", }), }) .meta({ @@ -230,16 +230,16 @@ export const differentialEquationSchema = z code: z.string().meta({ description: [ "Module: `export default Dynamics((tokens, parameters) => …)`.", - "`tokens` is THIS place's current tokens only — `Array<{ [elementName]: number }>` — NOT all places' tokens.", - "MUST return an array of the SAME LENGTH where each entry is `{ [elementName]: derivative }` (i.e. dx/dt, NOT the new value).", - "Missing keys default to 0 silently, so return every element your colour type declares.", + "`tokens` is THIS place's current tokens only — NOT all places' tokens.", + "MUST return an array of the SAME LENGTH where each entry provides real-valued derivatives (i.e. dx/dt, NOT the new value).", + "Integer, boolean, and uuid elements are discrete and remain unchanged by dynamics.", "`parameters` is keyed by each parameter's `variableName` value (lower_snake_case, e.g. `parameters.damage_per_second`).", ].join(" "), }), }) .meta({ description: - "A differential equation for continuous dynamics on coloured tokens. The `colorId` MUST match the colour of every place that references this equation via `differentialEquationId`, and the returned derivative keys MUST cover that colour's elements.", + "A differential equation for continuous dynamics on coloured tokens. The `colorId` MUST match the colour of every place that references this equation via `differentialEquationId`, and returned derivatives only update that colour's real-valued elements.", }) satisfies z.ZodType; export const parameterSchema = z diff --git a/libs/@hashintel/petrinaut-core/src/schemas/metric-schema.ts b/libs/@hashintel/petrinaut-core/src/schemas/metric-schema.ts index f9165645aaf..fb91dd1736b 100644 --- a/libs/@hashintel/petrinaut-core/src/schemas/metric-schema.ts +++ b/libs/@hashintel/petrinaut-core/src/schemas/metric-schema.ts @@ -18,7 +18,7 @@ export const metricSchema = z description: [ "Plain function body (NOT a module — no `export default`, no `Metric(...)` wrapper, no enclosing `function` declaration).", "The only variable in scope is `state`. The body MUST `return` a finite number — NaN, Infinity, and -Infinity throw and the metric series shows an error.", - "Access places by NAME: `state.places.PlaceName.count` (token count for any place) and `state.places.PlaceName.tokens` (`Array<{ [elementName]: number }>` for coloured places; always `[]` for uncoloured places).", + "Access places by NAME: `state.places.PlaceName.count` (token count for any place) and `state.places.PlaceName.tokens` (typed token objects for coloured places; always `[]` for uncoloured places).", "`parameters` and `scenario` are NOT available inside metrics.", "Example: `const i = state.places.Infected.count; const r = state.places.Recovered.count; return (i + r) === 0 ? 0 : i / (i + r);`", ].join(" "), diff --git a/libs/@hashintel/petrinaut-core/src/schemas/scenario-schema.ts b/libs/@hashintel/petrinaut-core/src/schemas/scenario-schema.ts index 1cb5017a610..fe92de69f94 100644 --- a/libs/@hashintel/petrinaut-core/src/schemas/scenario-schema.ts +++ b/libs/@hashintel/petrinaut-core/src/schemas/scenario-schema.ts @@ -6,6 +6,11 @@ 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(), + z.string(), +]); export const scenarioParameterSchema = z .strictObject({ @@ -39,13 +44,13 @@ const initialStateSchema = z content: z .record( z.string(), - z.union([z.string(), z.array(z.array(z.number()))]), + z.union([z.string(), z.array(z.array(tokenAttributeValueSchema))]), ) .meta({ description: [ "Map keyed by place ID (NOT place name).", 'For uncoloured places, the value is a string expression with `parameters` and `scenario` in scope (e.g. `"scenario.population * (1 - scenario.infected_ratio)"`). The result is `Math.round`ed and clamped to >= 0 (token counts are always non-negative integers).', - "For coloured places, the value is `number[][]` where each inner array supplies element values in the SAME ORDER as the colour type's `elements`. Extra columns throw at compile time; missing columns default to 0.", + "For coloured places, the value is a row array where each inner array supplies element values in the SAME ORDER as the colour type's `elements`. Extra columns throw at compile time; missing columns default to the element type's zero value.", "`parameters` in expressions is keyed by each parameter's `variableName` value (lower_snake_case).", ].join(" "), }), @@ -61,7 +66,7 @@ const initialStateSchema = z description: [ "Function body (NOT a module — no `export default`, no wrapper) with `parameters` and `scenario` in scope.", "MUST `return` an object keyed by PLACE NAME (NOT place ID — note the asymmetry with per_place mode, which uses place IDs).", - "Per-place values: a number for uncoloured places (rounded and clamped to >= 0); `Array<{ [elementName]: number }>` for coloured places.", + "Per-place values: a number for uncoloured places (rounded and clamped to >= 0); `Array<{ [elementName]: number | boolean | string }>` for coloured places.", "Unknown place names in the returned object are silently dropped — typos produce an empty initial state with no error, so verify names exactly match.", ].join(" "), }), diff --git a/libs/@hashintel/petrinaut-core/src/simulation/api.ts b/libs/@hashintel/petrinaut-core/src/simulation/api.ts index 8b0f166d045..29c4b6bed5f 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/api.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/api.ts @@ -1,7 +1,7 @@ import type { AbortSignalLike, WorkerFactoryLike } from "../environment"; import type { ReadableStore } from "../handle"; import type { EventStream } from "../instance"; -import type { Color, Place, SDCPN } from "../types/sdcpn"; +import type { Color, Place, SDCPN, TokenRecord } from "../types/sdcpn"; export type SimulationState = | "Initializing" @@ -38,7 +38,7 @@ export type WorkerFactory = WorkerFactoryLike; * - Uncolored places use a token count. * - Colored places use one record per token, keyed by color element name. */ -export type InitialPlaceMarking = number | Record[]; +export type InitialPlaceMarking = number | TokenRecord[]; export type InitialMarking = Record; /** @@ -103,10 +103,7 @@ export interface SimulationFrameReader { getPlaceTokenCount(placeId: string): number; getPlaceTokenValues(placeId: string): SimulationPlaceTokenValues | null; - getPlaceTokens( - place: Place, - color: Color | null | undefined, - ): Record[]; + getPlaceTokens(place: Place, color: Color | null | undefined): TokenRecord[]; getTransitionState(transitionId: string): { /** * Time elapsed since this transition last fired, in milliseconds. diff --git a/libs/@hashintel/petrinaut-core/src/simulation/authoring/metric/compile-metric.ts b/libs/@hashintel/petrinaut-core/src/simulation/authoring/metric/compile-metric.ts index f5d1b7c7d9d..4b0d8f78fa1 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/authoring/metric/compile-metric.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/authoring/metric/compile-metric.ts @@ -1,6 +1,6 @@ import { runSandboxed, SHADOWED_GLOBALS } from "../sandbox"; -import type { Metric } from "../../../types/sdcpn"; +import type { Metric, TokenRecord } from "../../../types/sdcpn"; // -- Public types ------------------------------------------------------------- @@ -13,7 +13,7 @@ import type { Metric } from "../../../types/sdcpn"; */ export interface MetricPlaceState { count: number; - tokens: Record[]; + tokens: TokenRecord[]; } /** 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 83ed9b8cb02..8bc4a020cf7 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 @@ -329,6 +329,54 @@ describe("compileScenario", () => { } }); + it("converts typed token row values to token records", () => { + const entityId = "45F588B6-0538-4FC9-9207-1DDFD7F65B64"; + const result = compileScenario( + scenario({ + initialState: { + type: "per_place", + content: { + place1: [[1.5, 2.7, true, entityId], []], + }, + }, + }), + [], + [place("place1", "Place 1", "type1")], + [ + { + id: "type1", + name: "Typed entity", + iconSlug: "circle", + displayColor: "#000000", + elements: [ + { elementId: "amount", name: "amount", type: "real" }, + { elementId: "count", name: "count", type: "integer" }, + { elementId: "active", name: "active", type: "boolean" }, + { elementId: "entityId", name: "entityId", type: "uuid" }, + ], + }, + ], + ); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.result.initialState.place1).toEqual([ + { + amount: 1.5, + count: 3, + active: true, + entityId: entityId.toLowerCase(), + }, + { + amount: 0, + count: 0, + active: false, + entityId: "00000000-0000-0000-0000-000000000000", + }, + ]); + } + }); + it("handles empty token array", () => { const result = compileScenario( scenario({ 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 05bf6f8e2a1..1e30dec0091 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,6 +1,14 @@ +import { coerceTokenRecord } from "../../engine/token-values"; 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"; // -- Result types ------------------------------------------------------------- @@ -86,22 +94,22 @@ function evaluateExpression( } function tokenRecordsFromRows( - rows: number[][], + rows: TokenAttributeValue[][], elements: Color["elements"], -): Record[] { +): TokenRecord[] { return rows.map((row) => { - const token: Record = {}; + const token: Record = {}; for (let i = 0; i < elements.length; i++) { - token[elements[i]!.name] = row[i] ?? 0; + token[elements[i]!.name] = row[i]; } - return token; + return coerceTokenRecord(token, elements, "Scenario initial state token"); }); } function normalizeTokenRecords( tokens: unknown[], elements: Color["elements"], -): Record[] { +): TokenRecord[] { return tokens.flatMap((rawToken) => { if ( typeof rawToken !== "object" || @@ -112,18 +120,9 @@ function normalizeTokenRecords( } const source = rawToken as Record; - const token: Record = {}; - const entries = - elements.length > 0 - ? elements.map( - (element) => [element.name, source[element.name]] as const, - ) - : Object.entries(source); - - for (const [name, value] of entries) { - token[name] = Number(value ?? 0); - } - return [token]; + return [ + coerceTokenRecord(source, elements, "Scenario initial state token"), + ]; }); } @@ -283,7 +282,7 @@ export function compileScenario( for (const [placeId, value] of Object.entries( scenario.initialState.content, )) { - // Colored places: number[][] stored directly by the UI. + // Colored places: row data stored directly by the UI. if (Array.isArray(value)) { const place = placeById.get(placeId); const color = place?.colorId ? typeById.get(place.colorId) : undefined; diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/build-simulation.test.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/build-simulation.test.ts index 2f2f7ef8041..20290524df0 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/build-simulation.test.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/build-simulation.test.ts @@ -1,11 +1,93 @@ import { describe, expect, it } from "vitest"; +import { compileSimulationFrameReader } from "../frames/frame-reader"; import { materializeEngineFrame } from "../frames/internal-frame"; import { buildSimulation } from "./build-simulation"; import type { SimulationInput } from "./types"; describe("buildSimulation", () => { + it("packs and decodes integer, boolean, and UUID token attributes", () => { + const entityId = "45F588B6-0538-4FC9-9207-1DDFD7F65B64"; + const normalizedEntityId = entityId.toLowerCase(); + const input: SimulationInput = { + sdcpn: { + types: [ + { + id: "type1", + name: "Typed entity", + iconSlug: "circle", + displayColor: "#FF0000", + elements: [ + { elementId: "e1", name: "amount", type: "real" }, + { elementId: "e2", name: "count", type: "integer" }, + { elementId: "e3", name: "active", type: "boolean" }, + { elementId: "e4", name: "entityId", type: "uuid" }, + ], + }, + ], + differentialEquations: [], + parameters: [], + places: [ + { + id: "p1", + name: "Place 1", + colorId: "type1", + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + ], + transitions: [], + }, + initialMarking: { + p1: [ + { + amount: 1.25, + count: 2.7, + active: true, + entityId, + }, + ], + }, + parameterValues: {}, + seed: 42, + dt: 0.1, + maxTime: null, + }; + + const simulationInstance = buildSimulation(input); + const engineFrame = simulationInstance.frames[0]!; + const frame = materializeEngineFrame( + simulationInstance.frameLayout, + engineFrame, + ); + + expect(frame.buffer).toEqual(new Float64Array([1.25, 3, 1, 1])); + expect(simulationInstance.tokenValueCodec.snapshot()).toEqual([ + normalizedEntityId, + ]); + + const reader = compileSimulationFrameReader(input.sdcpn)( + engineFrame, + 0, + 0, + simulationInstance.tokenValueCodec.snapshot(), + ); + + expect( + reader.getPlaceTokens(input.sdcpn.places[0]!, input.sdcpn.types[0]), + ).toEqual([ + { + amount: 1.25, + count: 3, + active: true, + entityId: normalizedEntityId, + }, + ]); + }); + it("builds a simulation with a single place and initial tokens", () => { const input: SimulationInput = { sdcpn: { 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 ea7f958fdba..25182c2e49a 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/build-simulation.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/build-simulation.ts @@ -9,7 +9,13 @@ import { createEngineFrameLayout, type EngineFrameSnapshot, } from "../frames/internal-frame"; +import { + coerceTokenRecord, + decodeTokenRecord, + TokenValueCodec, +} from "./token-values"; +import type { TokenRecord } from "../../types/sdcpn"; import type { CompiledTransition, DifferentialEquationFn, @@ -22,13 +28,16 @@ import type { TransitionTokenValues, } from "./types"; +type ColorElement = + SimulationInput["sdcpn"]["types"][number]["elements"][number]; + type PackedInitialPlaceMarking = { values: number[]; count: number; }; type UserDifferentialEquationFn = ( - tokens: Record[], + tokens: TokenRecord[], parameters: ParameterValues, ) => Record[]; @@ -75,6 +84,7 @@ function packInitialPlaceMarking( place: SimulationInput["sdcpn"]["places"][0], sdcpn: SimulationInput["sdcpn"], value: SimulationInput["initialMarking"][string] | undefined, + tokenValueCodec: TokenValueCodec, ): PackedInitialPlaceMarking { const dimensions = getPlaceDimensions(place, sdcpn); @@ -112,9 +122,19 @@ function packInitialPlaceMarking( `Initial marking token for place ${place.id} must be a record`, ); } - const tokenRecord = token as Record; + const tokenRecord = coerceTokenRecord( + token as Record, + type.elements, + `Initial marking token for place ${place.id}`, + ); for (const element of type.elements) { - values.push(Number(tokenRecord[element.name] ?? 0)); + values.push( + tokenValueCodec.encode( + element, + tokenRecord[element.name], + `Initial marking token for place ${place.id}.${element.name}`, + ), + ); } } @@ -124,12 +144,16 @@ function packInitialPlaceMarking( function createDifferentialEquationFn({ placeId, elementNames, + elements, parameterValues, + tokenValueCodec, userFn, }: { placeId: string; elementNames: string[]; + elements: SimulationInput["sdcpn"]["types"][number]["elements"]; parameterValues: ParameterValues; + tokenValueCodec: TokenValueCodec; userFn: UserDifferentialEquationFn; }): DifferentialEquationFn { const expectedDimensions = elementNames.length; @@ -141,19 +165,16 @@ function createDifferentialEquationFn({ ); } - const inputTokens: Record[] = []; + const inputTokens: TokenRecord[] = []; for (let tokenIndex = 0; tokenIndex < numberOfTokens; tokenIndex++) { const tokenStart = tokenIndex * dimensions; - const token: Record = {}; - for ( - let dimensionIndex = 0; - dimensionIndex < dimensions; - dimensionIndex++ - ) { - token[elementNames[dimensionIndex]!] = - currentState[tokenStart + dimensionIndex]!; - } - inputTokens.push(token); + inputTokens.push( + decodeTokenRecord( + elements, + currentState.subarray(tokenStart, tokenStart + dimensions), + tokenValueCodec.snapshot(), + ), + ); } const resultTokens = userFn(inputTokens, parameterValues); @@ -168,8 +189,9 @@ function createDifferentialEquationFn({ dimensionIndex++ ) { const dimensionName = elementNames[dimensionIndex]!; + const element = elements[dimensionIndex]!; result[tokenIndex * dimensions + dimensionIndex] = - token[dimensionName]!; + element.type === "real" ? Number(token[dimensionName] ?? 0) : 0; } } @@ -203,6 +225,32 @@ function getPlaceElementNames( return type.elements.map((element) => element.name); } +function getPlaceElements( + placeId: string, + placesMap: ReadonlyMap, + typesMap: ReadonlyMap, +): readonly ColorElement[] | null { + const place = placesMap.get(placeId); + if (!place) { + throw new Error( + `Place with ID ${placeId} referenced by transition does not exist in SDCPN`, + ); + } + + if (!place.colorId) { + return null; + } + + const type = typesMap.get(place.colorId); + if (!type) { + throw new Error( + `Type with ID ${place.colorId} referenced by place ${place.id} does not exist in SDCPN`, + ); + } + + return type.elements; +} + function createLambdaFn( transition: SimulationInput["sdcpn"]["transitions"][number], parameterValues: ParameterValues, @@ -289,6 +337,7 @@ function createCompiledTransition({ weight: arc.weight, arcType: arc.type, elementNames: getPlaceElementNames(arc.placeId, placesMap, typesMap), + elements: getPlaceElements(arc.placeId, placesMap, typesMap), }; }), outputPlaces: transition.outputArcs.map((arc) => { @@ -304,6 +353,7 @@ function createCompiledTransition({ placeName: place.name, weight: arc.weight, elementNames: getPlaceElementNames(arc.placeId, placesMap, typesMap), + elements: getPlaceElements(arc.placeId, placesMap, typesMap), }; }), lambdaFn, @@ -347,6 +397,7 @@ export function buildSimulation(input: SimulationInput): SimulationInstance { sdcpn.transitions.map((transition) => [transition.id, transition]), ); const typesMap = new Map(sdcpn.types.map((type) => [type.id, type])); + const tokenValueCodec = new TokenValueCodec(); // Build parameter values: merge input values with SDCPN defaults // Input values (from simulation store) take precedence over defaults @@ -373,6 +424,7 @@ export function buildSimulation(input: SimulationInput): SimulationInstance { place, sdcpn, getInitialMarkingValue(initialMarking, place.id), + tokenValueCodec, ), ); } @@ -407,15 +459,22 @@ export function buildSimulation(input: SimulationInput): SimulationInstance { ); } - const userFn = compileUserCode< - [Record[], ParameterValues] - >(code, "Dynamics") as UserDifferentialEquationFn; + if (!type.elements.some((element) => element.type === "real")) { + continue; + } + + const userFn = compileUserCode<[TokenRecord[], ParameterValues]>( + code, + "Dynamics", + ) as UserDifferentialEquationFn; differentialEquationFns.set( place.id, createDifferentialEquationFn({ placeId: place.id, elementNames: type.elements.map((element) => element.name), + elements: type.elements, parameterValues, + tokenValueCodec, userFn, }), ); @@ -499,6 +558,7 @@ export function buildSimulation(input: SimulationInput): SimulationInstance { differentialEquationFns, compiledTransitions, parameterValues, + tokenValueCodec, dt, maxTime, currentTime: 0, 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 85cfb2e5b7e..00dc5af535f 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 @@ -7,6 +7,7 @@ import { type EngineFrameSnapshot, } from "../frames/internal-frame"; import { computePossibleTransition as computePossibleTransitionImpl } from "./compute-possible-transition"; +import { TokenValueCodec } from "./token-values"; import type { Color, Place, Transition } from "../../types/sdcpn"; import type { @@ -100,6 +101,14 @@ function makeCompiledTransitions({ null ); }; + const getElements = (placeId: string) => { + const place = placesMap.get(placeId); + if (!place?.colorId) { + return null; + } + + return typesMap.get(place.colorId)?.elements ?? null; + }; return new Map( transitions.map((transition) => { @@ -120,12 +129,14 @@ function makeCompiledTransitions({ weight: arc.weight, arcType: arc.type, elementNames: getElementNames(arc.placeId), + elements: getElements(arc.placeId), })), outputPlaces: transition.outputArcs.map((arc) => ({ placeId: arc.placeId, placeName: placesMap.get(arc.placeId)?.name ?? arc.placeId, weight: arc.weight, elementNames: getElementNames(arc.placeId), + elements: getElements(arc.placeId), })), lambdaFn, transitionKernelFn, @@ -169,6 +180,7 @@ function makeSimulation({ transitionKernelFns, }), parameterValues: {}, + tokenValueCodec: new TokenValueCodec(), dt: 0.1, maxTime: null, currentTime: 0, @@ -354,4 +366,86 @@ describe("computePossibleTransition", () => { }); expect(result?.newRngState).toBeTypeOf("number"); }); + + it("decodes typed input tokens and encodes typed output tokens", () => { + const outputEntityId = "45f588b6-0538-4fc9-9207-1ddfd7f65b64"; + const typedColor: Color = { + id: "typed", + name: "Typed", + iconSlug: "circle", + displayColor: "#FF0000", + elements: [ + { elementId: "amount", name: "amount", type: "real" }, + { elementId: "count", name: "count", type: "integer" }, + { elementId: "active", name: "active", type: "boolean" }, + { elementId: "entityId", name: "entityId", type: "uuid" }, + ], + }; + const transition = makeTransition({ + id: "t1", + inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "p2", weight: 1 }], + }); + let lambdaInput: unknown; + const simulation = makeSimulation({ + places: [ + makePlace("p1", "Source", typedColor.id), + makePlace("p2", "Target", typedColor.id), + ], + transitions: [transition], + types: [typedColor], + lambdaFns: new Map([ + [ + "t1", + (tokens) => { + lambdaInput = tokens; + return 10.0; + }, + ], + ]), + transitionKernelFns: new Map([ + [ + "t1", + () => ({ + Target: [ + { + amount: 2.5, + count: 3.6, + active: false, + entityId: outputEntityId, + }, + ], + }), + ], + ]), + }); + const frame = makeFrame({ + places: { + p1: { offset: 0, count: 1, dimensions: 4 }, + p2: { offset: 4, count: 0, dimensions: 4 }, + }, + transitions: { + t1: transitionState(), + }, + buffer: new Float64Array([1.25, 3, 1, 0]), + }); + + const result = computePossibleTransition(frame, simulation, "t1", 42); + + expect(lambdaInput).toEqual({ + Source: [ + { + amount: 1.25, + count: 3, + active: true, + entityId: "00000000-0000-0000-0000-000000000000", + }, + ], + }); + expect(result).toMatchObject({ + remove: { p1: new Set([0]) }, + add: { p2: [[2.5, 4, 0, 1]] }, + }); + expect(simulation.tokenValueCodec.snapshot()).toEqual([outputEntityId]); + }); }); 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 ce6b08778f3..04b4e576a4d 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 @@ -4,6 +4,7 @@ import { materializeEngineFrame } from "../frames/internal-frame"; import { enumerateWeightedMarkingIndicesGenerator } from "./enumerate-weighted-markings"; import { sampleDistribution } from "./sample-distribution"; import { nextRandom } from "./seeded-rng"; +import { decodeTokenRecord } from "./token-values"; import type { ID } from "../../types/sdcpn"; import type { @@ -116,24 +117,26 @@ export function computePossibleTransition( inputPlace.placeId, ); } - const elementNames = inputPlace.elementNames; + const elements = inputPlace.elements; + if (!elements) { + throw new SDCPNItemError( + `Place \`${inputPlace.placeName}\` has no type defined`, + inputPlace.placeId, + ); + } // Convert tokens for this place to objects with named dimensions - const placeTokens: Record[] = placeTokenIndices.map( - (tokenIndexInPlace) => { - // Offset within the global buffer - const globalIndex = - placeOffsetInBuffer + tokenIndexInPlace * dimensions; - - // Create token object with named dimensions - const token: Record = {}; - for (let dimIdx = 0; dimIdx < dimensions; dimIdx++) { - const dimensionName = elementNames[dimIdx]!; - token[dimensionName] = snapshot.buffer[globalIndex + dimIdx]!; - } - return token; - }, - ); + const placeTokens = placeTokenIndices.map((tokenIndexInPlace) => { + // Offset within the global buffer + const globalIndex = + placeOffsetInBuffer + tokenIndexInPlace * dimensions; + + return decodeTokenRecord( + elements, + snapshot.buffer.subarray(globalIndex, globalIndex + dimensions), + simulation.tokenValueCodec.snapshot(), + ); + }); tokenCombinationValues[inputPlace.placeName] = placeTokens; } @@ -224,18 +227,28 @@ export function computePossibleTransition( const tokenArrays: number[][] = []; for (const token of outputTokens) { const values: number[] = []; - for (const elementName of outputPlace.elementNames) { - const raw = token[elementName]!; + for (const element of outputPlace.elements ?? []) { + let raw = token[element.name]; if (isDistribution(raw)) { + if (element.type !== "real" && element.type !== "integer") { + throw new Error( + `Transition ${transition.id} produced a distribution for discrete element ${element.name}.`, + ); + } const [sampled, nextRng] = sampleDistribution( raw, currentRngState, ); currentRngState = nextRng; - values.push(sampled); - } else { - values.push(raw); + raw = sampled; } + values.push( + simulation.tokenValueCodec.encode( + element, + raw, + `Transition ${transition.id} output ${outputPlace.placeName}.${element.name}`, + ), + ); } tokenArrays.push(values); } diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/execute-transitions.test.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/execute-transitions.test.ts index f680694ac67..f2106392644 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/execute-transitions.test.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/execute-transitions.test.ts @@ -8,6 +8,7 @@ import { type EngineFrameSnapshot, } from "../frames/internal-frame"; import { executeTransitions as executeEngineTransitions } from "./execute-transitions"; +import { TokenValueCodec } from "./token-values"; import type { Color, Place, Transition } from "../../types/sdcpn"; import type { @@ -112,6 +113,14 @@ function makeCompiledTransitions({ null ); }; + const getElements = (placeId: string) => { + const place = placesMap.get(placeId); + if (!place?.colorId) { + return null; + } + + return typesMap.get(place.colorId)?.elements ?? null; + }; return new Map( transitions.map((transition) => { @@ -132,12 +141,14 @@ function makeCompiledTransitions({ weight: arc.weight, arcType: arc.type, elementNames: getElementNames(arc.placeId), + elements: getElements(arc.placeId), })), outputPlaces: transition.outputArcs.map((arc) => ({ placeId: arc.placeId, placeName: placesMap.get(arc.placeId)?.name ?? arc.placeId, weight: arc.weight, elementNames: getElementNames(arc.placeId), + elements: getElements(arc.placeId), })), lambdaFn, transitionKernelFn, @@ -181,6 +192,7 @@ function makeSimulation({ transitionKernelFns, }), parameterValues: {}, + tokenValueCodec: new TokenValueCodec(), dt: 0.1, maxTime: null, currentTime: 0, diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/token-values.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/token-values.ts new file mode 100644 index 00000000000..8bb05f5ab89 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/token-values.ts @@ -0,0 +1,185 @@ +import type { + Color, + ColorElementType, + TokenAttributeValue, + TokenRecord, +} from "../../types/sdcpn"; + +export const NIL_UUID = "00000000-0000-0000-0000-000000000000"; + +export type EncodedDiscreteValues = readonly string[]; + +type ColorElement = Color["elements"][number]; + +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu; + +export function defaultTokenAttributeValue( + type: ColorElementType, +): TokenAttributeValue { + switch (type) { + case "boolean": + return false; + case "uuid": + return NIL_UUID; + case "integer": + case "real": + return 0; + } +} + +function coerceNumber(value: unknown, context: string): number { + const numberValue = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(numberValue)) { + throw new Error(`${context} must be a finite number.`); + } + return numberValue; +} + +function coerceBoolean(value: unknown, context: string): boolean { + if (typeof value === "boolean") { + return value; + } + if (typeof value === "number") { + return value !== 0; + } + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (normalized === "true" || normalized === "1") { + return true; + } + if (normalized === "false" || normalized === "0" || normalized === "") { + return false; + } + } + throw new Error(`${context} must be a boolean.`); +} + +function coerceUuid(value: unknown, context: string): string { + if (value === undefined || value === null || value === "") { + return NIL_UUID; + } + if (typeof value !== "string" || !UUID_RE.test(value)) { + throw new Error(`${context} must be a UUID string.`); + } + return value.toLowerCase(); +} + +export function coerceTokenAttributeValue( + element: ColorElement, + value: unknown, + context: string, +): TokenAttributeValue { + const rawValue = value ?? defaultTokenAttributeValue(element.type); + switch (element.type) { + case "real": + return coerceNumber(rawValue, context); + case "integer": + return Math.round(coerceNumber(rawValue, context)); + case "boolean": + return coerceBoolean(rawValue, context); + case "uuid": + return coerceUuid(rawValue, context); + } +} + +export function coerceTokenRecord( + source: Record, + elements: readonly ColorElement[], + context: string, +): TokenRecord { + const token: TokenRecord = {}; + for (const element of elements) { + token[element.name] = coerceTokenAttributeValue( + element, + source[element.name], + `${context}.${element.name}`, + ); + } + return token; +} + +export function decodeTokenAttributeValue( + element: ColorElement, + encodedValue: number, + encodedValues?: EncodedDiscreteValues, +): TokenAttributeValue { + switch (element.type) { + case "real": + return encodedValue; + case "integer": + return Math.round(encodedValue); + case "boolean": + return encodedValue !== 0; + case "uuid": + return encodedValue === 0 + ? NIL_UUID + : (encodedValues?.[encodedValue - 1] ?? NIL_UUID); + } +} + +export class TokenValueCodec { + readonly #uuidToCode = new Map(); + readonly #uuids: string[] = []; + + constructor(encodedValues?: EncodedDiscreteValues) { + for (const value of encodedValues ?? []) { + if (value === NIL_UUID) { + continue; + } + this.#uuidToCode.set(value, this.#uuids.length + 1); + this.#uuids.push(value); + } + } + + encode(element: ColorElement, value: unknown, context: string): number { + const coerced = coerceTokenAttributeValue(element, value, context); + + switch (element.type) { + case "real": + case "integer": + return coerced as number; + case "boolean": + return coerced ? 1 : 0; + case "uuid": { + const uuid = coerced as string; + if (uuid === NIL_UUID) { + return 0; + } + const existing = this.#uuidToCode.get(uuid); + if (existing !== undefined) { + return existing; + } + const nextCode = this.#uuids.length + 1; + this.#uuidToCode.set(uuid, nextCode); + this.#uuids.push(uuid); + return nextCode; + } + } + } + + decode(element: ColorElement, encodedValue: number): TokenAttributeValue { + return decodeTokenAttributeValue(element, encodedValue, this.snapshot()); + } + + snapshot(): EncodedDiscreteValues | undefined { + return this.#uuids.length > 0 ? [...this.#uuids] : undefined; + } +} + +export function decodeTokenRecord( + elements: readonly ColorElement[], + encodedValues: ArrayLike, + discreteValues?: EncodedDiscreteValues, +): TokenRecord { + const token: TokenRecord = {}; + for (let index = 0; index < elements.length; index++) { + const element = elements[index]!; + token[element.name] = decodeTokenAttributeValue( + element, + encodedValues[index] ?? 0, + discreteValues, + ); + } + return token; +} diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/types.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/types.ts index 488a9e4af27..b94d3637065 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/types.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/types.ts @@ -5,10 +5,18 @@ * part of the public simulation API. */ -import type { Color, Place, SDCPN, Transition } from "../../types/sdcpn"; +import type { + Color, + Place, + SDCPN, + TokenAttributeValue, + TokenRecord, + Transition, +} from "../../types/sdcpn"; import type { InitialMarking } from "../api"; import type { RuntimeDistribution } from "../authoring/user-code/distribution"; import type { EngineFrame, EngineFrameLayout } from "../frames/internal-frame"; +import type { TokenValueCodec } from "./token-values"; /** * Runtime parameter values used during simulation execution. @@ -29,10 +37,10 @@ export type DifferentialEquationFn = ( numberOfTokens: number, ) => Float64Array; -export type TransitionTokenValues = Record[]>; +export type TransitionTokenValues = Record; export type TransitionKernelOutput = Record< string, - Record[] + Record[] >; /** @@ -60,6 +68,7 @@ export type CompiledTransitionPlace = { placeName: string; weight: number; elementNames: readonly string[] | null; + elements: readonly Color["elements"][number][] | null; }; export type CompiledTransitionInputPlace = CompiledTransitionPlace & { @@ -110,6 +119,8 @@ export type SimulationInstance = { compiledTransitions: Map; /** Resolved parameter values for this simulation run */ parameterValues: ParameterValues; + /** Encodes/decodes discrete token values stored in numeric frame buffers. */ + tokenValueCodec: TokenValueCodec; /** Time step for simulation advancement */ dt: number; /** Maximum simulation time (immutable). Null means no limit. */ 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 82c837108e3..ef48f970874 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/frames/frame-reader.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/frames/frame-reader.ts @@ -1,3 +1,7 @@ +import { + decodeTokenAttributeValue, + type EncodedDiscreteValues, +} from "../engine/token-values"; import { createEngineFrameLayout, readEngineFrame, @@ -5,7 +9,7 @@ import { type EngineFrameLayout, } from "./internal-frame"; -import type { SDCPN } from "../../types/sdcpn"; +import type { SDCPN, TokenRecord } from "../../types/sdcpn"; import type { SimulationFrameReader, SimulationFrameState, @@ -17,6 +21,7 @@ function createSimulationFrameReader( frame: EngineFrame, number: number, time: number, + discreteValues?: EncodedDiscreteValues, ): SimulationFrameReader { const frameView = readEngineFrame(layout, frame); @@ -51,21 +56,25 @@ function createSimulationFrameReader( const { offset, count, dimensions } = placeState; const elements = color?.elements ?? []; - const tokens: Record[] = []; + const tokens: TokenRecord[] = []; if (elements.length === 0 || dimensions === 0 || count === 0) { return tokens; } for (let tokenIndex = 0; tokenIndex < count; tokenIndex++) { - const token: Record = {}; + const token: TokenRecord = {}; const base = offset + tokenIndex * dimensions; for ( let dimensionIndex = 0; dimensionIndex < elements.length && dimensionIndex < dimensions; dimensionIndex++ ) { - token[elements[dimensionIndex]!.name] = - frameView.tokenValues[base + dimensionIndex] ?? 0; + const element = elements[dimensionIndex]!; + token[element.name] = decodeTokenAttributeValue( + element, + frameView.tokenValues[base + dimensionIndex] ?? 0, + discreteValues, + ); } tokens.push(token); } @@ -90,9 +99,14 @@ function createSimulationFrameReader( export function compileSimulationFrameReader( sdcpn: Pick, -): (frame: EngineFrame, number: number, time: number) => SimulationFrameReader { +): ( + frame: EngineFrame, + number: number, + time: number, + discreteValues?: EncodedDiscreteValues, +) => SimulationFrameReader { const layout = createEngineFrameLayout(sdcpn); - return (frame, number, time) => - createSimulationFrameReader(layout, frame, number, time); + return (frame, number, time, discreteValues) => + createSimulationFrameReader(layout, frame, number, time, discreteValues); } 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 e2f0235dece..46613e40ea7 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 @@ -3,6 +3,7 @@ import { isDistribution } from "../authoring/user-code/distribution"; import { enumerateWeightedMarkingIndicesGenerator } from "../engine/enumerate-weighted-markings"; import { sampleDistribution } from "../engine/sample-distribution"; import { nextRandom } from "../engine/seeded-rng"; +import { decodeTokenRecord } from "../engine/token-values"; import { getPlaceIndex, getTransitionIndex } from "./layout"; import type { @@ -82,16 +83,21 @@ export function computeTransitionEffect( inputPlace.placeId, ); } - const elementNames = inputPlace.elementNames; + const elements = inputPlace.elements; + if (!elements) { + throw new SDCPNItemError( + `Place \`${inputPlace.placeName}\` has no type defined`, + inputPlace.placeId, + ); + } tokenValues[inputPlace.placeName] = tokenIndices.map((tokenIndex) => { const tokenOffset = offset + tokenIndex * dimensions; - const token: Record = {}; - for (let dimension = 0; dimension < dimensions; dimension++) { - token[elementNames[dimension]!] = - frame.tokenValues[tokenOffset + dimension]!; - } - return token; + return decodeTokenRecord( + elements, + frame.tokenValues.subarray(tokenOffset, tokenOffset + dimensions), + run.simulation.tokenValueCodec.snapshot(), + ); }); } @@ -155,18 +161,28 @@ export function computeTransitionEffect( const tokenArrays: number[][] = []; for (const token of outputTokens) { const values: number[] = []; - for (const elementName of outputPlace.elementNames) { - const rawValue = token[elementName]!; + for (const element of outputPlace.elements ?? []) { + let rawValue = token[element.name]; if (isDistribution(rawValue)) { + if (element.type !== "real" && element.type !== "integer") { + throw new Error( + `Transition ${transition.id} produced a distribution for discrete element ${element.name}.`, + ); + } const [sampled, nextRngState] = sampleDistribution( rawValue, currentRngState, ); currentRngState = nextRngState; - values.push(sampled); - } else { - values.push(rawValue); + rawValue = sampled; } + values.push( + run.simulation.tokenValueCodec.encode( + element, + rawValue, + `Transition ${transition.id} output ${outputPlace.placeName}.${element.name}`, + ), + ); } if (values.length !== dimensions) { diff --git a/libs/@hashintel/petrinaut-core/src/simulation/runtime/frame-store.ts b/libs/@hashintel/petrinaut-core/src/simulation/runtime/frame-store.ts index c1c18dba4d7..c9969b536c6 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/runtime/frame-store.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/runtime/frame-store.ts @@ -39,11 +39,25 @@ export function createInMemorySimulationFrameStore( latest() { const index = frames.length - 1; const frame = frames[index]; - return frame ? createFrameReader(frame.frame, index, frame.time) : null; + return frame + ? createFrameReader( + frame.frame, + index, + frame.time, + frame.discreteValues, + ) + : null; }, get(index) { const frame = frames[index]; - return frame ? createFrameReader(frame.frame, index, frame.time) : null; + return frame + ? createFrameReader( + frame.frame, + index, + frame.time, + frame.discreteValues, + ) + : null; }, }; } diff --git a/libs/@hashintel/petrinaut-core/src/simulation/worker/frame-payload.ts b/libs/@hashintel/petrinaut-core/src/simulation/worker/frame-payload.ts index fd9c67128f9..126eab305c0 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/worker/frame-payload.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/worker/frame-payload.ts @@ -1,3 +1,4 @@ +import type { EncodedDiscreteValues } from "../engine/token-values"; import type { EngineFrame } from "../frames/internal-frame"; /** @@ -8,14 +9,17 @@ import type { EngineFrame } from "../frames/internal-frame"; export type SimulationFramePayload = { time: number; frame: EngineFrame; + discreteValues?: EncodedDiscreteValues; }; export function framePayloadFromEngineFrame( frame: EngineFrame, time: number, + discreteValues?: EncodedDiscreteValues, ): SimulationFramePayload { return { time, frame, + discreteValues, }; } diff --git a/libs/@hashintel/petrinaut-core/src/simulation/worker/simulation.worker.ts b/libs/@hashintel/petrinaut-core/src/simulation/worker/simulation.worker.ts index cdfc5f915ef..b5583a6f4c5 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/worker/simulation.worker.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/worker/simulation.worker.ts @@ -100,7 +100,11 @@ async function computeLoop(): Promise { simulation = updatedSimulation; const newFrame = simulation.frames[simulation.currentFrameNumber]!; framesToSend.push( - framePayloadFromEngineFrame(newFrame, simulation.currentTime), + framePayloadFromEngineFrame( + newFrame, + simulation.currentTime, + simulation.tokenValueCodec.snapshot(), + ), ); // Check if simulation completed @@ -183,6 +187,7 @@ workerRuntime.onMessage((message) => { frame: framePayloadFromEngineFrame( initialFrame, simulation.currentTime, + simulation.tokenValueCodec.snapshot(), ), }); } diff --git a/libs/@hashintel/petrinaut-core/src/types/sdcpn.ts b/libs/@hashintel/petrinaut-core/src/types/sdcpn.ts index 133a19a7098..3eb2ae656e2 100644 --- a/libs/@hashintel/petrinaut-core/src/types/sdcpn.ts +++ b/libs/@hashintel/petrinaut-core/src/types/sdcpn.ts @@ -1,5 +1,11 @@ export type ID = string; +export type ColorElementType = "real" | "integer" | "boolean" | "uuid"; + +export type TokenAttributeValue = number | boolean | string; + +export type TokenRecord = Record; + export type InputArc = { placeId: string; weight: number; @@ -45,7 +51,7 @@ export type Color = { elements: { elementId: string; name: string; - type: "real" | "integer" | "boolean"; + type: ColorElementType; }[]; }; @@ -100,10 +106,10 @@ export type Scenario = { /** * Per-place initial state. Values are either: * - `string`: expression for uncolored places (evaluates to token count) - * - `number[][]`: token data for colored places (rows × elements) + * - `TokenAttributeValue[][]`: token data for colored places (rows × elements) */ type: "per_place"; - content: Record; + content: Record; } | { /** Single code block that returns the full initial state object. */ diff --git a/libs/@hashintel/petrinaut/src/ui/components/spreadsheet.stories.tsx b/libs/@hashintel/petrinaut/src/ui/components/spreadsheet.stories.tsx index 3d580d571fd..0570b819da6 100644 --- a/libs/@hashintel/petrinaut/src/ui/components/spreadsheet.stories.tsx +++ b/libs/@hashintel/petrinaut/src/ui/components/spreadsheet.stories.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { Spreadsheet } from "./spreadsheet"; -import type { SpreadsheetColumn } from "./spreadsheet"; +import type { SpreadsheetCellValue, SpreadsheetColumn } from "./spreadsheet"; import type { Meta, StoryObj } from "@storybook/react-vite"; const meta = { @@ -22,7 +22,7 @@ const COLUMNS_3: SpreadsheetColumn[] = [ { id: "z", name: "z" }, ]; -const SAMPLE_DATA: number[][] = [ +const SAMPLE_DATA: SpreadsheetCellValue[][] = [ [1, 2, 3], [4, 5, 6], [7, 8, 9], @@ -38,7 +38,7 @@ const InteractiveSpreadsheet = ({ readOnly, }: { columns: SpreadsheetColumn[]; - initialData: number[][]; + initialData: SpreadsheetCellValue[][]; readOnly?: boolean; }) => { const [data, setData] = useState(initialData); diff --git a/libs/@hashintel/petrinaut/src/ui/components/spreadsheet.tsx b/libs/@hashintel/petrinaut/src/ui/components/spreadsheet.tsx index acccd04d02c..1c3f855a90a 100644 --- a/libs/@hashintel/petrinaut/src/ui/components/spreadsheet.tsx +++ b/libs/@hashintel/petrinaut/src/ui/components/spreadsheet.tsx @@ -5,12 +5,15 @@ import { css, cva } from "@hashintel/ds-helpers/css"; export interface SpreadsheetColumn { id: string; name: string; + type?: "real" | "integer" | "boolean" | "uuid"; } +export type SpreadsheetCellValue = number | boolean | string; + export interface SpreadsheetProps { columns: SpreadsheetColumn[]; - data: number[][]; - onChange?: (data: number[][]) => void; + data: SpreadsheetCellValue[][]; + onChange?: (data: SpreadsheetCellValue[][]) => void; } type CellPosition = { @@ -183,6 +186,49 @@ const cellButtonStyle = cva({ }, }); +const booleanCellStyle = css({ + margin: "0", +}); + +const DEFAULT_UUID = "00000000-0000-0000-0000-000000000000"; + +const getDefaultCellValue = ( + column: SpreadsheetColumn | undefined, +): SpreadsheetCellValue => { + switch (column?.type) { + case "boolean": + return false; + case "uuid": + return DEFAULT_UUID; + case "integer": + case "real": + default: + return 0; + } +}; + +const formatCellValue = (value: SpreadsheetCellValue): string => + typeof value === "boolean" ? String(value) : String(value); + +const parseCellValue = ( + column: SpreadsheetColumn | undefined, + rawValue: string, +): SpreadsheetCellValue => { + switch (column?.type) { + case "boolean": { + const normalized = rawValue.trim().toLowerCase(); + return normalized === "true" || normalized === "1"; + } + case "uuid": + return rawValue.trim() || DEFAULT_UUID; + case "integer": + return Math.round(Number.parseFloat(rawValue) || 0); + case "real": + default: + return Number.parseFloat(rawValue) || 0; + } +}; + export const Spreadsheet: React.FC = ({ columns, data, @@ -205,7 +251,7 @@ export const Spreadsheet: React.FC = ({ null, ); const [editingValue, setEditingValue] = useState(""); - const cellRefs = useRef>(new Map()); + const cellRefs = useRef>(new Map()); const inputRef = useRef(null); const selectedRow = @@ -221,12 +267,19 @@ export const Spreadsheet: React.FC = ({ ? editingCellState : null; - const updateCell = (row: number, col: number, value: number) => { - let newData: number[][]; + const createEmptyRow = (): SpreadsheetCellValue[] => + columns.map((column) => getDefaultCellValue(column)); + + const updateCell = ( + row: number, + col: number, + value: SpreadsheetCellValue, + ) => { + let newData: SpreadsheetCellValue[][]; // If editing the phantom row (last row), create a new actual row if (row === tableData.length) { - newData = [...tableData, Array(colCount).fill(0) as number[]]; + newData = [...tableData, createEmptyRow()]; if (newData[row]) { newData[row][col] = value; } @@ -242,8 +295,14 @@ export const Spreadsheet: React.FC = ({ onChange?.(newData); }; + const toggleBooleanCell = (row: number, col: number) => { + const currentValue = + tableData[row]?.[col] ?? getDefaultCellValue(columns[col]); + updateCell(row, col, currentValue !== true); + }; + const removeRow = (rowIndex: number) => { - const newData: number[][] = tableData.filter( + const newData: SpreadsheetCellValue[][] = tableData.filter( (_, index) => index !== rowIndex, ); onChange?.(newData); @@ -300,7 +359,7 @@ export const Spreadsheet: React.FC = ({ if (editingCell && editingCell.row === row && editingCell.col === col) { if (event.key === "Enter") { event.preventDefault(); - const value = Number.parseFloat(editingValue) || 0; + const value = parseCellValue(columns[col], editingValue); updateCell(row, col, value); setEditingCell(null); setEditingValue(""); @@ -322,7 +381,7 @@ export const Spreadsheet: React.FC = ({ } } else if (event.key === "Tab") { event.preventDefault(); - const value = Number.parseFloat(editingValue) || 0; + const value = parseCellValue(columns[col], editingValue); updateCell(row, col, value); setEditingCell(null); setEditingValue(""); @@ -370,6 +429,36 @@ export const Spreadsheet: React.FC = ({ return; } + if (columns[col]?.type === "boolean") { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + toggleBooleanCell(row, col); + setSelectedRow(null); + return; + } + + if (event.key === "Delete" || event.key === "Backspace") { + event.preventDefault(); + updateCell(row, col, false); + setSelectedRow(null); + return; + } + + const normalizedKey = event.key.toLowerCase(); + if (normalizedKey === "t" || normalizedKey === "1") { + event.preventDefault(); + updateCell(row, col, true); + setSelectedRow(null); + return; + } + if (normalizedKey === "f" || normalizedKey === "0") { + event.preventDefault(); + updateCell(row, col, false); + setSelectedRow(null); + return; + } + } + // Navigation keys when not editing if (event.key === "ArrowRight" && col < colCount - 1) { event.preventDefault(); @@ -448,21 +537,25 @@ export const Spreadsheet: React.FC = ({ } else if (event.key === "Enter") { event.preventDefault(); setEditingCell({ row, col }); - setEditingValue(String(tableData[row]?.[col] ?? 0)); + setEditingValue( + formatCellValue( + tableData[row]?.[col] ?? getDefaultCellValue(columns[col]), + ), + ); setTimeout(() => inputRef.current?.focus(), 0); } else if (event.key === "Delete") { event.preventDefault(); if (selectedRow !== null) { removeRow(selectedRow); } else { - updateCell(row, col, 0); + updateCell(row, col, getDefaultCellValue(columns[col])); setEditingCell({ row, col }); setEditingValue(""); setTimeout(() => inputRef.current?.focus(), 0); } } else if (event.key === "Backspace") { event.preventDefault(); - updateCell(row, col, 0); + updateCell(row, col, getDefaultCellValue(columns[col])); setEditingCell({ row, col }); setEditingValue(""); setTimeout(() => inputRef.current?.focus(), 0); @@ -573,14 +666,14 @@ export const Spreadsheet: React.FC = ({ {(() => { const displayRows = isReadOnly ? tableData - : [...tableData, Array(colCount).fill(0) as number[]]; + : [...tableData, createEmptyRow()]; return displayRows.map((row, rowIndex) => { const isPhantomRow = !isReadOnly && rowIndex === tableData.length; return ( = ({ > {isReadOnly ? (
- {isPhantomRow ? "" : value} + {isPhantomRow ? "" : formatCellValue(value)}
) : isEditing ? ( setEditingValue(event.target.value) @@ -631,14 +733,56 @@ export const Spreadsheet: React.FC = ({ handleKeyDown(event, rowIndex, colIndex) } onBlur={() => { - const val = - Number.parseFloat(editingValue) || 0; + const val = parseCellValue( + columns[colIndex], + editingValue, + ); updateCell(rowIndex, colIndex, val); setEditingCell(null); setEditingValue(""); }} className={editingInputStyle} /> + ) : columns[colIndex]?.type === "boolean" ? ( +
{ + if (el) { + cellRefs.current.set( + `${rowIndex}-${colIndex}`, + el, + ); + } else { + cellRefs.current.delete( + `${rowIndex}-${colIndex}`, + ); + } + }} + role="checkbox" + aria-checked={Boolean(value)} + tabIndex={0} + onFocus={() => { + setFocusedCell({ + row: rowIndex, + col: colIndex, + }); + setSelectedRow(null); + }} + onKeyDown={(event) => + handleKeyDown(event, rowIndex, colIndex) + } + onClick={() => + toggleBooleanCell(rowIndex, colIndex) + } + className={cellButtonStyle({ isFocused })} + > + +
) : (
{ @@ -667,7 +811,7 @@ export const Spreadsheet: React.FC = ({ } className={cellButtonStyle({ isFocused })} > - {isPhantomRow ? "" : value} + {isPhantomRow ? "" : formatCellValue(value)}
)} diff --git a/libs/@hashintel/petrinaut/src/ui/lib/compile-visualizer.ts b/libs/@hashintel/petrinaut/src/ui/lib/compile-visualizer.ts index 28a31e49fe8..d57b711f8ac 100644 --- a/libs/@hashintel/petrinaut/src/ui/lib/compile-visualizer.ts +++ b/libs/@hashintel/petrinaut/src/ui/lib/compile-visualizer.ts @@ -2,7 +2,7 @@ import * as Babel from "@babel/standalone"; import { createElement, type ReactElement } from "react"; type VisualizerProps = { - tokens: Record[]; + tokens: Record[]; parameters: Record; }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-initial-state/initial-state-editor.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-initial-state/initial-state-editor.tsx index 3abc0c3ce7a..6a95c6f62f5 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-initial-state/initial-state-editor.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-initial-state/initial-state-editor.tsx @@ -4,22 +4,40 @@ import { PlaybackContext } from "../../../../../../../../react/playback/context" import { SimulationContext } from "../../../../../../../../react/simulation/context"; import { Spreadsheet } from "../../../../../../../components/spreadsheet"; -import type { SpreadsheetColumn } from "../../../../../../../components/spreadsheet"; -import type { Color } from "@hashintel/petrinaut-core"; +import type { + SpreadsheetCellValue, + SpreadsheetColumn, +} from "../../../../../../../components/spreadsheet"; +import type { Color, Place, TokenRecord } from "@hashintel/petrinaut-core"; + +const DEFAULT_UUID = "00000000-0000-0000-0000-000000000000"; + +const getDefaultValue = (column: SpreadsheetColumn): SpreadsheetCellValue => { + switch (column.type) { + case "boolean": + return false; + case "uuid": + return DEFAULT_UUID; + case "integer": + case "real": + default: + return 0; + } +}; /** * InitialStateEditor - A component for editing initial tokens in a place * Stores data in SimulationStore, not in the Place definition */ interface InitialStateEditorProps { - placeId: string; + place: Place; placeType: Color; /** Force read-only mode (e.g. when state is defined by a scenario). */ readOnly?: boolean; } export const InitialStateEditor: React.FC = ({ - placeId, + place, placeType, readOnly = false, }) => { @@ -34,41 +52,39 @@ export const InitialStateEditor: React.FC = ({ placeType.elements.map((element) => ({ id: element.elementId, name: element.name, + type: element.type, })), [placeType.elements], ); // Convert current frame data or serializable initial marking to spreadsheet rows. - const data: number[][] = useMemo(() => { + const data: SpreadsheetCellValue[][] = useMemo(() => { if (hasSimulation && currentFrameReader) { - const currentMarking = currentFrameReader.getPlaceTokenValues(placeId); - if (!currentMarking || currentMarking.count === 0) { - return []; - } - - const dimensions = columns.length; - const tokens: number[][] = []; - for (let i = 0; i < currentMarking.count; i++) { - const tokenValues: number[] = []; - for (let colIndex = 0; colIndex < dimensions; colIndex++) { - tokenValues.push( - currentMarking.values[i * dimensions + colIndex] ?? 0, - ); - } - tokens.push(tokenValues); - } - return tokens; + return currentFrameReader + .getPlaceTokens(place, placeType) + .map((token) => + columns.map( + (column) => token[column.name] ?? getDefaultValue(column), + ), + ); } - const marking = initialMarking[placeId]; + const marking = initialMarking[place.id]; if (!Array.isArray(marking)) { return []; } return marking.map((token) => - columns.map((column) => token[column.name] ?? 0), + columns.map((column) => token[column.name] ?? getDefaultValue(column)), ); - }, [hasSimulation, currentFrameReader, placeId, columns, initialMarking]); + }, [ + hasSimulation, + currentFrameReader, + place, + placeType, + columns, + initialMarking, + ]); // Convert spreadsheet rows back to serializable token records. const handleChange = useMemo(() => { @@ -76,15 +92,19 @@ export const InitialStateEditor: React.FC = ({ return undefined; } - return (newData: number[][]) => { - const tokens = newData.map((row) => - Object.fromEntries( - columns.map((column, col) => [column.name, row[col] ?? 0]), - ), + return (newData: SpreadsheetCellValue[][]) => { + const tokens: TokenRecord[] = newData.map( + (row) => + Object.fromEntries( + columns.map((column, col) => [ + column.name, + row[col] ?? getDefaultValue(column), + ]), + ) as TokenRecord, ); - setInitialMarking(placeId, tokens); + setInitialMarking(place.id, tokens); }; - }, [hasSimulation, readOnly, columns, setInitialMarking, placeId]); + }, [hasSimulation, readOnly, columns, setInitialMarking, place.id]); return ; }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-initial-state/subview.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-initial-state/subview.tsx index 0102d13738c..3c054bd0ecb 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-initial-state/subview.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-initial-state/subview.tsx @@ -108,7 +108,7 @@ const PlaceInitialStateContent: React.FC = () => { return ( @@ -173,11 +173,7 @@ const PlaceInitialStateContent: React.FC = () => { } return ( - + ); }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-visualizer/subview.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-visualizer/subview.tsx index 3213e757990..2776ad6775a 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-visualizer/subview.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/place-properties/subviews/place-visualizer/subview.tsx @@ -5,6 +5,7 @@ import { css } from "@hashintel/ds-helpers/css"; import { DEFAULT_VISUALIZER_CODE, generateDefaultVisualizerCode, + type TokenRecord, } from "@hashintel/petrinaut-core"; import { @@ -106,44 +107,17 @@ const VisualizerPreview: React.FC = () => { return
Place has no type set
; } - const dimensions = placeType.elements.length; - const tokens: Record[] = []; + const tokens: TokenRecord[] = []; let parameters: Record = {}; if (totalFrames > 0 && currentFrameReader) { - const placeTokenValues = currentFrameReader.getPlaceTokenValues(place.id); - if (!placeTokenValues) { - return
Place not found in frame
; - } - - const tokenValues = Array.from(placeTokenValues.values); - - for ( - let tokenIndex = 0; - tokenIndex < placeTokenValues.count; - tokenIndex++ - ) { - const token: Record = {}; - for (let colIndex = 0; colIndex < dimensions; colIndex++) { - const dimensionName = placeType.elements[colIndex]!.name; - token[dimensionName] = - tokenValues[tokenIndex * dimensions + colIndex] ?? 0; - } - tokens.push(token); - } + tokens.push(...currentFrameReader.getPlaceTokens(place, placeType)); parameters = mergeParameterValues(parameterValues, defaultParameterValues); } else { const marking = initialMarking[place.id]; if (Array.isArray(marking) && marking.length > 0) { - for (let tokenIndex = 0; tokenIndex < marking.length; tokenIndex++) { - const token: Record = {}; - for (let colIndex = 0; colIndex < dimensions; colIndex++) { - const dimensionName = placeType.elements[colIndex]!.name; - token[dimensionName] = marking[tokenIndex]?.[dimensionName] ?? 0; - } - tokens.push(token); - } + tokens.push(...marking); } parameters = mergeParameterValues(parameterValues, defaultParameterValues); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/type-properties/subviews/main.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/type-properties/subviews/main.tsx index 0e7967e9a16..236f36fddd8 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/type-properties/subviews/main.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/type-properties/subviews/main.tsx @@ -3,12 +3,16 @@ import { v4 as uuidv4 } from "uuid"; import { Button, Tooltip } from "@hashintel/ds-components"; import { css, cva } from "@hashintel/ds-helpers/css"; -import { validateDisplayName } from "@hashintel/petrinaut-core"; +import { + validateDisplayName, + type ColorElementType, +} from "@hashintel/petrinaut-core"; import { useIsReadOnly } from "../../../../../../../react/state/use-is-read-only"; import { DraftFieldInput } from "../../../../../../components/draft-field-input"; import { Input } from "../../../../../../components/input"; import { Section, SectionList } from "../../../../../../components/section"; +import { Select, type SelectOption } from "../../../../../../components/select"; import { TokenTypeIcon } from "../../../../../../constants/entity-icons"; import { UI_MESSAGES } from "../../../../../../constants/ui-messages"; import { ColorSelect } from "../color-select"; @@ -110,6 +114,12 @@ const indexChipStyle = css({ const dimensionNameInputStyle = css({ fontSize: "sm", flex: "[1]", + minWidth: "[0]", +}); + +const dimensionTypeSelectStyle = css({ + width: "[96px]", + flexShrink: 0, }); type ElementNameInputState = Record< @@ -117,6 +127,13 @@ type ElementNameInputState = Record< { sourceName: string; value: string } >; +const typeOptions: SelectOption[] = [ + { value: "real", label: "Real" }, + { value: "integer", label: "Int" }, + { value: "boolean", label: "Bool" }, + { value: "uuid", label: "UUID" }, +]; + const slugifyToIdentifier = (input: string): string => { let slug = input .toLowerCase() @@ -239,6 +256,17 @@ const TypeMainContent: React.FC = () => { }); }; + const handleUpdateElementType = ( + elementId: string, + elementType: ColorElementType, + ) => { + updateTypeElement({ + typeId: type.id, + elementId, + update: { type: elementType }, + }); + }; + const handleDragStart = (index: number) => { setDraggedIndex(index); }; @@ -311,7 +339,7 @@ const TypeMainContent: React.FC = () => {
(