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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/h-6519-discrete-token-attribute-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@hashintel/petrinaut": patch
"@hashintel/petrinaut-core": patch
---

Add discrete token attribute types to Petrinaut.
8 changes: 4 additions & 4 deletions libs/@hashintel/petrinaut-core/src/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 \`{ <elementName>: 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 \`{ <elementName>: 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 }) => <JSX/>)\`. Classic React runtime β€” do NOT import React, do NOT use \`<>…</>\` fragments, do NOT use hooks. Convention: return a sized \`<svg viewBox="0 0 W H">…</svg>\`.
- 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.<variableName>\` where \`<variableName>\` is the parameter's lower_snake_case \`variableName\` value (e.g. \`parameters.crash_threshold\`, never \`parameters.crashThreshold\`).

Expand Down
31 changes: 24 additions & 7 deletions libs/@hashintel/petrinaut-core/src/default-codes.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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 ")}
],`,
Expand Down
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand All @@ -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<string, never>";
}

/**
Expand Down Expand Up @@ -66,6 +91,7 @@ export function generateVirtualFiles(sdcpn: SDCPN): Map<string, VirtualFile> {
// 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,
});
Expand All @@ -90,7 +116,10 @@ export function generateVirtualFiles(sdcpn: SDCPN): Map<string, VirtualFile> {
sanitizedColorId
? `type Tokens = Array<Color_${sanitizedColorId}>;`
: `type Tokens = Array<number>;`,
`export type Dynamics = (fn: (tokens: Tokens, parameters: Parameters) => Tokens) => void;`,
color
? `type Derivative = ${toDynamicsDerivativeType(color)};`
: `type Derivative = Record<string, never>;`,
`export type Dynamics = (fn: (tokens: Tokens, parameters: Parameters) => Derivative[]) => void;`,
].join("\n"),
});

Expand Down Expand Up @@ -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<string, number>[]`.
// 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[] = [];
Expand All @@ -448,10 +477,10 @@ export function generateMetricSessionFiles(
}
tokensType = `Color_${sanitized}[]`;
} else {
tokensType = "Record<string, number>[]";
tokensType = "Record<string, number | boolean | string>[]";
}
} else {
tokensType = "Record<string, number>[]";
tokensType = "Record<string, number | boolean | string>[]";
}
placeStateProperties.push(
` "${place.name}": { count: number; tokens: ${tokensType} };`,
Expand All @@ -461,7 +490,7 @@ export function generateMetricSessionFiles(
const placesType =
placeStateProperties.length > 0
? `{\n${placeStateProperties.join("\n")}\n}`
: "Record<string, { count: number; tokens: Record<string, number>[] }>";
: "Record<string, { count: number; tokens: Record<string, number | boolean | string>[] }>";

// defs file (kept separate so updates only invalidate code on real changes)
const defsPath = getItemFilePath("metric-session-defs", { sessionId });
Expand Down
16 changes: 8 additions & 8 deletions libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `{ <name> }`, 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({
Expand Down Expand Up @@ -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(" "),
}),
Expand Down Expand Up @@ -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({
Expand All @@ -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<DifferentialEquation>;

export const parameterSchema = z
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(" "),
Expand Down
11 changes: 8 additions & 3 deletions libs/@hashintel/petrinaut-core/src/schemas/scenario-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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(" "),
}),
Expand All @@ -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(" "),
}),
Expand Down
Loading
Loading