diff --git a/.changeset/status-views-core.md b/.changeset/status-views-core.md new file mode 100644 index 00000000000..7b2a2829c2a --- /dev/null +++ b/.changeset/status-views-core.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut-core": patch +--- + +SDCPN documents carry optional `identities` (named instance identities that colour elements reference via `identityRef`) and `statusViews` (ordered, place-mapped status labels with optional token conditions and an optional exit label); both validate against the entity schemas and survive file import/export. Actual-mode transition firings carry the attribute values of the tokens they consumed and produced (`inputTokens` / `outputTokens`), and recordings carry version 2. A token record for a place with a colour must carry exactly the colour's elements, each a value of its element's type, and a record for an uncoloured place must be empty; a place whose colour has elements lists token records, not a token count. Replaying a firing whose records break that rule, or that consumes a token the marking does not hold, throws, and a recording containing one fails to parse. New evaluators derive per-instance status, time-in-state, and dwell summaries from simulation frames. diff --git a/.changeset/status-views-ui.md b/.changeset/status-views-ui.md new file mode 100644 index 00000000000..66ce6ba6c12 --- /dev/null +++ b/.changeset/status-views-ui.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut": patch +--- + +Behind the experimental **Status views** setting (off by default): the simulate panel displays and allows editing of a net's status views, token type attributes can be keyed to an identity, the events panel shows per-instance status changes, and the view switcher gains a Kanban board that lays out instances by status label with each instance's time-in-state. Component-instance nodes show a status badge tinted by the active label whenever the net declares a status view. diff --git a/apps/hash-api/src/petrinaut-optimizer/shared/optimization-run-test-harness.ts b/apps/hash-api/src/petrinaut-optimizer/shared/optimization-run-test-harness.ts index a2f404ece9f..4cb332b3bc6 100644 --- a/apps/hash-api/src/petrinaut-optimizer/shared/optimization-run-test-harness.ts +++ b/apps/hash-api/src/petrinaut-optimizer/shared/optimization-run-test-harness.ts @@ -28,6 +28,8 @@ export const validOptimizationInput = { parameters: [], subnets: [], componentInstances: [], + identities: [], + statusViews: [], scenarios: [ { id: "baseline", diff --git a/apps/petrinaut-website/scripts/brunch-sse-fixture.ts b/apps/petrinaut-website/scripts/brunch-sse-fixture.ts index f2dbc9d6fe3..4cba6924e40 100644 --- a/apps/petrinaut-website/scripts/brunch-sse-fixture.ts +++ b/apps/petrinaut-website/scripts/brunch-sse-fixture.ts @@ -26,12 +26,15 @@ import http, { type ServerResponse } from "node:http"; import { homedir } from "node:os"; import { resolve } from "node:path"; +import { ACTUAL_MODE_RECORDING_VERSION } from "@hashintel/petrinaut-core"; + import type { BrunchNetDefinitionInput, BrunchTransitionInput, } from "../src/main/app/brunch-demo/brunch-protocol"; import type { ActualModeReceivedEvent, + ActualModeTokenValues, ActualModeTransitionFiring, } from "@hashintel/petrinaut-core"; @@ -192,25 +195,27 @@ const parseNumericMarking = (data: unknown, label: string): NumericMarking => { return marking; }; -const parseTransitionEffect = ( +const parseTokenValues = ( data: unknown, label: string, -): ActualModeTransitionFiring["input"] => { +): ActualModeTokenValues => { if (!isRecord(data)) { throw new Error(`Recording ${label} must be an object.`); } - const effect: ActualModeTransitionFiring["input"] = {}; + const tokenValues: ActualModeTokenValues = {}; - for (const [placeId, value] of Object.entries(data)) { - if (typeof value !== "number" || !Number.isFinite(value)) { - throw new Error(`Recording ${label}.${placeId} must be a finite number.`); + for (const [placeId, tokens] of Object.entries(data)) { + if (!Array.isArray(tokens) || !tokens.every(isRecord)) { + throw new Error( + `Recording ${label}.${placeId} must be an array of token records.`, + ); } - effect[placeId] = value; + tokenValues[placeId] = tokens as ActualModeTokenValues[string]; } - return effect; + return tokenValues; }; const parseTransitionFiring = ( @@ -233,8 +238,8 @@ const parseTransitionFiring = ( return { transitionId: data.transitionId, - input: parseTransitionEffect(data.input, `${label}.input`), - output: parseTransitionEffect(data.output, `${label}.output`), + inputTokens: parseTokenValues(data.inputTokens, `${label}.inputTokens`), + outputTokens: parseTokenValues(data.outputTokens, `${label}.outputTokens`), ts: data.ts, }; }; @@ -413,6 +418,12 @@ const parseRecordingEvents = (data: unknown): ActualModeReceivedEvent[] => { throw new Error("Recording root must be an object."); } + if (data.version !== ACTUAL_MODE_RECORDING_VERSION) { + throw new Error( + `Recording version must be ${ACTUAL_MODE_RECORDING_VERSION}, got ${String(data.version)}.`, + ); + } + if ("events" in data) { return parseReceivedEventsRecording(data); } @@ -426,7 +437,7 @@ const parseRecordingEvents = (data: unknown): ActualModeReceivedEvent[] => { } throw new Error( - "Recording must be an Actual Events export with `events` or an older normalized recording.", + "Recording must be an Actual Events export with `events` or a normalized recording with `transitionFirings`.", ); }; @@ -517,12 +528,12 @@ const applyFiringToMarking = ( marking: NumericMarking, firing: ActualModeTransitionFiring, ): void => { - for (const [placeId, value] of Object.entries(firing.input)) { - marking[placeId] = (marking[placeId] ?? 0) - value; + for (const [placeId, tokens] of Object.entries(firing.inputTokens)) { + marking[placeId] = (marking[placeId] ?? 0) - tokens.length; } - for (const [placeId, value] of Object.entries(firing.output)) { - marking[placeId] = (marking[placeId] ?? 0) + value; + for (const [placeId, tokens] of Object.entries(firing.outputTokens)) { + marking[placeId] = (marking[placeId] ?? 0) + tokens.length; } }; @@ -609,32 +620,40 @@ const canFire = (marking: NumericMarking, transitionId: string): boolean => { ); }; +/** The fixture's places are uncoloured, so each moved token is an empty record. */ +const emptyTokens = (count: number): ActualModeTokenValues[string] => + Array.from({ length: count }, () => ({})); + const applyTransition = ( marking: NumericMarking, transitionId: string, ): ActualModeTransitionFiring => { const transition = getTransition(transitionId); - const input: ActualModeTransitionFiring["input"] = {}; - const output: ActualModeTransitionFiring["output"] = {}; + const inputTokens: ActualModeTokenValues = {}; + const outputTokens: ActualModeTokenValues = {}; for (const arc of transition.inputArcs) { if ((arc.type ?? "standard") !== "standard") { continue; } - input[arc.placeId] = (input[arc.placeId] ?? 0) + arc.weight; + inputTokens[arc.placeId] = (inputTokens[arc.placeId] ?? []).concat( + emptyTokens(arc.weight), + ); marking[arc.placeId] = (marking[arc.placeId] ?? 0) - arc.weight; } for (const arc of transition.outputArcs) { - output[arc.placeId] = (output[arc.placeId] ?? 0) + arc.weight; + outputTokens[arc.placeId] = (outputTokens[arc.placeId] ?? []).concat( + emptyTokens(arc.weight), + ); marking[arc.placeId] = (marking[arc.placeId] ?? 0) + arc.weight; } return { transitionId, - input, - output, + inputTokens, + outputTokens, ts: new Date().toISOString(), }; }; @@ -864,6 +883,16 @@ const server = http.createServer((request, response) => { ); }); +const producedTokensPerPlace = ( + outputTokens: ActualModeTokenValues, +): Record => + Object.fromEntries( + Object.entries(outputTokens).map(([placeId, tokens]) => [ + placeId, + tokens.length, + ]), + ); + const broadcastLiveFiring = (): void => { const firing = nextLiveFiring(); @@ -877,7 +906,7 @@ const broadcastLiveFiring = (): void => { console.log( `[${firing.ts}] transition_firing ${firing.transitionId} -> ${JSON.stringify( - firing.output, + producedTokensPerPlace(firing.outputTokens), )}`, ); }; diff --git a/apps/petrinaut-website/src/examples/example-search.ts b/apps/petrinaut-website/src/examples/example-search.ts index ea5f4286ca1..904d3d417da 100644 --- a/apps/petrinaut-website/src/examples/example-search.ts +++ b/apps/petrinaut-website/src/examples/example-search.ts @@ -24,12 +24,13 @@ import { */ export const sharedModes = ["edit", "simulate", "actual"] as const; -export const sharedEditViews = ["canvas", "definitions"] as const; +export const sharedEditViews = ["canvas", "definitions", "kanban"] as const; export const sharedSimulateViews = [ "scenarios", "metrics", "experiments", + "status-views", ] as const; export const sharedOverlays = [ @@ -38,6 +39,7 @@ export const sharedOverlays = [ "create-scenario", "create-metric", "create-experiment", + "create-status-view", ] as const; export const sharedSettingsSections = ["general", "viewport", "labs"] as const; @@ -46,6 +48,7 @@ export const sharedResourceTypes = [ "scenario", "metric", "experiment", + "status-view", ] as const; export type SharedEditView = (typeof sharedEditViews)[number]; diff --git a/apps/petrinaut-website/src/examples/navigation-search.ts b/apps/petrinaut-website/src/examples/navigation-search.ts index 93143cfb427..7cb09b8f9ae 100644 --- a/apps/petrinaut-website/src/examples/navigation-search.ts +++ b/apps/petrinaut-website/src/examples/navigation-search.ts @@ -100,7 +100,9 @@ export const sharedSearchToNavigationState = ( ? "scenarios" : search.resourceType === "experiment" ? "experiments" - : "metrics" + : search.resourceType === "status-view" + ? "status-views" + : "metrics" : (search.view ?? baseline.simulateView), simulateResource: search.resourceType && search.resourceId diff --git a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-actual-mode-provider.test.tsx b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-actual-mode-provider.test.tsx new file mode 100644 index 00000000000..e16af5fb012 --- /dev/null +++ b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-actual-mode-provider.test.tsx @@ -0,0 +1,148 @@ +// @vitest-environment jsdom + +import { act, render } from "@testing-library/react"; +import { use } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ActualModeContext } from "@hashintel/petrinaut/react"; + +import { BrunchActualModeProvider } from "./brunch-actual-mode-provider"; + +class FakeEventSource { + static latest: FakeEventSource | null = null; + + readonly listeners = new Map void)[]>(); + + constructor() { + FakeEventSource.latest = this; + } + + addEventListener(type: string, listener: (event: Event) => void) { + this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]); + } + + removeEventListener() {} + + close() {} + + emit(type: string, data: unknown) { + const event = new MessageEvent(type, { data: JSON.stringify(data) }); + for (const listener of this.listeners.get(type) ?? []) { + listener(event); + } + } +} + +const emit = (type: string, data: unknown) => { + act(() => { + FakeEventSource.latest!.emit(type, data); + }); +}; + +const emitDefinition = async () => { + await act(async () => { + FakeEventSource.latest!.emit("definition", { + places: [ + { id: "queued", name: "Queued", x: 0, y: 0 }, + { id: "done", name: "Done", x: 100, y: 0 }, + ], + transitions: [], + }); + }); +}; + +const StatusProbe = () => { + const actualMode = use(ActualModeContext); + return ( + + {actualMode.status}|{actualMode.transitionFirings.length}| + {actualMode.error ?? ""} + + ); +}; + +const renderProvider = () => + render( + + + , + ); + +const finishFiring = (inputTokens: Record) => ({ + transitionId: "finish", + inputTokens, + outputTokens: { done: [{}] }, + ts: "2026-06-05T10:00:00.000Z", +}); + +describe("BrunchActualModeProvider", () => { + beforeEach(() => { + vi.stubGlobal("EventSource", FakeEventSource); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + FakeEventSource.latest = null; + }); + + it("appends a firing that the marking absorbs", async () => { + const { container } = renderProvider(); + + await emitDefinition(); + emit("initial_state", { queued: 1, done: 0 }); + emit("transition_firing", finishFiring({ queued: [{}] })); + + expect(container.textContent).toBe("streaming|1|"); + }); + + it("ends the stream with an error for a firing that consumes a token the marking does not hold", async () => { + const { container } = renderProvider(); + + await emitDefinition(); + emit("initial_state", { queued: 1, done: 0 }); + emit("transition_firing", finishFiring({ queued: [{}] })); + emit("transition_firing", finishFiring({ queued: [{}] })); + + expect(container.textContent).toBe( + 'error|1|Invalid Brunch transition_firing frame: Transition firing of "finish" at 2026-06-05T10:00:00.000Z consumes 1 token from place "queued", which holds 0', + ); + }); + + it("checks firings received before the initial state once it arrives", async () => { + const { container } = renderProvider(); + + await emitDefinition(); + emit("transition_firing", finishFiring({ queued: [{}, {}] })); + emit("initial_state", { queued: 1, done: 0 }); + + expect(container.textContent).toBe( + 'error|1|Invalid Brunch transition_firing frame: Transition firing of "finish" at 2026-06-05T10:00:00.000Z consumes 2 tokens from place "queued", which holds 1', + ); + }); + + it("checks frames received before the definition once it arrives", async () => { + const { container } = renderProvider(); + + emit("initial_state", { queued: 1, done: 0 }); + emit("transition_firing", finishFiring({ queued: [{}, {}] })); + expect(container.textContent).toBe("streaming|1|"); + + await emitDefinition(); + + expect(container.textContent).toBe( + 'error|1|Invalid Brunch transition_firing frame: Transition firing of "finish" at 2026-06-05T10:00:00.000Z consumes 2 tokens from place "queued", which holds 1', + ); + }); + + it("ends the stream with an error for a firing whose token record does not fit its place", async () => { + const { container } = renderProvider(); + + await emitDefinition(); + emit("initial_state", { queued: 1, done: 0 }); + emit("transition_firing", finishFiring({ queued: [{ ticket_id: "a" }] })); + + expect(container.textContent).toBe( + 'error|0|Invalid Brunch transition_firing frame: Transition firing of "finish" at 2026-06-05T10:00:00.000Z consumes token {"ticket_id":"a"} from place "queued", which carries attribute "ticket_id" although the place has no colour', + ); + }); +}); diff --git a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-actual-mode-provider.tsx b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-actual-mode-provider.tsx index 8974dd4e4ef..a942cec4530 100644 --- a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-actual-mode-provider.tsx +++ b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-actual-mode-provider.tsx @@ -1,6 +1,10 @@ import { useEffect, useState, type FC, type PropsWithChildren } from "react"; -import { ACTUAL_MODE_TIMELINE_TICK_MS } from "@hashintel/petrinaut-core"; +import { + ACTUAL_MODE_TIMELINE_TICK_MS, + applyActualModeTransitionFiring, + validateActualModeInitialState, +} from "@hashintel/petrinaut-core"; import { ActualModeContext } from "@hashintel/petrinaut/react"; import { normalizeBrunchDefinition } from "./brunch-definition"; @@ -11,7 +15,12 @@ import { parseTransitionFiringFrameData, } from "./brunch-frame-parsers"; -import type { ActualModeContextValue } from "@hashintel/petrinaut-core"; +import type { + ActualModeContextValue, + ActualModeMarking, + ActualModeTransitionFiring, + SDCPN, +} from "@hashintel/petrinaut-core"; type AvailableActualModeContextValue = Extract< ActualModeContextValue, @@ -43,6 +52,53 @@ const createLoadingActualModeValue = ( }; }; +const withFrameContext = (frame: string, check: () => T): T => { + try { + return check(); + } catch (err) { + throw new Error( + `Invalid Brunch ${frame} frame: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } +}; + +const applyTransitionFiringFrame = ( + definition: SDCPN, + marking: ActualModeMarking, + firing: ActualModeTransitionFiring, +): ActualModeMarking => + withFrameContext("transition_firing", () => + applyActualModeTransitionFiring(definition, marking, firing), + ); + +/** + * The marking reached by applying every received firing to the initial + * state, or null until both the definition and the initial state have + * arrived. + * + * @throws when the initial state or a firing does not fit the definition, or + * a firing consumes a token the marking does not hold. + */ +const replayReceivedFrames = ( + definition: SDCPN | null, + initialState: ActualModeMarking | null, + firings: readonly ActualModeTransitionFiring[], +): ActualModeMarking | null => { + if (definition === null || initialState === null) { + return null; + } + withFrameContext("initial_state", () => + validateActualModeInitialState(definition, initialState), + ); + return firings.reduce( + (marking, firing) => + applyTransitionFiringFrame(definition, marking, firing), + initialState, + ); +}; + export const BrunchActualModeProvider: FC< PropsWithChildren<{ endpoint: string; runId?: string }> > = ({ children, endpoint, runId }) => { @@ -75,6 +131,15 @@ export const BrunchActualModeProvider: FC< let cancelled = false; let hasConnectedBefore = false; const eventSource = new EventSource(endpoint); + // Each firing is applied here as it arrives, or once the definition and + // initial state it depends on have both arrived, so a token record that + // does not fit its place, or a firing that consumes a token the marking + // does not hold, ends the stream with an error before it reaches the + // context, whose frames are replayed during render. + let receivedDefinition: SDCPN | null = null; + let receivedInitialState: ActualModeMarking | null = null; + let receivedFirings: ActualModeTransitionFiring[] = []; + let replayedMarking: ActualModeMarking | null = null; const setFatalError = (message: string) => { if (cancelled) { @@ -125,6 +190,15 @@ export const BrunchActualModeProvider: FC< const isReconnect = hasConnectedBefore; hasConnectedBefore = true; + if (isReconnect) { + receivedFirings = []; + replayedMarking = replayReceivedFrames( + receivedDefinition, + receivedInitialState, + [], + ); + } + setValue((prev) => { const error = prev.status === "error" ? prev.error : null; @@ -174,6 +248,13 @@ export const BrunchActualModeProvider: FC< return; } + replayedMarking = replayReceivedFrames( + sdcpn, + receivedInitialState, + receivedFirings, + ); + receivedDefinition = sdcpn; + setValue((prev) => ({ ...prev, status: prev.status === "complete" ? "complete" : "streaming", @@ -192,6 +273,12 @@ export const BrunchActualModeProvider: FC< try { const data = parseJsonEventData(event as MessageEvent, "initial_state"); const initialState = parseMarkingFrameData(data); + replayedMarking = replayReceivedFrames( + receivedDefinition, + initialState, + receivedFirings, + ); + receivedInitialState = initialState; setValue((prev) => ({ ...prev, status: prev.status === "complete" ? "complete" : "streaming", @@ -215,6 +302,14 @@ export const BrunchActualModeProvider: FC< "transition_firing", ); const firing = parseTransitionFiringFrameData(data); + if (receivedDefinition !== null && replayedMarking !== null) { + replayedMarking = applyTransitionFiringFrame( + receivedDefinition, + replayedMarking, + firing, + ); + } + receivedFirings.push(firing); setValue((prev) => ({ ...prev, status: prev.status === "complete" ? "complete" : "streaming", diff --git a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-frame-parsers.ts b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-frame-parsers.ts index 0e9f24b3b86..070616a9bd1 100644 --- a/apps/petrinaut-website/src/main/app/brunch-demo/brunch-frame-parsers.ts +++ b/apps/petrinaut-website/src/main/app/brunch-demo/brunch-frame-parsers.ts @@ -116,8 +116,9 @@ export const parseMarkingFrame = (event: MessageEvent): ActualModeMarking => * Validate a decoded Brunch `transition_firing` payload. * * This runs after JSON decoding and before the provider appends the event to - * Actual Mode state. The accepted schema is the transition effect protocol: - * `{ transitionId, input, output, ts }`. + * Actual Mode state. The accepted shape is + * `{ transitionId, inputTokens, outputTokens, ts }`, where the token maps + * carry the consumed and produced token attribute values keyed by place id. */ export const parseTransitionFiringFrameData = ( data: unknown, diff --git a/apps/petrinaut-website/src/main/app/local-storage-demo/documents/remote/use-worked-model-net-projection.ts b/apps/petrinaut-website/src/main/app/local-storage-demo/documents/remote/use-worked-model-net-projection.ts index 7373575e7b4..aae6548fea9 100644 --- a/apps/petrinaut-website/src/main/app/local-storage-demo/documents/remote/use-worked-model-net-projection.ts +++ b/apps/petrinaut-website/src/main/app/local-storage-demo/documents/remote/use-worked-model-net-projection.ts @@ -196,12 +196,10 @@ export const useWorkedModelNetProjection = (input: { `Worked-model revision ${revisionId} has no persistence operation.`, ); } - try { - await write; - } finally { + await write.finally(() => { if (writesByRevisionRef.current.get(revisionId) === write) writesByRevisionRef.current.delete(revisionId); - } + }); }, [], ); diff --git a/libs/@hashintel/petrinaut-cli/schemas/optimization-protocol.schema.json b/libs/@hashintel/petrinaut-cli/schemas/optimization-protocol.schema.json index 4a4c5882e2f..96cb219a387 100644 --- a/libs/@hashintel/petrinaut-cli/schemas/optimization-protocol.schema.json +++ b/libs/@hashintel/petrinaut-cli/schemas/optimization-protocol.schema.json @@ -1226,7 +1226,8 @@ "kernel", "metric", "scenario-expression", - "scenario-code" + "scenario-code", + "status-condition" ] }, "HirFunction": { diff --git a/libs/@hashintel/petrinaut-cli/src/runtime/protocol.test.ts b/libs/@hashintel/petrinaut-cli/src/runtime/protocol.test.ts index 8e864fd19ee..58094be198e 100644 --- a/libs/@hashintel/petrinaut-cli/src/runtime/protocol.test.ts +++ b/libs/@hashintel/petrinaut-cli/src/runtime/protocol.test.ts @@ -76,6 +76,7 @@ const emptyHirArtifacts = { lambdas: {}, kernels: {}, metrics: {}, + statusConditions: {}, }; function createModel(modelMetadata = metadata) { diff --git a/libs/@hashintel/petrinaut-core/src/action-schemas.ts b/libs/@hashintel/petrinaut-core/src/action-schemas.ts index 3e3a5644e21..cae16c30f79 100644 --- a/libs/@hashintel/petrinaut-core/src/action-schemas.ts +++ b/libs/@hashintel/petrinaut-core/src/action-schemas.ts @@ -7,6 +7,7 @@ import { colorSchema, componentInstanceSchema, differentialEquationSchema, + identitySchema, idSchema, inputArcSchema, nodePositionCommitSchema, @@ -19,6 +20,10 @@ import { } from "./schemas/entity-schemas"; import { metricSchema as simulationMetricSchema } from "./schemas/metric-schema"; import { scenarioSchema as simulationScenarioSchema } from "./schemas/scenario-schema"; +import { + statusViewObjectSchema, + statusViewSchema, +} from "./schemas/status-view-schema"; import type { SelectionItem } from "./types/selection"; @@ -29,6 +34,7 @@ export { colorSchema, componentInstanceSchema, differentialEquationSchema, + identitySchema, idSchema, nodePositionCommitSchema, parameterSchema, @@ -46,6 +52,10 @@ export { scenarioSchema as simulationScenarioSchema, type ScenarioSchema, } from "./schemas/scenario-schema"; +export { + statusLabelSchema, + statusViewSchema, +} from "./schemas/status-view-schema"; export { simulationMetricSchema as metricSchema, simulationScenarioSchema as scenarioSchema, @@ -115,6 +125,22 @@ export const metricUpdateSchema = simulationMetricSchema "Fields to assign to an existing metric. Omitted fields are left unchanged.", }); +export const identityUpdateSchema = identitySchema + .omit({ id: true }) + .partial() + .meta({ + description: + "Fields to assign to an existing identity. Omitted fields are left unchanged.", + }); + +export const statusViewUpdateSchema = statusViewObjectSchema + .omit({ id: true }) + .partial() + .meta({ + description: + "Fields to assign to an existing status view. Omitted fields are left unchanged.", + }); + export const componentInstanceUpdateSchema = componentInstanceSchema .omit({ id: true, x: true, y: true }) .partial() @@ -480,6 +506,46 @@ export const mutationActionInputSchemas = { removeMetric: z .strictObject({ metricId: idSchema }) .meta({ description: "Remove a simulation metric." }), + addIdentity: identitySchema.meta({ + description: + "Add an instance identity (e.g. `Ticket`). Mark a colour element as the identity's key by setting the element's `identityRef` to the identity's ID.", + }), + updateIdentity: z + .strictObject({ + identityId: idSchema, + update: identityUpdateSchema, + }) + .meta({ description: "Update fields on an existing identity." }), + removeIdentity: z.strictObject({ identityId: idSchema }).meta({ + description: + "Remove an identity, clearing `identityRef` from colour elements that reference it and removing status views that track it.", + }), + addStatusView: statusViewSchema.meta({ + description: + "Add a status view: a named mapping from places (plus optional token conditions) to ordered status labels for the instances of one identity. Label order is the `labels` array position.", + }), + updateStatusView: z + .strictObject({ + statusViewId: idSchema, + update: statusViewUpdateSchema, + }) + .meta({ description: "Update fields on an existing status view." }), + removeStatusView: z + .strictObject({ statusViewId: idSchema }) + .meta({ description: "Remove a status view." }), + moveStatusViewLabel: z + .strictObject({ + statusViewId: idSchema, + labelId: idSchema, + toIndex: z.number().int().nonnegative().meta({ + description: + "Destination index for the label within the view's `labels` array.", + }), + }) + .meta({ + description: + "Move a label within a status view. Label order is the array position, so this reorders Kanban columns and legends.", + }), addSubnet: subnetSchema.meta({ description: "Add a reusable subnet definition. Mark subnet places with `isPort: true` to expose them as component ports.", diff --git a/libs/@hashintel/petrinaut-core/src/actions.test.ts b/libs/@hashintel/petrinaut-core/src/actions.test.ts index 79acb2ce35d..6fe363b6f3a 100644 --- a/libs/@hashintel/petrinaut-core/src/actions.test.ts +++ b/libs/@hashintel/petrinaut-core/src/actions.test.ts @@ -652,6 +652,447 @@ describe("Petrinaut core actions", () => { ]); }); + test("adds, updates, and removes identities, clearing references on removal", () => { + const instance = createInstance(); + + instance.mutations.addIdentity({ + id: "identity-ticket", + name: "Ticket", + keyElementTypes: ["string"], + }); + instance.mutations.updateIdentity({ + identityId: "identity-ticket", + update: { keyElementTypes: ["uuid"] }, + }); + expect(instance.definition.get().identities).toEqual([ + { id: "identity-ticket", name: "Ticket", keyElementTypes: ["uuid"] }, + ]); + + instance.mutations.addType({ + id: "type-1", + name: "Ticket", + iconSlug: "circle", + displayColor: "#1E90FF", + elements: [ + { + elementId: "element-1", + name: "ticket_id", + type: "uuid", + identityRef: "identity-ticket", + }, + ], + }); + instance.mutations.addPlace({ + id: "p1", + name: "Todo", + colorId: "type-1", + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }); + instance.mutations.addStatusView({ + id: "view-1", + name: "Ticket status", + identityRef: "identity-ticket", + labels: [ + { + id: "label-1", + name: "Todo", + displayColor: "#808080", + places: ["p1"], + }, + ], + }); + + instance.mutations.removeIdentity({ identityId: "identity-ticket" }); + + const definition = instance.definition.get(); + expect(definition.identities).toEqual([]); + expect(definition.types[0]!.elements[0]!.identityRef).toBeUndefined(); + expect(definition.statusViews).toEqual([]); + }); + + const createInstanceForStatusViews = () => { + const instance = createInstance(); + instance.mutations.addIdentity({ + id: "identity-ticket", + name: "Ticket", + keyElementTypes: ["string"], + }); + for (const placeId of ["p1", "p2"]) { + instance.mutations.addPlace({ + id: placeId, + name: placeId.toUpperCase(), + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }); + } + return instance; + }; + + test("adds, updates, moves labels within, and removes status views", () => { + const instance = createInstanceForStatusViews(); + + instance.mutations.addStatusView({ + id: "view-1", + name: "Ticket status", + identityRef: "identity-ticket", + labels: [ + { + id: "label-1", + name: "Todo", + displayColor: "#808080", + places: ["p1"], + }, + { + id: "label-2", + name: "Done", + displayColor: "#00AA00", + places: ["p2"], + }, + ], + }); + expect(instance.definition.get().statusViews).toHaveLength(1); + + instance.mutations.updateStatusView({ + statusViewId: "view-1", + update: { name: "Tickets" }, + }); + expect(instance.definition.get().statusViews![0]!.name).toBe("Tickets"); + + instance.mutations.moveStatusViewLabel({ + statusViewId: "view-1", + labelId: "label-2", + toIndex: 0, + }); + expect( + instance.definition + .get() + .statusViews![0]!.labels.map((label) => label.id), + ).toEqual(["label-2", "label-1"]); + + instance.mutations.removeStatusView({ statusViewId: "view-1" }); + expect(instance.definition.get().statusViews).toHaveLength(0); + }); + + test("moveStatusViewLabel handles ends, clamping, unknown ids, and exit labels", () => { + const instance = createInstanceForStatusViews(); + instance.mutations.addStatusView({ + id: "view-1", + name: "Ticket status", + identityRef: "identity-ticket", + labels: [ + { + id: "label-1", + name: "Todo", + displayColor: "#808080", + places: ["p1"], + }, + { + id: "label-2", + name: "Done", + displayColor: "#00AA00", + places: ["p2"], + }, + { + id: "label-exit", + name: "Gone", + displayColor: "#333333", + places: [], + isExit: true, + }, + ], + }); + const labelIds = () => + instance.definition + .get() + .statusViews![0]!.labels.map((label) => label.id); + + instance.mutations.moveStatusViewLabel({ + statusViewId: "view-1", + labelId: "label-1", + toIndex: 2, + }); + expect(labelIds()).toEqual(["label-2", "label-exit", "label-1"]); + + instance.mutations.moveStatusViewLabel({ + statusViewId: "view-1", + labelId: "label-2", + toIndex: 99, + }); + expect(labelIds()).toEqual(["label-exit", "label-1", "label-2"]); + + instance.mutations.moveStatusViewLabel({ + statusViewId: "view-1", + labelId: "label-unknown", + toIndex: 0, + }); + expect(labelIds()).toEqual(["label-exit", "label-1", "label-2"]); + + instance.mutations.moveStatusViewLabel({ + statusViewId: "view-unknown", + labelId: "label-1", + toIndex: 0, + }); + expect(labelIds()).toEqual(["label-exit", "label-1", "label-2"]); + }); + + test("addStatusView rejects unknown identities and unresolvable label places", () => { + const instance = createInstanceForStatusViews(); + + expect(() => + instance.mutations.addStatusView({ + id: "view-1", + name: "Ticket status", + identityRef: "identity-unknown", + labels: [ + { id: "label-1", name: "Todo", displayColor: "#808080", places: [] }, + ], + }), + ).toThrow(/identity ID `identity-unknown`/); + + expect(() => + instance.mutations.addStatusView({ + id: "view-1", + name: "Ticket status", + identityRef: "identity-ticket", + labels: [ + { + id: "label-1", + name: "Todo", + displayColor: "#808080", + places: ["missing-place"], + }, + ], + }), + ).toThrow(/place ID `missing-place`/); + + expect(instance.definition.get().statusViews ?? []).toHaveLength(0); + }); + + const createInstanceWithScopedPlaces = () => { + const place = (id: string) => ({ + id, + name: id.toUpperCase(), + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }); + const instance = createInstance({ + ...emptySDCPN, + places: [place("p1"), place("p2")], + componentInstances: [ + { + id: "inst", + name: "Worker", + subnetId: "subnet-1", + parameterValues: {}, + x: 0, + y: 0, + }, + ], + subnets: [ + { + id: "subnet-1", + name: "Worker", + places: [place("p1")], + transitions: [], + types: [], + differentialEquations: [], + parameters: [], + componentInstances: [], + }, + ], + }); + instance.mutations.addIdentity({ + id: "identity-ticket", + name: "Ticket", + keyElementTypes: ["string"], + }); + instance.mutations.addStatusView({ + id: "view-1", + name: "Ticket status", + identityRef: "identity-ticket", + labels: [ + { + id: "label-1", + name: "Todo", + displayColor: "#808080", + places: ["p1", "inst::p1", "p2"], + }, + ], + }); + const labelPlaces = () => + instance.definition.get().statusViews![0]!.labels[0]!.places; + return { instance, labelPlaces }; + }; + + test("place deletes prune only the label places they remove", () => { + const { instance, labelPlaces } = createInstanceWithScopedPlaces(); + + // The instance's copy shares the root place's bare id but is a + // different place, so it stays. + instance.mutations.removePlace({ placeId: "p1" }); + expect(labelPlaces()).toEqual(["inst::p1", "p2"]); + + // The canvas Delete key goes through deleteItemsByIds. + instance.mutations.deleteItemsByIds({ + items: [{ type: "place", id: "p2" }], + }); + expect(labelPlaces()).toEqual(["inst::p1"]); + + // A later edit to the view still validates its references. + instance.mutations.updateStatusView({ + statusViewId: "view-1", + update: { name: "Tickets" }, + }); + expect(instance.definition.get().statusViews![0]!.name).toBe("Tickets"); + }); + + test("subnet place deletes prune the instance copies from labels", () => { + const { instance, labelPlaces } = createInstanceWithScopedPlaces(); + + instance.mutations.removePlace({ + targetSubnetId: "subnet-1", + placeId: "p1", + }); + + expect(labelPlaces()).toEqual(["p1", "p2"]); + }); + + test("removing a component instance or its subnet prunes its label places", () => { + const first = createInstanceWithScopedPlaces(); + first.instance.mutations.removeComponentInstance({ instanceId: "inst" }); + expect(first.labelPlaces()).toEqual(["p1", "p2"]); + + const second = createInstanceWithScopedPlaces(); + second.instance.mutations.removeSubnet({ subnetId: "subnet-1" }); + expect(second.labelPlaces()).toEqual(["p1", "p2"]); + }); + + test("addIdentity rejects duplicate ids and names", () => { + const instance = createInstance(); + instance.mutations.addIdentity({ + id: "identity-1", + name: "Ticket", + keyElementTypes: ["string"], + }); + + expect(() => + instance.mutations.addIdentity({ + id: "identity-1", + name: "Other", + keyElementTypes: ["string"], + }), + ).toThrow(/already exists/); + expect(() => + instance.mutations.addIdentity({ + id: "identity-2", + name: "Ticket", + keyElementTypes: ["string"], + }), + ).toThrow(/already exists/); + expect(instance.definition.get().identities).toHaveLength(1); + }); + + test("rejects identity key elements whose types diverge from the identity", () => { + const instance = createInstance(); + instance.mutations.addIdentity({ + id: "identity-machine", + name: "Machine", + keyElementTypes: ["uuid"], + }); + instance.mutations.addType({ + id: "type-1", + name: "Machine", + iconSlug: "circle", + displayColor: "#1E90FF", + elements: [ + { elementId: "element-1", name: "machine_id", type: "uuid" }, + { elementId: "element-2", name: "label", type: "string" }, + ], + }); + + expect(() => + instance.mutations.updateTypeElement({ + typeId: "type-1", + elementId: "element-2", + update: { identityRef: "identity-machine" }, + }), + ).toThrow(/requires \[uuid\]/); + expect( + instance.definition.get().types[0]!.elements[1]!.identityRef, + ).toBeUndefined(); + + instance.mutations.updateTypeElement({ + typeId: "type-1", + elementId: "element-1", + update: { identityRef: "identity-machine" }, + }); + + // A second key element on the same colour would silently turn the key + // into a 2-tuple that can never correlate with single-key colours. + expect(() => + instance.mutations.updateTypeElement({ + typeId: "type-1", + elementId: "element-2", + update: { identityRef: "identity-machine", type: "uuid" }, + }), + ).toThrow(/requires \[uuid\]/); + + expect(() => + instance.mutations.updateIdentity({ + identityId: "identity-machine", + update: { keyElementTypes: ["string"] }, + }), + ).toThrow(/requires \[string\]/); + }); + + test("removeIdentity clears identityRef inside subnet colours", () => { + const instance = createInstance(); + instance.mutations.addIdentity({ + id: "identity-ticket", + name: "Ticket", + keyElementTypes: ["string"], + }); + instance.mutations.addSubnet({ + id: "subnet-1", + name: "Worker", + places: [], + transitions: [], + types: [ + { + id: "subnet-type-1", + name: "Ticket", + iconSlug: "circle", + displayColor: "#1E90FF", + elements: [ + { + elementId: "element-1", + name: "ticket_id", + type: "string", + identityRef: "identity-ticket", + }, + ], + }, + ], + differentialEquations: [], + parameters: [], + }); + + instance.mutations.removeIdentity({ identityId: "identity-ticket" }); + + expect( + instance.definition.get().subnets![0]!.types[0]!.elements[0]!.identityRef, + ).toBeUndefined(); + }); + test("deleteItemsByIds removes referenced types and equations", () => { const instance = createInstance({ ...emptySDCPN, diff --git a/libs/@hashintel/petrinaut-core/src/actions.ts b/libs/@hashintel/petrinaut-core/src/actions.ts index a16c8dcc285..c301f81fa9d 100644 --- a/libs/@hashintel/petrinaut-core/src/actions.ts +++ b/libs/@hashintel/petrinaut-core/src/actions.ts @@ -2,11 +2,13 @@ import { colorSchema, componentInstanceSchema, differentialEquationSchema, + identitySchema, metricSchema, parameterSchema, mutationActionInputSchemas, placeSchema, scenarioSchema, + statusViewSchema, subnetSchema, transitionSchema, type MutationActionInput, @@ -32,7 +34,9 @@ import { stripDisabledExtensionData, type PetrinautExtensionSettings, } from "./extensions"; +import { identityKeyTypesMatch } from "./identity-key-coherence"; import { migrateScenarioRowsForTypeEdit } from "./schema-migration"; +import { resolveStatusViewLabelPlace } from "./status-view-scope"; import type { ArcEndpoint, @@ -41,6 +45,7 @@ import type { InputArc, OutputArc, SDCPN, + StatusView, } from "./types/sdcpn"; export type MutationHelperFunctions = { @@ -353,6 +358,82 @@ const assertArcEndpointReferences = ( } }; +/** + * A status view must name an existing identity, and each label place + * reference must resolve — to a root place, or through the component-instance + * path of a scoped id — or the view could never track anything. + */ +const assertStatusViewReferences = ( + sdcpn: SDCPN, + statusView: StatusView, +): void => { + if ( + !(sdcpn.identities ?? []).some( + (identity) => identity.id === statusView.identityRef, + ) + ) { + throw new Error( + `Status view \`${statusView.name}\` references identity ID \`${statusView.identityRef}\` which does not exist.`, + ); + } + for (const label of statusView.labels) { + for (const placeId of label.places) { + if (!resolveStatusViewLabelPlace(sdcpn, placeId)) { + throw new Error( + `Status view label \`${label.name}\` references place ID \`${placeId}\` which does not resolve to a place (or a component instance's copy of a subnet place).`, + ); + } + } + } +}; + +/** + * Drops status label place references that no longer resolve. Call after any + * mutation that can remove a place, a component instance or a subnet, or + * repoint an instance at another subnet, so label references stay valid and a + * later `updateStatusView` does not reject the view for a reference the user + * never touched. + */ +const pruneUnresolvedStatusLabelPlaces = (sdcpn: SDCPN): void => { + for (const statusView of sdcpn.statusViews ?? []) { + for (const label of statusView.labels) { + label.places = label.places.filter((labelPlaceId) => + resolveStatusViewLabelPlace(sdcpn, labelPlaceId), + ); + } + } +}; + +/** + * Every colour whose elements reference an identity must carry key elements + * whose types match the identity's `keyElementTypes` in order — the + * cross-colour instance key is the tuple of those element values, so a + * mismatched colour would silently never correlate. + */ +const assertIdentityKeyElementCoherence = (sdcpn: SDCPN): void => { + const identities = sdcpn.identities ?? []; + if (identities.length === 0) { + return; + } + for (const net of getAllMutableNets(sdcpn)) { + for (const type of net.types) { + for (const identity of identities) { + const keyTypes = type.elements + .filter((element) => element.identityRef === identity.id) + .map((element) => element.type); + if (keyTypes.length === 0) { + continue; + } + if (!identityKeyTypesMatch(keyTypes, identity)) { + throw new Error( + `Colour \`${type.name}\` carries key elements of types [${keyTypes.join(", ")}] for identity \`${identity.name}\`, which requires [${identity.keyElementTypes.join(", ")}] in this order.`, + ); + } + } + } + } +}; + const assertComponentInstanceReferences = ( sdcpn: SDCPN, instance: ComponentInstance, @@ -538,6 +619,7 @@ export function createPetrinautActions( parsed.placeId, ); } + pruneUnresolvedStatusLabelPlaces(sdcpn); sanitizeAllTransitions(sdcpn); break; } @@ -743,6 +825,9 @@ export function createPetrinautActions( } mutateWithExtensionGuards((sdcpn) => { resolveTargetNet(sdcpn, targetSubnetId).types.push(parsedType); + if (parsedType.elements.some((element) => element.identityRef)) { + assertIdentityKeyElementCoherence(sdcpn); + } }); }, updateType(input) { @@ -772,6 +857,9 @@ export function createPetrinautActions( if (type.id === parsed.typeId) { type.elements.push(parsed.element); colorSchema.parse(type); + if (parsed.element.identityRef !== undefined) { + assertIdentityKeyElementCoherence(sdcpn); + } migrateScenarioRowsForTypeEdit(sdcpn, parsed.typeId, { kind: "add", element: parsed.element, @@ -795,6 +883,12 @@ export function createPetrinautActions( const previousElementType = element.type; Object.assign(element, parsed.update); colorSchema.parse(type); + if ( + parsed.update.identityRef !== undefined || + parsed.update.type !== undefined + ) { + assertIdentityKeyElementCoherence(sdcpn); + } if ( parsed.update.type !== undefined && parsed.update.type !== previousElementType @@ -857,6 +951,9 @@ export function createPetrinautActions( if (element) { type.elements.splice(parsed.toIndex, 0, element); colorSchema.parse(type); + if (element.identityRef !== undefined) { + assertIdentityKeyElementCoherence(sdcpn); + } // Use the actual landing index (splice clamps out-of-range // destinations to the end of the array). const toIndex = type.elements.findIndex( @@ -1080,6 +1177,136 @@ export function createPetrinautActions( } }); }, + addIdentity(identity) { + const parsedIdentity = + mutationActionInputSchemas.addIdentity.parse(identity); + mutateWithExtensionGuards((sdcpn) => { + const targetSdcpn = sdcpn; + targetSdcpn.identities ??= []; + const identities = targetSdcpn.identities; + if (identities.some(({ id }) => id === parsedIdentity.id)) { + throw new Error( + `An identity with ID \`${parsedIdentity.id}\` already exists.`, + ); + } + if (identities.some(({ name }) => name === parsedIdentity.name)) { + throw new Error( + `An identity named \`${parsedIdentity.name}\` already exists. Choose a unique name.`, + ); + } + identities.push(parsedIdentity); + }); + }, + updateIdentity(input) { + const parsed = mutationActionInputSchemas.updateIdentity.parse(input); + mutateWithExtensionGuards((sdcpn) => { + for (const identity of sdcpn.identities ?? []) { + if (identity.id === parsed.identityId) { + Object.assign(identity, parsed.update); + identitySchema.parse(identity); + if (parsed.update.keyElementTypes !== undefined) { + assertIdentityKeyElementCoherence(sdcpn); + } + break; + } + } + }); + }, + removeIdentity(input) { + const { identityId: parsedIdentityId } = + mutationActionInputSchemas.removeIdentity.parse(input); + mutateWithExtensionGuards((sdcpn) => { + const identities = sdcpn.identities; + if (!identities) { + return; + } + for (const [index, identity] of identities.entries()) { + if (identity.id === parsedIdentityId) { + identities.splice(index, 1); + break; + } + } + for (const net of getAllMutableNets(sdcpn)) { + for (const type of net.types) { + for (const element of type.elements) { + if (element.identityRef === parsedIdentityId) { + delete element.identityRef; + } + } + } + } + const statusViews = sdcpn.statusViews; + if (statusViews) { + for (let index = statusViews.length - 1; index >= 0; index--) { + if (statusViews[index]!.identityRef === parsedIdentityId) { + statusViews.splice(index, 1); + } + } + } + }); + }, + addStatusView(statusView) { + const parsedStatusView = statusViewSchema.parse(statusView); + mutateWithExtensionGuards((sdcpn) => { + const targetSdcpn = sdcpn; + targetSdcpn.statusViews ??= []; + const statusViews = targetSdcpn.statusViews; + assertStatusViewReferences(sdcpn, parsedStatusView); + statusViews.push(parsedStatusView); + }); + }, + updateStatusView(input) { + const parsed = mutationActionInputSchemas.updateStatusView.parse(input); + mutateWithExtensionGuards((sdcpn) => { + for (const statusView of sdcpn.statusViews ?? []) { + if (statusView.id === parsed.statusViewId) { + Object.assign(statusView, parsed.update); + statusViewSchema.parse(statusView); + assertStatusViewReferences(sdcpn, statusView); + break; + } + } + }); + }, + removeStatusView(input) { + const { statusViewId: parsedStatusViewId } = + mutationActionInputSchemas.removeStatusView.parse(input); + mutateWithExtensionGuards((sdcpn) => { + const statusViews = sdcpn.statusViews; + if (!statusViews) { + return; + } + for (const [index, statusView] of statusViews.entries()) { + if (statusView.id === parsedStatusViewId) { + statusViews.splice(index, 1); + break; + } + } + }); + }, + moveStatusViewLabel(input) { + const parsed = + mutationActionInputSchemas.moveStatusViewLabel.parse(input); + mutateWithExtensionGuards((sdcpn) => { + for (const statusView of sdcpn.statusViews ?? []) { + if (statusView.id === parsed.statusViewId) { + const fromIndex = statusView.labels.findIndex( + (label) => label.id === parsed.labelId, + ); + if (fromIndex === -1) { + break; + } + const [label] = statusView.labels.splice(fromIndex, 1); + if (label) { + // Splice clamps out-of-range destinations to the array end. + statusView.labels.splice(parsed.toIndex, 0, label); + statusViewSchema.parse(statusView); + } + break; + } + } + }); + }, addSubnet(subnet) { const parsedSubnet = subnetSchema.parse(subnet); mutateWithExtensionGuards((sdcpn) => { @@ -1112,6 +1339,7 @@ export function createPetrinautActions( if (subnet.id === subnetId) { subnets.splice(index, 1); removeComponentInstancesReferencingSubnet(sdcpn, subnetId); + pruneUnresolvedStatusLabelPlaces(sdcpn); break; } } @@ -1138,6 +1366,7 @@ export function createPetrinautActions( Object.assign(instance, parsed.update); componentInstanceSchema.parse(instance); assertComponentInstanceReferences(sdcpn, instance); + pruneUnresolvedStatusLabelPlaces(sdcpn); break; } } @@ -1170,6 +1399,7 @@ export function createPetrinautActions( if (instance.id === parsed.instanceId) { removeArcsReferencingComponentInstance(net, instance.id); instances.splice(index, 1); + pruneUnresolvedStatusLabelPlaces(sdcpn); break; } } @@ -1342,6 +1572,9 @@ export function createPetrinautActions( } } + if (hasCanvasDeletes) { + pruneUnresolvedStatusLabelPlaces(sdcpn); + } if (hasCanvasDeletes || typeIds.size > 0) { sanitizeAllTransitions(sdcpn); } diff --git a/libs/@hashintel/petrinaut-core/src/actual-mode/README.md b/libs/@hashintel/petrinaut-core/src/actual-mode/README.md index ace369c6efd..5ea098dd40f 100644 --- a/libs/@hashintel/petrinaut-core/src/actual-mode/README.md +++ b/libs/@hashintel/petrinaut-core/src/actual-mode/README.md @@ -26,8 +26,8 @@ Petrinaut teams standardize that contract. Core currently owns only the pieces that are useful independently of React and independently of how a host transports events: -- the transition firing effect shape used by Petrinaut's timeline -- marking reconstruction from an initial state plus transition effects +- the transition firing shape used by Petrinaut's timeline +- marking reconstruction from an initial state plus transition firings - timeline point generation for a live or completed external execution - a `SimulationFrameReader` adapter so existing visualizer/timeline code can inspect Actual Mode frames @@ -41,26 +41,74 @@ The current demo path is: 1. `apps/petrinaut-website` opens `/brunch?sse=`. 2. The Brunch provider connects with `EventSource`. 3. Website-local parsers validate the temporary Brunch definition, initial - state, and transition firing payloads. + state, and transition firing payloads. Once the definition and the initial + state have both arrived, the provider checks the initial state against the + definition and applies each firing to the marking reconstructed so far; an + initial state or firing that fails ends the stream with `status: "error"` + and the thrown message. 4. The website normalizes the Brunch definition into a read-only SDCPN with Petrinaut extensions disabled. 5. `@hashintel/petrinaut` receives `ActualModeContext`. 6. Core reconstructs markings and timeline frames from the initial state and - transition firing effects. + transition firings. -The currently accepted transition firing shape is: +The transition firing shape is: ```json { "transitionId": "start_implementation", - "input": { "queued": 1 }, - "output": { "implementing": 1 }, + "inputTokens": { "queued": [{ "ticket_id": "T-1", "attempts": 0 }] }, + "outputTokens": { "implementing": [{ "ticket_id": "T-1", "attempts": 1 }] }, "ts": "2026-06-05T17:17:27.866Z" } ``` -`input` and `output` are transition-local token count maps. They are not full -before/after markings. +`inputTokens` and `outputTokens` list the tokens the firing consumed and +produced, one record per token, keyed by place id. They are not full +before/after markings. Place keys may be scoped ids (`instanceId::placeId`) +when a firing touches a componentInstance's copy of a subnet place. + +Each token record must fit its place in the net definition. The same rule +covers the token arrays of an initial marking: + +- A record for a place with a colour carries exactly the colour's elements, + with no element missing and no other attribute. +- Each value is the at-rest form of its element's type: a finite number for + `real`, an integer for `integer`, a boolean for `boolean`, a string for + `string`, and a canonical lowercase UUID string for `uuid`. +- A record for an uncoloured place is `{}`. +- A place whose colour declares elements lists its tokens as records. A + token count there is an error, because a firing consumes a coloured token + by its element values. +- Every place a firing or marking names is defined by the net. + +`applyActualModeTransitionFiring` checks the firing's records before applying +it, and `validateActualModeInitialState` checks an initial marking. Both throw +an error naming the place, the record, and the element or attribute at fault; +a firing's error also names the transition and timestamp. Recording parsing, +the frame replay, and the Brunch provider all run these checks. + +Marking reconstruction consumes tokens by value: + +- A place stays a token count while every token recorded for it is `{}`; the + first token with attributes turns it into an array. +- A recorded input token removes the first token in the reconstructed place + that is equal to it on every attribute. +- A firing that consumes a token the reconstructed marking does not hold is + an error: `applyActualModeTransitionFiring` throws, naming the transition, + the place and the unmatched record. This covers a recorded input token that + matches no token in the place and a firing that consumes more tokens than a + count place holds. The React frame source replays firings during render, so a host + applies each firing as it arrives and reports the error through the + context's `status: "error"` before the firing reaches the context. +- Produced tokens are appended as recorded. + +Recordings carry `version: 2`. + +The transition-firing log is retained unbounded for the life of a stream, and +each firing holds one record per token moved, so a long-running stream's +memory grows with the number of tokens moved. Windowed retention (a checkpoint +marking plus the last N firings) is the known follow-up. ## File Map @@ -69,6 +117,7 @@ before/after markings. - `schemas.ts`: Zod schemas for core Actual Mode payloads and recordings. - `context.ts`: unavailable/default context value. - `marking.ts`: marking reconstruction helpers. +- `token-records.ts`: checks that token records fit their places in the net. - `timeline.ts`: live timeline point generation and frame-reader adapter. - `recording.ts`: normalized and raw-event recording helpers. - `time.ts`: timestamp parsing helpers used by recordings and timelines. diff --git a/libs/@hashintel/petrinaut-core/src/actual-mode/actual-mode.test.ts b/libs/@hashintel/petrinaut-core/src/actual-mode/actual-mode.test.ts index efbee8fd542..669b84b75cf 100644 --- a/libs/@hashintel/petrinaut-core/src/actual-mode/actual-mode.test.ts +++ b/libs/@hashintel/petrinaut-core/src/actual-mode/actual-mode.test.ts @@ -1,35 +1,80 @@ import { describe, expect, it } from "vitest"; import { + actualModeTransitionFiringSchema, + applyActualModeTransitionFiring, + createActualModeFrameReplay, createActualModeReceivedEventsRecording, createActualModeRecording, createActualModeTimelineFrameReader, + extendActualModeTransitionFiringTimesMs, + getActualModeMarkingAtTransitionFiringIndex, + getActualModeTransitionFiringTimesMs, parseActualModeRecording, retimeActualModeRecordingForReplay, + validateActualModeInitialState, } from "."; import { compileHirArtifacts } from "../hir/compile"; import { createHirMetricEvaluator } from "../simulation/frames/hir-metric"; -import type { SDCPN } from "../types/sdcpn"; +import type { Color, ColorElementType, Place, SDCPN } from "../types/sdcpn"; +import type { ActualModeMarking, ActualModeTransitionFiring } from "./types"; + +const makePlace = (id: string, colorId: string | null = null): Place => ({ + id, + name: id.charAt(0).toUpperCase() + id.slice(1), + colorId, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, +}); const definition: SDCPN = { - places: [ - { - id: "queued", - name: "Queued", - colorId: null, - dynamicsEnabled: false, - differentialEquationId: null, - x: 0, - y: 0, - }, - ], + places: [makePlace("queued"), makePlace("done")], transitions: [], types: [], differentialEquations: [], parameters: [], }; +const ticketColour: Color = { + id: "ticket", + name: "Ticket", + iconSlug: "circle", + displayColor: "#0000FF", + elements: [ + { elementId: "ticket-id", name: "ticket_id", type: "string" }, + { elementId: "attempts", name: "attempts", type: "integer" }, + ], +}; + +const ticketDefinition: SDCPN = { + ...definition, + places: [ + makePlace("queued", "ticket"), + makePlace("implementing", "ticket"), + makePlace("log"), + ], + types: [ticketColour], +}; + +const ticket = (ticketId: string, attempts = 0) => ({ + ticket_id: ticketId, + attempts, +}); + +const firingAt = ( + transitionId: string, + inputTokens: ActualModeTransitionFiring["inputTokens"], + outputTokens: ActualModeTransitionFiring["outputTokens"], +): ActualModeTransitionFiring => ({ + transitionId, + inputTokens, + outputTokens, + ts: "2026-06-05T10:00:00.000Z", +}); + describe("Actual mode recordings", () => { it("parses exported recordings", () => { const recording = createActualModeRecording({ @@ -43,14 +88,15 @@ describe("Actual mode recordings", () => { transitionFirings: [ { transitionId: "start", - input: { queued: 1 }, - output: {}, + inputTokens: { queued: [{}] }, + outputTokens: {}, ts: "2026-06-05T10:00:00.000Z", }, ], exportedAt: "2026-06-05T10:01:00.000Z", }); + expect(recording.version).toBe(2); expect(parseActualModeRecording(recording)).toEqual(recording); }); @@ -69,7 +115,7 @@ describe("Actual mode recordings", () => { }); expect(recording).toEqual({ - version: 1, + version: 2, exportedAt: "2026-06-05T10:01:00.000Z", title: "Replay", source: null, @@ -77,6 +123,86 @@ describe("Actual mode recordings", () => { }); }); + it("parses recordings whose firings carry token values", () => { + const recording = createActualModeRecording({ + title: "Replay", + source: null, + definition: ticketDefinition, + initialState: { queued: [] }, + transitionFirings: [ + firingAt("create", {}, { queued: [ticket("a")] }), + firingAt( + "start", + { queued: [ticket("a")] }, + { implementing: [ticket("a", 1)] }, + ), + ], + exportedAt: "2026-06-05T10:01:00.000Z", + }); + + expect(parseActualModeRecording(recording)).toEqual(recording); + }); + + it("rejects a recording whose firing consumes a token the marking does not hold", () => { + const recording = createActualModeRecording({ + title: "Replay", + source: null, + definition: ticketDefinition, + initialState: { queued: [ticket("b")] }, + transitionFirings: [firingAt("start", { queued: [ticket("a")] }, {})], + exportedAt: "2026-06-05T10:01:00.000Z", + }); + + expect(() => parseActualModeRecording(recording)).toThrow( + expect.objectContaining({ + issues: [ + expect.objectContaining({ + path: ["transitionFirings", 0], + message: + 'Transition firing of "start" at 2026-06-05T10:00:00.000Z consumes token {"ticket_id":"a","attempts":0} from place "queued", which holds no matching token (1 remaining)', + }), + ], + }), + ); + }); + + it("rejects a recording whose initial state holds an incomplete token record", () => { + const recording = createActualModeRecording({ + title: "Replay", + source: null, + definition: ticketDefinition, + initialState: { queued: [{ ticket_id: "a" }] }, + transitionFirings: [], + exportedAt: "2026-06-05T10:01:00.000Z", + }); + + expect(() => parseActualModeRecording(recording)).toThrow( + expect.objectContaining({ + issues: [ + expect.objectContaining({ + path: ["initialState"], + message: + 'Initial marking holds token {"ticket_id":"a"} in place "queued", which lacks element "attempts" of colour "Ticket"', + }), + ], + }), + ); + }); + + it.each([1, 3])("rejects recording version %i", (version) => { + expect(() => + parseActualModeRecording({ + version, + exportedAt: "2026-06-05T10:01:00.000Z", + title: "Replay", + source: null, + definition, + initialState: { queued: 1 }, + transitionFirings: [], + }), + ).toThrow(); + }); + it("retimes transition firings relative to the first event", () => { const recording = createActualModeRecording({ title: "Replay", @@ -86,14 +212,14 @@ describe("Actual mode recordings", () => { transitionFirings: [ { transitionId: "first", - input: { queued: 1 }, - output: {}, + inputTokens: { queued: [{}] }, + outputTokens: {}, ts: "2026-06-05T10:00:00.000Z", }, { transitionId: "second", - input: { queued: 1 }, - output: {}, + inputTokens: { queued: [{}] }, + outputTokens: {}, ts: "2026-06-05T10:00:03.250Z", }, ], @@ -113,7 +239,7 @@ describe("Actual mode recordings", () => { it("rejects transition firings with extra fields", () => { expect(() => parseActualModeRecording({ - version: 1, + version: 2, exportedAt: "2026-06-05T10:01:00.000Z", title: "Replay", source: null, @@ -122,8 +248,8 @@ describe("Actual mode recordings", () => { transitionFirings: [ { transitionId: "finish", - input: { queued: 1 }, - output: { done: 1 }, + inputTokens: { queued: [{}] }, + outputTokens: { done: [{}] }, unsupported: { done: 1 }, ts: "2026-06-05T10:00:00.000Z", }, @@ -132,50 +258,35 @@ describe("Actual mode recordings", () => { ).toThrow(); }); - it("rejects transition firings with non-count effect values", () => { - expect(() => - parseActualModeRecording({ - version: 1, - exportedAt: "2026-06-05T10:01:00.000Z", - title: "Replay", - source: null, - definition, - initialState: { queued: 1, done: 0 }, - transitionFirings: [ - { - transitionId: "finish", - input: { queued: 1 }, - output: { done: [{}] }, - ts: "2026-06-05T10:00:00.000Z", - }, - ], - }), - ).toThrow(); + it("rejects transition firings missing either side", () => { + const missingOutput = actualModeTransitionFiringSchema.safeParse({ + transitionId: "start", + inputTokens: { queued: [{}] }, + ts: "2026-06-05T10:00:00.000Z", + }); + const missingInput = actualModeTransitionFiringSchema.safeParse({ + transitionId: "start", + outputTokens: { done: [{}] }, + ts: "2026-06-05T10:00:00.000Z", + }); + + expect(missingOutput.error?.issues.map((issue) => issue.path)).toEqual([ + ["outputTokens"], + ]); + expect(missingInput.error?.issues.map((issue) => issue.path)).toEqual([ + ["inputTokens"], + ]); }); it("reconstructs timeline markings from firing effects", () => { const reader = createActualModeTimelineFrameReader({ - definition: { - ...definition, - places: [ - ...definition.places, - { - id: "done", - name: "Done", - colorId: null, - dynamicsEnabled: false, - differentialEquationId: null, - x: 100, - y: 0, - }, - ], - }, + definition, initialState: { queued: 2, done: 0 }, transitionFirings: [ { transitionId: "finish", - input: { queued: 1 }, - output: { done: 1 }, + inputTokens: { queued: [{}] }, + outputTokens: { done: [{}] }, ts: "2026-06-05T10:00:00.000Z", }, ], @@ -194,6 +305,98 @@ describe("Actual mode recordings", () => { }); }); + it("extends known firing times without recomputing them", () => { + const firings = [ + { + transitionId: "a", + inputTokens: {}, + outputTokens: {}, + ts: "2026-06-05T10:00:00.000Z", + }, + { + transitionId: "b", + inputTokens: {}, + outputTokens: {}, + ts: "2026-06-05T10:00:02.000Z", + }, + { + transitionId: "c", + inputTokens: {}, + outputTokens: {}, + ts: "not a timestamp", + }, + ]; + const firstTwo = getActualModeTransitionFiringTimesMs( + firings.slice(0, 2), + null, + null, + ); + + expect(firstTwo).toEqual([0, 2_000]); + expect( + extendActualModeTransitionFiringTimesMs(firstTwo, firings, null, null), + ).toEqual(getActualModeTransitionFiringTimesMs(firings, null, null)); + expect( + extendActualModeTransitionFiringTimesMs(firstTwo, firings, null, null), + ).toEqual([0, 2_000, 2_001]); + }); + + it("replays firings once across points and restarts on an earlier point", () => { + const transitionFirings = [ + { + transitionId: "finish", + inputTokens: { queued: [{}] }, + outputTokens: { done: [{}] }, + ts: "2026-06-05T10:00:00.000Z", + }, + { + transitionId: "finish", + inputTokens: { queued: [{}] }, + outputTokens: { done: [{}] }, + ts: "2026-06-05T10:00:01.000Z", + }, + ]; + const replay = createActualModeFrameReplay({ + definition, + initialState: { queued: 2, done: 0 }, + }); + const readerAt = (transitionFiringIndex: number | null) => + replay.readerAt({ + transitionFirings, + transitionFiringTimesMs: [0, 1_000], + point: { + kind: + transitionFiringIndex === null ? "initial" : "transition_firing", + timeMs: 0, + transitionFiringIndex, + }, + number: 0, + }); + + expect(readerAt(null).getPlaceTokenCount("queued")).toBe(2); + expect(readerAt(1).getPlaceTokenCount("queued")).toBe(0); + expect(readerAt(1).getPlaceTokenCount("done")).toBe(2); + expect(readerAt(0).getPlaceTokenCount("queued")).toBe(1); + }); + + it("keeps a place numeric while every token recorded for it is attribute-less", () => { + const marking = getActualModeMarkingAtTransitionFiringIndex({ + definition, + initialState: { queued: 2 }, + transitionFirings: [ + { + transitionId: "finish", + inputTokens: { queued: [{}] }, + outputTokens: { done: [{}, {}] }, + ts: "2026-06-05T10:00:00.000Z", + }, + ], + transitionFiringIndex: 0, + }); + + expect(marking).toEqual({ queued: 1, done: 2 }); + }); + it.each([ { marking: -1, expected: 0 }, { marking: 2.9, expected: 2 }, @@ -219,7 +422,146 @@ describe("Actual mode recordings", () => { }, ); - it("keeps count-only coloured markings consistent for HIR metrics", () => { + it("removes the marking token equal to the recorded input token", () => { + const marking = getActualModeMarkingAtTransitionFiringIndex({ + definition: ticketDefinition, + initialState: { + queued: [ticket("a"), ticket("b"), ticket("c")], + implementing: [], + }, + transitionFirings: [ + firingAt( + "start", + { queued: [ticket("b")] }, + { implementing: [ticket("b")] }, + ), + ], + transitionFiringIndex: 0, + }); + + expect(marking.queued).toEqual([ticket("a"), ticket("c")]); + expect(marking.implementing).toEqual([ticket("b")]); + }); + + it("removes the first of several equal marking tokens", () => { + const marking = getActualModeMarkingAtTransitionFiringIndex({ + definition: ticketDefinition, + initialState: { queued: [ticket("a"), ticket("b"), ticket("a")] }, + transitionFirings: [firingAt("start", { queued: [ticket("a")] }, {})], + transitionFiringIndex: 0, + }); + + expect(marking.queued).toEqual([ticket("b"), ticket("a")]); + }); + + it("throws for a recorded input token that differs from every marking token on one attribute", () => { + expect(() => + getActualModeMarkingAtTransitionFiringIndex({ + definition: ticketDefinition, + initialState: { queued: [ticket("a"), ticket("b")] }, + transitionFirings: [ + firingAt("start", { queued: [ticket("a", 1)] }, {}), + ], + transitionFiringIndex: 0, + }), + ).toThrow( + 'Transition firing of "start" at 2026-06-05T10:00:00.000Z consumes token {"ticket_id":"a","attempts":1} from place "queued", which holds no matching token (2 remaining)', + ); + }); + + it("throws for a record consumed twice from a place that holds it once", () => { + expect(() => + applyActualModeTransitionFiring( + ticketDefinition, + { queued: [ticket("a")] }, + firingAt("start", { queued: [ticket("a"), ticket("a")] }, {}), + ), + ).toThrow( + /consumes token \{"ticket_id":"a","attempts":0\} from place "queued".*\(0 remaining\)/, + ); + }); + + it.each<{ initialState: ActualModeMarking; holds: number }>([ + { initialState: { queued: 1 }, holds: 1 }, + { initialState: {}, holds: 0 }, + ])( + "throws for a firing that consumes more tokens than a count place holds ($holds)", + ({ initialState, holds }) => { + expect(() => + applyActualModeTransitionFiring(definition, initialState, { + transitionId: "finish", + inputTokens: { queued: [{}, {}] }, + outputTokens: { done: [{}] }, + ts: "2026-06-05T10:00:00.000Z", + }), + ).toThrow( + `Transition firing of "finish" at 2026-06-05T10:00:00.000Z consumes 2 tokens from place "queued", which holds ${holds}`, + ); + }, + ); + + it("throws from the frame replay when a firing does not match the marking", () => { + const replay = createActualModeFrameReplay({ + definition, + initialState: { queued: 0 }, + }); + + expect(() => + replay.readerAt({ + transitionFirings: [ + { + transitionId: "finish", + inputTokens: { queued: [{}] }, + outputTokens: {}, + ts: "2026-06-05T10:00:00.000Z", + }, + ], + transitionFiringTimesMs: [0], + point: { + kind: "transition_firing", + timeMs: 0, + transitionFiringIndex: 0, + }, + number: 1, + }), + ).toThrow(/consumes 1 token from place "queued", which holds 0/); + }); + + it("appends produced tokens as recorded", () => { + const marking = getActualModeMarkingAtTransitionFiringIndex({ + definition: ticketDefinition, + initialState: { queued: [] }, + transitionFirings: [ + firingAt("create", {}, { queued: [ticket("a"), ticket("b")] }), + ], + transitionFiringIndex: 0, + }); + + expect(marking.queued).toEqual([ticket("a"), ticket("b")]); + }); + + it("exposes recorded token values through the frame reader", () => { + const reader = createActualModeTimelineFrameReader({ + definition: ticketDefinition, + initialState: { queued: [] }, + transitionFirings: [ + firingAt("create", {}, { queued: [ticket("X-1234", 2)] }), + ], + transitionFiringTimesMs: [0], + point: { + kind: "transition_firing", + timeMs: 0, + transitionFiringIndex: 0, + }, + number: 1, + }); + + expect(reader.getPlaceTokens(ticketDefinition.places[0]!)).toEqual([ + ticket("X-1234", 2), + ]); + }); + + it("gives HIR metrics the recorded tokens of a coloured place", () => { const colouredDefinition = { ...definition, places: [ @@ -247,15 +589,15 @@ describe("Actual mode recordings", () => { ], metrics: [ { - id: "item-count", - name: "Item count", - code: "return state.places.Items.tokens.length;", + id: "item-total", + name: "Item total", + code: "return state.places.Items.tokens.reduce((sum, token) => sum + token.value, 0);", }, ], } satisfies SDCPN; const reader = createActualModeTimelineFrameReader({ definition: colouredDefinition, - initialState: { items: 2.9 }, + initialState: { items: [{ value: 1.5 }, { value: 2.25 }] }, transitionFirings: [], transitionFiringTimesMs: [], point: { @@ -267,19 +609,215 @@ describe("Actual mode recordings", () => { }); const { artifacts, failures } = compileHirArtifacts(colouredDefinition); expect(failures).toEqual([]); - const artifact = artifacts.metrics["item-count"]; + const artifact = artifacts.metrics["item-total"]; if (!artifact) { - throw new Error("Expected the item-count HIR artifact"); + throw new Error("Expected the item-total HIR artifact"); } const evaluate = createHirMetricEvaluator({ - metricName: "Item count", + metricName: "Item total", artifact, places: colouredDefinition.places, }); - const tokens = reader.getPlaceTokens(colouredDefinition.places[0]!); expect(reader.getPlaceTokenCount("items")).toBe(2); - expect(tokens).toEqual([{ value: 0 }, { value: 0 }]); - expect(evaluate(reader)).toBe(tokens.length); + expect(reader.getPlaceTokens(colouredDefinition.places[0]!)).toEqual([ + { value: 1.5 }, + { value: 2.25 }, + ]); + expect(evaluate(reader)).toBe(3.75); + }); +}); + +describe("Actual mode token record validation", () => { + const sampleColour: Color = { + id: "sample", + name: "Sample", + iconSlug: "circle", + displayColor: "#FF0000", + elements: ( + [ + ["weight", "real"], + ["count", "integer"], + ["checked", "boolean"], + ["sample_id", "uuid"], + ["label", "string"], + ] as const + ).map(([name, type]) => ({ elementId: name, name, type })), + }; + const sampleDefinition: SDCPN = { + ...definition, + places: [makePlace("samples", "sample"), makePlace("queued")], + types: [sampleColour], + }; + const sample = { + weight: 1.5, + count: 2, + checked: true, + sample_id: "0f8fad5b-d9cb-469f-a165-70867728950e", + label: "first", + }; + const produceSample = (record: Record) => + applyActualModeTransitionFiring( + sampleDefinition, + { samples: [] }, + firingAt("take", {}, { samples: [record] }), + ); + + it("accepts a record that carries every element with a value of its type", () => { + expect(produceSample(sample).samples).toEqual([sample]); + }); + + it("rejects a record that lacks an element", () => { + const { label: _label, ...withoutLabel } = sample; + + expect(() => produceSample(withoutLabel)).toThrow( + `Transition firing of "take" at 2026-06-05T10:00:00.000Z produces token ${JSON.stringify(withoutLabel)} in place "samples", which lacks element "label" of colour "Sample"`, + ); + }); + + it("rejects a record with an attribute the colour does not declare", () => { + const withExtra = { ...sample, colour: "red" }; + + expect(() => produceSample(withExtra)).toThrow( + `Transition firing of "take" at 2026-06-05T10:00:00.000Z produces token ${JSON.stringify(withExtra)} in place "samples", which carries attribute "colour" that colour "Sample" does not declare`, + ); + }); + + it.each<{ + name: string; + type: ColorElementType; + value: number | boolean | string; + expected: string; + }>([ + { name: "weight", type: "real", value: "1.5", expected: "a finite number" }, + { name: "count", type: "integer", value: 2.5, expected: "an integer" }, + { name: "checked", type: "boolean", value: 1, expected: "a boolean" }, + { + name: "sample_id", + type: "uuid", + value: "0F8FAD5B-D9CB-469F-A165-70867728950E", + expected: "a canonical lowercase UUID string", + }, + { name: "label", type: "string", value: 7, expected: "a string" }, + ])("rejects a $type element holding $value", ({ name, value, expected }) => { + expect(() => produceSample({ ...sample, [name]: value })).toThrow( + `whose element "${name}" of colour "Sample" is ${JSON.stringify(value)}, not ${expected}`, + ); + }); + + it("rejects a non-empty record for an uncoloured place", () => { + expect(() => + applyActualModeTransitionFiring( + sampleDefinition, + { queued: 1 }, + firingAt("start", { queued: [{ ticket_id: "a" }] }, {}), + ), + ).toThrow( + 'Transition firing of "start" at 2026-06-05T10:00:00.000Z consumes token {"ticket_id":"a"} from place "queued", which carries attribute "ticket_id" although the place has no colour', + ); + }); + + it.each([0, 2])( + "rejects an initial token count of %d in a place whose colour has elements", + (count) => { + expect(() => + validateActualModeInitialState(sampleDefinition, { samples: count }), + ).toThrow( + `Initial marking holds a token count of ${count} in place "samples", whose colour "Sample" has elements, so the place needs a token record for each token`, + ); + }, + ); + + it("accepts a token count in a place whose colour has no elements", () => { + const markerDefinition: SDCPN = { + ...definition, + places: [makePlace("flags", "marker")], + types: [{ ...sampleColour, id: "marker", name: "Marker", elements: [] }], + }; + + expect( + getActualModeMarkingAtTransitionFiringIndex({ + definition: markerDefinition, + initialState: { flags: 2 }, + transitionFirings: [firingAt("clear", { flags: [{}] }, {})], + transitionFiringIndex: 0, + }), + ).toEqual({ flags: 1 }); + }); + + it("rejects a firing on a marking that holds a token count in a coloured place", () => { + expect(() => + applyActualModeTransitionFiring( + sampleDefinition, + { samples: 1 }, + firingAt("take", {}, { samples: [sample] }), + ), + ).toThrow( + 'Marking holds a token count of 1 in place "samples", whose colour "Sample" has elements, so the place needs a token record for each token', + ); + }); + + it("keeps a coloured place a token array when a firing moves no tokens there", () => { + expect( + applyActualModeTransitionFiring( + sampleDefinition, + {}, + firingAt("noop", { samples: [] }, {}), + ), + ).toEqual({ samples: [] }); + }); + + it("rejects a firing that names a place the net does not define", () => { + expect(() => + applyActualModeTransitionFiring( + sampleDefinition, + {}, + firingAt("start", {}, { archived: [{}] }), + ), + ).toThrow( + 'Transition firing of "start" at 2026-06-05T10:00:00.000Z names place "archived", which the net does not define', + ); + }); + + it("checks a scoped place against the subnet colour", () => { + const scopedDefinition: SDCPN = { + ...definition, + subnets: [ + { + id: "worker", + name: "Worker", + places: [makePlace("inbox", "ticket")], + transitions: [], + types: [ticketColour], + differentialEquations: [], + parameters: [], + }, + ], + componentInstances: [ + { + id: "worker-1", + name: "WorkerOne", + subnetId: "worker", + parameterValues: {}, + x: 0, + y: 0, + }, + ], + }; + + expect( + applyActualModeTransitionFiring( + scopedDefinition, + {}, + firingAt("assign", {}, { "worker-1::inbox": [ticket("a")] }), + ), + ).toEqual({ "worker-1::inbox": [ticket("a")] }); + expect(() => + applyActualModeTransitionFiring( + scopedDefinition, + {}, + firingAt("assign", {}, { "worker-1::inbox": [{}] }), + ), + ).toThrow('which lacks element "ticket_id" of colour "Ticket"'); }); }); diff --git a/libs/@hashintel/petrinaut-core/src/actual-mode/constants.ts b/libs/@hashintel/petrinaut-core/src/actual-mode/constants.ts index 8827add9c1b..a894f01ccea 100644 --- a/libs/@hashintel/petrinaut-core/src/actual-mode/constants.ts +++ b/libs/@hashintel/petrinaut-core/src/actual-mode/constants.ts @@ -1,2 +1,3 @@ export const ACTUAL_MODE_TIMELINE_TICK_MS = 500; -export const ACTUAL_MODE_RECORDING_VERSION = 1; + +export const ACTUAL_MODE_RECORDING_VERSION = 2; diff --git a/libs/@hashintel/petrinaut-core/src/actual-mode/index.ts b/libs/@hashintel/petrinaut-core/src/actual-mode/index.ts index 4f4719805da..b38d9ef5a55 100644 --- a/libs/@hashintel/petrinaut-core/src/actual-mode/index.ts +++ b/libs/@hashintel/petrinaut-core/src/actual-mode/index.ts @@ -19,13 +19,20 @@ export { actualModeReceivedEventsRecordingSchema, actualModeRecordingSchema, actualModeSourceSchema, - actualModeTransitionEffectSchema, + actualModeTokenValuesSchema, actualModeTransitionFiringSchema, } from "./schemas"; +export { + validateActualModeInitialState, + type ActualModeDefinition, +} from "./token-records"; export { buildActualModeTimelinePoints, + createActualModeFrameReplay, createActualModeTimelineFrameReader, + extendActualModeTransitionFiringTimesMs, getActualModeTransitionFiringTimesMs, + type ActualModeFrameReplay, } from "./timeline"; export type { ActualModeContextValue, @@ -36,7 +43,7 @@ export type { ActualModeSource, ActualModeTimelinePoint, ActualModeTimelinePointKind, - ActualModeTokenColour, - ActualModeTransitionEffect, + ActualModeTokenRecord, + ActualModeTokenValues, ActualModeTransitionFiring, } from "./types"; diff --git a/libs/@hashintel/petrinaut-core/src/actual-mode/marking.ts b/libs/@hashintel/petrinaut-core/src/actual-mode/marking.ts index dd8ebffbbce..ef83f1acb36 100644 --- a/libs/@hashintel/petrinaut-core/src/actual-mode/marking.ts +++ b/libs/@hashintel/petrinaut-core/src/actual-mode/marking.ts @@ -1,17 +1,24 @@ import { createUserKeyedRecord } from "../validation/record-keys"; - +import { + createTokenCountOnColouredPlaceError, + getElementBearingPlaceColour, + validateActualModeInitialState, + validateActualModeTransitionFiring, +} from "./token-records"; + +import type { ActualModeDefinition } from "./token-records"; import type { ActualModeMarking, - ActualModeTokenColour, + ActualModeTokenRecord, ActualModeTransitionFiring, } from "./types"; export const isActualModeTokenColourArray = ( - markingValue: number | ActualModeTokenColour[] | undefined, -): markingValue is ActualModeTokenColour[] => Array.isArray(markingValue); + markingValue: number | ActualModeTokenRecord[] | undefined, +): markingValue is ActualModeTokenRecord[] => Array.isArray(markingValue); export const getActualModePlaceMarkingTokenCount = ( - markingValue: number | ActualModeTokenColour[] | undefined, + markingValue: number | ActualModeTokenRecord[] | undefined, ): number => { if (markingValue === undefined) { return 0; @@ -24,15 +31,17 @@ export const getActualModePlaceMarkingTokenCount = ( : 0; }; -const cloneTokenColour = ( - token: ActualModeTokenColour, -): ActualModeTokenColour => ({ ...token }); +const cloneTokenRecord = ( + token: ActualModeTokenRecord, +): ActualModeTokenRecord => ({ + ...token, +}); const cloneMarkingValue = ( - markingValue: number | ActualModeTokenColour[], -): number | ActualModeTokenColour[] => + markingValue: number | ActualModeTokenRecord[], +): number | ActualModeTokenRecord[] => Array.isArray(markingValue) - ? markingValue.map((token) => cloneTokenColour(token)) + ? markingValue.map((token) => cloneTokenRecord(token)) : markingValue; // Keyed by place ids from recorded firings: no prototype, so the writes in @@ -45,65 +54,152 @@ const cloneMarking = (marking: ActualModeMarking): ActualModeMarking => { return next; }; -const emptyTokens = (count: number): ActualModeTokenColour[] => +const emptyTokens = (count: number): ActualModeTokenRecord[] => Array.from( { length: getActualModePlaceMarkingTokenCount(count) }, () => ({}), ); const toTokenArray = ( - markingValue: number | ActualModeTokenColour[] | undefined, -): ActualModeTokenColour[] => { + markingValue: number | ActualModeTokenRecord[] | undefined, +): ActualModeTokenRecord[] => { if (markingValue === undefined) { return []; } return Array.isArray(markingValue) - ? markingValue.map((token) => cloneTokenColour(token)) + ? markingValue.map((token) => cloneTokenRecord(token)) : emptyTokens(markingValue); }; +const tokenRecordsEqual = ( + left: ActualModeTokenRecord, + right: ActualModeTokenRecord, +): boolean => { + const leftNames = Object.keys(left); + return ( + leftNames.length === Object.keys(right).length && + leftNames.every( + (name) => Object.hasOwn(right, name) && left[name] === right[name], + ) + ); +}; + +const hasAttributes = (token: ActualModeTokenRecord): boolean => + Object.keys(token).length > 0; + +/** + * Removes the consumed tokens from a place's token array. Each recorded token + * removes the first marking token equal to it on every attribute. + * + * @throws when a recorded token matches no token left in the place. + */ +const removeConsumedTokens = ( + currentTokens: ActualModeTokenRecord[], + consumedTokens: readonly ActualModeTokenRecord[], + firing: ActualModeTransitionFiring, + placeId: string, +): ActualModeTokenRecord[] => { + const remaining = [...currentTokens]; + for (const consumedToken of consumedTokens) { + const matchIndex = remaining.findIndex((token) => + tokenRecordsEqual(token, consumedToken), + ); + if (matchIndex === -1) { + throw new Error( + `Transition firing of "${firing.transitionId}" at ${firing.ts} consumes token ${JSON.stringify( + consumedToken, + )} from place "${placeId}", which holds no matching token (${remaining.length} remaining)`, + ); + } + remaining.splice(matchIndex, 1); + } + return remaining; +}; + +/** + * Applies one firing to a marking. A place stays a token count while every + * token recorded for it is `{}`; the first token with attributes turns it + * into an array. A place whose colour declares elements holds an array once + * a firing names it. + * + * @throws when a token record does not fit its place in `definition` (see + * `validateActualModeTransitionFiring`), when `marking` holds a token count + * on a place the firing names whose colour declares elements, or when the + * firing consumes a token the marking does not hold: more tokens than a + * place holds, or a recorded token equal to none of them. + */ export const applyActualModeTransitionFiring = ( + definition: ActualModeDefinition, marking: ActualModeMarking, firing: ActualModeTransitionFiring, ): ActualModeMarking => { + validateActualModeTransitionFiring(definition, firing); const next = cloneMarking(marking); const placeIds = new Set([ - ...Object.keys(next), - ...Object.keys(firing.input), - ...Object.keys(firing.output), + ...Object.keys(firing.inputTokens), + ...Object.keys(firing.outputTokens), ]); for (const placeId of placeIds) { const currentValue = next[placeId]; - const inputValue = firing.input[placeId]; - const outputValue = firing.output[placeId]; + const consumedTokens = firing.inputTokens[placeId] ?? []; + const producedTokens = firing.outputTokens[placeId] ?? []; + const recordColour = getElementBearingPlaceColour(definition, placeId); + + if (typeof currentValue === "number" && recordColour) { + throw createTokenCountOnColouredPlaceError( + "Marking", + placeId, + currentValue, + recordColour, + ); + } if ( - Array.isArray(currentValue) || - Array.isArray(inputValue) || - Array.isArray(outputValue) + !recordColour && + !Array.isArray(currentValue) && + !consumedTokens.some(hasAttributes) && + !producedTokens.some(hasAttributes) ) { - const currentTokens = toTokenArray(currentValue); - const inputCount = getActualModePlaceMarkingTokenCount(inputValue); - const outputTokens = toTokenArray(outputValue); - next[placeId] = currentTokens.slice(inputCount).concat(outputTokens); + const tokenCount = getActualModePlaceMarkingTokenCount(currentValue); + if (consumedTokens.length > tokenCount) { + throw new Error( + `Transition firing of "${firing.transitionId}" at ${firing.ts} consumes ${consumedTokens.length} ${consumedTokens.length === 1 ? "token" : "tokens"} from place "${placeId}", which holds ${tokenCount}`, + ); + } + next[placeId] = + tokenCount - consumedTokens.length + producedTokens.length; continue; } - next[placeId] = - (currentValue ?? 0) - (inputValue ?? 0) + (outputValue ?? 0); + next[placeId] = removeConsumedTokens( + toTokenArray(currentValue), + consumedTokens, + firing, + placeId, + ).concat(producedTokens.map((token) => cloneTokenRecord(token))); } return next; }; +/** + * @throws when `initialState` or a replayed firing holds a token record that + * does not fit its place in `definition`, `initialState` holds a token count + * on a place whose colour declares elements, or a firing consumes a token the + * marking does not hold. + */ export const getActualModeMarkingAtTransitionFiringIndex = (params: { + definition: ActualModeDefinition; initialState: ActualModeMarking; transitionFirings: readonly ActualModeTransitionFiring[]; transitionFiringIndex: number | null; }): ActualModeMarking => { - const { initialState, transitionFiringIndex, transitionFirings } = params; + const { definition, initialState, transitionFiringIndex, transitionFirings } = + params; + + validateActualModeInitialState(definition, initialState); if (transitionFiringIndex === null) { return initialState; @@ -119,7 +215,7 @@ export const getActualModeMarkingAtTransitionFiringIndex = (params: { const firing = transitionFirings[index]; if (firing) { - marking = applyActualModeTransitionFiring(marking, firing); + marking = applyActualModeTransitionFiring(definition, marking, firing); } } diff --git a/libs/@hashintel/petrinaut-core/src/actual-mode/schemas.ts b/libs/@hashintel/petrinaut-core/src/actual-mode/schemas.ts index e08178022c3..9bafb535136 100644 --- a/libs/@hashintel/petrinaut-core/src/actual-mode/schemas.ts +++ b/libs/@hashintel/petrinaut-core/src/actual-mode/schemas.ts @@ -2,6 +2,8 @@ import { z } from "zod"; import { sdcpnSchema } from "../file-format/types"; import { ACTUAL_MODE_RECORDING_VERSION } from "./constants"; +import { applyActualModeTransitionFiring } from "./marking"; +import { validateActualModeInitialState } from "./token-records"; import type { SDCPN } from "../types/sdcpn"; import type { @@ -10,22 +12,41 @@ import type { ActualModeReceivedEventsRecording, ActualModeRecording, ActualModeSource, - ActualModeTransitionEffect, + ActualModeTokenValues, ActualModeTransitionFiring, } from "./types"; -const actualModeTokenColourSchema = z.record(z.string(), z.number()); +const actualModeTokenValueSchema = z.union([ + z.number(), + z.boolean(), + z.string(), +]); +const actualModeTokenRecordSchema = z.record( + z.string(), + actualModeTokenValueSchema, +); const actualModeMarkingValueSchema = z.union([ z.number(), - z.array(actualModeTokenColourSchema), + z.array(actualModeTokenRecordSchema), ]); +/** + * Attribute values of the tokens a firing consumed or produced, keyed by + * place id. The schema checks the JSON shape only; whether each record fits + * its place needs the net, so `validateActualModeTransitionFiring` checks it. + */ +export const actualModeTokenValuesSchema = z.record( + z.string(), + z.array(actualModeTokenRecordSchema), +) satisfies z.ZodType; + /** * Root schema for an Actual Mode marking. * - * This validates `initial_state` stream frames and recording snapshots. Places - * can currently be represented by a numeric token count or by token-colour - * arrays for future coloured-token support. + * This validates `initial_state` stream frames and recording snapshots. A + * place is either a token count or an array of token records; whether each + * fits its place needs the net, so `validateActualModeInitialState` checks + * it. */ export const actualModeMarkingSchema = z.record( z.string(), @@ -33,34 +54,20 @@ export const actualModeMarkingSchema = z.record( ) satisfies z.ZodType; /** - * Root schema for a transition-local token effect. + * Root schema for Actual Mode transition events. * - * This is intentionally not a full marking: keys are only the places affected - * by a transition, and values are the token counts consumed or produced there. + * A `transition_firing` payload names the transition and the tokens it + * consumed (`inputTokens`) and produced (`outputTokens`), keyed by place id; + * neither field is a full before or after marking. */ -export const actualModeTransitionEffectSchema = z.record( - z.string(), - z.number(), -) satisfies z.ZodType; - -const actualModeTransitionFiringEffectSchema = z +export const actualModeTransitionFiringSchema = z .object({ transitionId: z.string(), - input: actualModeTransitionEffectSchema, - output: actualModeTransitionEffectSchema, + inputTokens: actualModeTokenValuesSchema, + outputTokens: actualModeTokenValuesSchema, ts: z.string(), }) - .strict(); - -/** - * Root schema for Actual Mode transition events. - * - * This is the only accepted `transition_firing` payload shape for this PR: - * `input` contains consumed token counts, `output` contains produced token - * counts, and neither field carries a full before or after marking. - */ -export const actualModeTransitionFiringSchema = - actualModeTransitionFiringEffectSchema satisfies z.ZodType; + .strict() satisfies z.ZodType; export const actualModeSourceSchema = z .object({ @@ -77,6 +84,10 @@ export const actualModeReceivedEventSchema = z }) .strict() satisfies z.ZodType; +const actualModeRecordingVersionSchema = z.literal( + ACTUAL_MODE_RECORDING_VERSION, +); + const actualModeRecordingDefinitionSchema = z.custom( (value) => sdcpnSchema.safeParse(value).success, { message: "Invalid SDCPN definition" }, @@ -87,19 +98,56 @@ const actualModeRecordingDefinitionSchema = z.custom( * * A recording combines the normalized SDCPN, initial marking, source metadata, * and ordered transition events needed to reconstruct the timeline offline. + * Every token record must fit its place in the recording's definition, and + * the firings must replay against the initial marking: the first record or + * firing that fails either check fails validation. */ -export const actualModeRecordingSchema = z.object({ - version: z.literal(ACTUAL_MODE_RECORDING_VERSION), - exportedAt: z.string(), - title: z.string().nullable(), - source: actualModeSourceSchema.nullable(), - definition: actualModeRecordingDefinitionSchema, - initialState: actualModeMarkingSchema, - transitionFirings: z.array(actualModeTransitionFiringSchema), -}) satisfies z.ZodType; +export const actualModeRecordingSchema = z + .object({ + version: actualModeRecordingVersionSchema, + exportedAt: z.string(), + title: z.string().nullable(), + source: actualModeSourceSchema.nullable(), + definition: actualModeRecordingDefinitionSchema, + initialState: actualModeMarkingSchema, + transitionFirings: z.array(actualModeTransitionFiringSchema), + }) + .superRefine((recording, context) => { + try { + validateActualModeInitialState( + recording.definition, + recording.initialState, + ); + } catch (error) { + context.addIssue({ + code: "custom", + path: ["initialState"], + message: error instanceof Error ? error.message : String(error), + }); + return; + } + + let marking: ActualModeMarking = recording.initialState; + for (const [index, firing] of recording.transitionFirings.entries()) { + try { + marking = applyActualModeTransitionFiring( + recording.definition, + marking, + firing, + ); + } catch (error) { + context.addIssue({ + code: "custom", + path: ["transitionFirings", index], + message: error instanceof Error ? error.message : String(error), + }); + return; + } + } + }) satisfies z.ZodType; export const actualModeReceivedEventsRecordingSchema = z.object({ - version: z.literal(ACTUAL_MODE_RECORDING_VERSION), + version: actualModeRecordingVersionSchema, exportedAt: z.string(), title: z.string().nullable(), source: actualModeSourceSchema.nullable(), diff --git a/libs/@hashintel/petrinaut-core/src/actual-mode/timeline.ts b/libs/@hashintel/petrinaut-core/src/actual-mode/timeline.ts index 17f03ea8159..8b3e6b7ef3b 100644 --- a/libs/@hashintel/petrinaut-core/src/actual-mode/timeline.ts +++ b/libs/@hashintel/petrinaut-core/src/actual-mode/timeline.ts @@ -3,14 +3,19 @@ import { createTokenRegionViews, encodeTokenToBytes, } from "../simulation/engine/token-layout"; -import { defaultTokenAttributeValue } from "../simulation/engine/token-values"; +import { + coerceTokenRecord, + defaultTokenAttributeValue, +} from "../simulation/engine/token-values"; import { ACTUAL_MODE_TIMELINE_TICK_MS } from "./constants"; import { + applyActualModeTransitionFiring, getActualModeMarkingAtTransitionFiringIndex, getActualModePlaceMarkingTokenCount, isActualModeTokenColourArray, } from "./marking"; import { parseActualModeTimestampMs } from "./time"; +import { validateActualModeInitialState } from "./token-records"; import type { SimulationFrameRawView, @@ -18,6 +23,7 @@ import type { SimulationFrameState, } from "../simulation/api"; import type { Place, SDCPN, TokenRecord } from "../types/sdcpn"; +import type { ActualModeDefinition } from "./token-records"; import type { ActualModeContextValue, ActualModeMarking, @@ -42,7 +48,15 @@ const getTimelineBaselineMs = ( return timelineStartedAtMs ?? timelineNowMs ?? 0; }; -export const getActualModeTransitionFiringTimesMs = ( +/** + * Timeline times, in ms from the baseline, of the firings not yet covered by + * `knownTimesMs` (the times of `transitionFirings[0..knownTimesMs.length)`), + * appended to a copy of it. Times never decrease along the log: a firing + * whose timestamp precedes the previous firing's takes that firing's time, + * and one without a parseable timestamp takes the previous time plus 1 ms. + */ +export const extendActualModeTransitionFiringTimesMs = ( + knownTimesMs: readonly number[], transitionFirings: readonly ActualModeTransitionFiring[], timelineStartedAtMs: number | null, timelineNowMs: number | null, @@ -52,9 +66,9 @@ export const getActualModeTransitionFiringTimesMs = ( timelineStartedAtMs, timelineNowMs, ); - const times: number[] = []; + const times = knownTimesMs.slice(0, transitionFirings.length); - for (const firing of transitionFirings) { + for (const firing of transitionFirings.slice(times.length)) { const timestampMs = parseActualModeTimestampMs(firing.ts); const previousTimeMs = times.at(-1) ?? 0; const nextTimeMs = @@ -68,6 +82,18 @@ export const getActualModeTransitionFiringTimesMs = ( return times; }; +export const getActualModeTransitionFiringTimesMs = ( + transitionFirings: readonly ActualModeTransitionFiring[], + timelineStartedAtMs: number | null, + timelineNowMs: number | null, +): readonly number[] => + extendActualModeTransitionFiringTimesMs( + [], + transitionFirings, + timelineStartedAtMs, + timelineNowMs, + ); + export const buildActualModeTimelinePoints = (params: { status: ActualModeContextValue["status"]; transitionFirings: readonly ActualModeTransitionFiring[]; @@ -173,12 +199,19 @@ const getTransitionFiringCount = ( }; export const createActualModeTimelineFrameReader = (params: { - definition: Pick; + definition: ActualModeDefinition & Pick; initialState: ActualModeMarking; transitionFirings: readonly ActualModeTransitionFiring[]; transitionFiringTimesMs: readonly number[]; point: ActualModeTimelinePoint; number: number; + /** + * The reconstructed marking at `point`, for callers that replay a range + * of points with a shared cursor (each firing applied once) instead of + * paying a from-zero replay per reader. The reader only reads it. Omitted, + * the marking is reconstructed by replaying from `initialState`. + */ + marking?: ActualModeMarking; }): SimulationFrameReader => { const { definition, @@ -188,11 +221,14 @@ export const createActualModeTimelineFrameReader = (params: { transitionFirings, transitionFiringTimesMs, } = params; - const marking = getActualModeMarkingAtTransitionFiringIndex({ - initialState, - transitionFirings, - transitionFiringIndex: point.transitionFiringIndex, - }); + const marking = + params.marking ?? + getActualModeMarkingAtTransitionFiringIndex({ + definition, + initialState, + transitionFirings, + transitionFiringIndex: point.transitionFiringIndex, + }); const colorById = new Map(definition.types.map((color) => [color.id, color])); const tokensByPlaceId = new Map(); @@ -208,9 +244,14 @@ export const createActualModeTimelineFrameReader = (params: { const placeMarking = marking[place.id]; if (isActualModeTokenColourArray(placeMarking)) { + // Recorded token values are at-rest JSON (uuid values are canonical + // strings); coercion brings them to the runtime form simulation frames + // expose. tokensByPlaceId.set( place.id, - placeMarking.map((token) => ({ ...token })), + placeMarking.map((token) => + coerceTokenRecord(token, color.elements, `actual-mode.${place.name}`), + ), ); continue; } @@ -366,3 +407,76 @@ export const createActualModeTimelineFrameReader = (params: { }), }; }; + +export type ActualModeFrameReplay = { + /** + * The reader for `point`, over the marking reached by applying every firing + * up to the point's firing index. Firings between the previous point's index + * and this one are applied once; a point with an earlier index restarts + * from the initial state. `transitionFirings` must extend the list passed + * before, so a log that grows between calls keeps its cursor. + */ + readerAt(params: { + transitionFirings: readonly ActualModeTransitionFiring[]; + transitionFiringTimesMs: readonly number[]; + point: ActualModeTimelinePoint; + number: number; + }): SimulationFrameReader; +}; + +/** + * One marking cursor for a run of timeline points visited in firing order, + * so a range of frames costs one pass over the firing log rather than a + * from-zero replay per frame. + * + * @throws when `initialState` holds a token record that does not fit its + * place in `definition`, or a token count on a place whose colour declares + * elements; `readerAt` throws for a firing record that does not fit its place, + * and for a firing that consumes a token the marking does not hold. + */ +export const createActualModeFrameReplay = (params: { + definition: ActualModeDefinition & Pick; + initialState: ActualModeMarking; +}): ActualModeFrameReplay => { + const { definition, initialState } = params; + validateActualModeInitialState(definition, initialState); + let marking = initialState; + let appliedThroughFiringIndex = -1; + + return { + readerAt({ transitionFirings, transitionFiringTimesMs, point, number }) { + const targetFiringIndex = point.transitionFiringIndex ?? -1; + if (targetFiringIndex < appliedThroughFiringIndex) { + marking = initialState; + appliedThroughFiringIndex = -1; + } + for ( + let firingIndex = appliedThroughFiringIndex + 1; + firingIndex <= targetFiringIndex; + firingIndex += 1 + ) { + const firing = transitionFirings[firingIndex]; + if (firing) { + marking = applyActualModeTransitionFiring( + definition, + marking, + firing, + ); + } + } + appliedThroughFiringIndex = Math.max( + appliedThroughFiringIndex, + targetFiringIndex, + ); + return createActualModeTimelineFrameReader({ + definition, + initialState, + transitionFirings, + transitionFiringTimesMs, + point, + number, + marking, + }); + }, + }; +}; diff --git a/libs/@hashintel/petrinaut-core/src/actual-mode/token-records.ts b/libs/@hashintel/petrinaut-core/src/actual-mode/token-records.ts new file mode 100644 index 00000000000..7bb870b7834 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/actual-mode/token-records.ts @@ -0,0 +1,253 @@ +import { isUuidString } from "../simulation/engine/uuid"; +import { getStatusViewEvaluationScope } from "../status-view-scope"; + +import type { Color, ColorElementType, SDCPN } from "../types/sdcpn"; +import type { + ActualModeMarking, + ActualModeTokenRecord, + ActualModeTokenValues, + ActualModeTransitionFiring, +} from "./types"; + +/** + * The parts of a net definition that decide which token records its places + * accept. Scoped place ids (`instanceId::placeId`) resolve through + * `componentInstances` and `subnets`, or directly when `places` already holds + * the scoped copies. + */ +export type ActualModeDefinition = Pick< + SDCPN, + "places" | "types" | "subnets" | "componentInstances" +>; + +type PlaceColour = + | { kind: "uncoloured" } + | { kind: "coloured"; colour: Color } + | { kind: "missingColour"; colorId: string }; + +const atRestValueRules: Record< + ColorElementType, + { accepts: (value: unknown) => boolean; expected: string } +> = { + real: { + accepts: (value) => typeof value === "number" && Number.isFinite(value), + expected: "a finite number", + }, + integer: { + accepts: (value) => Number.isInteger(value), + expected: "an integer", + }, + boolean: { + accepts: (value) => typeof value === "boolean", + expected: "a boolean", + }, + uuid: { + accepts: (value) => isUuidString(value) && value === value.toLowerCase(), + expected: "a canonical lowercase UUID string", + }, + string: { + accepts: (value) => typeof value === "string", + expected: "a string", + }, +}; + +const placeColoursByDefinition = new WeakMap< + ActualModeDefinition, + ReadonlyMap +>(); + +const getPlaceColours = ( + definition: ActualModeDefinition, +): ReadonlyMap => { + const cached = placeColoursByDefinition.get(definition); + if (cached) { + return cached; + } + + const { places, types } = getStatusViewEvaluationScope(definition); + const colourById = new Map(types.map((colour) => [colour.id, colour])); + const placeColours = new Map(); + for (const place of places) { + if (!place.colorId) { + placeColours.set(place.id, { kind: "uncoloured" }); + continue; + } + const colour = colourById.get(place.colorId); + placeColours.set( + place.id, + colour + ? { kind: "coloured", colour } + : { kind: "missingColour", colorId: place.colorId }, + ); + } + + placeColoursByDefinition.set(definition, placeColours); + return placeColours; +}; + +/** + * The colour of `placeId` when it declares elements, so that the place's + * tokens are records a firing consumes by value; null otherwise, including + * for a place the net does not define. + */ +export const getElementBearingPlaceColour = ( + definition: ActualModeDefinition, + placeId: string, +): Color | null => { + const placeColour = getPlaceColours(definition).get(placeId); + return placeColour?.kind === "coloured" && + placeColour.colour.elements.length > 0 + ? placeColour.colour + : null; +}; + +/** + * The error for a token count held by a place whose colour declares + * elements. `subject` names the marking, as in "Initial marking". + */ +export const createTokenCountOnColouredPlaceError = ( + subject: string, + placeId: string, + count: number, + colour: Color, +): Error => + new Error( + `${subject} holds a token count of ${count} in place "${placeId}", whose colour "${colour.name}" has elements, so the place needs a token record for each token`, + ); + +/** + * What is wrong with `record` as a token of a place with `placeColour`, as a + * clause that completes "… token {record} … place "p"", or null when the + * record carries exactly the colour's elements, each an at-rest value of its + * element's type. + */ +const describeRecordProblem = ( + record: ActualModeTokenRecord, + placeColour: Exclude, +): string | null => { + const attributeNames = Object.keys(record); + + if (placeColour.kind === "uncoloured") { + return attributeNames.length === 0 + ? null + : `, which carries attribute "${attributeNames[0]}" although the place has no colour`; + } + + const { colour } = placeColour; + const elementNames = new Set(colour.elements.map((element) => element.name)); + + for (const element of colour.elements) { + if (!Object.hasOwn(record, element.name)) { + return `, which lacks element "${element.name}" of colour "${colour.name}"`; + } + const rule = atRestValueRules[element.type]; + if (!rule.accepts(record[element.name])) { + return `, whose element "${element.name}" of colour "${colour.name}" is ${JSON.stringify(record[element.name])}, not ${rule.expected}`; + } + } + + const extraName = attributeNames.find((name) => !elementNames.has(name)); + return extraName === undefined + ? null + : `, which carries attribute "${extraName}" that colour "${colour.name}" does not declare`; +}; + +const validateTokenRecords = ( + placeColours: ReadonlyMap, + placeId: string, + records: readonly ActualModeTokenRecord[], + describeToken: (record: ActualModeTokenRecord) => string, + subject: string, +): void => { + const placeColour = placeColours.get(placeId); + if (!placeColour) { + throw new Error( + `${subject} names place "${placeId}", which the net does not define`, + ); + } + if (placeColour.kind === "missingColour") { + throw new Error( + `${subject} names place "${placeId}", whose colour "${placeColour.colorId}" the net does not define`, + ); + } + + for (const record of records) { + const problem = describeRecordProblem(record, placeColour); + if (problem !== null) { + throw new Error(`${describeToken(record)}${problem}`); + } + } +}; + +/** + * Checks every token record in an Actual Mode initial marking against the + * net: a place with a colour lists records carrying exactly the colour's + * elements, each an at-rest value of the element's type (`uuid` values are + * canonical lowercase strings), and an uncoloured place lists only `{}` + * records. A place whose colour declares elements lists records, not a + * token count: a firing consumes a coloured token by its element values, + * which a count does not carry. + * + * @throws naming the place, the record and the element or attribute at + * fault, a token count on a place whose colour declares elements, or a place + * the net does not define. + */ +export const validateActualModeInitialState = ( + definition: ActualModeDefinition, + marking: ActualModeMarking, +): void => { + const placeColours = getPlaceColours(definition); + for (const [placeId, markingValue] of Object.entries(marking)) { + validateTokenRecords( + placeColours, + placeId, + Array.isArray(markingValue) ? markingValue : [], + (record) => + `Initial marking holds token ${JSON.stringify(record)} in place "${placeId}"`, + "Initial marking", + ); + const recordColour = getElementBearingPlaceColour(definition, placeId); + if (!Array.isArray(markingValue) && recordColour) { + throw createTokenCountOnColouredPlaceError( + "Initial marking", + placeId, + markingValue, + recordColour, + ); + } + } +}; + +/** + * Checks every token record in a firing's `inputTokens` and `outputTokens` + * against the net, by the rule `validateActualModeInitialState` applies. + * + * @throws naming the transition, the timestamp, the place, the record and + * the element or attribute at fault, or a place the net does not define. + */ +export const validateActualModeTransitionFiring = ( + definition: ActualModeDefinition, + firing: ActualModeTransitionFiring, +): void => { + const placeColours = getPlaceColours(definition); + const subject = `Transition firing of "${firing.transitionId}" at ${firing.ts}`; + const validateSide = ( + tokenValues: ActualModeTokenValues, + verb: "consumes" | "produces", + preposition: "from" | "in", + ) => { + for (const [placeId, records] of Object.entries(tokenValues)) { + validateTokenRecords( + placeColours, + placeId, + records, + (record) => + `${subject} ${verb} token ${JSON.stringify(record)} ${preposition} place "${placeId}"`, + subject, + ); + } + }; + + validateSide(firing.inputTokens, "consumes", "from"); + validateSide(firing.outputTokens, "produces", "in"); +}; diff --git a/libs/@hashintel/petrinaut-core/src/actual-mode/types.ts b/libs/@hashintel/petrinaut-core/src/actual-mode/types.ts index b9f63ba55f7..7ba171b7782 100644 --- a/libs/@hashintel/petrinaut-core/src/actual-mode/types.ts +++ b/libs/@hashintel/petrinaut-core/src/actual-mode/types.ts @@ -1,6 +1,5 @@ -import { ACTUAL_MODE_RECORDING_VERSION } from "./constants"; - import type { SDCPN } from "../types/sdcpn"; +import type { ACTUAL_MODE_RECORDING_VERSION } from "./constants"; /** * Host-provided live execution state for Petrinaut's Actual mode. @@ -9,19 +8,34 @@ import type { SDCPN } from "../types/sdcpn"; * the concrete context/provider surface for UI consumption. */ -export type ActualModeTokenColour = Record; +/** + * At-rest token attribute value in a firing record or reconstructed marking. + * The wire format is JSON, so `uuid` values are canonical lowercase strings, + * as in documents. + */ +type ActualModeTokenValue = number | boolean | string; + +export type ActualModeTokenRecord = Record; export type ActualModeMarking = Record< string, - number | ActualModeTokenColour[] + number | ActualModeTokenRecord[] >; -export type ActualModeTransitionEffect = Record; +/** + * Attribute values of the tokens a firing consumed or produced, keyed by + * placeId, or `instanceId::placeId` for a componentInstance's copy of a subnet + * place (see `scoped-ids.ts`). A record for a place with a colour carries + * exactly the colour's elements, each an at-rest value of the element's type; + * a record for an uncoloured place is `{}`. + */ +export type ActualModeTokenValues = Record; export type ActualModeTransitionFiring = { + /** Scoped id (`instanceId::transitionId`) when inside a component instance. */ transitionId: string; - input: ActualModeTransitionEffect; - output: ActualModeTransitionEffect; + inputTokens: ActualModeTokenValues; + outputTokens: ActualModeTokenValues; ts: string; }; @@ -36,8 +50,10 @@ export type ActualModeSource = { runId?: string; }; +export type ActualModeRecordingVersion = typeof ACTUAL_MODE_RECORDING_VERSION; + export type ActualModeRecording = { - version: typeof ACTUAL_MODE_RECORDING_VERSION; + version: ActualModeRecordingVersion; exportedAt: string; title: string | null; source: ActualModeSource | null; @@ -47,7 +63,7 @@ export type ActualModeRecording = { }; export type ActualModeReceivedEventsRecording = { - version: typeof ACTUAL_MODE_RECORDING_VERSION; + version: ActualModeRecordingVersion; exportedAt: string; title: string | null; source: ActualModeSource | null; diff --git a/libs/@hashintel/petrinaut-core/src/ai.ts b/libs/@hashintel/petrinaut-core/src/ai.ts index 5b1470a108e..97fc0e1321d 100644 --- a/libs/@hashintel/petrinaut-core/src/ai.ts +++ b/libs/@hashintel/petrinaut-core/src/ai.ts @@ -28,11 +28,13 @@ export { colorSchema, componentInstanceSchema, differentialEquationSchema, + identitySchema, metricSchema, parameterSchema, mutationActionInputSchemas, placeSchema, scenarioSchema, + statusViewSchema, subnetSchema, transitionSchema, } from "./action-schemas"; @@ -107,6 +109,7 @@ export const petrinautDocNames = [ "simulation-panels", "actual-mode", "preview", + "status-views", "ai-assistant", "visual-settings", "code-editor", @@ -137,6 +140,8 @@ export const petrinautDocSummaries: Record = { "Actual mode: host-provided live execution view, Brunch stream URL route, read-only extension-free net, current limits.", preview: "Compact read-only PetrinautPreview for host-controlled embeds: shared SDCPN canvas, pan/zoom/fit/minimap, selection and responsive inspector, root/subnet navigation, URL-state ownership, omitted editing and management UI, and host-owned iframe security.", + "status-views": + "Status views: identities and key dimensions, place-mapped labels with token conditions and the exit label, canvas badges, the Kanban board view, and derived time-in-status.", "ai-assistant": "In-app AI assistant: opening the panel, one text and Voice mode transcript/composer, waveform start, inline Voice state and provenance, typed handoff, consent/recovery, prompt chips, tool cards, read-only/simulate-mode rules, host configuration.", "code-editor": diff --git a/libs/@hashintel/petrinaut-core/src/examples/examples.test.ts b/libs/@hashintel/petrinaut-core/src/examples/examples.test.ts index 9382167c005..c85179922ab 100644 --- a/libs/@hashintel/petrinaut-core/src/examples/examples.test.ts +++ b/libs/@hashintel/petrinaut-core/src/examples/examples.test.ts @@ -13,6 +13,7 @@ import { supplyChainProfit, supplyChainWithDisruption, vaccinationCampaign, + ticketProcessingSDCPN, } from "./index"; const EXAMPLES = [ @@ -25,6 +26,7 @@ const EXAMPLES = [ supplyChainProfit, supplyChainWithDisruption, vaccinationCampaign, + ticketProcessingSDCPN, ]; describe.each(EXAMPLES.map((example) => [example.title, example] as const))( diff --git a/libs/@hashintel/petrinaut-core/src/examples/index.ts b/libs/@hashintel/petrinaut-core/src/examples/index.ts index 4fe133bc8e8..1a45a312876 100644 --- a/libs/@hashintel/petrinaut-core/src/examples/index.ts +++ b/libs/@hashintel/petrinaut-core/src/examples/index.ts @@ -12,3 +12,4 @@ export { dronePatrol } from "./drone-patrol"; export { supplyChainWithDisruption } from "./supply-chain-with-disruption"; export { supplyChainProfit } from "./supply-chain-profit"; export { vaccinationCampaign } from "./vaccination-campaign"; +export { ticketProcessingSDCPN } from "./ticket-processing"; diff --git a/libs/@hashintel/petrinaut-core/src/examples/ticket-processing.ts b/libs/@hashintel/petrinaut-core/src/examples/ticket-processing.ts new file mode 100644 index 00000000000..8b556e09651 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/examples/ticket-processing.ts @@ -0,0 +1,417 @@ +import type { SDCPN } from "../types/sdcpn"; + +/** + * Ticket ids the "Steady intake" scenario seeds into the Backlog place, one + * request per id. "Create Ticket" turns each request into a ticket carrying + * that id, so every ticket in a run has a distinct, human-readable key. + */ +const BACKLOG_TICKET_IDS = Array.from( + { length: 40 }, + (_, index) => `FE-${1045 + index * 13}`, +); + +/** + * Ticket workflow demonstrating identities and status views. + * + * Ticket requests wait in a seeded Backlog and are opened stochastically, + * then flow Todo → In Progress → In Review → Done, with a Blocked side-track + * and a review loop that sends work back to In Progress, so one ticket can + * enter a status several times. Every ticket carries a `ticket_id` key + * element referencing the Ticket identity; the requests carry the same id + * without the identity, so the board only tracks opened tickets. The + * "Ticket status" view maps each place to a label in Kanban column order. + * Done is an explicit sink place, so completion is an ordinary place label; + * archiving consumes the token outright, which the view's exit label + * ("Archived") captures. + */ +export const ticketProcessingSDCPN: { + title: string; + petriNetDefinition: SDCPN; +} = { + title: "Ticket Processing", + petriNetDefinition: { + places: [ + { + id: "place__backlog", + name: "Backlog", + colorId: "type__ticket-request", + dynamicsEnabled: false, + differentialEquationId: null, + x: -360, + y: 0, + }, + { + id: "place__todo", + name: "Todo", + colorId: "type__ticket", + dynamicsEnabled: false, + differentialEquationId: null, + x: 180, + y: 0, + }, + { + id: "place__in-progress", + name: "InProgress", + colorId: "type__ticket", + dynamicsEnabled: false, + differentialEquationId: null, + x: 480, + y: 0, + }, + { + id: "place__in-review", + name: "InReview", + colorId: "type__ticket", + dynamicsEnabled: false, + differentialEquationId: null, + x: 780, + y: 0, + }, + { + id: "place__blocked", + name: "Blocked", + colorId: "type__ticket", + dynamicsEnabled: false, + differentialEquationId: null, + x: 480, + y: 240, + }, + { + id: "place__done", + name: "Done", + colorId: "type__ticket", + dynamicsEnabled: false, + differentialEquationId: null, + x: 1080, + y: 0, + }, + ], + transitions: [ + { + id: "transition__create-ticket", + name: "Create Ticket", + inputArcs: [{ placeId: "place__backlog", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "place__todo", weight: 1 }], + lambdaType: "stochastic", + lambdaCode: `// Ticket arrivals: expected new tickets per simulation second while the +// Backlog still holds requests. +return parameters.ticket_arrival_rate;`, + transitionKernelCode: `// Open one Ticket from the next request. The request's ticket_id becomes the +// ticket's key value, which identifies the ticket everywhere else. +const request = input.Backlog[0]; +const rawPriority = Distribution.Gaussian(0.5, 0.2); +return { + Todo: [ + { + ticket_id: request.ticket_id, + priority: rawPriority.map((p) => Math.max(0.05, Math.min(1, p))), + }, + ], +};`, + x: -60, + y: 0, + }, + { + id: "transition__start-work", + name: "Start Work", + inputArcs: [{ placeId: "place__todo", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "place__in-progress", weight: 1 }], + lambdaType: "stochastic", + lambdaCode: `// Higher-priority tickets are picked up sooner. +const ticket = input.Todo[0]; +return parameters.start_rate * (0.5 + ticket.priority);`, + transitionKernelCode: `// Copy the key element so the ticket keeps its identity across places. +const ticket = input.Todo[0]; +return { + InProgress: [{ ticket_id: ticket.ticket_id, priority: ticket.priority }], +};`, + x: 330, + y: 0, + }, + { + id: "transition__send-to-review", + name: "Send To Review", + inputArcs: [ + { placeId: "place__in-progress", weight: 1, type: "standard" }, + ], + outputArcs: [{ placeId: "place__in-review", weight: 1 }], + lambdaType: "stochastic", + lambdaCode: `return parameters.review_rate;`, + transitionKernelCode: `const ticket = input.InProgress[0]; +return { + InReview: [{ ticket_id: ticket.ticket_id, priority: ticket.priority }], +};`, + x: 630, + y: 0, + }, + { + id: "transition__request-changes", + name: "Request Changes", + inputArcs: [ + { placeId: "place__in-review", weight: 1, type: "standard" }, + ], + outputArcs: [{ placeId: "place__in-progress", weight: 1 }], + lambdaType: "stochastic", + lambdaCode: `// Review loop: sends the ticket back, so it re-enters In Progress and its +// time-in-status becomes multi-interval. +return parameters.rework_rate;`, + transitionKernelCode: `const ticket = input.InReview[0]; +return { + InProgress: [{ ticket_id: ticket.ticket_id, priority: ticket.priority }], +};`, + x: 630, + y: -160, + }, + { + id: "transition__get-blocked", + name: "Get Blocked", + inputArcs: [ + { placeId: "place__in-progress", weight: 1, type: "standard" }, + ], + outputArcs: [{ placeId: "place__blocked", weight: 1 }], + lambdaType: "stochastic", + lambdaCode: `return parameters.block_rate;`, + transitionKernelCode: `const ticket = input.InProgress[0]; +return { + Blocked: [{ ticket_id: ticket.ticket_id, priority: ticket.priority }], +};`, + x: 330, + y: 240, + }, + { + id: "transition__unblock", + name: "Unblock", + inputArcs: [{ placeId: "place__blocked", weight: 1, type: "standard" }], + outputArcs: [{ placeId: "place__in-progress", weight: 1 }], + lambdaType: "stochastic", + lambdaCode: `return parameters.unblock_rate;`, + transitionKernelCode: `const ticket = input.Blocked[0]; +return { + InProgress: [{ ticket_id: ticket.ticket_id, priority: ticket.priority }], +};`, + x: 630, + y: 240, + }, + { + id: "transition__approve", + name: "Approve", + inputArcs: [ + { placeId: "place__in-review", weight: 1, type: "standard" }, + ], + outputArcs: [{ placeId: "place__done", weight: 1 }], + lambdaType: "stochastic", + lambdaCode: `return parameters.approve_rate;`, + transitionKernelCode: `// Done is an explicit sink place, so completed tickets keep a marking-derived +// label rather than needing the exit label. +const ticket = input.InReview[0]; +return { + Done: [{ ticket_id: ticket.ticket_id, priority: ticket.priority }], +};`, + x: 930, + y: 0, + }, + { + id: "transition__archive-ticket", + name: "Archive Ticket", + inputArcs: [{ placeId: "place__done", weight: 1, type: "standard" }], + outputArcs: [], + lambdaType: "stochastic", + lambdaCode: `// Archiving consumes the token outright: the ticket leaves every place of +// the status view, so the view's exit label ("Archived") takes over. +return parameters.archive_rate;`, + transitionKernelCode: "", + x: 1230, + y: 0, + }, + ], + types: [ + { + id: "type__ticket-request", + name: "TicketRequest", + iconSlug: "clipboard", + displayColor: "#94a3b8", + elements: [ + { + elementId: "ticket-request__id", + name: "ticket_id", + type: "string", + }, + ], + }, + { + id: "type__ticket", + name: "Ticket", + iconSlug: "circle", + displayColor: "#2563eb", + elements: [ + { + elementId: "ticket__id", + name: "ticket_id", + type: "string", + identityRef: "identity__ticket", + }, + { + elementId: "ticket__priority", + name: "priority", + type: "real", + }, + ], + }, + ], + differentialEquations: [], + parameters: [ + { + id: "param__ticket_arrival_rate", + name: "Ticket Arrival Rate", + variableName: "ticket_arrival_rate", + type: "real", + defaultValue: "0.4", + }, + { + id: "param__start_rate", + name: "Start Rate", + variableName: "start_rate", + type: "real", + defaultValue: "0.5", + }, + { + id: "param__review_rate", + name: "Review Rate", + variableName: "review_rate", + type: "real", + defaultValue: "0.45", + }, + { + id: "param__rework_rate", + name: "Rework Rate", + variableName: "rework_rate", + type: "real", + defaultValue: "0.15", + }, + { + id: "param__block_rate", + name: "Block Rate", + variableName: "block_rate", + type: "real", + defaultValue: "0.08", + }, + { + id: "param__unblock_rate", + name: "Unblock Rate", + variableName: "unblock_rate", + type: "real", + defaultValue: "0.25", + }, + { + id: "param__approve_rate", + name: "Approve Rate", + variableName: "approve_rate", + type: "real", + defaultValue: "0.35", + }, + { + id: "param__archive_rate", + name: "Archive Rate", + variableName: "archive_rate", + type: "real", + defaultValue: "0.05", + }, + ], + identities: [ + { + id: "identity__ticket", + name: "Ticket", + keyElementTypes: ["string"], + }, + ], + statusViews: [ + { + id: "status-view__ticket", + name: "Ticket status", + description: + "Where each ticket sits in the workflow, with time-in-status derived from the firing log.", + identityRef: "identity__ticket", + labels: [ + { + id: "status-label__todo", + name: "Todo", + displayColor: "#94a3b8", + places: ["place__todo"], + }, + { + id: "status-label__in-progress", + name: "In Progress", + displayColor: "#2563eb", + places: ["place__in-progress"], + }, + { + id: "status-label__in-review", + name: "In Review", + displayColor: "#9333ea", + places: ["place__in-review"], + }, + { + id: "status-label__blocked", + name: "Blocked", + displayColor: "#dc2626", + places: ["place__blocked"], + }, + { + id: "status-label__done", + name: "Done", + displayColor: "#16a34a", + places: ["place__done"], + }, + { + id: "status-label__archived", + name: "Archived", + displayColor: "#64748b", + places: [], + isExit: true, + }, + ], + }, + ], + metrics: [ + { + id: "metric__open_tickets", + name: "Open tickets", + description: "Tickets anywhere in the workflow that are not yet done.", + code: `return ( + state.places.Todo.count + + state.places.InProgress.count + + state.places.InReview.count + + state.places.Blocked.count +);`, + }, + { + id: "metric__done_tickets", + name: "Done tickets", + description: "Tickets sitting in the Done sink place.", + code: `return state.places.Done.count;`, + }, + ], + scenarios: [ + { + id: "scenario__steady_intake", + name: "Steady intake", + description: + "Tickets arrive at a steady rate against a modest review loop and occasional blockers.", + scenarioParameters: [ + { type: "real", identifier: "arrival_rate", default: 0.4 }, + { type: "real", identifier: "block_rate", default: 0.08 }, + ], + parameterOverrides: { + param__ticket_arrival_rate: "scenario.arrival_rate", + param__block_rate: "scenario.block_rate", + }, + initialState: { + type: "per_place", + content: { + place__backlog: BACKLOG_TICKET_IDS.map((ticketId) => [ticketId]), + }, + }, + }, + ], + }, +}; diff --git a/libs/@hashintel/petrinaut-core/src/extensions.ts b/libs/@hashintel/petrinaut-core/src/extensions.ts index 4585e3b7e89..0fb3222360a 100644 --- a/libs/@hashintel/petrinaut-core/src/extensions.ts +++ b/libs/@hashintel/petrinaut-core/src/extensions.ts @@ -507,6 +507,23 @@ export const sanitizeSDCPNForExtensions = ( next.metrics = sdcpn.metrics.map((metric) => ({ ...metric })); } + if (sdcpn.identities) { + next.identities = sdcpn.identities.map((identity) => ({ + ...identity, + keyElementTypes: [...identity.keyElementTypes], + })); + } + + if (sdcpn.statusViews) { + next.statusViews = sdcpn.statusViews.map((statusView) => ({ + ...statusView, + labels: statusView.labels.map((label) => ({ + ...label, + places: [...label.places], + })), + })); + } + if (sdcpn.subnets) { next.subnets = sdcpn.subnets.map(cloneSubnet); } diff --git a/libs/@hashintel/petrinaut-core/src/file-format/parse-sdcpn-file.ts b/libs/@hashintel/petrinaut-core/src/file-format/parse-sdcpn-file.ts index 3d318cf98a4..154346e1220 100644 --- a/libs/@hashintel/petrinaut-core/src/file-format/parse-sdcpn-file.ts +++ b/libs/@hashintel/petrinaut-core/src/file-format/parse-sdcpn-file.ts @@ -65,6 +65,7 @@ const fillMissingVisualInfo = (sdcpn: { places: Array<{ x?: number; y?: number }>; transitions: Array<{ x?: number; y?: number }>; types: Array<{ iconSlug?: string; displayColor?: string }>; + statusViews?: Array<{ labels: Array<{ displayColor?: string }> }>; componentInstances?: Array<{ x?: number; y?: number }>; subnets?: Array<{ places: Array<{ x?: number; y?: number }>; @@ -90,6 +91,13 @@ const fillMissingVisualInfo = (sdcpn: { iconSlug: type.iconSlug ?? "circle", displayColor: type.displayColor ?? "#808080", })), + statusViews: (sdcpn.statusViews ?? []).map((statusView) => ({ + ...statusView, + labels: statusView.labels.map((label) => ({ + ...label, + displayColor: label.displayColor ?? "#808080", + })), + })), componentInstances: (sdcpn.componentInstances ?? []).map((instance) => ({ ...instance, x: instance.x ?? 0, diff --git a/libs/@hashintel/petrinaut-core/src/file-format/remove-visual-info.ts b/libs/@hashintel/petrinaut-core/src/file-format/remove-visual-info.ts index cbb22b70cd2..2d8df3c24ac 100644 --- a/libs/@hashintel/petrinaut-core/src/file-format/remove-visual-info.ts +++ b/libs/@hashintel/petrinaut-core/src/file-format/remove-visual-info.ts @@ -3,6 +3,8 @@ import type { ComponentInstance, Place, SDCPN, + StatusLabel, + StatusView, Subnet, Transition, } from "../types/sdcpn"; @@ -18,8 +20,15 @@ type NetWithoutVisualInfo = Omit< }; type SubnetWithoutVisualInfo = NetWithoutVisualInfo; -type SDCPNWithoutVisualInfo = NetWithoutVisualInfo & { +type StatusViewWithoutVisualInfo = Omit & { + labels: Array>; +}; +type SDCPNWithoutVisualInfo = Omit< + NetWithoutVisualInfo, + "statusViews" +> & { subnets: SubnetWithoutVisualInfo[]; + statusViews?: StatusViewWithoutVisualInfo[]; }; const stripVisualFromNet = ( @@ -47,5 +56,15 @@ export function removeVisualInformation(sdcpn: SDCPN): SDCPNWithoutVisualInfo { return { ...stripVisualFromNet(sdcpn), subnets: (sdcpn.subnets ?? []).map((subnet) => stripVisualFromNet(subnet)), + ...(sdcpn.statusViews + ? { + statusViews: sdcpn.statusViews.map((statusView) => ({ + ...statusView, + labels: statusView.labels.map( + ({ displayColor: _displayColor, ...label }) => label, + ), + })), + } + : {}), }; } diff --git a/libs/@hashintel/petrinaut-core/src/file-format/serialize-sdcpn.test.ts b/libs/@hashintel/petrinaut-core/src/file-format/serialize-sdcpn.test.ts index 2ec279c327f..c565f68bf74 100644 --- a/libs/@hashintel/petrinaut-core/src/file-format/serialize-sdcpn.test.ts +++ b/libs/@hashintel/petrinaut-core/src/file-format/serialize-sdcpn.test.ts @@ -46,6 +46,52 @@ const sourceDocument = { componentInstances: [ { id: "instance1", name: "Instance 1", subnetId: "subnet1", x: 0, y: 0 }, ], + types: [ + { + id: "color1", + name: "Ticket", + iconSlug: "circle", + displayColor: "#1E90FF", + elements: [ + { + elementId: "element1", + name: "ticket_id", + type: "string", + identityRef: "identity-ticket", + }, + ], + }, + ], + identities: [ + { + id: "identity-ticket", + name: "Ticket", + keyElementTypes: ["string"], + }, + ], + statusViews: [ + { + id: "view1", + name: "Ticket status", + identityRef: "identity-ticket", + labels: [ + { + id: "label1", + name: "In Progress", + displayColor: "#1E90FF", + places: ["p1", "instance1::p1"], + tokenCondition: "attempts === 0", + }, + { + id: "label2", + name: "Gone", + displayColor: "#333333", + places: [], + isExit: true, + }, + ], + }, + ], }; const parseFixture = () => { @@ -111,6 +157,29 @@ describe("serializeSDCPN", () => { expect(reimported.hadMissingPositions).toBe(true); }); + it("strips status label colours with removeVisualInfo and defaults them on import", () => { + const sdcpn = parseFixture(); + + const text = serializeSDCPN({ + petriNetDefinition: sdcpn, + title: "Test Net", + removeVisualInfo: true, + format: "json", + }); + + const document = JSON.parse(text) as { + statusViews: { labels: { displayColor?: string }[] }[]; + }; + expect(document.statusViews[0]!.labels[0]!.displayColor).toBeUndefined(); + + const reimported = parseSDCPNDocument(text); + expect(reimported.ok).toBe(true); + if (!reimported.ok) return; + expect(reimported.sdcpn.statusViews![0]!.labels[0]!.displayColor).toBe( + "#808080", + ); + }); + it("writes format metadata first, then the sections in dependency order", () => { const sdcpn = parseFixture(); @@ -136,6 +205,7 @@ describe("serializeSDCPN", () => { "description", "metadata", "parameters", + "identities", "types", "differentialEquations", "subnets", @@ -144,6 +214,7 @@ describe("serializeSDCPN", () => { "transitions", "metrics", "scenarios", + "statusViews", ]); expect(Object.keys(document.subnets![0]!)).toEqual([ "id", diff --git a/libs/@hashintel/petrinaut-core/src/file-format/serialize-sdcpn.ts b/libs/@hashintel/petrinaut-core/src/file-format/serialize-sdcpn.ts index a3fac5c890b..fad08166eda 100644 --- a/libs/@hashintel/petrinaut-core/src/file-format/serialize-sdcpn.ts +++ b/libs/@hashintel/petrinaut-core/src/file-format/serialize-sdcpn.ts @@ -20,6 +20,7 @@ const DOCUMENT_KEY_ORDER = [ "description", "metadata", "parameters", + "identities", "types", "differentialEquations", "subnets", @@ -28,6 +29,7 @@ const DOCUMENT_KEY_ORDER = [ "transitions", "metrics", "scenarios", + "statusViews", ] as const satisfies readonly SDCPNDocumentKey[]; const SUBNET_KEY_ORDER = [ diff --git a/libs/@hashintel/petrinaut-core/src/file-format/types.ts b/libs/@hashintel/petrinaut-core/src/file-format/types.ts index 50dd63d746f..1dbc7344714 100644 --- a/libs/@hashintel/petrinaut-core/src/file-format/types.ts +++ b/libs/@hashintel/petrinaut-core/src/file-format/types.ts @@ -7,6 +7,7 @@ import { componentInstanceSchema as currentComponentInstanceSchema, descriptionSchema, differentialEquationSchema as currentDifferentialEquationSchema, + identitySchema as currentIdentitySchema, inputArcSchema as currentInputArcSchema, metadataSchema, outputArcSchema as currentOutputArcSchema, @@ -19,6 +20,11 @@ import { scenarioParameterSchema as currentScenarioParameterSchema, scenarioSchema as currentScenarioSchema, } from "../schemas/scenario-schema"; +import { + assertStatusViewLabelInvariants, + statusLabelSchema as currentStatusLabelSchema, + statusViewObjectSchema as currentStatusViewObjectSchema, +} from "../schemas/status-view-schema"; export const SDCPN_FILE_FORMAT_VERSION = 1; @@ -137,6 +143,29 @@ const metricSchema = z.object({ code: z.string().default(""), }); +const identitySchema = z.object({ + ...currentIdentitySchema.shape, + id: z.string(), + name: z.string(), +}); + +const statusLabelSchema = z.object({ + ...currentStatusLabelSchema.shape, + id: z.string(), + name: z.string(), + displayColor: z.string().optional(), +}); + +const statusViewSchema = z + .object({ + ...currentStatusViewObjectSchema.shape, + id: z.string(), + name: z.string(), + identityRef: z.string(), + labels: z.array(statusLabelSchema).default([]), + }) + .check(assertStatusViewLabelInvariants); + const componentInstanceSchema = z.object({ ...currentComponentInstanceSchema.shape, id: z.string(), @@ -169,6 +198,8 @@ export const sdcpnSchema = z.object({ parameters: z.array(parameterSchema).default([]), scenarios: z.array(scenarioSchema).default([]), metrics: z.array(metricSchema).default([]), + identities: z.array(identitySchema).default([]), + statusViews: z.array(statusViewSchema).default([]), subnets: z.array(subnetSchema).default([]), componentInstances: z.array(componentInstanceSchema).default([]), }); diff --git a/libs/@hashintel/petrinaut-core/src/hir.ts b/libs/@hashintel/petrinaut-core/src/hir.ts index 35904d35f41..32a565e9e87 100644 --- a/libs/@hashintel/petrinaut-core/src/hir.ts +++ b/libs/@hashintel/petrinaut-core/src/hir.ts @@ -26,12 +26,14 @@ export { type HirCompileResult, } from "./hir/compile"; export { + getStatusConditionArtifactKey, hirDistributionRuntime, instantiateHirBufferDynamics, instantiateHirBufferKernel, instantiateHirBufferLambda, instantiateHirMetric, type HirArtifacts, + type HirStatusConditionArtifact, type HirCompiledBufferDynamics, type HirCompiledBufferKernel, type HirCompiledBufferLambda, @@ -121,6 +123,7 @@ export { buildMetricContext, buildScenarioCodeContext, buildScenarioExpressionContext, + buildStatusConditionContext, type HirDynamicsContext, type HirKernelContext, type HirLambdaContext, @@ -132,6 +135,7 @@ export { type HirScenarioExpressionContext, type HirScenarioParameterInfo, type HirScenarioPlaceInfo, + type HirStatusConditionContext, type HirSurfaceContext, type HirTokenElementInfo, } from "./hir/surface-context"; diff --git a/libs/@hashintel/petrinaut-core/src/hir/compile.ts b/libs/@hashintel/petrinaut-core/src/hir/compile.ts index 5d0849935db..527c216dfb0 100644 --- a/libs/@hashintel/petrinaut-core/src/hir/compile.ts +++ b/libs/@hashintel/petrinaut-core/src/hir/compile.ts @@ -30,12 +30,14 @@ import { emitBufferLambdaJs, emitBufferMetricJs, } from "./emit-buffer-js"; +import { getStatusConditionArtifactKey } from "./instantiate"; import { lowerTypeScriptToHir } from "./lower-typescript"; import { buildDynamicsContext, buildKernelContext, buildLambdaContext, buildMetricContext, + buildStatusConditionContext, } from "./surface-context"; import { typecheckHir } from "./typecheck"; @@ -50,7 +52,8 @@ export type HirCompileFailure = { | "differential-equation" | "transition-lambda" | "transition-kernel" - | "metric"; + | "metric" + | "status-label-condition"; diagnostics: HirDiagnostic[]; }; @@ -133,6 +136,7 @@ export function compileHirArtifacts( lambdas: createUserKeyedRecord(), kernels: createUserKeyedRecord(), metrics: createUserKeyedRecord(), + statusConditions: createUserKeyedRecord(), }; const failures: HirCompileFailure[] = []; @@ -307,5 +311,29 @@ export function compileHirArtifacts( }; } + // Status views live on the root net only; each label's token condition is + // a single boolean expression over `token`, evaluated by the interpreter. + for (const statusView of sanitized.statusViews ?? []) { + for (const label of statusView.labels) { + const condition = label.tokenCondition; + if (condition === undefined || condition.trim() === "") { + continue; + } + const context = buildStatusConditionContext(sanitized, label, extensions); + const item = lowerAndCheck(condition, "status-condition", context); + if (!item.ok) { + failures.push({ + itemId: label.id, + itemType: "status-label-condition", + diagnostics: item.diagnostics, + }); + continue; + } + artifacts.statusConditions[ + getStatusConditionArtifactKey(statusView.id, label.id) + ] = { fn: item.fn }; + } + } + return { artifacts, failures }; } diff --git a/libs/@hashintel/petrinaut-core/src/hir/hir-schema.ts b/libs/@hashintel/petrinaut-core/src/hir/hir-schema.ts index 0830d1ecef5..0954158e0bb 100644 --- a/libs/@hashintel/petrinaut-core/src/hir/hir-schema.ts +++ b/libs/@hashintel/petrinaut-core/src/hir/hir-schema.ts @@ -390,6 +390,7 @@ export const hirSurfaceKindSchema = z "metric", "scenario-expression", "scenario-code", + "status-condition", ]) .meta({ id: "HirSurfaceKind" }); diff --git a/libs/@hashintel/petrinaut-core/src/hir/hir.ts b/libs/@hashintel/petrinaut-core/src/hir/hir.ts index 0cd5a52585d..d37f0cd45f5 100644 --- a/libs/@hashintel/petrinaut-core/src/hir/hir.ts +++ b/libs/@hashintel/petrinaut-core/src/hir/hir.ts @@ -57,7 +57,8 @@ export type HirSurfaceKind = | "kernel" | "metric" | "scenario-expression" - | "scenario-code"; + | "scenario-code" + | "status-condition"; /** Scalar and structural types inferred over HIR nodes. */ export type HirType = diff --git a/libs/@hashintel/petrinaut-core/src/hir/instantiate.ts b/libs/@hashintel/petrinaut-core/src/hir/instantiate.ts index 7785a466836..b28c23f33cf 100644 --- a/libs/@hashintel/petrinaut-core/src/hir/instantiate.ts +++ b/libs/@hashintel/petrinaut-core/src/hir/instantiate.ts @@ -133,6 +133,24 @@ export type HirMetricArtifact = { hir?: HirFunction; }; +/** + * A lowered, type-checked status-label token condition. Evaluated by the + * HIR interpreter (like scenario surfaces), with the token bound as `token`, + * rather than emitted as a buffer program. + */ +export type HirStatusConditionArtifact = { + fn: HirFunction; +}; + +/** + * Key for one status label's condition in `HirArtifacts.statusConditions`. + * Label ids are only unique within their view, so the key pairs the two. + */ +export const getStatusConditionArtifactKey = ( + statusViewId: string, + labelId: string, +): string => `${statusViewId}\u0000${labelId}`; + /** * Precompiled HIR programs for one SDCPN, keyed by item id (differential * equation id / transition id / metric id, pre-flattening — the engine @@ -147,6 +165,7 @@ export type HirArtifacts = { lambdas: Record; kernels: Record; metrics: Record; + statusConditions: Record; }; /** diff --git a/libs/@hashintel/petrinaut-core/src/hir/interpret.ts b/libs/@hashintel/petrinaut-core/src/hir/interpret.ts index 37ccfe9822b..81561e3065d 100644 --- a/libs/@hashintel/petrinaut-core/src/hir/interpret.ts +++ b/libs/@hashintel/petrinaut-core/src/hir/interpret.ts @@ -43,6 +43,8 @@ export class HirInterpretError extends Error { type Env = ReadonlyMap; +const EMPTY_LOCALS: Env = new Map(); + const CONSTANT_VALUES = { PI: Math.PI, E: Math.E, @@ -362,12 +364,15 @@ export function interpretHirExpr( * Evaluates a lowered (and, at the caller's responsibility, type-checked) * HIR function with the given ambient bindings. Scenario functions declare * no parameters — `parameters` and `scenario` reads resolve through - * `bindings`. Throws `HirInterpretError` (positioned in the user source) on - * evaluation failure. + * `bindings`. Surfaces with declared parameters (status conditions bind the + * token as `token`) pass them through `locals`; the map is only read, never + * mutated or retained, so a caller may reuse one map across calls. Throws + * `HirInterpretError` (positioned in the user source) on evaluation failure. */ export function interpretHir( fn: HirFunction, bindings: HirInterpretBindings, + locals?: ReadonlyMap, ): HirValue { - return evalExpr(fn.body, new Map(), bindings); + return evalExpr(fn.body, locals ?? EMPTY_LOCALS, bindings); } diff --git a/libs/@hashintel/petrinaut-core/src/hir/lint.ts b/libs/@hashintel/petrinaut-core/src/hir/lint.ts index af0dfaac73a..aca455ca61f 100644 --- a/libs/@hashintel/petrinaut-core/src/hir/lint.ts +++ b/libs/@hashintel/petrinaut-core/src/hir/lint.ts @@ -94,8 +94,10 @@ function canEmitBufferProgram( return emitBufferMetricJs(fn, context) !== null; case "scenario-expression": case "scenario-code": - // Scenario surfaces are interpreted (`interpret.ts`), never emitted as - // buffer programs, so every checked shape can run. + case "status-condition": + // Scenario and status-condition surfaces are interpreted + // (`interpret.ts`), never emitted as buffer programs, so every checked + // shape can run. return true; } } diff --git a/libs/@hashintel/petrinaut-core/src/hir/lower-typescript.ts b/libs/@hashintel/petrinaut-core/src/hir/lower-typescript.ts index ac0118261a0..069920db3ef 100644 --- a/libs/@hashintel/petrinaut-core/src/hir/lower-typescript.ts +++ b/libs/@hashintel/petrinaut-core/src/hir/lower-typescript.ts @@ -84,6 +84,9 @@ const SCENARIO_CODE_SUFFIX = "\n}"; const SCENARIO_EXPRESSION_PREFIX = "() => (\n"; const SCENARIO_EXPRESSION_SUFFIX = "\n)"; +const STATUS_CONDITION_PREFIX = "(token) => (\n"; +const STATUS_CONDITION_SUFFIX = "\n)"; + /** Bare-body surfaces: wrapped in prefix/suffix for parsing, spans shifted * back onto the raw user text afterwards. */ const WRAPPED_SURFACES: Partial< @@ -111,6 +114,11 @@ const WRAPPED_SURFACES: Partial< suffix: SCENARIO_EXPRESSION_SUFFIX, lower: (lowering) => lowering.lowerScenarioModule("scenario-expression"), }, + "status-condition": { + prefix: STATUS_CONDITION_PREFIX, + suffix: STATUS_CONDITION_SUFFIX, + lower: (lowering) => lowering.lowerStatusConditionModule(), + }, }; const DISTRIBUTION_FACTORIES: Record = { @@ -399,6 +407,49 @@ class Lowering { }; } + /** + * Lowers a wrapped status-label condition (see `STATUS_CONDITION_PREFIX`): + * a single boolean expression over `token`, the token being labelled. + */ + lowerStatusConditionModule(): HirFunction { + const fail = (): LowerError => + new LowerError({ + code: "hir:unsupported-syntax", + message: + "Status label conditions must be a single expression over `token`.", + severity: "error", + span: { start: 0, length: Math.max(this.sourceFile.text.length, 1) }, + }); + const [statement, ...rest] = this.sourceFile.statements; + const arrow = + rest.length === 0 && + statement && + ts.isExpressionStatement(statement) && + ts.isArrowFunction(statement.expression) + ? statement.expression + : null; + if (!arrow || ts.isBlock(arrow.body)) { + throw fail(); + } + const scope: LowerScope = { + locals: new Set(["token"]), + distributionLocals: new Set(), + destructuredFields: new Map(), + parameterAliases: new Map(), + parametersName: null, + scenarioAliases: new Map(), + scenarioName: null, + }; + const body = this.lowerExpr(arrow.body, scope); + return { + hirVersion: 1, + surface: "status-condition", + params: [{ name: "token", span: this.spanOf(arrow) }], + body, + span: this.spanOf(arrow), + }; + } + lowerModule(): HirFunction { // Wrapped bare-body code (metric and scenario surfaces, and the bare-body // form of dynamics/lambda/kernel) dispatches in `lowerTypeScriptToHir` diff --git a/libs/@hashintel/petrinaut-core/src/hir/surface-context.ts b/libs/@hashintel/petrinaut-core/src/hir/surface-context.ts index 4f3a4434565..2eada90d985 100644 --- a/libs/@hashintel/petrinaut-core/src/hir/surface-context.ts +++ b/libs/@hashintel/petrinaut-core/src/hir/surface-context.ts @@ -30,6 +30,7 @@ import { getTransitionLogicAvailability, type PetrinautExtensionSettings, } from "../extensions"; +import { resolveStatusViewLabelPlace } from "../status-view-scope"; import type { Color, @@ -179,13 +180,25 @@ export type HirScenarioCodeContext = { places: HirScenarioPlaceInfo[]; }; +/** + * Context for one status label's token condition: a single boolean + * expression over `token`, the token being labelled. + */ +export type HirStatusConditionContext = { + surface: "status-condition"; + /** Elements of the colours carried by the label's places, merged by name. */ + tokenAttributes: HirTokenElementInfo[]; + expected: "boolean"; +}; + export type HirSurfaceContext = | HirDynamicsContext | HirLambdaContext | HirKernelContext | HirMetricContext | HirScenarioExpressionContext - | HirScenarioCodeContext; + | HirScenarioCodeContext + | HirStatusConditionContext; const SCOPE_SEPARATOR = "::"; @@ -398,6 +411,40 @@ export function buildMetricContext( }; } +/** + * Builds the context for one status label's token condition: the union of + * the attributes of the colours carried by the label's places, merged by + * name (first occurrence wins). Instance-scoped place ids resolve to the + * subnet place they copy. + */ +export function buildStatusConditionContext( + sdcpn: SDCPN, + label: { places: readonly string[] }, + extensions: PetrinautExtensionSettings = DEFAULT_PETRINAUT_EXTENSIONS, +): HirStatusConditionContext { + const colorById = collectColors(sdcpn, extensions); + + const attributesByName = new Map(); + for (const placeId of label.places) { + const place = resolveStatusViewLabelPlace(sdcpn, placeId); + const color = place?.colorId ? colorById.get(place.colorId) : undefined; + for (const element of color?.elements ?? []) { + if (!attributesByName.has(element.name)) { + attributesByName.set(element.name, { + name: element.name, + type: element.type, + }); + } + } + } + + return { + surface: "status-condition", + tokenAttributes: [...attributesByName.values()], + expected: "boolean", + }; +} + function toScenarioParameterInfos( scenarioParameters: readonly ScenarioParameter[], ): HirScenarioParameterInfo[] { diff --git a/libs/@hashintel/petrinaut-core/src/hir/typecheck.ts b/libs/@hashintel/petrinaut-core/src/hir/typecheck.ts index 38726e34a28..c702481685d 100644 --- a/libs/@hashintel/petrinaut-core/src/hir/typecheck.ts +++ b/libs/@hashintel/petrinaut-core/src/hir/typecheck.ts @@ -217,6 +217,8 @@ class Typechecker { // Scenario functions have no declared parameters — `parameters` and // `scenario` are ambient. return HIR_TYPE_UNKNOWN; + case "status-condition": + return tokenRecordType(context.tokenAttributes); case "metric": return { kind: "record", @@ -294,7 +296,11 @@ class Typechecker { case "localRef": return env.get(expr.name) ?? HIR_TYPE_UNKNOWN; case "paramRef": { - const parameter = this.context.parameters.find( + const contextParameters = + this.context.surface === "status-condition" + ? [] + : this.context.parameters; + const parameter = contextParameters.find( (candidate) => candidate.name === expr.name, ); if (!parameter) { @@ -898,6 +904,16 @@ class Typechecker { } return; } + case "status-condition": { + if (!isBoolish(returnType)) { + this.report( + bodySpan, + "hir:status-condition-return", + `Status label conditions must produce a boolean, got ${formatHirType(returnType)}.`, + ); + } + return; + } case "scenario-code": { if (returnType.kind !== "record") { if (returnType.kind !== "unknown") { diff --git a/libs/@hashintel/petrinaut-core/src/identity-key-coherence.ts b/libs/@hashintel/petrinaut-core/src/identity-key-coherence.ts new file mode 100644 index 00000000000..9e11c361547 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/identity-key-coherence.ts @@ -0,0 +1,16 @@ +import type { ColorElementType, Identity } from "./types/sdcpn"; + +/** + * Whether a colour's key elements for `identity` — their types in element + * order — are the identity's `keyElementTypes`. The cross-colour instance key + * is the tuple of those element values, so a colour whose key types differ + * would never correlate with the others. + */ +export const identityKeyTypesMatch = ( + keyTypes: readonly ColorElementType[], + identity: Identity, +): boolean => + keyTypes.length === identity.keyElementTypes.length && + keyTypes.every( + (keyType, index) => keyType === identity.keyElementTypes[index], + ); diff --git a/libs/@hashintel/petrinaut-core/src/index.ts b/libs/@hashintel/petrinaut-core/src/index.ts index 5794c6d02b9..596513e1beb 100644 --- a/libs/@hashintel/petrinaut-core/src/index.ts +++ b/libs/@hashintel/petrinaut-core/src/index.ts @@ -16,21 +16,26 @@ export { actualModeMarkingSchema, actualModeRecordingSchema, actualModeSourceSchema, - actualModeTransitionEffectSchema, + actualModeTokenValuesSchema, actualModeTransitionFiringSchema, applyActualModeTransitionFiring, buildActualModeTimelinePoints, + createActualModeFrameReplay, createActualModeRecording, createActualModeReceivedEventsRecording, createActualModeTimelineFrameReader, + extendActualModeTransitionFiringTimesMs, getActualModeMarkingAtTransitionFiringIndex, getActualModeTransitionFiringTimesMs, parseActualModeRecording, retimeActualModeRecordingForReplay, unavailableActualMode, + validateActualModeInitialState, } from "./actual-mode"; export type { ActualModeContextValue, + ActualModeDefinition, + ActualModeFrameReplay, ActualModeMarking, ActualModeReceivedEvent, ActualModeReceivedEventsRecording, @@ -38,8 +43,8 @@ export type { ActualModeSource, ActualModeTimelinePoint, ActualModeTimelinePointKind, - ActualModeTokenColour, - ActualModeTransitionEffect, + ActualModeTokenRecord, + ActualModeTokenValues, ActualModeTransitionFiring, } from "./actual-mode"; export { @@ -206,6 +211,7 @@ export { differentialEquationSchema, getLatestNetDefinitionToolName, getNetCompilationErrorsToolName, + identitySchema, metricSchema, parameterSchema, petrinautAiCommandTools, @@ -220,6 +226,7 @@ export { scenarioSchema, setNetTitleToolInputSchema, setNetTitleToolName, + statusViewSchema, subnetSchema, transitionSchema, } from "./ai"; @@ -362,7 +369,9 @@ export type { HirCompileResult, HirDiagnostic, HirMetricArtifact, + HirStatusConditionArtifact, } from "./hir"; +export { getStatusConditionArtifactKey } from "./hir/instantiate"; // --- Playback --- export { @@ -424,6 +433,17 @@ export { placeArcEndpoint, } from "./arc-endpoints"; export { GRID_SIZE } from "./grid-size"; +export { + parseScopedId, + SCOPED_ID_SEPARATOR, + type ParsedScopedId, +} from "./scoped-ids"; +export { identityKeyTypesMatch } from "./identity-key-coherence"; +export { + getStatusViewEvaluationScope, + visitComponentInstancePlaces, + type ScopedPlaceVisit, +} from "./status-view-scope"; export { type DefaultParameterValues, deriveDefaultParameterValues, @@ -569,6 +589,23 @@ export { } from "./simulation/authoring/scenario/ad-hoc/scenario-to-ad-hoc-state"; export { adHocScenarioStateSchema } from "./simulation/authoring/scenario/ad-hoc/ad-hoc-state-schema"; export { createHirMetricEvaluator } from "./simulation/frames/hir-metric"; +export { + createStatusViewFrameEvaluator, + type InstanceKey, + type StatusConditionEvaluationError, + type StatusViewInstanceAssignment, +} from "./simulation/frames/hir-status-view"; +export { + createStatusViewTracker, + diffInstanceLabelStates, + summarizeStatusIntervals, + type InstanceLabelChange, + type InstanceLabelState, + type InstanceStatus, + type StatusInterval, + type StatusLabelDwell, + type StatusViewTracker, +} from "./simulation/status-views"; export { coerceTokenAttributeValue, coerceTokenRecord, diff --git a/libs/@hashintel/petrinaut-core/src/optimization/optimization.test.ts b/libs/@hashintel/petrinaut-core/src/optimization/optimization.test.ts index a441e610672..29b86bd5ed4 100644 --- a/libs/@hashintel/petrinaut-core/src/optimization/optimization.test.ts +++ b/libs/@hashintel/petrinaut-core/src/optimization/optimization.test.ts @@ -28,6 +28,8 @@ const definition = { componentInstances: [], scenarios: [scenario], metrics: [{ id: "profit", name: "Profit", code: "return 1;" }], + identities: [], + statusViews: [], }; const validManifest = { diff --git a/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts b/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts index 3abf71c59fa..d4324e5dff5 100644 --- a/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts +++ b/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts @@ -17,6 +17,7 @@ import type { Color, ComponentInstance, DifferentialEquation, + Identity, InputArc, OutputArc, Parameter, @@ -181,6 +182,10 @@ export const colorElementSchema = z description: "Token attribute identifier used DIRECTLY in code. Lambdas, kernels, dynamics, visualizers, and metrics destructure tokens as `{ }`, so this must be a valid JavaScript identifier (e.g. `machine_damage_ratio`, `x`, `velocity`). Spaces, hyphens, and leading digits will break user code that references the attribute; prefer lower_snake_case for consistency with parameter naming.", }), + identityRef: idSchema.optional().meta({ + description: + "ID of the Identity whose key this element carries. Setting it marks the element as a key element — there is no separate key flag — and tokens whose key elements are tuple-equal are the same instance, across colours. Status views track instances of the referenced identity.", + }), type: z.enum(COLOR_ELEMENT_TYPES).meta({ description: "`real` is continuous and may be updated by dynamics. `integer`, `boolean`, `uuid`, and `string` are discrete token attributes updated by transition kernels. `integer` values are stored as Float64 and rounded on read/write: they are exact only within ±2^53 (±9,007,199,254,740,992); values beyond that lose precision silently. `uuid` is a 128-bit RFC 4122 identifier: runtime code sees it as a `bigint`, frame buffers store it as two little-endian 64-bit lanes, and at-rest data (documents, scenarios) uses canonical lowercase strings. `uuid` fields are OPTIONAL in kernel outputs — omitted values are auto-generated deterministically from the seeded simulation RNG — and non-UUID inputs are converted deterministically via UUIDv5. `string` is variable-length text, compared by value: runtime code sees plain JS strings, and each distinct value is stored once per run via interning — frame buffers hold 64-bit pool references. Kernels and markings write `string` values (missing values become the empty string); dynamics can read but never update them.", @@ -190,6 +195,23 @@ export const colorElementSchema = z description: "One typed attribute on a coloured token.", }); +export const identitySchema = z + .strictObject({ + id: idSchema, + name: displayNameSchema.meta({ + description: + "Human-readable identity name — the thing being tracked, e.g. `Ticket` or `Machine`.", + }), + keyElementTypes: z.array(z.enum(COLOR_ELEMENT_TYPES)).min(1).meta({ + description: + "Type(s) of the key element(s), in key order. A single entry is a simple key; two or more entries form a compound key, correlated by tuple equality. Every colour whose elements reference this identity must carry key elements matching these types in this order.", + }), + }) + .meta({ + description: + "A named instance identity. Colour elements reference it via `identityRef` to mark themselves as key elements, so keys correlate across colours without relying on element-name equality; status views name the identity they track.", + }) satisfies z.ZodType; + export const placeSchema = z .strictObject({ id: idSchema, diff --git a/libs/@hashintel/petrinaut-core/src/schemas/status-view-schema.test.ts b/libs/@hashintel/petrinaut-core/src/schemas/status-view-schema.test.ts new file mode 100644 index 00000000000..4b4096af5f2 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/schemas/status-view-schema.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; + +import { statusViewSchema } from "./status-view-schema"; + +import type { StatusView } from "../types/sdcpn"; + +const todoLabel = { + id: "label-todo", + name: "Todo", + displayColor: "#808080", + places: ["place-todo"], +}; + +const inProgressLabel = { + id: "label-in-progress", + name: "In Progress", + displayColor: "#1E90FF", + places: ["place-doing", "instance-1::place-doing"], + tokenCondition: "attempts === 0", +}; + +const exitLabel = { + id: "label-exit", + name: "Gone", + displayColor: "#333333", + places: [], + isExit: true, +}; + +const validStatusView: StatusView = { + id: "view-1", + name: "Ticket status", + identityRef: "identity-ticket", + labels: [todoLabel, inProgressLabel, exitLabel], +}; + +const parseExpectingIssues = (input: unknown) => { + const result = statusViewSchema.safeParse(input); + expect(result.success).toBe(false); + return result.success ? [] : result.error.issues; +}; + +describe("statusViewSchema", () => { + it("accepts a view with place labels, a token condition, and one exit label", () => { + expect(statusViewSchema.parse(validStatusView)).toEqual(validStatusView); + }); + + it("rejects duplicate label ids", () => { + const issues = parseExpectingIssues({ + ...validStatusView, + labels: [todoLabel, { ...inProgressLabel, id: todoLabel.id }], + }); + expect(issues.some((issue) => issue.path.includes("id"))).toBe(true); + }); + + it("rejects duplicate label names", () => { + const issues = parseExpectingIssues({ + ...validStatusView, + labels: [todoLabel, { ...inProgressLabel, name: "Todo" }], + }); + expect(issues.some((issue) => issue.path.includes("name"))).toBe(true); + }); + + it("rejects more than one exit label", () => { + const issues = parseExpectingIssues({ + ...validStatusView, + labels: [exitLabel, { ...exitLabel, id: "label-exit-2", name: "Gone 2" }], + }); + expect( + issues.some((issue) => issue.message.includes("at most one exit label")), + ).toBe(true); + }); + + it("rejects an exit label with places", () => { + const issues = parseExpectingIssues({ + ...validStatusView, + labels: [{ ...exitLabel, places: ["place-done"] }], + }); + expect(issues.some((issue) => issue.path.includes("places"))).toBe(true); + }); +}); diff --git a/libs/@hashintel/petrinaut-core/src/schemas/status-view-schema.ts b/libs/@hashintel/petrinaut-core/src/schemas/status-view-schema.ts new file mode 100644 index 00000000000..2fc84405f76 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/schemas/status-view-schema.ts @@ -0,0 +1,134 @@ +import { z } from "zod"; + +import { displayNameSchema } from "../validation/display-name"; +import { idSchema } from "./entity-schemas"; + +import type { StatusLabel, StatusView } from "../types/sdcpn"; + +export const statusLabelSchema = z + .strictObject({ + id: idSchema, + name: displayNameSchema.meta({ + description: + "Human-readable label name shown on badges and as a Kanban column title (e.g. `In Progress`, `Blocked`).", + }), + displayColor: z.string().min(1).meta({ + description: + 'CSS colour string for the label\'s badge, tint, and Kanban column, e.g. `"#1E90FF"`.', + }), + places: z.array(idSchema).meta({ + description: + "IDs of the places whose tokens carry this label. Several places may map to one label. A componentInstance's copy of a subnet place is addressed by its scoped id `instanceId::placeId` (nested instances give `outer::inner::placeId`). MUST be empty when `isExit` is true.", + }), + tokenCondition: z.string().optional().meta({ + description: + "Optional boolean expression over the token's attributes (e.g. `attempts > 0`). The label applies only while the token is in the label's places AND this expression holds. Omit to match every token in the places.", + }), + isExit: z.boolean().optional().meta({ + description: + "Marks the view's exit label: it is assigned to a tracked instance whose token has left every place of the view's labels (e.g. consumed by a final transition with no sink place). At most one label per view may set this, and an exit label has no `places`. Prefer explicit sink places for distinct terminal states such as Done vs Failed.", + }), + }) + .meta({ + description: "One named status within a status view.", + }) satisfies z.ZodType; + +/** + * The `statusViewSchema` shape without the whole-view label invariants, for + * deriving partial-update schemas. Parse full views with `statusViewSchema`. + */ +export const statusViewObjectSchema = z.strictObject({ + id: idSchema, + name: displayNameSchema.meta({ + description: "Human-readable status view name (e.g. `Ticket status`).", + }), + description: z.string().optional().meta({ + description: "Optional status view summary shown to users.", + }), + identityRef: idSchema.meta({ + description: + "ID of the Identity this view tracks. Tokens participate when their colour has an element referencing the same identity, and instances are keyed by that element's value.", + }), + labels: z.array(statusLabelSchema).meta({ + description: + "The view's labels. Position in this array is the label's order: the Kanban column position and the legend position. Reorder with `moveStatusViewLabel`.", + }), +}); + +type StatusLabelInvariantShape = Pick< + StatusLabel, + "id" | "name" | "places" | "isExit" +>; + +/** + * Whole-view label invariants: unique label ids and names, at most one exit + * label, and no places on the exit label. Applied by `statusViewSchema` and + * by the file-format document schema, so hand-edited documents fail the + * import parse instead of feeding downstream code that assumes them. + */ +export const assertStatusViewLabelInvariants = (ctx: { + value: { labels: StatusLabelInvariantShape[] }; + issues: { + push(issue: { + code: "custom"; + path: (string | number)[]; + message: string; + input: unknown; + }): void; + }; +}) => { + const seenIds = new Set(); + const seenNames = new Set(); + let exitLabelSeen = false; + + for (const [index, label] of ctx.value.labels.entries()) { + if (seenIds.has(label.id)) { + ctx.issues.push({ + code: "custom", + path: ["labels", index, "id"], + message: `Duplicate label id \`${label.id}\`. Label ids must be unique within a status view.`, + input: label.id, + }); + } + seenIds.add(label.id); + + if (seenNames.has(label.name)) { + ctx.issues.push({ + code: "custom", + path: ["labels", index, "name"], + message: `Duplicate label name \`${label.name}\`. Label names must be unique within a status view.`, + input: label.name, + }); + } + seenNames.add(label.name); + + if (label.isExit) { + if (exitLabelSeen) { + ctx.issues.push({ + code: "custom", + path: ["labels", index, "isExit"], + message: "A status view may declare at most one exit label.", + input: label.isExit, + }); + } + exitLabelSeen = true; + + if (label.places.length > 0) { + ctx.issues.push({ + code: "custom", + path: ["labels", index, "places"], + message: + "An exit label has no places — it applies to instances whose token has left the view's places.", + input: label.places, + }); + } + } + } +}; + +export const statusViewSchema = statusViewObjectSchema + .check(assertStatusViewLabelInvariants) + .meta({ + description: + "A user-defined mapping from net state to named statuses for the instances of one identity. Which label a tracked instance carries is derived from where its token sits (and the labels' token conditions) — status is never stored.", + }) satisfies z.ZodType; diff --git a/libs/@hashintel/petrinaut-core/src/scoped-ids.test.ts b/libs/@hashintel/petrinaut-core/src/scoped-ids.test.ts new file mode 100644 index 00000000000..fe2307a4b17 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/scoped-ids.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; + +import { + formatScopedId, + parseScopedId, + SCOPED_ID_SEPARATOR, +} from "./scoped-ids"; + +describe("formatScopedId", () => { + it("returns the entity id unchanged for an empty instance path", () => { + expect(formatScopedId([], "place-1")).toBe("place-1"); + }); + + it("joins the instance path and entity id with the separator", () => { + expect(formatScopedId(["instance-1"], "place-1")).toBe( + "instance-1::place-1", + ); + expect(formatScopedId(["outer", "inner"], "place-1")).toBe( + "outer::inner::place-1", + ); + }); + + it("rejects segments containing the separator", () => { + expect(() => formatScopedId([], `a${SCOPED_ID_SEPARATOR}b`)).toThrow( + /scope separator/, + ); + expect(() => formatScopedId(["a::b"], "place-1")).toThrow( + /scope separator/, + ); + }); +}); + +describe("parseScopedId", () => { + it("parses nested instance paths outermost-first", () => { + expect(parseScopedId("outer::inner::place-1")).toEqual({ + instancePath: ["outer", "inner"], + entityId: "place-1", + }); + }); +}); diff --git a/libs/@hashintel/petrinaut-core/src/scoped-ids.ts b/libs/@hashintel/petrinaut-core/src/scoped-ids.ts new file mode 100644 index 00000000000..64f6861dcc1 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/scoped-ids.ts @@ -0,0 +1,61 @@ +/** + * Scoped entity ids for component instances. + * + * When a net containing component instances is flattened for simulation, + * every subnet entity id is rewritten to `instanceId::entityId`, with nested + * instances giving `outer::inner::entityId`. Simulation frames, firing + * records, and status-view place references all use these ids, so parsing + * and formatting live here rather than being re-derived from the string + * shape at each consumer. + */ + +import type { ID } from "./types/sdcpn"; + +export const SCOPED_ID_SEPARATOR = "::"; + +const assertScopableId = (id: ID): void => { + if (id.includes(SCOPED_ID_SEPARATOR)) { + throw new Error( + `SDCPN IDs used with component instances must not contain the scope separator \`${SCOPED_ID_SEPARATOR}\`: \`${id}\`.`, + ); + } +}; + +/** + * Formats an entity id under a component-instance path. An empty path + * returns the id unchanged (a root-net entity). Throws when any segment + * already contains the separator, since such an id cannot be parsed back. + */ +export const formatScopedId = ( + instancePath: readonly ID[], + entityId: ID, +): ID => { + for (const instanceId of instancePath) { + assertScopableId(instanceId); + } + assertScopableId(entityId); + + return instancePath.length === 0 + ? entityId + : [...instancePath, entityId].join(SCOPED_ID_SEPARATOR); +}; + +export type ParsedScopedId = { + /** + * Component-instance ids from outermost to innermost. Empty for a + * root-net entity id. + */ + instancePath: ID[]; + /** The entity's id within its defining net. */ + entityId: ID; +}; + +/** + * Splits a (possibly) scoped id into its component-instance path and the + * entity id within the defining net. An unscoped id parses to an empty path. + */ +export const parseScopedId = (scopedId: ID): ParsedScopedId => { + const segments = scopedId.split(SCOPED_ID_SEPARATOR); + const entityId = segments[segments.length - 1] ?? scopedId; + return { instancePath: segments.slice(0, -1), entityId }; +}; diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/flatten-component-instances.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/flatten-component-instances.ts index b828c856030..8856f9dc980 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/flatten-component-instances.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/flatten-component-instances.ts @@ -1,4 +1,5 @@ import { getArcEndpoint } from "../../arc-endpoints"; +import { formatScopedId, SCOPED_ID_SEPARATOR } from "../../scoped-ids"; import { createUserKeyedRecord, getOwn } from "../../validation/record-keys"; import type { @@ -31,25 +32,6 @@ export const getArcPlaceNameOverrideKey = ({ placeId: ID; }): string => `${transitionId}\u0000${placeId}`; -const scopeSeparator = "::"; - -const assertScopableId = (id: ID): void => { - if (id.includes(scopeSeparator)) { - throw new Error( - `SDCPN IDs used with component instances must not contain the scope separator \`${scopeSeparator}\`: \`${id}\`.`, - ); - } -}; - -const scopedId = (path: readonly ID[], id: ID): ID => { - for (const part of path) { - assertScopableId(part); - } - assertScopableId(id); - - return path.length === 0 ? id : [...path, id].join(scopeSeparator); -}; - const _codeIdentifier = (value: string): string => { const cleaned = value.replace(/[^A-Za-z0-9_$]/g, "_"); if (/^[A-Za-z_$]/.test(cleaned)) { @@ -64,7 +46,7 @@ const scopedPortPlaceName = ({ }: { instance: ComponentInstance; portName: string; -}): string => `${instance.name}${scopeSeparator}${portName}`; +}): string => `${instance.name}${SCOPED_ID_SEPARATOR}${portName}`; const coerceParameterValue = ( parameter: Parameter, @@ -181,7 +163,7 @@ const resolveComponentPortEndpoint = ({ ); } - const placeId = scopedId([...path, instance.id], endpoint.portPlaceId); + const placeId = formatScopedId([...path, instance.id], endpoint.portPlaceId); ctx.arcPlaceNameOverrides.set( getArcPlaceNameOverrideKey({ transitionId: mappedTransitionId, @@ -290,7 +272,7 @@ const flattenNet = ({ const placeIdMap = new Map(); for (const type of net.types) { - const id = scopedId(path, type.id); + const id = formatScopedId(path, type.id); assertUniqueFlatId({ ctx, kind: "types", id }); typeIdMap.set(type.id, id); ctx.target.types.push({ @@ -301,7 +283,7 @@ const flattenNet = ({ } for (const equation of net.differentialEquations) { - const id = scopedId(path, equation.id); + const id = formatScopedId(path, equation.id); assertUniqueFlatId({ ctx, kind: "differentialEquations", id }); equationIdMap.set(equation.id, id); ctx.target.differentialEquations.push({ @@ -314,13 +296,13 @@ const flattenNet = ({ } for (const parameter of net.parameters) { - const id = scopedId(path, parameter.id); + const id = formatScopedId(path, parameter.id); assertUniqueFlatId({ ctx, kind: "parameters", id }); ctx.target.parameters.push({ ...parameter, id }); } for (const place of net.places) { - const id = scopedId(path, place.id); + const id = formatScopedId(path, place.id); assertUniqueFlatId({ ctx, kind: "places", id }); placeIdMap.set(place.id, id); ctx.placeParameterValues.set(id, parameterValues); @@ -335,7 +317,7 @@ const flattenNet = ({ } for (const transition of net.transitions) { - const id = scopedId(path, transition.id); + const id = formatScopedId(path, transition.id); assertUniqueFlatId({ ctx, kind: "transitions", id }); ctx.transitionParameterValues.set(id, parameterValues); @@ -456,6 +438,8 @@ export const flattenComponentInstancesForSimulation = ({ parameters: [], scenarios: sdcpn.scenarios?.map((scenario) => ({ ...scenario })), metrics: sdcpn.metrics?.map((metric) => ({ ...metric })), + identities: sdcpn.identities?.map((identity) => ({ ...identity })), + statusViews: sdcpn.statusViews?.map((statusView) => ({ ...statusView })), subnets: [], componentInstances: [], }; diff --git a/libs/@hashintel/petrinaut-core/src/simulation/frames/hir-status-view.ts b/libs/@hashintel/petrinaut-core/src/simulation/frames/hir-status-view.ts new file mode 100644 index 00000000000..af1864145df --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/simulation/frames/hir-status-view.ts @@ -0,0 +1,314 @@ +import { walkHir } from "../../hir/hir"; +import { + getStatusConditionArtifactKey, + type HirStatusConditionArtifact, +} from "../../hir/instantiate"; +import { + HirInterpretError, + interpretHir, + type HirValue, +} from "../../hir/interpret"; +import { getOwn } from "../../validation/record-keys"; +import { formatUuid, NIL_UUID } from "../engine/uuid"; + +import type { + Color, + ID, + Place, + StatusView, + TokenAttributeValue, + TokenRecord, +} from "../../types/sdcpn"; +import type { SimulationFrameReader } from "../api"; + +/** + * Canonical string encoding of one instance's key tuple: the at-rest string + * forms of the key element values, joined by a separator no value contains. + */ +export type InstanceKey = string; + +export type StatusViewInstanceAssignment = { + labelId: ID; + /** Key element values in key order, in at-rest string form (raw display). */ + keyValues: string[]; + /** + * The place holding the instance's token, as the label references it — + * scoped (`instanceId::placeId`) for a componentInstance's copies, so + * consumers can attribute the instance to a node on the canvas. + */ + placeId: ID; +}; + +type ColorElement = Color["elements"][number]; + +/** A token-condition evaluation failure, surfaced instead of swallowed. */ +export type StatusConditionEvaluationError = { + statusViewId: ID; + labelId: ID; + message: string; +}; + +const KEY_SEPARATOR = "\u0000"; + +const encodeInstanceKey = (keyValues: readonly string[]): InstanceKey => + keyValues.join(KEY_SEPARATOR); + +const toKeyString = (value: TokenAttributeValue): string => + typeof value === "bigint" ? formatUuid(value) : String(value); + +/** + * Whether a key element value marks the token as untracked: a key that was + * never set coerces to the type default, and treating the nil uuid or an + * empty string as an instance key would silently merge every such token + * into one phantom instance. + */ +const isUnsetKeyValue = ( + element: ColorElement, + value: TokenAttributeValue, +): boolean => + (element.type === "uuid" && value === NIL_UUID) || + (element.type === "string" && value === ""); + +/** + * Runtime token records carry `uuid` values as bigints; the interpreter's + * value space (and the at-rest wire form) uses canonical strings. + */ +const toConditionLocals = (token: TokenRecord): Record => { + const locals: Record = {}; + for (const [attributeName, attributeValue] of Object.entries(token)) { + locals[attributeName] = + typeof attributeValue === "bigint" + ? formatUuid(attributeValue) + : attributeValue; + } + return locals; +}; + +/** + * The token attributes a compiled condition reads (`token.` accesses), + * plus whether `token` is also used outside such an access — the read set is + * then an under-approximation and per-place satisfiability cannot be + * decided statically. + */ +const collectConditionTokenReads = ( + fn: HirStatusConditionArtifact["fn"], +): { attributeNames: Set; tokenEscapes: boolean } => { + const attributeNames = new Set(); + const accessedTokenRefs = new Set(); + walkHir(fn.body, (node) => { + if ( + node.kind === "fieldAccess" && + node.target.kind === "localRef" && + node.target.name === "token" + ) { + attributeNames.add(node.field); + accessedTokenRefs.add(node.target); + } + }); + let tokenEscapes = false; + walkHir(fn.body, (node) => { + if ( + node.kind === "localRef" && + node.name === "token" && + !accessedTokenRefs.has(node) + ) { + tokenEscapes = true; + } + }); + return { attributeNames, tokenEscapes }; +}; + +type LabelPlaceBinding = { + place: Place; + /** The place colour's key elements for the view's identity, in order. */ + keyElements: ColorElement[]; +}; + +type LabelBinding = { + labelId: ID; + conditionFn: HirStatusConditionArtifact["fn"] | null; + /** + * True when the condition's read set could not be decided statically + * (`token` escapes a direct attribute access): an interpretation failure + * then keeps the documented missing-attribute semantics (no match, no + * report) instead of being surfaced as an error. + */ + conditionReadsUndecidable: boolean; + places: LabelPlaceBinding[]; +}; + +const EMPTY_BINDINGS = { parameters: {}, scenario: {} } as const; + +/** + * Binds a status view to a frame source: per frame, produces the current + * label of every tracked instance whose token sits in one of the view's + * places. + * + * `places` and `types` must come from the definition the frames execute — + * the flattened net for simulation (where a componentInstance's copies carry + * scoped `instanceId::placeId` ids, matching the view's place references) or + * the recording's definition for actual mode. + * + * Labels apply in array order and the first match wins per instance. A label + * with a token condition matches only tokens for which the condition holds; + * a condition referencing an attribute the place's colour does not carry + * does not match tokens in that place (cross-colour views merge attributes + * by name at compile time). A label whose declared condition has no compiled + * artifact — not compiled yet, or failed to compile — matches nothing, so a + * broken condition narrows a label rather than widening it to every token. + * A token whose key element holds the type default (nil uuid, empty string) + * is untracked. The exit label is not assigned here — it needs cross-frame + * history, which `createStatusViewTracker` owns. + */ +export function createStatusViewFrameEvaluator(args: { + statusView: StatusView; + places: readonly Place[]; + types: readonly Color[]; + /** Compiled label conditions, from `HirArtifacts.statusConditions`. */ + statusConditions?: Record; + /** Called for each token-condition evaluation failure (see the doc). */ + onConditionError?: (error: StatusConditionEvaluationError) => void; +}): ( + frame: SimulationFrameReader, +) => Map { + const { + statusView, + places, + types, + statusConditions = {}, + onConditionError, + } = args; + const placeById = new Map(places.map((place) => [place.id, place])); + const colorById = new Map(types.map((color) => [color.id, color])); + + const labelBindings: LabelBinding[] = statusView.labels.map((label) => { + const conditionArtifact = getOwn( + statusConditions, + getStatusConditionArtifactKey(statusView.id, label.id), + ); + const declaresCondition = (label.tokenCondition ?? "").trim() !== ""; + const conditionReads = conditionArtifact + ? collectConditionTokenReads(conditionArtifact.fn) + : null; + + const placeBindings: LabelPlaceBinding[] = []; + // A declared condition without a compiled artifact fails closed: the + // label binds no places, so it matches nothing until compilation lands. + if (!declaresCondition || conditionArtifact) { + for (const placeId of label.places) { + const place = placeById.get(placeId); + const color = place?.colorId ? colorById.get(place.colorId) : undefined; + if (!place || !color) { + continue; + } + const keyElements = color.elements.filter( + (element) => element.identityRef === statusView.identityRef, + ); + if (keyElements.length === 0) { + // The place's colour carries no key for this identity, so its + // tokens name no instance. + continue; + } + if (conditionReads && !conditionReads.tokenEscapes) { + const elementNames = new Set( + color.elements.map((element) => element.name), + ); + const readsSatisfiable = [...conditionReads.attributeNames].every( + (attributeName) => elementNames.has(attributeName), + ); + if (!readsSatisfiable) { + // The condition reads an attribute this colour does not carry: + // its tokens never match this label. + continue; + } + } + placeBindings.push({ place, keyElements }); + } + } + return { + labelId: label.id, + conditionFn: conditionArtifact?.fn ?? null, + conditionReadsUndecidable: conditionReads?.tokenEscapes ?? false, + places: placeBindings, + }; + }); + + // Reused across tokens: `interpretHir` reads the locals map, never + // mutates or retains it. + const conditionLocals = new Map(); + + return (frame) => { + const assignments = new Map(); + // A place can be listed by several labels; decode its tokens once. + const tokensByPlaceId = new Map(); + const getPlaceTokens = (place: Place): readonly TokenRecord[] => { + let tokens = tokensByPlaceId.get(place.id); + if (!tokens) { + tokens = frame.getPlaceTokens(place); + tokensByPlaceId.set(place.id, tokens); + } + return tokens; + }; + + for (const labelBinding of labelBindings) { + for (const { place, keyElements } of labelBinding.places) { + for (const token of getPlaceTokens(place)) { + let keyValues: string[] | null = []; + for (const element of keyElements) { + const value = getOwn(token, element.name); + if (value === undefined || isUnsetKeyValue(element, value)) { + keyValues = null; + break; + } + keyValues.push(toKeyString(value)); + } + if (keyValues === null) { + continue; + } + const key = encodeInstanceKey(keyValues); + if (assignments.has(key)) { + continue; + } + + if (labelBinding.conditionFn) { + let holds: HirValue; + try { + conditionLocals.set( + "token", + toConditionLocals(token) as HirValue, + ); + holds = interpretHir( + labelBinding.conditionFn, + EMPTY_BINDINGS, + conditionLocals, + ); + } catch (error) { + if (error instanceof HirInterpretError) { + if (!labelBinding.conditionReadsUndecidable) { + onConditionError?.({ + statusViewId: statusView.id, + labelId: labelBinding.labelId, + message: error.message, + }); + } + continue; + } + throw error; + } + if (holds !== true) { + continue; + } + } + + assignments.set(key, { + labelId: labelBinding.labelId, + keyValues, + placeId: place.id, + }); + } + } + } + + return assignments; + }; +} diff --git a/libs/@hashintel/petrinaut-core/src/simulation/status-views.test.ts b/libs/@hashintel/petrinaut-core/src/simulation/status-views.test.ts new file mode 100644 index 00000000000..aade44ed2a8 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/simulation/status-views.test.ts @@ -0,0 +1,636 @@ +import { describe, expect, it } from "vitest"; + +import { + createActualModeTimelineFrameReader, + getActualModeTransitionFiringTimesMs, +} from "../actual-mode"; +import { compileHirArtifacts } from "../hir/compile"; +import { createStatusViewFrameEvaluator } from "./frames/hir-status-view"; +import { + createStatusViewTracker, + diffInstanceLabelStates, + summarizeStatusIntervals, +} from "./status-views"; + +import type { ActualModeTransitionFiring } from "../actual-mode"; +import type { + Color, + Place, + SDCPN, + StatusView, + TokenRecord, +} from "../types/sdcpn"; +import type { SimulationFrameReader } from "./api"; + +const ticketColor: Color = { + id: "type-ticket", + name: "Ticket", + iconSlug: "circle", + displayColor: "#0000FF", + elements: [ + { + elementId: "ticket-id", + name: "ticket_id", + type: "string", + identityRef: "identity-ticket", + }, + { elementId: "attempts", name: "attempts", type: "integer" }, + ], +}; + +const makePlace = (id: string, name: string): Place => ({ + id, + name, + colorId: "type-ticket", + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, +}); + +const places: Place[] = [ + makePlace("todo", "Todo"), + makePlace("doing", "Doing"), + makePlace("done", "Done"), +]; + +const statusView: StatusView = { + id: "view-1", + name: "Ticket status", + identityRef: "identity-ticket", + labels: [ + { + id: "label-retrying", + name: "Retrying", + displayColor: "#f59e0b", + places: ["doing"], + tokenCondition: "token.attempts > 0", + }, + { + id: "label-doing", + name: "Doing", + displayColor: "#2563eb", + places: ["doing"], + }, + { + id: "label-todo", + name: "Todo", + displayColor: "#94a3b8", + places: ["todo"], + }, + { + id: "label-done", + name: "Done", + displayColor: "#16a34a", + places: ["done"], + }, + { + id: "label-gone", + name: "Gone", + displayColor: "#64748b", + places: [], + isExit: true, + }, + ], +}; + +const sdcpnWithView: SDCPN = { + places, + transitions: [], + types: [ticketColor], + differentialEquations: [], + parameters: [], + identities: [ + { id: "identity-ticket", name: "Ticket", keyElementTypes: ["string"] }, + ], + statusViews: [statusView], +}; + +const makeFrame = ( + number: number, + timeSeconds: number, + tokensByPlaceId: Record, +): SimulationFrameReader => ({ + number, + time: timeSeconds, + getPlaceTokenCount: (placeId) => tokensByPlaceId[placeId]?.length ?? 0, + getPlaceTokens: (place) => tokensByPlaceId[place.id] ?? [], + getTransitionState: () => null, + toFrameState: () => ({ number, places: {} }), +}); + +const compileStatusConditions = () => { + const { artifacts, failures } = compileHirArtifacts(sdcpnWithView); + expect(failures).toEqual([]); + return artifacts.statusConditions; +}; + +describe("status view derivation", () => { + it("compiles label token conditions and reports bad ones", () => { + const statusConditions = compileStatusConditions(); + expect(Object.keys(statusConditions)).toHaveLength(1); + + const broken = compileHirArtifacts({ + ...sdcpnWithView, + statusViews: [ + { + ...statusView, + labels: [ + { + ...statusView.labels[0]!, + tokenCondition: "token.attempts +", + }, + ], + }, + ], + }); + expect(broken.failures).toHaveLength(1); + expect(broken.failures[0]).toMatchObject({ + itemId: "label-retrying", + itemType: "status-label-condition", + }); + }); + + it("assigns labels by array order with token conditions deciding ties", () => { + const evaluate = createStatusViewFrameEvaluator({ + statusView, + places, + types: [ticketColor], + statusConditions: compileStatusConditions(), + }); + + const assignments = evaluate( + makeFrame(0, 0, { + todo: [{ ticket_id: "a", attempts: 0 }], + doing: [ + { ticket_id: "b", attempts: 0 }, + { ticket_id: "c", attempts: 2 }, + ], + }), + ); + + expect(assignments.get("a")).toEqual({ + labelId: "label-todo", + keyValues: ["a"], + placeId: "todo", + }); + expect(assignments.get("b")).toEqual({ + labelId: "label-doing", + keyValues: ["b"], + placeId: "doing", + }); + expect(assignments.get("c")).toEqual({ + labelId: "label-retrying", + keyValues: ["c"], + placeId: "doing", + }); + }); + + it("collapses tokens sharing one identity key into a single assignment", () => { + const evaluate = createStatusViewFrameEvaluator({ + statusView, + places, + types: [ticketColor], + statusConditions: compileStatusConditions(), + }); + + const samePlace = evaluate( + makeFrame(0, 0, { + doing: [ + { ticket_id: "a", attempts: 0 }, + { ticket_id: "a", attempts: 2 }, + ], + }), + ); + expect([...samePlace.keys()]).toEqual(["a"]); + expect(samePlace.get("a")?.labelId).toBe("label-retrying"); + + const acrossLabels = evaluate( + makeFrame(0, 0, { + todo: [{ ticket_id: "a", attempts: 0 }], + done: [{ ticket_id: "a", attempts: 0 }], + }), + ); + expect([...acrossLabels.keys()]).toEqual(["a"]); + expect(acrossLabels.get("a")?.labelId, "label array order decides").toBe( + "label-todo", + ); + }); + + it("skips tokens with missing or unset key values and colours without key elements", () => { + const noteColor: Color = { + id: "type-note", + name: "Note", + iconSlug: "circle", + displayColor: "#00FF00", + elements: [{ elementId: "note-text", name: "text", type: "string" }], + }; + const notesPlace: Place = { + ...makePlace("notes", "Notes"), + colorId: "type-note", + }; + const viewWithNotes: StatusView = { + ...statusView, + labels: [ + ...statusView.labels.slice(0, 4), + { + id: "label-notes", + name: "Notes", + displayColor: "#0ea5e9", + places: ["notes"], + }, + ], + }; + const evaluate = createStatusViewFrameEvaluator({ + statusView: viewWithNotes, + places: [...places, notesPlace], + types: [ticketColor, noteColor], + statusConditions: compileStatusConditions(), + }); + + const assignments = evaluate( + makeFrame(0, 0, { + // Missing key attribute, and empty-string (type default) key: both + // untracked rather than merged into a phantom "" instance. + todo: [{ attempts: 0 }, { ticket_id: "", attempts: 0 }], + // The Note colour carries no key element for the ticket identity. + notes: [{ text: "unkeyed" }], + }), + ); + + expect(assignments.size).toBe(0); + }); + + it("tracks one instance across colours and scopes conditions to colours carrying the read attributes", () => { + const machineColor: Color = { + id: "type-machine", + name: "Machine", + iconSlug: "circle", + displayColor: "#FF0000", + elements: [ + { + elementId: "machine-id", + name: "machine_id", + type: "string", + identityRef: "identity-machine", + }, + { elementId: "damage", name: "damage", type: "real" }, + ], + }; + const producingColor: Color = { + id: "type-producing", + name: "MachineProducing", + iconSlug: "circle", + displayColor: "#AA0000", + elements: [ + { + elementId: "producing-machine-id", + name: "machine_id", + type: "string", + identityRef: "identity-machine", + }, + ], + }; + const machinePlaces: Place[] = [ + { ...makePlace("idle", "Idle"), colorId: "type-machine" }, + { ...makePlace("producing", "Producing"), colorId: "type-producing" }, + ]; + const machineView: StatusView = { + id: "view-machines", + name: "Machine status", + identityRef: "identity-machine", + labels: [ + { + id: "label-worn", + name: "Worn", + displayColor: "#f97316", + places: ["idle", "producing"], + tokenCondition: "token.damage > 0.5", + }, + { + id: "label-active", + name: "Active", + displayColor: "#22c55e", + places: ["idle", "producing"], + }, + ], + }; + const machineSdcpn: SDCPN = { + places: machinePlaces, + transitions: [], + types: [machineColor, producingColor], + differentialEquations: [], + parameters: [], + identities: [ + { + id: "identity-machine", + name: "Machine", + keyElementTypes: ["string"], + }, + ], + statusViews: [machineView], + }; + const { artifacts, failures } = compileHirArtifacts(machineSdcpn); + expect(failures).toEqual([]); + + const conditionErrors: unknown[] = []; + const evaluate = createStatusViewFrameEvaluator({ + statusView: machineView, + places: machinePlaces, + types: [machineColor, producingColor], + statusConditions: artifacts.statusConditions, + onConditionError: (error) => conditionErrors.push(error), + }); + const tracker = createStatusViewTracker({ + statusView: machineView, + evaluateFrame: evaluate, + }); + + tracker.observeFrame( + makeFrame(0, 0, { idle: [{ machine_id: "m1", damage: 0.9 }] }), + ); + // The MachineProducing colour has no `damage`: the Worn condition never + // matches its tokens, without evaluation errors, so the instance falls + // to Active — while its key stays the same across the colour change. + tracker.observeFrame( + makeFrame(1, 2, { producing: [{ machine_id: "m1" }] }), + ); + + const statuses = tracker.getInstanceStatuses(); + expect(statuses).toHaveLength(1); + expect(statuses[0]!.keyValues).toEqual(["m1"]); + expect(statuses[0]!.intervals.map((interval) => interval.labelId)).toEqual([ + "label-worn", + "label-active", + ]); + expect(conditionErrors).toEqual([]); + }); + + it("matches nothing for a declared condition without a compiled artifact", () => { + const evaluate = createStatusViewFrameEvaluator({ + statusView, + places, + types: [ticketColor], + statusConditions: {}, + }); + + const assignments = evaluate( + makeFrame(0, 0, { doing: [{ ticket_id: "a", attempts: 5 }] }), + ); + + expect( + assignments.get("a")?.labelId, + "fails closed to the next label", + ).toBe("label-doing"); + }); + + it("reports condition evaluation failures instead of swallowing them", () => { + const conditionErrors: { labelId: string; message: string }[] = []; + const evaluate = createStatusViewFrameEvaluator({ + statusView, + places, + types: [ticketColor], + statusConditions: compileStatusConditions(), + onConditionError: (error) => + conditionErrors.push({ + labelId: error.labelId, + message: error.message, + }), + }); + + // The colour declares `attempts`, so the read is statically satisfiable, + // but this hand-built frame's token record does not carry it: the + // interpreter failure is surfaced and the token falls to the next label. + const assignments = evaluate( + makeFrame(0, 0, { doing: [{ ticket_id: "a" }] }), + ); + + expect(assignments.get("a")?.labelId).toBe("label-doing"); + expect(conditionErrors).toHaveLength(1); + expect(conditionErrors[0]!.labelId).toBe("label-retrying"); + }); + + it("closes the open interval without a new one when no exit label exists", () => { + const viewWithoutExit: StatusView = { + ...statusView, + labels: statusView.labels.filter((label) => !label.isExit), + }; + const evaluate = createStatusViewFrameEvaluator({ + statusView: viewWithoutExit, + places, + types: [ticketColor], + statusConditions: compileStatusConditions(), + }); + const tracker = createStatusViewTracker({ + statusView: viewWithoutExit, + evaluateFrame: evaluate, + }); + + const ticket = { ticket_id: "a", attempts: 0 }; + tracker.observeFrame(makeFrame(0, 0, { todo: [ticket] })); + tracker.observeFrame(makeFrame(1, 2, {})); + + const [afterExit] = tracker.getInstanceStatuses(); + expect(afterExit!.currentLabelId).toBeNull(); + expect(afterExit!.intervals).toEqual([ + { labelId: "label-todo", fromMs: 0, toMs: 2_000 }, + ]); + + tracker.observeFrame(makeFrame(2, 5, { doing: [ticket] })); + + const [afterReentry] = tracker.getInstanceStatuses(); + expect(afterReentry!.currentLabelId).toBe("label-doing"); + expect(afterReentry!.intervals).toEqual([ + { labelId: "label-todo", fromMs: 0, toMs: 2_000 }, + { labelId: "label-doing", fromMs: 5_000, toMs: null }, + ]); + }); + + it("tracks multi-interval dwell across enter/leave/re-enter loops", () => { + const evaluate = createStatusViewFrameEvaluator({ + statusView, + places, + types: [ticketColor], + statusConditions: compileStatusConditions(), + }); + const tracker = createStatusViewTracker({ + statusView, + evaluateFrame: evaluate, + }); + + const ticket = (attempts: number): TokenRecord => ({ + ticket_id: "a", + attempts, + }); + tracker.observeFrame(makeFrame(0, 0, { todo: [ticket(0)] })); + tracker.observeFrame(makeFrame(1, 1, { doing: [ticket(0)] })); + tracker.observeFrame(makeFrame(2, 3, { todo: [ticket(1)] })); + tracker.observeFrame(makeFrame(3, 4, { doing: [ticket(1)] })); + tracker.observeFrame(makeFrame(4, 6, { done: [ticket(1)] })); + + const [instance] = tracker.getInstanceStatuses(); + expect(instance).toBeDefined(); + expect(instance!.currentLabelId).toBe("label-done"); + expect(instance!.keyValues).toEqual(["a"]); + + const nowMs = tracker.lastObservedTimeMs(); + expect( + summarizeStatusIntervals(instance!.intervals, "label-todo", nowMs), + ).toEqual({ totalMs: 2_000, entryCount: 2 }); + expect( + summarizeStatusIntervals(instance!.intervals, "label-doing", nowMs), + ).toEqual({ totalMs: 2_000, entryCount: 1 }); + expect( + summarizeStatusIntervals(instance!.intervals, "label-retrying", nowMs), + ).toEqual({ totalMs: 2_000, entryCount: 1 }); + expect( + summarizeStatusIntervals(instance!.intervals, "label-done", nowMs), + ).toEqual({ totalMs: 0, entryCount: 1 }); + }); + + it("falls back to the exit label when an instance's token leaves the view", () => { + const evaluate = createStatusViewFrameEvaluator({ + statusView, + places, + types: [ticketColor], + statusConditions: compileStatusConditions(), + }); + const tracker = createStatusViewTracker({ + statusView, + evaluateFrame: evaluate, + }); + + tracker.observeFrame( + makeFrame(0, 0, { done: [{ ticket_id: "a", attempts: 0 }] }), + ); + tracker.observeFrame(makeFrame(1, 2, {})); + + const [instance] = tracker.getInstanceStatuses(); + expect(instance!.currentLabelId).toBe("label-gone"); + expect(instance!.intervals).toEqual([ + { labelId: "label-done", fromMs: 0, toMs: 2_000 }, + { labelId: "label-gone", fromMs: 2_000, toMs: null }, + ]); + }); + + it("diffs label states between frames with the dwell in the label left", () => { + const evaluate = createStatusViewFrameEvaluator({ + statusView, + places, + types: [ticketColor], + statusConditions: compileStatusConditions(), + }); + const tracker = createStatusViewTracker({ + statusView, + evaluateFrame: evaluate, + }); + + tracker.observeFrame(makeFrame(0, 0, { todo: [{ ticket_id: "a" }] })); + const initial = tracker.getInstanceLabelStates(); + tracker.observeFrame( + makeFrame(1, 4, { + doing: [{ ticket_id: "a" }], + todo: [{ ticket_id: "b" }], + }), + ); + const next = tracker.getInstanceLabelStates(); + const [keyA, keyB] = [...next.keys()]; + + expect(diffInstanceLabelStates(initial, next, 4_000)).toEqual([ + { + key: keyA, + keyValues: ["a"], + fromLabelId: "label-todo", + toLabelId: "label-doing", + dwellMs: 4_000, + }, + { + key: keyB, + keyValues: ["b"], + fromLabelId: null, + toLabelId: "label-todo", + dwellMs: null, + }, + ]); + expect(diffInstanceLabelStates(next, next, 5_000)).toEqual([]); + }); + + it("derives status from actual-mode frames carrying token values", () => { + const transitionFirings: ActualModeTransitionFiring[] = [ + { + transitionId: "start", + inputTokens: { todo: [{ ticket_id: "a", attempts: 0 }] }, + outputTokens: { doing: [{ ticket_id: "a", attempts: 0 }] }, + ts: "2026-06-05T10:00:00.000Z", + }, + { + transitionId: "finish", + inputTokens: { doing: [{ ticket_id: "a", attempts: 0 }] }, + outputTokens: { done: [{ ticket_id: "a", attempts: 0 }] }, + ts: "2026-06-05T10:00:05.000Z", + }, + ]; + const definition = { + places, + transitions: [], + types: [ticketColor], + }; + const initialState = { todo: [{ ticket_id: "a", attempts: 0 }] }; + const transitionFiringTimesMs = getActualModeTransitionFiringTimesMs( + transitionFirings, + null, + null, + ); + + const evaluate = createStatusViewFrameEvaluator({ + statusView, + places, + types: [ticketColor], + statusConditions: compileStatusConditions(), + }); + const tracker = createStatusViewTracker({ + statusView, + evaluateFrame: evaluate, + }); + + tracker.observeFrame( + createActualModeTimelineFrameReader({ + definition, + initialState, + transitionFirings, + transitionFiringTimesMs, + point: { kind: "initial", timeMs: 0, transitionFiringIndex: null }, + number: 0, + }), + ); + for (const [index, timeMs] of transitionFiringTimesMs.entries()) { + tracker.observeFrame( + createActualModeTimelineFrameReader({ + definition, + initialState, + transitionFirings, + transitionFiringTimesMs, + point: { + kind: "transition_firing", + timeMs, + transitionFiringIndex: index, + }, + number: index + 1, + }), + ); + } + + const [instance] = tracker.getInstanceStatuses(); + expect(instance!.currentLabelId).toBe("label-done"); + expect(instance!.intervals.map((interval) => interval.labelId)).toEqual([ + "label-todo", + "label-doing", + "label-done", + ]); + expect(instance!.intervals[1]).toEqual({ + labelId: "label-doing", + fromMs: 0, + toMs: 5_000, + }); + }); +}); diff --git a/libs/@hashintel/petrinaut-core/src/simulation/status-views.ts b/libs/@hashintel/petrinaut-core/src/simulation/status-views.ts new file mode 100644 index 00000000000..aeb7128cd7a --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/simulation/status-views.ts @@ -0,0 +1,225 @@ +/** + * Derives per-instance status and time-in-state from execution frames. + */ + +import type { ID, StatusLabel, StatusView } from "../types/sdcpn"; +import type { SimulationFrameReader } from "./api"; +import type { + InstanceKey, + StatusViewInstanceAssignment, +} from "./frames/hir-status-view"; + +/** + * One stay of an instance in a label. Loops make time-in-state + * multi-interval — a ticket can enter In Review several times — so displays + * show the current stay and, past the first, the sum plus the entry count. + */ +export type StatusInterval = { + labelId: ID; + fromMs: number; + /** null while the instance is still in the label. */ + toMs: number | null; +}; + +export type InstanceStatus = { + /** Canonical string encoding of the instance's key tuple. */ + key: InstanceKey; + /** Key element values in key order, in at-rest string form (raw display). */ + keyValues: string[]; + /** null when no label matches; the exit label counts as a label. */ + currentLabelId: ID | null; + enteredCurrentAtMs: number; + intervals: StatusInterval[]; +}; + +export type StatusLabelDwell = { + /** Total time the instance has spent in the label across every stay. */ + totalMs: number; + entryCount: number; +}; + +export const getStatusViewExitLabel = ( + statusView: StatusView, +): StatusLabel | undefined => statusView.labels.find((label) => label.isExit); + +/** + * Sums an instance's stays in one label. Open intervals extend to `nowMs`. + */ +export const summarizeStatusIntervals = ( + intervals: readonly StatusInterval[], + labelId: ID, + nowMs: number, +): StatusLabelDwell => { + let totalMs = 0; + let entryCount = 0; + for (const interval of intervals) { + if (interval.labelId !== labelId) { + continue; + } + entryCount += 1; + totalMs += Math.max(0, (interval.toMs ?? nowMs) - interval.fromMs); + } + return { totalMs, entryCount }; +}; + +type TrackedInstance = { + keyValues: string[]; + currentLabelId: ID | null; + enteredCurrentAtMs: number; + intervals: StatusInterval[]; +}; + +export type InstanceLabelState = { + keyValues: readonly string[]; + currentLabelId: ID | null; + enteredCurrentAtMs: number; +}; + +export type InstanceLabelChange = { + key: InstanceKey; + keyValues: readonly string[]; + /** null when the instance is first seen. */ + fromLabelId: ID | null; + /** null when the token left the view and it declares no exit label. */ + toLabelId: ID | null; + /** Time spent in the previous label, in ms; null when there was none. */ + dwellMs: number | null; +}; + +/** + * The instances whose label differs between two tracker snapshots, with the + * time each spent in the label it left. An instance that appears with no + * label is not a change. + */ +export const diffInstanceLabelStates = ( + previous: ReadonlyMap, + next: ReadonlyMap, + nowMs: number, +): InstanceLabelChange[] => { + const changes: InstanceLabelChange[] = []; + for (const [key, labelState] of next) { + const previousState = previous.get(key); + if (previousState?.currentLabelId === labelState.currentLabelId) { + continue; + } + if (!previousState && labelState.currentLabelId === null) { + continue; + } + changes.push({ + key, + keyValues: labelState.keyValues, + fromLabelId: previousState?.currentLabelId ?? null, + toLabelId: labelState.currentLabelId, + dwellMs: previousState ? nowMs - previousState.enteredCurrentAtMs : null, + }); + } + return changes; +}; + +export type StatusViewTracker = { + /** Feed frames in order; each frame's time closes and opens intervals. */ + observeFrame(frame: SimulationFrameReader): void; + /** The time of the last observed frame, in ms (0 before any frame). */ + readonly lastObservedTimeMs: () => number; + getInstanceStatuses(): InstanceStatus[]; + /** + * Current label and entry time per instance, without copying interval + * history — for consumers that diff consecutive frames. + */ + getInstanceLabelStates(): Map; +}; + +/** + * Walks frames and turns per-frame label assignments into per-instance + * interval sets: status-change history and time-in-state, derived — never + * stored. An instance whose token has left every place of the view falls to + * the view's exit label when it declares one, and to no label otherwise. + */ +export function createStatusViewTracker(args: { + statusView: StatusView; + /** Per-frame assignment, from `createStatusViewFrameEvaluator`. */ + evaluateFrame: ( + frame: SimulationFrameReader, + ) => Map; +}): StatusViewTracker { + const { statusView, evaluateFrame } = args; + const exitLabelId = getStatusViewExitLabel(statusView)?.id ?? null; + const instances = new Map(); + // Instances whose current label is a place-bound one: only they can fall + // to the exit label, so the per-frame exit sweep stays proportional to + // live instances rather than to every instance ever seen. + const exitCandidateKeys = new Set(); + let lastTimeMs = 0; + + const transitionTo = ( + key: InstanceKey, + labelId: ID | null, + timeMs: number, + ): void => { + const instance = instances.get(key); + if (!instance || instance.currentLabelId === labelId) { + return; + } + const openInterval = instance.intervals.at(-1); + if (openInterval && openInterval.toMs === null) { + openInterval.toMs = timeMs; + } + instance.currentLabelId = labelId; + instance.enteredCurrentAtMs = timeMs; + if (labelId !== null) { + instance.intervals.push({ labelId, fromMs: timeMs, toMs: null }); + } + if (labelId === null || labelId === exitLabelId) { + exitCandidateKeys.delete(key); + } else { + exitCandidateKeys.add(key); + } + }; + + return { + observeFrame(frame) { + const timeMs = frame.time * 1_000; + lastTimeMs = timeMs; + const assignments = evaluateFrame(frame); + + for (const [key, assignment] of assignments) { + if (!instances.has(key)) { + instances.set(key, { + keyValues: assignment.keyValues, + currentLabelId: null, + enteredCurrentAtMs: timeMs, + intervals: [], + }); + } + transitionTo(key, assignment.labelId, timeMs); + } + + for (const key of exitCandidateKeys) { + if (!assignments.has(key)) { + transitionTo(key, exitLabelId, timeMs); + } + } + }, + lastObservedTimeMs: () => lastTimeMs, + getInstanceLabelStates() { + const labelStates = new Map(); + for (const [key, instance] of instances) { + labelStates.set(key, { + keyValues: instance.keyValues, + currentLabelId: instance.currentLabelId, + enteredCurrentAtMs: instance.enteredCurrentAtMs, + }); + } + return labelStates; + }, + getInstanceStatuses() { + return [...instances.entries()].map(([key, instance]) => ({ + key, + keyValues: [...instance.keyValues], + currentLabelId: instance.currentLabelId, + enteredCurrentAtMs: instance.enteredCurrentAtMs, + intervals: instance.intervals.map((interval) => ({ ...interval })), + })); + }, + }; +} diff --git a/libs/@hashintel/petrinaut-core/src/status-view-scope.test.ts b/libs/@hashintel/petrinaut-core/src/status-view-scope.test.ts new file mode 100644 index 00000000000..5f2c3cdf5b8 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/status-view-scope.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from "vitest"; + +import { + getStatusViewEvaluationScope, + resolveStatusViewLabelPlace, + visitComponentInstancePlaces, +} from "./status-view-scope"; + +import type { Place, SDCPN } from "./types/sdcpn"; + +const makePlace = ( + id: string, + name: string, + colorId: string | null, +): Place => ({ + id, + name, + colorId, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, +}); + +const makeInstance = (id: string, name: string, subnetId: string) => ({ + id, + name, + subnetId, + parameterValues: {}, + x: 0, + y: 0, +}); + +const sdcpn: SDCPN = { + places: [makePlace("root-place", "RootPlace", "root-color")], + transitions: [], + types: [ + { + id: "root-color", + name: "RootColor", + iconSlug: "circle", + displayColor: "#111111", + elements: [], + }, + ], + differentialEquations: [], + parameters: [], + subnets: [ + { + id: "subnet-outer", + name: "Outer", + places: [makePlace("shared-id", "OuterPlace", "outer-color")], + transitions: [], + types: [ + { + id: "outer-color", + name: "OuterColor", + iconSlug: "circle", + displayColor: "#222222", + elements: [], + }, + ], + differentialEquations: [], + parameters: [], + componentInstances: [makeInstance("inner-1", "InnerOne", "subnet-inner")], + }, + { + id: "subnet-inner", + name: "Inner", + places: [makePlace("shared-id", "InnerPlace", "inner-color")], + transitions: [], + types: [ + { + id: "inner-color", + name: "InnerColor", + iconSlug: "circle", + displayColor: "#333333", + elements: [], + }, + ], + differentialEquations: [], + parameters: [], + }, + ], + componentInstances: [makeInstance("outer-1", "OuterOne", "subnet-outer")], +}; + +describe("visitComponentInstancePlaces", () => { + it("yields scoped ids and name paths for nested instances", () => { + const visited: { scopedId: string; namePath: readonly string[] }[] = []; + visitComponentInstancePlaces(sdcpn, ({ scopedId, instanceNamePath }) => { + visited.push({ scopedId, namePath: instanceNamePath }); + }); + + expect(visited).toEqual([ + { scopedId: "outer-1::shared-id", namePath: ["OuterOne"] }, + { + scopedId: "outer-1::inner-1::shared-id", + namePath: ["OuterOne", "InnerOne"], + }, + ]); + }); + + it("skips instances and places whose ids contain the scope separator", () => { + const withBadIds: SDCPN = { + ...sdcpn, + subnets: [ + { + ...sdcpn.subnets![0]!, + places: [ + ...sdcpn.subnets![0]!.places, + makePlace("bad::place", "BadPlace", "outer-color"), + ], + componentInstances: [], + }, + sdcpn.subnets![1]!, + ], + componentInstances: [ + ...sdcpn.componentInstances!, + makeInstance("bad::instance", "BadInstance", "subnet-inner"), + ], + }; + + const visited: string[] = []; + visitComponentInstancePlaces(withBadIds, ({ scopedId }) => { + visited.push(scopedId); + }); + + expect(visited).toEqual(["outer-1::shared-id"]); + }); +}); + +describe("getStatusViewEvaluationScope", () => { + it("collects root places, scoped instance copies, and all colours", () => { + const { places, types } = getStatusViewEvaluationScope(sdcpn); + + expect(places.map((place) => place.id)).toEqual([ + "root-place", + "outer-1::shared-id", + "outer-1::inner-1::shared-id", + ]); + expect(types.map((color) => color.id)).toEqual([ + "root-color", + "outer-color", + "inner-color", + ]); + }); +}); + +describe("resolveStatusViewLabelPlace", () => { + it("resolves a bare id to a root place", () => { + expect(resolveStatusViewLabelPlace(sdcpn, "root-place")?.colorId).toBe( + "root-color", + ); + }); + + it("resolves a scoped id through the instance path, not by bare place id", () => { + expect( + resolveStatusViewLabelPlace(sdcpn, "outer-1::shared-id")?.colorId, + ).toBe("outer-color"); + expect( + resolveStatusViewLabelPlace(sdcpn, "outer-1::inner-1::shared-id") + ?.colorId, + ).toBe("inner-color"); + }); + + it("returns undefined for unresolvable references", () => { + expect(resolveStatusViewLabelPlace(sdcpn, "missing")).toBeUndefined(); + expect( + resolveStatusViewLabelPlace(sdcpn, "missing-instance::shared-id"), + ).toBeUndefined(); + expect( + resolveStatusViewLabelPlace(sdcpn, "outer-1::missing-place"), + ).toBeUndefined(); + }); +}); diff --git a/libs/@hashintel/petrinaut-core/src/status-view-scope.ts b/libs/@hashintel/petrinaut-core/src/status-view-scope.ts new file mode 100644 index 00000000000..302c04cfced --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/status-view-scope.ts @@ -0,0 +1,124 @@ +/** + * The place universe status views evaluate against and resolve label + * references in: the root net's places, plus each componentInstance's copies + * of its subnet's places under scoped ids — the id space execution frames + * key places by (see `scoped-ids.ts`). + */ + +import { + formatScopedId, + parseScopedId, + SCOPED_ID_SEPARATOR, +} from "./scoped-ids"; + +import type { Color, ID, Place, SDCPN } from "./types/sdcpn"; + +export type ScopedPlaceVisit = { + /** The place id as status labels and execution frames reference it. */ + scopedId: ID; + /** Component-instance names from outermost to innermost, for display. */ + instanceNamePath: readonly string[]; + /** The subnet place the componentInstance copies. */ + place: Place; +}; + +/** + * Visits every componentInstance's copy of a subnet place, outermost + * instances first, nested instances after their parent. An instance whose + * subnet is missing, and any instance or place whose id already contains the + * scope separator (such an id cannot be addressed by a scoped id), is + * skipped. + */ +export const visitComponentInstancePlaces = ( + sdcpn: Pick, + visit: (scopedPlace: ScopedPlaceVisit) => void, +): void => { + const subnetById = new Map( + (sdcpn.subnets ?? []).map((subnet) => [subnet.id, subnet]), + ); + + const visitInstances = ( + instances: NonNullable, + idPath: readonly ID[], + namePath: readonly string[], + ): void => { + for (const instance of instances) { + const subnet = subnetById.get(instance.subnetId); + if (!subnet || instance.id.includes(SCOPED_ID_SEPARATOR)) { + continue; + } + const instanceIdPath = [...idPath, instance.id]; + const instanceNamePath = [...namePath, instance.name]; + for (const place of subnet.places) { + if (place.id.includes(SCOPED_ID_SEPARATOR)) { + continue; + } + visit({ + scopedId: formatScopedId(instanceIdPath, place.id), + instanceNamePath, + place, + }); + } + visitInstances( + subnet.componentInstances ?? [], + instanceIdPath, + instanceNamePath, + ); + } + }; + + visitInstances(sdcpn.componentInstances ?? [], [], []); +}; + +/** + * The place and colour universe status views evaluate against: the root + * net's places, plus each componentInstance's copies of its subnet's places + * under scoped ids. Subnet colours keep their definition ids, matching the + * copies' `colorId`. + */ +export const getStatusViewEvaluationScope = ( + sdcpn: Pick, +): { places: Place[]; types: Color[] } => { + const places: Place[] = [...sdcpn.places]; + const types: Color[] = [ + ...sdcpn.types, + ...(sdcpn.subnets ?? []).flatMap((subnet) => subnet.types), + ]; + + visitComponentInstancePlaces(sdcpn, ({ scopedId, place }) => { + places.push({ ...place, id: scopedId }); + }); + + return { places, types }; +}; + +/** + * Resolves a status label's place reference: a bare id names a root-net + * place, and a scoped id (`instanceId::placeId`) names a componentInstance's + * copy of a subnet place, following the instance path from the root. + * Returns undefined for a reference that does not resolve. + */ +export const resolveStatusViewLabelPlace = ( + sdcpn: SDCPN, + labelPlaceId: ID, +): Place | undefined => { + const { instancePath, entityId } = parseScopedId(labelPlaceId); + if (instancePath.length === 0) { + return sdcpn.places.find((place) => place.id === entityId); + } + + const subnetById = new Map( + (sdcpn.subnets ?? []).map((subnet) => [subnet.id, subnet]), + ); + let instances = sdcpn.componentInstances ?? []; + let subnet: NonNullable[number] | undefined; + for (const instanceId of instancePath) { + const instance = instances.find((candidate) => candidate.id === instanceId); + subnet = instance ? subnetById.get(instance.subnetId) : undefined; + if (!subnet) { + return undefined; + } + instances = subnet.componentInstances ?? []; + } + return subnet?.places.find((place) => place.id === entityId); +}; diff --git a/libs/@hashintel/petrinaut-core/src/types/sdcpn-input.test.ts b/libs/@hashintel/petrinaut-core/src/types/sdcpn-input.test.ts index 00b0c0e36cb..197b1d9057d 100644 --- a/libs/@hashintel/petrinaut-core/src/types/sdcpn-input.test.ts +++ b/libs/@hashintel/petrinaut-core/src/types/sdcpn-input.test.ts @@ -61,6 +61,39 @@ describe("normalizeSDCPN", () => { expect(Object.hasOwn(result.places[0]!, "showAsInitialState")).toBe(false); expect(Object.hasOwn(result, "scenarios")).toBe(false); expect(Object.hasOwn(result, "metrics")).toBe(false); + expect(Object.hasOwn(result, "identities")).toBe(false); + expect(Object.hasOwn(result, "statusViews")).toBe(false); + }); + + it("passes through identities and status views", () => { + const result = normalizeSDCPN({ + places: [{ id: "p1", name: "P1", x: 0, y: 0 }], + transitions: [], + identities: [ + { id: "identity1", name: "Ticket", keyElementTypes: ["uuid"] }, + ], + statusViews: [ + { + id: "view1", + name: "Ticket status", + identityRef: "identity1", + labels: [ + { + id: "label1", + name: "Todo", + displayColor: "#94a3b8", + places: ["p1"], + }, + ], + }, + ], + }); + + expect(result.identities).toEqual([ + { id: "identity1", name: "Ticket", keyElementTypes: ["uuid"] }, + ]); + expect(result.statusViews).toHaveLength(1); + expect(result.statusViews![0]!.labels[0]!.places).toEqual(["p1"]); }); it.each([undefined, null, 0, 3])( diff --git a/libs/@hashintel/petrinaut-core/src/types/sdcpn-input.ts b/libs/@hashintel/petrinaut-core/src/types/sdcpn-input.ts index b91463001f4..d102923c2eb 100644 --- a/libs/@hashintel/petrinaut-core/src/types/sdcpn-input.ts +++ b/libs/@hashintel/petrinaut-core/src/types/sdcpn-input.ts @@ -4,6 +4,7 @@ import type { ComponentInstance, DifferentialEquation, ID, + Identity, InputArc, InputArcType, Metric, @@ -12,6 +13,7 @@ import type { Place, Scenario, SDCPN, + StatusView, Subnet, Transition, } from "./sdcpn"; @@ -46,6 +48,8 @@ export type SDCPNInput = { differentialEquations?: DifferentialEquation[]; scenarios?: Scenario[]; metrics?: Metric[]; + identities?: Identity[]; + statusViews?: StatusView[]; subnets?: Subnet[]; componentInstances?: ComponentInstance[]; }; @@ -128,8 +132,9 @@ function arcEndpointFields(arc: SDCPNArcEndpointInput): SDCPNArcEndpointInput { * equivalent value. * * Optional output fields (`capacity`, `isPort`, `visualizerCode`, `showAsInitialState`, - * arc `placeId`/`endpoint`, `scenarios`, `metrics`, `subnets`, - * `componentInstances`) are only set when present on the input, so the result + * arc `placeId`/`endpoint`, `scenarios`, `metrics`, `identities`, + * `statusViews`, `subnets`, `componentInstances`) are only set when present on + * the input, so the result * matches the shape the editor itself produces (relevant for structural * dirty-tracking via `isSDCPNEqual`). */ @@ -210,6 +215,12 @@ export function normalizeSDCPN(input: SDCPNInput): SDCPN { if (input.metrics !== undefined) { result.metrics = input.metrics; } + if (input.identities !== undefined) { + result.identities = input.identities; + } + if (input.statusViews !== undefined) { + result.statusViews = input.statusViews; + } if (input.subnets !== undefined) { result.subnets = input.subnets; } diff --git a/libs/@hashintel/petrinaut-core/src/types/sdcpn.ts b/libs/@hashintel/petrinaut-core/src/types/sdcpn.ts index 249fb42cb20..08fcd5fab65 100644 --- a/libs/@hashintel/petrinaut-core/src/types/sdcpn.ts +++ b/libs/@hashintel/petrinaut-core/src/types/sdcpn.ts @@ -113,6 +113,23 @@ export type Place = { y: number; }; +/** + * A named instance identity, e.g. "ticket": the thing whose per-instance + * status a status view tracks. Colour elements reference it via + * `identityRef` to mark themselves as key elements, so identity is declared + * once and correlates keys across colours without relying on element-name + * equality. + */ +export type Identity = { + id: ID; + name: string; + /** + * Type(s) of the key element(s), in key order; two or more entries form a + * compound key, correlated by tuple equality. + */ + keyElementTypes: ColorElementType[]; +}; + export type Color = { id: ID; name: string; @@ -123,6 +140,12 @@ export type Color = { elementId: string; name: string; type: ColorElementType; + /** + * Id of the Identity whose key this element carries; setting it marks + * the element as a key element. Tokens whose key elements are + * tuple-equal are the same instance, across colours. + */ + identityRef?: ID; }[]; }; @@ -336,6 +359,53 @@ export type Metric = { code: string; }; +/** + * One named status within a status view, mapped to the places whose tokens + * carry it. Labels are many-to-one: several places can map to the same label. + */ +export type StatusLabel = { + id: ID; + name: string; + /** CSS colour used for the label's badge, tint, and Kanban column. */ + displayColor: string; + /** + * Places whose tokens carry this label. A componentInstance's copy of a + * subnet place is addressed by scoped id (`instanceId::placeId`, see + * `scoped-ids.ts`). Empty for an exit label. + */ + places: ID[]; + /** + * Optional boolean expression over the token's attributes; the label + * applies only while the token is in the label's places AND the + * expression holds. + */ + tokenCondition?: string; + /** + * Marks the view's exit label, assigned to an instance whose token has + * left every place of the view's labels. At most one per view, and it + * has no places. + */ + isExit?: boolean; +}; + +/** + * A user-defined mapping from net state to named statuses for the instances + * of one identity: which label each tracked instance carries is derived from + * where its token sits (and the labels' token conditions), never stored. + */ +export type StatusView = { + id: ID; + name: string; + description?: string; + /** Id of the Identity this view tracks. */ + identityRef: ID; + /** + * Position in this array is the label's order: the Kanban column + * position and the legend position. + */ + labels: StatusLabel[]; +}; + /** * An instance of a subnet placed inside another net. */ @@ -383,6 +453,8 @@ export type SDCPN = { parameters: Parameter[]; scenarios?: Scenario[]; metrics?: Metric[]; + identities?: Identity[]; + statusViews?: StatusView[]; subnets?: Subnet[]; componentInstances?: ComponentInstance[]; }; diff --git a/libs/@hashintel/petrinaut-core/src/webgpu/compilation-report.test.ts b/libs/@hashintel/petrinaut-core/src/webgpu/compilation-report.test.ts index 59b987d7508..9c64d8f4144 100644 --- a/libs/@hashintel/petrinaut-core/src/webgpu/compilation-report.test.ts +++ b/libs/@hashintel/petrinaut-core/src/webgpu/compilation-report.test.ts @@ -87,12 +87,14 @@ describe("analyzeCompilation", () => { ).toHaveLength(2); }); - it("accepts every bundled example except the one multi-place consumer", () => { + it("accepts every bundled example except Production Machines and Ticket Processing", () => { // With derived capacities, calibrated histogram windows, forwarded - // kernel tokens, and the tiling-aware state gate, the only example the - // GPU still declines is Production Machines — its \`Start Repair\` - // consumes typed tokens from two places, a cross-product enumeration - // the shader does not scan yet (the weight > 2 family). + // kernel tokens, and the tiling-aware state gate, the GPU declines only + // two examples. Ticket Processing's tokens carry a \`string\` identity key + // so Kanban cards read as tickets, and the shader has no string + // attributes. Production Machines passes eligibility, but its + // \`Start Repair\` consumes typed tokens from two places, a cross-product + // enumeration the shader does not scan yet. // Deliberately exhaustive over the examples namespace: adding an example // MUST extend this matrix, so its GPU verdict is a decision, not an // accident. @@ -107,6 +109,7 @@ describe("analyzeCompilation", () => { expect(readiness).toStrictEqual({ productionMachines: false, deploymentPipelineSDCPN: true, + ticketProcessingSDCPN: false, probabilisticSatellitesSDCPN: true, sirModel: true, cafeQueue: true, @@ -115,9 +118,18 @@ describe("analyzeCompilation", () => { supplyChainProfit: true, vaccinationCampaign: true, }); + const tickets = analyze( + allExamples.ticketProcessingSDCPN.petriNetDefinition, + ); + expect(tickets.eligibilityReasons.length).toBeGreaterThan(0); + for (const reason of tickets.eligibilityReasons) { + expect(reason.code).toBe("unsupported-attribute-type"); + expect(reason.message).toMatch(/`string` attribute/); + } const production = analyze( allExamples.productionMachines.petriNetDefinition, ); + expect(production.eligibilityReasons).toStrictEqual([]); expect(production.shaderFailure).toMatch( /consumes typed tokens from 2 places/, ); @@ -281,8 +293,9 @@ describe("analyzeCompilation", () => { // the readiness matrix: a metric added to an example fails here until its // GPU verdict is recorded. Every translatable metric on a GPU-ready net is // `gpu-ready`; the two `.concat` averages are `cpu-only` with their own - // reason; Production Machines' other metrics are `cpu-only` because the - // net's shader fails, which is a different sentence. + // reason; Production Machines' other metrics are `cpu-only` because its + // shader fails to compile, and Ticket Processing's are `not-attempted` + // because eligibility refuses the net before emission. const statuses = Object.fromEntries( Object.entries(allExamples).map(([name, example]) => { const definition = (example as { petriNetDefinition: SDCPN }) @@ -315,6 +328,10 @@ describe("analyzeCompilation", () => { metric__deployment_gate_blocked: "gpu-ready", metric__failure_share: "gpu-ready", }, + ticketProcessingSDCPN: { + metric__open_tickets: "not-attempted", + metric__done_tickets: "not-attempted", + }, probabilisticSatellitesSDCPN: { metric__satellites_in_orbit: "gpu-ready", metric__debris: "gpu-ready", diff --git a/libs/@hashintel/petrinaut/docs/README.md b/libs/@hashintel/petrinaut/docs/README.md index 30faf45aa23..2953c9f16da 100644 --- a/libs/@hashintel/petrinaut/docs/README.md +++ b/libs/@hashintel/petrinaut/docs/README.md @@ -39,6 +39,7 @@ Petrinaut has three global modes in the top bar, though **Actual** is only enabl - [Simulation Panels](simulation-panels.md) -- Open scenarios and experiments beside the main view, expand to fullscreen, and use links and browser history. - [Actual Mode](actual-mode.md) -- View a host-provided live Petri net execution, currently via Brunch. - [Embedded Preview](preview.md) -- Explore a compact, read-only Petri net embedded in a host application. +- [Status Views](status-views.md) -- Track per-instance statuses derived from net state: identities, place-mapped labels, the Kanban board, and time-in-status. - [AI Assistant](ai-assistant.md) -- Build, review, and revise nets with text or inline Voice mode. - [Code Editor](code-editor.md) -- Edit model functions and expand their sections within the Properties Panel. - [User Settings](visual-settings.md) -- Open preferences from any workspace tab and configure General, Viewport, and Labs. diff --git a/libs/@hashintel/petrinaut/docs/actual-mode.md b/libs/@hashintel/petrinaut/docs/actual-mode.md index 77b8982cdfc..4a9bb878ab6 100644 --- a/libs/@hashintel/petrinaut/docs/actual-mode.md +++ b/libs/@hashintel/petrinaut/docs/actual-mode.md @@ -14,7 +14,7 @@ The demo website enables Actual mode on the `/brunch` route when the URL include Petrinaut connects to the stream, waits for the Petri net definition and initial state, lays out the net if the stream did not include node positions, and then shows the net in Actual mode. -If the stream connection is interrupted, Petrinaut keeps any loaded Actual mode data visible and waits for the browser to reconnect. Once the connection is restored, the Brunch stream replays the run from the beginning and Petrinaut rebuilds the timeline and events from that replay, so an interruption does not duplicate transition events or miss ones that fired while disconnected. If the stream sends invalid data, Petrinaut shows an error page with a link back to the normal demo site. +If the stream connection is interrupted, Petrinaut keeps any loaded Actual mode data visible and waits for the browser to reconnect. Once the connection is restored, the Brunch stream replays the run from the beginning and Petrinaut rebuilds the timeline and events from that replay, so an interruption does not duplicate transition events or miss ones that fired while disconnected. If the stream sends invalid data, such as a token record that does not match its place or a transition that consumes a token the place does not hold, Petrinaut shows an error page with a link back to the normal demo site. ## Timeline and events @@ -26,13 +26,13 @@ Choose **Export Stream** to download the received event stream. Brunch stream ex Choose **Export Net** to download a normal Petrinaut net file (YAML). This file contains the read-only Petri net currently shown in Actual mode and can be imported back into Petrinaut like other net exports. -For Brunch, the export is a JSON object with an `events` array. Each item stores the SSE event name and the parsed JSON payload exactly as Petrinaut received it. Transition payloads store the firing effect rather than a full before/after snapshot. The `input` and `output` fields are numeric count maps keyed by place id: +For Brunch, the export is a JSON object with an `events` array. Each item stores the SSE event name and the parsed JSON payload exactly as Petrinaut received it. Transition payloads store the tokens the firing consumed and produced rather than a full before/after snapshot. The `inputTokens` and `outputTokens` fields list one record per token, keyed by place id. A record carries a value for every element of the place's colour, and is empty (`{}`) for a place without a colour. Brunch nets have no colours, so their records are empty: ```json { "transitionId": "start_implementation", - "input": { "queued": 1 }, - "output": { "implementing": 1 }, + "inputTokens": { "queued": [{}] }, + "outputTokens": { "implementing": [{}] }, "ts": "2026-06-05T17:17:27.866Z" } ``` diff --git a/libs/@hashintel/petrinaut/docs/status-views.md b/libs/@hashintel/petrinaut/docs/status-views.md new file mode 100644 index 00000000000..d8081f6c3b6 --- /dev/null +++ b/libs/@hashintel/petrinaut/docs/status-views.md @@ -0,0 +1,44 @@ +# Status Views + +A status view maps net state to named statuses — Todo, In Progress, Blocked, Done — for the instances of one identity, such as tickets or machines. Which status an instance carries is derived from where its token sits; it is never stored, so a status view can never disagree with the net. + +Status views are experimental and off by default. Turn on **Settings → Labs → Status views** to show the Status views tab, the identity picker on token type attributes, the Kanban board in the view switcher, the Status changes column, and the Ticket Processing example in the Load example menu. A document's identities and status views are kept either way and round-trip through import and export. + +Status views power three surfaces: + +- status badges and tinting on component-instance nodes on the canvas, +- the Kanban board projection of the net, +- status-change and dwell information in Actual mode's Events tab. + +## Identities + +An identity names the thing a status view tracks, e.g. "Ticket". To declare one, open a type's properties and pick **New identity** on the dimension that carries the instance's key (an id-like attribute — a `uuid` or `string` dimension works well). Tokens whose key values are equal are the same instance, even across different types: give each type's key dimension the same identity, and a machine that changes type as it moves through the net keeps one status history. + +Transition kernels must copy the key attribute from input token to output token — a kernel that drops the key ends the recorded history for that instance. + +A token whose key was never set is not tracked: an unset `uuid` key (the nil UUID) or an empty `string` key marks the token as untracked rather than merging every such token into one instance. When several tokens carry the same key in one frame, they count as one instance — the first matching label (in label order, then place order) decides its status. + +## Creating a status view + +Open the **Simulate** mode and pick the **Status views** tab, then **Create**. A status view has: + +- **Identity** — which instances the view tracks. +- **Labels** — the statuses, in order. Label order is the Kanban column order, and when several labels could match, the first one wins. Each label maps to a set of places: a token in any of those places carries the label. A component instance's internal places appear in the picker as `InstanceName::PlaceName`. +- **Token conditions** — an optional boolean expression over the token's attributes, e.g. `token.attempts > 0`. The label applies only while the token is in the label's places AND the condition holds, so "Retrying" can be the same place as "In Progress" with a condition on the attempts attribute. A condition that is still compiling, or fails to compile, makes its label match nothing (the board shows a notice), so a broken condition never widens a label to every token. +- **Exit label** — at most one label can be the exit label. It has no places: it applies to instances whose token has left every place of the view, e.g. consumed outright by a final transition. Model distinct terminal statuses (Done vs Failed) as ordinary labels on sink places; use the exit label as the catch-all. + +## Kanban board + +When the net has at least one status view, the view switcher at the top left of the workspace gains a **Kanban** option beside Canvas and Definitions (Canvas and Kanban in Actual mode). The **Kanban board** keeps the side and bottom panels of the canvas. Columns are the selected view's labels in order, with the exit label last. Each card is one tracked instance, showing its key value, the time it has spent in its current stay, and — when it has entered the status more than once, e.g. through a review loop — the total time across stays and the number of stays. + +The board reads the same frames as the canvas: simulation playback in Edit mode, or the live stream in Actual mode. Scrub the timeline and the board follows. + +## Timing + +All durations derive from the firing history — the recorded wall-clock timestamps in Actual mode, simulated time otherwise. Because a token can re-enter a status, time-in-status is a set of intervals; a card shows the current stay, and the total across stays with the number of stays once there is more than one. + +In Actual mode, the Events tab gains a **Status changes** column when the net declares a status view and the stream's firings carry token values: each row lists which instances changed status and how long they spent in the previous one. + +## Examples + +The **Ticket Processing** example ships a complete status view over a ticket workflow, including a review loop and an archived exit label. diff --git a/libs/@hashintel/petrinaut/docs/visual-settings.md b/libs/@hashintel/petrinaut/docs/visual-settings.md index 1b9355edcc9..29a095c5f2b 100644 --- a/libs/@hashintel/petrinaut/docs/visual-settings.md +++ b/libs/@hashintel/petrinaut/docs/visual-settings.md @@ -172,6 +172,10 @@ Controls selection box behavior in [Select mode](drawing-a-net.md#pan-and-select Enable subnet definitions and component instances for hierarchical nets. This option appears when the net supports subnets. Off by default. +### Status views (experimental) + +Off by default. Derives per-instance statuses from where each instance's token sits: adds a **Status views** tab to the Simulate panel, an identity picker to token type attributes, a Kanban option to the view switcher of a net that has status views, and a **Status changes** column to Actual mode's Events tab. See [Status Views](status-views.md). + ### Compilation output (experimental) Off by default. Adds a [Compilation](compilation-output.md) tab to the bottom panel. diff --git a/libs/@hashintel/petrinaut/src/react/execution-frame/provider.test.tsx b/libs/@hashintel/petrinaut/src/react/execution-frame/provider.test.tsx index 237e6878cde..a30dd7979d9 100644 --- a/libs/@hashintel/petrinaut/src/react/execution-frame/provider.test.tsx +++ b/libs/@hashintel/petrinaut/src/react/execution-frame/provider.test.tsx @@ -67,8 +67,8 @@ const availableActualMode: ActualModeContextValue = { transitionFirings: [ { transitionId: "finish", - input: { queued: 1 }, - output: { done: 1 }, + inputTokens: { queued: [{}] }, + outputTokens: { done: [{}] }, ts: "2026-06-05T10:00:00.000Z", }, ], diff --git a/libs/@hashintel/petrinaut/src/react/execution-frame/provider.tsx b/libs/@hashintel/petrinaut/src/react/execution-frame/provider.tsx index 4cf11ca1b3f..5b90f0594ce 100644 --- a/libs/@hashintel/petrinaut/src/react/execution-frame/provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/execution-frame/provider.tsx @@ -7,7 +7,7 @@ import { use, useState, type FC, type PropsWithChildren } from "react"; import { buildActualModeTimelinePoints, - createActualModeTimelineFrameReader, + createActualModeFrameReplay, getActualModeTransitionFiringTimesMs, } from "@hashintel/petrinaut-core"; @@ -112,9 +112,10 @@ export const useActualExecutionFrameSource = (params: { ); const currentPoint = timelinePoints[currentFrameIndex]; const currentFrameReader = currentPoint - ? createActualModeTimelineFrameReader({ + ? createActualModeFrameReplay({ definition: petriNetDefinition, initialState, + }).readerAt({ transitionFirings: actualMode.transitionFirings, transitionFiringTimesMs, point: currentPoint, @@ -125,17 +126,20 @@ export const useActualExecutionFrameSource = (params: { const getFramesInRange = async ( startIndex: number, endIndex = timelinePoints.length, - ): Promise => - timelinePoints.slice(startIndex, endIndex).map((point, offset) => - createActualModeTimelineFrameReader({ - definition: petriNetDefinition, - initialState, + ): Promise => { + const replay = createActualModeFrameReplay({ + definition: petriNetDefinition, + initialState, + }); + return timelinePoints.slice(startIndex, endIndex).map((point, offset) => + replay.readerAt({ transitionFirings: actualMode.transitionFirings, transitionFiringTimesMs, point, number: startIndex + offset, }), ); + }; const { source } = actualMode; const baselineKey = getSourceBaselineKey( diff --git a/libs/@hashintel/petrinaut/src/react/experiments/provider/create-experiment.test.ts b/libs/@hashintel/petrinaut/src/react/experiments/provider/create-experiment.test.ts index ea02bce6cd9..0b8dba73e19 100644 --- a/libs/@hashintel/petrinaut/src/react/experiments/provider/create-experiment.test.ts +++ b/libs/@hashintel/petrinaut/src/react/experiments/provider/create-experiment.test.ts @@ -299,6 +299,7 @@ describe("createExperimentRequestBuilder", () => { lambdas: {}, kernels: {}, metrics: {}, + statusConditions: {}, }, failures: [], }), diff --git a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.ts b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.ts index 52a8829ceff..6f86411abb5 100644 --- a/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.ts +++ b/libs/@hashintel/petrinaut/src/react/hooks/use-petrinaut-mutations.ts @@ -107,6 +107,27 @@ export function usePetrinautMutations(): PetrinautMutations { removeMetric: withReadonlyGuard("removeMetric", { targetActiveSubnet: false, }), + addIdentity: withReadonlyGuard("addIdentity", { + targetActiveSubnet: false, + }), + updateIdentity: withReadonlyGuard("updateIdentity", { + targetActiveSubnet: false, + }), + removeIdentity: withReadonlyGuard("removeIdentity", { + targetActiveSubnet: false, + }), + addStatusView: withReadonlyGuard("addStatusView", { + targetActiveSubnet: false, + }), + updateStatusView: withReadonlyGuard("updateStatusView", { + targetActiveSubnet: false, + }), + removeStatusView: withReadonlyGuard("removeStatusView", { + targetActiveSubnet: false, + }), + moveStatusViewLabel: withReadonlyGuard("moveStatusViewLabel", { + targetActiveSubnet: false, + }), addSubnet: withReadonlyGuard("addSubnet", { targetActiveSubnet: false }), updateSubnet: withReadonlyGuard("updateSubnet", { targetActiveSubnet: false, diff --git a/libs/@hashintel/petrinaut/src/react/lsp/context.ts b/libs/@hashintel/petrinaut/src/react/lsp/context.ts index 917cd122ea9..6b441e6a0ef 100644 --- a/libs/@hashintel/petrinaut/src/react/lsp/context.ts +++ b/libs/@hashintel/petrinaut/src/react/lsp/context.ts @@ -129,6 +129,7 @@ export const DEFAULT_LANGUAGE_CLIENT_CONTEXT: LanguageClientContextValue = { lambdas: {}, kernels: {}, metrics: {}, + statusConditions: {}, }, failures: [], }), diff --git a/libs/@hashintel/petrinaut/src/react/navigation/index.tsx b/libs/@hashintel/petrinaut/src/react/navigation/index.tsx index 93c532d3dfd..262e737c51b 100644 --- a/libs/@hashintel/petrinaut/src/react/navigation/index.tsx +++ b/libs/@hashintel/petrinaut/src/react/navigation/index.tsx @@ -28,7 +28,8 @@ import type { SelectionItem } from "@hashintel/petrinaut-core"; export type PetrinautSimulateResource = | { type: "scenario"; id: string } | { type: "metric"; id: string } - | { type: "experiment"; id: string }; + | { type: "experiment"; id: string } + | { type: "status-view"; id: string }; export type PetrinautSettingsSection = "general" | "viewport" | "labs"; @@ -38,6 +39,7 @@ export type PetrinautNavigationOverlay = | { type: "create-scenario" } | { type: "create-metric" } | { type: "create-experiment" } + | { type: "create-status-view" } | null; /** @@ -381,6 +383,8 @@ const simulateResourceTypeToView = ( return "metrics"; case "experiment": return "experiments"; + case "status-view": + return "status-views"; } }; @@ -413,11 +417,14 @@ export const simulateDrawerToNavigationResource = ( return { type: "metric", id: drawer.metricId }; case "view-experiment": return { type: "experiment", id: drawer.experimentId }; + case "view-status-view": + return { type: "status-view", id: drawer.statusViewId }; // A create drawer opens above whatever record is already open, the way // `simulateDrawerToNavigationOverlay` keeps the overlay behind it. case "create-scenario": case "create-metric": case "create-experiment": + case "create-status-view": return current.simulateResource; // `closed` means whichever drawer is on top. Closing a create overlay // reveals the record it was layered over; closing that record's own @@ -437,11 +444,13 @@ export const simulateDrawerToNavigationOverlay = ( case "create-scenario": case "create-metric": case "create-experiment": + case "create-status-view": return { type: drawer.type }; case "closed": case "view-scenario": case "view-metric": case "view-experiment": + case "view-status-view": return current?.type.startsWith("create-") ? null : current; } }; @@ -454,6 +463,7 @@ export const navigationResourceToSimulateDrawer = ( case "create-scenario": case "create-metric": case "create-experiment": + case "create-status-view": return { type: overlay.type }; case "viewport-settings": case "user-settings": @@ -467,6 +477,8 @@ export const navigationResourceToSimulateDrawer = ( return { type: "view-metric", metricId: resource.id }; case "experiment": return { type: "view-experiment", experimentId: resource.id }; + case "status-view": + return { type: "view-status-view", statusViewId: resource.id }; case undefined: return { type: "closed" }; } diff --git a/libs/@hashintel/petrinaut/src/react/petrinaut-provider.tsx b/libs/@hashintel/petrinaut/src/react/petrinaut-provider.tsx index 318e89062c5..5b662f26970 100644 --- a/libs/@hashintel/petrinaut/src/react/petrinaut-provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/petrinaut-provider.tsx @@ -12,6 +12,7 @@ import { PetrinautDocumentProvider, } from "./petrinaut-provider-layers"; import { SimulationProvider } from "./simulation/provider"; +import { StatusConditionArtifactsProvider } from "./status-condition-artifacts"; import type { NetManagement } from "./net-management-context"; import type { @@ -88,7 +89,9 @@ export const PetrinautProvider: React.FC = ({ - {children} + + {children} + diff --git a/libs/@hashintel/petrinaut/src/react/state/editor-context.ts b/libs/@hashintel/petrinaut/src/react/state/editor-context.ts index d36ffb178b4..a3eb8217986 100644 --- a/libs/@hashintel/petrinaut/src/react/state/editor-context.ts +++ b/libs/@hashintel/petrinaut/src/react/state/editor-context.ts @@ -15,7 +15,12 @@ export type DraggingStateByNodeId = Record< >; export type EditorGlobalMode = "edit" | "simulate" | "actual"; -export type EditViewMode = "canvas" | "definitions"; +/** + * The surface the workspace shows instead of a simulation. The Kanban board + * projects a status view over the same frame source as the canvas, so it is + * a view of Edit and Actual mode rather than an `EditorGlobalMode`. + */ +export type EditViewMode = "canvas" | "definitions" | "kanban"; type EditorEditionMode = | "cursor" | "add-place" @@ -32,7 +37,11 @@ export type BottomPanelTab = export type TimelineChartType = "run" | "stacked"; -export type SimulateViewMode = "scenarios" | "metrics" | "experiments"; +export type SimulateViewMode = + | "scenarios" + | "metrics" + | "experiments" + | "status-views"; export type SimulateDrawerState = | { type: "closed" } @@ -41,7 +50,9 @@ export type SimulateDrawerState = | { type: "view-metric"; metricId: string } | { type: "create-metric" } | { type: "view-experiment"; experimentId: string } - | { type: "create-experiment" }; + | { type: "create-experiment" } + | { type: "view-status-view"; statusViewId: string } + | { type: "create-status-view" }; export type EditorNavigationTarget = { globalMode?: EditorGlobalMode; diff --git a/libs/@hashintel/petrinaut/src/react/state/simulate-mode-allowed-mutation-names.ts b/libs/@hashintel/petrinaut/src/react/state/simulate-mode-allowed-mutation-names.ts index 8800f95d344..3debb298af7 100644 --- a/libs/@hashintel/petrinaut/src/react/state/simulate-mode-allowed-mutation-names.ts +++ b/libs/@hashintel/petrinaut/src/react/state/simulate-mode-allowed-mutation-names.ts @@ -15,4 +15,8 @@ export const simulateModeAllowedMutationNames = new Set< "addMetric", "updateMetric", "removeMetric", + "addStatusView", + "updateStatusView", + "removeStatusView", + "moveStatusViewLabel", ]); diff --git a/libs/@hashintel/petrinaut/src/react/state/use-effective-edit-view-mode.ts b/libs/@hashintel/petrinaut/src/react/state/use-effective-edit-view-mode.ts new file mode 100644 index 00000000000..74d9fc16d78 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/react/state/use-effective-edit-view-mode.ts @@ -0,0 +1,38 @@ +import { use } from "react"; + +import { EditorContext } from "./editor-context"; +import { SDCPNContext } from "./sdcpn-context"; +import { UserSettingsContext } from "./user-settings-context"; + +import type { EditViewMode } from "./editor-context"; + +/** + * The Kanban board projects a status view, so the view is offered only while + * the Status views setting is on and the net declares at least one view. + */ +export const useKanbanViewAvailable = (): boolean => { + const { enableStatusViews } = use(UserSettingsContext); + const { petriNetDefinition } = use(SDCPNContext); + + return enableStatusViews && (petriNetDefinition.statusViews ?? []).length > 0; +}; + +/** + * The view the workspace actually renders. The stored view can say "kanban" + * after the setting was turned off or the last status view was deleted, and + * "definitions" outside Edit mode; both fall back to the canvas. Every + * consumer derives the view here so the rendered surface and the command + * rules never disagree. + */ +export const useEffectiveEditViewMode = (): EditViewMode => { + const { globalMode, editViewMode } = use(EditorContext); + const kanbanAvailable = useKanbanViewAvailable(); + + if (editViewMode === "kanban" && !kanbanAvailable) { + return "canvas"; + } + if (editViewMode === "definitions" && globalMode !== "edit") { + return "canvas"; + } + return editViewMode; +}; diff --git a/libs/@hashintel/petrinaut/src/react/state/user-settings-context.ts b/libs/@hashintel/petrinaut/src/react/state/user-settings-context.ts index f33d76ccfad..82be7882884 100644 --- a/libs/@hashintel/petrinaut/src/react/state/user-settings-context.ts +++ b/libs/@hashintel/petrinaut/src/react/state/user-settings-context.ts @@ -54,6 +54,15 @@ export type UserSettings = { highlightOnHover: boolean; partialSelection: boolean; enableNetComponents: boolean; + /** + * Experimental: derive per-instance statuses from the net. On, the Simulate + * panel gains a Status views tab, a token type's attributes offer an + * identity to key instances by, a net with status views gets a Kanban board + * toggle above the canvas, and Actual mode's Events tab gains a status + * changes column. Off, none of those show; a document's identities and + * status views are kept and still round-trip through import and export. + */ + enableStatusViews: boolean; /** * Persisted preference controlling whether the product walkthrough opens * automatically the next time the app initializes. The live open state is @@ -100,6 +109,7 @@ export type UserSettingsActions = { setHighlightOnHover: (value: boolean) => void; setPartialSelection: (value: boolean) => void; setEnableNetComponents: (value: boolean) => void; + setEnableStatusViews: (value: boolean) => void; setShowWalkthroughOnInit: (value: boolean) => void; setShowCompilationOutput: (value: boolean) => void; setBrunchDemoMode: (value: boolean) => void; @@ -133,6 +143,7 @@ export const defaultUserSettings: UserSettings = { highlightOnHover: true, partialSelection: true, enableNetComponents: false, + enableStatusViews: false, showWalkthroughOnInit: true, showCompilationOutput: false, brunchDemoMode: false, @@ -165,6 +176,7 @@ export const defaultUserSettingsContextValue: UserSettingsContextValue = { setHighlightOnHover: () => {}, setPartialSelection: () => {}, setEnableNetComponents: () => {}, + setEnableStatusViews: () => {}, setShowWalkthroughOnInit: () => {}, setShowCompilationOutput: () => {}, setBrunchDemoMode: () => {}, diff --git a/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.tsx b/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.tsx index 9664a6ecaef..8de926416b3 100644 --- a/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.tsx +++ b/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.tsx @@ -132,6 +132,8 @@ const OwnedUserSettingsProvider: React.FC = ({ setState((prev) => ({ ...prev, partialSelection: value })), setEnableNetComponents: (value: boolean) => setState((prev) => ({ ...prev, enableNetComponents: value })), + setEnableStatusViews: (value: boolean) => + setState((prev) => ({ ...prev, enableStatusViews: value })), setShowWalkthroughOnInit: (value: boolean) => setState((prev) => ({ ...prev, showWalkthroughOnInit: value })), setShowCompilationOutput: (value: boolean) => diff --git a/libs/@hashintel/petrinaut/src/react/status-condition-artifacts.tsx b/libs/@hashintel/petrinaut/src/react/status-condition-artifacts.tsx new file mode 100644 index 00000000000..3bdc5fc3890 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/react/status-condition-artifacts.tsx @@ -0,0 +1,128 @@ +import { + createContext, + use, + useEffect, + useState, + type FC, + type PropsWithChildren, +} from "react"; + +import { LanguageClientContext } from "./lsp/context"; +import { SDCPNContext } from "./state/sdcpn-context"; + +import type { HirStatusConditionArtifact } from "@hashintel/petrinaut-core"; + +export type StatusConditionArtifactsValue = { + /** Compiled label conditions, keyed by `getStatusConditionArtifactKey`. */ + statusConditions: Record; + /** + * True while declared conditions await compilation. Labels with a + * condition match nothing until their artifact arrives (the evaluator + * fails closed), so consumers can tell a settled result from a pending + * one. + */ + pending: boolean; + /** Compile or transport failure summary; null when everything compiled. */ + error: string | null; +}; + +const noArtifacts: StatusConditionArtifactsValue = { + statusConditions: {}, + pending: false, + error: null, +}; + +/** + * The net's compiled status-label token conditions, recompiled through the + * LSP worker whenever the document changes — shared here so the canvas + * badges, the Kanban board, and the events panel issue one compile request + * per change rather than one each. Empty (and never pending) when no label + * declares a condition. + */ +export const StatusConditionArtifactsContext = + createContext(noArtifacts); + +export const StatusConditionArtifactsProvider: FC = ({ + children, +}) => { + const { petriNetDefinition } = use(SDCPNContext); + const { requestHirArtifacts } = use(LanguageClientContext); + + const hasConditions = (petriNetDefinition.statusViews ?? []).some( + (statusView) => + statusView.labels.some( + (label) => (label.tokenCondition ?? "").trim() !== "", + ), + ); + + const [compiled, setCompiled] = useState<{ + forDefinition: unknown; + result: StatusConditionArtifactsValue; + } | null>(null); + + useEffect(() => { + if (!hasConditions) { + return; + } + let cancelled = false; + requestHirArtifacts(petriNetDefinition) + .then(({ artifacts, failures }) => { + if (cancelled) { + return; + } + const conditionFailures = failures.filter( + (failure) => failure.itemType === "status-label-condition", + ); + const firstMessage = conditionFailures[0]?.diagnostics[0]?.message; + setCompiled({ + forDefinition: petriNetDefinition, + result: { + statusConditions: artifacts.statusConditions, + pending: false, + error: + conditionFailures.length === 0 + ? null + : `${conditionFailures.length} status label condition(s) failed to compile${ + firstMessage ? `: ${firstMessage}` : "." + }`, + }, + }); + }) + .catch((error: unknown) => { + if (cancelled) { + return; + } + setCompiled({ + forDefinition: petriNetDefinition, + result: { + statusConditions: {}, + pending: false, + error: `Status label conditions could not be compiled: ${ + error instanceof Error ? error.message : String(error) + }`, + }, + }); + }); + return () => { + cancelled = true; + }; + }, [hasConditions, petriNetDefinition, requestHirArtifacts]); + + // While a recompile for the current definition is in flight, the previous + // artifacts stay available and `pending` marks them provisional. + const value = !hasConditions + ? noArtifacts + : compiled && compiled.forDefinition === petriNetDefinition + ? compiled.result + : { + statusConditions: compiled?.result.statusConditions ?? {}, + pending: true, + error: compiled?.result.error ?? null, + }; + + return ( + + {children} + + ); +}; diff --git a/libs/@hashintel/petrinaut/src/ui/preview/quick-simulation.test.ts b/libs/@hashintel/petrinaut/src/ui/preview/quick-simulation.test.ts index ad3886009f1..0be5cb79e19 100644 --- a/libs/@hashintel/petrinaut/src/ui/preview/quick-simulation.test.ts +++ b/libs/@hashintel/petrinaut/src/ui/preview/quick-simulation.test.ts @@ -15,6 +15,7 @@ const hirArtifacts: HirArtifacts = { lambdas: {}, kernels: {}, metrics: {}, + statusConditions: {}, }; const scenarioHir: ScenarioHir = { diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.test.tsx index da1fca3da34..5b3d64f9967 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.test.tsx @@ -1,6 +1,6 @@ /** @vitest-environment jsdom */ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { use, useEffect, useState } from "react"; +import { use, useEffect, useState, type ReactNode } from "react"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { @@ -8,9 +8,12 @@ import { type EditorGlobalMode, type EditViewMode, } from "../../../react/state/editor-context"; +import { SDCPNContext } from "../../../react/state/sdcpn-context"; +import { UserSettingsContext } from "../../../react/state/user-settings-context"; import { EditorView } from "./editor-view"; import type { PetrinautAiAssistant } from "../../petrinaut"; +import type { StatusView } from "@hashintel/petrinaut-core"; import type { UIMessageChunk } from "ai"; const lifecycle = vi.hoisted(() => ({ @@ -59,6 +62,11 @@ vi.mock("../Notebook/notebook-view", () => ({ ); }, })); +vi.mock("../Kanban/kanban-view", () => ({ + KanbanView: ({ toolbarStart }: { toolbarStart?: ReactNode }) => ( +
{toolbarStart}
+ ), +})); vi.mock("../SDCPN/sdcpn-view", () => ({ SDCPNView: () => { const [zoom, setZoom] = useState(1); @@ -139,6 +147,62 @@ const EditableWorkspace = () => { ); }; +const ticketStatusView: StatusView = { + id: "status-view__tickets", + name: "Ticket status", + identityRef: "identity__ticket", + labels: [ + { + id: "label__open", + name: "Open", + displayColor: "#3b82f6", + places: ["place__open"], + }, + ], +}; + +/** + * The Kanban board is offered only while the Status views setting is on and + * the net declares a status view; both are overridden per test. + */ +const StatusViewsWorkspace = ({ + mode = "edit", + initialView = "canvas", + statusViewsEnabled = true, + hasStatusView = true, +}: { + mode?: EditorGlobalMode; + initialView?: EditViewMode; + statusViewsEnabled?: boolean; + hasStatusView?: boolean; +}) => { + const editor = use(EditorContext); + const sdcpn = use(SDCPNContext); + const settings = use(UserSettingsContext); + const [editViewMode, setEditViewMode] = useState(initialView); + return ( + + + + + + + + ); +}; + beforeEach(() => { vi.stubGlobal( "ResizeObserver", @@ -230,3 +294,59 @@ describe("Edit workspace views", () => { }, ); }); + +describe("Kanban board view", () => { + test("is offered only while the Status views setting is on and the net has a status view", () => { + const { rerender } = render( + , + ); + expect(screen.getByRole("radio", { name: "Definitions" })).toBeTruthy(); + expect(screen.queryByRole("radio", { name: "Kanban" })).toBeNull(); + + rerender(); + expect(screen.queryByRole("radio", { name: "Kanban" })).toBeNull(); + + rerender(); + expect(screen.getByRole("radio", { name: "Kanban" })).toBeTruthy(); + }); + + test("shows the board in place of the canvas and keeps the canvas state", async () => { + render(); + const canvas = screen.getByRole("button", { name: "Canvas zoom 1" }); + fireEvent.click(canvas); + fireEvent.click(screen.getByRole("radio", { name: "Kanban" })); + expect(await screen.findByRole("region", { name: "Kanban" })).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Canvas zoom 2" })).toBeNull(); + expect(screen.queryByRole("region", { name: "Definitions" })).toBeNull(); + fireEvent.click(screen.getByRole("radio", { name: "Canvas" })); + expect(await screen.findByRole("button", { name: "Canvas zoom 2" })).toBe( + canvas, + ); + expect(screen.queryByRole("region", { name: "Kanban" })).toBeNull(); + }); + + test("falls back to the canvas when the board is no longer available", async () => { + const { rerender } = render(); + expect(await screen.findByRole("region", { name: "Kanban" })).toBeTruthy(); + + rerender( + , + ); + expect(screen.queryByRole("region", { name: "Kanban" })).toBeNull(); + expect( + await screen.findByRole("button", { name: "Canvas zoom 1" }), + ).toBeTruthy(); + expect( + screen.getByRole("radio", { name: "Canvas", checked: true }), + ).toBeTruthy(); + }); + + test("offers the canvas and the board, but not Definitions, in Actual mode", async () => { + render(); + screen.getByRole("radiogroup", { name: "Actual view" }); + expect(screen.getByRole("radio", { name: "Canvas" })).toBeTruthy(); + expect(screen.queryByRole("radio", { name: "Definitions" })).toBeNull(); + fireEvent.click(screen.getByRole("radio", { name: "Kanban" })); + expect(await screen.findByRole("region", { name: "Kanban" })).toBeTruthy(); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx index 0fcab9d63fa..1550c62512a 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view.tsx @@ -3,7 +3,7 @@ * @role Arranges the panels, toolbars and dialogs around the canvas */ -import { Activity, use, useState } from "react"; +import { Activity, use, useState, type CSSProperties } from "react"; import { type MenuItem } from "@hashintel/ds-components"; import { css } from "@hashintel/ds-helpers/css"; @@ -22,6 +22,7 @@ import { sirModel, supplyChainWithDisruption, supplyChainProfit, + ticketProcessingSDCPN, vaccinationCampaign, } from "@hashintel/petrinaut-core/examples"; @@ -30,6 +31,10 @@ import { ActualModeContext } from "../../../react/actual-mode-context"; import { usePetrinautNavigation } from "../../../react/navigation"; import { EditorContext } from "../../../react/state/editor-context"; import { SDCPNContext } from "../../../react/state/sdcpn-context"; +import { + useEffectiveEditViewMode, + useKanbanViewAvailable, +} from "../../../react/state/use-effective-edit-view-mode"; import { useIsReadOnly } from "../../../react/state/use-is-read-only"; import { useSelectionCleanup } from "../../../react/state/use-selection-cleanup"; import { UserSettingsContext } from "../../../react/state/user-settings-context"; @@ -47,6 +52,7 @@ import { exportTikZ } from "../../file-io/export-tikz"; import { importSDCPN } from "../../file-io/import-sdcpn"; import { KeyboardShortcut } from "../../keyboard-shortcut"; import { CodeNavigationProvider } from "../../monaco/code-navigation"; +import { KanbanView } from "../Kanban/kanban-view"; import { NotebookView } from "../Notebook/notebook-view"; import { SDCPNView } from "../SDCPN/sdcpn-view"; import { AiCtaModal } from "./components/ai-cta-modal"; @@ -129,6 +135,7 @@ const canvasContainerStyle = css({ const workspaceStyle = css({ position: "relative", "--edit-view-selector-width": "[160px]", + "--edit-view-selector-height": "[24px]", display: "flex", flexDirection: "column", flex: "[1]", @@ -206,7 +213,6 @@ const EditorViewContent = ({ // Get editor context const { globalMode, - editViewMode, isAiAssistantOpen, navigateTo, setGlobalMode, @@ -220,6 +226,8 @@ const EditorViewContent = ({ isBottomPanelOpen, bottomPanelHeight, } = use(EditorContext); + const editViewMode = useEffectiveEditViewMode(); + const kanbanAvailable = useKanbanViewAvailable(); const actualMode = use(ActualModeContext); const [pendingAiAssistantMessage, setPendingAiAssistantMessage] = useState< @@ -234,6 +242,7 @@ const EditorViewContent = ({ const { brunchDemoMode, enableExperimentalIconPack, + enableStatusViews, showAnimations, showWalkthroughOnInit, setShowWalkthroughOnInit, @@ -496,6 +505,20 @@ const EditorViewContent = ({ clearSelection(); }, }, + // Built around a status view, so listed only while the setting + // that shows status views is on. + ...(enableStatusViews + ? [ + { + id: "load-example-ticket-processing", + text: "Ticket Processing", + onClick: () => { + createNewNet(ticketProcessingSDCPN); + clearSelection(); + }, + }, + ] + : []), { id: "load-example-supply-chain-stochastic", text: "Supply Chain with Disruption", @@ -555,6 +578,16 @@ const EditorViewContent = ({ }, ]; + // Actual mode has no Definitions view, so its selector appears only once + // the Kanban board gives it a second option. + const showEditViewSelector = + globalMode === "edit" || (globalMode === "actual" && kanbanAvailable); + // Three labels need more room than the two the selector is sized for. + const workspaceVariables = { + "--edit-view-selector-width": + globalMode === "edit" && kanbanAvailable ? "232px" : undefined, + } as CSSProperties; + const showEmptyAiHero = aiAssistant !== undefined && !isAiAssistantOpen && @@ -616,14 +649,10 @@ const EditorViewContent = ({ {globalMode === "simulate" ? ( ) : ( -
- {globalMode === "edit" && } +
+ {showEditViewSelector && } {/* Left Sidebar - Tools and content panels */} @@ -632,11 +661,28 @@ const EditorViewContent = ({ {/* Properties Panel - Right Side */} - {/* SDCPN Visualization */} - + {/* SDCPN Visualization, or the Kanban projection of a status + view over the same frame source */} + + + + + + } + /> + {showEmptyAiHero && ( { const { - editViewMode, + globalMode, setEditViewMode, isLeftSidebarOpen, isSearchOpen, @@ -77,39 +94,47 @@ export const EditViewSelector = () => { isPanelAnimating, } = use(EditorContext); const { showAnimations } = use(UserSettingsContext); - const isCanvas = editViewMode === "canvas"; + const editViewMode = useEffectiveEditViewMode(); + const kanbanAvailable = useKanbanViewAvailable(); + // The canvas and the Kanban board share the floating panels, so the + // selector clears the left sidebar over both; Definitions has a toolbar. + const overlaysPanels = editViewMode !== "definitions"; const left = - isCanvas && (isLeftSidebarOpen || isSearchOpen) + overlaysPanels && (isLeftSidebarOpen || isSearchOpen) ? leftSidebarWidth + 12 : 12; + const items: SegmentedControlItem[] = [ + { label: "Canvas", value: "canvas" }, + ...(globalMode === "edit" + ? [{ label: "Definitions", value: "definitions" as const }] + : []), + ...(kanbanAvailable ? [{ label: "Kanban", value: "kanban" as const }] : []), + ]; return (
); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings.test.tsx index 13d38437166..6609b61d3d4 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings.test.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings.test.tsx @@ -431,6 +431,16 @@ describe("Labs settings", () => { }); }); +describe("Status views setting", () => { + it("offers a Status views row under Labs", async () => { + renderSettings({ overlay: { type: "user-settings", section: "labs" } }); + + expect( + await screen.findByRole("checkbox", { name: "Status views" }), + ).toBeTruthy(); + }); +}); + describe("WebGPU availability", () => { it("detects WebGPU support from the host, not a build flag", () => { // The runtime gate the control's `disabled` state is derived from. diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings/user-settings-dialog.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings/user-settings-dialog.tsx index 268b096b482..9e8ec459694 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings/user-settings-dialog.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings/user-settings-dialog.tsx @@ -558,16 +558,23 @@ export const UserSettingsDialog = ({ )} {item.id === "labs" && ( <> - {extensions.subnets && ( - + + {extensions.subnets && ( - - )} + )} + + { + const from = change.fromLabelName ?? "—"; + const to = change.toLabelName ?? "—"; + const dwell = + change.dwellMs === null || change.fromLabelName === null + ? "" + : ` (${formatDwellMs(change.dwellMs)} in ${change.fromLabelName})`; + return `${change.keyDisplay}: ${from} → ${to}${dwell}`; +}; + +type StatusDeriverInputs = { + statusView: StatusView; + definition: SDCPN; + initialState: ActualModeMarking; + statusConditions: Record; +}; + +/** + * The per-firing status changes, derived incrementally: the deriver lives in + * a ref and folds in only the firings appended since the previous render — + * re-deriving the whole history per arriving event is the O(n^2) this + * avoids. + */ +const useActualEventStatusChanges = ( + args: { + statusView: StatusView | undefined; + definition: SDCPN | null; + initialState: ActualModeMarking | null; + statusConditions: Record; + }, + transitionFirings: readonly ActualModeTransitionFiring[], +): ActualEventStatusChange[][] | null => { + const { statusView, definition, initialState, statusConditions } = args; + const [changesByFiring, setChangesByFiring] = useState< + ActualEventStatusChange[][] | null + >(null); + const deriverRef = useRef<{ + deriver: ActualEventStatusDeriver; + inputs: StatusDeriverInputs; + } | null>(null); + + useEffect(() => { + if (!statusView || !definition || initialState === null) { + deriverRef.current = null; + return; + } + let cancelled = false; + void Promise.resolve().then(() => { + if (cancelled) { + return; + } + const inputs: StatusDeriverInputs = { + statusView, + definition, + initialState, + statusConditions, + }; + const cached = deriverRef.current; + const entry = + cached && + cached.inputs.statusView === inputs.statusView && + cached.inputs.definition === inputs.definition && + cached.inputs.initialState === inputs.initialState && + cached.inputs.statusConditions === inputs.statusConditions + ? cached + : { deriver: createActualEventStatusDeriver(inputs), inputs }; + deriverRef.current = entry; + setChangesByFiring(entry.deriver.deriveUpTo(transitionFirings)); + }); + return () => { + cancelled = true; + }; + }, [ + statusView, + definition, + initialState, + statusConditions, + transitionFirings, + ]); + + if (!statusView || !definition || initialState === null) { + return null; + } + return changesByFiring; +}; + const formatTimestamp = (timestamp: string): string => { const date = new Date(timestamp); @@ -174,7 +287,8 @@ const formatMarking = (marking: ActualModeMarking): string => const EventRow: React.FC<{ firing: ActualModeTransitionFiring; index: number; -}> = ({ firing, index }) => ( + statusChanges: ActualEventStatusChange[] | undefined; +}> = ({ firing, index, statusChanges }) => ( - {formatMarking(firing.input)} + {formatMarking(firing.inputTokens)} - {formatMarking(firing.output)} + {formatMarking(firing.outputTokens)} + {statusChanges && ( + + {statusChanges.map((change) => ( + + {formatStatusChange(change)} + + ))} + + )} ); @@ -234,6 +360,23 @@ const ActualEventsContent: React.FC = () => { const visibleFirings = transitionFirings.slice(-MAX_VISIBLE_EVENTS); const firstVisibleIndex = transitionFirings.length - visibleFirings.length; + const { statusConditions } = use(StatusConditionArtifactsContext); + const { enableStatusViews } = use(UserSettingsContext); + const statusViews = + enableStatusViews && actualMode.available + ? (actualMode.definition?.statusViews ?? []) + : []; + const statusView = statusViews[0]; + const statusChangesByFiring = useActualEventStatusChanges( + { + statusView, + definition: actualMode.available ? actualMode.definition : null, + initialState: actualMode.available ? actualMode.initialState : null, + statusConditions, + }, + transitionFirings, + ); + const handleExportStream = () => { if (!actualMode.available || !canExportStream) { return; @@ -358,6 +501,21 @@ const ActualEventsContent: React.FC = () => { Input Output + {statusChangesByFiring && ( + + {/* Name the view when others exist, since only the + first status view drives this column. */} + {statusViews.length > 1 && statusView + ? `Status changes (${statusView.name})` + : "Status changes"} + + )} @@ -368,6 +526,11 @@ const ActualEventsContent: React.FC = () => { }`} firing={firing} index={firstVisibleIndex + index} + statusChanges={ + statusChangesByFiring + ? (statusChangesByFiring[firstVisibleIndex + index] ?? []) + : undefined + } /> ))} diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/actual-events/derive-status-changes.test.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/actual-events/derive-status-changes.test.ts new file mode 100644 index 00000000000..91f50a63601 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/actual-events/derive-status-changes.test.ts @@ -0,0 +1,313 @@ +import { describe, expect, it } from "vitest"; + +import { compileHirArtifacts } from "@hashintel/petrinaut-core/hir"; + +import { + makeTicketPlace, + ticketColor, +} from "../../../../../shared/status-view.test-helpers"; +import { createActualEventStatusDeriver } from "./derive-status-changes"; + +import type { SDCPN, StatusView } from "@hashintel/petrinaut-core"; + +const definition: SDCPN = { + places: [ + makeTicketPlace("todo", "todo"), + makeTicketPlace("doing", "doing"), + makeTicketPlace("done", "done"), + ], + transitions: [], + types: [ticketColor], + differentialEquations: [], + parameters: [], + identities: [ + { id: "identity-ticket", name: "Ticket", keyElementTypes: ["string"] }, + ], +}; + +const statusView: StatusView = { + id: "view-1", + name: "Ticket status", + identityRef: "identity-ticket", + labels: [ + { id: "l-todo", name: "Todo", displayColor: "#888888", places: ["todo"] }, + { + id: "l-doing", + name: "Doing", + displayColor: "#2563eb", + places: ["doing"], + }, + { id: "l-done", name: "Done", displayColor: "#16a34a", places: ["done"] }, + { + id: "l-gone", + name: "Archived", + displayColor: "#64748b", + places: [], + isExit: true, + }, + ], +}; + +describe("createActualEventStatusDeriver", () => { + it("tracks per-instance label changes with dwell in the previous label", () => { + const deriver = createActualEventStatusDeriver({ + statusView, + definition, + initialState: {}, + }); + const changes = deriver.deriveUpTo([ + { + transitionId: "create", + inputTokens: {}, + outputTokens: { todo: [{ ticket_id: "a", attempts: 0 }] }, + ts: "2026-06-05T10:00:00.000Z", + }, + { + transitionId: "start", + inputTokens: { todo: [{ ticket_id: "a", attempts: 0 }] }, + outputTokens: { doing: [{ ticket_id: "a", attempts: 0 }] }, + ts: "2026-06-05T10:00:04.000Z", + }, + { + transitionId: "archive", + inputTokens: { doing: [{ ticket_id: "a", attempts: 0 }] }, + outputTokens: {}, + ts: "2026-06-05T10:00:10.000Z", + }, + ]); + + expect(changes).toEqual([ + [ + { + keyDisplay: "a", + fromLabelName: null, + toLabelName: "Todo", + dwellMs: null, + }, + ], + [ + { + keyDisplay: "a", + fromLabelName: "Todo", + toLabelName: "Doing", + dwellMs: 4_000, + }, + ], + [ + { + keyDisplay: "a", + fromLabelName: "Doing", + toLabelName: "Archived", + dwellMs: 6_000, + }, + ], + ]); + }); + + it("folds newly appended firings without rederiving earlier rows", () => { + const deriver = createActualEventStatusDeriver({ + statusView, + definition, + initialState: { todo: [{ ticket_id: "a", attempts: 0 }] }, + }); + const firstFiring = { + transitionId: "start", + inputTokens: { todo: [{ ticket_id: "a", attempts: 0 }] }, + outputTokens: { doing: [{ ticket_id: "a", attempts: 0 }] }, + ts: "2026-06-05T10:00:05.000Z", + }; + + const first = deriver.deriveUpTo([firstFiring]); + // The instance sat in Todo from the initial state, so its first change + // reports the real starting label and dwell. + expect(first).toEqual([ + [ + { + keyDisplay: "a", + fromLabelName: "Todo", + toLabelName: "Doing", + dwellMs: 0, + }, + ], + ]); + + const second = deriver.deriveUpTo([ + firstFiring, + { + transitionId: "finish", + inputTokens: { doing: [{ ticket_id: "a", attempts: 0 }] }, + outputTokens: { done: [{ ticket_id: "a", attempts: 0 }] }, + ts: "2026-06-05T10:00:08.000Z", + }, + ]); + expect(second).toHaveLength(2); + expect(second[0]).toEqual(first[0]); + expect(second[1]).toEqual([ + { + keyDisplay: "a", + fromLabelName: "Doing", + toLabelName: "Done", + dwellMs: 3_000, + }, + ]); + }); + + it("honors token conditions like the frame evaluator", () => { + const conditionedView: StatusView = { + ...statusView, + labels: [ + { + id: "l-retrying", + name: "Retrying", + displayColor: "#f59e0b", + places: ["doing"], + tokenCondition: "token.attempts > 0", + }, + ...statusView.labels, + ], + }; + const { artifacts, failures } = compileHirArtifacts({ + ...definition, + statusViews: [conditionedView], + }); + expect(failures).toEqual([]); + + const deriver = createActualEventStatusDeriver({ + statusView: conditionedView, + definition, + initialState: {}, + statusConditions: artifacts.statusConditions, + }); + const changes = deriver.deriveUpTo([ + { + transitionId: "start", + inputTokens: {}, + outputTokens: { doing: [{ ticket_id: "a", attempts: 0 }] }, + ts: "2026-06-05T10:00:00.000Z", + }, + { + transitionId: "retry", + inputTokens: { doing: [{ ticket_id: "a", attempts: 0 }] }, + outputTokens: { doing: [{ ticket_id: "a", attempts: 1 }] }, + ts: "2026-06-05T10:00:03.000Z", + }, + ]); + + expect(changes).toEqual([ + [ + { + keyDisplay: "a", + fromLabelName: null, + toLabelName: "Doing", + dwellMs: null, + }, + ], + [ + { + keyDisplay: "a", + fromLabelName: "Doing", + toLabelName: "Retrying", + dwellMs: 3_000, + }, + ], + ]); + }); + + it("derives changes for scoped component-instance places", () => { + const scopedDefinition: SDCPN = { + ...definition, + places: [], + types: [], + subnets: [ + { + id: "subnet-1", + name: "Worker", + places: [ + { + id: "inner-doing", + name: "InnerDoing", + colorId: ticketColor.id, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + ], + transitions: [], + types: definition.types, + differentialEquations: [], + parameters: [], + }, + ], + componentInstances: [ + { + id: "instance-1", + name: "WorkerA", + subnetId: "subnet-1", + parameterValues: {}, + x: 0, + y: 0, + }, + ], + }; + const scopedView: StatusView = { + ...statusView, + labels: [ + { + id: "l-doing", + name: "Doing", + displayColor: "#2563eb", + places: ["instance-1::inner-doing"], + }, + ], + }; + + const deriver = createActualEventStatusDeriver({ + statusView: scopedView, + definition: scopedDefinition, + initialState: {}, + }); + const changes = deriver.deriveUpTo([ + { + transitionId: "instance-1::start", + inputTokens: {}, + outputTokens: { + "instance-1::inner-doing": [{ ticket_id: "a", attempts: 0 }], + }, + ts: "2026-06-05T10:00:00.000Z", + }, + ]); + + expect(changes).toEqual([ + [ + { + keyDisplay: "a", + fromLabelName: null, + toLabelName: "Doing", + dwellMs: null, + }, + ], + ]); + }); + + it("emits nothing for firings through uncoloured places", () => { + const deriver = createActualEventStatusDeriver({ + statusView, + definition: { + ...definition, + places: definition.places.map((place) => ({ ...place, colorId: null })), + }, + initialState: { todo: 1 }, + }); + const changes = deriver.deriveUpTo([ + { + transitionId: "start", + inputTokens: { todo: [{}] }, + outputTokens: { doing: [{}] }, + ts: "2026-06-05T10:00:00.000Z", + }, + ]); + + expect(changes).toEqual([[]]); + }); +}); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/actual-events/derive-status-changes.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/actual-events/derive-status-changes.ts new file mode 100644 index 00000000000..ba4bf4d844e --- /dev/null +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/actual-events/derive-status-changes.ts @@ -0,0 +1,184 @@ +import { + createActualModeFrameReplay, + createStatusViewFrameEvaluator, + createStatusViewTracker, + diffInstanceLabelStates, + extendActualModeTransitionFiringTimesMs, + getStatusViewEvaluationScope, +} from "@hashintel/petrinaut-core"; + +import type { + ActualModeMarking, + ActualModeTransitionFiring, + HirStatusConditionArtifact, + InstanceLabelState, + InstanceKey, + SDCPN, + StatusView, +} from "@hashintel/petrinaut-core"; + +export type ActualEventStatusChange = { + /** The instance's key element values, joined for display. */ + keyDisplay: string; + /** null when the firing first introduces the instance. */ + fromLabelName: string | null; + /** null when the token left the view and it declares no exit label. */ + toLabelName: string | null; + /** Time the instance spent in the previous label, ms; null without one. */ + dwellMs: number | null; +}; + +export type ActualEventStatusDeriver = { + /** + * Folds newly appended firings into the derived history and returns one + * entry per firing seen so far. Feeding a list that is not an extension of + * the previous one (fewer firings, or a different firing at the seam) + * rederives from scratch. + */ + deriveUpTo( + transitionFirings: readonly ActualModeTransitionFiring[], + ): ActualEventStatusChange[][]; +}; + +/** + * Derives, per firing, the status changes under one status view: which + * instances entered a new label and how long they spent in the previous one. + * The frames come from the same replay as the canvas and the Kanban board + * and the statuses from the same evaluator and tracker, so label order, + * token conditions, scoped (`instanceId::placeId`) places and the exit + * label behave identically. The pre-firing marking is observed first + * (emitting nothing), so instances present in the initial state report + * their real starting label and dwell on their first change. + */ +export function createActualEventStatusDeriver(args: { + statusView: StatusView; + definition: SDCPN; + initialState: ActualModeMarking; + /** Compiled label conditions, from `HirArtifacts.statusConditions`. */ + statusConditions?: Record; +}): ActualEventStatusDeriver { + const { statusView, definition, initialState, statusConditions } = args; + + const { places, types } = getStatusViewEvaluationScope(definition); + const readerDefinition = { + places, + transitions: definition.transitions, + types, + }; + const labelNameById = new Map( + statusView.labels.map((label) => [label.id, label.name]), + ); + const labelName = (labelId: string | null): string | null => + labelId === null ? null : (labelNameById.get(labelId) ?? null); + + const createTracker = () => + createStatusViewTracker({ + statusView, + evaluateFrame: createStatusViewFrameEvaluator({ + statusView, + places, + types, + statusConditions, + }), + }); + + let tracker = createTracker(); + let replay = createActualModeFrameReplay({ + definition: readerDefinition, + initialState, + }); + let previousLabelStates = new Map(); + let initialStateObserved = false; + let transitionFiringTimesMs: readonly number[] = []; + let processedCount = 0; + let lastProcessedFiring: ActualModeTransitionFiring | null = null; + let changesByFiring: ActualEventStatusChange[][] = []; + + const observeInitialState = ( + transitionFirings: readonly ActualModeTransitionFiring[], + ) => { + tracker.observeFrame( + replay.readerAt({ + transitionFirings, + transitionFiringTimesMs, + point: { kind: "initial", timeMs: 0, transitionFiringIndex: null }, + number: 0, + }), + ); + previousLabelStates = tracker.getInstanceLabelStates(); + initialStateObserved = true; + }; + + return { + deriveUpTo(transitionFirings) { + const isExtension = + transitionFirings.length >= processedCount && + (processedCount === 0 || + transitionFirings[processedCount - 1] === lastProcessedFiring); + if (!isExtension) { + tracker = createTracker(); + replay = createActualModeFrameReplay({ + definition: readerDefinition, + initialState, + }); + initialStateObserved = false; + transitionFiringTimesMs = []; + processedCount = 0; + lastProcessedFiring = null; + changesByFiring = []; + } + + transitionFiringTimesMs = extendActualModeTransitionFiringTimesMs( + transitionFiringTimesMs, + transitionFirings, + null, + null, + ); + + if (!initialStateObserved) { + observeInitialState(transitionFirings); + } + + for ( + let firingIndex = processedCount; + firingIndex < transitionFirings.length; + firingIndex += 1 + ) { + const firing = transitionFirings[firingIndex]; + if (!firing) { + continue; + } + const timeMs = transitionFiringTimesMs[firingIndex] ?? 0; + tracker.observeFrame( + replay.readerAt({ + transitionFirings, + transitionFiringTimesMs, + point: { + kind: "transition_firing", + timeMs, + transitionFiringIndex: firingIndex, + }, + number: firingIndex + 1, + }), + ); + + const labelStates = tracker.getInstanceLabelStates(); + changesByFiring.push( + diffInstanceLabelStates(previousLabelStates, labelStates, timeMs).map( + (change) => ({ + keyDisplay: change.keyValues.join(", "), + fromLabelName: labelName(change.fromLabelId), + toLabelName: labelName(change.toLabelId), + dwellMs: change.dwellMs, + }), + ), + ); + previousLabelStates = labelStates; + lastProcessedFiring = firing; + } + processedCount = transitionFirings.length; + + return [...changesByFiring]; + }, + }; +} diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/type-properties/subviews/main.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/type-properties/subviews/main.tsx index 2aa97bfb145..955d60a828b 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/type-properties/subviews/main.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/type-properties/subviews/main.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { use, useState } from "react"; import { v4 as uuidv4 } from "uuid"; import { @@ -11,18 +11,22 @@ import { } from "@hashintel/ds-components"; import { css, cva } from "@hashintel/ds-helpers/css"; import { + identityKeyTypesMatch, validateDisplayName, type ColorElementType, } from "@hashintel/petrinaut-core"; +import { usePetrinautMutations } from "../../../../../../../react/hooks/use-petrinaut-mutations"; +import { SDCPNContext } from "../../../../../../../react/state/sdcpn-context"; import { useIsReadOnly } from "../../../../../../../react/state/use-is-read-only"; +import { UserSettingsContext } from "../../../../../../../react/state/user-settings-context"; import { DescriptionField } from "../../../../../../components/description-field"; import { DraftFieldInput } from "../../../../../../components/draft-field-input"; import { SectionList } from "../../../../../../components/section"; import { TokenTypeIcon } from "../../../../../../constants/entity-icons"; import { UI_MESSAGES } from "../../../../../../constants/ui-messages"; import { usePetrinautPresentation } from "../../../../../shared/presentation-context"; -import { ColorSelect } from "../color-select"; +import { ColorSelect } from "../../../shared/color-select"; import { useTypePropertiesContext } from "../context"; import type { SubView } from "../../../../../../components/sub-view/types"; @@ -130,6 +134,11 @@ const dimensionTypeSelectStyle = css({ flexShrink: 0, }); +const dimensionIdentitySelectStyle = css({ + width: "[110px]", + flexShrink: 0, +}); + const deleteDimensionButtonStyle = css({ color: "neutral.s90", @@ -149,6 +158,9 @@ type ElementNameInputState = Record< { sourceName: string; value: string } >; +const NO_IDENTITY_VALUE = "__none__"; +const NEW_IDENTITY_VALUE = "__new__"; + const typeOptions: SelectItem[] = [ { value: "real", text: "Real" }, { value: "integer", text: "Integer" }, @@ -187,6 +199,10 @@ const TypeMainContent: React.FC = () => { } = useTypePropertiesContext(); const isDisabled = useIsReadOnly(); const presentation = usePetrinautPresentation(); + const { petriNetDefinition } = use(SDCPNContext); + const { enableStatusViews } = use(UserSettingsContext); + const { addIdentity } = usePetrinautMutations(); + const identities = petriNetDefinition.identities ?? []; const [draggedIndex, setDraggedIndex] = useState(null); const [dragOverIndex, setDragOverIndex] = useState(null); const [elementNameInputs, setElementNameInputs] = @@ -291,6 +307,73 @@ const TypeMainContent: React.FC = () => { }); }; + /** + * Identities an element can take without breaking key coherence: the + * resulting key elements of this colour for the identity must match its + * keyElementTypes in order (the actions layer rejects anything else). The + * element's current identity always stays listed so an imported document + * still displays. + */ + const getIdentityOptionsForElement = ( + element: (typeof type.elements)[number], + ): SelectItem[] => { + const selectableIdentities = identities.filter((identity) => { + if (identity.id === element.identityRef) { + return true; + } + const resultingKeyTypes = type.elements + .map((candidate) => + candidate.elementId === element.elementId + ? { ...candidate, identityRef: identity.id } + : candidate, + ) + .filter((candidate) => candidate.identityRef === identity.id) + .map((candidate) => candidate.type); + return identityKeyTypesMatch(resultingKeyTypes, identity); + }); + return [ + { value: NO_IDENTITY_VALUE, text: "No identity" }, + ...selectableIdentities.map((identity) => ({ + value: identity.id, + text: identity.name, + })), + { value: NEW_IDENTITY_VALUE, text: "New identity…" }, + ]; + }; + + const handleUpdateElementIdentity = ( + element: (typeof type.elements)[number], + selectedValue: string, + ) => { + if (selectedValue === NEW_IDENTITY_VALUE) { + const identityId = uuidv4(); + const existingNames = new Set(identities.map(({ name }) => name)); + let identityName = type.name; + for (let suffix = 2; existingNames.has(identityName); suffix += 1) { + identityName = `${type.name} ${suffix}`; + } + addIdentity({ + id: identityId, + name: identityName, + keyElementTypes: [element.type], + }); + updateTypeElement({ + typeId: type.id, + elementId: element.elementId, + update: { identityRef: identityId }, + }); + return; + } + updateTypeElement({ + typeId: type.id, + elementId: element.elementId, + update: { + identityRef: + selectedValue === NO_IDENTITY_VALUE ? undefined : selectedValue, + }, + }); + }; + const handleDragStart = (index: number) => { setDraggedIndex(index); }; @@ -477,6 +560,28 @@ const TypeMainContent: React.FC = () => { connectToLeftInput /> + + {enableStatusViews && ( + +