Skip to content

Commit dabfc56

Browse files
kubeclaude
andcommitted
FE-769: String token dimension type
Plain JS strings in user code, stored via per-run interning: frame buffers hold u64 references into an append-only pool owned by the simulation; interactive runs ship newStrings deltas with frame payloads. Editing a type's schema now migrates stored initial state (scenario rows and session marking) with value coercion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 5a8b38e commit dabfc56

55 files changed

Lines changed: 2460 additions & 106 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@hashintel/petrinaut": patch
3+
"@hashintel/petrinaut-core": patch
4+
---
5+
6+
Add the `string` token attribute type, stored via per-run interning. Editing a type's schema now migrates stored initial state (values convert, falling back to the new type's default).

libs/@hashintel/petrinaut-core/docs/architecture/engine.html

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -708,6 +708,17 @@ <h2>Token memory layout — packed structs</h2>
708708
optional — omitted values auto-generate from the seeded RNG.
709709
</td>
710710
</tr>
711+
<tr>
712+
<td><code>string</code> (FE-769)</td>
713+
<td><code>u64</code> pool reference — 8 B</td>
714+
<td>
715+
The frame stores an ID into an append-only per-run string intern
716+
pool (<code>engine/string-pool.ts</code>); the pool lives on
717+
<code>SimulationInstance</code>, not on the frame, so frames stay
718+
fixed-stride and byte-copyable. Equal strings share one ID; id 0 is
719+
the pre-seeded <code>""</code>, so zeroed buffers decode cleanly.
720+
</td>
721+
</tr>
711722
</tbody>
712723
</table>
713724
<ul>
@@ -726,7 +737,11 @@ <h2>Token memory layout — packed structs</h2>
726737
only code that indexes token bytes; all whole-token moves are byte-range
727738
copies (<code>Uint8Array.set</code>). The raw
728739
<code>getPlaceTokenValues</code> reader was removed from the public API
729-
— raw f64 access is meaningless under mixed widths.
740+
— raw f64 access is meaningless under mixed widths. Because the string
741+
pool never crosses the worker boundary with the frames, each frame
742+
payload ships an append-only <code>newStrings</code> delta that the
743+
main-thread frame store accumulates and hands to the frame reader for
744+
decoding.
730745
</li>
731746
</ul>
732747

libs/@hashintel/petrinaut-core/src/actions.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
stripDisabledExtensionData,
3333
type PetrinautExtensionSettings,
3434
} from "./extensions";
35+
import { migrateScenarioRowsForTypeEdit } from "./schema-migration";
3536

3637
import type {
3738
ArcEndpoint,
@@ -768,6 +769,10 @@ export function createPetrinautActions(
768769
if (type.id === parsed.typeId) {
769770
type.elements.push(parsed.element);
770771
colorSchema.parse(type);
772+
migrateScenarioRowsForTypeEdit(sdcpn, parsed.typeId, {
773+
kind: "add",
774+
element: parsed.element,
775+
});
771776
break;
772777
}
773778
}
@@ -782,10 +787,21 @@ export function createPetrinautActions(
782787
const net = resolveTargetNet(sdcpn, parsed.targetSubnetId);
783788
for (const type of net.types) {
784789
if (type.id === parsed.typeId) {
785-
for (const element of type.elements) {
790+
for (const [index, element] of type.elements.entries()) {
786791
if (element.elementId === parsed.elementId) {
792+
const previousElementType = element.type;
787793
Object.assign(element, parsed.update);
788794
colorSchema.parse(type);
795+
if (
796+
parsed.update.type !== undefined &&
797+
parsed.update.type !== previousElementType
798+
) {
799+
migrateScenarioRowsForTypeEdit(sdcpn, parsed.typeId, {
800+
kind: "changeType",
801+
index,
802+
element,
803+
});
804+
}
789805
break;
790806
}
791807
}
@@ -807,6 +823,10 @@ export function createPetrinautActions(
807823
if (element.elementId === parsed.elementId) {
808824
type.elements.splice(index, 1);
809825
colorSchema.parse(type);
826+
migrateScenarioRowsForTypeEdit(sdcpn, parsed.typeId, {
827+
kind: "remove",
828+
index,
829+
});
810830
break;
811831
}
812832
}
@@ -834,6 +854,18 @@ export function createPetrinautActions(
834854
if (element) {
835855
type.elements.splice(parsed.toIndex, 0, element);
836856
colorSchema.parse(type);
857+
// Use the actual landing index (splice clamps out-of-range
858+
// destinations to the end of the array).
859+
const toIndex = type.elements.findIndex(
860+
(candidate) => candidate.elementId === parsed.elementId,
861+
);
862+
if (toIndex !== fromIndex) {
863+
migrateScenarioRowsForTypeEdit(sdcpn, parsed.typeId, {
864+
kind: "move",
865+
fromIndex,
866+
toIndex,
867+
});
868+
}
837869
}
838870
break;
839871
}

libs/@hashintel/petrinaut-core/src/ai.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -269,12 +269,12 @@ Validate every code-writing change. After any tool call that writes code — lam
269269
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.
270270
271271
Code-surface cheatsheet (exact shapes expected by the runtime):
272-
- Transition lambda (\`transition.lambdaCode\`): \`export default Lambda((input, parameters) => …)\`. Available when stochasticity is enabled OR when colours are enabled and the transition has at least one standard or read input arc from a coloured place. \`input.PlaceName\` is a tuple sized to the input arc weight for coloured standard and read input arcs; token attributes are typed by the colour element: real/integer → number, boolean → boolean, uuid → bigint. Read arcs expose tokens in \`input\` but do not consume them when the transition fires. Inhibitor arcs and uncoloured input places are NOT in \`input\`. Predicate → boolean; stochastic → non-negative rate in firings per simulation second (0 disables, Infinity always fires). Must be deterministic. If unavailable or empty, the runtime uses true for predicate-style transitions and Infinity for stochastic-style transitions.
273-
- Transition kernel (\`transition.transitionKernelCode\`): \`export default TransitionKernel((input, parameters) => …)\`. Available only for transitions with coloured output places. Return \`{ OutputPlaceName: [token, …] }\` sized to the output arc weight. Include only coloured output places; uncoloured output places are auto-populated. Output values must match element types: real/integer use numbers, boolean uses booleans. uuid attributes are OPTIONAL in output tokens: omit them to auto-generate a fresh UUID deterministically from the seeded simulation RNG, use \`Uuid.generate()\` for an explicit fresh UUID, \`Uuid.from(value)\` to derive one from any value, or forward an input token's uuid bigint unchanged; plain non-UUID values (numbers, arbitrary strings) are converted deterministically via UUIDv5. When stochasticity is enabled, real attributes may use \`Distribution.Gaussian(mean, sd)\` / \`Distribution.Uniform(min, max)\` / \`Distribution.Lognormal(mu, sigma)\` (never integer/boolean/uuid attributes), and chained \`.map(fn)\` on the same distribution shares one draw. When stochasticity is disabled, kernel outputs must use plain values only. Leave empty when no coloured outputs exist.
274-
- 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\`.
272+
- Transition lambda (\`transition.lambdaCode\`): \`export default Lambda((input, parameters) => …)\`. Available when stochasticity is enabled OR when colours are enabled and the transition has at least one standard or read input arc from a coloured place. \`input.PlaceName\` is a tuple sized to the input arc weight for coloured standard and read input arcs; token attributes are typed by the colour element: real/integer → number, boolean → boolean, uuid → bigint, string → string (plain JS strings everywhere, compared by value). Read arcs expose tokens in \`input\` but do not consume them when the transition fires. Inhibitor arcs and uncoloured input places are NOT in \`input\`. Predicate → boolean; stochastic → non-negative rate in firings per simulation second (0 disables, Infinity always fires). Must be deterministic. If unavailable or empty, the runtime uses true for predicate-style transitions and Infinity for stochastic-style transitions.
273+
- Transition kernel (\`transition.transitionKernelCode\`): \`export default TransitionKernel((input, parameters) => …)\`. Available only for transitions with coloured output places. Return \`{ OutputPlaceName: [token, …] }\` sized to the output arc weight. Include only coloured output places; uncoloured output places are auto-populated. Output values must match element types: real/integer use numbers, boolean uses booleans, string uses plain strings (REQUIRED in the token type; a missing/undefined value becomes the empty string \`""\`, and non-string values are stringified via \`String(value)\`). uuid attributes are OPTIONAL in output tokens: omit them to auto-generate a fresh UUID deterministically from the seeded simulation RNG, use \`Uuid.generate()\` for an explicit fresh UUID, \`Uuid.from(value)\` to derive one from any value, or forward an input token's uuid bigint unchanged; plain non-UUID values (numbers, arbitrary strings) are converted deterministically via UUIDv5. When stochasticity is enabled, real attributes may use \`Distribution.Gaussian(mean, sd)\` / \`Distribution.Uniform(min, max)\` / \`Distribution.Lognormal(mu, sigma)\` (never integer/boolean/uuid/string attributes), and chained \`.map(fn)\` on the same distribution shares one draw. When stochasticity is disabled, kernel outputs must use plain values only. Leave empty when no coloured outputs exist.
274+
- 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, uuid, and string elements are discrete and remain unchanged by dynamics (they can be read from input tokens but never written). The equation's \`colorId\` MUST match every referencing place's \`colorId\`.
275275
- 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>\`.
276276
- 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.
277-
- Scenario per_place initial state: \`content\` keys are place IDs; uncoloured values are expressions with \`parameters\` and \`scenario\` in scope; coloured values are row arrays in colour element order using numbers and booleans; uuid columns accept UUID strings (any other text converts deterministically to a UUID via UUIDv5).
277+
- Scenario per_place initial state: \`content\` keys are place IDs; uncoloured values are expressions with \`parameters\` and \`scenario\` in scope; coloured values are row arrays in colour element order using numbers and booleans; string columns take literal text; uuid columns accept UUID strings (any other text converts deterministically to a UUID via UUIDv5).
278278
- Scenario code-mode initial state: function body returning \`{ PlaceName: tokens }\` keyed by NAME (asymmetric with per_place IDs); unknown names are silently dropped.
279279
- 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\`).
280280

libs/@hashintel/petrinaut-core/src/clipboard/serialize.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -494,7 +494,7 @@ describe("parseClipboardPayload", () => {
494494
name: "Token",
495495
iconSlug: "circle",
496496
displayColor: "#FF0000",
497-
elements: [{ elementId: "e1", name: "val", type: "string" }],
497+
elements: [{ elementId: "e1", name: "val", type: "complex" }],
498498
},
499499
],
500500
differentialEquations: [],

libs/@hashintel/petrinaut-core/src/default-codes.ts

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,23 @@ const defaultTokenAttributeSource = (
99
case "integer":
1010
case "real":
1111
return "0";
12+
case "string":
13+
return '""';
1214
case "uuid":
1315
return "Uuid.generate()";
1416
}
1517
};
1618

1719
export function generateDefaultVisualizerCode(type: Color): string {
18-
return `// This function defines how to visualize the tokens in the place of type "${type.name}".
20+
return `// This function defines how to visualize the tokens in the place of type "${
21+
type.name
22+
}".
1923
// It receives the current tokens and parameters.
2024
export default Visualization(({ tokens, parameters }) => {
2125
return <svg viewBox="0 0 800 600">
22-
{tokens.map(({ ${type.elements.map((el) => el.name).join(", ")} }, index) => (
26+
{tokens.map(({ ${type.elements
27+
.map((el) => el.name)
28+
.join(", ")} }, index) => (
2329
// Example: simple circle for each token
2430
<circle />
2531
))}
@@ -47,7 +53,9 @@ export function generateDefaultDifferentialEquationCode(type: Color): string {
4753
}; // Example: all real-valued derivatives = 1`
4854
: `{}; // This type has no real-valued attributes; discrete values are unchanged by dynamics`;
4955

50-
return `// This function defines the differential equation for the place of type "${type.name}".
56+
return `// This function defines the differential equation for the place of type "${
57+
type.name
58+
}".
5159
// The function receives the current tokens in this place and the parameters.
5260
// It should return derivatives for real-valued token attributes in this place.
5361
export default Dynamics((tokens, parameters) => {
@@ -90,7 +98,11 @@ export default Lambda((tokensByPlace, parameters) => {
9098
// 2. Infinity means always enabled
9199
// 3. Any other number is the average rate per second
92100
93-
${lambdaType === "predicate" ? "return true; // Always enabled (alternative: return Infinity;)" : "return 1.0; // Average firing rate of once per second"}
101+
${
102+
lambdaType === "predicate"
103+
? "return true; // Always enabled (alternative: return Infinity;)"
104+
: "return 1.0; // Average firing rate of once per second"
105+
}
94106
});`;
95107

96108
export function generateDefaultTransitionKernelCode(
@@ -117,7 +129,9 @@ export default TransitionKernel((tokensByPlace, parameters) => {
117129
${Array.from({ length: arc.weight })
118130
.map(
119131
() =>
120-
`{ ${arc.type.elements.map((el) => `${el.name}: ${defaultTokenAttributeSource(el)}`).join(", ")} }`,
132+
`{ ${arc.type.elements
133+
.map((el) => `${el.name}: ${defaultTokenAttributeSource(el)}`)
134+
.join(", ")} }`,
121135
)
122136
.join(",\n ")}
123137
],`,

libs/@hashintel/petrinaut-core/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ export type {
174174
WorkerFactory,
175175
InitialMarking,
176176
InitialPlaceMarking,
177+
InitialTokenAttributeValue,
177178
MonteCarloAdvanceResult,
178179
MonteCarloActiveRunPlaceCountsVisitor,
179180
MonteCarloExperiment,
@@ -342,10 +343,13 @@ export {
342343
encodeTokenToBytes,
343344
readTokenRecord,
344345
type PhysicalKind,
346+
type StringPoolReader,
347+
type StringPoolWriter,
345348
type TokenLayoutField,
346349
type TokenRegionViews,
347350
type TokenSlotLayout,
348351
} from "./simulation/engine/token-layout";
352+
export { StringPool } from "./simulation/engine/string-pool";
349353
export {
350354
formatUuid,
351355
isUuidString,

libs/@hashintel/petrinaut-core/src/lsp/lib/checker.test.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1116,6 +1116,117 @@ describe("checkSDCPN", () => {
11161116
});
11171117
});
11181118

1119+
describe("string elements", () => {
1120+
const stringTypes = [
1121+
{
1122+
id: "color1",
1123+
elements: [
1124+
{ name: "label", type: "string" as const },
1125+
{ name: "x", type: "real" as const },
1126+
],
1127+
},
1128+
];
1129+
const stringPlaces = [
1130+
{ id: "place1", name: "Source", colorId: "color1" },
1131+
{ id: "place2", name: "Target", colorId: "color1" },
1132+
];
1133+
const stringKernel = (body: string) =>
1134+
createSDCPN({
1135+
types: stringTypes,
1136+
places: stringPlaces,
1137+
transitions: [
1138+
{
1139+
id: "t1",
1140+
inputArcs: [{ placeId: "place1", weight: 1, type: "standard" }],
1141+
outputArcs: [{ placeId: "place2", weight: 1 }],
1142+
transitionKernelCode: `export default TransitionKernel((input, parameters) => {
1143+
return { Target: [${body}] };
1144+
});`,
1145+
},
1146+
],
1147+
});
1148+
1149+
it("types input string attributes as string (comparison and methods are valid)", () => {
1150+
const sdcpn = createSDCPN({
1151+
types: stringTypes,
1152+
places: stringPlaces,
1153+
transitions: [
1154+
{
1155+
id: "t1",
1156+
lambdaType: "predicate",
1157+
inputArcs: [{ placeId: "place1", weight: 1, type: "standard" }],
1158+
outputArcs: [{ placeId: "place2", weight: 1 }],
1159+
lambdaCode: `export default Lambda((input, parameters) => {
1160+
return input.Source[0].label === "queued" && input.Source[0].label.startsWith("q");
1161+
});`,
1162+
transitionKernelCode: `export default TransitionKernel((input, parameters) => {
1163+
return { Target: [input.Source[0]] };
1164+
});`,
1165+
},
1166+
],
1167+
});
1168+
1169+
const result = check(sdcpn);
1170+
1171+
expect(result.isValid).toBe(true);
1172+
expect(result.itemDiagnostics).toHaveLength(0);
1173+
});
1174+
1175+
it("accepts plain string kernel outputs and forwarded input strings", () => {
1176+
const literal = check(stringKernel(`{ label: "shipped", x: 1 }`));
1177+
expect(literal.isValid).toBe(true);
1178+
expect(literal.itemDiagnostics).toHaveLength(0);
1179+
1180+
const forwarded = check(
1181+
stringKernel(`{ label: input.Source[0].label, x: input.Source[0].x }`),
1182+
);
1183+
expect(forwarded.isValid).toBe(true);
1184+
expect(forwarded.itemDiagnostics).toHaveLength(0);
1185+
});
1186+
1187+
it("rejects arithmetic on an input string attribute", () => {
1188+
const sdcpn = createSDCPN({
1189+
types: stringTypes,
1190+
places: stringPlaces,
1191+
transitions: [
1192+
{
1193+
id: "t1",
1194+
lambdaType: "predicate",
1195+
inputArcs: [{ placeId: "place1", weight: 1, type: "standard" }],
1196+
outputArcs: [{ placeId: "place2", weight: 1 }],
1197+
lambdaCode: `export default Lambda((input, parameters) => {
1198+
return input.Source[0].label * 2 > 0;
1199+
});`,
1200+
transitionKernelCode: `export default TransitionKernel((input, parameters) => {
1201+
return { Target: [input.Source[0]] };
1202+
});`,
1203+
},
1204+
],
1205+
});
1206+
1207+
const result = check(sdcpn);
1208+
1209+
expect(result.isValid).toBe(false);
1210+
expect(result.itemDiagnostics[0]?.itemType).toBe("transition-lambda");
1211+
});
1212+
1213+
it("rejects Distribution values on string attributes", () => {
1214+
const result = check(
1215+
stringKernel(`{ label: Distribution.Uniform(0, 1), x: 1 }`),
1216+
);
1217+
1218+
expect(result.isValid).toBe(false);
1219+
expect(result.itemDiagnostics[0]?.itemType).toBe("transition-kernel");
1220+
});
1221+
1222+
it("rejects numeric values on string attributes", () => {
1223+
const result = check(stringKernel(`{ label: 42, x: 1 }`));
1224+
1225+
expect(result.isValid).toBe(false);
1226+
expect(result.itemDiagnostics[0]?.itemType).toBe("transition-kernel");
1227+
});
1228+
});
1229+
11191230
describe("Multiple errors", () => {
11201231
it("reports errors from multiple items", () => {
11211232
// GIVEN - SDCPN with errors in both a differential equation and a transition

0 commit comments

Comments
 (0)