diff --git a/.changeset/read-arcs-core.md b/.changeset/read-arcs-core.md new file mode 100644 index 00000000000..332cb70f7d8 --- /dev/null +++ b/.changeset/read-arcs-core.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut-core": patch +--- + +Support read input arcs across SDCPN parsing, simulation, AI arc creation, and typed code inputs. diff --git a/.changeset/read-arcs-editor.md b/.changeset/read-arcs-editor.md new file mode 100644 index 00000000000..3b7332a077d --- /dev/null +++ b/.changeset/read-arcs-editor.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut": patch +--- + +Add editor support for read input arcs, including arc controls and distinct canvas rendering. diff --git a/libs/@hashintel/petrinaut-core/src/action-schemas.ts b/libs/@hashintel/petrinaut-core/src/action-schemas.ts index 6a940617822..3403e98c38a 100644 --- a/libs/@hashintel/petrinaut-core/src/action-schemas.ts +++ b/libs/@hashintel/petrinaut-core/src/action-schemas.ts @@ -6,6 +6,7 @@ import { colorSchema, differentialEquationSchema, idSchema, + inputArcSchema, nodePositionCommitSchema, parameterSchema, placeSchema, @@ -169,6 +170,22 @@ export const mutationActionInputSchemas = { weight: z.number().positive().meta({ description: "Token multiplicity for the arc.", }), + type: inputArcSchema.shape.type.optional().meta({ + description: + "Input arc type, only valid when arcDirection is input. Standard arcs consume tokens; read arcs inspect tokens without consuming them; inhibitor arcs block firing when enough tokens are present. Omit this for output arcs.", + }), + }) + .check((ctx) => { + const input = ctx.value; + if (input.arcDirection === "output" && input.type !== undefined) { + ctx.issues.push({ + code: "custom", + path: ["type"], + message: + 'Output arcs do not have an input arc type. Omit `type` when `arcDirection` is "output".', + input: input.type, + }); + } }) .meta({ description: "Add an input or output arc to a transition." }), removeArc: z @@ -192,7 +209,7 @@ export const mutationActionInputSchemas = { .strictObject({ transitionId: idSchema, placeId: idSchema, - type: z.enum(["standard", "inhibitor"]).meta({ + type: inputArcSchema.shape.type.meta({ description: "Replacement input arc type.", }), }) diff --git a/libs/@hashintel/petrinaut-core/src/actions.test.ts b/libs/@hashintel/petrinaut-core/src/actions.test.ts index 21f71f2651d..d4a951997ad 100644 --- a/libs/@hashintel/petrinaut-core/src/actions.test.ts +++ b/libs/@hashintel/petrinaut-core/src/actions.test.ts @@ -157,6 +157,64 @@ describe("Petrinaut core actions", () => { }); }); + test("adds and updates read input arcs", () => { + const instance = createInstance({ + ...emptySDCPN, + transitions: [ + { + id: "transition-1", + name: "Move", + inputArcs: [], + outputArcs: [], + lambdaType: "predicate", + lambdaCode: "export default Lambda(() => true);", + transitionKernelCode: "", + x: 50, + y: 0, + }, + ], + }); + + instance.mutations.addArc({ + transitionId: "transition-1", + arcDirection: "input", + placeId: "place-1", + weight: 2, + type: "read", + }); + instance.mutations.updateArcType({ + transitionId: "transition-1", + placeId: "place-1", + type: "standard", + }); + instance.mutations.updateArcType({ + transitionId: "transition-1", + placeId: "place-1", + type: "read", + }); + instance.mutations.addArc({ + transitionId: "transition-1", + arcDirection: "output", + placeId: "place-2", + weight: 3, + }); + + expect(() => + callActionWithUnknownInput(instance.mutations.addArc, { + transitionId: "transition-1", + arcDirection: "output", + placeId: "place-3", + weight: 1, + type: "read", + }), + ).toThrow(); + + expect(instance.definition.get().transitions[0]).toMatchObject({ + inputArcs: [{ placeId: "place-1", weight: 2, type: "read" }], + outputArcs: [{ placeId: "place-2", weight: 3 }], + }); + }); + test("adds, updates, removes, and moves type elements granularly", () => { const instance = createInstance({ ...emptySDCPN, diff --git a/libs/@hashintel/petrinaut-core/src/actions.ts b/libs/@hashintel/petrinaut-core/src/actions.ts index 783fc4a448c..d6322928e4e 100644 --- a/libs/@hashintel/petrinaut-core/src/actions.ts +++ b/libs/@hashintel/petrinaut-core/src/actions.ts @@ -168,7 +168,7 @@ export function createPetrinautActions( if (transition.id === parsed.transitionId) { if (parsed.arcDirection === "input") { transition.inputArcs.push({ - type: "standard", + type: parsed.type ?? "standard", placeId: parsed.placeId, weight: parsed.weight, }); diff --git a/libs/@hashintel/petrinaut-core/src/ai.test.ts b/libs/@hashintel/petrinaut-core/src/ai.test.ts index 4f18870df0c..a801b2490cb 100644 --- a/libs/@hashintel/petrinaut-core/src/ai.test.ts +++ b/libs/@hashintel/petrinaut-core/src/ai.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "vitest"; +import { z } from "zod"; import { aiCommandActionInputSchemas, @@ -43,6 +44,21 @@ describe("Petrinaut AI core exports", () => { expect(petrinautAiTools).toHaveProperty("applyAutoLayout"); }); + test("addArc exposes an AI-friendly object input schema", () => { + const schema = z.toJSONSchema(petrinautAiTools.addArc.inputSchema) as { + properties?: Record; + type?: unknown; + } & Record; + + expect(schema.type).toBe("object"); + expect(schema).not.toHaveProperty("oneOf"); + expect(schema).not.toHaveProperty("anyOf"); + expect(schema.properties).toMatchObject({ + arcDirection: { enum: ["input", "output"] }, + type: { enum: ["standard", "inhibitor", "read"] }, + }); + }); + test("callback map applies tool inputs to a Petrinaut instance", () => { const instance = createInstance(); const callbacks = createPetrinautAiWritableCallbacks(instance); diff --git a/libs/@hashintel/petrinaut-core/src/ai.ts b/libs/@hashintel/petrinaut-core/src/ai.ts index 5a5aceb0379..73c7b33d8ab 100644 --- a/libs/@hashintel/petrinaut-core/src/ai.ts +++ b/libs/@hashintel/petrinaut-core/src/ai.ts @@ -261,7 +261,7 @@ Validate every code-writing change. After any tool call that writes code — lam Place names are part of the code surface: lambdas/kernels read \`input.PlaceName\`, metrics read \`state.places.PlaceName.count\`, and scenario code-mode initial state keys are place names. Renaming a place via \`updatePlace\` requires updating every dependent lambda, kernel, dynamics, metric, visualizer, and scenario in the same batch — otherwise you will silently break references. Code-surface cheatsheet (exact shapes expected by the runtime): -- Transition lambda (\`transition.lambdaCode\`): \`export default Lambda((input, parameters) => …)\`. \`input.PlaceName\` is a tuple sized to the input arc weight; tokens are \`{ : number }\`. Inhibitor arcs and uncoloured input places are NOT in \`input\`. Predicate → boolean; stochastic → non-negative finite rate in firings per simulation second (0 disables, Infinity always fires). Must be deterministic. +- Transition lambda (\`transition.lambdaCode\`): \`export default Lambda((input, parameters) => …)\`. \`input.PlaceName\` is a tuple sized to the input arc weight for coloured standard and read input arcs; tokens are \`{ : number }\`. Read arcs expose tokens in \`input\` but do not consume them when the transition fires. Inhibitor arcs and uncoloured input places are NOT in \`input\`. Predicate → boolean; stochastic → non-negative finite rate in firings per simulation second (0 disables, Infinity always fires). Must be deterministic. - Transition kernel (\`transition.transitionKernelCode\`): \`export default TransitionKernel((input, parameters) => …)\`. Return \`{ OutputPlaceName: [token, …] }\` sized to the output arc weight. Include only coloured output places; uncoloured output places are auto-populated. Use \`Distribution.Gaussian(mean, sd)\` / \`Distribution.Uniform(min, max)\` / \`Distribution.Lognormal(mu, sigma)\` for stochastic attributes; chained \`.map(fn)\` on the same distribution shares one draw. Always required (use \`() => ({})\` when no coloured outputs). - Differential equation (\`differentialEquation.code\`): \`export default Dynamics((tokens, parameters) => …)\`. \`tokens\` is THIS place's tokens only. Return an array of the same length whose entries are \`{ : derivative }\` (i.e. dx/dt, not the new value). The equation's \`colorId\` MUST match every referencing place's \`colorId\`. - Place visualizer (\`place.visualizerCode\`): \`export default Visualization(({ tokens, parameters }) => )\`. Classic React runtime — do NOT import React, do NOT use \`<>…\` fragments, do NOT use hooks. Convention: return a sized \`\`. diff --git a/libs/@hashintel/petrinaut-core/src/clipboard/serialize.test.ts b/libs/@hashintel/petrinaut-core/src/clipboard/serialize.test.ts index 40be37215e6..aaadd9565d0 100644 --- a/libs/@hashintel/petrinaut-core/src/clipboard/serialize.test.ts +++ b/libs/@hashintel/petrinaut-core/src/clipboard/serialize.test.ts @@ -338,6 +338,47 @@ describe("parseClipboardPayload", () => { expect(parsed?.data.transitions[0]?.inputArcs[0]?.type).toBe("standard"); }); + it("preserves read arc types in clipboard payloads", () => { + const json = JSON.stringify({ + format: "petrinaut-sdcpn", + version: CLIPBOARD_FORMAT_VERSION, + documentId: null, + data: { + places: [ + { + id: "place-1", + name: "Place 1", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + ], + transitions: [ + { + id: "transition-1", + name: "Transition 1", + inputArcs: [{ placeId: "place-1", weight: 1, type: "read" }], + outputArcs: [], + lambdaType: "predicate", + lambdaCode: "", + transitionKernelCode: "", + x: 0, + y: 0, + }, + ], + types: [], + differentialEquations: [], + parameters: [], + }, + }); + + const parsed = parseClipboardPayload(json); + expect(parsed).not.toBeNull(); + expect(parsed?.data.transitions[0]?.inputArcs[0]?.type).toBe("read"); + }); + it("returns null when version is not a number", () => { const json = JSON.stringify({ format: "petrinaut-sdcpn", diff --git a/libs/@hashintel/petrinaut-core/src/clipboard/types.ts b/libs/@hashintel/petrinaut-core/src/clipboard/types.ts index d85a4e2d527..254df11512d 100644 --- a/libs/@hashintel/petrinaut-core/src/clipboard/types.ts +++ b/libs/@hashintel/petrinaut-core/src/clipboard/types.ts @@ -19,7 +19,7 @@ const clipboardPlaceShape = currentPlaceSchema.omit({ const inputArcSchema = z.object({ ...currentInputArcSchema.shape, - type: z.enum(["standard", "inhibitor"]).optional().default("standard"), + type: currentInputArcSchema.shape.type.optional().default("standard"), }); const outputArcSchema = z.object({ diff --git a/libs/@hashintel/petrinaut-core/src/default-codes.ts b/libs/@hashintel/petrinaut-core/src/default-codes.ts index 7c54f5461b8..34947921c71 100644 --- a/libs/@hashintel/petrinaut-core/src/default-codes.ts +++ b/libs/@hashintel/petrinaut-core/src/default-codes.ts @@ -51,7 +51,7 @@ export const generateDefaultLambdaCode = ( ): string => `/** * This function controls when the transition will fire, * once enabled by sufficient tokens in its input places. -* It receives tokens from input places keyed by place name, +* It receives tokens from coloured standard/read input places keyed by place name, * and any global parameters defined. */ export default Lambda((tokensByPlace, parameters) => { @@ -77,7 +77,7 @@ export function generateDefaultTransitionKernelCode( ): string { return `/** * This function defines the kernel for the transition. -* It receives tokens from input places, +* It receives tokens from coloured standard/read input places, * and any global parameters defined, * and should return tokens for output places keyed by place name. */ @@ -107,7 +107,7 @@ export default TransitionKernel((tokensByPlace, parameters) => { export const DEFAULT_TRANSITION_KERNEL_CODE = `/** * This function defines the kernel for the transition. -* It receives tokens from input places, +* It receives tokens from coloured standard/read input places, * and any global parameters defined, * and should return tokens for output places keyed by place name. */ diff --git a/libs/@hashintel/petrinaut-core/src/file-format/parse-sdcpn-file.test.ts b/libs/@hashintel/petrinaut-core/src/file-format/parse-sdcpn-file.test.ts index 1f81dbc4b93..d1fe9eb1424 100644 --- a/libs/@hashintel/petrinaut-core/src/file-format/parse-sdcpn-file.test.ts +++ b/libs/@hashintel/petrinaut-core/src/file-format/parse-sdcpn-file.test.ts @@ -61,6 +61,24 @@ describe("parseSDCPNFile", () => { expect(result.sdcpn.differentialEquations).toEqual([]); }); + it("preserves read arc types during import", () => { + const result = parseSDCPNFile({ + version: 1, + meta: { generator: "Petrinaut" }, + ...minimalSDCPN, + transitions: [ + { + ...minimalTransition, + inputArcs: [{ placeId: "p1", weight: 1, type: "read" }], + }, + ], + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.sdcpn.transitions[0]?.inputArcs[0]?.type).toBe("read"); + }); + it("preserves relaxed scenario and metric import defaults", () => { const result = parseSDCPNFile({ version: 1, diff --git a/libs/@hashintel/petrinaut-core/src/file-format/types.ts b/libs/@hashintel/petrinaut-core/src/file-format/types.ts index e00c955f14a..1f61dbe0c56 100644 --- a/libs/@hashintel/petrinaut-core/src/file-format/types.ts +++ b/libs/@hashintel/petrinaut-core/src/file-format/types.ts @@ -25,7 +25,7 @@ export const SDCPN_FILE_FORMAT_VERSION = 1; */ const inputArcSchema = z.object({ ...currentInputArcSchema.shape, - type: z.enum(["standard", "inhibitor"]).optional().default("standard"), + type: currentInputArcSchema.shape.type.optional().default("standard"), }); const outputArcSchema = z.object({ diff --git a/libs/@hashintel/petrinaut-core/src/lsp/lib/create-sdcpn-language-service.test.ts b/libs/@hashintel/petrinaut-core/src/lsp/lib/create-sdcpn-language-service.test.ts index f52457dc9a5..0bfabc4f17e 100644 --- a/libs/@hashintel/petrinaut-core/src/lsp/lib/create-sdcpn-language-service.test.ts +++ b/libs/@hashintel/petrinaut-core/src/lsp/lib/create-sdcpn-language-service.test.ts @@ -173,6 +173,26 @@ describe("SDCPNLanguageServer completions", () => { expect(names).toContain("x"); }); + it("returns token properties for read arc inputs", () => { + const names = getCompletionNames( + { + ...baseSdcpn, + transitions: [ + { + ...baseSdcpn.transitions[0]!, + inputArcs: [ + { placeId: "place1", weight: 1, type: "read" as const }, + ], + }, + ], + }, + `export default Lambda((input, parameters) => {\n const token = input.Source[0];\n return token.${CURSOR};\n});`, + { type: "transition-lambda" }, + ); + + expect(names).toContain("x"); + }); + it("returns String methods after string expression", () => { const names = getCompletionNames( baseSdcpn, diff --git a/libs/@hashintel/petrinaut-core/src/lsp/lib/generate-virtual-files.ts b/libs/@hashintel/petrinaut-core/src/lsp/lib/generate-virtual-files.ts index e0b42569c86..4b11178b1df 100644 --- a/libs/@hashintel/petrinaut-core/src/lsp/lib/generate-virtual-files.ts +++ b/libs/@hashintel/petrinaut-core/src/lsp/lib/generate-virtual-files.ts @@ -128,7 +128,8 @@ export function generateVirtualFiles(sdcpn: SDCPN): Map { for (const arc of transition.inputArcs) { // Inhibitor arcs never deliver tokens to the transition, so they should - // not contribute to the input type. + // not contribute to the input type. Read arcs do deliver tokens and are + // typed like standard input arcs. if (arc.type === "inhibitor") { continue; } diff --git a/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts b/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts index 1f52a74a04b..91fc3dbfcdb 100644 --- a/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts +++ b/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts @@ -7,6 +7,7 @@ import { variableNameSchema } from "../validation/variable-name"; import type { Color, DifferentialEquation, + InputArc, Parameter, Place, Transition, @@ -49,16 +50,16 @@ export const inputArcSchema = z }), weight: z.number().positive().meta({ description: - "Number of tokens consumed from the input place per firing. For coloured input places this also determines the tuple length the transition's lambda and kernel see at `input.PlaceName` (weight 2 means a 2-token array).", + "Token multiplicity for this input arc. Standard arcs consume this many tokens; read arcs require and expose this many tokens without consuming them; inhibitor arcs require the source place to have fewer than this many tokens. For coloured standard/read input places this also determines the tuple length the transition's lambda and kernel see at `input.PlaceName` (weight 2 means a 2-token array).", }), - type: z.enum(["standard", "inhibitor"]).meta({ + type: z.enum(["standard", "inhibitor", "read"]).meta({ description: - "Standard arcs consume tokens from the input place; inhibitor arcs prevent firing when the source place has at least the weight indicated. Inhibitor arcs do NOT consume tokens and their place is NOT present in the lambda or kernel `input`.", + "Standard arcs consume tokens from the input place; read arcs require and expose tokens to the lambda/kernel but do NOT consume them; inhibitor arcs prevent firing when the source place has at least the weight indicated and are NOT present in the lambda or kernel `input`.", }), }) .meta({ description: "Input arc from a place into a transition.", - }); + }) satisfies z.ZodType; export const outputArcSchema = z .strictObject({ @@ -150,7 +151,7 @@ export const transitionSchema = z }), inputArcs: z.array(inputArcSchema).meta({ description: - "Input arcs that gate and consume tokens for this transition.", + "Input arcs that gate transition firing. Standard arcs consume tokens, read arcs observe tokens without consuming them, and inhibitor arcs block firing based on token counts.", }), outputArcs: z.array(outputArcSchema).meta({ description: @@ -163,7 +164,8 @@ export const transitionSchema = z lambdaCode: z.string().meta({ description: [ "Module: `export default Lambda((input, parameters) => …)`.", - "`input` is keyed by INPUT PLACE NAME (PascalCase) and the value is a tuple sized to that arc's weight (weight 2 means a 2-token array).", + "`input` is keyed by INPUT PLACE NAME (PascalCase) for coloured standard and read arcs, and the value is a tuple sized to that arc's weight (weight 2 means a 2-token array).", + "Read arc tokens are present in `input` but are not consumed when the transition fires.", "Inhibitor arcs and uncoloured input places are NOT present in `input`.", "Each token is an object keyed by the colour type's element names (e.g. `{ x, y, velocity }`).", "`parameters` is keyed by each parameter's `variableName` value (lower_snake_case, e.g. `parameters.infection_rate`).", diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/check-transition-enablement.test.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/check-transition-enablement.test.ts index 428f6cae179..3c15b60268d 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/check-transition-enablement.test.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/check-transition-enablement.test.ts @@ -145,6 +145,44 @@ describe("isTransitionStructurallyEnabled", () => { ).toBe(false); }); + it("returns true for read arc when place has sufficient tokens", () => { + const transition = makeTransition({ + inputArcs: [{ placeId: "p1", weight: 2, type: "read" }], + }); + const frame = makeFrame({ + places: { p1: { offset: 0, count: 2, dimensions: 0 } }, + transitions: [transition], + }); + + expect( + isTransitionStructurallyEnabled( + frame, + makeTransitionMap([transition]), + frame.layout, + "t1", + ), + ).toBe(true); + }); + + it("returns false for read arc when place has insufficient tokens", () => { + const transition = makeTransition({ + inputArcs: [{ placeId: "p1", weight: 2, type: "read" }], + }); + const frame = makeFrame({ + places: { p1: { offset: 0, count: 1, dimensions: 0 } }, + transitions: [transition], + }); + + expect( + isTransitionStructurallyEnabled( + frame, + makeTransitionMap([transition]), + frame.layout, + "t1", + ), + ).toBe(false); + }); + it("checks all input places for enablement", () => { const transition = makeTransition({ inputArcs: [ diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/check-transition-enablement.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/check-transition-enablement.ts index bef885176f3..ab3b810a541 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/check-transition-enablement.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/check-transition-enablement.ts @@ -26,8 +26,9 @@ export type TransitionEnablementResult = { /** * Checks if a single transition has its input token requirements satisfied. * - * A transition is structurally enabled when all its input places have at least - * as many tokens as required by their respective arc weights. + * A transition is structurally enabled when standard/read input places have at + * least as many tokens as required by their respective arc weights, and + * inhibitor input places have fewer tokens than their arc weights. * * Note: This only checks token counts, not lambda conditions. A transition may * be structurally enabled but still not fire due to lambda returning 0 or false. @@ -53,7 +54,7 @@ function isTransitionStructurallyEnabledSnapshot( ); } - // Check if all input places have enough tokens for the required arc weights + // Check if all input places satisfy the required arc conditions. return transition.inputArcs.every((arc) => { const placeState = snapshot.places[arc.placeId]; if (!placeState) { diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/compute-possible-transition.test.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/compute-possible-transition.test.ts index 85cfb2e5b7e..a2452d89fa8 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/compute-possible-transition.test.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/compute-possible-transition.test.ts @@ -15,6 +15,7 @@ import type { LambdaFn, SimulationInstance, TransitionKernelFn, + TransitionTokenValues, } from "./types"; const type1: Color = { @@ -314,6 +315,78 @@ describe("computePossibleTransition", () => { expect(result!.add).toMatchObject({ p3: [[5.0]] }); }); + it("passes read arc tokens to lambda and kernel without consuming them", () => { + const transition = makeTransition({ + id: "t1", + inputArcs: [ + { placeId: "p1", weight: 1, type: "standard" }, + { placeId: "p2", weight: 1, type: "read" }, + ], + outputArcs: [{ placeId: "p3", weight: 1 }], + lambdaType: "predicate", + lambdaCode: "return true;", + transitionKernelCode: "return { Target: input.Guard };", + }); + let lambdaInput: TransitionTokenValues | null = null; + let kernelInput: TransitionTokenValues | null = null; + const simulation = makeSimulation({ + places: [ + makePlace("p1", "Source", "type1"), + makePlace("p2", "Guard", "type1"), + makePlace("p3", "Target", "type1"), + ], + transitions: [transition], + types: [type1], + lambdaFns: new Map([ + [ + "t1", + (input) => { + lambdaInput = input; + return true; + }, + ], + ]), + transitionKernelFns: new Map([ + [ + "t1", + (input) => { + kernelInput = input; + const guardToken = input.Guard?.[0]; + if (guardToken?.x === undefined) { + throw new Error("Expected read arc token"); + } + return { Target: [{ x: guardToken.x }] }; + }, + ], + ]), + }); + const frame = makeFrame({ + places: { + p1: { offset: 0, count: 1, dimensions: 1 }, + p2: { offset: 1, count: 1, dimensions: 1 }, + p3: { offset: 2, count: 0, dimensions: 1 }, + }, + transitions: { + t1: transitionState(), + }, + buffer: new Float64Array([3.0, 7.0]), + }); + + const result = computePossibleTransition(frame, simulation, "t1", 42); + + expect(result).not.toBeNull(); + expect(lambdaInput).toMatchObject({ + Source: [{ x: 3.0 }], + Guard: [{ x: 7.0 }], + }); + expect(kernelInput).toMatchObject({ + Source: [{ x: 3.0 }], + Guard: [{ x: 7.0 }], + }); + expect(result!.remove).toEqual({ p1: new Set([0]) }); + expect(result!.add).toEqual({ p3: [[7.0]] }); + }); + it("returns token combinations when transition is enabled and fires", () => { const transition = makeTransition({ id: "t1", diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/compute-possible-transition.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/compute-possible-transition.ts index ce6b08778f3..a68c5e6a6cf 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/compute-possible-transition.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/compute-possible-transition.ts @@ -60,7 +60,8 @@ export function computePossibleTransition( }; }); - // Transition is enabled if all input places have more tokens than the arc weight. + // Transition is enabled if standard/read arcs have enough tokens and + // inhibitor arcs have fewer than their threshold. const isTransitionEnabled = inputPlaces.every((inputPlace) => inputPlace.arcType === "inhibitor" ? inputPlace.count < inputPlace.weight @@ -83,17 +84,17 @@ export function computePossibleTransition( // TODO: This should acumulate lambda over time, but for now we just consider that lambda is constant per combination. // (just multiply by time since last transition) - const inputPlacesWithAtLeastOneDimension = inputPlaces.filter( + const inputPlacesWithTokenValues = inputPlaces.filter( (place) => place.dimensions > 0 && place.arcType !== "inhibitor", ); - const inputPlacesWithZeroDimensions = inputPlaces.filter( - (place) => place.dimensions === 0 && place.arcType !== "inhibitor", + const standardInputPlacesWithZeroDimensions = inputPlaces.filter( + (place) => place.dimensions === 0 && place.arcType === "standard", ); // TODO: This should acumulate lambda over time, but for now we just consider that lambda is constant per combination. // (just multiply by time since last transition) const tokensCombinations = enumerateWeightedMarkingIndicesGenerator( - inputPlacesWithAtLeastOneDimension, + inputPlacesWithTokenValues, ); for (const tokenCombinationIndices of tokensCombinations) { @@ -106,7 +107,7 @@ export function computePossibleTransition( placeIndex, placeTokenIndices, ] of tokenCombinationIndices.entries()) { - const inputPlace = inputPlacesWithAtLeastOneDimension[placeIndex]!; + const inputPlace = inputPlacesWithTokenValues[placeIndex]!; const placeOffsetInBuffer = inputPlace.offset; const dimensions = inputPlace.dimensions; @@ -248,14 +249,18 @@ export function computePossibleTransition( // TODO: Need to provide better typing here, to not let TS infer to any[] // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment remove: Object.fromEntries([ - ...inputPlacesWithZeroDimensions.map((inputPlace) => [ + ...standardInputPlacesWithZeroDimensions.map((inputPlace) => [ inputPlace.placeId, inputPlace.weight, ]), - ...tokenCombinationIndices.map((placeTokenIndices, placeIndex) => { - const inputArc = inputPlacesWithAtLeastOneDimension[placeIndex]!; - return [inputArc.placeId, new Set(placeTokenIndices)]; - }), + ...tokenCombinationIndices.flatMap( + (placeTokenIndices, placeIndex) => { + const inputArc = inputPlacesWithTokenValues[placeIndex]!; + return inputArc.arcType === "standard" + ? [[inputArc.placeId, new Set(placeTokenIndices)]] + : []; + }, + ), ]), // Map from place ID to array of token values to // create as per transition kernel output diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/execute-transitions.test.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/execute-transitions.test.ts index f680694ac67..8168cea31ee 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/execute-transitions.test.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/execute-transitions.test.ts @@ -318,6 +318,66 @@ describe("executeTransitions", () => { expect(result.transitionFired).toBe(true); }); + it("keeps read arc tokens in the frame when a transition fires", () => { + const transition = makeTransition({ + id: "t1", + inputArcs: [ + { placeId: "p1", weight: 1, type: "standard" }, + { placeId: "p2", weight: 1, type: "read" }, + ], + outputArcs: [{ placeId: "p3", weight: 1 }], + lambdaType: "predicate", + lambdaCode: "return true;", + transitionKernelCode: "return { Target: input.Guard };", + }); + const simulation = makeSimulation({ + places: [ + makePlace("p1", "Source", "type1"), + makePlace("p2", "Guard", "type1"), + makePlace("p3", "Target", "type1"), + ], + transitions: [transition], + types: [type1], + lambdaFns: new Map([["t1", () => true]]), + transitionKernelFns: new Map([ + [ + "t1", + (input) => { + const guardToken = input.Guard?.[0]; + if (guardToken?.x === undefined) { + throw new Error("Expected read arc token"); + } + return { Target: [{ x: guardToken.x }] }; + }, + ], + ]), + }); + const frame = makeFrame({ + places: { + p1: { offset: 0, count: 1, dimensions: 1 }, + p2: { offset: 1, count: 1, dimensions: 1 }, + p3: { offset: 2, count: 0, dimensions: 1 }, + }, + transitions: { + t1: transitionState(), + }, + buffer: new Float64Array([3.0, 7.0]), + }); + + const result = executeTransitions( + frame, + simulation, + simulation.dt, + simulation.rngState, + ); + + expect(result.transitionFired).toBe(true); + expect(result.frame.places.p1?.count).toBe(0); + expect(result.frame.places.p2?.count).toBe(1); + expect(result.frame.places.p3?.count).toBe(1); + expect(result.frame.buffer).toEqual(new Float64Array([7.0, 7.0])); + }); + it("executes multiple transitions sequentially with proper token removal between each", () => { const transitions = [ makeTransition({ diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/types.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/types.ts index 488a9e4af27..4f5d8cf84ce 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/engine/types.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/types.ts @@ -5,7 +5,13 @@ * part of the public simulation API. */ -import type { Color, Place, SDCPN, Transition } from "../../types/sdcpn"; +import type { + Color, + InputArcType, + Place, + SDCPN, + Transition, +} from "../../types/sdcpn"; import type { InitialMarking } from "../api"; import type { RuntimeDistribution } from "../authoring/user-code/distribution"; import type { EngineFrame, EngineFrameLayout } from "../frames/internal-frame"; @@ -63,7 +69,7 @@ export type CompiledTransitionPlace = { }; export type CompiledTransitionInputPlace = CompiledTransitionPlace & { - arcType: "standard" | "inhibitor"; + arcType: InputArcType; }; export type CompiledTransition = { diff --git a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/frame-operations.ts b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/frame-operations.ts index 5fb179badff..1a72f6bb47c 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/frame-operations.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/frame-operations.ts @@ -299,7 +299,7 @@ export function updateTransitionTimers( * * This is used for deadlock detection after a step where no transition fired. * It intentionally ignores lambda probability and only checks input-place - * token availability and inhibitor conditions. + * token availability, read-arc availability, and inhibitor conditions. */ export function hasStructurallyEnabledTransition( run: MonteCarloRunState, diff --git a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/monte-carlo-simulator.test.ts b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/monte-carlo-simulator.test.ts index 3cc5480eef4..4c2d384ea26 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/monte-carlo-simulator.test.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/monte-carlo-simulator.test.ts @@ -91,6 +91,66 @@ const selfLoopSdcpn: SDCPN = { parameters: [], }; +const readArcSdcpn: SDCPN = { + types: [ + { + id: "type-product", + name: "Product", + iconSlug: "circle", + displayColor: "#00FF00", + elements: [{ elementId: "quality", name: "quality", type: "real" }], + }, + ], + places: [ + { + id: "source", + name: "Source", + colorId: null, + dynamicsEnabled: false, + differentialEquationId: null, + x: 0, + y: 0, + }, + { + id: "guard", + name: "Guard", + colorId: "type-product", + dynamicsEnabled: false, + differentialEquationId: null, + x: 100, + y: 0, + }, + { + id: "product", + name: "Product", + colorId: "type-product", + dynamicsEnabled: false, + differentialEquationId: null, + x: 200, + y: 0, + }, + ], + transitions: [ + { + id: "make-product", + name: "Make Product", + inputArcs: [ + { placeId: "source", weight: 1, type: "standard" }, + { placeId: "guard", weight: 1, type: "read" }, + ], + outputArcs: [{ placeId: "product", weight: 1 }], + lambdaType: "predicate", + lambdaCode: "export default Lambda(() => true);", + transitionKernelCode: + "export default TransitionKernel((input) => ({ Product: [{ quality: input.Guard[0].quality }] }));", + x: 50, + y: 0, + }, + ], + differentialEquations: [], + parameters: [], +}; + describe("MonteCarloSimulator", () => { it("runs multiple independent simulations without retaining frame history", () => { const simulator = createMonteCarloSimulator({ @@ -138,6 +198,44 @@ describe("MonteCarloSimulator", () => { expect(secondRun.tokenValueCount).toBe(2); }); + it("does not consume tokens read by read arcs", () => { + const productQualityMetric = createMonteCarloUserDefinedMetric({ + id: "product-quality", + label: "Product quality", + sampleRuns: "all", + aggregateRuns: "last", + aggregateTime: "none", + measure: ({ frame }) => frame.getPlaceTokenValues("product")?.values[0], + }); + const simulator = createMonteCarloSimulator({ + sdcpn: readArcSdcpn, + runCount: 1, + initialMarking: { + source: 1, + guard: [{ quality: 7 }], + }, + seed: 100, + dt: 1, + maxTime: 10, + metrics: [productQualityMetric], + }); + + const result = simulator.runUntilComplete({ maxBatches: 10 }); + const run = simulator.getRunSnapshot(0); + + expect(result.allFinished).toBe(true); + expect(run.status).toBe("complete"); + expect(run.completionReason).toBe("deadlock"); + expect(run.placeTokenCounts).toMatchObject({ + source: 0, + guard: 1, + product: 1, + }); + expect(productQualityMetric.getLatestFrame()).toMatchObject({ + value: 7, + }); + }); + it("advances active runs in deterministic round-robin batches", () => { const simulator = createMonteCarloSimulator({ sdcpn, diff --git a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/transition-effect.ts b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/transition-effect.ts index e2f0235dece..f7e7ccbd129 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/transition-effect.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/monte-carlo/transition-effect.ts @@ -59,8 +59,8 @@ export function computeTransitionEffect( const inputPlacesWithValues = inputPlaces.filter( (place) => place.dimensions > 0 && place.arcType !== "inhibitor", ); - const inputPlacesWithoutValues = inputPlaces.filter( - (place) => place.dimensions === 0 && place.arcType !== "inhibitor", + const standardInputPlacesWithoutValues = inputPlaces.filter( + (place) => place.dimensions === 0 && place.arcType === "standard", ); const tokenCombinations = enumerateWeightedMarkingIndicesGenerator( @@ -180,12 +180,14 @@ export function computeTransitionEffect( } const remove: TransitionEffect["remove"] = {}; - for (const inputPlace of inputPlacesWithoutValues) { + for (const inputPlace of standardInputPlacesWithoutValues) { remove[inputPlace.placeId] = inputPlace.weight; } for (const [index, tokenIndices] of tokenCombinationIndices.entries()) { const inputPlace = inputPlacesWithValues[index]!; - remove[inputPlace.placeId] = new Set(tokenIndices); + if (inputPlace.arcType === "standard") { + remove[inputPlace.placeId] = new Set(tokenIndices); + } } return { diff --git a/libs/@hashintel/petrinaut-core/src/types/sdcpn.ts b/libs/@hashintel/petrinaut-core/src/types/sdcpn.ts index 133a19a7098..4e05130d579 100644 --- a/libs/@hashintel/petrinaut-core/src/types/sdcpn.ts +++ b/libs/@hashintel/petrinaut-core/src/types/sdcpn.ts @@ -1,9 +1,11 @@ export type ID = string; +export type InputArcType = "standard" | "inhibitor" | "read"; + export type InputArc = { placeId: string; weight: number; - type: "standard" | "inhibitor"; + type: InputArcType; }; export type OutputArc = { diff --git a/libs/@hashintel/petrinaut/docs/README.md b/libs/@hashintel/petrinaut/docs/README.md index 6453d90b331..cd99baa8c5b 100644 --- a/libs/@hashintel/petrinaut/docs/README.md +++ b/libs/@hashintel/petrinaut/docs/README.md @@ -27,7 +27,7 @@ Petrinaut has two global modes you switch between in the top bar: ## Contents - [Drawing a Net](drawing-a-net.md) -- Top bar, canvas, sidebars, adding nodes and connecting arcs, selection, keyboard shortcuts, import/export, auto-layout. -- [Petri Net Extensions](petri-net-extensions.md) -- Types, dynamics, transition kernels, firing rules, and inhibitor arcs, as well as parameters and state visualizers. +- [Petri Net Extensions](petri-net-extensions.md) -- Types, dynamics, transition kernels, firing rules, read/inhibitor arcs, as well as parameters and state visualizers. - [Useful Patterns](useful-patterns.md) -- Common modelling techniques, including duration and resource pools. - [Simulation](simulation.md) -- Set initial state, run a single simulation, use the timeline, control playback. - [Scenarios](scenarios.md) -- Save and switch between named simulation configurations. diff --git a/libs/@hashintel/petrinaut/docs/drawing-a-net.md b/libs/@hashintel/petrinaut/docs/drawing-a-net.md index 999b5a1a778..c9564e3d282 100644 --- a/libs/@hashintel/petrinaut/docs/drawing-a-net.md +++ b/libs/@hashintel/petrinaut/docs/drawing-a-net.md @@ -62,16 +62,16 @@ New nodes are named automatically (Place1, Place2, Transition1, etc.). Rename th Drag from a node's handle to connect it: -- **Place to Transition** creates an **input arc** (the transition consumes tokens from the place). +- **Place to Transition** creates an **input arc** (standard input arcs consume tokens from the place). - **Transition to Place** creates an **output arc** (the transition produces tokens in the place). -Petri nets are bipartite: you cannot connect a place to another place or a transition to another transition. New arcs default to weight 1. +Petri nets are bipartite: you cannot connect a place to another place or a transition to another transition. New arcs default to weight 1. Input arcs default to **Standard**, and can be changed to **Read** or **Inhibitor** in the arc properties panel. ![drawing-arc](https://github.com/user-attachments/assets/ac688560-bba8-44fe-a6f8-c7ff320474a4) ## Arc weight -Select an arc to open its properties. Set the **weight** to control how many tokens are consumed (input) or produced (output) per firing. +Select an arc to open its properties. Set the **weight** to control how many tokens are required and consumed (standard input), required without being consumed (read input), blocked by (inhibitor input), or produced (output) per firing. You can also edit an arc's weight via the properties panel for the transition it is connected to. @@ -149,7 +149,7 @@ From the top-bar menu (hamburger icon), under **Export**: - **JSON** -- the full SDCPN: places, transitions, arcs, types, dynamics, parameters, scenarios, metrics, **and** canvas positions / display colours. The format other Petrinaut instances can re-import faithfully. - **JSON without visual info** -- the same payload minus node positions and type display colours. Useful when only the logical structure matters (sharing for review, embedding in another tool, comparing two nets without layout noise). On import, the receiving editor applies auto-layout to fill in positions. -- **TikZ** -- a `.tex` file with a structural diagram. This is a simplified view: only the place / transition / arc structure is included. Token types, dynamics, inhibitor arcs, scenarios, and metrics are **not** encoded. Intended for papers and presentations. +- **TikZ** -- a `.tex` file with a structural diagram. This is a simplified view: only the place / transition / arc structure is included. Token types, dynamics, read/inhibitor arcs, scenarios, and metrics are **not** encoded. Intended for papers and presentations. **Import**: loads a net from a `.json` file. If node positions are missing, an automatic layout is applied on load. diff --git a/libs/@hashintel/petrinaut/docs/petri-net-extensions.md b/libs/@hashintel/petrinaut/docs/petri-net-extensions.md index 68b86859fbb..6fa7344a1f5 100644 --- a/libs/@hashintel/petrinaut/docs/petri-net-extensions.md +++ b/libs/@hashintel/petrinaut/docs/petri-net-extensions.md @@ -109,6 +109,8 @@ Two important asymmetries: - **Uncoloured input places and inhibitor arcs are not included in `tokensByPlace`**. Only typed input places appear, and only for normal (non-inhibitor) arcs. - **Uncoloured output places do not need to appear in the return value** -- the engine generates the correct number of plain tokens automatically based on the output arc weight. Coloured output places must appear with one token object per token produced. +Tokens from **read arcs** are included in `tokensByPlace` like standard input arcs, but are not consumed when the transition fires. Tokens from inhibitor arcs are not included. + Use the menu in the code editor header to **Load default template** for a starting point. ### Distributions @@ -185,6 +187,16 @@ Inhibitor arcs **do not consume tokens** when the transition fires. **Example:** in [Deployment Pipeline](examples.md#deployment-pipeline), inhibitor arcs from "IncidentBeingInvestigated" and "DeploymentInProgress" block new deployments while an incident is open or a deployment is already running. +## Read arcs + +A read arc is a special input arc that **requires** tokens to be present and exposes those tokens to the transition lambda and kernel, but **does not consume** them when the transition fires. + +**To set:** select an input arc (place to transition) and switch its **Type** to **Read** in the properties panel. Only input arcs can be read arcs. + +**Semantics:** the transition is enabled (on this arc) when the source place has **at least the arc weight** in tokens. For coloured places, the lambda and transition kernel receive a tuple of tokens under `tokensByPlace.SourcePlaceName`, sized to the arc weight. If the transition fires, those read tokens remain in the source place. + +Use read arcs when a transition needs to inspect shared state, permission tokens, sensor readings, or another entity's attributes without moving that token through the transition. + ## Diagnostics The **Diagnostics** tab in the bottom panel shows TypeScript errors in your code (dynamics, firing rate, kernels, visualizers), grouped by entity. Click a diagnostic to select the relevant entity and see the error in context. diff --git a/libs/@hashintel/petrinaut/docs/simulation.md b/libs/@hashintel/petrinaut/docs/simulation.md index d4e6c2ab455..35cfd82f4b8 100644 --- a/libs/@hashintel/petrinaut/docs/simulation.md +++ b/libs/@hashintel/petrinaut/docs/simulation.md @@ -70,9 +70,9 @@ Each simulation step proceeds in two phases: 1. **Continuous dynamics** -- for every place with dynamics enabled, the differential equation is integrated one step (Euler method, step size = dt). This updates all token dimension values. 2. **Discrete transitions** -- transitions are evaluated in definition order (deterministic, not random). For each transition: - - Checks structural enablement (enough tokens in input places, inhibitor conditions met). + - Checks structural enablement (enough tokens for standard/read input arcs, inhibitor conditions met). - Evaluates the lambda (predicate or stochastic rate). - - If the transition fires, removes input tokens **immediately** (subsequent transitions see the updated state). + - If the transition fires, removes standard input tokens **immediately** (subsequent transitions see the updated state). Read and inhibitor arcs do not consume tokens. All produced output tokens are added at the end of the step. diff --git a/libs/@hashintel/petrinaut/docs/useful-patterns.md b/libs/@hashintel/petrinaut/docs/useful-patterns.md index 8c13af0b87a..57b6f85af6e 100644 --- a/libs/@hashintel/petrinaut/docs/useful-patterns.md +++ b/libs/@hashintel/petrinaut/docs/useful-patterns.md @@ -66,6 +66,18 @@ The number of initial tokens in "Available" determines the resource capacity. If **Example:** the [Production With Machine Failure](examples.md#production-with-machine-failure) example models machines cycling between available, producing, broken, and being repaired states. +## Shared-state checks with read arcs + +Use a [read arc](petri-net-extensions.md#read-arcs) when a transition needs a token to be present, and may need to inspect its typed attributes in the lambda or kernel, but should leave that token in place. + +**Structure:** + +```text +(SensorState) ---> [ReactToReading] (read arc, weight 1) +``` + +The transition can read `tokensByPlace.SensorState[0]` while the sensor-state token remains available for future transitions. + ## Mutual exclusion with inhibitor arcs Use an [inhibitor arc](petri-net-extensions.md#inhibitor-arcs) from a "busy" or "blocked" place to prevent a transition from firing while a condition holds. diff --git a/libs/@hashintel/petrinaut/src/ui/components/arc-item.tsx b/libs/@hashintel/petrinaut/src/ui/components/arc-item.tsx index c3edf55ef26..5dd9485f61e 100644 --- a/libs/@hashintel/petrinaut/src/ui/components/arc-item.tsx +++ b/libs/@hashintel/petrinaut/src/ui/components/arc-item.tsx @@ -6,6 +6,7 @@ import { css, cx } from "@hashintel/ds-helpers/css"; import { NumberInput } from "./number-input"; import { Select, type SelectOption } from "./select"; +import type { InputArc } from "@hashintel/petrinaut-core"; import type { ReactNode } from "react"; // -- Types ------------------------------------------------------------------- @@ -99,6 +100,17 @@ const nameTextStyle = css({ whiteSpace: "nowrap", }); +const arcTypeBadgeStyle = css({ + flexShrink: 0, + borderRadius: "sm", + border: "[1px solid rgba(0, 0, 0, 0.12)]", + color: "neutral.s110", + fontSize: "[10px]", + fontWeight: "medium", + lineHeight: "[12px]", + px: "1", +}); + const chevronStyle = css({ color: "[rgba(0, 0, 0, 0.3)]", }); @@ -235,6 +247,7 @@ interface ArcItemProps { placeId: string; weight: number; color?: string; + arcType?: InputArc["type"]; disabled?: boolean; availablePlaces?: PlaceOption[]; onPlaceChange?: (placeId: string) => void; @@ -247,6 +260,7 @@ export const ArcItem = ({ placeId, weight, color, + arcType = "standard", disabled = false, availablePlaces, onPlaceChange, @@ -270,6 +284,11 @@ export const ArcItem = ({ style={{ backgroundColor: color ?? "#d4d4d4" }} /> {placeName} + {arcType !== "standard" && ( + + {arcType === "read" ? "Read" : "Inhibitor"} + + )} ); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/arc-properties/main.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/arc-properties/main.tsx index 98dcdf73e73..1957b3ea663 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/arc-properties/main.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/arc-properties/main.tsx @@ -2,9 +2,11 @@ import { createContext, use } from "react"; import { Button, Icon } from "@hashintel/ds-components"; import { css } from "@hashintel/ds-helpers/css"; - -const ArcIcon = () => ; -import { parseArcId, type SDCPN } from "@hashintel/petrinaut-core"; +import { + parseArcId, + type InputArc, + type SDCPN, +} from "@hashintel/petrinaut-core"; import { EditorContext } from "../../../../../../react/state/editor-context"; import { useIsReadOnly } from "../../../../../../react/state/use-is-read-only"; @@ -17,6 +19,8 @@ import { UI_MESSAGES } from "../../../../../constants/ui-messages"; import type { PetrinautMutations } from "../../../../../../react"; import type { SubView } from "../../../../../components/sub-view/types"; +const ArcIcon = () => ; + const containerStyle = css({ display: "flex", flexDirection: "column", @@ -38,7 +42,7 @@ interface ArcPropertiesData { sourceName: string; targetName: string; weight: number; - type: "standard" | "inhibitor"; + type: InputArc["type"]; updateArcWeight: PetrinautMutations["updateArcWeight"]; updateArcType: PetrinautMutations["updateArcType"]; removeArc: PetrinautMutations["removeArc"]; @@ -86,11 +90,12 @@ const ArcMainContent: React.FC = () => { updateArcType({ transitionId, placeId, - type: value as "inhibitor" | "standard", + type: value as InputArc["type"], }); }} options={[ { value: "standard", label: "Standard" }, + { value: "read", label: "Read" }, { value: "inhibitor", label: "Inhibitor" }, ]} disabled={isReadOnly} diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/transition-properties/subviews/main.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/transition-properties/subviews/main.tsx index 2d5acdce2fd..bc9c8d6c728 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/transition-properties/subviews/main.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/PropertiesPanel/transition-properties/subviews/main.tsx @@ -143,6 +143,7 @@ const TransitionMainContent: React.FC = () => { placeName={place?.name ?? arc.placeId} weight={arc.weight} color={getPlaceColor(arc.placeId)} + arcType={arc.type} disabled={isReadOnly} availablePlaces={getAvailableInputPlaces(arc.placeId)} onPlaceChange={(newPlaceId) => @@ -182,6 +183,7 @@ const TransitionMainContent: React.FC = () => { placeName={place?.name ?? arc.placeId} weight={arc.weight} color={getPlaceColor(arc.placeId)} + arcType="standard" disabled={isReadOnly} availablePlaces={getAvailableOutputPlaces(arc.placeId)} onPlaceChange={(newPlaceId) => diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/tool-summaries.test.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/tool-summaries.test.ts index 485da294a7a..7302e29ab56 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/tool-summaries.test.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/tool-summaries.test.ts @@ -80,4 +80,25 @@ describe("summarizePetrinautAiToolCall", () => { title: "Updated place Queue", }); }); + + test("summarizes typed input arcs from AI addArc calls", () => { + expect( + summarizePetrinautAiToolCall( + { + input: { + arcDirection: "input", + placeId: "place__buffer", + transitionId: "transition__ship", + type: "read", + weight: 1, + }, + toolName: "addArc", + }, + { definition }, + ), + ).toMatchObject({ + detail: "Buffer <-> Ship", + title: "Added read input arc", + }); + }); }); diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/tool-summaries.ts b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/tool-summaries.ts index a1c64139e99..619eafdf1d6 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/tool-summaries.ts +++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/ai-assistant-panel/tool-summaries.ts @@ -167,6 +167,21 @@ const arcEndpointDetail = ( return `${place} <-> ${transition}`; }; +const addArcTitle = (input: { + arcDirection: "input" | "output"; + type?: "standard" | "read" | "inhibitor"; +}): string => { + if ( + input.arcDirection === "input" && + input.type !== undefined && + input.type !== "standard" + ) { + return `Added ${input.type} input arc`; + } + + return `Added ${input.arcDirection} arc`; +}; + const arcTarget = (input: { arcDirection: "input" | "output"; placeId: string; @@ -301,7 +316,7 @@ export const summarizePetrinautAiToolCall = ( }; case "addArc": return { - title: `Added ${input.arcDirection} arc`, + title: addArcTitle(input), detail: arcEndpointDetail(definition, input), target: selectionTarget(arcTarget(input)), }; diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/arc.tsx b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/arc.tsx index 9316a0a54ad..5c68d1ff53d 100644 --- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/arc.tsx +++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/components/arc.tsx @@ -13,13 +13,16 @@ import { EditorContext } from "../../../../react/state/editor-context"; import { UserSettingsContext } from "../../../../react/state/user-settings-context"; import { useFiringDelta } from "../hooks/use-firing-delta"; -import type { ArcEdgeType } from "../reactflow-types"; +import type { ArcData, ArcEdgeType } from "../reactflow-types"; const BASE_STROKE_WIDTH = 2; const ANIMATION_DURATION_MS = 500; const INHIBITOR_DASH_PATTERN = "10 5 3 3 3 5"; +const READ_DASH_PATTERN = "2 6"; const INHIBITOR_MARKER_RADIUS = 10; const INHIBITOR_MARKER_SIZE = (INHIBITOR_MARKER_RADIUS + BASE_STROKE_WIDTH) * 2; +const READ_MARKER_RADIUS = 4; +const READ_MARKER_SIZE = (READ_MARKER_RADIUS + BASE_STROKE_WIDTH) * 2; type AnimationState = { animation: Animation; @@ -131,6 +134,18 @@ const weightTextStyle = css({ pointerEvents: "none", }); +function getArcStrokeDasharray( + arcType: ArcData["arcType"] | undefined, +): string | undefined { + if (arcType === "inhibitor") { + return INHIBITOR_DASH_PATTERN; + } + if (arcType === "read") { + return READ_DASH_PATTERN; + } + return undefined; +} + /** * Custom cubic bezier path between two points. * Control point offsets are proportional to the horizontal distance @@ -191,6 +206,7 @@ export const Arc: React.FC> = ({ const selected = isSelected(id); const inhibitorMarkerId = `inhibitor-circle-${id}`; + const readMarkerId = `read-dot-${id}`; // Track firing count delta for simulation visualization const firingDelta = useFiringDelta(data?.frame?.firingCount ?? null); @@ -236,31 +252,58 @@ export const Arc: React.FC> = ({ } let strokeColor = style?.stroke ?? "#b1b1b7"; + const arcType = data?.arcType; + const strokeDasharray = getArcStrokeDasharray(arcType); + const markerEndOverride = + arcType === "inhibitor" + ? `url(#${inhibitorMarkerId})` + : arcType === "read" + ? `url(#${readMarkerId})` + : markerEnd; return ( <> - {/* Custom SVG marker definition for inhibitor arcs (empty circle) */} - {data?.arcType === "inhibitor" && ( + {(arcType === "inhibitor" || arcType === "read") && ( - - - + {arcType === "inhibitor" ? ( + + + + ) : ( + + + + )} )} @@ -280,9 +323,8 @@ export const Arc: React.FC> = ({ fill="none" stroke={strokeColor} strokeWidth={BASE_STROKE_WIDTH} - strokeDasharray={ - data?.arcType === "inhibitor" ? INHIBITOR_DASH_PATTERN : undefined - } + strokeDasharray={strokeDasharray} + strokeLinecap={arcType === "read" ? "round" : undefined} style={{ pointerEvents: "none" }} /> @@ -290,16 +332,13 @@ export const Arc: React.FC> = ({