Skip to content
Draft
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
28 changes: 28 additions & 0 deletions libs/@hashintel/petrinaut-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,31 @@ language services, and supporting domain utilities.

This package intentionally has no React or UI dependencies. The visual editor
package, `@hashintel/petrinaut`, builds on top of it.

## Handle Creation

Petrinaut reads and writes documents through `PetrinautDocHandle`. Use
`createJsonDocHandle()` for an in-memory handle with patch events,
extension sanitization, and optional undo/redo history:

```ts
import { createJsonDocHandle } from "@hashintel/petrinaut-core";

const handle = createJsonDocHandle({
id: "my-net",
initial: {
places: [{ id: "p1", name: "P1", x: 0, y: 0 }],
transitions: [],
},
});
```

`initial` accepts `SDCPNInput`, a loose document shape for host integrations.
Plain-net defaults are filled in automatically: omitted arc weights become `1`,
input arc types become `"standard"`, extension arrays default to `[]`, and
disabled extension data is sanitized according to handle capabilities.

When another application is the source of truth, implement `PetrinautDocHandle`
directly so editor edits can emit `source: "local"` and host/store updates can
emit `source: "remote"`. The visual editor package has a fuller guide in
`@hashintel/petrinaut/INTEGRATION.md`.
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ import {
type PetrinautHandleCapabilities,
} from "../../extensions";
import { createReadableStore } from "../../store";
import { normalizeSDCPN } from "../../types/sdcpn-input";

import type { SDCPN } from "../../types/sdcpn";
import type { SDCPNInput } from "../../types/sdcpn-input";
import type {
DocChangeEvent,
DocHandleState,
Expand Down Expand Up @@ -50,7 +52,14 @@ const DEFAULT_HISTORY_LIMIT = 50;

export type CreateJsonDocHandleOptions = {
id?: DocumentId;
initial: SDCPN;
/**
* Initial document. Accepts a loose {@link SDCPNInput} β€” extension fields
* (`colorId`, `lambdaCode`, arc `type`/`weight`, the `types` /
* `parameters` / `differentialEquations` arrays, ...) may be omitted and are
* filled with plain-net defaults via {@link normalizeSDCPN}. A complete
* {@link SDCPN} is a valid input too.
*/
initial: SDCPNInput;
capabilities?: PetrinautHandleCapabilities;
/**
* Maximum number of history checkpoints retained. Older entries are dropped
Expand Down Expand Up @@ -82,7 +91,7 @@ export function createJsonDocHandle(
const subscribers = new Set<(event: DocChangeEvent) => void>();

let current: SDCPN = sanitizeSDCPNForExtensions(
opts.initial,
normalizeSDCPN(opts.initial),
resolvedCapabilities.extensions,
);

Expand Down
8 changes: 8 additions & 0 deletions libs/@hashintel/petrinaut-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,14 @@ export type {

// --- Domain types ---
export type * from "./types/sdcpn";
export { normalizeSDCPN } from "./types/sdcpn-input";
export type {
SDCPNInput,
SDCPNInputArcInput,
SDCPNOutputArcInput,
SDCPNPlaceInput,
SDCPNTransitionInput,
} from "./types/sdcpn-input";
export { parseArcId } from "./types/selection";
export type * from "./types/selection";

Expand Down
141 changes: 141 additions & 0 deletions libs/@hashintel/petrinaut-core/src/types/sdcpn-input.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { describe, expect, it } from "vitest";

import { isSDCPNEqual } from "../lib/deep-equal";

import { normalizeSDCPN } from "./sdcpn-input";

import type { SDCPN } from "./sdcpn";

describe("normalizeSDCPN", () => {
it("fills plain-net defaults for omitted place and transition fields", () => {
const result = normalizeSDCPN({
places: [{ id: "p1", name: "P1", x: 1, y: 2 }],
transitions: [
{
id: "t1",
name: "T1",
inputArcs: [{ placeId: "p1" }],
outputArcs: [{ placeId: "p1" }],
x: 3,
y: 4,
},
],
});

expect(result.places[0]).toEqual({
id: "p1",
name: "P1",
colorId: null,
dynamicsEnabled: false,
differentialEquationId: null,
x: 1,
y: 2,
});
expect(result.transitions[0]).toEqual({
id: "t1",
name: "T1",
inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }],
outputArcs: [{ placeId: "p1", weight: 1 }],
lambdaType: "predicate",
lambdaCode: "",
transitionKernelCode: "",
x: 3,
y: 4,
});
expect(result.types).toEqual([]);
expect(result.parameters).toEqual([]);
expect(result.differentialEquations).toEqual([]);
});

it("omits optional keys that are absent so structural equality is preserved", () => {
const result = normalizeSDCPN({
places: [{ id: "p1", name: "P1", x: 0, y: 0 }],
transitions: [],
});

expect(Object.hasOwn(result.places[0]!, "visualizerCode")).toBe(false);
expect(Object.hasOwn(result.places[0]!, "showAsInitialState")).toBe(false);
expect(Object.hasOwn(result, "scenarios")).toBe(false);
expect(Object.hasOwn(result, "metrics")).toBe(false);
});

it("preserves provided extension values instead of overwriting them", () => {
const result = normalizeSDCPN({
places: [
{
id: "p1",
name: "P1",
x: 0,
y: 0,
colorId: "c1",
dynamicsEnabled: true,
differentialEquationId: "d1",
visualizerCode: "code",
showAsInitialState: true,
},
],
transitions: [
{
id: "t1",
name: "T1",
inputArcs: [{ placeId: "p1", weight: 2, type: "inhibitor" }],
outputArcs: [{ placeId: "p1", weight: 3 }],
x: 0,
y: 0,
lambdaType: "stochastic",
lambdaCode: "l",
transitionKernelCode: "k",
},
],
});

expect(result.places[0]).toMatchObject({
colorId: "c1",
dynamicsEnabled: true,
differentialEquationId: "d1",
visualizerCode: "code",
showAsInitialState: true,
});
expect(result.transitions[0]).toMatchObject({
inputArcs: [{ placeId: "p1", weight: 2, type: "inhibitor" }],
outputArcs: [{ placeId: "p1", weight: 3 }],
lambdaType: "stochastic",
lambdaCode: "l",
transitionKernelCode: "k",
});
});

it("is idempotent on an already-complete SDCPN", () => {
const complete: SDCPN = {
places: [
{
id: "p1",
name: "P1",
colorId: null,
dynamicsEnabled: false,
differentialEquationId: null,
x: 0,
y: 0,
},
],
transitions: [
{
id: "t1",
name: "T1",
inputArcs: [{ placeId: "p1", weight: 1, type: "standard" }],
outputArcs: [{ placeId: "p1", weight: 1 }],
lambdaType: "predicate",
lambdaCode: "",
transitionKernelCode: "",
x: 0,
y: 0,
},
],
types: [],
parameters: [],
differentialEquations: [],
};

expect(isSDCPNEqual(normalizeSDCPN(complete), complete)).toBe(true);
});
});
147 changes: 147 additions & 0 deletions libs/@hashintel/petrinaut-core/src/types/sdcpn-input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import type {
Color,
DifferentialEquation,
ID,
InputArcType,
Metric,
Parameter,
Scenario,
SDCPN,
} from "./sdcpn";

/**
* Loose, authoring-friendly variant of {@link SDCPN}.
*
* The canonical {@link SDCPN} type is what the editor, simulation, LSP, and
* serialization consume, so every extension field is present on it. When a
* host integrates Petrinaut and maps its own domain model into a Petri net, it
* usually doesn't care about colours, dynamics, stochasticity, or parameters β€”
* yet the strict type still forces it to spell out `colorId: null`,
* `lambdaCode: ""`, empty `types`/`parameters`/`differentialEquations` arrays,
* and so on for every node.
*
* `SDCPNInput` makes all of that optional. Pass it to
* {@link normalizeSDCPN} (or straight to `createJsonDocHandle`, which
* normalizes internally) to get a fully-populated {@link SDCPN} with plain-net
* defaults filled in. A complete {@link SDCPN} is always a valid `SDCPNInput`,
* so existing callers are unaffected.
*/
export type SDCPNInput = {
places: SDCPNPlaceInput[];
transitions: SDCPNTransitionInput[];
/** @default [] */
types?: Color[];
/** @default [] */
parameters?: Parameter[];
/** @default [] */
differentialEquations?: DifferentialEquation[];
scenarios?: Scenario[];
metrics?: Metric[];
};

export type SDCPNPlaceInput = {
id: ID;
name: string;
x: number;
y: number;
/** @default null */
colorId?: ID | null;
/** @default false */
dynamicsEnabled?: boolean;
/** @default null */
differentialEquationId?: ID | null;
visualizerCode?: string;
showAsInitialState?: boolean;
};

export type SDCPNInputArcInput = {
placeId: string;
/** @default 1 */
weight?: number;
/** @default "standard" */
type?: InputArcType;
};

export type SDCPNOutputArcInput = {
placeId: string;
/** @default 1 */
weight?: number;
};

export type SDCPNTransitionInput = {
id: ID;
name: string;
inputArcs: SDCPNInputArcInput[];
outputArcs: SDCPNOutputArcInput[];
x: number;
y: number;
/** @default "predicate" */
lambdaType?: "predicate" | "stochastic";
/** @default "" */
lambdaCode?: string;
/** @default "" */
transitionKernelCode?: string;
};

/**
* Fill plain-net defaults into an {@link SDCPNInput} to produce a canonical
* {@link SDCPN}. Idempotent: normalizing an already-complete `SDCPN` returns an
* equivalent value.
*
* Optional output fields (`visualizerCode`, `showAsInitialState`, `scenarios`,
* `metrics`) 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`).
*/
export function normalizeSDCPN(input: SDCPNInput): SDCPN {
const result: SDCPN = {
places: input.places.map((place) => {
const normalized: SDCPN["places"][number] = {
id: place.id,
name: place.name,
colorId: place.colorId ?? null,
dynamicsEnabled: place.dynamicsEnabled ?? false,
differentialEquationId: place.differentialEquationId ?? null,
x: place.x,
y: place.y,
};
if (place.visualizerCode !== undefined) {
normalized.visualizerCode = place.visualizerCode;
}
if (place.showAsInitialState !== undefined) {
normalized.showAsInitialState = place.showAsInitialState;
}
return normalized;
}),
transitions: input.transitions.map((transition) => ({
id: transition.id,
name: transition.name,
inputArcs: transition.inputArcs.map((arc) => ({
placeId: arc.placeId,
weight: arc.weight ?? 1,
type: arc.type ?? "standard",
})),
outputArcs: transition.outputArcs.map((arc) => ({
placeId: arc.placeId,
weight: arc.weight ?? 1,
})),
lambdaType: transition.lambdaType ?? "predicate",
lambdaCode: transition.lambdaCode ?? "",
transitionKernelCode: transition.transitionKernelCode ?? "",
x: transition.x,
y: transition.y,
})),
types: input.types ?? [],
parameters: input.parameters ?? [],
differentialEquations: input.differentialEquations ?? [],
};

if (input.scenarios !== undefined) {
result.scenarios = input.scenarios;
}
if (input.metrics !== undefined) {
result.metrics = input.metrics;
}

return result;
}
Loading
Loading