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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/read-arcs-core.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hashintel/petrinaut-core": patch
---

Support read input arcs across SDCPN parsing, simulation, AI arc creation, and typed code inputs.
5 changes: 5 additions & 0 deletions .changeset/read-arcs-editor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hashintel/petrinaut": patch
---

Add editor support for read input arcs, including arc controls and distinct canvas rendering.
19 changes: 18 additions & 1 deletion libs/@hashintel/petrinaut-core/src/action-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
colorSchema,
differentialEquationSchema,
idSchema,
inputArcSchema,
nodePositionCommitSchema,
parameterSchema,
placeSchema,
Expand Down Expand Up @@ -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,
});
Comment thread
kube marked this conversation as resolved.
}
})
.meta({ description: "Add an input or output arc to a transition." }),
removeArc: z
Expand All @@ -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.",
}),
})
Expand Down
58 changes: 58 additions & 0 deletions libs/@hashintel/petrinaut-core/src/actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion libs/@hashintel/petrinaut-core/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
16 changes: 16 additions & 0 deletions libs/@hashintel/petrinaut-core/src/ai.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, expect, test } from "vitest";
import { z } from "zod";

import {
aiCommandActionInputSchemas,
Expand Down Expand Up @@ -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<string, unknown>;
type?: unknown;
} & Record<string, unknown>;

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);
Expand Down
2 changes: 1 addition & 1 deletion libs/@hashintel/petrinaut-core/src/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 \`{ <elementName>: number }\`. Inhibitor arcs and uncoloured input places are NOT in \`input\`. Predicate β†’ boolean; stochastic β†’ non-negative finite rate in firings per simulation second (0 disables, Infinity always fires). Must be deterministic.
- Transition 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 \`{ <elementName>: 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 \`{ <elementName>: 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 }) => <JSX/>)\`. Classic React runtime β€” do NOT import React, do NOT use \`<>…</>\` fragments, do NOT use hooks. Convention: return a sized \`<svg viewBox="0 0 W H">…</svg>\`.
Expand Down
41 changes: 41 additions & 0 deletions libs/@hashintel/petrinaut-core/src/clipboard/serialize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion libs/@hashintel/petrinaut-core/src/clipboard/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
6 changes: 3 additions & 3 deletions libs/@hashintel/petrinaut-core/src/default-codes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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.
*/
Expand Down Expand Up @@ -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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion libs/@hashintel/petrinaut-core/src/file-format/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,8 @@ export function generateVirtualFiles(sdcpn: SDCPN): Map<string, VirtualFile> {

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;
}
Expand Down
14 changes: 8 additions & 6 deletions libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { variableNameSchema } from "../validation/variable-name";
import type {
Color,
DifferentialEquation,
InputArc,
Parameter,
Place,
Transition,
Expand Down Expand Up @@ -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<InputArc>;

export const outputArcSchema = z
.strictObject({
Expand Down Expand Up @@ -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:
Expand All @@ -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`).",
Expand Down
Loading
Loading