diff --git a/.changeset/token-frame-format-packed-structs.md b/.changeset/token-frame-format-packed-structs.md new file mode 100644 index 00000000000..eb31763d8b3 --- /dev/null +++ b/.changeset/token-frame-format-packed-structs.md @@ -0,0 +1,6 @@ +--- +"@hashintel/petrinaut-core": patch +"@hashintel/petrinaut": patch +--- + +Packed-struct token frame layout (f64 numbers, u8 booleans, 8-byte strides). `getPlaceTokens(place)` and `buildMetricState(frame, places)` drop unused parameters. diff --git a/libs/@hashintel/petrinaut-core/src/actual-mode/timeline.ts b/libs/@hashintel/petrinaut-core/src/actual-mode/timeline.ts index 9d3e5627c6e..5b07559fcc3 100644 --- a/libs/@hashintel/petrinaut-core/src/actual-mode/timeline.ts +++ b/libs/@hashintel/petrinaut-core/src/actual-mode/timeline.ts @@ -9,9 +9,8 @@ import { parseActualModeTimestampMs } from "./time"; import type { SimulationFrameReader, SimulationFrameState, - SimulationPlaceTokenValues, } from "../simulation/api"; -import type { Color, Place, SDCPN } from "../types/sdcpn"; +import type { Place, SDCPN } from "../types/sdcpn"; import type { ActualModeContextValue, ActualModeMarking, @@ -193,47 +192,10 @@ export const createActualModeTimelineFrameReader = (params: { time: point.timeMs / 1_000, getPlaceTokenCount: (placeId: string) => getActualModePlaceMarkingTokenCount(marking[placeId]), - getPlaceTokenValues: ( - placeId: string, - ): SimulationPlaceTokenValues | null => { - const place = definition.places.find( - (candidatePlace) => candidatePlace.id === placeId, - ); - const color = place - ? definition.types.find((type) => type.id === place.colorId) - : null; - const placeMarking = marking[placeId]; - - if (!place || !color || !isActualModeTokenColourArray(placeMarking)) { - return { - count: getActualModePlaceMarkingTokenCount(placeMarking), - values: new Float64Array(), - }; - } - - const values = new Float64Array( - placeMarking.length * color.elements.length, - ); - - for (const [tokenIndex, token] of placeMarking.entries()) { - for (const [elementIndex, element] of color.elements.entries()) { - values[tokenIndex * color.elements.length + elementIndex] = - token[element.name] ?? token[element.elementId] ?? 0; - } - } - - return { - count: placeMarking.length, - values, - }; - }, - getPlaceTokens: ( - place: Place, - color: Color | null | undefined, - ): Record[] => { + getPlaceTokens: (place: Place): Record[] => { const placeMarking = marking[place.id]; - if (!color || !isActualModeTokenColourArray(placeMarking)) { + if (!place.colorId || !isActualModeTokenColourArray(placeMarking)) { return []; } diff --git a/libs/@hashintel/petrinaut-core/src/index.ts b/libs/@hashintel/petrinaut-core/src/index.ts index 67e075ab970..caf9be1048d 100644 --- a/libs/@hashintel/petrinaut-core/src/index.ts +++ b/libs/@hashintel/petrinaut-core/src/index.ts @@ -169,7 +169,6 @@ export type { SimulationFrameReader, SimulationFrameState, SimulationFrameSummary, - SimulationPlaceTokenValues, SimulationState, SimulationTransport, WorkerFactory, @@ -334,11 +333,18 @@ export { buildMetricState } from "./simulation/frames/metric-state"; export { coerceTokenAttributeValue, coerceTokenRecord, - decodeTokenAttributeValue, - decodeTokenRecord, defaultTokenAttributeValue, encodeTokenAttributeValue, } from "./simulation/engine/token-values"; +export { + computeTokenSlotLayout, + createTokenRegionViews, + encodeTokenToBytes, + readTokenRecord, + type PhysicalKind, + type TokenLayoutField, + type TokenSlotLayout, +} from "./simulation/engine/token-layout"; export { compileUserCode } from "./simulation/authoring/user-code/compile-user-code"; export { displayNameSchema, diff --git a/libs/@hashintel/petrinaut-core/src/simulation/ARCHITECTURE.md b/libs/@hashintel/petrinaut-core/src/simulation/ARCHITECTURE.md index 65079fe3937..6cdd4aa9f89 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/ARCHITECTURE.md +++ b/libs/@hashintel/petrinaut-core/src/simulation/ARCHITECTURE.md @@ -28,9 +28,15 @@ SDCPN snapshot ``` `EngineFrame` is not a public API or stable storage format. It is currently a -binary `ArrayBuffer` with typed sections for place token counts, place value -offsets, transition timers/counts/flags, and packed token values. It can only be -read with the matching SDCPN-derived layout. +binary `ArrayBuffer` (header-stamped `FRAME_VERSION = 2`) with typed sections +for place token counts, per-place byte offsets, transition timers/counts/flags, +and a packed-struct token byte region. Each colour's token layout is computed +by `engine/token-layout.ts`: `real` and `integer` elements map to f64 (8 B, +8-aligned; integers rounded by the value codec), `boolean` elements map to u8 +(1 B), fields are ordered by decreasing alignment, and the per-token stride is +rounded up to 8 bytes so f64 fields stay addressable through a shared +`Float64Array` view. A frame can only be read with the matching SDCPN-derived +layout. The public frame path is: diff --git a/libs/@hashintel/petrinaut-core/src/simulation/api.ts b/libs/@hashintel/petrinaut-core/src/simulation/api.ts index aeae9f01e06..b594c90de83 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/api.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/api.ts @@ -2,7 +2,7 @@ import type { AbortSignalLike, WorkerFactoryLike } from "../environment"; import type { PetrinautExtensionSettings } from "../extensions"; import type { EventStream } from "../instance"; import type { ReadableStore } from "../store"; -import type { Color, Place, SDCPN, TokenRecord } from "../types/sdcpn"; +import type { Place, SDCPN, TokenRecord } from "../types/sdcpn"; export type SimulationState = | "Initializing" @@ -92,11 +92,6 @@ export type SimulationFrameState = { }; }; -export type SimulationPlaceTokenValues = { - values: Float64Array; - count: number; -}; - export interface SimulationFrameReader { /** Frame index in the simulation history. */ readonly number: number; @@ -104,8 +99,8 @@ export interface SimulationFrameReader { readonly time: number; getPlaceTokenCount(placeId: string): number; - getPlaceTokenValues(placeId: string): SimulationPlaceTokenValues | null; - getPlaceTokens(place: Place, color: Color | null | undefined): TokenRecord[]; + /** Typed token records for a coloured place; `[]` for uncoloured places. */ + getPlaceTokens(place: Place): TokenRecord[]; getTransitionState(transitionId: string): { /** * Time elapsed since this transition last fired, in milliseconds. diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/README.md b/libs/@hashintel/petrinaut-core/src/simulation/engine/README.md index 539b7bea091..58551a8f40e 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/README.md +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/README.md @@ -56,24 +56,51 @@ The frame does not contain place or transition IDs. `SimulationInstance` owns an `EngineFrameLayout` derived from the SDCPN and passes it to internal readers and writers. -## Token Value Storage - -Token values are stored in a packed `Float64Array` section inside the frame -buffer. Place counts and value offsets are stored in separate `Uint32Array` +## Token Value Storage (packed token structs) + +Token values are stored as a byte region of packed structs inside the frame +buffer (`FRAME_VERSION = 2`). Each colour has a `TokenSlotLayout` computed by +`engine/token-layout.ts` — the single source of truth for the physical +mapping: + +| Element type | Physical type | Size | Alignment | +| ------------ | ----------------------------------- | ---- | --------- | +| `real` | `f64` | 8 B | 8 B | +| `integer` | `f64` (rounded via the value codec) | 8 B | 8 B | +| `boolean` | `u8` (0/1) | 1 B | 1 B | + +Per colour, fields are ordered by decreasing alignment (stable within equal +alignment), each field's byte offset is aligned to its physical alignment, and +the stride (`sizeof(token)`) is rounded up to 8 bytes. Because the token +region starts at an 8-aligned offset and every stride is a multiple of 8, all +f64 fields are addressable through a shared `Float64Array` view +(`index = (placeByteOffset + token * strideBytes + fieldByteOffset) / 8`) and +u8 fields through a `Uint8Array` view — no `DataView` in hot paths. + +Place counts and per-place byte offsets are stored in separate `Uint32Array` sections, so a place can be accessed in O(1) when the SDCPN layout is known. ```text -Place p1: offset=0, count=2, dimensions=3 -Place p2: offset=6, count=1, dimensions=2 +Colour of p1: { x: real, n: integer, on: boolean } → stride 24 B + x @ 0 (f64) · n @ 8 (f64) · on @ 16 (u8) · padding 17..24 + +Place p1: byteOffset=0, count=2, strideBytes=24 +Place p2: byteOffset=48, count=1, strideBytes=16 -buffer: [v1.x, v1.y, v1.z, v2.x, v2.y, v2.z, v3.a, v3.b] - └──────── p1 tokens ────────────┘ └── p2 ──┘ +bytes: [tok0: x n on pad][tok1: x n on pad][tok2: a b] + └──────── p1 tokens (48 B) ──────┘ └ p2 (16 B) ┘ ``` Access: ```typescript const frameView = readEngineFrame(simulation.frameLayout, frame); -const place = frameView.getPlaceState("p1"); -const values = frameView.getPlaceTokenValues("p1"); +const place = frameView.getPlaceState("p1"); // { byteOffset, count, strideBytes } +const layout = simulation.frameLayout.placeTokenLayouts[placeIndex]; +const token = readTokenRecord( + layout, + frameView.tokenF64, + frameView.tokenBytes, + place.byteOffset + tokenIndex * place.strideBytes, +); ``` 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 580eff5c6b1..c1bb7803dc5 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 @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { compileSimulationFrameReader } from "../frames/frame-reader"; import { materializeEngineFrame } from "../frames/internal-frame"; import { buildSimulation } from "./build-simulation"; +import { decodePlaceTokens } from "./token-layout.test-helpers"; import type { SimulationInput } from "./types"; @@ -60,13 +61,18 @@ describe("buildSimulation", () => { engineFrame, ); - expect(frame.buffer).toEqual(new Float64Array([1.25, 3, 1])); + // Packed struct layout: amount (f64) @ 0, count (f64) @ 8, active (u8) + // @ 16, stride rounded up to 24 bytes. + expect(frame.buffer).toBeInstanceOf(Uint8Array); + expect(frame.buffer.byteLength).toBe(24); + expect( + new Float64Array(frame.buffer.buffer, frame.buffer.byteOffset, 2), + ).toEqual(new Float64Array([1.25, 3])); + expect(frame.buffer[16]).toBe(1); const reader = compileSimulationFrameReader(input.sdcpn)(engineFrame, 0, 0); - expect( - reader.getPlaceTokens(input.sdcpn.places[0]!, input.sdcpn.types[0]), - ).toEqual([ + expect(reader.getPlaceTokens(input.sdcpn.places[0]!)).toEqual([ { amount: 1.25, count: 3, @@ -508,8 +514,8 @@ describe("buildSimulation", () => { placeId: "processor-1::port-in", placeName: "Processor 1::Port In", weight: 1, - elementNames: null, elements: null, + tokenLayout: null, }, ]); expect(simulation.compiledTransitions.get("receive")?.inputPlaces).toEqual([ @@ -518,8 +524,8 @@ describe("buildSimulation", () => { placeName: "Processor 1::Port Out", weight: 1, arcType: "standard", - elementNames: null, elements: null, + tokenLayout: null, }, ]); expect(frame.places["root-input"]?.count).toBe(2); @@ -599,14 +605,19 @@ describe("buildSimulation", () => { const p1State = frame.places.p1; expect(p1State).toBeDefined(); expect(p1State?.count).toBe(2); - expect(p1State?.offset).toBe(0); + expect(p1State?.byteOffset).toBe(0); const p1Type = simulationInstance.places.get("p1")?.colorId; expect(p1Type).toBe("type1"); const typeDefinition = input.sdcpn.types.find((tp) => tp.id === p1Type); expect(typeDefinition?.elements.length).toBe(2); // Verify buffer contains the correct token values - expect(frame.buffer).toEqual(new Float64Array([1.0, 2.0, 3.0, 4.0])); + expect( + decodePlaceTokens(simulationInstance.frameLayout, engineFrame, "p1"), + ).toEqual([ + { x: 1.0, y: 2.0 }, + { x: 3.0, y: 4.0 }, + ]); // Verify compiled functions exist expect(simulationInstance.differentialEquationFns.has("p1")).toBe(true); @@ -717,9 +728,10 @@ describe("buildSimulation", () => { }; const simulationInstance = buildSimulation(input); + const engineFrame = simulationInstance.frames[0]!; const frame = materializeEngineFrame( simulationInstance.frameLayout, - simulationInstance.frames[0]!, + engineFrame, ); // Verify simulation instance properties @@ -732,7 +744,7 @@ describe("buildSimulation", () => { // Verify p1 state (places are sorted by ID, so p1 comes first) const p1State = frame.places.p1; expect(p1State?.count).toBe(3); - expect(p1State?.offset).toBe(0); + expect(p1State?.byteOffset).toBe(0); const p1Type = simulationInstance.places.get("p1")?.colorId; expect(p1Type).toBe("type1"); const p1TypeDef = input.sdcpn.types.find((tp) => tp.id === p1Type); @@ -741,7 +753,7 @@ describe("buildSimulation", () => { // Verify p2 state (comes after p1) const p2State = frame.places.p2; expect(p2State?.count).toBe(1); - expect(p2State?.offset).toBe(3); // After p1's 3 tokens + expect(p2State?.byteOffset).toBe(24); // After p1's 3 tokens × 8-byte stride const p2Type = simulationInstance.places.get("p2")?.colorId; expect(p2Type).toBe("type2"); const p2TypeDef = input.sdcpn.types.find((tp) => tp.id === p2Type); @@ -750,16 +762,22 @@ describe("buildSimulation", () => { // Verify p3 state (comes after p2, has no tokens) const p3State = frame.places.p3; expect(p3State?.count).toBe(0); - expect(p3State?.offset).toBe(5); // After p1's 3 values + p2's 2 values + expect(p3State?.byteOffset).toBe(40); // After p1's 24 bytes + p2's 16 bytes const p3Type = simulationInstance.places.get("p3")?.colorId; expect(p3Type).toBe("type1"); const p3TypeDef = input.sdcpn.types.find((tp) => tp.id === p3Type); expect(p3TypeDef?.elements.length).toBe(1); - // Verify buffer layout: [p1: 10, 20, 30 | p2: 1, 2] - expect(frame.buffer).toEqual( - new Float64Array([10.0, 20.0, 30.0, 1.0, 2.0]), - ); + // Verify buffer layout: [p1: {x:10}, {x:20}, {x:30} | p2: {x:1, y:2}] + expect( + decodePlaceTokens(simulationInstance.frameLayout, engineFrame, "p1"), + ).toEqual([{ x: 10 }, { x: 20 }, { x: 30 }]); + expect( + decodePlaceTokens(simulationInstance.frameLayout, engineFrame, "p2"), + ).toEqual([{ x: 1, y: 2 }]); + expect( + decodePlaceTokens(simulationInstance.frameLayout, engineFrame, "p3"), + ).toEqual([]); // Verify transitions exist with initial state expect(Object.keys(frame.transitions).length).toBe(2); 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 6abbfb2e734..d3d00ef1316 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/build-simulation.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/build-simulation.ts @@ -23,10 +23,13 @@ import { getArcPlaceNameOverrideKey, } from "./flatten-component-instances"; import { - coerceTokenRecord, - decodeTokenRecord, - encodeTokenAttributeValue, -} from "./token-values"; + computeTokenSlotLayout, + createTokenRegionViews, + encodeTokenToBytes, + readTokenRecord, + type TokenSlotLayout, +} from "./token-layout"; +import { coerceTokenRecord } from "./token-values"; import type { TokenRecord } from "../../types/sdcpn"; import type { @@ -45,7 +48,7 @@ type ColorElement = SimulationInput["sdcpn"]["types"][number]["elements"][number]; type PackedInitialPlaceMarking = { - values: number[]; + bytes: Uint8Array; count: number; }; @@ -74,15 +77,15 @@ function getInitialMarkingValue( } /** - * Get the dimensions (number of elements) for a place based on its type. - * If the place has no type, returns 0. + * Get the packed token layout for a place based on its type. + * If the place has no type, returns null. */ -function getPlaceDimensions( +function getPlaceTokenLayout( place: SimulationInput["sdcpn"]["places"][0], sdcpn: SimulationInput["sdcpn"], -): number { +): TokenSlotLayout | null { if (!place.colorId) { - return 0; + return null; } const type = sdcpn.types.find((tp) => tp.id === place.colorId); if (!type) { @@ -90,27 +93,29 @@ function getPlaceDimensions( `Type with ID ${place.colorId} referenced by place ${place.id} does not exist in SDCPN`, ); } - return type.elements.length; + return computeTokenSlotLayout(type.elements); } +const EMPTY_BYTES = new Uint8Array(0); + function packInitialPlaceMarking( place: SimulationInput["sdcpn"]["places"][0], sdcpn: SimulationInput["sdcpn"], value: SimulationInput["initialMarking"][string] | undefined, ): PackedInitialPlaceMarking { - const dimensions = getPlaceDimensions(place, sdcpn); + const tokenLayout = getPlaceTokenLayout(place, sdcpn); if (value === undefined) { - return { values: [], count: 0 }; + return { bytes: EMPTY_BYTES, count: 0 }; } - if (dimensions === 0) { + if (tokenLayout === null || tokenLayout.strideBytes === 0) { if (typeof value !== "number") { throw new Error( `Initial marking for uncolored place ${place.id} must be a token count number`, ); } - return { values: [], count: Math.max(0, Math.round(value)) }; + return { bytes: EMPTY_BYTES, count: Math.max(0, Math.round(value)) }; } if (!Array.isArray(value)) { @@ -127,8 +132,8 @@ function packInitialPlaceMarking( } const tokenRecords: unknown[] = value; - const values: number[] = []; - for (const token of tokenRecords) { + const bytes = new Uint8Array(tokenRecords.length * tokenLayout.strideBytes); + for (const [tokenIndex, token] of tokenRecords.entries()) { if (typeof token !== "object" || token === null || Array.isArray(token)) { throw new Error( `Initial marking token for place ${place.id} must be a record`, @@ -139,68 +144,69 @@ function packInitialPlaceMarking( type.elements, `Initial marking token for place ${place.id}`, ); - for (const element of type.elements) { - values.push( - encodeTokenAttributeValue( - element, - tokenRecord[element.name], - `Initial marking token for place ${place.id}.${element.name}`, - ), - ); - } + bytes.set( + encodeTokenToBytes( + tokenLayout, + tokenRecord, + `Initial marking token for place ${place.id}`, + ), + tokenIndex * tokenLayout.strideBytes, + ); } - return { values, count: value.length }; + return { bytes, count: value.length }; } function createDifferentialEquationFn({ placeId, - elementNames, - elements, + tokenLayout, parameterValues, userFn, }: { placeId: string; - elementNames: string[]; - elements: SimulationInput["sdcpn"]["types"][number]["elements"]; + tokenLayout: TokenSlotLayout; parameterValues: ParameterValues; userFn: UserDifferentialEquationFn; }): DifferentialEquationFn { - const expectedDimensions = elementNames.length; + const { strideBytes } = tokenLayout; + const realFields = tokenLayout.fields.filter( + (field) => field.element.type === "real", + ); + const realFieldCount = realFields.length; - return (currentState, dimensions, numberOfTokens) => { - if (dimensions !== expectedDimensions) { + return (placeBytes, numberOfTokens) => { + if (placeBytes.byteLength !== numberOfTokens * strideBytes) { throw new Error( - `Place ${placeId} has ${dimensions} dimensions in frame, expected ${expectedDimensions}`, + `Place ${placeId} has ${placeBytes.byteLength} token bytes in frame, expected ${ + numberOfTokens * strideBytes + }`, ); } + const { f64, u8 } = createTokenRegionViews( + placeBytes.buffer, + placeBytes.byteOffset, + placeBytes.byteLength, + ); + const inputTokens: TokenRecord[] = []; for (let tokenIndex = 0; tokenIndex < numberOfTokens; tokenIndex++) { - const tokenStart = tokenIndex * dimensions; inputTokens.push( - decodeTokenRecord( - elements, - currentState.subarray(tokenStart, tokenStart + dimensions), - ), + readTokenRecord(tokenLayout, f64, u8, tokenIndex * strideBytes), ); } const resultTokens = userFn(inputTokens, parameterValues); - const result = new Float64Array(numberOfTokens * dimensions); + const result = new Float64Array(numberOfTokens * realFieldCount); const tokenCount = Math.min(resultTokens.length, numberOfTokens); for (let tokenIndex = 0; tokenIndex < tokenCount; tokenIndex++) { const token = resultTokens[tokenIndex]!; - for ( - let dimensionIndex = 0; - dimensionIndex < dimensions; - dimensionIndex++ - ) { - const dimensionName = elementNames[dimensionIndex]!; - const element = elements[dimensionIndex]!; - result[tokenIndex * dimensions + dimensionIndex] = - element.type === "real" ? Number(token[dimensionName] ?? 0) : 0; + for (let fieldIndex = 0; fieldIndex < realFieldCount; fieldIndex++) { + const field = realFields[fieldIndex]!; + result[tokenIndex * realFieldCount + fieldIndex] = Number( + token[field.element.name] ?? 0, + ); } } @@ -208,32 +214,6 @@ function createDifferentialEquationFn({ }; } -function getPlaceElementNames( - placeId: string, - placesMap: ReadonlyMap, - typesMap: ReadonlyMap, -): readonly string[] | 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.map((element) => element.name); -} - function getPlaceElements( placeId: string, placesMap: ReadonlyMap, @@ -387,6 +367,7 @@ function createCompiledTransition({ ); } + const elements = getPlaceElements(placeId, placesMap, typesMap); return { placeId, placeName: @@ -398,8 +379,8 @@ function createCompiledTransition({ ) ?? place.name, weight: arc.weight, arcType: arc.type, - elementNames: getPlaceElementNames(placeId, placesMap, typesMap), - elements: getPlaceElements(placeId, placesMap, typesMap), + elements, + tokenLayout: elements ? computeTokenSlotLayout(elements) : null, }; }), outputPlaces: transition.outputArcs.map((arc) => { @@ -416,6 +397,7 @@ function createCompiledTransition({ ); } + const elements = getPlaceElements(placeId, placesMap, typesMap); return { placeId, placeName: @@ -426,8 +408,8 @@ function createCompiledTransition({ }), ) ?? place.name, weight: arc.weight, - elementNames: getPlaceElementNames(placeId, placesMap, typesMap), - elements: getPlaceElements(placeId, placesMap, typesMap), + elements, + tokenLayout: elements ? computeTokenSlotLayout(elements) : null, }; }), lambdaFn, @@ -555,8 +537,7 @@ export function buildSimulation(input: SimulationInput): SimulationInstance { place.id, createDifferentialEquationFn({ placeId: place.id, - elementNames: type.elements.map((element) => element.name), - elements: type.elements, + tokenLayout: computeTokenSlotLayout(type.elements), parameterValues: flattened.placeParameterValues.get(place.id) ?? parameterValues, userFn, @@ -603,35 +584,33 @@ export function buildSimulation(input: SimulationInput): SimulationInstance { } // Calculate buffer size and build place states - let bufferSize = 0; + let bufferByteSize = 0; const frameLayout = createEngineFrameLayout(sdcpn); const placeStates: EngineFrameSnapshot["places"] = {}; - for (const placeId of frameLayout.placeIds) { - const place = placesMap.get(placeId)!; + for (const [placeIndex, placeId] of frameLayout.placeIds.entries()) { const marking = packedInitialMarking.get(placeId); const count = marking?.count ?? 0; - const dimensions = getPlaceDimensions(place, sdcpn); + const strideBytes = frameLayout.placeStrideBytes[placeIndex] ?? 0; placeStates[placeId] = { - offset: bufferSize, + byteOffset: bufferByteSize, count, - dimensions, + strideBytes, }; - bufferSize += dimensions * count; + bufferByteSize += strideBytes * count; } - // Build the initial buffer with token values - const buffer = new Float64Array(bufferSize); - let bufferIndex = 0; + // Build the initial buffer with token bytes + const buffer = new Uint8Array(bufferByteSize); + let bufferByteOffset = 0; for (const placeId of frameLayout.placeIds) { const marking = packedInitialMarking.get(placeId); - if (marking && marking.count > 0) { - for (let i = 0; i < marking.values.length; i++) { - buffer[bufferIndex++] = marking.values[i]!; - } + if (marking && marking.bytes.byteLength > 0) { + buffer.set(marking.bytes, bufferByteOffset); + bufferByteOffset += marking.bytes.byteLength; } } diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/check-transition-enablement.test.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/check-transition-enablement.test.ts index 3c15b60268d..26658673cf4 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/check-transition-enablement.test.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/check-transition-enablement.test.ts @@ -81,7 +81,7 @@ function makeFrame({ transitions: Object.fromEntries( transitions.map((transition) => [transition.id, transitionState]), ), - buffer: new Float64Array([]), + buffer: new Uint8Array(0), }) as TestFrame; Object.defineProperty(frame, "layout", { value: layout }); return frame; @@ -93,7 +93,7 @@ describe("isTransitionStructurallyEnabled", () => { inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], }); const frame = makeFrame({ - places: { p1: { offset: 0, count: 2, dimensions: 0 } }, + places: { p1: { byteOffset: 0, count: 2, strideBytes: 0 } }, transitions: [transition], }); @@ -112,7 +112,7 @@ describe("isTransitionStructurallyEnabled", () => { inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }], }); const frame = makeFrame({ - places: { p1: { offset: 0, count: 0, dimensions: 0 } }, + places: { p1: { byteOffset: 0, count: 0, strideBytes: 0 } }, transitions: [transition], }); @@ -131,7 +131,7 @@ describe("isTransitionStructurallyEnabled", () => { inputArcs: [{ placeId: "p1", weight: 3, type: "standard" }], }); const frame = makeFrame({ - places: { p1: { offset: 0, count: 2, dimensions: 0 } }, + places: { p1: { byteOffset: 0, count: 2, strideBytes: 0 } }, transitions: [transition], }); @@ -150,7 +150,7 @@ describe("isTransitionStructurallyEnabled", () => { inputArcs: [{ placeId: "p1", weight: 2, type: "read" }], }); const frame = makeFrame({ - places: { p1: { offset: 0, count: 2, dimensions: 0 } }, + places: { p1: { byteOffset: 0, count: 2, strideBytes: 0 } }, transitions: [transition], }); @@ -169,7 +169,7 @@ describe("isTransitionStructurallyEnabled", () => { inputArcs: [{ placeId: "p1", weight: 2, type: "read" }], }); const frame = makeFrame({ - places: { p1: { offset: 0, count: 1, dimensions: 0 } }, + places: { p1: { byteOffset: 0, count: 1, strideBytes: 0 } }, transitions: [transition], }); @@ -192,8 +192,8 @@ describe("isTransitionStructurallyEnabled", () => { }); const frame = makeFrame({ places: { - p1: { offset: 0, count: 2, dimensions: 0 }, - p2: { offset: 0, count: 0, dimensions: 0 }, + p1: { byteOffset: 0, count: 2, strideBytes: 0 }, + p2: { byteOffset: 0, count: 0, strideBytes: 0 }, }, transitions: [transition], }); @@ -213,7 +213,7 @@ describe("isTransitionStructurallyEnabled", () => { inputArcs: [{ placeId: "p1", weight: 2, type: "inhibitor" }], }); const frame = makeFrame({ - places: { p1: { offset: 0, count: 1, dimensions: 0 } }, + places: { p1: { byteOffset: 0, count: 1, strideBytes: 0 } }, transitions: [transition], }); @@ -232,7 +232,7 @@ describe("isTransitionStructurallyEnabled", () => { inputArcs: [{ placeId: "p1", weight: 2, type: "inhibitor" }], }); const frame = makeFrame({ - places: { p1: { offset: 0, count: 3, dimensions: 0 } }, + places: { p1: { byteOffset: 0, count: 3, strideBytes: 0 } }, transitions: [transition], }); @@ -251,7 +251,7 @@ describe("isTransitionStructurallyEnabled", () => { inputArcs: [{ placeId: "p1", weight: 2, type: "inhibitor" }], }); const frame = makeFrame({ - places: { p1: { offset: 0, count: 2, dimensions: 0 } }, + places: { p1: { byteOffset: 0, count: 2, strideBytes: 0 } }, transitions: [transition], }); @@ -270,7 +270,7 @@ describe("isTransitionStructurallyEnabled", () => { inputArcs: [{ placeId: "p1", weight: 1, type: "inhibitor" }], }); const frame = makeFrame({ - places: { p1: { offset: 0, count: 0, dimensions: 0 } }, + places: { p1: { byteOffset: 0, count: 0, strideBytes: 0 } }, transitions: [transition], }); @@ -293,8 +293,8 @@ describe("isTransitionStructurallyEnabled", () => { }); const frame = makeFrame({ places: { - p1: { offset: 0, count: 2, dimensions: 0 }, - p2: { offset: 0, count: 0, dimensions: 0 }, + p1: { byteOffset: 0, count: 2, strideBytes: 0 }, + p2: { byteOffset: 0, count: 0, strideBytes: 0 }, }, transitions: [transition], }); @@ -318,8 +318,8 @@ describe("isTransitionStructurallyEnabled", () => { }); const frame = makeFrame({ places: { - p1: { offset: 0, count: 2, dimensions: 0 }, - p2: { offset: 0, count: 3, dimensions: 0 }, + p1: { byteOffset: 0, count: 2, strideBytes: 0 }, + p2: { byteOffset: 0, count: 3, strideBytes: 0 }, }, transitions: [transition], }); @@ -366,8 +366,8 @@ describe("checkTransitionEnablement", () => { ]; const frame = makeFrame({ places: { - p1: { offset: 0, count: 1, dimensions: 0 }, - p2: { offset: 0, count: 0, dimensions: 0 }, + p1: { byteOffset: 0, count: 1, strideBytes: 0 }, + p2: { byteOffset: 0, count: 0, strideBytes: 0 }, }, transitions, }); @@ -396,8 +396,8 @@ describe("checkTransitionEnablement", () => { ]; const frame = makeFrame({ places: { - p1: { offset: 0, count: 0, dimensions: 0 }, - p2: { offset: 0, count: 0, dimensions: 0 }, + p1: { byteOffset: 0, count: 0, strideBytes: 0 }, + p2: { byteOffset: 0, count: 0, strideBytes: 0 }, }, transitions, }); @@ -442,7 +442,7 @@ describe("checkTransitionEnablement", () => { }), ]; const frame = makeFrame({ - places: { p1: { offset: 0, count: 5, dimensions: 0 } }, + places: { p1: { byteOffset: 0, count: 5, strideBytes: 0 } }, transitions, }); diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/compute-next-frame.test.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/compute-next-frame.test.ts index 6cfceac745b..37739d5c531 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/compute-next-frame.test.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/compute-next-frame.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "vitest"; -import { materializeEngineFrame } from "../frames/internal-frame"; import { buildSimulation } from "./build-simulation"; import { computeNextFrame } from "./compute-next-frame"; +import { decodePlaceTokens } from "./token-layout.test-helpers"; import type { SDCPN } from "../../types/sdcpn"; @@ -81,17 +81,19 @@ describe("computeNextFrame", () => { expect(result.transitionFired).toBe(false); // The run controller should advance time by dt. - const nextFrame = materializeEngineFrame( + expect(result.simulation.currentTime).toBe(0.1); + + // The tokens should reflect dynamics (values should have increased by derivative * dt) + // Initial: { x: 10, y: 20 }, derivative: { x: 1, y: 1 }, dt: 0.1 + // Expected after dynamics: { x: 10.1, y: 20.1 } + const tokens = decodePlaceTokens( result.simulation.frameLayout, result.simulation.frames[1]!, + "p1", ); - expect(result.simulation.currentTime).toBe(0.1); - - // The buffer should reflect dynamics (values should have increased by derivative * dt) - // Initial: [10, 20], derivative: [1, 1], dt: 0.1 - // Expected after dynamics: [10.1, 20.1] - expect(nextFrame.buffer[0]).toBeCloseTo(10.1); - expect(nextFrame.buffer[1]).toBeCloseTo(20.1); + expect(tokens).toEqual([ + { x: expect.closeTo(10.1) as number, y: expect.closeTo(20.1) as number }, + ]); }); it("should skip dynamics for places without type", () => { @@ -182,12 +184,13 @@ describe("computeNextFrame", () => { // WHEN computing the next frame const result = computeNextFrame(simulation); - // THEN the buffer should be unchanged (no dynamics applied) - const nextFrame = materializeEngineFrame( + // THEN the tokens should be unchanged (no dynamics applied) + const tokens = decodePlaceTokens( result.simulation.frameLayout, result.simulation.frames[1]!, + "p1", ); - expect(nextFrame.buffer[0]).toBe(10.0); + expect(tokens).toEqual([{ x: 10.0 }]); expect(result.transitionFired).toBe(false); }); }); diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/compute-next-frame.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/compute-next-frame.ts index 4ebd84450ae..0497372a882 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/compute-next-frame.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/compute-next-frame.ts @@ -74,7 +74,7 @@ export function computeNextFrame( // Only apply dynamics if there are places with differential equations if (simulation.differentialEquationFns.size > 0) { - const newBuffer = new Float64Array(currentSnapshot.buffer); + const newBuffer = new Uint8Array(currentSnapshot.buffer); for (const [ placeId, @@ -84,24 +84,34 @@ export function computeNextFrame( if (!placeState) { throw new Error(`Place with ID ${placeId} not found in frame`); } - const { offset, count, dimensions } = placeState; - const placeSize = count * dimensions; - const placeBuffer = currentSnapshot.buffer.slice( - offset, - offset + placeSize, + const placeIndex = simulation.frameLayout.placeIndexById.get(placeId); + const tokenLayout = + placeIndex === undefined + ? null + : simulation.frameLayout.placeTokenLayouts[placeIndex]; + if (!tokenLayout) { + throw new Error( + `Place with ID ${placeId} has no token layout but has dynamics`, + ); + } + const { byteOffset, count, strideBytes } = placeState; + const placeByteSize = count * strideBytes; + const placeBytes = currentSnapshot.buffer.slice( + byteOffset, + byteOffset + placeByteSize, ); - const nextPlaceBuffer = computePlaceNextState( - placeBuffer, - dimensions, + const nextPlaceBytes = computePlaceNextState( + placeBytes, + tokenLayout, count, differentialEquation, "euler", // Currently only Euler method is implemented simulation.dt, ); - // Copy the updated values back into the new buffer - newBuffer.set(nextPlaceBuffer, offset); + // Copy the updated bytes back into the new buffer + newBuffer.set(nextPlaceBytes, byteOffset); } // Create frame with updated buffer after applying dynamics diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/compute-place-next-state.test.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/compute-place-next-state.test.ts index 6444831753b..06356bd8b81 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/compute-place-next-state.test.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/compute-place-next-state.test.ts @@ -1,26 +1,52 @@ -/* eslint-disable @typescript-eslint/no-shadow */ import { expect, it } from "vitest"; import { computePlaceNextState } from "./compute-place-next-state"; +import { computeTokenSlotLayout } from "./token-layout"; +import { + buildTokenBytes, + decodeTokenBlock, + realElements, + type ColorElement, +} from "./token-layout.test-helpers"; +import type { TokenRecord } from "../../types/sdcpn"; import type { PlaceDifferentialEquation } from "./compute-place-next-state"; +function decodeTokens( + elements: readonly ColorElement[], + placeBytes: Uint8Array, + numberOfTokens: number, +): TokenRecord[] { + const layout = computeTokenSlotLayout(elements); + return Array.from({ length: numberOfTokens }, (_, tokenIndex) => + decodeTokenBlock( + elements, + placeBytes.subarray( + tokenIndex * layout.strideBytes, + (tokenIndex + 1) * layout.strideBytes, + ), + ), + ); +} + it("executes without errors", () => { // GIVEN - const dimensions = 2; + const elements = realElements("x", "y"); + const layout = computeTokenSlotLayout(elements); const numberOfTokens = 2; - const initialState = Float64Array.from([ - [1, 2], - [3, 4], + const initialState = buildTokenBytes(layout, [ + { x: 1, y: 2 }, + { x: 3, y: 4 }, ]); const differentialEquation: PlaceDifferentialEquation = ( _state, - dimensions, - numberOfTokens, + tokenCount, ) => { - return new Float64Array(dimensions * numberOfTokens).fill(1); + return new Float64Array( + layout.realFieldF64Offsets.length * tokenCount, + ).fill(1); }; const dt = 0.1; @@ -28,7 +54,7 @@ it("executes without errors", () => { // WHEN const nextState = computePlaceNextState( initialState, - dimensions, + layout, numberOfTokens, differentialEquation, "euler", @@ -41,20 +67,22 @@ it("executes without errors", () => { it("adds derivative to current state value", () => { // GIVEN - const dimensions = 2; + const elements = realElements("x", "y"); + const layout = computeTokenSlotLayout(elements); const numberOfTokens = 2; - const initialState = Float64Array.from([ - [1, 2], - [3, 4], + const initialState = buildTokenBytes(layout, [ + { x: 1, y: 2 }, + { x: 3, y: 4 }, ]); const differentialEquation: PlaceDifferentialEquation = ( _state, - dimensions, - numberOfTokens, + tokenCount, ) => { - return new Float64Array(dimensions * numberOfTokens).fill(1); + return new Float64Array( + layout.realFieldF64Offsets.length * tokenCount, + ).fill(1); }; const dt = 0.5; @@ -62,7 +90,7 @@ it("adds derivative to current state value", () => { // WHEN const nextState = computePlaceNextState( initialState, - dimensions, + layout, numberOfTokens, differentialEquation, "euler", @@ -70,10 +98,49 @@ it("adds derivative to current state value", () => { ); // THEN - expect(nextState).toEqual( - Float64Array.from([ - [1.5, 2.5], - [3.5, 4.5], - ]), + expect(decodeTokens(elements, nextState, numberOfTokens)).toEqual([ + { x: 1.5, y: 2.5 }, + { x: 3.5, y: 4.5 }, + ]); +}); + +it("leaves discrete fields untouched while integrating real fields", () => { + // GIVEN a colour mixing real, integer, and boolean elements + const elements: ColorElement[] = [ + { elementId: "amount", name: "amount", type: "real" }, + { elementId: "count", name: "count", type: "integer" }, + { elementId: "active", name: "active", type: "boolean" }, + ]; + const layout = computeTokenSlotLayout(elements); + const numberOfTokens = 1; + + const initialState = buildTokenBytes(layout, [ + { amount: 1, count: 3, active: true }, + ]); + + const differentialEquation: PlaceDifferentialEquation = ( + _state, + tokenCount, + ) => { + return new Float64Array( + layout.realFieldF64Offsets.length * tokenCount, + ).fill(1); + }; + + const dt = 0.5; + + // WHEN + const nextState = computePlaceNextState( + initialState, + layout, + numberOfTokens, + differentialEquation, + "euler", + dt, ); + + // THEN only the real field is integrated; discrete fields pass through + expect(decodeTokens(elements, nextState, numberOfTokens)).toEqual([ + { amount: 1.5, count: 3, active: true }, + ]); }); diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/compute-place-next-state.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/compute-place-next-state.ts index f562ef8aad9..19d62cbdb4a 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/compute-place-next-state.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/compute-place-next-state.ts @@ -1,41 +1,59 @@ +import type { TokenSlotLayout } from "./token-layout"; + export type ODEType = "euler" | "midpoint" | "rk4"; export type PlaceDifferentialEquation = ( - currentState: Float64Array, - dimensions: number, + placeBytes: Uint8Array, numberOfTokens: number, ) => Float64Array; /** - * Takes current Place state, a differential equation defining its dynamics, - * an ODE solving method, and a time step (dt), and computes the next state - * of the Place using the specified ODE method. + * Takes one place's token byte region, its packed token layout, a + * differential equation defining its dynamics, an ODE solving method, and a + * time step (dt), and computes the next state of the place using the + * specified ODE method. + * + * Only `real` fields are integrated (via `layout.realFieldF64Offsets`); + * discrete bytes (integers rounded on decode, booleans stored as u8) are + * copied through untouched. * * Currently, only the Euler method is implemented. */ export function computePlaceNextState( - placeState: Float64Array, - dimensions: number, + placeBytes: Uint8Array, + layout: TokenSlotLayout, numberOfTokens: number, differentialEquation: PlaceDifferentialEquation, odeType: ODEType, dt: number, -): Float64Array { +): Uint8Array { if (odeType !== "euler") { throw new Error( `ODE type ${odeType} not implemented yet. Use Euler method.`, ); } - const derivatives = differentialEquation( - placeState, - dimensions, - numberOfTokens, - ); + const derivatives = differentialEquation(placeBytes, numberOfTokens); + + // Copy the whole region (including discrete bytes and padding) into a fresh + // 8-aligned buffer, then integrate the real fields in place. + const next = new Uint8Array(placeBytes.byteLength); + next.set(placeBytes); - return ( - placeState + const f64 = new Float64Array(next.buffer, 0, next.byteLength / 8); + const strideF64 = layout.strideBytes / 8; + const realOffsets = layout.realFieldF64Offsets; + const realFieldCount = realOffsets.length; + + for (let tokenIndex = 0; tokenIndex < numberOfTokens; tokenIndex++) { + for (let fieldIndex = 0; fieldIndex < realFieldCount; fieldIndex++) { // Apply Euler method: nextState = currentState + derivative * dt - .map((value, index) => value + derivatives[index]! * dt) - ); + const valueIndex = tokenIndex * strideF64 + realOffsets[fieldIndex]!; + f64[valueIndex] = + (f64[valueIndex] ?? 0) + + (derivatives[tokenIndex * realFieldCount + fieldIndex] ?? 0) * dt; + } + } + + return next; } 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 39c8e2dffba..af2f719db8f 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 @@ -1,18 +1,18 @@ import { describe, expect, it } from "vitest"; import { getArcEndpointPlaceId } from "../../arc-endpoints"; -import { - createEngineFrame, - createEngineFrameLayout, - type EngineFrameLayout, - type EngineFrameSnapshot, -} from "../frames/internal-frame"; +import { createEngineFrameLayout } from "../frames/internal-frame"; import { computePossibleTransition as computePossibleTransitionImpl } from "./compute-possible-transition"; +import { computeTokenSlotLayout } from "./token-layout"; +import { + decodeTokenBlock, + makeTestFrame, + type TestFrame, +} from "./token-layout.test-helpers"; import type { Color, Place, Transition } from "../../types/sdcpn"; import type { CompiledTransition, - EngineFrame, LambdaFn, SimulationInstance, TransitionKernelFn, @@ -33,8 +33,6 @@ const transitionState = (timeSinceLastFiringMs = 1.0) => ({ firingCount: 0, }); -type TestFrame = EngineFrame & { layout: EngineFrameLayout }; - function makePlace(id: string, name: string, colorId: string | null): Place { return { id, @@ -47,20 +45,6 @@ function makePlace(id: string, name: string, colorId: string | null): Place { }; } -function makeColor(dimensions: number): Color { - return { - id: `frame-type-${dimensions}`, - name: `Frame Type ${dimensions}`, - iconSlug: "circle", - displayColor: "#000000", - elements: Array.from({ length: dimensions }, (_, index) => ({ - elementId: `d${index}`, - name: `d${index}`, - type: "real", - })), - }; -} - function makeTransition( transition: Pick & Partial>, @@ -91,17 +75,6 @@ function makeCompiledTransitions({ }): Map { const placesMap = new Map(places.map((place) => [place.id, place])); const typesMap = new Map(types.map((type) => [type.id, type])); - const getElementNames = (placeId: string) => { - const place = placesMap.get(placeId); - if (!place?.colorId) { - return null; - } - - return ( - typesMap.get(place.colorId)?.elements.map((element) => element.name) ?? - null - ); - }; const getElements = (placeId: string) => { const place = placesMap.get(placeId); if (!place?.colorId) { @@ -126,23 +99,25 @@ function makeCompiledTransitions({ name: transition.name, inputPlaces: transition.inputArcs.map((arc) => { const placeId = getArcEndpointPlaceId(arc)!; + const elements = getElements(placeId); return { placeId, placeName: placesMap.get(placeId)?.name ?? placeId, weight: arc.weight, arcType: arc.type, - elementNames: getElementNames(placeId), - elements: getElements(placeId), + elements, + tokenLayout: elements ? computeTokenSlotLayout(elements) : null, }; }), outputPlaces: transition.outputArcs.map((arc) => { const placeId = getArcEndpointPlaceId(arc)!; + const elements = getElements(placeId); return { placeId, placeName: placesMap.get(placeId)?.name ?? placeId, weight: arc.weight, - elementNames: getElementNames(placeId), - elements: getElements(placeId), + elements, + tokenLayout: elements ? computeTokenSlotLayout(elements) : null, }; }), lambdaFn, @@ -197,30 +172,6 @@ function makeSimulation({ }; } -function makeFrame(snapshot: EngineFrameSnapshot): TestFrame { - const dimensions = new Set( - Object.values(snapshot.places).map((place) => place.dimensions), - ); - const layout = createEngineFrameLayout({ - places: Object.entries(snapshot.places).map(([id, place]) => - makePlace( - id, - id, - place.dimensions === 0 ? null : `frame-type-${place.dimensions}`, - ), - ), - transitions: Object.keys(snapshot.transitions).map((id) => - makeTransition({ id, inputArcs: [], outputArcs: [] }), - ), - types: [...dimensions] - .filter((dimension) => dimension > 0) - .map((dimension) => makeColor(dimension)), - }); - const frame = createEngineFrame(layout, snapshot) as TestFrame; - Object.defineProperty(frame, "layout", { value: layout }); - return frame; -} - function computePossibleTransition( frame: TestFrame, simulation: SimulationInstance, @@ -249,14 +200,13 @@ describe("computePossibleTransition", () => { ["t1", () => ({ p2: [{ x: 1.0 }] })], ]), }); - const frame = makeFrame({ + const frame = makeTestFrame({ places: { - p1: { offset: 0, count: 1, dimensions: 1 }, + p1: { elements: type1.elements, tokens: [{ x: 1.0 }] }, }, transitions: { t1: transitionState(), }, - buffer: new Float64Array([1.0]), }); expect(computePossibleTransition(frame, simulation, "t1", 42)).toBeNull(); @@ -275,14 +225,13 @@ describe("computePossibleTransition", () => { ["t1", () => ({})], ]), }); - const frame = makeFrame({ + const frame = makeTestFrame({ places: { - p1: { offset: 0, count: 2, dimensions: 0 }, + p1: { count: 2 }, }, transitions: { t1: transitionState(), }, - buffer: new Float64Array([]), }); expect(computePossibleTransition(frame, simulation, "t1", 42)).toBeNull(); @@ -312,16 +261,15 @@ describe("computePossibleTransition", () => { ["t1", () => ({ Target: [{ x: 5.0 }] })], ]), }); - const frame = makeFrame({ + const frame = makeTestFrame({ places: { - p1: { offset: 0, count: 1, dimensions: 1 }, - p2: { offset: 1, count: 0, dimensions: 0 }, - p3: { offset: 1, count: 0, dimensions: 1 }, + p1: { elements: type1.elements, tokens: [{ x: 3.0 }] }, + p2: { count: 0 }, + p3: { elements: type1.elements, tokens: [] }, }, transitions: { t1: transitionState(), }, - buffer: new Float64Array([3.0]), }); const result = computePossibleTransition(frame, simulation, "t1", 42); @@ -329,7 +277,9 @@ describe("computePossibleTransition", () => { expect(result).not.toBeNull(); expect(result!.remove).toHaveProperty("p1"); expect(result!.remove).not.toHaveProperty("p2"); - expect(result!.add).toMatchObject({ p3: [[5.0]] }); + expect( + result!.add.p3!.map((block) => decodeTokenBlock(type1.elements, block)), + ).toEqual([{ x: 5 }]); }); it("passes read arc tokens to lambda and kernel without consuming them", () => { @@ -377,16 +327,15 @@ describe("computePossibleTransition", () => { ], ]), }); - const frame = makeFrame({ + const frame = makeTestFrame({ places: { - p1: { offset: 0, count: 1, dimensions: 1 }, - p2: { offset: 1, count: 1, dimensions: 1 }, - p3: { offset: 2, count: 0, dimensions: 1 }, + p1: { elements: type1.elements, tokens: [{ x: 3.0 }] }, + p2: { elements: type1.elements, tokens: [{ x: 7.0 }] }, + p3: { elements: type1.elements, tokens: [] }, }, transitions: { t1: transitionState(), }, - buffer: new Float64Array([3.0, 7.0]), }); const result = computePossibleTransition(frame, simulation, "t1", 42); @@ -401,7 +350,10 @@ describe("computePossibleTransition", () => { Guard: [{ x: 7.0 }], }); expect(result!.remove).toEqual({ p1: new Set([0]) }); - expect(result!.add).toEqual({ p3: [[7.0]] }); + expect(Object.keys(result!.add)).toEqual(["p3"]); + expect( + result!.add.p3!.map((block) => decodeTokenBlock(type1.elements, block)), + ).toEqual([{ x: 7 }]); }); it("returns token combinations when transition is enabled and fires", () => { @@ -424,15 +376,14 @@ describe("computePossibleTransition", () => { ["t1", () => ({ "Place 2": [{ x: 2.0 }] })], ]), }); - const frame = makeFrame({ + const frame = makeTestFrame({ places: { - p1: { offset: 0, count: 2, dimensions: 1 }, - p2: { offset: 2, count: 0, dimensions: 1 }, + p1: { elements: type1.elements, tokens: [{ x: 1.0 }, { x: 1.5 }] }, + p2: { elements: type1.elements, tokens: [] }, }, transitions: { t1: transitionState(), }, - buffer: new Float64Array([1.0, 1.5]), }); const result = computePossibleTransition(frame, simulation, "t1", 42); @@ -440,8 +391,10 @@ describe("computePossibleTransition", () => { expect(result).not.toBeNull(); expect(result).toMatchObject({ remove: { p1: new Set([0]) }, - add: { p2: [[2.0]] }, }); + expect( + result!.add.p2!.map((block) => decodeTokenBlock(type1.elements, block)), + ).toEqual([{ x: 2 }]); expect(result?.newRngState).toBeTypeOf("number"); }); @@ -494,15 +447,17 @@ describe("computePossibleTransition", () => { ], ]), }); - const frame = makeFrame({ + const frame = makeTestFrame({ places: { - p1: { offset: 0, count: 1, dimensions: 3 }, - p2: { offset: 3, count: 0, dimensions: 3 }, + p1: { + elements: typedColor.elements, + tokens: [{ amount: 1.25, count: 3, active: true }], + }, + p2: { elements: typedColor.elements, tokens: [] }, }, transitions: { t1: transitionState(), }, - buffer: new Float64Array([1.25, 3, 1]), }); const result = computePossibleTransition(frame, simulation, "t1", 42); @@ -518,7 +473,11 @@ describe("computePossibleTransition", () => { }); expect(result).toMatchObject({ remove: { p1: new Set([0]) }, - add: { p2: [[2.5, 4, 0]] }, }); + expect( + result!.add.p2!.map((block) => + decodeTokenBlock(typedColor.elements, block), + ), + ).toEqual([{ amount: 2.5, count: 4, active: false }]); }); }); 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 699aab99e71..e62298118dc 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,7 +4,12 @@ import { materializeEngineFrame } from "../frames/internal-frame"; import { enumerateWeightedMarkingIndicesGenerator } from "./enumerate-weighted-markings"; import { sampleDistribution } from "./sample-distribution"; import { nextRandom } from "./seeded-rng"; -import { decodeTokenRecord, encodeTokenAttributeValue } from "./token-values"; +import { + createTokenRegionViews, + encodeTokenValuesToBytes, + readTokenRecord, +} from "./token-layout"; +import { encodeTokenAttributeValue } from "./token-values"; import type { ID } from "../../types/sdcpn"; import type { @@ -15,12 +20,14 @@ import type { type PlaceID = ID; +const EMPTY_TOKEN_BYTES = new Uint8Array(0); + /** * Takes an EngineFrame, a SimulationInstance, a TransitionID, and computes the possible transition. * Returns null if no transition is possible. * Returns a record with: * - removed: Map from PlaceID to Set of token indices to remove. - * - added: Map from PlaceID to array of token values to create. + * - added: Map from PlaceID to array of packed token byte blocks to create. * - newRngState: Updated RNG seed after consuming randomness */ export function computePossibleTransition( @@ -30,7 +37,7 @@ export function computePossibleTransition( rngState: number, ): null | { remove: Record | number>; - add: Record; + add: Record; newRngState: number; } { const snapshot = materializeEngineFrame(simulation.frameLayout, frame); @@ -82,17 +89,21 @@ export function computePossibleTransition( const [U1, newRngState] = nextRandom(rngState); const { timeSinceLastFiringMs } = transitionState; - // TODO: This should acumulate lambda over time, but for now we just consider that lambda is constant per combination. - // (just multiply by time since last transition) + // Shared views over the frame's token byte region. + const tokenViews = createTokenRegionViews( + snapshot.buffer.buffer, + snapshot.buffer.byteOffset, + snapshot.buffer.byteLength, + ); const inputPlacesWithTokenValues = inputPlaces.filter( - (place) => place.dimensions > 0 && place.arcType !== "inhibitor", + (place) => place.strideBytes > 0 && place.arcType !== "inhibitor", ); - const standardInputPlacesWithZeroDimensions = inputPlaces.filter( - (place) => place.dimensions === 0 && place.arcType === "standard", + const standardInputPlacesWithZeroStride = inputPlaces.filter( + (place) => place.strideBytes === 0 && place.arcType === "standard", ); - // TODO: This should acumulate lambda over time, but for now we just consider that lambda is constant per combination. + // TODO: This should accumulate lambda over time, but for now we just consider that lambda is constant per combination. // (just multiply by time since last transition) const tokensCombinations = enumerateWeightedMarkingIndicesGenerator( inputPlacesWithTokenValues, @@ -109,17 +120,11 @@ export function computePossibleTransition( placeTokenIndices, ] of tokenCombinationIndices.entries()) { const inputPlace = inputPlacesWithTokenValues[placeIndex]!; - const placeOffsetInBuffer = inputPlace.offset; - const dimensions = inputPlace.dimensions; + const placeByteOffset = inputPlace.byteOffset; + const strideBytes = inputPlace.strideBytes; - if (!inputPlace.elementNames) { - throw new SDCPNItemError( - `Place \`${inputPlace.placeName}\` has no type defined`, - inputPlace.placeId, - ); - } - const elements = inputPlace.elements; - if (!elements) { + const tokenLayout = inputPlace.tokenLayout; + if (!tokenLayout) { throw new SDCPNItemError( `Place \`${inputPlace.placeName}\` has no type defined`, inputPlace.placeId, @@ -127,16 +132,14 @@ export function computePossibleTransition( } // Convert tokens for this place to objects with named dimensions - const placeTokens = placeTokenIndices.map((tokenIndexInPlace) => { - // Offset within the global buffer - const globalIndex = - placeOffsetInBuffer + tokenIndexInPlace * dimensions; - - return decodeTokenRecord( - elements, - snapshot.buffer.subarray(globalIndex, globalIndex + dimensions), - ); - }); + const placeTokens = placeTokenIndices.map((tokenIndexInPlace) => + readTokenRecord( + tokenLayout, + tokenViews.f64, + tokenViews.u8, + placeByteOffset + tokenIndexInPlace * strideBytes, + ), + ); tokenCombinationValues[inputPlace.placeName] = placeTokens; } @@ -190,9 +193,10 @@ export function computePossibleTransition( // Convert transition kernel output back to place-indexed format // The kernel returns { PlaceName: [{ x: 0, y: 0 }, ...], ... } - // We need to convert this to place IDs and flatten to number[][] + // We need to convert this to place IDs and pack each token into its + // stride-sized byte block. // Distribution values are sampled here, advancing the RNG state. - const addMap: Record = {}; + const addMap: Record = {}; let currentRngState = newRngState; for (const outputPlace of transition.outputPlaces) { @@ -203,13 +207,12 @@ export function computePossibleTransition( ); } - // If place has no type, create n empty tuples where n is the arc weight - if (!outputPlace.elementNames) { - const emptyTokens: number[][] = Array.from( + // If place has no type, create n empty blocks where n is the arc weight + if (!outputPlace.tokenLayout) { + addMap[outputPlace.placeId] = Array.from( { length: outputPlace.weight }, - () => [], + () => EMPTY_TOKEN_BYTES, ); - addMap[outputPlace.placeId] = emptyTokens; continue; } @@ -222,11 +225,12 @@ export function computePossibleTransition( ); } - // Convert token objects back to number arrays in correct order, - // sampling any Distribution values using the RNG - const tokenArrays: number[][] = []; + // Sample any Distribution values using the RNG (in element + // declaration order), encode each value, then pack the token into a + // stride-sized byte block. + const tokenBlocks: Uint8Array[] = []; for (const token of outputTokens) { - const values: number[] = []; + const encodedByName: Record = {}; for (const element of outputPlace.elements ?? []) { let raw = token[element.name]; if (isDistribution(raw)) { @@ -242,18 +246,18 @@ export function computePossibleTransition( currentRngState = nextRng; raw = sampled; } - values.push( - encodeTokenAttributeValue( - element, - raw, - `Transition ${transition.id} output ${outputPlace.placeName}.${element.name}`, - ), + encodedByName[element.name] = encodeTokenAttributeValue( + element, + raw, + `Transition ${transition.id} output ${outputPlace.placeName}.${element.name}`, ); } - tokenArrays.push(values); + tokenBlocks.push( + encodeTokenValuesToBytes(outputPlace.tokenLayout, encodedByName), + ); } - addMap[outputPlace.placeId] = tokenArrays; + addMap[outputPlace.placeId] = tokenBlocks; } return { @@ -261,7 +265,7 @@ export function computePossibleTransition( // TODO: Need to provide better typing here, to not let TS infer to any[] // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment remove: Object.fromEntries([ - ...standardInputPlacesWithZeroDimensions.map((inputPlace) => [ + ...standardInputPlacesWithZeroStride.map((inputPlace) => [ inputPlace.placeId, inputPlace.weight, ]), @@ -274,7 +278,7 @@ export function computePossibleTransition( }, ), ]), - // Map from place ID to array of token values to + // Map from place ID to array of packed token byte blocks to // create as per transition kernel output add: addMap, newRngState: currentRngState, 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 18ec6d3ab0d..7934d5ad95c 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 @@ -2,18 +2,21 @@ import { describe, expect, it } from "vitest"; import { getArcEndpointPlaceId } from "../../arc-endpoints"; import { - createEngineFrame, createEngineFrameLayout, materializeEngineFrame, - type EngineFrameLayout, type EngineFrameSnapshot, } from "../frames/internal-frame"; import { executeTransitions as executeEngineTransitions } from "./execute-transitions"; +import { computeTokenSlotLayout } from "./token-layout"; +import { + decodePlaceTokens, + makeTestFrame, + type TestFrame, +} from "./token-layout.test-helpers"; import type { Color, Place, Transition } from "../../types/sdcpn"; import type { CompiledTransition, - EngineFrame, LambdaFn, SimulationInstance, TransitionKernelFn, @@ -44,8 +47,6 @@ const transitionState = (timeSinceLastFiringMs = 1.0) => ({ firingCount: 0, }); -type TestFrame = EngineFrame & { layout: EngineFrameLayout }; - function makePlace(id: string, name: string, colorId: string | null): Place { return { id, @@ -58,20 +59,6 @@ function makePlace(id: string, name: string, colorId: string | null): Place { }; } -function makeColor(dimensions: number): Color { - return { - id: `frame-type-${dimensions}`, - name: `Frame Type ${dimensions}`, - iconSlug: "circle", - displayColor: "#000000", - elements: Array.from({ length: dimensions }, (_, index) => ({ - elementId: `d${index}`, - name: `d${index}`, - type: "real", - })), - }; -} - function makeTransition( transition: Pick & Partial>, @@ -102,17 +89,6 @@ function makeCompiledTransitions({ }): Map { const placesMap = new Map(places.map((place) => [place.id, place])); const typesMap = new Map(types.map((type) => [type.id, type])); - const getElementNames = (placeId: string) => { - const place = placesMap.get(placeId); - if (!place?.colorId) { - return null; - } - - return ( - typesMap.get(place.colorId)?.elements.map((element) => element.name) ?? - null - ); - }; const getElements = (placeId: string) => { const place = placesMap.get(placeId); if (!place?.colorId) { @@ -137,23 +113,25 @@ function makeCompiledTransitions({ name: transition.name, inputPlaces: transition.inputArcs.map((arc) => { const placeId = getArcEndpointPlaceId(arc)!; + const elements = getElements(placeId); return { placeId, placeName: placesMap.get(placeId)?.name ?? placeId, weight: arc.weight, arcType: arc.type, - elementNames: getElementNames(placeId), - elements: getElements(placeId), + elements, + tokenLayout: elements ? computeTokenSlotLayout(elements) : null, }; }), outputPlaces: transition.outputArcs.map((arc) => { const placeId = getArcEndpointPlaceId(arc)!; + const elements = getElements(placeId); return { placeId, placeName: placesMap.get(placeId)?.name ?? placeId, weight: arc.weight, - elementNames: getElementNames(placeId), - elements: getElements(placeId), + elements, + tokenLayout: elements ? computeTokenSlotLayout(elements) : null, }; }), lambdaFn, @@ -208,30 +186,6 @@ function makeSimulation({ }; } -function makeFrame(snapshot: EngineFrameSnapshot): TestFrame { - const dimensions = new Set( - Object.values(snapshot.places).map((place) => place.dimensions), - ); - const layout = createEngineFrameLayout({ - places: Object.entries(snapshot.places).map(([id, place]) => - makePlace( - id, - id, - place.dimensions === 0 ? null : `frame-type-${place.dimensions}`, - ), - ), - transitions: Object.keys(snapshot.transitions).map((id) => - makeTransition({ id, inputArcs: [], outputArcs: [] }), - ), - types: [...dimensions] - .filter((dimension) => dimension > 0) - .map((dimension) => makeColor(dimension)), - }); - const frame = createEngineFrame(layout, snapshot) as TestFrame; - Object.defineProperty(frame, "layout", { value: layout }); - return frame; -} - function executeTransitions( frame: TestFrame, simulation: SimulationInstance, @@ -247,6 +201,7 @@ function executeTransitions( return { ...result, + rawFrame: result.frame, frame: result.frame === frame ? (frame as unknown as EngineFrameSnapshot) @@ -268,14 +223,13 @@ describe("executeTransitions", () => { ["t1", () => ({ p2: [{ x: 1.0 }] })], ]), }); - const frame = makeFrame({ + const frame = makeTestFrame({ places: { - p1: { offset: 0, count: 0, dimensions: 1 }, + p1: { elements: type1.elements, tokens: [] }, }, transitions: { t1: transitionState(), }, - buffer: new Float64Array([]), }); const result = executeTransitions( @@ -309,15 +263,14 @@ describe("executeTransitions", () => { ["t1", () => ({ "Place 2": [{ x: 2.0 }] })], ]), }); - const frame = makeFrame({ + const frame = makeTestFrame({ places: { - p1: { offset: 0, count: 2, dimensions: 1 }, - p2: { offset: 2, count: 0, dimensions: 1 }, + p1: { elements: type1.elements, tokens: [{ x: 1.0 }, { x: 1.5 }] }, + p2: { elements: type1.elements, tokens: [] }, }, transitions: { t1: transitionState(), }, - buffer: new Float64Array([1.0, 1.5]), }); const result = executeTransitions( @@ -328,9 +281,13 @@ describe("executeTransitions", () => { ); expect(result.frame.places.p1?.count).toBe(1); - expect(result.frame.buffer[0]).toBe(1.5); + expect(decodePlaceTokens(frame.layout, result.rawFrame, "p1")).toEqual([ + { x: 1.5 }, + ]); expect(result.frame.places.p2?.count).toBe(1); - expect(result.frame.buffer[1]).toBe(2.0); + expect(decodePlaceTokens(frame.layout, result.rawFrame, "p2")).toEqual([ + { x: 2.0 }, + ]); expect(result.frame.transitions.t1?.timeSinceLastFiringMs).toBe(0); expect(result.transitionFired).toBe(true); }); @@ -369,16 +326,15 @@ describe("executeTransitions", () => { ], ]), }); - const frame = makeFrame({ + const frame = makeTestFrame({ places: { - p1: { offset: 0, count: 1, dimensions: 1 }, - p2: { offset: 1, count: 1, dimensions: 1 }, - p3: { offset: 2, count: 0, dimensions: 1 }, + p1: { elements: type1.elements, tokens: [{ x: 3.0 }] }, + p2: { elements: type1.elements, tokens: [{ x: 7.0 }] }, + p3: { elements: type1.elements, tokens: [] }, }, transitions: { t1: transitionState(), }, - buffer: new Float64Array([3.0, 7.0]), }); const result = executeTransitions( @@ -392,7 +348,13 @@ describe("executeTransitions", () => { expect(result.frame.places.p1?.count).toBe(0); expect(result.frame.places.p2?.count).toBe(1); expect(result.frame.places.p3?.count).toBe(1); - expect(result.frame.buffer).toEqual(new Float64Array([7.0, 7.0])); + expect(decodePlaceTokens(frame.layout, result.rawFrame, "p1")).toEqual([]); + expect(decodePlaceTokens(frame.layout, result.rawFrame, "p2")).toEqual([ + { x: 7.0 }, + ]); + expect(decodePlaceTokens(frame.layout, result.rawFrame, "p3")).toEqual([ + { x: 7.0 }, + ]); }); it("executes multiple transitions sequentially with proper token removal between each", () => { @@ -429,17 +391,19 @@ describe("executeTransitions", () => { ["t2", () => ({ "Place 3": [{ x: 10.0 }] })], ]), }); - const frame = makeFrame({ + const frame = makeTestFrame({ places: { - p1: { offset: 0, count: 3, dimensions: 1 }, - p2: { offset: 3, count: 0, dimensions: 1 }, - p3: { offset: 3, count: 0, dimensions: 1 }, + p1: { + elements: type1.elements, + tokens: [{ x: 1.0 }, { x: 2.0 }, { x: 3.0 }], + }, + p2: { elements: type1.elements, tokens: [] }, + p3: { elements: type1.elements, tokens: [] }, }, transitions: { t1: transitionState(), t2: transitionState(), }, - buffer: new Float64Array([1.0, 2.0, 3.0]), }); const result = executeTransitions( @@ -476,15 +440,14 @@ describe("executeTransitions", () => { ["t1", () => ({ "Place 2": [{ x: 3.0, y: 4.0 }] })], ]), }); - const frame = makeFrame({ + const frame = makeTestFrame({ places: { - p1: { offset: 0, count: 1, dimensions: 2 }, - p2: { offset: 2, count: 0, dimensions: 2 }, + p1: { elements: type2.elements, tokens: [{ x: 1.0, y: 2.0 }] }, + p2: { elements: type2.elements, tokens: [] }, }, transitions: { t1: transitionState(), }, - buffer: new Float64Array([1.0, 2.0]), }); const result = executeTransitions( @@ -496,8 +459,9 @@ describe("executeTransitions", () => { expect(result.frame.places.p1?.count).toBe(0); expect(result.frame.places.p2?.count).toBe(1); - expect(result.frame.buffer[0]).toBe(3.0); - expect(result.frame.buffer[1]).toBe(4.0); + expect(decodePlaceTokens(frame.layout, result.rawFrame, "p2")).toEqual([ + { x: 3.0, y: 4.0 }, + ]); }); it("updates timeSinceLastFiringMs for transitions that did not fire", () => { @@ -533,16 +497,15 @@ describe("executeTransitions", () => { ["t2", () => ({ "Place 2": [{ x: 3.0 }] })], ]), }); - const frame = makeFrame({ + const frame = makeTestFrame({ places: { - p1: { offset: 0, count: 2, dimensions: 1 }, - p2: { offset: 2, count: 0, dimensions: 1 }, + p1: { elements: type1.elements, tokens: [{ x: 1.0 }, { x: 1.5 }] }, + p2: { elements: type1.elements, tokens: [] }, }, transitions: { t1: transitionState(0.5), t2: transitionState(0.3), }, - buffer: new Float64Array([1.0, 1.5]), }); const result = executeTransitions( diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/execute-transitions.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/execute-transitions.ts index f1b8676c0ff..39efc14a33a 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/execute-transitions.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/execute-transitions.ts @@ -18,20 +18,20 @@ type PlaceID = ID; /** * Adds tokens to multiple places in the simulation frame. * - * Takes an EngineFrame and a Map of Place IDs to arrays of token values, - * and returns a new EngineFrame with: - * - The specified tokens added to each place's section in the buffer + * Takes an EngineFrame and a Map of Place IDs to arrays of packed token byte + * blocks, and returns a new EngineFrame with: + * - The specified tokens appended to each place's section in the buffer * - Each place's count incremented by the number of added tokens - * - All subsequent places' offsets adjusted accordingly + * - All subsequent places' byte offsets adjusted accordingly * * @param frame - The simulation frame to modify - * @param tokensToAdd - Map from Place ID to array of token values to add (each token is an array of numbers) + * @param tokensToAdd - Map from Place ID to array of token byte blocks to add (each block is one packed token, strideBytes long) * @returns A new EngineFrame with the tokens added - * @throws Error if a place is not found or token dimensions don't match + * @throws Error if a place is not found or token byte sizes don't match */ function addTokensToSimulationFrame( frame: EngineFrame, - tokensToAdd: Map, + tokensToAdd: Map, layout: EngineFrameLayout, ): EngineFrame { // If no tokens to add, return frame as-is @@ -41,7 +41,7 @@ function addTokensToSimulationFrame( const snapshot = materializeEngineFrame(layout, frame); - // Validate all places and token dimensions first + // Validate all places and token byte sizes first for (const [placeId, tokens] of tokensToAdd) { const placeState = snapshot.places[placeId]; if (!placeState) { @@ -50,56 +50,61 @@ function addTokensToSimulationFrame( ); } - // Validate that all tokens have the correct dimensions - const expectedDimensions = placeState.dimensions; + // Validate that all token blocks have the correct byte size + const expectedStrideBytes = placeState.strideBytes; for (const token of tokens) { - if (token.length !== expectedDimensions) { + if (token.byteLength !== expectedStrideBytes) { throw new Error( - `Token dimension mismatch for place ${placeId}. Expected ${expectedDimensions}, got ${token.length}.`, + `Token byte size mismatch for place ${placeId}. Expected ${expectedStrideBytes}, got ${token.byteLength}.`, ); } } } - // Calculate total size increase needed in buffer - let totalSizeIncrease = 0; + // Calculate total byte size increase needed in buffer + let totalByteSizeIncrease = 0; for (const [placeId, tokens] of tokensToAdd) { const placeState = snapshot.places[placeId]!; - const tokenSize = placeState.dimensions; - totalSizeIncrease += tokens.length * tokenSize; + totalByteSizeIncrease += tokens.length * placeState.strideBytes; } // Create a new buffer with increased size - const newBuffer = new Float64Array( - snapshot.buffer.length + totalSizeIncrease, + const newBuffer = new Uint8Array( + snapshot.buffer.byteLength + totalByteSizeIncrease, ); - // Process places in order of their offsets to build the new buffer + // Process places in order of their byte offsets to build the new buffer const placesByOffset = Object.entries(snapshot.places).sort( - (a, b) => a[1].offset - b[1].offset, + (a, b) => a[1].byteOffset - b[1].byteOffset, ); const newPlaces: EngineFrameSnapshot["places"] = { ...snapshot.places }; - let sourceIndex = 0; - let targetIndex = 0; + let targetByteOffset = 0; for (const [placeId, placeState] of placesByOffset) { - const { count, dimensions } = placeState; - const tokenSize = dimensions; - const placeSize = count * tokenSize; - - // Copy existing tokens from this place - for (let i = 0; i < placeSize; i++) { - newBuffer[targetIndex++] = snapshot.buffer[sourceIndex++]!; + const { count, strideBytes } = placeState; + const placeByteSize = count * strideBytes; + + // Copy existing tokens from this place, reading at the place's recorded + // offset rather than a running counter so the copy stays correct even if + // the source region ever contains gaps. + if (placeByteSize > 0) { + newBuffer.set( + snapshot.buffer.subarray( + placeState.byteOffset, + placeState.byteOffset + placeByteSize, + ), + targetByteOffset, + ); + targetByteOffset += placeByteSize; } // Add new tokens for this place if any const newTokens = tokensToAdd.get(placeId); if (newTokens) { for (const token of newTokens) { - for (const value of token) { - newBuffer[targetIndex++] = value; - } + newBuffer.set(token, targetByteOffset); + targetByteOffset += token.byteLength; } // Update this place's count @@ -110,19 +115,18 @@ function addTokensToSimulationFrame( } } - // Recalculate all offsets based on the new buffer layout - let currentOffset = 0; + // Recalculate all byte offsets based on the new buffer layout + let currentByteOffset = 0; for (const [placeId, _placeState] of placesByOffset) { const updatedState = newPlaces[placeId]!; - const tokenSize = updatedState.dimensions; - const placeSize = updatedState.count * tokenSize; + const placeByteSize = updatedState.count * updatedState.strideBytes; newPlaces[placeId] = { ...updatedState, - offset: currentOffset, + byteOffset: currentByteOffset, }; - currentOffset += placeSize; + currentByteOffset += placeByteSize; } return createEngineFrame(layout, { @@ -166,8 +170,8 @@ export function executeTransitions( dt: number, rngState: number, ): ExecuteTransitionsResult { - // Map to accumulate all tokens to add: PlaceID -> array of token values - const tokensToAdd = new Map(); + // Map to accumulate all tokens to add: PlaceID -> array of token byte blocks + const tokensToAdd = new Map(); // Keep track of which transitions fired for updating timeSinceLastFiringMs const transitionsFired = new Set(); diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/remove-tokens-from-simulation-frame.test.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/remove-tokens-from-simulation-frame.test.ts index e57d6ff450d..f85ff8248f3 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/remove-tokens-from-simulation-frame.test.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/remove-tokens-from-simulation-frame.test.ts @@ -1,85 +1,41 @@ import { describe, expect, it } from "vitest"; -import { - createEngineFrame, - createEngineFrameLayout, - materializeEngineFrame, - type EngineFrameLayout, - type EngineFrameSnapshot, -} from "../frames/internal-frame"; +import { materializeEngineFrame } from "../frames/internal-frame"; import { removeTokensFromSimulationFrame as removeTokensFromEngineFrame } from "./remove-tokens-from-simulation-frame"; - -import type { Color, Place } from "../../types/sdcpn"; +import { + decodePlaceTokens, + makeTestFrame, + realElements, + type TestFrame, +} from "./token-layout.test-helpers"; + +import type { TokenRecord } from "../../types/sdcpn"; +import type { EngineFrameSnapshot } from "../frames/internal-frame"; import type { EngineFrame } from "./types"; -type TestFrame = EngineFrame & { layout: EngineFrameLayout }; - -function makeColor(dimensions: number): Color { - return { - id: `type-${dimensions}`, - name: `Type ${dimensions}`, - iconSlug: "circle", - displayColor: "#000000", - elements: Array.from({ length: dimensions }, (_, index) => ({ - elementId: `d${index}`, - name: `d${index}`, - type: "real", - })), - }; -} - -function makePlace(id: string, dimensions: number): Place { - return { - id, - name: id, - colorId: dimensions === 0 ? null : `type-${dimensions}`, - dynamicsEnabled: false, - differentialEquationId: null, - x: 0, - y: 0, - }; -} - -function makeFrame(snapshot: EngineFrameSnapshot): TestFrame { - const dimensions = new Set( - Object.values(snapshot.places).map((place) => place.dimensions), - ); - const layout = createEngineFrameLayout({ - places: Object.entries(snapshot.places).map(([id, place]) => - makePlace(id, place.dimensions), - ), - transitions: [], - types: [...dimensions] - .filter((dimension) => dimension > 0) - .map((dimension) => makeColor(dimension)), - }); - const frame = createEngineFrame(layout, snapshot) as TestFrame; - Object.defineProperty(frame, "layout", { value: layout }); - return frame; -} - function removeTokensFromSimulationFrame( frame: TestFrame, tokensToRemove: Map | number>, -): EngineFrameSnapshot { +): { + frame: EngineFrame; + snapshot: EngineFrameSnapshot; + decode: (placeId: string) => TokenRecord[]; +} { const result = removeTokensFromEngineFrame( frame, tokensToRemove, frame.layout, ); - if (result === frame) { - return frame as unknown as EngineFrameSnapshot; - } - return materializeEngineFrame(frame.layout, result); + return { + frame: result, + snapshot: materializeEngineFrame(frame.layout, result), + decode: (placeId) => decodePlaceTokens(frame.layout, result, placeId), + }; } describe("removeTokensFromSimulationFrame", () => { it("throws error when place ID is not found", () => { - const frame = makeFrame({ - places: {}, - transitions: {}, - buffer: new Float64Array([]), - }); + const frame = makeTestFrame({ places: {} }); expect(() => { removeTokensFromSimulationFrame( @@ -90,34 +46,32 @@ describe("removeTokensFromSimulationFrame", () => { }); it("returns frame unchanged when tokens map is empty", () => { - const frame = makeFrame({ + const frame = makeTestFrame({ places: { p1: { - offset: 0, - count: 3, - dimensions: 2, + elements: realElements("d0", "d1"), + tokens: [ + { d0: 1, d1: 2 }, + { d0: 3, d1: 4 }, + { d0: 5, d1: 6 }, + ], }, }, - transitions: {}, - buffer: new Float64Array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0]), }); - const result = removeTokensFromSimulationFrame(frame, new Map()); + const result = removeTokensFromEngineFrame(frame, new Map(), frame.layout); expect(result).toBe(frame); }); it("throws error when token index is out of bounds", () => { - const frame = makeFrame({ + const frame = makeTestFrame({ places: { p1: { - offset: 0, - count: 2, - dimensions: 1, + elements: realElements("d0"), + tokens: [{ d0: 1 }, { d0: 2 }], }, }, - transitions: {}, - buffer: new Float64Array([1.0, 2.0]), }); expect(() => { @@ -126,38 +80,32 @@ describe("removeTokensFromSimulationFrame", () => { }); it("returns frame unchanged when place has empty set of indices", () => { - const frame = makeFrame({ + const frame = makeTestFrame({ places: { p1: { - offset: 0, - count: 3, - dimensions: 1, + elements: realElements("d0"), + tokens: [{ d0: 1 }, { d0: 2 }, { d0: 3 }], }, }, - transitions: {}, - buffer: new Float64Array([1.0, 2.0, 3.0]), }); const result = removeTokensFromSimulationFrame( frame, - new Map([["p1", new Set()]]), + new Map([["p1", new Set()]]), ); - expect(result.buffer).toEqual(new Float64Array([1.0, 2.0, 3.0])); - expect(result.places.p1?.count).toBe(3); + expect(result.decode("p1")).toEqual([{ d0: 1 }, { d0: 2 }, { d0: 3 }]); + expect(result.snapshot.places.p1?.count).toBe(3); }); - it("removes a single token from a place with 1D tokens", () => { - const frame = makeFrame({ + it("removes a single token from a place with one attribute", () => { + const frame = makeTestFrame({ places: { p1: { - offset: 0, - count: 3, - dimensions: 1, + elements: realElements("d0"), + tokens: [{ d0: 1 }, { d0: 2 }, { d0: 3 }], }, }, - transitions: {}, - buffer: new Float64Array([1.0, 2.0, 3.0]), }); const result = removeTokensFromSimulationFrame( @@ -165,22 +113,19 @@ describe("removeTokensFromSimulationFrame", () => { new Map([["p1", new Set([1])]]), ); - expect(result.buffer).toEqual(new Float64Array([1.0, 3.0])); - expect(result.places.p1?.count).toBe(2); - expect(result.places.p1?.offset).toBe(0); + expect(result.decode("p1")).toEqual([{ d0: 1 }, { d0: 3 }]); + expect(result.snapshot.places.p1?.count).toBe(2); + expect(result.snapshot.places.p1?.byteOffset).toBe(0); }); - it("removes multiple tokens from a place with 1D tokens", () => { - const frame = makeFrame({ + it("removes multiple tokens from a place with one attribute", () => { + const frame = makeTestFrame({ places: { p1: { - offset: 0, - count: 4, - dimensions: 1, + elements: realElements("d0"), + tokens: [{ d0: 1 }, { d0: 2 }, { d0: 3 }, { d0: 4 }], }, }, - transitions: {}, - buffer: new Float64Array([1.0, 2.0, 3.0, 4.0]), }); const result = removeTokensFromSimulationFrame( @@ -188,55 +133,55 @@ describe("removeTokensFromSimulationFrame", () => { new Map([["p1", new Set([0, 2])]]), ); - expect(result.buffer).toEqual(new Float64Array([2.0, 4.0])); - expect(result.places.p1?.count).toBe(2); - expect(result.places.p1?.offset).toBe(0); + expect(result.decode("p1")).toEqual([{ d0: 2 }, { d0: 4 }]); + expect(result.snapshot.places.p1?.count).toBe(2); + expect(result.snapshot.places.p1?.byteOffset).toBe(0); }); - it("removes tokens from a place with multi-dimensional tokens", () => { - const frame = makeFrame({ + it("removes tokens from a place with multi-attribute tokens", () => { + const frame = makeTestFrame({ places: { p1: { - offset: 0, - count: 3, - dimensions: 3, + elements: realElements("d0", "d1", "d2"), + tokens: [ + { d0: 1, d1: 2, d2: 3 }, + { d0: 4, d1: 5, d2: 6 }, + { d0: 7, d1: 8, d2: 9 }, + ], }, }, - transitions: {}, - // 3 tokens with 3 dimensions each: [1,2,3], [4,5,6], [7,8,9] - buffer: new Float64Array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]), }); - // Remove token at index 1 (middle token: [4,5,6]) + // Remove token at index 1 (middle token: {4,5,6}) const result = removeTokensFromSimulationFrame( frame, new Map([["p1", new Set([1])]]), ); - expect(result.buffer).toEqual( - new Float64Array([1.0, 2.0, 3.0, 7.0, 8.0, 9.0]), - ); - expect(result.places.p1?.count).toBe(2); - expect(result.places.p1?.offset).toBe(0); + expect(result.decode("p1")).toEqual([ + { d0: 1, d1: 2, d2: 3 }, + { d0: 7, d1: 8, d2: 9 }, + ]); + expect(result.snapshot.places.p1?.count).toBe(2); + expect(result.snapshot.places.p1?.byteOffset).toBe(0); }); - it("adjusts offsets for subsequent places after removal", () => { - const frame = makeFrame({ + it("adjusts byte offsets for subsequent places after removal", () => { + const frame = makeTestFrame({ places: { + // 2 tokens with stride 16 bytes → p2 starts at byte offset 32 p1: { - offset: 0, - count: 2, - dimensions: 2, + elements: realElements("d0", "d1"), + tokens: [ + { d0: 1, d1: 2 }, + { d0: 3, d1: 4 }, + ], }, p2: { - offset: 4, // After p1's 2 tokens * 2 dimensions - count: 3, - dimensions: 1, + elements: realElements("d0"), + tokens: [{ d0: 5 }, { d0: 6 }, { d0: 7 }], }, }, - transitions: {}, - // p1: [1,2], [3,4] | p2: [5], [6], [7] - buffer: new Float64Array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0]), }); // Remove one token from p1 @@ -245,31 +190,29 @@ describe("removeTokensFromSimulationFrame", () => { new Map([["p1", new Set([0])]]), ); - // Expected: p1: [3,4] | p2: [5], [6], [7] - expect(result.buffer).toEqual(new Float64Array([3.0, 4.0, 5.0, 6.0, 7.0])); - expect(result.places.p1?.count).toBe(1); - expect(result.places.p1?.offset).toBe(0); - // p2's offset should be adjusted from 4 to 2 (removed 2 elements) - expect(result.places.p2?.offset).toBe(2); - expect(result.places.p2?.count).toBe(3); + // Expected: p1: [{3,4}] | p2: [{5}], [{6}], [{7}] + expect(result.decode("p1")).toEqual([{ d0: 3, d1: 4 }]); + expect(result.decode("p2")).toEqual([{ d0: 5 }, { d0: 6 }, { d0: 7 }]); + expect(result.snapshot.places.p1?.count).toBe(1); + expect(result.snapshot.places.p1?.byteOffset).toBe(0); + // p2's byte offset should be adjusted from 32 to 16 (removed one + // 16-byte token) + expect(result.snapshot.places.p2?.byteOffset).toBe(16); + expect(result.snapshot.places.p2?.count).toBe(3); }); it("removes all tokens from a place", () => { - const frame = makeFrame({ + const frame = makeTestFrame({ places: { p1: { - offset: 0, - count: 2, - dimensions: 1, + elements: realElements("d0"), + tokens: [{ d0: 1 }, { d0: 2 }], }, p2: { - offset: 2, - count: 2, - dimensions: 1, + elements: realElements("d0"), + tokens: [{ d0: 3 }, { d0: 4 }], }, }, - transitions: {}, - buffer: new Float64Array([1.0, 2.0, 3.0, 4.0]), }); const result = removeTokensFromSimulationFrame( @@ -277,35 +220,31 @@ describe("removeTokensFromSimulationFrame", () => { new Map([["p1", new Set([0, 1])]]), ); - expect(result.buffer).toEqual(new Float64Array([3.0, 4.0])); - expect(result.places.p1?.count).toBe(0); - expect(result.places.p1?.offset).toBe(0); - expect(result.places.p2?.offset).toBe(0); - expect(result.places.p2?.count).toBe(2); + expect(result.decode("p1")).toEqual([]); + expect(result.decode("p2")).toEqual([{ d0: 3 }, { d0: 4 }]); + expect(result.snapshot.places.p1?.count).toBe(0); + expect(result.snapshot.places.p1?.byteOffset).toBe(0); + expect(result.snapshot.places.p2?.byteOffset).toBe(0); + expect(result.snapshot.places.p2?.count).toBe(2); }); it("handles removal from middle place with three places", () => { - const frame = makeFrame({ + const frame = makeTestFrame({ places: { + // Stride 8 bytes each: p1 @ 0, p2 @ 16, p3 @ 40 p1: { - offset: 0, - count: 2, - dimensions: 1, + elements: realElements("d0"), + tokens: [{ d0: 1 }, { d0: 2 }], }, p2: { - offset: 2, - count: 3, - dimensions: 1, + elements: realElements("d0"), + tokens: [{ d0: 3 }, { d0: 4 }, { d0: 5 }], }, p3: { - offset: 5, - count: 2, - dimensions: 1, + elements: realElements("d0"), + tokens: [{ d0: 6 }, { d0: 7 }], }, }, - transitions: {}, - // p1: [1, 2] | p2: [3, 4, 5] | p3: [6, 7] - buffer: new Float64Array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0]), }); // Remove one token from p2 (middle place) @@ -314,44 +253,44 @@ describe("removeTokensFromSimulationFrame", () => { new Map([["p2", new Set([1])]]), ); - // Expected: p1: [1, 2] | p2: [3, 5] | p3: [6, 7] - expect(result.buffer).toEqual( - new Float64Array([1.0, 2.0, 3.0, 5.0, 6.0, 7.0]), - ); - expect(result.places.p1?.offset).toBe(0); - expect(result.places.p1?.count).toBe(2); - expect(result.places.p2?.offset).toBe(2); - expect(result.places.p2?.count).toBe(2); - // p3's offset should be adjusted from 5 to 4 (removed 1 element) - expect(result.places.p3?.offset).toBe(4); - expect(result.places.p3?.count).toBe(2); + // Expected: p1: [{1}, {2}] | p2: [{3}, {5}] | p3: [{6}, {7}] + expect(result.decode("p1")).toEqual([{ d0: 1 }, { d0: 2 }]); + expect(result.decode("p2")).toEqual([{ d0: 3 }, { d0: 5 }]); + expect(result.decode("p3")).toEqual([{ d0: 6 }, { d0: 7 }]); + expect(result.snapshot.places.p1?.byteOffset).toBe(0); + expect(result.snapshot.places.p1?.count).toBe(2); + expect(result.snapshot.places.p2?.byteOffset).toBe(16); + expect(result.snapshot.places.p2?.count).toBe(2); + // p3's byte offset should be adjusted from 40 to 32 (removed one + // 8-byte token) + expect(result.snapshot.places.p3?.byteOffset).toBe(32); + expect(result.snapshot.places.p3?.count).toBe(2); }); it("removes tokens from multiple places simultaneously", () => { - const frame = makeFrame({ + const frame = makeTestFrame({ places: { + // p1 @ 0 (stride 8), p2 @ 24 (stride 16), p3 @ 56 (stride 8) p1: { - offset: 0, - count: 3, - dimensions: 1, + elements: realElements("d0"), + tokens: [{ d0: 1 }, { d0: 2 }, { d0: 3 }], }, p2: { - offset: 3, - count: 2, - dimensions: 2, + elements: realElements("d0", "d1"), + tokens: [ + { d0: 4, d1: 5 }, + { d0: 6, d1: 7 }, + ], }, p3: { - offset: 7, - count: 2, - dimensions: 1, + elements: realElements("d0"), + tokens: [{ d0: 8 }, { d0: 9 }], }, }, - transitions: {}, - // p1: [1], [2], [3] | p2: [4,5], [6,7] | p3: [8], [9] - buffer: new Float64Array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0]), }); - // Remove tokens from multiple places: token 1 from p1, token 0 from p2, token 1 from p3 + // Remove tokens from multiple places: token 1 from p1, token 0 from p2, + // token 1 from p3 const result = removeTokensFromSimulationFrame( frame, new Map([ @@ -361,13 +300,15 @@ describe("removeTokensFromSimulationFrame", () => { ]), ); - // Expected: p1: [1], [3] | p2: [6,7] | p3: [8] - expect(result.buffer).toEqual(new Float64Array([1.0, 3.0, 6.0, 7.0, 8.0])); - expect(result.places.p1?.count).toBe(2); - expect(result.places.p1?.offset).toBe(0); - expect(result.places.p2?.count).toBe(1); - expect(result.places.p2?.offset).toBe(2); - expect(result.places.p3?.count).toBe(1); - expect(result.places.p3?.offset).toBe(4); + // Expected: p1: [{1}, {3}] | p2: [{6,7}] | p3: [{8}] + expect(result.decode("p1")).toEqual([{ d0: 1 }, { d0: 3 }]); + expect(result.decode("p2")).toEqual([{ d0: 6, d1: 7 }]); + expect(result.decode("p3")).toEqual([{ d0: 8 }]); + expect(result.snapshot.places.p1?.count).toBe(2); + expect(result.snapshot.places.p1?.byteOffset).toBe(0); + expect(result.snapshot.places.p2?.count).toBe(1); + expect(result.snapshot.places.p2?.byteOffset).toBe(16); + expect(result.snapshot.places.p3?.count).toBe(1); + expect(result.snapshot.places.p3?.byteOffset).toBe(32); }); }); diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/remove-tokens-from-simulation-frame.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/remove-tokens-from-simulation-frame.ts index a1cb9a2f403..7b04fc9df58 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/remove-tokens-from-simulation-frame.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/remove-tokens-from-simulation-frame.ts @@ -46,14 +46,14 @@ export function removeTokensFromSimulationFrame( ); } - // If zero dimensions (no type), we expect indices to be a number (count to remove) - if (placeState.dimensions === 0) { + // If zero stride (no type), we expect indices to be a number (count to remove) + if (placeState.strideBytes === 0) { if (typeof indices !== "number") { throw new Error( - `For place ${placeId} with zero dimensions, expected number of tokens to remove, got Set.`, + `For place ${placeId} with zero stride (uncoloured), expected number of tokens to remove, got Set.`, ); } - continue; // No further validation needed for zero-dimension places + continue; // No further validation needed for zero-stride places } else { if (typeof indices === "number") { throw new Error( @@ -72,74 +72,70 @@ export function removeTokensFromSimulationFrame( } } - // Build a set of all global buffer indices to remove - const globalIndicesToRemove = new Set(); - + // Compute the removed byte size to allocate the compacted buffer + let removedByteSize = 0; for (const [placeId, indices] of tokensToRemove) { const placeState = snapshot.places[placeId]!; - const { offset, dimensions } = placeState; - const tokenSize = dimensions; - - // Handle zero-dimension places (no buffer indices to remove) - if (typeof indices === "number") { - if (tokenSize === 0) { - // Nothing to do in buffer, just continue - continue; - } else { - throw new Error( - `For place ${placeId} with zero dimensions, expected number of tokens to remove, got Set.`, - ); - } - } - - for (const tokenIndex of indices) { - const tokenStartOffset = offset + tokenIndex * tokenSize; - for (let i = 0; i < tokenSize; i++) { - globalIndicesToRemove.add(tokenStartOffset + i); - } + if (typeof indices !== "number") { + removedByteSize += indices.size * placeState.strideBytes; } } - // Create a new buffer without the removed tokens - const newBufferSize = snapshot.buffer.length - globalIndicesToRemove.size; - const newBuffer = new Float64Array(newBufferSize); - - // Copy buffer excluding removed indices - let newBufferIndex = 0; - for (let i = 0; i < snapshot.buffer.length; i++) { - if (!globalIndicesToRemove.has(i)) { - newBuffer[newBufferIndex++] = snapshot.buffer[i]!; - } - } + // Create a new buffer without the removed tokens, compacting each place's + // kept tokens as contiguous byte ranges + const newBuffer = new Uint8Array( + snapshot.buffer.byteLength - removedByteSize, + ); - // Calculate offset adjustments for each place - // We need to track cumulative size removed before each place's offset + // Process places in order of their byte offsets const placesByOffset = Object.entries(snapshot.places).sort( - (a, b) => a[1].offset - b[1].offset, + (a, b) => a[1].byteOffset - b[1].byteOffset, ); const newPlaces: EngineFrameSnapshot["places"] = { ...snapshot.places }; - let cumulativeRemoved = 0; + let writeByteOffset = 0; for (const [placeId, placeState] of placesByOffset) { - const { offset, count, dimensions } = placeState; - const tokenSize = dimensions; - - // Count how many tokens are being removed from this place + const { byteOffset, count, strideBytes } = placeState; const entryInMap = tokensToRemove.get(placeId); - const tokensRemovedFromPlace = - typeof entryInMap === "number" ? entryInMap : (entryInMap?.size ?? 0); - const sizeRemovedFromPlace = tokensRemovedFromPlace * tokenSize; - // Update this place with adjusted offset and count + if (strideBytes === 0) { + const removedCount = typeof entryInMap === "number" ? entryInMap : 0; + newPlaces[placeId] = { + ...placeState, + byteOffset: writeByteOffset, + count: count - removedCount, + }; + continue; + } + + const removedIndices = + entryInMap instanceof Set ? entryInMap : new Set(); + const newPlaceByteOffset = writeByteOffset; + let keptCount = 0; + + for (let tokenIndex = 0; tokenIndex < count; tokenIndex++) { + if (removedIndices.has(tokenIndex)) { + continue; + } + + const tokenByteOffset = byteOffset + tokenIndex * strideBytes; + newBuffer.set( + snapshot.buffer.subarray( + tokenByteOffset, + tokenByteOffset + strideBytes, + ), + writeByteOffset, + ); + writeByteOffset += strideBytes; + keptCount++; + } + newPlaces[placeId] = { ...placeState, - offset: offset - cumulativeRemoved, - count: count - tokensRemovedFromPlace, + byteOffset: newPlaceByteOffset, + count: keptCount, }; - - // Add this place's removed size to cumulative for next places - cumulativeRemoved += sizeRemovedFromPlace; } // Return new frame with updated buffer and places diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/token-layout.test-helpers.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/token-layout.test-helpers.ts new file mode 100644 index 00000000000..b8d4dc8ab48 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/token-layout.test-helpers.ts @@ -0,0 +1,188 @@ +/** + * Test helpers for building engine frames from readable token + * record fixtures. Not shipped — only imported from `*.test.ts` files. + */ +import { + createEngineFrame, + createEngineFrameLayout, + readEngineFrame, + type EngineFrame, + type EngineFrameLayout, + type EngineFrameSnapshot, +} from "../frames/internal-frame"; +import { + computeTokenSlotLayout, + createTokenRegionViews, + encodeTokenToBytes, + readTokenRecord, + type TokenSlotLayout, +} from "./token-layout"; + +import type { Color, ID, TokenRecord, Transition } from "../../types/sdcpn"; +import type { SimulationTransitionState } from "../frames/transition-state"; + +export type ColorElement = Color["elements"][number]; + +/** Shorthand for `real` elements with the given names. */ +export const realElements = (...names: string[]): ColorElement[] => + names.map((name) => ({ elementId: name, name, type: "real" as const })); + +/** + * One place's fixture: either a coloured place with token records, or an + * uncoloured place with only a count. + */ +export type TestPlaceSpec = + | { + elements: readonly ColorElement[]; + tokens: readonly Record[]; + } + | { elements?: undefined; count: number }; + +export type TestFrame = EngineFrame & { layout: EngineFrameLayout }; + +/** Packs token records into a contiguous byte region for one colour. */ +export function buildTokenBytes( + layout: TokenSlotLayout, + tokens: readonly Record[], +): Uint8Array { + const bytes = new Uint8Array(tokens.length * layout.strideBytes); + for (const [tokenIndex, token] of tokens.entries()) { + bytes.set( + encodeTokenToBytes(layout, token, "Test token"), + tokenIndex * layout.strideBytes, + ); + } + return bytes; +} + +/** Decodes one packed token byte block back into a record. */ +export function decodeTokenBlock( + elements: readonly ColorElement[], + block: Uint8Array, +): TokenRecord { + const layout = computeTokenSlotLayout(elements); + const { f64, u8 } = createTokenRegionViews( + block.buffer, + block.byteOffset, + block.byteLength, + ); + return readTokenRecord(layout, f64, u8, 0); +} + +/** Decodes all of one place's tokens from a frame. */ +export function decodePlaceTokens( + layout: EngineFrameLayout, + frame: EngineFrame, + placeId: ID, +): TokenRecord[] { + const view = readEngineFrame(layout, frame); + const placeState = view.getPlaceState(placeId); + const placeIndex = layout.placeIndexById.get(placeId); + const tokenLayout = + placeIndex === undefined ? null : layout.placeTokenLayouts[placeIndex]; + if (!placeState || !tokenLayout || tokenLayout.strideBytes === 0) { + return []; + } + + const tokens: TokenRecord[] = []; + for (let tokenIndex = 0; tokenIndex < placeState.count; tokenIndex++) { + tokens.push( + readTokenRecord( + tokenLayout, + view.tokenF64, + view.tokenBytes, + placeState.byteOffset + tokenIndex * placeState.strideBytes, + ), + ); + } + return tokens; +} + +const makeStubTransition = (id: ID): Transition => ({ + id, + name: id, + inputArcs: [], + outputArcs: [], + lambdaType: "stochastic", + lambdaCode: "return 1.0;", + transitionKernelCode: "return {};", + x: 0, + y: 0, +}); + +/** + * Builds an engine frame (and its layout, attached as `.layout`) from + * readable per-place fixtures. Coloured places get a synthetic colour named + * `color:` carrying the given elements. + */ +export function makeTestFrame({ + places, + transitions = {}, +}: { + places: Record; + transitions?: Record; +}): TestFrame { + const types: Color[] = []; + const sdcpnPlaces = Object.entries(places).map(([placeId, spec]) => { + let colorId: string | null = null; + if (spec.elements) { + colorId = `color:${placeId}`; + types.push({ + id: colorId, + name: colorId, + iconSlug: "circle", + displayColor: "#000000", + elements: [...spec.elements], + }); + } + return { + id: placeId, + name: placeId, + colorId, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }; + }); + + const layout = createEngineFrameLayout({ + places: sdcpnPlaces, + transitions: Object.keys(transitions).map(makeStubTransition), + types, + }); + + const snapshotPlaces: EngineFrameSnapshot["places"] = {}; + const placeBytes: Uint8Array[] = []; + let byteOffset = 0; + for (const [placeIndex, [placeId, spec]] of Object.entries( + places, + ).entries()) { + const tokenLayout = layout.placeTokenLayouts[placeIndex] ?? null; + const strideBytes = tokenLayout?.strideBytes ?? 0; + const count = spec.elements ? spec.tokens.length : spec.count; + const bytes = + spec.elements && tokenLayout + ? buildTokenBytes(tokenLayout, spec.tokens) + : new Uint8Array(0); + + snapshotPlaces[placeId] = { byteOffset, count, strideBytes }; + placeBytes.push(bytes); + byteOffset += bytes.byteLength; + } + + const buffer = new Uint8Array(byteOffset); + let writeOffset = 0; + for (const bytes of placeBytes) { + buffer.set(bytes, writeOffset); + writeOffset += bytes.byteLength; + } + + const frame = createEngineFrame(layout, { + places: snapshotPlaces, + transitions, + buffer, + }) as TestFrame; + Object.defineProperty(frame, "layout", { value: layout }); + return frame; +} diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/token-layout.test.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/token-layout.test.ts new file mode 100644 index 00000000000..1419e7611ba --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/token-layout.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from "vitest"; + +import { + computeTokenSlotLayout, + createTokenRegionViews, + encodeTokenToBytes, + encodeTokenValuesToBytes, + readTokenRecord, +} from "./token-layout"; + +import type { Color } from "../../types/sdcpn"; + +type ColorElement = Color["elements"][number]; + +const element = (name: string, type: ColorElement["type"]): ColorElement => ({ + elementId: name, + name, + type, +}); + +describe("computeTokenSlotLayout", () => { + it("returns a zero-stride layout for an empty colour", () => { + const layout = computeTokenSlotLayout([]); + + expect(layout.strideBytes).toBe(0); + expect(layout.fields).toEqual([]); + expect(layout.paddingRanges).toEqual([]); + expect(layout.realFieldF64Offsets).toEqual([]); + }); + + it("orders fields by decreasing alignment, stable within equal alignment", () => { + const layout = computeTokenSlotLayout([ + element("active", "boolean"), + element("amount", "real"), + element("count", "integer"), + element("done", "boolean"), + ]); + + expect( + layout.fields.map((field) => [ + field.element.name, + field.kind, + field.byteOffset, + ]), + ).toEqual([ + ["amount", "f64", 0], + ["count", "f64", 8], + ["active", "u8", 16], + ["done", "u8", 17], + ]); + expect(layout.strideBytes).toBe(24); + expect(layout.paddingRanges).toEqual([{ start: 18, end: 24 }]); + expect(layout.realFieldF64Offsets).toEqual([0]); + }); + + it("rounds the stride up to 8 bytes for boolean-only colours", () => { + const layout = computeTokenSlotLayout([ + element("a", "boolean"), + element("b", "boolean"), + ]); + + expect(layout.strideBytes).toBe(8); + expect(layout.fields.map((field) => field.byteOffset)).toEqual([0, 1]); + expect(layout.paddingRanges).toEqual([{ start: 2, end: 8 }]); + expect(layout.realFieldF64Offsets).toEqual([]); + }); + + it("keeps f64-only colours padding-free", () => { + const layout = computeTokenSlotLayout([ + element("x", "real"), + element("y", "real"), + element("n", "integer"), + ]); + + expect(layout.strideBytes).toBe(24); + expect(layout.paddingRanges).toEqual([]); + expect(layout.realFieldF64Offsets).toEqual([0, 1]); + }); +}); + +describe("encode/decode round trip", () => { + const elements = [ + element("amount", "real"), + element("count", "integer"), + element("active", "boolean"), + ]; + const layout = computeTokenSlotLayout(elements); + + it("round-trips reals, rounded integers, and boolean u8 values", () => { + const bytes = encodeTokenToBytes( + layout, + { amount: 1.25, count: 2.7, active: true }, + "Test", + ); + + expect(bytes.byteLength).toBe(24); + // Boolean is stored as one byte at its packed offset. + expect(bytes[16]).toBe(1); + + const { f64, u8 } = createTokenRegionViews( + bytes.buffer, + bytes.byteOffset, + bytes.byteLength, + ); + expect(readTokenRecord(layout, f64, u8, 0)).toEqual({ + amount: 1.25, + count: 3, + active: true, + }); + }); + + it("stores false booleans as 0 and defaults missing values", () => { + const bytes = encodeTokenToBytes(layout, { amount: -2 }, "Test"); + const { f64, u8 } = createTokenRegionViews( + bytes.buffer, + bytes.byteOffset, + bytes.byteLength, + ); + + expect(bytes[16]).toBe(0); + expect(readTokenRecord(layout, f64, u8, 0)).toEqual({ + amount: -2, + count: 0, + active: false, + }); + }); + + it("packs pre-encoded slot values by element name", () => { + const bytes = encodeTokenValuesToBytes(layout, { + amount: 0.5, + count: 4, + active: 1, + }); + const { f64, u8 } = createTokenRegionViews( + bytes.buffer, + bytes.byteOffset, + bytes.byteLength, + ); + + expect(readTokenRecord(layout, f64, u8, 0)).toEqual({ + amount: 0.5, + count: 4, + active: true, + }); + }); + + it("reads tokens at non-zero token byte offsets", () => { + const region = new Uint8Array(2 * layout.strideBytes); + region.set( + encodeTokenToBytes(layout, { amount: 1, count: 1, active: false }, "T"), + 0, + ); + region.set( + encodeTokenToBytes(layout, { amount: 2, count: 5, active: true }, "T"), + layout.strideBytes, + ); + + const { f64, u8 } = createTokenRegionViews( + region.buffer, + region.byteOffset, + region.byteLength, + ); + expect(readTokenRecord(layout, f64, u8, layout.strideBytes)).toEqual({ + amount: 2, + count: 5, + active: true, + }); + }); +}); + +describe("createTokenRegionViews", () => { + it("rejects unaligned offsets and lengths", () => { + const buffer = new ArrayBuffer(32); + + expect(() => createTokenRegionViews(buffer, 4, 8)).toThrow( + "not 8-byte aligned", + ); + expect(() => createTokenRegionViews(buffer, 0, 12)).toThrow( + "not a multiple of 8", + ); + }); +}); diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/token-layout.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/token-layout.ts new file mode 100644 index 00000000000..c957440559d --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/token-layout.ts @@ -0,0 +1,266 @@ +import { + decodeTokenAttributeValue, + encodeTokenAttributeValue, +} from "./token-values"; + +import type { Color, ColorElementType, TokenRecord } from "../../types/sdcpn"; + +type ColorElement = Color["elements"][number]; + +/** + * Physical (buffer-level) representation of one token attribute in the + * format-v2 packed struct layout: + * + * - `f64`: 8 bytes, 8-byte aligned (`real` and `integer` elements). + * - `u8`: 1 byte, 1-byte aligned (`boolean` elements). + */ +export type PhysicalKind = "f64" | "u8"; + +export type TokenLayoutField = { + element: ColorElement; + kind: PhysicalKind; + byteOffset: number; + byteSize: number; +}; + +/** + * Packed struct layout for one token of a colour (the C `sizeof` and + * `offsetof`). This is the single source of truth for how token bytes are + * arranged inside engine frames. + * + * Fields are ordered by decreasing alignment (stable within equal alignment), + * each field's byte offset is aligned to its physical alignment, and the + * stride is rounded up to 8 bytes so consecutive tokens keep f64 fields + * 8-aligned. Because the stride is a multiple of 8 and token regions start at + * 8-aligned offsets, all f64 fields are addressable through a shared + * `Float64Array` view and all u8 fields through a `Uint8Array` view — no + * `DataView` is needed in hot paths. + */ +export type TokenSlotLayout = { + /** sizeof(token) — total bytes per token, including padding. 0 when empty. */ + strideBytes: number; + /** Fields sorted by byteOffset. */ + fields: TokenLayoutField[]; + /** Alignment gaps and tail padding, as half-open byte ranges. */ + paddingRanges: { start: number; end: number }[]; + /** + * f64-view index within one token (`byteOffset / 8`) of each `real` + * element, in field order. Continuous dynamics only integrate these. + */ + realFieldF64Offsets: number[]; +}; + +type PhysicalType = { kind: PhysicalKind; byteSize: number; align: number }; + +const PHYSICAL_TYPES: Record = { + f64: { kind: "f64", byteSize: 8, align: 8 }, + u8: { kind: "u8", byteSize: 1, align: 1 }, +}; + +function physicalTypeFor(elementType: ColorElementType): PhysicalType { + switch (elementType) { + case "boolean": + return PHYSICAL_TYPES.u8; + case "integer": + case "real": + return PHYSICAL_TYPES.f64; + } +} + +const alignTo = (value: number, alignment: number): number => + Math.ceil(value / alignment) * alignment; + +/** + * Computes the packed struct layout for one token of a colour. + * + * An empty element list yields a zero-stride layout (uncoloured places store + * no token bytes). + */ +export function computeTokenSlotLayout( + elements: readonly ColorElement[], +): TokenSlotLayout { + const withPhysical = elements.map((element) => ({ + element, + physical: physicalTypeFor(element.type), + })); + // `Array.prototype.sort` is stable, so fields with equal alignment keep + // their declaration order. + const ordered = [...withPhysical].sort( + (a, b) => b.physical.align - a.physical.align, + ); + + const fields: TokenLayoutField[] = []; + const paddingRanges: { start: number; end: number }[] = []; + let cursor = 0; + for (const { element, physical } of ordered) { + const byteOffset = alignTo(cursor, physical.align); + if (byteOffset > cursor) { + paddingRanges.push({ start: cursor, end: byteOffset }); + } + fields.push({ + element, + kind: physical.kind, + byteOffset, + byteSize: physical.byteSize, + }); + cursor = byteOffset + physical.byteSize; + } + + const strideBytes = fields.length === 0 ? 0 : alignTo(cursor, 8); + if (strideBytes > cursor) { + paddingRanges.push({ start: cursor, end: strideBytes }); + } + + const realFieldF64Offsets = fields + .filter((field) => field.element.type === "real") + .map((field) => field.byteOffset / 8); + + return { strideBytes, fields, paddingRanges, realFieldF64Offsets }; +} + +export type TokenRegionViews = { + f64: Float64Array; + u8: Uint8Array; +}; + +/** + * Creates the shared f64/u8 views over one token byte region. + * + * The region must start at an 8-aligned byte offset and span a multiple of 8 + * bytes — both invariants hold for engine frame token regions because place + * strides are multiples of 8 and regions are allocated at 8-aligned offsets. + */ +export function createTokenRegionViews( + buffer: ArrayBufferLike, + byteOffset: number, + byteLength: number, +): TokenRegionViews { + if (byteOffset % 8 !== 0) { + throw new Error( + `Token region byte offset ${byteOffset} is not 8-byte aligned`, + ); + } + if (byteLength % 8 !== 0) { + throw new Error( + `Token region byte length ${byteLength} is not a multiple of 8`, + ); + } + + return { + f64: new Float64Array(buffer, byteOffset, byteLength / 8), + u8: new Uint8Array(buffer, byteOffset, byteLength), + }; +} + +/** + * Token starts must be 8-aligned: f64 fields are read as + * `f64[byteOffset / 8]`, and a fractional index on a typed array is a plain + * (silent, always-undefined) property access, not a buffer read. Field + * offsets within a token are 8-aligned by construction + * (`computeTokenSlotLayout` orders fields by alignment and rounds strides to + * 8), so guarding the token start catches every misalignment loudly. + */ +function assertTokenAligned(tokenByteOffset: number): void { + if (tokenByteOffset % 8 !== 0) { + throw new Error( + `Token byte offset ${tokenByteOffset} is not 8-byte aligned`, + ); + } +} + +/** + * Decodes one token starting at `tokenByteOffset` (relative to the start of + * the viewed region) into a logical record, using the shared value codec. + */ +export function readTokenRecord( + layout: TokenSlotLayout, + f64: Float64Array, + u8: Uint8Array, + tokenByteOffset: number, +): TokenRecord { + assertTokenAligned(tokenByteOffset); + const token: TokenRecord = {}; + for (const field of layout.fields) { + const encodedValue = + field.kind === "f64" + ? (f64[(tokenByteOffset + field.byteOffset) / 8] ?? 0) + : (u8[tokenByteOffset + field.byteOffset] ?? 0); + token[field.element.name] = decodeTokenAttributeValue( + field.element, + encodedValue, + ); + } + return token; +} + +/** + * Writes one already-encoded slot value (see `encodeTokenAttributeValue`) + * into a token's field. `tokenByteOffset` is relative to the start of the + * viewed region. + */ +export function writeTokenValue( + field: TokenLayoutField, + f64: Float64Array, + u8: Uint8Array, + tokenByteOffset: number, + encodedSlotValue: number, +): void { + assertTokenAligned(tokenByteOffset); + /* eslint-disable no-param-reassign -- writing through shared token region views is the point of this helper */ + if (field.kind === "f64") { + f64[(tokenByteOffset + field.byteOffset) / 8] = encodedSlotValue; + } else { + u8[tokenByteOffset + field.byteOffset] = encodedSlotValue; + } + /* eslint-enable no-param-reassign */ +} + +/** + * Encodes pre-sampled, already-encoded slot values (keyed by element name) + * into a fresh stride-sized byte block. Used by transition kernels, which + * sample distribution values in element declaration order before packing. + */ +export function encodeTokenValuesToBytes( + layout: TokenSlotLayout, + encodedValuesByName: Readonly>, +): Uint8Array { + const { f64, u8 } = createTokenRegionViews( + new ArrayBuffer(layout.strideBytes), + 0, + layout.strideBytes, + ); + for (const field of layout.fields) { + writeTokenValue( + field, + f64, + u8, + 0, + encodedValuesByName[field.element.name] ?? 0, + ); + } + return u8; +} + +/** + * Coerces and encodes one token record into a fresh stride-sized byte block. + */ +export function encodeTokenToBytes( + layout: TokenSlotLayout, + record: Record, + context: string, +): Uint8Array { + const { f64, u8 } = createTokenRegionViews( + new ArrayBuffer(layout.strideBytes), + 0, + layout.strideBytes, + ); + for (const field of layout.fields) { + const encodedValue = encodeTokenAttributeValue( + field.element, + record[field.element.name], + `${context}.${field.element.name}`, + ); + writeTokenValue(field, f64, u8, 0, encodedValue); + } + return u8; +} diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/types.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/types.ts index cb743f537f3..05007a46ace 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/types.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/types.ts @@ -18,6 +18,7 @@ import type { import type { InitialMarking } from "../api"; import type { RuntimeDistribution } from "../authoring/user-code/distribution"; import type { EngineFrame, EngineFrameLayout } from "../frames/internal-frame"; +import type { TokenSlotLayout } from "./token-layout"; /** * Runtime parameter values used during simulation execution. @@ -29,12 +30,16 @@ export type ParameterValues = Record; * Engine-facing differential equation for one place's continuous dynamics. * * Today this wraps the user-authored object API and adapts it to/from the - * engine's packed numeric buffers. Later this can be replaced by an + * engine's packed token byte regions. Later this can be replaced by an * IR-compiled buffer-native function without changing the stepping loop. + * + * `placeBytes` is one place's token byte region (`numberOfTokens × + * strideBytes`, 8-aligned). The returned derivatives are laid out as + * `numberOfTokens × realFieldF64Offsets.length`, in the field order of the + * place colour's `TokenSlotLayout.realFieldF64Offsets`. */ export type DifferentialEquationFn = ( - currentState: Float64Array, - dimensions: number, + placeBytes: Uint8Array, numberOfTokens: number, ) => Float64Array; @@ -68,8 +73,10 @@ export type CompiledTransitionPlace = { placeId: string; placeName: string; weight: number; - elementNames: readonly string[] | null; + /** Colour elements in declaration order, or null for uncoloured places. */ elements: readonly Color["elements"][number][] | null; + /** Packed token layout for the place colour, or null for uncoloured places. */ + tokenLayout: TokenSlotLayout | null; }; export type CompiledTransitionInputPlace = CompiledTransitionPlace & { diff --git a/libs/@hashintel/petrinaut-core/src/simulation/frames/frame-reader.test.ts b/libs/@hashintel/petrinaut-core/src/simulation/frames/frame-reader.test.ts index edfa5ee0776..5337911b26c 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/frames/frame-reader.test.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/frames/frame-reader.test.ts @@ -49,9 +49,12 @@ const sdcpn: Pick = { }; function makeFrame(): EngineFrame { + // Two 16-byte tokens ({x, y} as two f64 fields) preceded by 16 junk bytes. + const buffer = new Uint8Array(new Float64Array([99, 99, 1, 2, 3, 4]).buffer); + return createEngineFrame(createEngineFrameLayout(sdcpn), { places: { - [place.id]: { offset: 2, count: 2, dimensions: 2 }, + [place.id]: { byteOffset: 16, count: 2, strideBytes: 16 }, }, transitions: { "transition-1": { @@ -60,7 +63,7 @@ function makeFrame(): EngineFrame { firingCount: 3, }, }, - buffer: new Float64Array([99, 99, 1, 2, 3, 4]), + buffer, }); } @@ -73,11 +76,7 @@ describe("SimulationFrameReader", () => { expect(reader.getPlaceTokenCount(place.id)).toBe(2); expect(reader.getPlaceTokenCount("missing")).toBe(0); - expect(reader.getPlaceTokenValues(place.id)).toEqual({ - values: new Float64Array([1, 2, 3, 4]), - count: 2, - }); - expect(reader.getPlaceTokens(place, color)).toEqual([ + expect(reader.getPlaceTokens(place)).toEqual([ { x: 1, y: 2 }, { x: 3, y: 4 }, ]); @@ -98,15 +97,16 @@ describe("SimulationFrameReader", () => { }); }); - it("returns a copied token value buffer", () => { + it("returns copied token records", () => { const reader = compileSimulationFrameReader(sdcpn)(makeFrame(), 7, 1.25); - const values = reader.getPlaceTokenValues(place.id); + const tokens = reader.getPlaceTokens(place); - expect(values).not.toBeNull(); - values!.values[0] = 42; + expect(tokens).toHaveLength(2); + tokens[0]!.x = 42; - expect(reader.getPlaceTokenValues(place.id)?.values).toEqual( - new Float64Array([1, 2, 3, 4]), - ); + expect(reader.getPlaceTokens(place)).toEqual([ + { x: 1, y: 2 }, + { x: 3, y: 4 }, + ]); }); }); 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 200f168c795..657a50663dc 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/frames/frame-reader.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/frames/frame-reader.ts @@ -1,4 +1,4 @@ -import { decodeTokenAttributeValue } from "../engine/token-values"; +import { readTokenRecord } from "../engine/token-layout"; import { createEngineFrameLayout, readEngineFrame, @@ -7,11 +7,7 @@ import { } from "./internal-frame"; import type { SDCPN, TokenRecord } from "../../types/sdcpn"; -import type { - SimulationFrameReader, - SimulationFrameState, - SimulationPlaceTokenValues, -} from "../api"; +import type { SimulationFrameReader, SimulationFrameState } from "../api"; function createSimulationFrameReader( layout: EngineFrameLayout, @@ -24,54 +20,34 @@ function createSimulationFrameReader( const getPlaceTokenCount = (placeId: string): number => frameView.getPlaceState(placeId)?.count ?? 0; - const getPlaceTokenValues = ( - placeId: string, - ): SimulationPlaceTokenValues | null => { - const placeState = frameView.getPlaceState(placeId); - if (!placeState) { - return null; - } - - const tokenValues = frameView.getPlaceTokenValues(placeId)!; - return { - values: tokenValues.slice(), - count: placeState.count, - }; - }; - return { number, time, getPlaceTokenCount, - getPlaceTokenValues, - getPlaceTokens(place, color) { + getPlaceTokens(place) { const placeState = frameView.getPlaceState(place.id); if (!placeState) { return []; } - const { offset, count, dimensions } = placeState; - const elements = color?.elements ?? []; + const placeIndex = layout.placeIndexById.get(place.id); + const tokenLayout = + placeIndex === undefined ? null : layout.placeTokenLayouts[placeIndex]; + const { byteOffset, count, strideBytes } = placeState; const tokens: TokenRecord[] = []; - if (elements.length === 0 || dimensions === 0 || count === 0) { + if (!tokenLayout || strideBytes === 0 || count === 0) { return tokens; } for (let tokenIndex = 0; tokenIndex < count; tokenIndex++) { - const token: TokenRecord = {}; - const base = offset + tokenIndex * dimensions; - for ( - let dimensionIndex = 0; - dimensionIndex < elements.length && dimensionIndex < dimensions; - dimensionIndex++ - ) { - const element = elements[dimensionIndex]!; - token[element.name] = decodeTokenAttributeValue( - element, - frameView.tokenValues[base + dimensionIndex] ?? 0, - ); - } - tokens.push(token); + tokens.push( + readTokenRecord( + tokenLayout, + frameView.tokenF64, + frameView.tokenBytes, + byteOffset + tokenIndex * strideBytes, + ), + ); } return tokens; diff --git a/libs/@hashintel/petrinaut-core/src/simulation/frames/internal-frame.ts b/libs/@hashintel/petrinaut-core/src/simulation/frames/internal-frame.ts index 242d2363784..85f3d9603bd 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/frames/internal-frame.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/frames/internal-frame.ts @@ -1,25 +1,38 @@ +import { + computeTokenSlotLayout, + type TokenSlotLayout, +} from "../engine/token-layout"; + import type { ID, SDCPN } from "../../types/sdcpn"; import type { SimulationTransitionState } from "./transition-state"; /** * Internal place layout within an engine frame. + * + * `byteOffset` is the place's offset within the frame's token byte region and + * `strideBytes` is the packed-struct size of one token of the place's colour + * (0 for uncoloured places). */ export type EngineFramePlaceState = { - offset: number; + byteOffset: number; count: number; - dimensions: number; + strideBytes: number; }; export type EngineFrameSnapshot = { places: Record; transitions: Record; - buffer: Float64Array; + /** Token region bytes (packed-struct token layout). */ + buffer: Uint8Array; }; export type EngineFrameLayout = { placeIds: readonly ID[]; placeIndexById: ReadonlyMap; - placeDimensions: Uint32Array; + /** Per-place token stride in bytes (0 for uncoloured places). */ + placeStrideBytes: Uint32Array; + /** Per-place packed token layout (null for uncoloured places). */ + placeTokenLayouts: (TokenSlotLayout | null)[]; transitionIds: readonly ID[]; transitionIndexById: ReadonlyMap; }; @@ -36,7 +49,7 @@ export type EngineFrame = ArrayBuffer; type EngineFrameHeader = { placeCount: number; transitionCount: number; - tokenValueCount: number; + tokenByteLength: number; placeCountsOffset: number; placeValueOffsetsOffset: number; transitionElapsedOffset: number; @@ -47,17 +60,19 @@ type EngineFrameHeader = { }; export type EngineFrameView = { - tokenValues: Float64Array; + /** The whole token byte region. */ + tokenBytes: Uint8Array; + /** f64 view over the whole token region (region offset/length are 8-aligned). */ + tokenF64: Float64Array; getPlaceState(placeId: ID): EngineFramePlaceState | null; getPlaceEntries(): [ID, EngineFramePlaceState][]; - getPlaceTokenValues(placeId: ID): Float64Array | null; getTransitionState(transitionId: ID): SimulationTransitionState | null; getTransitionEntries(): [ID, SimulationTransitionState][]; toSnapshot(): EngineFrameSnapshot; }; const FRAME_MAGIC = 0x5046524d; // "PFRM" -const FRAME_VERSION = 1; +const FRAME_VERSION = 2; const HEADER_BYTES = 64; const enum HeaderOffset { @@ -66,7 +81,7 @@ const enum HeaderOffset { HeaderBytes = 6, PlaceCount = 8, TransitionCount = 12, - TokenValueCount = 16, + TokenByteLength = 16, PlaceCountsOffset = 20, PlaceValueOffsetsOffset = 24, TransitionElapsedOffset = 28, @@ -79,12 +94,12 @@ const enum HeaderOffset { const alignTo = (value: number, alignment: number): number => Math.ceil(value / alignment) * alignment; -function getPlaceDimensions( +function getPlaceTokenLayout( sdcpn: Pick, place: Pick, -): number { +): TokenSlotLayout | null { if (!place.colorId) { - return 0; + return null; } const color = sdcpn.types.find((type) => type.id === place.colorId); @@ -94,7 +109,7 @@ function getPlaceDimensions( ); } - return color.elements.length; + return computeTokenSlotLayout(color.elements); } export function createEngineFrameLayout( @@ -102,7 +117,8 @@ export function createEngineFrameLayout( ): EngineFrameLayout { const placeIds = sdcpn.places.map((place) => place.id); const placeIndexById = new Map(); - const placeDimensions = new Uint32Array(placeIds.length); + const placeStrideBytes = new Uint32Array(placeIds.length); + const placeTokenLayouts: (TokenSlotLayout | null)[] = []; for (let index = 0; index < sdcpn.places.length; index++) { const place = sdcpn.places[index]!; @@ -113,7 +129,9 @@ export function createEngineFrameLayout( throw new Error(`Duplicate place id in SDCPN: ${place.id}`); } placeIndexById.set(place.id, index); - placeDimensions[index] = getPlaceDimensions(sdcpn, place); + const tokenLayout = getPlaceTokenLayout(sdcpn, place); + placeTokenLayouts.push(tokenLayout); + placeStrideBytes[index] = tokenLayout?.strideBytes ?? 0; } const transitionIds = sdcpn.transitions.map((transition) => transition.id); @@ -132,7 +150,8 @@ export function createEngineFrameLayout( return { placeIds, placeIndexById, - placeDimensions, + placeStrideBytes, + placeTokenLayouts, transitionIds, transitionIndexById, }; @@ -167,7 +186,7 @@ function readHeader(frame: EngineFrame): EngineFrameHeader { return { placeCount: view.getUint32(HeaderOffset.PlaceCount, true), transitionCount: view.getUint32(HeaderOffset.TransitionCount, true), - tokenValueCount: view.getUint32(HeaderOffset.TokenValueCount, true), + tokenByteLength: view.getUint32(HeaderOffset.TokenByteLength, true), placeCountsOffset: view.getUint32(HeaderOffset.PlaceCountsOffset, true), placeValueOffsetsOffset: view.getUint32( HeaderOffset.PlaceValueOffsetsOffset, @@ -213,7 +232,7 @@ function writeHeader(buffer: ArrayBuffer, header: EngineFrameHeader): void { view.setUint16(HeaderOffset.HeaderBytes, HEADER_BYTES, true); view.setUint32(HeaderOffset.PlaceCount, header.placeCount, true); view.setUint32(HeaderOffset.TransitionCount, header.transitionCount, true); - view.setUint32(HeaderOffset.TokenValueCount, header.tokenValueCount, true); + view.setUint32(HeaderOffset.TokenByteLength, header.tokenByteLength, true); view.setUint32( HeaderOffset.PlaceCountsOffset, header.placeCountsOffset, @@ -254,34 +273,34 @@ export function createEngineFrame( const placeCount = layout.placeIds.length; const transitionCount = layout.transitionIds.length; const packedPlaceCounts = new Uint32Array(placeCount); - const packedPlaceOffsets = new Uint32Array(placeCount); + const packedPlaceByteOffsets = new Uint32Array(placeCount); - let tokenValueCount = 0; + let tokenByteLength = 0; for (let index = 0; index < placeCount; index++) { const placeId = layout.placeIds[index]!; - const dimensions = layout.placeDimensions[index] ?? 0; + const strideBytes = layout.placeStrideBytes[index] ?? 0; const placeState = snapshot.places[placeId] ?? { - offset: tokenValueCount, + byteOffset: tokenByteLength, count: 0, - dimensions, + strideBytes, }; - if (placeState.dimensions !== dimensions) { + if (placeState.strideBytes !== strideBytes) { throw new Error( - `Place ${placeId} has ${placeState.dimensions} dimensions in snapshot, expected ${dimensions}`, + `Place ${placeId} has a token stride of ${placeState.strideBytes} bytes in snapshot, expected ${strideBytes}`, ); } packedPlaceCounts[index] = placeState.count; - packedPlaceOffsets[index] = tokenValueCount; - tokenValueCount += placeState.count * dimensions; + packedPlaceByteOffsets[index] = tokenByteLength; + tokenByteLength += placeState.count * strideBytes; } const placeCountsOffset = HEADER_BYTES; const placeValueOffsetsOffset = placeCountsOffset + packedPlaceCounts.byteLength; const transitionElapsedOffset = alignTo( - placeValueOffsetsOffset + packedPlaceOffsets.byteLength, + placeValueOffsetsOffset + packedPlaceByteOffsets.byteLength, 8, ); const transitionFiringCountsOffset = @@ -293,14 +312,13 @@ export function createEngineFrame( transitionFiredFlagsOffset + transitionCount * Uint8Array.BYTES_PER_ELEMENT, 8, ); - const byteLength = - tokenValuesOffset + tokenValueCount * Float64Array.BYTES_PER_ELEMENT; + const byteLength = tokenValuesOffset + tokenByteLength; const frame = new ArrayBuffer(byteLength); writeHeader(frame, { placeCount, transitionCount, - tokenValueCount, + tokenByteLength, placeCountsOffset, placeValueOffsetsOffset, transitionElapsedOffset, @@ -312,7 +330,7 @@ export function createEngineFrame( new Uint32Array(frame, placeCountsOffset, placeCount).set(packedPlaceCounts); new Uint32Array(frame, placeValueOffsetsOffset, placeCount).set( - packedPlaceOffsets, + packedPlaceByteOffsets, ); const transitionElapsed = new Float64Array( @@ -343,25 +361,24 @@ export function createEngineFrame( transitionFiredFlags[index] = transitionState.firedInThisFrame ? 1 : 0; } - const tokenValues = new Float64Array( - frame, - tokenValuesOffset, - tokenValueCount, - ); + const tokenBytes = new Uint8Array(frame, tokenValuesOffset, tokenByteLength); for (let index = 0; index < placeCount; index++) { const placeId = layout.placeIds[index]!; - const dimensions = layout.placeDimensions[index] ?? 0; + const strideBytes = layout.placeStrideBytes[index] ?? 0; const count = packedPlaceCounts[index] ?? 0; - const targetOffset = packedPlaceOffsets[index] ?? 0; - const size = count * dimensions; - if (size === 0) { + const targetByteOffset = packedPlaceByteOffsets[index] ?? 0; + const byteSize = count * strideBytes; + if (byteSize === 0) { continue; } const sourceState = snapshot.places[placeId]!; - tokenValues.set( - snapshot.buffer.subarray(sourceState.offset, sourceState.offset + size), - targetOffset, + tokenBytes.set( + snapshot.buffer.subarray( + sourceState.byteOffset, + sourceState.byteOffset + byteSize, + ), + targetByteOffset, ); } @@ -400,10 +417,15 @@ export function readEngineFrame( header.transitionFiredFlagsOffset, header.transitionCount, ); - const tokenValues = new Float64Array( + const tokenBytes = new Uint8Array( frame, header.tokenValuesOffset, - header.tokenValueCount, + header.tokenByteLength, + ); + const tokenF64 = new Float64Array( + frame, + header.tokenValuesOffset, + header.tokenByteLength / 8, ); const getPlaceState = (placeId: ID): EngineFramePlaceState | null => { @@ -413,9 +435,9 @@ export function readEngineFrame( } return { - offset: placeValueOffsets[index] ?? 0, + byteOffset: placeValueOffsets[index] ?? 0, count: placeCounts[index] ?? 0, - dimensions: layout.placeDimensions[index] ?? 0, + strideBytes: layout.placeStrideBytes[index] ?? 0, }; }; @@ -444,17 +466,10 @@ export function readEngineFrame( ]); return { - tokenValues, + tokenBytes, + tokenF64, getPlaceState, getPlaceEntries, - getPlaceTokenValues(placeId) { - const placeState = getPlaceState(placeId); - if (!placeState) { - return null; - } - const size = placeState.count * placeState.dimensions; - return tokenValues.subarray(placeState.offset, placeState.offset + size); - }, getTransitionState, getTransitionEntries, toSnapshot() { @@ -471,7 +486,7 @@ export function readEngineFrame( return { places, transitions, - buffer: tokenValues.slice(), + buffer: tokenBytes.slice(), }; }, }; diff --git a/libs/@hashintel/petrinaut-core/src/simulation/frames/metric-state.ts b/libs/@hashintel/petrinaut-core/src/simulation/frames/metric-state.ts index 781421cf505..f2ec6325f60 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/frames/metric-state.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/frames/metric-state.ts @@ -1,4 +1,4 @@ -import type { Color, Place } from "../../types/sdcpn"; +import type { Place } from "../../types/sdcpn"; import type { SimulationFrameReader } from "../api"; import type { MetricState } from "../authoring/metric/compile-metric"; @@ -10,16 +10,13 @@ import type { MetricState } from "../authoring/metric/compile-metric"; export function buildMetricState( frame: SimulationFrameReader, places: Place[], - types: Color[], ): MetricState { - const typeById = new Map(types.map((t) => [t.id, t])); const placesByName: Record = {}; for (const place of places) { - const color = place.colorId ? typeById.get(place.colorId) : undefined; placesByName[place.name] = { count: frame.getPlaceTokenCount(place.id), - tokens: frame.getPlaceTokens(place, color), + tokens: frame.getPlaceTokens(place), }; } diff --git a/libs/@hashintel/petrinaut-core/src/simulation/index.ts b/libs/@hashintel/petrinaut-core/src/simulation/index.ts index 1ee0adc1f5e..6a13a209de3 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/index.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/index.ts @@ -11,7 +11,6 @@ export type { SimulationFrameReader, SimulationFrameState, SimulationFrameSummary, - SimulationPlaceTokenValues, SimulationTransport, SimulationState, WorkerFactory, diff --git a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/advance-run.ts b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/advance-run.ts index 96a96281943..f148921eea2 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/advance-run.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/advance-run.ts @@ -52,7 +52,7 @@ export function advanceRun(run: MonteCarloRunState): boolean { run.status = "running"; let workingFrame = writeFrameAfterDynamics(run); - const tokensToAdd = new Map(); + const tokensToAdd = new Map(); const firedTransitions = new Set(); for (const transitionId of run.simulation.frameLayout.transitionIds) { diff --git a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/frame-buffer.ts b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/frame-buffer.ts index e8d61130996..bc9a7c47011 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/frame-buffer.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/frame-buffer.ts @@ -6,15 +6,21 @@ import type { export type MonteCarloFrameBuffer = { buffer: ArrayBuffer; - tokenValueCapacity: number; - tokenValueCount: number; + /** Allocated token region capacity, in bytes (multiple of 8). */ + tokenByteCapacity: number; + /** Used token region length, in bytes. */ + tokenByteCount: number; placeCounts: Uint32Array; + /** Per-place byte offsets within the token region. */ placeOffsets: Uint32Array; transitionElapsedFrames: Float64Array; transitionElapsed: Float64Array; transitionFiringCounts: Uint32Array; transitionFiredFlags: Uint8Array; - tokenValues: Float64Array; + /** u8 view over the whole token region capacity. */ + tokenBytes: Uint8Array; + /** f64 view over the whole token region capacity. */ + tokenF64: Float64Array; }; const alignTo = (value: number, alignment: number): number => @@ -24,16 +30,18 @@ const alignTo = (value: number, alignment: number): number => * Creates typed-array views over one Monte Carlo frame buffer. * * The buffer contains fixed-size place and transition metadata followed by the - * variable-capacity token value region. Keeping all views over one ArrayBuffer - * makes frame swapping cheap and keeps ownership explicit. + * variable-capacity token byte region. Keeping all views over one ArrayBuffer + * makes frame swapping cheap and keeps ownership explicit. The token region + * starts at an 8-aligned offset and spans a multiple of 8 bytes, so both the + * u8 and f64 views address the same packed-struct token bytes. */ function createViews( layout: EngineFrameLayout, buffer: ArrayBuffer, - tokenValueCapacity: number, + tokenByteCapacity: number, ): Omit< MonteCarloFrameBuffer, - "buffer" | "tokenValueCapacity" | "tokenValueCount" + "buffer" | "tokenByteCapacity" | "tokenByteCount" > { const placeCount = layout.placeIds.length; const transitionCount = layout.transitionIds.length; @@ -81,24 +89,25 @@ function createViews( transitionFiredFlagsOffset, transitionCount, ), - tokenValues: new Float64Array( + tokenBytes: new Uint8Array(buffer, tokenValuesOffset, tokenByteCapacity), + tokenF64: new Float64Array( buffer, tokenValuesOffset, - tokenValueCapacity, + tokenByteCapacity / 8, ), }; } /** * Computes the ArrayBuffer byte length required for a frame with this layout - * and token value capacity. + * and token byte capacity. * - * `tokenValueCapacity` is measured in Float64 values, not token count, because - * colored places can have different dimensionality. + * `tokenByteCapacity` is measured in bytes, not token count, because colored + * places can have different token strides. */ export function getMonteCarloFrameBufferByteLength( layout: EngineFrameLayout, - tokenValueCapacity: number, + tokenByteCapacity: number, ): number { const placeCount = layout.placeIds.length; const transitionCount = layout.transitionIds.length; @@ -117,9 +126,7 @@ export function getMonteCarloFrameBufferByteLength( Float64Array.BYTES_PER_ELEMENT, ); - return ( - tokenValuesOffset + tokenValueCapacity * Float64Array.BYTES_PER_ELEMENT - ); + return tokenValuesOffset + tokenByteCapacity; } /** @@ -127,21 +134,24 @@ export function getMonteCarloFrameBufferByteLength( * views. * * The returned frame owns one ArrayBuffer and starts with zero used token - * values, even if a larger capacity was allocated. + * bytes, even if a larger capacity was allocated. */ export function createMonteCarloFrameBuffer( layout: EngineFrameLayout, - tokenValueCapacity: number, + tokenByteCapacity: number, ): MonteCarloFrameBuffer { - const normalizedCapacity = Math.max(0, Math.ceil(tokenValueCapacity)); + const normalizedCapacity = alignTo( + Math.max(0, Math.ceil(tokenByteCapacity)), + 8, + ); const buffer = new ArrayBuffer( getMonteCarloFrameBufferByteLength(layout, normalizedCapacity), ); return { buffer, - tokenValueCapacity: normalizedCapacity, - tokenValueCount: 0, + tokenByteCapacity: normalizedCapacity, + tokenByteCount: 0, ...createViews(layout, buffer, normalizedCapacity), }; } @@ -150,16 +160,16 @@ export function createMonteCarloFrameBuffer( * Copies the used portion of one Monte Carlo frame into another existing frame * buffer. * - * The target must already have enough token value capacity. This is used for + * The target must already have enough token byte capacity. This is used for * current/next frame swapping without allocating on every simulation step. */ export function copyMonteCarloFrameBuffer( source: MonteCarloFrameBuffer, target: MonteCarloFrameBuffer, ): void { - if (target.tokenValueCapacity < source.tokenValueCount) { + if (target.tokenByteCapacity < source.tokenByteCount) { throw new Error( - `Target MonteCarloFrameBuffer capacity ${target.tokenValueCapacity} cannot hold ${source.tokenValueCount} token values`, + `Target MonteCarloFrameBuffer capacity ${target.tokenByteCapacity} cannot hold ${source.tokenByteCount} token bytes`, ); } @@ -169,25 +179,23 @@ export function copyMonteCarloFrameBuffer( target.transitionElapsed.set(source.transitionElapsed); target.transitionFiringCounts.set(source.transitionFiringCounts); target.transitionFiredFlags.set(source.transitionFiredFlags); - target.tokenValues.set( - source.tokenValues.subarray(0, source.tokenValueCount), - ); - target.tokenValueCount = source.tokenValueCount; + target.tokenBytes.set(source.tokenBytes.subarray(0, source.tokenByteCount)); + target.tokenByteCount = source.tokenByteCount; } /** * Allocates a new Monte Carlo frame buffer and copies an existing frame into * it. * - * This is the resize path used when a run outgrows its current token value + * This is the resize path used when a run outgrows its current token byte * capacity. */ export function cloneMonteCarloFrameBuffer( layout: EngineFrameLayout, source: MonteCarloFrameBuffer, - tokenValueCapacity: number, + tokenByteCapacity: number, ): MonteCarloFrameBuffer { - const target = createMonteCarloFrameBuffer(layout, tokenValueCapacity); + const target = createMonteCarloFrameBuffer(layout, tokenByteCapacity); copyMonteCarloFrameBuffer(source, target); return target; } @@ -206,10 +214,10 @@ export function copyEngineFrameViewToMonteCarloFrameBuffer( target: MonteCarloFrameBuffer, dt: number, ): void { - const tokenValueCount = source.tokenValues.length; - if (target.tokenValueCapacity < tokenValueCount) { + const tokenByteCount = source.tokenBytes.byteLength; + if (target.tokenByteCapacity < tokenByteCount) { throw new Error( - `Target MonteCarloFrameBuffer capacity ${target.tokenValueCapacity} cannot hold ${tokenValueCount} token values`, + `Target MonteCarloFrameBuffer capacity ${target.tokenByteCapacity} cannot hold ${tokenByteCount} token bytes`, ); } @@ -221,7 +229,7 @@ export function copyEngineFrameViewToMonteCarloFrameBuffer( } target.placeCounts[index] = placeState.count; - target.placeOffsets[index] = placeState.offset; + target.placeOffsets[index] = placeState.byteOffset; } for (let index = 0; index < layout.transitionIds.length; index++) { @@ -244,6 +252,6 @@ export function copyEngineFrameViewToMonteCarloFrameBuffer( : 0; } - target.tokenValues.set(source.tokenValues); - target.tokenValueCount = tokenValueCount; + target.tokenBytes.set(source.tokenBytes); + target.tokenByteCount = tokenByteCount; } diff --git a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/frame-operations.ts b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/frame-operations.ts index 1a72f6bb47c..0d3df824f25 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/frame-operations.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/frame-operations.ts @@ -24,11 +24,7 @@ export function writeFrameAfterDynamics( const { simulation } = run; const { frameLayout } = simulation; const source = run.currentFrame; - const target = ensureFrameCapacity( - run, - run.nextFrame, - source.tokenValueCount, - ); + const target = ensureFrameCapacity(run, run.nextFrame, source.tokenByteCount); copyMonteCarloFrameBuffer(source, target); @@ -38,24 +34,29 @@ export function writeFrameAfterDynamics( ] of simulation.differentialEquationFns) { const placeIndex = getPlaceIndex(frameLayout, placeId); const count = source.placeCounts[placeIndex] ?? 0; - const dimensions = frameLayout.placeDimensions[placeIndex] ?? 0; - const placeSize = count * dimensions; - if (placeSize === 0) { + const strideBytes = frameLayout.placeStrideBytes[placeIndex] ?? 0; + const tokenLayout = frameLayout.placeTokenLayouts[placeIndex]; + const placeByteSize = count * strideBytes; + if (placeByteSize === 0 || !tokenLayout) { continue; } - const offset = source.placeOffsets[placeIndex] ?? 0; - const currentState = source.tokenValues.slice(offset, offset + placeSize); + const byteOffset = source.placeOffsets[placeIndex] ?? 0; + // `.slice` copies into a fresh, 8-aligned buffer. + const currentState = source.tokenBytes.slice( + byteOffset, + byteOffset + placeByteSize, + ); const nextState = computePlaceNextState( currentState, - dimensions, + tokenLayout, count, differentialEquation, "euler", simulation.dt, ); - target.tokenValues.set(nextState, offset); + target.tokenBytes.set(nextState, byteOffset); } return target; @@ -76,9 +77,9 @@ export function applyTokenRemovals( for (const [placeId, tokenSelection] of Object.entries(tokensToRemove)) { const placeIndex = getPlaceIndex(frameLayout, placeId); const count = frame.placeCounts[placeIndex] ?? 0; - const dimensions = frameLayout.placeDimensions[placeIndex] ?? 0; + const strideBytes = frameLayout.placeStrideBytes[placeIndex] ?? 0; - if (dimensions === 0) { + if (strideBytes === 0) { if (typeof tokenSelection !== "number") { throw new Error( `Expected token count removal for uncolored place ${placeId}`, @@ -105,7 +106,7 @@ export function applyTokenRemovals( } } - let writeOffset = 0; + let writeByteOffset = 0; for ( let placeIndex = 0; placeIndex < frameLayout.placeIds.length; @@ -113,13 +114,13 @@ export function applyTokenRemovals( ) { const placeId = frameLayout.placeIds[placeIndex]!; const count = frame.placeCounts[placeIndex] ?? 0; - const dimensions = frameLayout.placeDimensions[placeIndex] ?? 0; - const oldOffset = frame.placeOffsets[placeIndex] ?? 0; + const strideBytes = frameLayout.placeStrideBytes[placeIndex] ?? 0; + const oldByteOffset = frame.placeOffsets[placeIndex] ?? 0; const tokenSelection = tokensToRemove[placeId]; - frame.placeOffsets[placeIndex] = writeOffset; + frame.placeOffsets[placeIndex] = writeByteOffset; - if (dimensions === 0) { + if (strideBytes === 0) { const removedCount = typeof tokenSelection === "number" ? tokenSelection : 0; frame.placeCounts[placeIndex] = count - removedCount; @@ -134,22 +135,22 @@ export function applyTokenRemovals( continue; } - const sourceOffset = oldOffset + tokenIndex * dimensions; - if (writeOffset !== sourceOffset) { - frame.tokenValues.copyWithin( - writeOffset, - sourceOffset, - sourceOffset + dimensions, + const sourceByteOffset = oldByteOffset + tokenIndex * strideBytes; + if (writeByteOffset !== sourceByteOffset) { + frame.tokenBytes.copyWithin( + writeByteOffset, + sourceByteOffset, + sourceByteOffset + strideBytes, ); } - writeOffset += dimensions; + writeByteOffset += strideBytes; nextCount++; } frame.placeCounts[placeIndex] = nextCount; } - frame.tokenValueCount = writeOffset; + frame.tokenByteCount = writeByteOffset; } /** @@ -159,8 +160,8 @@ export function applyTokenRemovals( * multiple firings without repeatedly repacking the frame. */ export function mergeTokenAdditions( - target: Map, - additions: Record, + target: Map, + additions: Record, ): void { for (const [placeId, tokens] of Object.entries(additions)) { const existingTokens = target.get(placeId); @@ -175,14 +176,14 @@ export function mergeTokenAdditions( /** * Appends pending output tokens into the frame, resizing if needed. * - * The function computes the required Float64 value count, repacks all places - * into their new contiguous offsets, and writes added colored token values at - * the end of each place segment. + * The function computes the required token byte count, repacks all places + * into their new contiguous byte offsets, and writes added colored token byte + * blocks at the end of each place segment. */ export function applyTokenAdditions( run: MonteCarloRunState, frame: MonteCarloFrameBuffer, - tokensToAdd: ReadonlyMap, + tokensToAdd: ReadonlyMap, ): MonteCarloFrameBuffer { if (tokensToAdd.size === 0) { return frame; @@ -190,43 +191,43 @@ export function applyTokenAdditions( const { frameLayout } = run.simulation; const additionalTokenCounts = new Uint32Array(frameLayout.placeIds.length); - let addedTokenValueCount = 0; + let addedTokenByteCount = 0; for (const [placeId, tokens] of tokensToAdd) { const placeIndex = getPlaceIndex(frameLayout, placeId); - const dimensions = frameLayout.placeDimensions[placeIndex] ?? 0; + const strideBytes = frameLayout.placeStrideBytes[placeIndex] ?? 0; for (const token of tokens) { - if (token.length !== dimensions) { + if (token.byteLength !== strideBytes) { throw new Error( - `Token dimension mismatch for place ${placeId}. Expected ${dimensions}, got ${token.length}.`, + `Token byte size mismatch for place ${placeId}. Expected ${strideBytes}, got ${token.byteLength}.`, ); } } additionalTokenCounts[placeIndex] = (additionalTokenCounts[placeIndex] ?? 0) + tokens.length; - addedTokenValueCount += tokens.length * dimensions; + addedTokenByteCount += tokens.length * strideBytes; } - const requiredTokenValueCount = frame.tokenValueCount + addedTokenValueCount; - const target = ensureFrameCapacity(run, frame, requiredTokenValueCount); + const requiredTokenByteCount = frame.tokenByteCount + addedTokenByteCount; + const target = ensureFrameCapacity(run, frame, requiredTokenByteCount); const newPlaceOffsets = new Uint32Array(frameLayout.placeIds.length); const newPlaceCounts = new Uint32Array(frameLayout.placeIds.length); - let offset = 0; + let byteOffset = 0; for ( let placeIndex = 0; placeIndex < frameLayout.placeIds.length; placeIndex++ ) { - const dimensions = frameLayout.placeDimensions[placeIndex] ?? 0; + const strideBytes = frameLayout.placeStrideBytes[placeIndex] ?? 0; const count = target.placeCounts[placeIndex] ?? 0; const addedCount = additionalTokenCounts[placeIndex] ?? 0; const newCount = count + addedCount; - newPlaceOffsets[placeIndex] = offset; + newPlaceOffsets[placeIndex] = byteOffset; newPlaceCounts[placeIndex] = newCount; - offset += newCount * dimensions; + byteOffset += newCount * strideBytes; } for ( @@ -235,29 +236,33 @@ export function applyTokenAdditions( placeIndex-- ) { const placeId = frameLayout.placeIds[placeIndex]!; - const dimensions = frameLayout.placeDimensions[placeIndex] ?? 0; + const strideBytes = frameLayout.placeStrideBytes[placeIndex] ?? 0; const oldCount = target.placeCounts[placeIndex] ?? 0; - const oldOffset = target.placeOffsets[placeIndex] ?? 0; - const oldSize = oldCount * dimensions; - const newOffset = newPlaceOffsets[placeIndex] ?? 0; - - if (oldSize > 0 && oldOffset !== newOffset) { - target.tokenValues.copyWithin(newOffset, oldOffset, oldOffset + oldSize); + const oldByteOffset = target.placeOffsets[placeIndex] ?? 0; + const oldByteSize = oldCount * strideBytes; + const newByteOffset = newPlaceOffsets[placeIndex] ?? 0; + + if (oldByteSize > 0 && oldByteOffset !== newByteOffset) { + target.tokenBytes.copyWithin( + newByteOffset, + oldByteOffset, + oldByteOffset + oldByteSize, + ); } const addedTokens = tokensToAdd.get(placeId); - if (addedTokens && dimensions > 0) { - let writeOffset = newOffset + oldSize; + if (addedTokens && strideBytes > 0) { + let writeByteOffset = newByteOffset + oldByteSize; for (const token of addedTokens) { - target.tokenValues.set(token, writeOffset); - writeOffset += dimensions; + target.tokenBytes.set(token, writeByteOffset); + writeByteOffset += strideBytes; } } } target.placeOffsets.set(newPlaceOffsets); target.placeCounts.set(newPlaceCounts); - target.tokenValueCount = requiredTokenValueCount; + target.tokenByteCount = requiredTokenByteCount; return target; } diff --git a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/frame-reader.ts b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/frame-reader.ts index e5caf0ab205..8ec0da0f980 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/frame-reader.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/frame-reader.ts @@ -1,9 +1,7 @@ -import type { Color, Place } from "../../types/sdcpn"; -import type { - SimulationFrameReader, - SimulationFrameState, - SimulationPlaceTokenValues, -} from "../api"; +import { readTokenRecord } from "../engine/token-layout"; + +import type { Place, TokenRecord } from "../../types/sdcpn"; +import type { SimulationFrameReader, SimulationFrameState } from "../api"; import type { MonteCarloRunState } from "./internal-types"; export function createMonteCarloFrameReader( @@ -19,57 +17,33 @@ export function createMonteCarloFrameReader( : (currentFrame.placeCounts[placeIndex] ?? 0); }; - const getPlaceTokenValues = ( - placeId: string, - ): SimulationPlaceTokenValues | null => { - const placeIndex = frameLayout.placeIndexById.get(placeId); - if (placeIndex === undefined) { - return null; - } - - const count = currentFrame.placeCounts[placeIndex] ?? 0; - const dimensions = frameLayout.placeDimensions[placeIndex] ?? 0; - const offset = currentFrame.placeOffsets[placeIndex] ?? 0; - const size = count * dimensions; - - return { - values: currentFrame.tokenValues.slice(offset, offset + size), - count, - }; - }; - return { number: run.frameNumber, time: run.frameNumber * simulation.dt, getPlaceTokenCount, - getPlaceTokenValues, - getPlaceTokens(place: Place, color: Color | null | undefined) { + getPlaceTokens(place: Place) { const placeIndex = frameLayout.placeIndexById.get(place.id); if (placeIndex === undefined) { return []; } const count = currentFrame.placeCounts[placeIndex] ?? 0; - const dimensions = frameLayout.placeDimensions[placeIndex] ?? 0; - const offset = currentFrame.placeOffsets[placeIndex] ?? 0; - const elements = color?.elements ?? []; - if (count === 0 || dimensions === 0 || elements.length === 0) { + const tokenLayout = frameLayout.placeTokenLayouts[placeIndex]; + const byteOffset = currentFrame.placeOffsets[placeIndex] ?? 0; + if (count === 0 || !tokenLayout || tokenLayout.strideBytes === 0) { return []; } - const tokens: Record[] = []; + const tokens: TokenRecord[] = []; for (let tokenIndex = 0; tokenIndex < count; tokenIndex++) { - const token: Record = {}; - const base = offset + tokenIndex * dimensions; - for ( - let dimensionIndex = 0; - dimensionIndex < elements.length && dimensionIndex < dimensions; - dimensionIndex++ - ) { - token[elements[dimensionIndex]!.name] = - currentFrame.tokenValues[base + dimensionIndex] ?? 0; - } - tokens.push(token); + tokens.push( + readTokenRecord( + tokenLayout, + currentFrame.tokenF64, + currentFrame.tokenBytes, + byteOffset + tokenIndex * tokenLayout.strideBytes, + ), + ); } return tokens; diff --git a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/internal-types.ts b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/internal-types.ts index 405d2d4a743..cb245d19e86 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/internal-types.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/internal-types.ts @@ -8,7 +8,8 @@ export type PlaceID = string; export type TransitionEffect = { remove: Record | number>; - add: Record; + /** One packed token byte block (strideBytes long) per new token. */ + add: Record; newRngState: number; }; diff --git a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/metrics/specs.ts b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/metrics/specs.ts index 2cecaf8a0a8..f05e90d491c 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/metrics/specs.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/metrics/specs.ts @@ -43,7 +43,7 @@ function createExpressionMetricConfig( } return applyMetricSpecBase(spec, ({ frame }) => - compiled.fn(buildMetricState(frame, sdcpn.places, sdcpn.types)), + compiled.fn(buildMetricState(frame, sdcpn.places)), ); } diff --git a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/monte-carlo-simulator.test.ts b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/monte-carlo-simulator.test.ts index 4c2d384ea26..9a69f5f8cca 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/monte-carlo-simulator.test.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/monte-carlo-simulator.test.ts @@ -163,7 +163,7 @@ describe("MonteCarloSimulator", () => { ], dt: 1, maxTime: 20, - initialTokenValueCapacity: 0, + initialTokenByteCapacity: 0, }); const result = simulator.runUntilComplete({ maxBatches: 20 }); @@ -182,10 +182,9 @@ describe("MonteCarloSimulator", () => { source: 0, product: 1, }); - expect(firstRun.tokenValueCount).toBe(1); - expect(firstRun.tokenValueCapacity).toBeGreaterThan( - firstRun.tokenValueCount, - ); + // "product" tokens carry one real element → 8-byte stride per token. + expect(firstRun.tokenByteCount).toBe(8); + expect(firstRun.tokenByteCapacity).toBeGreaterThan(firstRun.tokenByteCount); expect(firstRun.reallocations).toBeGreaterThan(0); expect(secondRun.status).toBe("complete"); @@ -195,7 +194,7 @@ describe("MonteCarloSimulator", () => { source: 0, product: 2, }); - expect(secondRun.tokenValueCount).toBe(2); + expect(secondRun.tokenByteCount).toBe(16); }); it("does not consume tokens read by read arcs", () => { @@ -205,7 +204,14 @@ describe("MonteCarloSimulator", () => { sampleRuns: "all", aggregateRuns: "last", aggregateTime: "none", - measure: ({ frame }) => frame.getPlaceTokenValues("product")?.values[0], + measure: ({ frame }) => { + const token = frame.getPlaceTokens( + readArcSdcpn.places.find( + (sdcpnPlace) => sdcpnPlace.id === "product", + )!, + )[0]; + return token ? Number(token.quality) : undefined; + }, }); const simulator = createMonteCarloSimulator({ sdcpn: readArcSdcpn, diff --git a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/run-state.ts b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/run-state.ts index 527b3cd8c53..f35f2135025 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/run-state.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/run-state.ts @@ -33,7 +33,7 @@ function deriveRunSeed(baseSeed: number, runIndex: number): number { } /** - * Ensures a frame has enough Float64 token value capacity. + * Ensures a frame has enough token byte capacity. * * If the frame is too small, this allocates a replacement with 2x growth, * copies existing state, rewires the owning run's current/next frame pointer, @@ -42,16 +42,16 @@ function deriveRunSeed(baseSeed: number, runIndex: number): number { export function ensureFrameCapacity( run: MonteCarloRunState, frame: MonteCarloFrameBuffer, - requiredTokenValueCount: number, + requiredTokenByteCount: number, ): MonteCarloFrameBuffer { - if (frame.tokenValueCapacity >= requiredTokenValueCount) { + if (frame.tokenByteCapacity >= requiredTokenByteCount) { return frame; } const nextCapacity = Math.max( - requiredTokenValueCount, - frame.tokenValueCapacity * 2, - 8, + requiredTokenByteCount, + frame.tokenByteCapacity * 2, + 64, ); const resizedFrame = cloneMonteCarloFrameBuffer( run.simulation.frameLayout, @@ -102,10 +102,10 @@ export function createRunState( } const initialView = readEngineFrame(simulation.frameLayout, initialFrame); - const initialTokenValueCount = initialView.tokenValues.length; + const initialTokenByteCount = initialView.tokenBytes.byteLength; const initialCapacity = Math.max( - config.initialTokenValueCapacity ?? initialTokenValueCount, - initialTokenValueCount, + config.initialTokenByteCapacity ?? initialTokenByteCount, + initialTokenByteCount, ); const currentFrame = createMonteCarloFrameBuffer( simulation.frameLayout, @@ -160,8 +160,8 @@ export function summarizeRun(run: MonteCarloRunState): MonteCarloRunSummary { parameterValues: run.parameterValues, completionReason: run.completionReason, error: run.error, - tokenValueCount: run.currentFrame.tokenValueCount, - tokenValueCapacity: run.currentFrame.tokenValueCapacity, + tokenByteCount: run.currentFrame.tokenByteCount, + tokenByteCapacity: run.currentFrame.tokenByteCapacity, reallocations: run.reallocations, }; } 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 d6209ef04f8..15effeef66d 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 @@ -4,9 +4,10 @@ import { enumerateWeightedMarkingIndicesGenerator } from "../engine/enumerate-we import { sampleDistribution } from "../engine/sample-distribution"; import { nextRandom } from "../engine/seeded-rng"; import { - decodeTokenRecord, - encodeTokenAttributeValue, -} from "../engine/token-values"; + encodeTokenValuesToBytes, + readTokenRecord, +} from "../engine/token-layout"; +import { encodeTokenAttributeValue } from "../engine/token-values"; import { getPlaceIndex, getTransitionIndex } from "./layout"; import type { @@ -43,8 +44,8 @@ export function computeTransitionEffect( ...inputPlace, placeIndex, count: frame.placeCounts[placeIndex] ?? 0, - offset: frame.placeOffsets[placeIndex] ?? 0, - dimensions: frameLayout.placeDimensions[placeIndex] ?? 0, + byteOffset: frame.placeOffsets[placeIndex] ?? 0, + strideBytes: frameLayout.placeStrideBytes[placeIndex] ?? 0, }; }); @@ -61,10 +62,10 @@ export function computeTransitionEffect( const timeSinceLastFiring = (frame.transitionElapsedFrames[transitionIndex] ?? 0) * run.simulation.dt; const inputPlacesWithValues = inputPlaces.filter( - (place) => place.dimensions > 0 && place.arcType !== "inhibitor", + (place) => place.strideBytes > 0 && place.arcType !== "inhibitor", ); const standardInputPlacesWithoutValues = inputPlaces.filter( - (place) => place.dimensions === 0 && place.arcType === "standard", + (place) => place.strideBytes === 0 && place.arcType === "standard", ); const tokenCombinations = enumerateWeightedMarkingIndicesGenerator( @@ -79,28 +80,23 @@ export function computeTransitionEffect( tokenIndices, ] of tokenCombinationIndices.entries()) { const inputPlace = inputPlacesWithValues[placeIndex]!; - const { dimensions, offset } = inputPlace; - if (!inputPlace.elementNames) { - throw new SDCPNItemError( - `Place \`${inputPlace.placeName}\` has no type defined`, - inputPlace.placeId, - ); - } - const elements = inputPlace.elements; - if (!elements) { + const { strideBytes, byteOffset } = inputPlace; + const tokenLayout = inputPlace.tokenLayout; + if (!tokenLayout) { throw new SDCPNItemError( `Place \`${inputPlace.placeName}\` has no type defined`, inputPlace.placeId, ); } - tokenValues[inputPlace.placeName] = tokenIndices.map((tokenIndex) => { - const tokenOffset = offset + tokenIndex * dimensions; - return decodeTokenRecord( - elements, - frame.tokenValues.subarray(tokenOffset, tokenOffset + dimensions), - ); - }); + tokenValues[inputPlace.placeName] = tokenIndices.map((tokenIndex) => + readTokenRecord( + tokenLayout, + frame.tokenF64, + frame.tokenBytes, + byteOffset + tokenIndex * strideBytes, + ), + ); } let lambdaResult: ReturnType; @@ -138,16 +134,16 @@ export function computeTransitionEffect( ); } - const add: Record = {}; + const add: Record = {}; let currentRngState = candidateRngState; for (const outputPlace of transition.outputPlaces) { const outputPlaceIndex = getPlaceIndex(frameLayout, outputPlace.placeId); - const dimensions = frameLayout.placeDimensions[outputPlaceIndex] ?? 0; + const strideBytes = frameLayout.placeStrideBytes[outputPlaceIndex] ?? 0; - if (!outputPlace.elementNames) { + if (!outputPlace.tokenLayout) { add[outputPlace.placeId] = Array.from( { length: outputPlace.weight }, - () => [], + () => new Uint8Array(0), ); continue; } @@ -160,9 +156,9 @@ export function computeTransitionEffect( ); } - const tokenArrays: number[][] = []; + const tokenBlocks: Uint8Array[] = []; for (const token of outputTokens) { - const values: number[] = []; + const encodedByName: Record = {}; for (const element of outputPlace.elements ?? []) { let rawValue = token[element.name]; if (isDistribution(rawValue)) { @@ -178,23 +174,25 @@ export function computeTransitionEffect( currentRngState = nextRngState; rawValue = sampled; } - values.push( - encodeTokenAttributeValue( - element, - rawValue, - `Transition ${transition.id} output ${outputPlace.placeName}.${element.name}`, - ), + encodedByName[element.name] = encodeTokenAttributeValue( + element, + rawValue, + `Transition ${transition.id} output ${outputPlace.placeName}.${element.name}`, ); } - if (values.length !== dimensions) { + const block = encodeTokenValuesToBytes( + outputPlace.tokenLayout, + encodedByName, + ); + if (block.byteLength !== strideBytes) { throw new Error( - `Transition ${transition.id} produced ${values.length} values for place ${outputPlace.placeId}, expected ${dimensions}`, + `Transition ${transition.id} produced a ${block.byteLength}-byte token for place ${outputPlace.placeId}, expected ${strideBytes}`, ); } - tokenArrays.push(values); + tokenBlocks.push(block); } - add[outputPlace.placeId] = tokenArrays; + add[outputPlace.placeId] = tokenBlocks; } const remove: TransitionEffect["remove"] = {}; diff --git a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/types.ts b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/types.ts index 24d647dcb8b..bf7f66c655e 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/types.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/types.ts @@ -23,7 +23,8 @@ export type MonteCarloSimulatorConfig = { dt: number; maxTime: number; runs?: readonly MonteCarloRunConfig[]; - initialTokenValueCapacity?: number; + /** Initial token region capacity per run, in bytes. */ + initialTokenByteCapacity?: number; metrics?: readonly MonteCarloFrameMetric[]; }; @@ -37,8 +38,10 @@ export type MonteCarloRunSummary = { parameterValues: ParameterValues; completionReason: SimulationCompletionReason | null; error: string | null; - tokenValueCount: number; - tokenValueCapacity: number; + /** Used token region length of the current frame, in bytes. */ + tokenByteCount: number; + /** Allocated token region capacity of the current frame, in bytes. */ + tokenByteCapacity: number; reallocations: number; }; 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..2bf6b0fe2ef 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/runtime/frame-store.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/runtime/frame-store.ts @@ -14,7 +14,7 @@ export interface SimulationFrameStore { } /** - * Compatibility store for the v1 worker protocol. It keeps all full frame + * Default in-memory store for the worker protocol. It keeps all full frame * payloads in memory, while hiding that retention policy from `Simulation`. */ export function createInMemorySimulationFrameStore( diff --git a/libs/@hashintel/petrinaut-core/src/simulation/runtime/simulation.test.ts b/libs/@hashintel/petrinaut-core/src/simulation/runtime/simulation.test.ts index c65e7881e36..cc286715e43 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/runtime/simulation.test.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/runtime/simulation.test.ts @@ -28,7 +28,7 @@ function makeFrame(time: number): SimulationFramePayload { const frame = createEngineFrame(createEngineFrameLayout(empty()), { places: {}, transitions: {}, - buffer: new Float64Array(), + buffer: new Uint8Array(0), }); return { diff --git a/libs/@hashintel/petrinaut/src/main.ts b/libs/@hashintel/petrinaut/src/main.ts index a182f275367..71a3534d93d 100644 --- a/libs/@hashintel/petrinaut/src/main.ts +++ b/libs/@hashintel/petrinaut/src/main.ts @@ -45,7 +45,6 @@ export { type SimulationFrameReader, type SimulationFrameState, type SimulationFrameSummary, - type SimulationPlaceTokenValues, type SimulationState, type SimulationTransport, type Transition, diff --git a/libs/@hashintel/petrinaut/src/react/playback/provider.test.tsx b/libs/@hashintel/petrinaut/src/react/playback/provider.test.tsx index 8daa58d500d..c2a961acb37 100644 --- a/libs/@hashintel/petrinaut/src/react/playback/provider.test.tsx +++ b/libs/@hashintel/petrinaut/src/react/playback/provider.test.tsx @@ -28,7 +28,6 @@ function createMockFrameReader(number: number): SimulationFrameReader { number, time: number * 0.01, getPlaceTokenCount: () => 0, - getPlaceTokenValues: () => null, getPlaceTokens: () => [], getTransitionState: () => null, toFrameState: () => ({ diff --git a/libs/@hashintel/petrinaut/src/ui/dev/token-encoding-playground/physical-layout.test.ts b/libs/@hashintel/petrinaut/src/ui/dev/token-encoding-playground/physical-layout.test.ts index 330e66eafd3..5785cf40f86 100644 --- a/libs/@hashintel/petrinaut/src/ui/dev/token-encoding-playground/physical-layout.test.ts +++ b/libs/@hashintel/petrinaut/src/ui/dev/token-encoding-playground/physical-layout.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { - computeTokenLayout, + computePlaygroundTokenLayout, decodeToken, encodeToken, getFieldBits, @@ -16,28 +16,18 @@ const DIMENSIONS: PlaygroundDimension[] = [ { name: "count", type: "integer" }, ]; -describe("computeTokenLayout", () => { - it("v1 keeps declaration order with one f64 slot per dimension", () => { - const layout = computeTokenLayout(DIMENSIONS, "v1"); - - expect(layout.strideBytes).toBe(24); - expect(layout.paddingRanges).toEqual([]); - expect( - layout.fields.map((f) => [f.name, f.physical.kind, f.byteOffset]), - ).toEqual([ - ["active", "f64", 0], - ["amount", "f64", 8], - ["count", "f64", 16], - ]); - }); - - it("v2 orders by decreasing alignment and pads the stride to 8", () => { - const layout = computeTokenLayout(DIMENSIONS, "v2"); +describe("computePlaygroundTokenLayout", () => { + it("orders by decreasing alignment and pads the stride to 8", () => { + const layout = computePlaygroundTokenLayout(DIMENSIONS); // amount and count (f64, align 8) first — stable relative order — then // the u8 boolean, then 7 bytes of tail padding. expect( - layout.fields.map((f) => [f.name, f.physical.kind, f.byteOffset]), + layout.fields.map((field) => [ + field.element.name, + field.kind, + field.byteOffset, + ]), ).toEqual([ ["amount", "f64", 0], ["count", "f64", 8], @@ -48,39 +38,35 @@ describe("computeTokenLayout", () => { }); it("returns an empty layout for no dimensions", () => { - const layout = computeTokenLayout([], "v2"); + const layout = computePlaygroundTokenLayout([]); expect(layout.strideBytes).toBe(0); expect(layout.fields).toEqual([]); }); }); describe("encodeToken / decodeToken", () => { - it.each(["v1", "v2"] as const)( - "round-trips with product coercion in %s mode", - (mode) => { - const layout = computeTokenLayout(DIMENSIONS, mode); - const { stored, decoded } = encodeToken(layout, { - active: true, - amount: 1.25, - count: 2.7, - }); + it("round-trips with product coercion", () => { + const layout = computePlaygroundTokenLayout(DIMENSIONS); + const { stored, decoded } = encodeToken(layout, { + active: true, + amount: 1.25, + count: 2.7, + }); - expect(stored).toEqual({ active: true, amount: 1.25, count: 3 }); - expect(decoded).toEqual({ active: true, amount: 1.25, count: 3 }); - }, - ); + expect(stored).toEqual({ active: true, amount: 1.25, count: 3 }); + expect(decoded).toEqual({ active: true, amount: 1.25, count: 3 }); + }); it("applies typed defaults for missing values", () => { - const layout = computeTokenLayout(DIMENSIONS, "v2"); + const layout = computePlaygroundTokenLayout(DIMENSIONS); const { decoded } = encodeToken(layout, {}); expect(decoded).toEqual({ active: false, amount: 0, count: 0 }); }); - it("stores booleans as a single byte in v2", () => { - const layout = computeTokenLayout( - [{ name: "flag", type: "boolean" }], - "v2", - ); + it("stores booleans as a single byte", () => { + const layout = computePlaygroundTokenLayout([ + { name: "flag", type: "boolean" }, + ]); const { buffer } = encodeToken(layout, { flag: true }); expect(layout.strideBytes).toBe(8); expect(new Uint8Array(buffer)[0]).toBe(1); @@ -90,7 +76,7 @@ describe("encodeToken / decodeToken", () => { describe("bit inspection", () => { it("exposes IEEE-754 bits MSB-first for f64 fields", () => { - const layout = computeTokenLayout([{ name: "x", type: "real" }], "v1"); + const layout = computePlaygroundTokenLayout([{ name: "x", type: "real" }]); const { buffer } = encodeToken(layout, { x: -2 }); const bits = getFieldBits(buffer, layout.fields[0]!); diff --git a/libs/@hashintel/petrinaut/src/ui/dev/token-encoding-playground/physical-layout.ts b/libs/@hashintel/petrinaut/src/ui/dev/token-encoding-playground/physical-layout.ts index bcb5835cd44..a7403272d02 100644 --- a/libs/@hashintel/petrinaut/src/ui/dev/token-encoding-playground/physical-layout.ts +++ b/libs/@hashintel/petrinaut/src/ui/dev/token-encoding-playground/physical-layout.ts @@ -1,128 +1,42 @@ import { coerceTokenAttributeValue, - decodeTokenAttributeValue, - encodeTokenAttributeValue, + computeTokenSlotLayout, + createTokenRegionViews, + encodeTokenToBytes, + readTokenRecord, } from "@hashintel/petrinaut-core"; -import type { ColorElementType, TokenRecord } from "@hashintel/petrinaut-core"; - -/** - * Physical (buffer-level) representation of one token attribute. - * - * This mirrors the planned "format v2" abstraction for engine frames: the - * logical schema type (`ColorElementType`) maps to a physical type with a - * byte size and alignment, and a per-colour struct layout is computed from - * that mapping. `u64x2` exists for future 128-bit types (FE-1121) — the - * memory view renders it, but no logical type maps to it yet. - */ -export type PhysicalKind = "f64" | "u8" | "u64x2"; - -export type PhysicalType = { - kind: PhysicalKind; - byteSize: number; - align: number; -}; +import type { + ColorElementType, + TokenLayoutField, + TokenRecord, + TokenSlotLayout, +} from "@hashintel/petrinaut-core"; /** - * - `v1`: the shipped engine encoding — every dimension is one Float64 slot, - * in declaration order. - * - `v2`: the planned packed-struct encoding — booleans become `u8`, fields - * are ordered by decreasing alignment, stride is rounded up to 8 bytes. + * Thin playground wrapper over the engine's token layout module + * (`computeTokenSlotLayout` and friends from `@hashintel/petrinaut-core`). + * The layout math lives in the core package — this file only adapts + * playground dimension fixtures to colour elements and adds bit-level + * rendering helpers for the memory view. */ -export type LayoutMode = "v1" | "v2"; - export type PlaygroundDimension = { name: string; type: ColorElementType; }; -export type LayoutField = { - name: string; - elementType: ColorElementType; - physical: PhysicalType; - byteOffset: number; -}; - -/** Half-open byte range `[start, end)` within one token's stride. */ -export type ByteRange = { start: number; end: number }; - -export type TokenLayout = { - mode: LayoutMode; - /** sizeof(token) — total bytes per token, including padding. */ - strideBytes: number; - fields: LayoutField[]; - /** Alignment gaps and tail padding. */ - paddingRanges: ByteRange[]; -}; - -const PHYSICAL_TYPES: Record = { - f64: { kind: "f64", byteSize: 8, align: 8 }, - u8: { kind: "u8", byteSize: 1, align: 1 }, - u64x2: { kind: "u64x2", byteSize: 16, align: 8 }, -}; - -export function physicalTypeFor( - elementType: ColorElementType, - mode: LayoutMode, -): PhysicalType { - if (mode === "v1") { - return PHYSICAL_TYPES.f64; - } - switch (elementType) { - case "boolean": - return PHYSICAL_TYPES.u8; - case "integer": - case "real": - return PHYSICAL_TYPES.f64; - } -} - -const alignTo = (value: number, alignment: number): number => - Math.ceil(value / alignment) * alignment; - -/** - * Computes the packed struct layout for one token (the C `sizeof` and - * `offsetof`). In `v2` mode, fields are ordered by decreasing alignment - * (stable within equal alignment) so no interior padding is wasted, and the - * stride is rounded up to 8 bytes so consecutive tokens keep f64 fields - * 8-aligned. - */ -export function computeTokenLayout( - dimensions: readonly PlaygroundDimension[], - mode: LayoutMode, -): TokenLayout { - const withPhysical = dimensions.map((dimension) => ({ - dimension, - physical: physicalTypeFor(dimension.type, mode), +const toColorElements = (dimensions: readonly PlaygroundDimension[]) => + dimensions.map((dimension) => ({ + elementId: dimension.name, + name: dimension.name, + type: dimension.type, })); - const ordered = - mode === "v2" - ? [...withPhysical].sort((a, b) => b.physical.align - a.physical.align) - : withPhysical; - - const fields: LayoutField[] = []; - const paddingRanges: ByteRange[] = []; - let cursor = 0; - for (const { dimension, physical } of ordered) { - const byteOffset = alignTo(cursor, physical.align); - if (byteOffset > cursor) { - paddingRanges.push({ start: cursor, end: byteOffset }); - } - fields.push({ - name: dimension.name, - elementType: dimension.type, - physical, - byteOffset, - }); - cursor = byteOffset + physical.byteSize; - } - - const strideBytes = fields.length === 0 ? 0 : alignTo(cursor, 8); - if (strideBytes > cursor) { - paddingRanges.push({ start: cursor, end: strideBytes }); - } - return { mode, strideBytes, fields, paddingRanges }; +/** Computes the packed struct layout for the playground's dimensions. */ +export function computePlaygroundTokenLayout( + dimensions: readonly PlaygroundDimension[], +): TokenSlotLayout { + return computeTokenSlotLayout(toColorElements(dimensions)); } export type EncodedToken = { @@ -133,80 +47,36 @@ export type EncodedToken = { decoded: TokenRecord; }; -const toColorElement = (field: LayoutField) => ({ - elementId: field.name, - name: field.name, - type: field.elementType, -}); - export function decodeToken( - layout: TokenLayout, + layout: TokenSlotLayout, buffer: ArrayBuffer, ): TokenRecord { - const view = new DataView(buffer); - const token: TokenRecord = {}; - for (const field of layout.fields) { - const element = toColorElement(field); - switch (field.physical.kind) { - case "f64": - token[field.name] = decodeTokenAttributeValue( - element, - view.getFloat64(field.byteOffset, true), - ); - break; - case "u8": - token[field.name] = decodeTokenAttributeValue( - element, - view.getUint8(field.byteOffset), - ); - break; - case "u64x2": - throw new Error( - `Token.${field.name}: 128-bit decoding is not implemented yet (FE-1121)`, - ); - } + if (layout.strideBytes === 0) { + return {}; } - return token; + const { f64, u8 } = createTokenRegionViews(buffer, 0, layout.strideBytes); + return readTokenRecord(layout, f64, u8, 0); } /** * Encodes one token record into a fresh buffer using the real product codec - * (`encodeTokenAttributeValue`) for value coercion, then decodes it back so - * callers can show the round-trip. All multi-byte values are little-endian, - * matching the platform layout of the engine's typed-array views. + * (`encodeTokenToBytes`) for value coercion, then decodes it back so callers + * can show the round-trip. All multi-byte values are little-endian, matching + * the platform layout of the engine's typed-array views. */ export function encodeToken( - layout: TokenLayout, + layout: TokenSlotLayout, record: Record, ): EncodedToken { - const buffer = new ArrayBuffer(layout.strideBytes); - const view = new DataView(buffer); - const stored: TokenRecord = {}; + const bytes = encodeTokenToBytes(layout, record, "Token"); + const buffer = bytes.buffer as ArrayBuffer; + const stored: TokenRecord = {}; for (const field of layout.fields) { - const element = toColorElement(field); - const context = `Token.${field.name}`; - const slotValue = encodeTokenAttributeValue( - element, - record[field.name], - context, - ); - switch (field.physical.kind) { - case "f64": - view.setFloat64(field.byteOffset, slotValue, true); - break; - case "u8": - view.setUint8(field.byteOffset, slotValue); - break; - case "u64x2": - throw new Error( - `Token.${field.name}: 128-bit encoding is not implemented yet (FE-1121)`, - ); - } - stored[field.name] = coerceTokenAttributeValue( - element, - record[field.name], - context, + stored[field.element.name] = coerceTokenAttributeValue( + field.element, + record[field.element.name], + `Token.${field.element.name}`, ); } @@ -220,13 +90,9 @@ export function encodeToken( */ export function getFieldBits( buffer: ArrayBuffer, - field: LayoutField, + field: TokenLayoutField, ): number[] { - const bytes = new Uint8Array( - buffer, - field.byteOffset, - field.physical.byteSize, - ); + const bytes = new Uint8Array(buffer, field.byteOffset, field.byteSize); const bits: number[] = []; for (let byteIndex = bytes.length - 1; byteIndex >= 0; byteIndex--) { for (let bit = 7; bit >= 0; bit--) { @@ -238,12 +104,11 @@ export function getFieldBits( } /** Hex form of the field's stored bytes, most-significant byte first. */ -export function getFieldHex(buffer: ArrayBuffer, field: LayoutField): string { - const bytes = new Uint8Array( - buffer, - field.byteOffset, - field.physical.byteSize, - ); +export function getFieldHex( + buffer: ArrayBuffer, + field: TokenLayoutField, +): string { + const bytes = new Uint8Array(buffer, field.byteOffset, field.byteSize); let hex = ""; for (let byteIndex = bytes.length - 1; byteIndex >= 0; byteIndex--) { hex += bytes[byteIndex]!.toString(16).padStart(2, "0"); diff --git a/libs/@hashintel/petrinaut/src/ui/dev/token-encoding-playground/token-encoding-playground.stories.tsx b/libs/@hashintel/petrinaut/src/ui/dev/token-encoding-playground/token-encoding-playground.stories.tsx index 2bfcc00eafd..a5fa55e04c4 100644 --- a/libs/@hashintel/petrinaut/src/ui/dev/token-encoding-playground/token-encoding-playground.stories.tsx +++ b/libs/@hashintel/petrinaut/src/ui/dev/token-encoding-playground/token-encoding-playground.stories.tsx @@ -6,8 +6,8 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; /** * Interactive inspector for the simulation token encoding: define a colour's * dimensions, author a token value, and see the exact bits stored in the - * frame buffer — in the shipped v1 layout (uniform f64 slots) or the planned - * format-v2 packed struct (u8 booleans, alignment padding). + * frame buffer in the engine's packed-struct token layout (f64 numbers, u8 + * booleans, alignment padding). */ const meta = { title: "Dev / Token Encoding Playground", diff --git a/libs/@hashintel/petrinaut/src/ui/dev/token-encoding-playground/token-encoding-playground.tsx b/libs/@hashintel/petrinaut/src/ui/dev/token-encoding-playground/token-encoding-playground.tsx index c8f78337bc4..326f2b4ac49 100644 --- a/libs/@hashintel/petrinaut/src/ui/dev/token-encoding-playground/token-encoding-playground.tsx +++ b/libs/@hashintel/petrinaut/src/ui/dev/token-encoding-playground/token-encoding-playground.tsx @@ -7,7 +7,7 @@ import { SegmentGroup } from "../../components/segment-group"; import { CodeEditor } from "../../monaco/code-editor"; import { MonacoContext } from "../../monaco/context"; import { DimensionEditor } from "./dimension-editor"; -import { computeTokenLayout, encodeToken } from "./physical-layout"; +import { computePlaygroundTokenLayout, encodeToken } from "./physical-layout"; import { disableTypescriptLanguageService, enableTypescriptLanguageService, @@ -17,13 +17,9 @@ import { } from "./playground-monaco"; import { TokenMemoryView } from "./token-memory-view"; -import type { - EncodedToken, - LayoutMode, - PlaygroundDimension, - TokenLayout, -} from "./physical-layout"; +import type { EncodedToken, PlaygroundDimension } from "./physical-layout"; import type { BitOrder } from "./token-memory-view"; +import type { TokenSlotLayout } from "@hashintel/petrinaut-core"; const DEFAULT_DIMENSIONS: PlaygroundDimension[] = [ { name: "amount", type: "real" }, @@ -41,7 +37,7 @@ const DEFAULT_CODE = `export default Token(() => ({ type EvaluationResult = | { ok: true; - layout: TokenLayout; + layout: TokenSlotLayout; encoded: EncodedToken; input: Record; } @@ -50,10 +46,9 @@ type EvaluationResult = function evaluate( code: string, dimensions: PlaygroundDimension[], - mode: LayoutMode, ): EvaluationResult { try { - const layout = computeTokenLayout(dimensions, mode); + const layout = computePlaygroundTokenLayout(dimensions); // Same pipeline user code takes in the product (Babel TS-strip + eval). const create = compileUserCode<[]>(code, "Token"); const raw: unknown = create(); @@ -170,14 +165,13 @@ const PlaygroundEditor: React.FC<{ /** * Dev-only playground: define a colour's dimensions, author a token value, - * and inspect the exact bits the simulation buffer would hold — in the - * shipped v1 encoding (uniform f64 slots) or the planned v2 packed layout. + * and inspect the exact bits the simulation buffer holds in the shipped + * packed-struct token layout. */ export const TokenEncodingPlayground: React.FC = () => { const [dimensions, setDimensions] = useState(DEFAULT_DIMENSIONS); const [code, setCode] = useState(DEFAULT_CODE); - const [layoutMode, setLayoutMode] = useState("v1"); const [bitOrder, setBitOrder] = useState("logical"); const [result, setResult] = useState(null); @@ -190,10 +184,10 @@ export const TokenEncodingPlayground: React.FC = () => { const timer = setTimeout(() => { // Filter inside the effect: depending on a derived array would rerun // the debounce whenever the reference changes rather than the data. - setResult(evaluate(code, encodableDimensions(dimensions), layoutMode)); + setResult(evaluate(code, encodableDimensions(dimensions))); }, 250); return () => clearTimeout(timer); - }, [code, dimensions, layoutMode]); + }, [code, dimensions]); return (
@@ -219,15 +213,6 @@ export const TokenEncodingPlayground: React.FC = () => {
- setLayoutMode(value as LayoutMode)} - options={[ - { value: "v1", label: "v1 — uniform f64 (shipped)" }, - { value: "v2", label: "v2 — packed struct (planned)" }, - ]} - /> ; @@ -28,8 +31,11 @@ const fieldColor = (fieldIndex: number, lightness: number): string => const PADDING_BACKGROUND = "repeating-linear-gradient(45deg, #f2f2f2, #f2f2f2 2px, #d8d8d8 2px, #d8d8d8 4px)"; -const bitLightness = (field: LayoutField, msbFirstIndex: number): number => { - if (field.physical.kind !== "f64") { +const bitLightness = ( + field: TokenLayoutField, + msbFirstIndex: number, +): number => { + if (field.kind !== "f64") { return 78; } switch (f64BitPart(msbFirstIndex)) { @@ -54,7 +60,7 @@ type ByteRow = { type SlotGroup = { key: string; - field: LayoutField | null; // null = padding + field: TokenLayoutField | null; // null = padding fieldIndex: number; startByte: number; rows: ByteRow[]; @@ -66,7 +72,7 @@ type SlotGroup = { * order follows the buffer (little-endian: least-significant byte first). */ function buildSlotGroups( - layout: TokenLayout, + layout: TokenSlotLayout, buffer: ArrayBuffer, bitOrder: BitOrder, ): SlotGroup[] { @@ -74,7 +80,7 @@ function buildSlotGroups( const groups: SlotGroup[] = []; for (const [fieldIndex, field] of layout.fields.entries()) { - const size = field.physical.byteSize; + const size = field.byteSize; const byteIndices = Array.from({ length: size }, (_, position) => bitOrder === "logical" ? size - 1 - position : position, ); @@ -278,12 +284,8 @@ const formatValue = (value: unknown): string => { } }; -const fieldHex = (buffer: ArrayBuffer, field: LayoutField): string => { - const bytes = new Uint8Array( - buffer, - field.byteOffset, - field.physical.byteSize, - ); +const fieldHex = (buffer: ArrayBuffer, field: TokenLayoutField): string => { + const bytes = new Uint8Array(buffer, field.byteOffset, field.byteSize); let hex = ""; for (let byteIndex = bytes.length - 1; byteIndex >= 0; byteIndex--) { hex += bytes[byteIndex]!.toString(16).padStart(2, "0"); @@ -297,7 +299,7 @@ const fieldHex = (buffer: ArrayBuffer, field: LayoutField): string => { * Renders one encoded token as a compact memory column: one byte (8 bits) * per row, grouped by slot. Hovering a slot (or its legend chip) highlights * the whole slot and reveals its details in the side panel. Built against - * the format-v2 abstraction (`TokenLayout`): any mix of field widths and + * the engine's `TokenLayout` abstraction: any mix of field widths and * padding renders, not just uniform f64 slots. */ export const TokenMemoryView: React.FC = ({ @@ -322,7 +324,7 @@ export const TokenMemoryView: React.FC = ({ const groups = buildSlotGroups(layout, buffer, bitOrder); const hoveredGroup = groups.find((group) => group.key === hoveredKey) ?? null; - const hasF64 = layout.fields.some((field) => field.physical.kind === "f64"); + const hasF64 = layout.fields.some((field) => field.kind === "f64"); return (
@@ -348,7 +350,7 @@ export const TokenMemoryView: React.FC = ({ }} > - {field.name} · {field.physical.kind} + {field.element.name} · {field.kind} ))} {layout.paddingRanges.length > 0 ? ( @@ -358,7 +360,7 @@ export const TokenMemoryView: React.FC = ({ ) : null} - sizeof(token) = {layout.strideBytes} B ({layout.mode}) + sizeof(token) = {layout.strideBytes} B {hasF64 ? ( @@ -435,19 +437,20 @@ export const TokenMemoryView: React.FC = ({ className={infoTitleStyle} style={{ color: fieldColor(hoveredGroup.fieldIndex, 35) }} > - {hoveredGroup.field.name} + {hoveredGroup.field.element.name} - {hoveredGroup.field.elementType} →{" "} - {hoveredGroup.field.physical.kind} · offset{" "} - {hoveredGroup.field.byteOffset} ·{" "} - {hoveredGroup.field.physical.byteSize} B + {hoveredGroup.field.element.type} → {hoveredGroup.field.kind} · + offset {hoveredGroup.field.byteOffset} ·{" "} + {hoveredGroup.field.byteSize} B {fieldHex(buffer, hoveredGroup.field)} - input {formatValue(input[hoveredGroup.field.name])} → stored{" "} - {formatValue(stored[hoveredGroup.field.name])} → reads back{" "} - {formatValue(decoded[hoveredGroup.field.name])} + input {formatValue(input[hoveredGroup.field.element.name])} → + stored{" "} + {formatValue(stored[hoveredGroup.field.element.name])} → + reads back{" "} + {formatValue(decoded[hoveredGroup.field.element.name])} )} diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/series-config/index.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/series-config/index.ts index be64ebc9447..8cf575a22e4 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/series-config/index.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/series-config/index.ts @@ -43,7 +43,6 @@ export function buildTimelineSeriesConfig(args: { metric: selectedMetric, compiledMetric, places, - types, }); case "per-transition": return buildPerTransitionSeriesConfig({ transitions }); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/series-config/metric.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/series-config/metric.ts index bed4cfefad8..6a78ce3cfbc 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/series-config/metric.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-timeline/series-config/metric.ts @@ -1,7 +1,6 @@ import { buildMetricState, type CompiledMetric, - type Color, type Metric, type Place, } from "@hashintel/petrinaut-core"; @@ -21,9 +20,8 @@ export function buildMetricSeriesConfig(args: { metric: Metric | null; compiledMetric: CompiledMetric | null; places: Place[]; - types: Color[]; }): TimelineSeriesConfig { - const { metric, compiledMetric, places, types } = args; + const { metric, compiledMetric, places } = args; if (!metric || !compiledMetric) { return { @@ -42,7 +40,7 @@ export function buildMetricSeriesConfig(args: { ], extract: (frame) => { try { - return compiledMetric(buildMetricState(frame, places, types)); + return compiledMetric(buildMetricState(frame, places)); } catch { return Number.NaN; } 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 2ebca7e097c..04c2cdaf914 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 @@ -51,7 +51,7 @@ export const InitialStateEditor: React.FC = ({ const data: SpreadsheetCellValue[][] = useMemo(() => { if (hasSimulation && currentFrameReader) { return currentFrameReader - .getPlaceTokens(place, placeType) + .getPlaceTokens(place) .map((token) => columns.map( (column) => token[column.name] ?? getDefaultValue(column), @@ -67,14 +67,7 @@ export const InitialStateEditor: React.FC = ({ return marking.map((token) => columns.map((column) => token[column.name] ?? getDefaultValue(column)), ); - }, [ - hasSimulation, - currentFrameReader, - place, - placeType, - columns, - initialMarking, - ]); + }, [hasSimulation, currentFrameReader, place, columns, initialMarking]); // Convert spreadsheet rows back to serializable token records. const handleChange = useMemo(() => { diff --git a/libs/@hashintel/petrinaut/src/ui/views/shared/place-state-visualization.tsx b/libs/@hashintel/petrinaut/src/ui/views/shared/place-state-visualization.tsx index b64c75adc7c..c17d5580fde 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/shared/place-state-visualization.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/shared/place-state-visualization.tsx @@ -69,7 +69,7 @@ export const PlaceStateVisualization: React.FC< let parameters: Record = {}; if (totalFrames > 0 && currentFrameReader) { - tokens.push(...currentFrameReader.getPlaceTokens(place, placeType)); + tokens.push(...currentFrameReader.getPlaceTokens(place)); parameters = mergeParameterValues(parameterValues, defaultParameterValues); } else { const marking = initialMarking[place.id];