From ebf0d3d8a9dbc9ff220cd563bd5117c119cd9789 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 20 Feb 2026 17:25:05 +0100 Subject: [PATCH 01/24] H-5641: Extract virtual file generation and add global Distribution type Extract `generateVirtualFiles` into its own module and add a global `sdcpn-lib.d.ts` virtual file exposing `DistributionGaussian` and `Distribution` types for use in transition kernel code. Co-Authored-By: Claude Opus 4.6 --- .../lsp/lib/create-sdcpn-language-service.ts | 222 +---------------- .../petrinaut/src/lsp/lib/file-paths.ts | 5 + .../src/lsp/lib/generate-virtual-files.ts | 231 ++++++++++++++++++ 3 files changed, 237 insertions(+), 221 deletions(-) create mode 100644 libs/@hashintel/petrinaut/src/lsp/lib/generate-virtual-files.ts diff --git a/libs/@hashintel/petrinaut/src/lsp/lib/create-sdcpn-language-service.ts b/libs/@hashintel/petrinaut/src/lsp/lib/create-sdcpn-language-service.ts index 7ad9a30b46a..bc294c553cd 100644 --- a/libs/@hashintel/petrinaut/src/lsp/lib/create-sdcpn-language-service.ts +++ b/libs/@hashintel/petrinaut/src/lsp/lib/create-sdcpn-language-service.ts @@ -6,227 +6,7 @@ import { type LanguageServiceHostController, type VirtualFile, } from "./create-language-service-host"; -import { getItemFilePath } from "./file-paths"; - -/** - * Sanitizes a color ID to be a valid TypeScript identifier. - * Removes all characters that are not valid suffixes for TypeScript identifiers - * (keeps only letters, digits, and underscores). - */ -function sanitizeColorId(colorId: string): string { - return colorId.replace(/[^a-zA-Z0-9_]/g, ""); -} - -/** - * Maps SDCPN element types to TypeScript types - */ -function toTsType(type: "real" | "integer" | "boolean"): string { - return type === "boolean" ? "boolean" : "number"; -} - -/** - * Generates virtual files for all SDCPN entities - */ -function generateVirtualFiles(sdcpn: SDCPN): Map { - const files = new Map(); - - // Build lookup maps for places and types - const placeById = new Map(sdcpn.places.map((place) => [place.id, place])); - const colorById = new Map(sdcpn.types.map((color) => [color.id, color])); - - // Generate parameters type definition - const parametersProperties = sdcpn.parameters - .map((param) => ` "${param.variableName}": ${toTsType(param.type)};`) - .join("\n"); - - files.set(getItemFilePath("parameters-defs"), { - content: `export type Parameters = {\n${parametersProperties}\n};`, - }); - - // Generate type definitions for each color - for (const color of sdcpn.types) { - const sanitizedColorId = sanitizeColorId(color.id); - const properties = color.elements - .map((el) => ` ${el.name}: ${toTsType(el.type)};`) - .join("\n"); - - files.set(getItemFilePath("color-defs", { colorId: color.id }), { - content: `export type Color_${sanitizedColorId} = {\n${properties}\n}`, - }); - } - - // Generate files for each differential equation - for (const de of sdcpn.differentialEquations) { - const sanitizedColorId = sanitizeColorId(de.colorId); - const deDefsPath = getItemFilePath("differential-equation-defs", { - id: de.id, - }); - const deCodePath = getItemFilePath("differential-equation-code", { - id: de.id, - }); - const parametersDefsPath = getItemFilePath("parameters-defs"); - const colorDefsPath = getItemFilePath("color-defs", { - colorId: de.colorId, - }); - - // Type definitions file - files.set(deDefsPath, { - content: [ - `import type { Parameters } from "${parametersDefsPath}";`, - `import type { Color_${sanitizedColorId} } from "${colorDefsPath}";`, - ``, - `type Tokens = Array;`, - `export type Dynamics = (fn: (tokens: Tokens, parameters: Parameters) => Tokens) => void;`, - ].join("\n"), - }); - - // User code file with injected declarations - files.set(deCodePath, { - prefix: [ - `import type { Dynamics } from "${deDefsPath}";`, - // TODO: Directly wrap user code in Dynamics call to remove need for user to write it. - `declare const Dynamics: Dynamics;`, - "", - ].join("\n"), - content: de.code, - }); - } - - // Generate files for each transition - for (const transition of sdcpn.transitions) { - const parametersDefsPath = getItemFilePath("parameters-defs"); - const lambdaDefsPath = getItemFilePath("transition-lambda-defs", { - transitionId: transition.id, - }); - const lambdaCodePath = getItemFilePath("transition-lambda-code", { - transitionId: transition.id, - }); - const kernelDefsPath = getItemFilePath("transition-kernel-defs", { - transitionId: transition.id, - }); - const kernelCodePath = getItemFilePath("transition-kernel-code", { - transitionId: transition.id, - }); - - // Build input type: { [placeName]: [Token, Token, ...] } based on input arcs - const inputTypeImports: string[] = []; - const inputTypeProperties: string[] = []; - - for (const arc of transition.inputArcs) { - const place = placeById.get(arc.placeId); - if (!place?.colorId) { - continue; - } - const color = colorById.get(place.colorId); - if (!color) { - continue; - } - - const sanitizedColorId = sanitizeColorId(color.id); - const colorDefsPath = getItemFilePath("color-defs", { - colorId: color.id, - }); - // Only add import if not already present (multiple arcs may share the same color) - const importStatement = `import type { Color_${sanitizedColorId} } from "${colorDefsPath}";`; - if (!inputTypeImports.includes(importStatement)) { - inputTypeImports.push(importStatement); - } - const tokenTuple = Array.from({ length: arc.weight }) - .fill(`Color_${sanitizedColorId}`) - .join(", "); - inputTypeProperties.push(` "${place.name}": [${tokenTuple}];`); - } - - // Build output type: { [placeName]: [Token, Token, ...] } based on output arcs - const outputTypeImports: string[] = []; - const outputTypeProperties: string[] = []; - - for (const arc of transition.outputArcs) { - const place = placeById.get(arc.placeId); - if (!place?.colorId) { - continue; - } - const color = colorById.get(place.colorId); - if (!color) { - continue; - } - - const sanitizedColorId = sanitizeColorId(color.id); - const colorDefsPath = getItemFilePath("color-defs", { - colorId: color.id, - }); - // Only add import if not already present from input arcs or previous output arcs - const importStatement = `import type { Color_${sanitizedColorId} } from "${colorDefsPath}";`; - if ( - !inputTypeImports.includes(importStatement) && - !outputTypeImports.includes(importStatement) - ) { - outputTypeImports.push(importStatement); - } - const tokenTuple = Array.from({ length: arc.weight }) - .fill(`Color_${sanitizedColorId}`) - .join(", "); - outputTypeProperties.push(` "${place.name}": [${tokenTuple}];`); - } - - const allImports = [...inputTypeImports, ...outputTypeImports]; - const inputType = - inputTypeProperties.length > 0 - ? `{\n${inputTypeProperties.join("\n")}\n}` - : "Record"; - const outputType = - outputTypeProperties.length > 0 - ? `{\n${outputTypeProperties.join("\n")}\n}` - : "Record"; - const lambdaReturnType = - transition.lambdaType === "predicate" ? "boolean" : "number"; - - // Lambda definitions file - files.set(lambdaDefsPath, { - content: [ - `import type { Parameters } from "${parametersDefsPath}";`, - ...allImports, - ``, - `export type Input = ${inputType};`, - `export type Lambda = (fn: (input: Input, parameters: Parameters) => ${lambdaReturnType}) => void;`, - ].join("\n"), - }); - - // Lambda code file - files.set(lambdaCodePath, { - prefix: [ - `import type { Lambda } from "${lambdaDefsPath}";`, - `declare const Lambda: Lambda;`, - "", - ].join("\n"), - content: transition.lambdaCode, - }); - - // TransitionKernel definitions file - files.set(kernelDefsPath, { - content: [ - `import type { Parameters } from "${parametersDefsPath}";`, - ...allImports, - ``, - `export type Input = ${inputType};`, - `export type Output = ${outputType};`, - `export type TransitionKernel = (fn: (input: Input, parameters: Parameters) => Output) => void;`, - ].join("\n"), - }); - - // TransitionKernel code file - files.set(kernelCodePath, { - prefix: [ - `import type { TransitionKernel } from "${kernelDefsPath}";`, - `declare const TransitionKernel: TransitionKernel;`, - "", - ].join("\n"), - content: transition.transitionKernelCode, - }); - } - - return files; -} +import { generateVirtualFiles } from "./generate-virtual-files"; /** * Adjusts diagnostic positions to account for injected prefix diff --git a/libs/@hashintel/petrinaut/src/lsp/lib/file-paths.ts b/libs/@hashintel/petrinaut/src/lsp/lib/file-paths.ts index 3d81b931a29..197dda2fb6b 100644 --- a/libs/@hashintel/petrinaut/src/lsp/lib/file-paths.ts +++ b/libs/@hashintel/petrinaut/src/lsp/lib/file-paths.ts @@ -4,6 +4,7 @@ */ export type SDCPNFileType = + | "sdcpn-lib-defs" | "parameters-defs" | "color-defs" | "differential-equation-defs" @@ -14,6 +15,7 @@ export type SDCPNFileType = | "transition-kernel-code"; type FilePathParams = { + "sdcpn-lib-defs": Record; "parameters-defs": Record; "color-defs": { colorId: string }; "differential-equation-defs": { id: string }; @@ -40,6 +42,9 @@ export const getItemFilePath = ( const params = args[0]; switch (fileType) { + case "sdcpn-lib-defs": + return "/sdcpn-lib.d.ts"; + case "parameters-defs": return "/parameters/defs.d.ts"; diff --git a/libs/@hashintel/petrinaut/src/lsp/lib/generate-virtual-files.ts b/libs/@hashintel/petrinaut/src/lsp/lib/generate-virtual-files.ts new file mode 100644 index 00000000000..5b7605d2876 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/lsp/lib/generate-virtual-files.ts @@ -0,0 +1,231 @@ +import type { SDCPN } from "../../core/types/sdcpn"; +import type { VirtualFile } from "./create-language-service-host"; +import { getItemFilePath } from "./file-paths"; + +/** + * Sanitizes a color ID to be a valid TypeScript identifier. + * Removes all characters that are not valid suffixes for TypeScript identifiers + * (keeps only letters, digits, and underscores). + */ +function sanitizeColorId(colorId: string): string { + return colorId.replace(/[^a-zA-Z0-9_]/g, ""); +} + +/** + * Maps SDCPN element types to TypeScript types + */ +function toTsType(type: "real" | "integer" | "boolean"): string { + return type === "boolean" ? "boolean" : "number"; +} + +/** + * Generates virtual files for all SDCPN entities + */ +export function generateVirtualFiles(sdcpn: SDCPN): Map { + const files = new Map(); + + // Generate global SDCPN library definitions + files.set(getItemFilePath("sdcpn-lib-defs"), { + content: [ + `type Distribution = {};`, + `declare function DistributionGaussian(mean: number, deviation: number): Distribution;`, + ].join("\n"), + }); + + // Build lookup maps for places and types + const placeById = new Map(sdcpn.places.map((place) => [place.id, place])); + const colorById = new Map(sdcpn.types.map((color) => [color.id, color])); + + // Generate parameters type definition + const parametersProperties = sdcpn.parameters + .map((param) => ` "${param.variableName}": ${toTsType(param.type)};`) + .join("\n"); + + files.set(getItemFilePath("parameters-defs"), { + content: `export type Parameters = {\n${parametersProperties}\n};`, + }); + + // Generate type definitions for each color + for (const color of sdcpn.types) { + const sanitizedColorId = sanitizeColorId(color.id); + const properties = color.elements + .map((el) => ` ${el.name}: ${toTsType(el.type)};`) + .join("\n"); + + files.set(getItemFilePath("color-defs", { colorId: color.id }), { + content: `export type Color_${sanitizedColorId} = {\n${properties}\n}`, + }); + } + + // Generate files for each differential equation + for (const de of sdcpn.differentialEquations) { + const sanitizedColorId = sanitizeColorId(de.colorId); + const deDefsPath = getItemFilePath("differential-equation-defs", { + id: de.id, + }); + const deCodePath = getItemFilePath("differential-equation-code", { + id: de.id, + }); + const parametersDefsPath = getItemFilePath("parameters-defs"); + const colorDefsPath = getItemFilePath("color-defs", { + colorId: de.colorId, + }); + + // Type definitions file + files.set(deDefsPath, { + content: [ + `import type { Parameters } from "${parametersDefsPath}";`, + `import type { Color_${sanitizedColorId} } from "${colorDefsPath}";`, + ``, + `type Tokens = Array;`, + `export type Dynamics = (fn: (tokens: Tokens, parameters: Parameters) => Tokens) => void;`, + ].join("\n"), + }); + + // User code file with injected declarations + files.set(deCodePath, { + prefix: [ + `import type { Dynamics } from "${deDefsPath}";`, + // TODO: Directly wrap user code in Dynamics call to remove need for user to write it. + `declare const Dynamics: Dynamics;`, + "", + ].join("\n"), + content: de.code, + }); + } + + // Generate files for each transition + for (const transition of sdcpn.transitions) { + const parametersDefsPath = getItemFilePath("parameters-defs"); + const lambdaDefsPath = getItemFilePath("transition-lambda-defs", { + transitionId: transition.id, + }); + const lambdaCodePath = getItemFilePath("transition-lambda-code", { + transitionId: transition.id, + }); + const kernelDefsPath = getItemFilePath("transition-kernel-defs", { + transitionId: transition.id, + }); + const kernelCodePath = getItemFilePath("transition-kernel-code", { + transitionId: transition.id, + }); + + // Build input type: { [placeName]: [Token, Token, ...] } based on input arcs + const inputTypeImports: string[] = []; + const inputTypeProperties: string[] = []; + + for (const arc of transition.inputArcs) { + const place = placeById.get(arc.placeId); + if (!place?.colorId) { + continue; + } + const color = colorById.get(place.colorId); + if (!color) { + continue; + } + + const sanitizedColorId = sanitizeColorId(color.id); + const colorDefsPath = getItemFilePath("color-defs", { + colorId: color.id, + }); + // Only add import if not already present (multiple arcs may share the same color) + const importStatement = `import type { Color_${sanitizedColorId} } from "${colorDefsPath}";`; + if (!inputTypeImports.includes(importStatement)) { + inputTypeImports.push(importStatement); + } + const tokenTuple = Array.from({ length: arc.weight }) + .fill(`Color_${sanitizedColorId}`) + .join(", "); + inputTypeProperties.push(` "${place.name}": [${tokenTuple}];`); + } + + // Build output type: { [placeName]: [Token, Token, ...] } based on output arcs + const outputTypeImports: string[] = []; + const outputTypeProperties: string[] = []; + + for (const arc of transition.outputArcs) { + const place = placeById.get(arc.placeId); + if (!place?.colorId) { + continue; + } + const color = colorById.get(place.colorId); + if (!color) { + continue; + } + + const sanitizedColorId = sanitizeColorId(color.id); + const colorDefsPath = getItemFilePath("color-defs", { + colorId: color.id, + }); + // Only add import if not already present from input arcs or previous output arcs + const importStatement = `import type { Color_${sanitizedColorId} } from "${colorDefsPath}";`; + if ( + !inputTypeImports.includes(importStatement) && + !outputTypeImports.includes(importStatement) + ) { + outputTypeImports.push(importStatement); + } + const tokenTuple = Array.from({ length: arc.weight }) + .fill(`Color_${sanitizedColorId}`) + .join(", "); + outputTypeProperties.push(` "${place.name}": [${tokenTuple}];`); + } + + const allImports = [...inputTypeImports, ...outputTypeImports]; + const inputType = + inputTypeProperties.length > 0 + ? `{\n${inputTypeProperties.join("\n")}\n}` + : "Record"; + const outputType = + outputTypeProperties.length > 0 + ? `{\n${outputTypeProperties.join("\n")}\n}` + : "Record"; + const lambdaReturnType = + transition.lambdaType === "predicate" ? "boolean" : "number"; + + // Lambda definitions file + files.set(lambdaDefsPath, { + content: [ + `import type { Parameters } from "${parametersDefsPath}";`, + ...allImports, + ``, + `export type Input = ${inputType};`, + `export type Lambda = (fn: (input: Input, parameters: Parameters) => ${lambdaReturnType}) => void;`, + ].join("\n"), + }); + + // Lambda code file + files.set(lambdaCodePath, { + prefix: [ + `import type { Lambda } from "${lambdaDefsPath}";`, + `declare const Lambda: Lambda;`, + "", + ].join("\n"), + content: transition.lambdaCode, + }); + + // TransitionKernel definitions file + files.set(kernelDefsPath, { + content: [ + `import type { Parameters } from "${parametersDefsPath}";`, + ...allImports, + ``, + `export type Input = ${inputType};`, + `export type Output = ${outputType};`, + `export type TransitionKernel = (fn: (input: Input, parameters: Parameters) => Output) => void;`, + ].join("\n"), + }); + + // TransitionKernel code file + files.set(kernelCodePath, { + prefix: [ + `import type { TransitionKernel } from "${kernelDefsPath}";`, + `declare const TransitionKernel: TransitionKernel;`, + "", + ].join("\n"), + content: transition.transitionKernelCode, + }); + } + + return files; +} From b7a426b3c41d8f82c75460b8de2080c9142f59b9 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 20 Feb 2026 17:28:30 +0100 Subject: [PATCH 02/24] H-5641: Change DistributionGaussian to Distribution.Gaussian namespace Use a namespace pattern so additional distribution types can be added alongside Gaussian (e.g. Distribution.Uniform, Distribution.Exponential). Co-Authored-By: Claude Opus 4.6 --- .../petrinaut/src/lsp/lib/generate-virtual-files.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/libs/@hashintel/petrinaut/src/lsp/lib/generate-virtual-files.ts b/libs/@hashintel/petrinaut/src/lsp/lib/generate-virtual-files.ts index 5b7605d2876..ef364036937 100644 --- a/libs/@hashintel/petrinaut/src/lsp/lib/generate-virtual-files.ts +++ b/libs/@hashintel/petrinaut/src/lsp/lib/generate-virtual-files.ts @@ -28,7 +28,9 @@ export function generateVirtualFiles(sdcpn: SDCPN): Map { files.set(getItemFilePath("sdcpn-lib-defs"), { content: [ `type Distribution = {};`, - `declare function DistributionGaussian(mean: number, deviation: number): Distribution;`, + `declare namespace Distribution {`, + ` function Gaussian(mean: number, deviation: number): Distribution;`, + `}`, ].join("\n"), }); From 172e4327f3e939a6f54b0c2b45eb1794cd4643d3 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 20 Feb 2026 21:17:05 +0100 Subject: [PATCH 03/24] H-5641: Add runtime support for probabilistic transition kernels Add Distribution.Gaussian and Distribution.Uniform as runtime-available functions in transition kernel code. Distribution values in kernel output are sampled using the seeded RNG (Box-Muller for Gaussian) before being stored as token values. Co-Authored-By: Claude Opus 4.6 --- .../src/lsp/lib/generate-virtual-files.ts | 4 +- .../simulation/simulator/compile-user-code.ts | 3 + .../simulator/compute-possible-transition.ts | 21 +++++- .../src/simulation/simulator/distribution.ts | 71 +++++++++++++++++++ .../src/simulation/simulator/types.ts | 3 +- 5 files changed, 97 insertions(+), 5 deletions(-) create mode 100644 libs/@hashintel/petrinaut/src/simulation/simulator/distribution.ts diff --git a/libs/@hashintel/petrinaut/src/lsp/lib/generate-virtual-files.ts b/libs/@hashintel/petrinaut/src/lsp/lib/generate-virtual-files.ts index ef364036937..1b0ff4c72d1 100644 --- a/libs/@hashintel/petrinaut/src/lsp/lib/generate-virtual-files.ts +++ b/libs/@hashintel/petrinaut/src/lsp/lib/generate-virtual-files.ts @@ -28,8 +28,10 @@ export function generateVirtualFiles(sdcpn: SDCPN): Map { files.set(getItemFilePath("sdcpn-lib-defs"), { content: [ `type Distribution = {};`, + `type Probabilistic = { [K in keyof T]: T[K] extends number ? number | Distribution : T[K] };`, `declare namespace Distribution {`, ` function Gaussian(mean: number, deviation: number): Distribution;`, + ` function Uniform(min: number, max: number): Distribution;`, `}`, ].join("\n"), }); @@ -168,7 +170,7 @@ export function generateVirtualFiles(sdcpn: SDCPN): Map { outputTypeImports.push(importStatement); } const tokenTuple = Array.from({ length: arc.weight }) - .fill(`Color_${sanitizedColorId}`) + .fill(`Probabilistic`) .join(", "); outputTypeProperties.push(` "${place.name}": [${tokenTuple}];`); } diff --git a/libs/@hashintel/petrinaut/src/simulation/simulator/compile-user-code.ts b/libs/@hashintel/petrinaut/src/simulation/simulator/compile-user-code.ts index 8a1caeda371..e4b13065f3f 100644 --- a/libs/@hashintel/petrinaut/src/simulation/simulator/compile-user-code.ts +++ b/libs/@hashintel/petrinaut/src/simulation/simulator/compile-user-code.ts @@ -1,5 +1,7 @@ import * as Babel from "@babel/standalone"; +import { distributionRuntimeCode } from "./distribution"; + /** * Strips TypeScript type annotations from code to make it executable JavaScript. * Uses Babel standalone (browser-compatible) to properly parse and transform TypeScript code. @@ -77,6 +79,7 @@ export function compileUserCode( // Create an executable module-like environment const executableCode = ` + ${distributionRuntimeCode} ${mockConstructor} let __default_export__; ${sanitizedCode.replace(/export\s+default\s+/, "__default_export__ = ")} diff --git a/libs/@hashintel/petrinaut/src/simulation/simulator/compute-possible-transition.ts b/libs/@hashintel/petrinaut/src/simulation/simulator/compute-possible-transition.ts index c04b84ccacd..8b7310233a9 100644 --- a/libs/@hashintel/petrinaut/src/simulation/simulator/compute-possible-transition.ts +++ b/libs/@hashintel/petrinaut/src/simulation/simulator/compute-possible-transition.ts @@ -1,5 +1,6 @@ import { SDCPNItemError } from "../../core/errors"; import type { ID } from "../../core/types/sdcpn"; +import { isDistribution, sampleDistribution } from "./distribution"; import { enumerateWeightedMarkingIndicesGenerator } from "./enumerate-weighted-markings"; import { nextRandom } from "./seeded-rng"; import type { SimulationFrame, SimulationInstance } from "./types"; @@ -204,7 +205,9 @@ export function computePossibleTransition( // Convert transition kernel output back to place-indexed format // The kernel returns { PlaceName: [{ x: 0, y: 0 }, ...], ... } // We need to convert this to place IDs and flatten to number[][] + // Distribution values are sampled here, advancing the RNG state. const addMap: Record = {}; + let currentRngState = newRngState; for (const outputArc of transition.instance.outputArcs) { const outputPlaceState = frame.places[outputArc.placeId]; @@ -251,9 +254,21 @@ export function computePossibleTransition( ); } - // Convert token objects back to number arrays in correct order + // Convert token objects back to number arrays in correct order, + // sampling any Distribution values using the RNG const tokenArrays = outputTokens.map((token) => { - return type.elements.map((element) => token[element.name]!); + return type.elements.map((element) => { + const value = token[element.name]!; + if (isDistribution(value)) { + const [sampled, nextRng] = sampleDistribution( + value, + currentRngState, + ); + currentRngState = nextRng; + return sampled; + } + return value; + }); }); addMap[outputArc.placeId] = tokenArrays; @@ -275,7 +290,7 @@ export function computePossibleTransition( // Map from place ID to array of token values to // create as per transition kernel output add: addMap, - newRngState, + newRngState: currentRngState, }; } } diff --git a/libs/@hashintel/petrinaut/src/simulation/simulator/distribution.ts b/libs/@hashintel/petrinaut/src/simulation/simulator/distribution.ts new file mode 100644 index 00000000000..5c8fc7fce53 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/simulation/simulator/distribution.ts @@ -0,0 +1,71 @@ +import { nextRandom } from "./seeded-rng"; + +/** + * Runtime representation of a probability distribution. + * Created by user code via Distribution.Gaussian() or Distribution.Uniform(), + * then sampled during transition kernel output resolution. + */ +export type RuntimeDistribution = + | { + __brand: "distribution"; + type: "gaussian"; + mean: number; + deviation: number; + } + | { __brand: "distribution"; type: "uniform"; min: number; max: number }; + +/** + * Checks if a value is a RuntimeDistribution object. + */ +export function isDistribution(value: unknown): value is RuntimeDistribution { + return ( + typeof value === "object" && + value !== null && + "__brand" in value && + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + (value as Record).__brand === "distribution" + ); +} + +/** + * JavaScript source code that defines the Distribution namespace at runtime. + * Injected into the compiled user code execution context so that + * Distribution.Gaussian() and Distribution.Uniform() are available. + */ +export const distributionRuntimeCode = ` + var Distribution = { + Gaussian: function(mean, deviation) { + return { __brand: "distribution", type: "gaussian", mean: mean, deviation: deviation }; + }, + Uniform: function(min, max) { + return { __brand: "distribution", type: "uniform", min: min, max: max }; + } + }; +`; + +/** + * Samples a single numeric value from a distribution using the seeded RNG. + * + * @returns A tuple of [sampledValue, newRngState] + */ +export function sampleDistribution( + distribution: RuntimeDistribution, + rngState: number, +): [number, number] { + switch (distribution.type) { + case "gaussian": { + // Box-Muller transform: converts two uniform random values to a standard normal + const [u1, rng1] = nextRandom(rngState); + const [u2, rng2] = nextRandom(rng1); + const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2); + return [distribution.mean + z * distribution.deviation, rng2]; + } + case "uniform": { + const [sample, newRng] = nextRandom(rngState); + return [ + distribution.min + sample * (distribution.max - distribution.min), + newRng, + ]; + } + } +} diff --git a/libs/@hashintel/petrinaut/src/simulation/simulator/types.ts b/libs/@hashintel/petrinaut/src/simulation/simulator/types.ts index 59585c59c61..2cdad5a0886 100644 --- a/libs/@hashintel/petrinaut/src/simulation/simulator/types.ts +++ b/libs/@hashintel/petrinaut/src/simulation/simulator/types.ts @@ -6,6 +6,7 @@ */ import type { Color, Place, SDCPN, Transition } from "../../core/types/sdcpn"; +import type { RuntimeDistribution } from "./distribution"; import type { SimulationFrame } from "../context"; /** @@ -39,7 +40,7 @@ export type LambdaFn = ( export type TransitionKernelFn = ( tokenValues: Record[]>, parameters: ParameterValues, -) => Record[]>; +) => Record[]>; /** * Input configuration for building a new simulation instance. From 9813382a89fa38f926434683e183ed795d32dfcc Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Fri, 20 Feb 2026 21:19:32 +0100 Subject: [PATCH 04/24] H-5641: Add changeset for probabilistic transition kernels Co-Authored-By: Claude Opus 4.6 --- .changeset/probabilistic-transition-kernels.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/probabilistic-transition-kernels.md diff --git a/.changeset/probabilistic-transition-kernels.md b/.changeset/probabilistic-transition-kernels.md new file mode 100644 index 00000000000..7116facd95e --- /dev/null +++ b/.changeset/probabilistic-transition-kernels.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut": minor +--- + +Add probability distribution support to transition kernels (`Distribution.Gaussian`, `Distribution.Uniform`) From f7d34b41ebdd5922e00f47eeceec88d72bfa55c9 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sat, 21 Feb 2026 03:36:15 +0100 Subject: [PATCH 05/24] H-5641: Add Distribution.map() and satellites launcher example Add `.map(fn)` method to Distribution for deriving correlated values from a single sample (e.g. angle.map(Math.cos), angle.map(Math.sin)). Sampled values are cached on the distribution object so mapped siblings share one draw. Also add a probabilistic satellites launcher example. Co-Authored-By: Claude Opus 4.6 --- .../src/examples/satellites-launcher.ts | 354 ++++++++++++++++++ .../src/lsp/lib/generate-virtual-files.ts | 2 +- .../src/simulation/simulator/distribution.ts | 61 ++- .../src/views/Editor/editor-view.tsx | 31 ++ 4 files changed, 436 insertions(+), 12 deletions(-) create mode 100644 libs/@hashintel/petrinaut/src/examples/satellites-launcher.ts diff --git a/libs/@hashintel/petrinaut/src/examples/satellites-launcher.ts b/libs/@hashintel/petrinaut/src/examples/satellites-launcher.ts new file mode 100644 index 00000000000..d530bc91127 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/examples/satellites-launcher.ts @@ -0,0 +1,354 @@ +import type { SDCPN } from "../core/types/sdcpn"; + +export const probabilisticSatellitesSDCPN: { + title: string; + petriNetDefinition: SDCPN; +} = { + title: "Probabilistic Satellites Launcher", + petriNetDefinition: { + places: [ + { + id: "3cbc7944-34cb-4eeb-b779-4e392a171fe1", + name: "Space", + colorId: "f8e9d7c6-b5a4-3210-fedc-ba9876543210", + dynamicsEnabled: true, + differentialEquationId: "1a2b3c4d-5e6f-7890-abcd-1234567890ab", + visualizerCode: `export default Visualization(({ tokens, parameters }) => { + const { satellite_radius, earth_radius } = parameters; + + const width = 800; + const height = 600; + + const centerX = width / 2; + const centerY = height / 2; + + return ( + + {/* Background */} + + + {/* Earth at center */} + + + {/* Satellites */} + {tokens.map(({ x, y, direction, velocity }, index) => { + // Convert satellite coordinates to screen coordinates + // Assuming satellite coordinates are relative to Earth center + const screenX = centerX + x; + const screenY = centerY + y; + + return ( + + {/* Satellite */} + + + {/* Velocity vector indicator */} + {velocity > 0 && ( + + )} + + ); + })} + + {/* Arrow marker for velocity vectors */} + + + + + + + ); +});`, + x: 30, + y: 90, + width: 130, + height: 130, + }, + { + id: "ea42ba61-03ea-4940-b2e2-b594d5331a71", + name: "Debris", + colorId: "f8e9d7c6-b5a4-3210-fedc-ba9876543210", + dynamicsEnabled: false, + differentialEquationId: null, + x: 510, + y: 75, + width: 130, + height: 130, + }, + ], + transitions: [ + { + id: "d25015d8-7aac-45ff-82b0-afd943f1b7ec", + name: "Collision", + inputArcs: [ + { + placeId: "3cbc7944-34cb-4eeb-b779-4e392a171fe1", + weight: 2, + }, + ], + outputArcs: [ + { + placeId: "ea42ba61-03ea-4940-b2e2-b594d5331a71", + weight: 2, + }, + ], + lambdaType: "predicate", + lambdaCode: `// Check if two satellites collide (are within collision threshold) +export default Lambda((tokens, parameters) => { + const { satellite_radius } = parameters; + + // Get the two satellites + const [a, b] = tokens.Space; + + // Calculate distance between satellites + const distance = Math.hypot(b.x - a.x, b.y - a.y); + + // Collision occurs if distance is less than threshold + return distance < satellite_radius; +})`, + transitionKernelCode: `// When satellites collide, they become debris (lose velocity) +export default TransitionKernel((tokens) => { + // Both satellites become stationary debris at their collision point + return { + Debris: [ + // Position preserved, direction and velocity zeroed + { + x: tokens.Space[0].x, + y: tokens.Space[0].y, + velocity: 0, + direction: 0 + }, + { + x: tokens.Space[1].x, + y: tokens.Space[1].y, + velocity: 0, + direction: 0 + }, + ] + }; +})`, + x: 255, + y: 180, + width: 160, + height: 80, + }, + { + id: "716fe1e5-9b35-413f-83fe-99b28ba73945", + name: "Crash", + inputArcs: [ + { + placeId: "3cbc7944-34cb-4eeb-b779-4e392a171fe1", + weight: 1, + }, + ], + outputArcs: [ + { + placeId: "ea42ba61-03ea-4940-b2e2-b594d5331a71", + weight: 1, + }, + ], + lambdaType: "predicate", + lambdaCode: `// Check if satellite crashes into Earth (within crash threshold of origin) +export default Lambda((tokens, parameters) => { + const { earth_radius } = parameters; + + // Get satellite position + const { x, y } = tokens.Space[0]; + + // Calculate distance from Earth center (origin) + const distance = Math.hypot(x, y); + + // Crash occurs if satellite is too close to Earth + return distance < earth_radius; +})`, + transitionKernelCode: `// When satellite crashes into Earth, it becomes debris at crash site +export default TransitionKernel((tokens) => { + return { + Debris: [ + { + // Position preserved, direction and velocity zeroed + x: tokens.Space[0].x, + y: tokens.Space[0].y, + direction: 0, + velocity: 0 + }, + ] + }; +})`, + x: 255, + y: 30, + width: 160, + height: 80, + }, + { + id: "transition__c7008acb-b0e7-468e-a5d3-d56eaa1fe806", + name: "LaunchSatellite", + inputArcs: [], + outputArcs: [ + { + placeId: "3cbc7944-34cb-4eeb-b779-4e392a171fe1", + weight: 1, + }, + ], + lambdaType: "stochastic", + lambdaCode: `export default Lambda((tokensByPlace, parameters) => { + return 1; +});`, + transitionKernelCode: `export default TransitionKernel((tokensByPlace, parameters) => { + const distance = 80; + const angle = Distribution.Uniform(0, Math.PI * 2); + + return { + Space: [ + { + x: angle.map(a => Math.cos(a) * distance), + y: angle.map(a => Math.sin(a) * distance), + direction: Distribution.Uniform(0, Math.PI * 2), + velocity: Distribution.Gaussian(60, 20) + } + ], + }; +});`, + x: -225, + y: 75, + width: 160, + height: 80, + }, + ], + types: [ + { + id: "f8e9d7c6-b5a4-3210-fedc-ba9876543210", + name: "Satellite", + iconSlug: "9a8b7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d", + displayColor: "#1E90FF", + elements: [ + { + elementId: "2b3c4d5e-6f7a-8b9c-0d1e-2f3a4b5c6d7e", + name: "x", + type: "real", + }, + { + elementId: "3c4d5e6f-7a8b-9c0d-1e2f-3a4b5c6d7e8f", + name: "y", + type: "real", + }, + { + elementId: "4d5e6f7a-8b9c-0d1e-2f3a-4b5c6d7e8f9a", + name: "direction", + type: "real", + }, + { + elementId: "5e6f7a8b-9c0d-1e2f-3a4b-5c6d7e8f9a0b", + name: "velocity", + type: "real", + }, + ], + }, + ], + differentialEquations: [ + { + id: "1a2b3c4d-5e6f-7890-abcd-1234567890ab", + colorId: "f8e9d7c6-b5a4-3210-fedc-ba9876543210", + name: "Satellite Orbit Dynamics", + code: `// Example of ODE for Satellite in orbit (simplified) +export default Dynamics((tokens, parameters) => { + const mu = parameters.gravitational_constant; // Gravitational parameter + + // Process each token (satellite) + return tokens.map(({ x, y, direction, velocity }) => { + const r = Math.hypot(x, y); // Distance to Earth center + + // Gravitational acceleration vector (points toward origin) + const ax = (-mu * x) / (r * r * r); + const ay = (-mu * y) / (r * r * r); + + // Return derivatives for this token + return { + x: velocity * Math.cos(direction), + y: velocity * Math.sin(direction), + direction: + (-ax * Math.sin(direction) + ay * Math.cos(direction)) / velocity, + velocity: + ax * Math.cos(direction) + ay * Math.sin(direction), + } + }) +})`, + }, + ], + parameters: [ + { + id: "6f7a8b9c-0d1e-2f3a-4b5c-6d7e8f9a0b1c", + name: "Earth Radius", + variableName: "earth_radius", + type: "real", + defaultValue: "50.0", + }, + { + id: "7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d", + name: "Satellite Radius", + variableName: "satellite_radius", + type: "real", + defaultValue: "4.0", + }, + { + id: "8b9c0d1e-2f3a-4b5c-6d7e-8f9a0b1c2d3e", + name: "Collision Threshold", + variableName: "collision_threshold", + type: "real", + defaultValue: "10.0", + }, + { + id: "9c0d1e2f-3a4b-5c6d-7e8f-9a0b1c2d3e4f", + name: "Crash Threshold", + variableName: "crash_threshold", + type: "real", + defaultValue: "5.0", + }, + { + id: "0d1e2f3a-4b5c-6d7e-8f9a-0b1c2d3e4f5a", + name: "Gravitational Constant", + variableName: "gravitational_constant", + type: "real", + defaultValue: "400000.0", + }, + ], + }, +}; diff --git a/libs/@hashintel/petrinaut/src/lsp/lib/generate-virtual-files.ts b/libs/@hashintel/petrinaut/src/lsp/lib/generate-virtual-files.ts index 1b0ff4c72d1..24242d9a348 100644 --- a/libs/@hashintel/petrinaut/src/lsp/lib/generate-virtual-files.ts +++ b/libs/@hashintel/petrinaut/src/lsp/lib/generate-virtual-files.ts @@ -27,7 +27,7 @@ export function generateVirtualFiles(sdcpn: SDCPN): Map { // Generate global SDCPN library definitions files.set(getItemFilePath("sdcpn-lib-defs"), { content: [ - `type Distribution = {};`, + `type Distribution = { map(fn: (value: number) => number): Distribution };`, `type Probabilistic = { [K in keyof T]: T[K] extends number ? number | Distribution : T[K] };`, `declare namespace Distribution {`, ` function Gaussian(mean: number, deviation: number): Distribution;`, diff --git a/libs/@hashintel/petrinaut/src/simulation/simulator/distribution.ts b/libs/@hashintel/petrinaut/src/simulation/simulator/distribution.ts index 5c8fc7fce53..5333af326d4 100644 --- a/libs/@hashintel/petrinaut/src/simulation/simulator/distribution.ts +++ b/libs/@hashintel/petrinaut/src/simulation/simulator/distribution.ts @@ -1,18 +1,29 @@ import { nextRandom } from "./seeded-rng"; +type DistributionBase = { + __brand: "distribution"; + /** Cached sampled value. Set after first sample so that multiple + * `.map()` calls on the same distribution share one draw. */ + sampledValue?: number; +}; + /** * Runtime representation of a probability distribution. * Created by user code via Distribution.Gaussian() or Distribution.Uniform(), * then sampled during transition kernel output resolution. */ export type RuntimeDistribution = - | { - __brand: "distribution"; + | (DistributionBase & { type: "gaussian"; mean: number; deviation: number; - } - | { __brand: "distribution"; type: "uniform"; min: number; max: number }; + }) + | (DistributionBase & { type: "uniform"; min: number; max: number }) + | (DistributionBase & { + type: "mapped"; + inner: RuntimeDistribution; + fn: (value: number) => number; + }); /** * Checks if a value is a RuntimeDistribution object. @@ -33,18 +44,26 @@ export function isDistribution(value: unknown): value is RuntimeDistribution { * Distribution.Gaussian() and Distribution.Uniform() are available. */ export const distributionRuntimeCode = ` + function __addMap(dist) { + dist.map = function(fn) { + return __addMap({ __brand: "distribution", type: "mapped", inner: dist, fn: fn }); + }; + return dist; + } var Distribution = { Gaussian: function(mean, deviation) { - return { __brand: "distribution", type: "gaussian", mean: mean, deviation: deviation }; + return __addMap({ __brand: "distribution", type: "gaussian", mean: mean, deviation: deviation }); }, Uniform: function(min, max) { - return { __brand: "distribution", type: "uniform", min: min, max: max }; + return __addMap({ __brand: "distribution", type: "uniform", min: min, max: max }); } }; `; /** * Samples a single numeric value from a distribution using the seeded RNG. + * Caches the result on the distribution object so that sibling `.map()` calls + * sharing the same inner distribution get a coherent sample. * * @returns A tuple of [sampledValue, newRngState] */ @@ -52,20 +71,40 @@ export function sampleDistribution( distribution: RuntimeDistribution, rngState: number, ): [number, number] { + if (distribution.sampledValue !== undefined) { + return [distribution.sampledValue, rngState]; + } + + let value: number; + let nextRng: number; + switch (distribution.type) { case "gaussian": { // Box-Muller transform: converts two uniform random values to a standard normal const [u1, rng1] = nextRandom(rngState); const [u2, rng2] = nextRandom(rng1); const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2); - return [distribution.mean + z * distribution.deviation, rng2]; + value = distribution.mean + z * distribution.deviation; + nextRng = rng2; + break; } case "uniform": { const [sample, newRng] = nextRandom(rngState); - return [ - distribution.min + sample * (distribution.max - distribution.min), - newRng, - ]; + value = distribution.min + sample * (distribution.max - distribution.min); + nextRng = newRng; + break; + } + case "mapped": { + const [innerValue, newRng] = sampleDistribution( + distribution.inner, + rngState, + ); + value = distribution.fn(innerValue); + nextRng = newRng; + break; } } + + distribution.sampledValue = value; + return [value, nextRng]; } diff --git a/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx b/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx index cb9fc732aa8..43705436616 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx @@ -5,6 +5,7 @@ import { Box } from "../../components/box"; import { Stack } from "../../components/stack"; import { productionMachines } from "../../examples/broken-machines"; import { satellitesSDCPN } from "../../examples/satellites"; +import { probabilisticSatellitesSDCPN } from "../../examples/satellites-launcher"; import { sirModel } from "../../examples/sir-model"; import { convertOldFormatToSDCPN } from "../../old-formats/convert-old-format"; import { EditorContext } from "../../state/editor-context"; @@ -172,6 +173,28 @@ export const EditorView = ({ id: "load-example", label: "Load example", submenu: [ + /** + * @todo H-5641: once probabilistic transition kernel available, + * update this example so that the Manufacture step probabilistically + * produces either good or bad product, then enable a 'Dispose' or 'Dispatch' + * transition depending on which was randomly selected. + */ + // { + // id: "load-example-supply-chain", + // label: "Supply Chain", + // onClick: () => { + // createNewNet(supplyChainSDCPN); + // clearSelection(); + // }, + // }, + // { + // id: "load-example-supply-chain-stochastic", + // label: "Supply Chain (Stochastic)", + // onClick: () => { + // createNewNet(supplyChainStochasticSDCPN); + // clearSelection(); + // }, + // }, { id: "load-example-satellites", label: "Satellites", @@ -180,6 +203,14 @@ export const EditorView = ({ clearSelection(); }, }, + { + id: "load-example-probabilistic-satellites", + label: "Probabilistic Satellites Launcher", + onClick: () => { + createNewNet(probabilisticSatellitesSDCPN); + clearSelection(); + }, + }, { id: "load-example-production-machines", label: "Production Machines", From c3e4427a250514cf792821ab42116ac64d9871e6 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Sat, 21 Feb 2026 03:54:29 +0100 Subject: [PATCH 06/24] H-5641: Fix ESLint errors in distribution and transition code Fix import sort order, remove unused eslint-disable directive, add no-param-reassign override for distribution caching, and replace .map() closure with imperative loop to avoid no-loop-func violation. Co-Authored-By: Claude Opus 4.6 --- .../simulator/compute-possible-transition.ts | 22 +++++++++++-------- .../src/simulation/simulator/distribution.ts | 2 +- .../src/simulation/simulator/types.ts | 2 +- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/simulation/simulator/compute-possible-transition.ts b/libs/@hashintel/petrinaut/src/simulation/simulator/compute-possible-transition.ts index 8b7310233a9..2a2a12840de 100644 --- a/libs/@hashintel/petrinaut/src/simulation/simulator/compute-possible-transition.ts +++ b/libs/@hashintel/petrinaut/src/simulation/simulator/compute-possible-transition.ts @@ -256,20 +256,24 @@ export function computePossibleTransition( // Convert token objects back to number arrays in correct order, // sampling any Distribution values using the RNG - const tokenArrays = outputTokens.map((token) => { - return type.elements.map((element) => { - const value = token[element.name]!; - if (isDistribution(value)) { + const tokenArrays: number[][] = []; + for (const token of outputTokens) { + const values: number[] = []; + for (const element of type.elements) { + const raw = token[element.name]!; + if (isDistribution(raw)) { const [sampled, nextRng] = sampleDistribution( - value, + raw, currentRngState, ); currentRngState = nextRng; - return sampled; + values.push(sampled); + } else { + values.push(raw); } - return value; - }); - }); + } + tokenArrays.push(values); + } addMap[outputArc.placeId] = tokenArrays; } diff --git a/libs/@hashintel/petrinaut/src/simulation/simulator/distribution.ts b/libs/@hashintel/petrinaut/src/simulation/simulator/distribution.ts index 5333af326d4..c4f80e9808b 100644 --- a/libs/@hashintel/petrinaut/src/simulation/simulator/distribution.ts +++ b/libs/@hashintel/petrinaut/src/simulation/simulator/distribution.ts @@ -33,7 +33,6 @@ export function isDistribution(value: unknown): value is RuntimeDistribution { typeof value === "object" && value !== null && "__brand" in value && - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access (value as Record).__brand === "distribution" ); } @@ -105,6 +104,7 @@ export function sampleDistribution( } } + // eslint-disable-next-line no-param-reassign -- intentional: cache sampled value for coherent .map() siblings distribution.sampledValue = value; return [value, nextRng]; } diff --git a/libs/@hashintel/petrinaut/src/simulation/simulator/types.ts b/libs/@hashintel/petrinaut/src/simulation/simulator/types.ts index 2cdad5a0886..ee185bd6ff8 100644 --- a/libs/@hashintel/petrinaut/src/simulation/simulator/types.ts +++ b/libs/@hashintel/petrinaut/src/simulation/simulator/types.ts @@ -6,8 +6,8 @@ */ import type { Color, Place, SDCPN, Transition } from "../../core/types/sdcpn"; -import type { RuntimeDistribution } from "./distribution"; import type { SimulationFrame } from "../context"; +import type { RuntimeDistribution } from "./distribution"; /** * Runtime parameter values used during simulation execution. From b7ee80ca917fc4a05317d0b928afa959c357ad83 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Mon, 2 Mar 2026 16:46:11 +0100 Subject: [PATCH 07/24] H-5641: Add Distribution.Lognormal(mu, sigma) support Uses standard mathematical parameterization where mu and sigma are the mean and standard deviation of the underlying normal distribution ln(X). Co-Authored-By: Claude Opus 4.6 --- .../src/lsp/lib/generate-virtual-files.ts | 1 + .../src/simulation/simulator/distribution.ts | 17 +++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/libs/@hashintel/petrinaut/src/lsp/lib/generate-virtual-files.ts b/libs/@hashintel/petrinaut/src/lsp/lib/generate-virtual-files.ts index 24242d9a348..3e2aa7006fa 100644 --- a/libs/@hashintel/petrinaut/src/lsp/lib/generate-virtual-files.ts +++ b/libs/@hashintel/petrinaut/src/lsp/lib/generate-virtual-files.ts @@ -32,6 +32,7 @@ export function generateVirtualFiles(sdcpn: SDCPN): Map { `declare namespace Distribution {`, ` function Gaussian(mean: number, deviation: number): Distribution;`, ` function Uniform(min: number, max: number): Distribution;`, + ` function Lognormal(mu: number, sigma: number): Distribution;`, `}`, ].join("\n"), }); diff --git a/libs/@hashintel/petrinaut/src/simulation/simulator/distribution.ts b/libs/@hashintel/petrinaut/src/simulation/simulator/distribution.ts index c4f80e9808b..2c809440fea 100644 --- a/libs/@hashintel/petrinaut/src/simulation/simulator/distribution.ts +++ b/libs/@hashintel/petrinaut/src/simulation/simulator/distribution.ts @@ -19,6 +19,11 @@ export type RuntimeDistribution = deviation: number; }) | (DistributionBase & { type: "uniform"; min: number; max: number }) + | (DistributionBase & { + type: "lognormal"; + mu: number; + sigma: number; + }) | (DistributionBase & { type: "mapped"; inner: RuntimeDistribution; @@ -55,6 +60,9 @@ export const distributionRuntimeCode = ` }, Uniform: function(min, max) { return __addMap({ __brand: "distribution", type: "uniform", min: min, max: max }); + }, + Lognormal: function(mu, sigma) { + return __addMap({ __brand: "distribution", type: "lognormal", mu: mu, sigma: sigma }); } }; `; @@ -93,6 +101,15 @@ export function sampleDistribution( nextRng = newRng; break; } + case "lognormal": { + // Lognormal(μ, σ): if X ~ Normal(μ, σ), then e^X ~ Lognormal(μ, σ) + const [u1, rng1] = nextRandom(rngState); + const [u2, rng2] = nextRandom(rng1); + const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2); + value = Math.exp(distribution.mu + z * distribution.sigma); + nextRng = rng2; + break; + } case "mapped": { const [innerValue, newRng] = sampleDistribution( distribution.inner, From e60e8f44998a0103a43152b43f879c0838c590e1 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 5 Mar 2026 19:17:33 +0100 Subject: [PATCH 08/24] Remove width/height from example --- .../petrinaut/src/examples/satellites-launcher.ts | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/examples/satellites-launcher.ts b/libs/@hashintel/petrinaut/src/examples/satellites-launcher.ts index d530bc91127..f3f4935bda3 100644 --- a/libs/@hashintel/petrinaut/src/examples/satellites-launcher.ts +++ b/libs/@hashintel/petrinaut/src/examples/satellites-launcher.ts @@ -99,8 +99,6 @@ export const probabilisticSatellitesSDCPN: { });`, x: 30, y: 90, - width: 130, - height: 130, }, { id: "ea42ba61-03ea-4940-b2e2-b594d5331a71", @@ -110,8 +108,6 @@ export const probabilisticSatellitesSDCPN: { differentialEquationId: null, x: 510, y: 75, - width: 130, - height: 130, }, ], transitions: [ @@ -167,8 +163,6 @@ export default TransitionKernel((tokens) => { })`, x: 255, y: 180, - width: 160, - height: 80, }, { id: "716fe1e5-9b35-413f-83fe-99b28ba73945", @@ -215,8 +209,6 @@ export default TransitionKernel((tokens) => { })`, x: 255, y: 30, - width: 160, - height: 80, }, { id: "transition__c7008acb-b0e7-468e-a5d3-d56eaa1fe806", @@ -249,8 +241,6 @@ export default TransitionKernel((tokens) => { });`, x: -225, y: 75, - width: 160, - height: 80, }, ], types: [ From 76a1dab50d7b03fffdeac1c9c3b4e55ec57fbdf1 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 5 Mar 2026 19:17:45 +0100 Subject: [PATCH 09/24] Update changeset to patch --- .changeset/probabilistic-transition-kernels.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/probabilistic-transition-kernels.md b/.changeset/probabilistic-transition-kernels.md index 7116facd95e..e4a83776bc4 100644 --- a/.changeset/probabilistic-transition-kernels.md +++ b/.changeset/probabilistic-transition-kernels.md @@ -1,5 +1,5 @@ --- -"@hashintel/petrinaut": minor +"@hashintel/petrinaut": patch --- Add probability distribution support to transition kernels (`Distribution.Gaussian`, `Distribution.Uniform`) From 0580476cacb28b73a255c309b2e0d752470fd081 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Mon, 9 Mar 2026 12:53:47 +0100 Subject: [PATCH 10/24] H-5641: Fix review feedback and enable supply chain examples - Fix Box-Muller log(0) bug by using (1 - u1) in gaussian/lognormal sampling - Fix parameter name mismatches in satellite example lambdas - Update supply chain stochastic example to use Distribution.Uniform for probabilistic product quality, with predicate-based Dispatch/Dispose - Re-enable Supply Chain menu entries and remove stale H-5641 todo Co-Authored-By: Claude Opus 4.6 --- .../src/examples/satellites-launcher.ts | 4 +- .../petrinaut/src/examples/satellites.ts | 4 +- .../src/examples/supply-chain-stochastic.ts | 111 ++++++++++++------ .../src/simulation/simulator/distribution.ts | 6 +- .../src/views/Editor/editor-view.tsx | 40 +++---- 5 files changed, 104 insertions(+), 61 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/examples/satellites-launcher.ts b/libs/@hashintel/petrinaut/src/examples/satellites-launcher.ts index f3f4935bda3..1918e7fe8ea 100644 --- a/libs/@hashintel/petrinaut/src/examples/satellites-launcher.ts +++ b/libs/@hashintel/petrinaut/src/examples/satellites-launcher.ts @@ -182,7 +182,7 @@ export default TransitionKernel((tokens) => { lambdaType: "predicate", lambdaCode: `// Check if satellite crashes into Earth (within crash threshold of origin) export default Lambda((tokens, parameters) => { - const { earth_radius } = parameters; + const { earth_radius, crash_threshold, satellite_radius } = parameters; // Get satellite position const { x, y } = tokens.Space[0]; @@ -191,7 +191,7 @@ export default Lambda((tokens, parameters) => { const distance = Math.hypot(x, y); // Crash occurs if satellite is too close to Earth - return distance < earth_radius; + return distance < earth_radius + crash_threshold - satellite_radius; })`, transitionKernelCode: `// When satellite crashes into Earth, it becomes debris at crash site export default TransitionKernel((tokens) => { diff --git a/libs/@hashintel/petrinaut/src/examples/satellites.ts b/libs/@hashintel/petrinaut/src/examples/satellites.ts index 9042b7ae74d..3f9bafffa5a 100644 --- a/libs/@hashintel/petrinaut/src/examples/satellites.ts +++ b/libs/@hashintel/petrinaut/src/examples/satellites.ts @@ -179,7 +179,7 @@ export default TransitionKernel((tokens) => { lambdaType: "predicate", lambdaCode: `// Check if satellite crashes into Earth (within crash threshold of origin) export default Lambda((tokens, parameters) => { - const { earth_radius } = parameters; + const { earth_radius, crash_threshold, satellite_radius } = parameters; // Get satellite position const { x, y } = tokens.Space[0]; @@ -188,7 +188,7 @@ export default Lambda((tokens, parameters) => { const distance = Math.hypot(x, y); // Crash occurs if satellite is too close to Earth - return distance < earth_radius; + return distance < earth_radius + crash_threshold - satellite_radius; })`, transitionKernelCode: `// When satellite crashes into Earth, it becomes debris at crash site export default TransitionKernel((tokens) => { diff --git a/libs/@hashintel/petrinaut/src/examples/supply-chain-stochastic.ts b/libs/@hashintel/petrinaut/src/examples/supply-chain-stochastic.ts index f8f9349ae63..1c29645a5a9 100644 --- a/libs/@hashintel/petrinaut/src/examples/supply-chain-stochastic.ts +++ b/libs/@hashintel/petrinaut/src/examples/supply-chain-stochastic.ts @@ -13,8 +13,8 @@ export const supplyChainStochasticSDCPN: { colorId: null, dynamicsEnabled: false, differentialEquationId: null, - x: 20, - y: 120, + x: -180, + y: 360, }, { id: "place__1", @@ -22,8 +22,8 @@ export const supplyChainStochasticSDCPN: { colorId: null, dynamicsEnabled: false, differentialEquationId: null, - x: 20, - y: 600, + x: -180, + y: 450, }, { id: "place__2", @@ -31,17 +31,17 @@ export const supplyChainStochasticSDCPN: { colorId: null, dynamicsEnabled: false, differentialEquationId: null, - x: 300, - y: 300, + x: 315, + y: 405, }, { id: "place__3", name: "QAQueue", - colorId: null, + colorId: "type__product", dynamicsEnabled: false, differentialEquationId: null, - x: 700, - y: 350, + x: 795, + y: 405, }, { id: "place__4", @@ -49,8 +49,8 @@ export const supplyChainStochasticSDCPN: { colorId: null, dynamicsEnabled: false, differentialEquationId: null, - x: 1100, - y: 600, + x: 1275, + y: 525, }, { id: "place__5", @@ -58,8 +58,8 @@ export const supplyChainStochasticSDCPN: { colorId: null, dynamicsEnabled: false, differentialEquationId: null, - x: 1000, - y: 200, + x: 1275, + y: 300, }, { id: "place__6", @@ -67,8 +67,8 @@ export const supplyChainStochasticSDCPN: { colorId: null, dynamicsEnabled: false, differentialEquationId: null, - x: 1300, - y: 380, + x: 1755, + y: 300, }, ], transitions: [ @@ -83,8 +83,8 @@ export const supplyChainStochasticSDCPN: { lambdaType: "stochastic", lambdaCode: "export default Lambda(() => 1);", transitionKernelCode: "", - x: 100, - y: 400, + x: 75, + y: 405, }, { id: "transition__1", @@ -93,38 +93,83 @@ export const supplyChainStochasticSDCPN: { outputArcs: [{ placeId: "place__3", weight: 1 }], lambdaType: "stochastic", lambdaCode: "export default Lambda(() => 1);", - transitionKernelCode: "", - x: 490, - y: 350, + transitionKernelCode: `// Produce a product with random quality +export default TransitionKernel(() => { + return { + QAQueue: [ + { quality: Distribution.Uniform(0, 1) } + ], + }; +});`, + x: 555, + y: 405, }, { id: "transition__2", - name: "Quality Check", + name: "Dispatch", inputArcs: [{ placeId: "place__3", weight: 1 }], - outputArcs: [ - { placeId: "place__5", weight: 1 }, - { placeId: "place__4", weight: 1 }, - ], - lambdaType: "stochastic", - lambdaCode: "export default Lambda(() => 1 / 2);", + outputArcs: [{ placeId: "place__5", weight: 1 }], + lambdaType: "predicate", + lambdaCode: `// Dispatch if product quality exceeds the defect threshold +export default Lambda((tokens, parameters) => { + const { defect_rate } = parameters; + return tokens.QAQueue[0].quality > defect_rate; +});`, transitionKernelCode: "", - x: 870, - y: 400, + x: 1035, + y: 300, }, { id: "transition__3", + name: "Dispose", + inputArcs: [{ placeId: "place__3", weight: 1 }], + outputArcs: [{ placeId: "place__4", weight: 1 }], + lambdaType: "predicate", + lambdaCode: `// Dispose if product quality is below the defect threshold +export default Lambda((tokens, parameters) => { + const { defect_rate } = parameters; + return tokens.QAQueue[0].quality <= defect_rate; +});`, + transitionKernelCode: "", + x: 1035, + y: 525, + }, + { + id: "transition__4", name: "Ship", inputArcs: [{ placeId: "place__5", weight: 1 }], outputArcs: [{ placeId: "place__6", weight: 1 }], lambdaType: "stochastic", lambdaCode: "export default Lambda(() => 1 / 3);", transitionKernelCode: "", - x: 1150, - y: 280, + x: 1515, + y: 300, + }, + ], + types: [ + { + id: "type__product", + name: "Product", + iconSlug: "product-icon", + displayColor: "#4CAF50", + elements: [ + { + elementId: "element__quality", + name: "quality", + type: "real", + }, + ], }, ], - types: [], differentialEquations: [], - parameters: [], + parameters: [ + { + id: "param__defect_rate", + name: "Defect Rate", + variableName: "defect_rate", + type: "real", + defaultValue: "0.2", + }, + ], }, }; diff --git a/libs/@hashintel/petrinaut/src/simulation/simulator/distribution.ts b/libs/@hashintel/petrinaut/src/simulation/simulator/distribution.ts index 2c809440fea..80a3b889131 100644 --- a/libs/@hashintel/petrinaut/src/simulation/simulator/distribution.ts +++ b/libs/@hashintel/petrinaut/src/simulation/simulator/distribution.ts @@ -88,9 +88,10 @@ export function sampleDistribution( switch (distribution.type) { case "gaussian": { // Box-Muller transform: converts two uniform random values to a standard normal + // Use (1 - u1) to avoid Math.log(0) since nextRandom returns [0, 1) const [u1, rng1] = nextRandom(rngState); const [u2, rng2] = nextRandom(rng1); - const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2); + const z = Math.sqrt(-2 * Math.log(1 - u1)) * Math.cos(2 * Math.PI * u2); value = distribution.mean + z * distribution.deviation; nextRng = rng2; break; @@ -103,9 +104,10 @@ export function sampleDistribution( } case "lognormal": { // Lognormal(μ, σ): if X ~ Normal(μ, σ), then e^X ~ Lognormal(μ, σ) + // Use (1 - u1) to avoid Math.log(0) since nextRandom returns [0, 1) const [u1, rng1] = nextRandom(rngState); const [u2, rng2] = nextRandom(rng1); - const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2); + const z = Math.sqrt(-2 * Math.log(1 - u1)) * Math.cos(2 * Math.PI * u2); value = Math.exp(distribution.mu + z * distribution.sigma); nextRng = rng2; break; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx b/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx index 43705436616..c6c635b6794 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx @@ -7,6 +7,8 @@ import { productionMachines } from "../../examples/broken-machines"; import { satellitesSDCPN } from "../../examples/satellites"; import { probabilisticSatellitesSDCPN } from "../../examples/satellites-launcher"; import { sirModel } from "../../examples/sir-model"; +import { supplyChainSDCPN } from "../../examples/supply-chain"; +import { supplyChainStochasticSDCPN } from "../../examples/supply-chain-stochastic"; import { convertOldFormatToSDCPN } from "../../old-formats/convert-old-format"; import { EditorContext } from "../../state/editor-context"; import { PortalContainerContext } from "../../state/portal-container-context"; @@ -173,28 +175,22 @@ export const EditorView = ({ id: "load-example", label: "Load example", submenu: [ - /** - * @todo H-5641: once probabilistic transition kernel available, - * update this example so that the Manufacture step probabilistically - * produces either good or bad product, then enable a 'Dispose' or 'Dispatch' - * transition depending on which was randomly selected. - */ - // { - // id: "load-example-supply-chain", - // label: "Supply Chain", - // onClick: () => { - // createNewNet(supplyChainSDCPN); - // clearSelection(); - // }, - // }, - // { - // id: "load-example-supply-chain-stochastic", - // label: "Supply Chain (Stochastic)", - // onClick: () => { - // createNewNet(supplyChainStochasticSDCPN); - // clearSelection(); - // }, - // }, + { + id: "load-example-supply-chain", + label: "Supply Chain", + onClick: () => { + createNewNet(supplyChainSDCPN); + clearSelection(); + }, + }, + { + id: "load-example-supply-chain-stochastic", + label: "Probabilistic Supply Chain", + onClick: () => { + createNewNet(supplyChainStochasticSDCPN); + clearSelection(); + }, + }, { id: "load-example-satellites", label: "Satellites", From ed260eda0ce4beef4744171d1f90b023eb749e36 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Mon, 9 Mar 2026 13:41:39 +0100 Subject: [PATCH 11/24] Update changeset --- .changeset/probabilistic-transition-kernels.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/probabilistic-transition-kernels.md b/.changeset/probabilistic-transition-kernels.md index e4a83776bc4..bce32a50814 100644 --- a/.changeset/probabilistic-transition-kernels.md +++ b/.changeset/probabilistic-transition-kernels.md @@ -2,4 +2,4 @@ "@hashintel/petrinaut": patch --- -Add probability distribution support to transition kernels (`Distribution.Gaussian`, `Distribution.Uniform`) +Add probability distribution support to transition kernels (`Distribution.Gaussian`, `Distribution.Uniform`, `Distribution.Lognormal`) From 0e399ad472bc8d612093deb14cff46fdaed35014 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Mon, 9 Mar 2026 17:46:59 +0100 Subject: [PATCH 12/24] Rename defect_threshold to quality_threshold in supply chain example Co-Authored-By: Claude Opus 4.6 --- .../src/examples/supply-chain-stochastic.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/examples/supply-chain-stochastic.ts b/libs/@hashintel/petrinaut/src/examples/supply-chain-stochastic.ts index 1c29645a5a9..61ef7beea56 100644 --- a/libs/@hashintel/petrinaut/src/examples/supply-chain-stochastic.ts +++ b/libs/@hashintel/petrinaut/src/examples/supply-chain-stochastic.ts @@ -110,10 +110,10 @@ export default TransitionKernel(() => { inputArcs: [{ placeId: "place__3", weight: 1 }], outputArcs: [{ placeId: "place__5", weight: 1 }], lambdaType: "predicate", - lambdaCode: `// Dispatch if product quality exceeds the defect threshold + lambdaCode: `// Dispatch if product quality exceeds the quality threshold export default Lambda((tokens, parameters) => { - const { defect_rate } = parameters; - return tokens.QAQueue[0].quality > defect_rate; + const { quality_threshold } = parameters; + return tokens.QAQueue[0].quality > quality_threshold; });`, transitionKernelCode: "", x: 1035, @@ -125,10 +125,10 @@ export default Lambda((tokens, parameters) => { inputArcs: [{ placeId: "place__3", weight: 1 }], outputArcs: [{ placeId: "place__4", weight: 1 }], lambdaType: "predicate", - lambdaCode: `// Dispose if product quality is below the defect threshold + lambdaCode: `// Dispose if product quality is below the quality threshold export default Lambda((tokens, parameters) => { - const { defect_rate } = parameters; - return tokens.QAQueue[0].quality <= defect_rate; + const { quality_threshold } = parameters; + return tokens.QAQueue[0].quality <= quality_threshold; });`, transitionKernelCode: "", x: 1035, @@ -164,9 +164,9 @@ export default Lambda((tokens, parameters) => { differentialEquations: [], parameters: [ { - id: "param__defect_rate", - name: "Defect Rate", - variableName: "defect_rate", + id: "param__quality_threshold", + name: "Quality Threshold", + variableName: "quality_threshold", type: "real", defaultValue: "0.2", }, From 676b471b0e38b141cf9274cd7049e9801ef41cd1 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Mon, 9 Mar 2026 18:17:12 +0100 Subject: [PATCH 13/24] Fix --- .../petrinaut/src/examples/supply-chain-stochastic.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/examples/supply-chain-stochastic.ts b/libs/@hashintel/petrinaut/src/examples/supply-chain-stochastic.ts index 61ef7beea56..bb85d7a64de 100644 --- a/libs/@hashintel/petrinaut/src/examples/supply-chain-stochastic.ts +++ b/libs/@hashintel/petrinaut/src/examples/supply-chain-stochastic.ts @@ -113,7 +113,7 @@ export default TransitionKernel(() => { lambdaCode: `// Dispatch if product quality exceeds the quality threshold export default Lambda((tokens, parameters) => { const { quality_threshold } = parameters; - return tokens.QAQueue[0].quality > quality_threshold; + return tokens.QAQueue[0].quality >= quality_threshold; });`, transitionKernelCode: "", x: 1035, @@ -128,7 +128,7 @@ export default Lambda((tokens, parameters) => { lambdaCode: `// Dispose if product quality is below the quality threshold export default Lambda((tokens, parameters) => { const { quality_threshold } = parameters; - return tokens.QAQueue[0].quality <= quality_threshold; + return tokens.QAQueue[0].quality < quality_threshold; });`, transitionKernelCode: "", x: 1035, From 7d91cc5d1ec26dae17066451a58af6578ebdceca Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Mon, 9 Mar 2026 18:25:21 +0100 Subject: [PATCH 14/24] Fix --- libs/@hashintel/petrinaut/src/examples/satellites.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/@hashintel/petrinaut/src/examples/satellites.ts b/libs/@hashintel/petrinaut/src/examples/satellites.ts index 3f9bafffa5a..0683e6a0d75 100644 --- a/libs/@hashintel/petrinaut/src/examples/satellites.ts +++ b/libs/@hashintel/petrinaut/src/examples/satellites.ts @@ -188,7 +188,7 @@ export default Lambda((tokens, parameters) => { const distance = Math.hypot(x, y); // Crash occurs if satellite is too close to Earth - return distance < earth_radius + crash_threshold - satellite_radius; + return distance < earth_radius + crash_threshold + satellite_radius; })`, transitionKernelCode: `// When satellite crashes into Earth, it becomes debris at crash site export default TransitionKernel((tokens) => { From 033dfd684b2f0c2f2a76fd70454ae0f8e29ff474 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Mon, 9 Mar 2026 18:30:18 +0100 Subject: [PATCH 15/24] Last fix --- libs/@hashintel/petrinaut/src/examples/satellites-launcher.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/@hashintel/petrinaut/src/examples/satellites-launcher.ts b/libs/@hashintel/petrinaut/src/examples/satellites-launcher.ts index 1918e7fe8ea..7a86f00676d 100644 --- a/libs/@hashintel/petrinaut/src/examples/satellites-launcher.ts +++ b/libs/@hashintel/petrinaut/src/examples/satellites-launcher.ts @@ -191,7 +191,7 @@ export default Lambda((tokens, parameters) => { const distance = Math.hypot(x, y); // Crash occurs if satellite is too close to Earth - return distance < earth_radius + crash_threshold - satellite_radius; + return distance < earth_radius + crash_threshold + satellite_radius; })`, transitionKernelCode: `// When satellite crashes into Earth, it becomes debris at crash site export default TransitionKernel((tokens) => { From 51a7939f078b60d70b8161eba6cd0789ea368dd8 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 4 Mar 2026 20:15:56 +0100 Subject: [PATCH 16/24] H-6281: Add undo/redo support to Petrinaut demo app Consumer-side history management via useUndoRedo hook with full SDCPN snapshots, 500ms debounce for drag coalescing, and no-op detection. Petrinaut library accepts optional undoRedo props and renders UI (toolbar buttons, keyboard shortcuts, version history popover) only when provided. Co-Authored-By: Claude Opus 4.6 --- .../petrinaut/demo-site/main/app.tsx | 127 ++++++++++++++-- .../demo-site/main/app/use-undo-redo.ts | 141 ++++++++++++++++++ libs/@hashintel/petrinaut/src/petrinaut.tsx | 48 +++--- .../petrinaut/src/state/undo-redo-context.ts | 13 ++ .../BottomBar/use-keyboard-shortcuts.ts | 27 +++- .../Editor/components/TopBar/top-bar.tsx | 29 +++- .../TopBar/version-history-button.tsx | 118 +++++++++++++++ 7 files changed, 466 insertions(+), 37 deletions(-) create mode 100644 libs/@hashintel/petrinaut/demo-site/main/app/use-undo-redo.ts create mode 100644 libs/@hashintel/petrinaut/src/state/undo-redo-context.ts create mode 100644 libs/@hashintel/petrinaut/src/views/Editor/components/TopBar/version-history-button.tsx diff --git a/libs/@hashintel/petrinaut/demo-site/main/app.tsx b/libs/@hashintel/petrinaut/demo-site/main/app.tsx index c8a26d5ffdf..45ced81c2c5 100644 --- a/libs/@hashintel/petrinaut/demo-site/main/app.tsx +++ b/libs/@hashintel/petrinaut/demo-site/main/app.tsx @@ -1,5 +1,5 @@ import { produce } from "immer"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { MinimalNetMetadata, SDCPN } from "../../src/core/types/sdcpn"; import { convertOldFormatToSDCPN } from "../../src/old-formats/convert-old-format"; @@ -9,6 +9,7 @@ import { type SDCPNInLocalStorage, useLocalStorageSDCPNs, } from "./app/use-local-storage-sdcpns"; +import { useUndoRedo } from "./app/use-undo-redo"; export const DevApp = () => { const { storedSDCPNs, setStoredSDCPNs } = useLocalStorageSDCPNs(); @@ -72,25 +73,118 @@ export const DevApp = () => { [currentNetId, setStoredSDCPNs], ); - const mutatePetriNetDefinition = useCallback( - (definitionMutationFn: (draft: SDCPN) => void) => { - if (!currentNetId) { + const setSDCPNDirectly = (sdcpn: SDCPN) => { + if (!currentNetId) { + return; + } + setStoredSDCPNs((prev) => + produce(prev, (draft) => { + if (draft[currentNetId]) { + draft[currentNetId].sdcpn = sdcpn; + } + }), + ); + }; + + const mutatePetriNetDefinitionBase = ( + definitionMutationFn: (draft: SDCPN) => void, + ) => { + if (!currentNetId) { + return; + } + + setStoredSDCPNs((prev) => + produce(prev, (draft) => { + if (draft[currentNetId]) { + draft[currentNetId].sdcpn = produce( + draft[currentNetId].sdcpn, + definitionMutationFn, + ); + } + }), + ); + }; + + const emptySDCPN: SDCPN = { + places: [], + transitions: [], + types: [], + parameters: [], + differentialEquations: [], + }; + + const { + pushState, + undo: undoHistory, + redo: redoHistory, + goToIndex: goToHistoryIndex, + canUndo, + canRedo, + history, + currentIndex, + reset: resetHistory, + } = useUndoRedo( + currentNet && !isOldFormatInLocalStorage(currentNet) + ? currentNet.sdcpn + : emptySDCPN, + ); + + // A ref to access the latest storedSDCPNs in the microtask callback + const storedSDCPNsRef = useRef(storedSDCPNs); + storedSDCPNsRef.current = storedSDCPNs; + + const mutatePetriNetDefinition = ( + definitionMutationFn: (draft: SDCPN) => void, + ) => { + mutatePetriNetDefinitionBase(definitionMutationFn); + + // Push the new SDCPN onto the history stack after the state update settles + queueMicrotask(() => { + const netId = currentNetId; + if (!netId) { return; } + const latest = storedSDCPNsRef.current[netId]; + if (latest && !isOldFormatInLocalStorage(latest)) { + pushState(latest.sdcpn); + } + }); + }; - setStoredSDCPNs((prev) => - produce(prev, (draft) => { - if (draft[currentNetId]) { - draft[currentNetId].sdcpn = produce( - draft[currentNetId].sdcpn, - definitionMutationFn, - ); - } - }), - ); + const prevNetIdRef = useRef(currentNetId); + useEffect(() => { + if (currentNetId !== prevNetIdRef.current) { + prevNetIdRef.current = currentNetId; + if (currentNet && !isOldFormatInLocalStorage(currentNet)) { + resetHistory(currentNet.sdcpn); + } + } + }, [currentNetId, currentNet, resetHistory]); + + const undoRedo = { + undo: () => { + const sdcpn = undoHistory(); + if (sdcpn) { + setSDCPNDirectly(sdcpn); + } }, - [currentNetId, setStoredSDCPNs], - ); + redo: () => { + const sdcpn = redoHistory(); + if (sdcpn) { + setSDCPNDirectly(sdcpn); + } + }, + canUndo, + canRedo, + history: history.current, + currentIndex, + goToIndex: (index: number) => { + const sdcpn = goToHistoryIndex(index); + if (sdcpn) { + setSDCPNDirectly(sdcpn); + } + }, + }; // Initialize with a default net if none exists useEffect(() => { @@ -168,6 +262,7 @@ export const DevApp = () => { readonly={false} setTitle={setTitle} title={currentNet.title} + undoRedo={undoRedo} /> ); diff --git a/libs/@hashintel/petrinaut/demo-site/main/app/use-undo-redo.ts b/libs/@hashintel/petrinaut/demo-site/main/app/use-undo-redo.ts new file mode 100644 index 00000000000..a859a78ed38 --- /dev/null +++ b/libs/@hashintel/petrinaut/demo-site/main/app/use-undo-redo.ts @@ -0,0 +1,141 @@ +import { useRef, useState } from "react"; + +import type { SDCPN } from "../../../src/core/types/sdcpn"; +import { isSDCPNEqual } from "../../../src/petrinaut"; + +export type HistoryEntry = { + sdcpn: SDCPN; + timestamp: string; +}; + +const MAX_HISTORY = 50; +const DEBOUNCE_MS = 500; + +export function useUndoRedo(initialSDCPN: SDCPN) { + const historyRef = useRef([ + { sdcpn: initialSDCPN, timestamp: new Date().toISOString() }, + ]); + const currentIndexRef = useRef(0); + const debounceTimerRef = useRef | null>(null); + + /** + * Snapshot of render-visible values derived from the refs. + * Updated via `bump()` after every mutation so consumers re-render + * without reading refs during render. + */ + const [snapshot, setSnapshot] = useState({ + currentIndex: 0, + historyLength: 1, + }); + const bump = () => + setSnapshot({ + currentIndex: currentIndexRef.current, + historyLength: historyRef.current.length, + }); + + const canUndo = snapshot.currentIndex > 0; + const canRedo = snapshot.currentIndex < snapshot.historyLength - 1; + + const pushState = (sdcpn: SDCPN) => { + const current = historyRef.current[currentIndexRef.current]; + + // No-op detection + if (current && isSDCPNEqual(current.sdcpn, sdcpn)) { + return; + } + + // Debounce: coalesce rapid mutations into one entry + if (debounceTimerRef.current !== null) { + clearTimeout(debounceTimerRef.current); + // Replace the entry at currentIndex (it was a pending debounced entry) + historyRef.current[currentIndexRef.current] = { + sdcpn, + timestamp: new Date().toISOString(), + }; + + debounceTimerRef.current = setTimeout(() => { + debounceTimerRef.current = null; + }, DEBOUNCE_MS); + + bump(); + return; + } + + // Truncate any redo entries + historyRef.current = historyRef.current.slice( + 0, + currentIndexRef.current + 1, + ); + + // Push new entry + historyRef.current.push({ + sdcpn, + timestamp: new Date().toISOString(), + }); + + // Enforce max history size + if (historyRef.current.length > MAX_HISTORY) { + historyRef.current = historyRef.current.slice( + historyRef.current.length - MAX_HISTORY, + ); + } + + currentIndexRef.current = historyRef.current.length - 1; + + // Start debounce window + debounceTimerRef.current = setTimeout(() => { + debounceTimerRef.current = null; + }, DEBOUNCE_MS); + + bump(); + }; + + const undo = (): SDCPN | null => { + if (currentIndexRef.current <= 0) { + return null; + } + currentIndexRef.current -= 1; + bump(); + return historyRef.current[currentIndexRef.current]!.sdcpn; + }; + + const redo = (): SDCPN | null => { + if (currentIndexRef.current >= historyRef.current.length - 1) { + return null; + } + currentIndexRef.current += 1; + bump(); + return historyRef.current[currentIndexRef.current]!.sdcpn; + }; + + const goToIndex = (index: number): SDCPN | null => { + if (index < 0 || index >= historyRef.current.length) { + return null; + } + currentIndexRef.current = index; + bump(); + return historyRef.current[index]!.sdcpn; + }; + + const reset = (sdcpn: SDCPN) => { + historyRef.current = [{ sdcpn, timestamp: new Date().toISOString() }]; + currentIndexRef.current = 0; + if (debounceTimerRef.current !== null) { + clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + } + bump(); + }; + + return { + pushState, + undo, + redo, + goToIndex, + canUndo, + canRedo, + history: historyRef, + currentIndex: snapshot.currentIndex, + reset, + }; +} diff --git a/libs/@hashintel/petrinaut/src/petrinaut.tsx b/libs/@hashintel/petrinaut/src/petrinaut.tsx index b5ea3d4ef0b..c5c49601d57 100644 --- a/libs/@hashintel/petrinaut/src/petrinaut.tsx +++ b/libs/@hashintel/petrinaut/src/petrinaut.tsx @@ -18,6 +18,10 @@ import { PlaybackProvider } from "./playback/provider"; import { SimulationProvider } from "./simulation/provider"; import { EditorProvider } from "./state/editor-provider"; import { SDCPNProvider } from "./state/sdcpn-provider"; +import { + UndoRedoContext, + type UndoRedoContextValue, +} from "./state/undo-redo-context"; import { UserSettingsProvider } from "./state/user-settings-provider"; import { EditorView } from "./views/Editor/editor-view"; @@ -34,6 +38,8 @@ export type { Transition, }; +export type { UndoRedoContextValue as UndoRedoProps } from "./state/undo-redo-context"; + export type PetrinautProps = { /** * Nets other than this one which are available for selection, e.g. to switch to or to link from a transition. @@ -90,31 +96,39 @@ export type PetrinautProps = { * The title of the net which is currently loaded. */ title: string; + /** + * Optional undo/redo support. When provided, the editor will show + * undo/redo buttons in the top bar and register keyboard shortcuts. + */ + undoRedo?: UndoRedoContextValue; }; export const Petrinaut = ({ hideNetManagementControls, + undoRedo, ...rest }: PetrinautProps) => { return ( - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + ); }; diff --git a/libs/@hashintel/petrinaut/src/state/undo-redo-context.ts b/libs/@hashintel/petrinaut/src/state/undo-redo-context.ts new file mode 100644 index 00000000000..f9c3aba05c0 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/state/undo-redo-context.ts @@ -0,0 +1,13 @@ +import { createContext } from "react"; + +export type UndoRedoContextValue = { + undo: () => void; + redo: () => void; + canUndo: boolean; + canRedo: boolean; + history: Array<{ timestamp: string }>; + currentIndex: number; + goToIndex: (index: number) => void; +}; + +export const UndoRedoContext = createContext(null); diff --git a/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts b/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts index 20873791480..7e6463dc2c9 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts +++ b/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts @@ -1,6 +1,7 @@ -import { useEffect } from "react"; +import { use, useEffect } from "react"; import type { CursorMode, EditorState } from "../../../../state/editor-context"; +import { UndoRedoContext } from "../../../../state/undo-redo-context"; type EditorMode = EditorState["globalMode"]; type EditorEditionMode = EditorState["editionMode"]; @@ -10,10 +11,30 @@ export function useKeyboardShortcuts( onEditionModeChange: (mode: EditorEditionMode) => void, onCursorModeChange: (mode: CursorMode) => void, ) { + const undoRedo = use(UndoRedoContext); + useEffect(() => { function handleKeyDown(event: KeyboardEvent) { - // Don't trigger if focus is in an input, textarea, contentEditable, or Monaco editor const target = event.target as HTMLElement; + + // Handle undo/redo shortcuts before the input focus check, + // but let Monaco editors handle their own undo/redo. + if ( + undoRedo && + (event.metaKey || event.ctrlKey) && + event.key.toLowerCase() === "z" && + !target.closest(".monaco-editor") + ) { + event.preventDefault(); + if (event.shiftKey) { + undoRedo.redo(); + } else { + undoRedo.undo(); + } + return; + } + + // Don't trigger if focus is in an input, textarea, contentEditable, or Monaco editor const isInputFocused = target.tagName === "INPUT" || target.tagName === "TEXTAREA" || @@ -67,5 +88,5 @@ export function useKeyboardShortcuts( return () => { window.removeEventListener("keydown", handleKeyDown); }; - }, [mode, onEditionModeChange, onCursorModeChange]); + }, [mode, onEditionModeChange, onCursorModeChange, undoRedo]); } diff --git a/libs/@hashintel/petrinaut/src/views/Editor/components/TopBar/top-bar.tsx b/libs/@hashintel/petrinaut/src/views/Editor/components/TopBar/top-bar.tsx index b7acf0f04d9..97a212e66b9 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/components/TopBar/top-bar.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/components/TopBar/top-bar.tsx @@ -2,6 +2,8 @@ import { css } from "@hashintel/ds-helpers/css"; import { use } from "react"; import { FaBars } from "react-icons/fa6"; import { + TbArrowBackUp, + TbArrowForwardUp, TbLayoutSidebarLeftCollapse, TbLayoutSidebarRightCollapse, } from "react-icons/tb"; @@ -12,8 +14,10 @@ import { EditorContext, type EditorState, } from "../../../../state/editor-context"; +import { UndoRedoContext } from "../../../../state/undo-redo-context"; import { FloatingTitle } from "./floating-title"; import { ModeSelector } from "./mode-selector"; +import { VersionHistoryButton } from "./version-history-button"; const topBarStyle = css({ display: "flex", @@ -65,6 +69,7 @@ export const TopBar: React.FC = ({ onModeChange, }) => { const { isLeftSidebarOpen, setLeftSidebarOpen } = use(EditorContext); + const undoRedo = use(UndoRedoContext); return (
@@ -105,7 +110,29 @@ export const TopBar: React.FC = ({
- {/* Right section - version info, save button, etc. (placeholder for now) */} + {undoRedo && ( + <> + + + + + + + + + )}
); diff --git a/libs/@hashintel/petrinaut/src/views/Editor/components/TopBar/version-history-button.tsx b/libs/@hashintel/petrinaut/src/views/Editor/components/TopBar/version-history-button.tsx new file mode 100644 index 00000000000..72ee3692cee --- /dev/null +++ b/libs/@hashintel/petrinaut/src/views/Editor/components/TopBar/version-history-button.tsx @@ -0,0 +1,118 @@ +import { css, cx } from "@hashintel/ds-helpers/css"; +import { use } from "react"; +import { TbClock } from "react-icons/tb"; + +import { IconButton } from "../../../../components/icon-button"; +import { Popover } from "../../../../components/popover"; +import { UndoRedoContext } from "../../../../state/undo-redo-context"; + +const listStyle = css({ + display: "flex", + flexDirection: "column", + maxHeight: "[300px]", + overflowY: "auto", +}); + +const entryStyle = css({ + display: "flex", + alignItems: "center", + gap: "[8px]", + padding: "[6px_8px]", + fontSize: "[12px]", + color: "neutral.s100", + cursor: "pointer", + borderRadius: "[4px]", + border: "none", + backgroundColor: "[transparent]", + width: "[100%]", + textAlign: "left", + _hover: { + backgroundColor: "neutral.s10", + }, +}); + +const activeEntryStyle = css({ + backgroundColor: "neutral.s10", + fontWeight: "semibold", +}); + +const entryNumberStyle = css({ + color: "neutral.s60", + fontVariantNumeric: "tabular-nums", + flexShrink: 0, +}); + +const entryTimeStyle = css({ + color: "neutral.s60", + marginLeft: "auto", + flexShrink: 0, +}); + +function formatRelativeTime(timestamp: string): string { + const now = Date.now(); + const then = new Date(timestamp).getTime(); + const diffSeconds = Math.round((now - then) / 1000); + + if (diffSeconds < 5) { + return "just now"; + } + if (diffSeconds < 60) { + return `${diffSeconds}s ago`; + } + const diffMinutes = Math.round(diffSeconds / 60); + if (diffMinutes < 60) { + return `${diffMinutes}m ago`; + } + const diffHours = Math.round(diffMinutes / 60); + return `${diffHours}h ago`; +} + +export const VersionHistoryButton = () => { + const undoRedo = use(UndoRedoContext); + + if (!undoRedo) { + return null; + } + + const { history, currentIndex, goToIndex } = undoRedo; + + return ( + + + + + + + + Version History + + +
+ {[...history].reverse().map((entry, reversedIdx) => { + const realIndex = history.length - 1 - reversedIdx; + const isCurrent = realIndex === currentIndex; + return ( + + ); + })} +
+
+
+
+
+ ); +}; From 584586acaf730bca9c61099ef2f504f25ad60a79 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 4 Mar 2026 23:00:41 +0100 Subject: [PATCH 17/24] H-6281: Clear drag state after drag ends to prevent stale position restore After undo, ReactFlow could re-apply the old dragged position because the draggingStateByNodeId entry persisted with the pre-undo coordinates. Now the entry is removed when drag completes, so subsequent position changes correctly bail out via the !lastPosition guard. Co-Authored-By: Claude Opus 4.6 --- .../views/SDCPN/hooks/use-apply-node-changes.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/views/SDCPN/hooks/use-apply-node-changes.ts b/libs/@hashintel/petrinaut/src/views/SDCPN/hooks/use-apply-node-changes.ts index 50485bc022d..bdbbf354597 100644 --- a/libs/@hashintel/petrinaut/src/views/SDCPN/hooks/use-apply-node-changes.ts +++ b/libs/@hashintel/petrinaut/src/views/SDCPN/hooks/use-apply-node-changes.ts @@ -87,13 +87,14 @@ export function useApplyNodeChanges() { position: lastPosition, }); - updateDraggingStateByNodeId((existing) => ({ - ...existing, - [change.id]: { - dragging: false, - position: lastPosition, - }, - })); + // Clear the dragging state for this node now that the drag is complete + // and the position has been collected for commit to the SDCPN store. + // Keeping stale positions here would cause them to be re-applied + // if ReactFlow emits a spurious position change after an undo. + updateDraggingStateByNodeId((existing) => { + const { [change.id]: _, ...rest } = existing; + return rest; + }); } } } From 157374ad089ed29e9bc5c01c7f7bb95df870e037 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 4 Mar 2026 23:08:17 +0100 Subject: [PATCH 18/24] H-6281: Add changeset for undo/redo support Co-Authored-By: Claude Opus 4.6 --- .changeset/undo-redo-support.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/undo-redo-support.md diff --git a/.changeset/undo-redo-support.md b/.changeset/undo-redo-support.md new file mode 100644 index 00000000000..b7d25a4fe1f --- /dev/null +++ b/.changeset/undo-redo-support.md @@ -0,0 +1,5 @@ +--- +"@hashintel/petrinaut": minor +--- + +Add optional undo/redo support with version history UI, keyboard shortcuts (Cmd+Z / Cmd+Shift+Z), and drag debouncing From 1eb324e5a21cbf9054fbf1aa441b6b3f04138aa4 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 5 Mar 2026 18:14:37 +0100 Subject: [PATCH 19/24] H-6281: Remove window.confirm dialogs for deletions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Now that undo/redo is available, confirmation prompts before deleting places, transitions, types, equations, parameters, and type elements are unnecessary friction — users can simply undo if needed. Co-Authored-By: Claude Opus 4.6 --- .../subviews/differential-equations-list.tsx | 11 +---------- .../panels/LeftSideBar/subviews/parameters-list.tsx | 9 +-------- .../panels/LeftSideBar/subviews/types-list.tsx | 11 +---------- .../place-properties/subviews/main.tsx | 11 +---------- .../transition-properties/subviews/main.tsx | 11 +---------- .../type-properties/subviews/main.tsx | 13 ++----------- 6 files changed, 7 insertions(+), 59 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx index 72353c7aed9..b323758f1c3 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/differential-equations-list.tsx @@ -105,16 +105,7 @@ const DifferentialEquationsSectionContent: React.FC = () => { variant="ghost" colorScheme="red" disabled={isReadOnly} - onClick={() => { - if ( - // eslint-disable-next-line no-alert - window.confirm( - `Delete equation "${eq.name}"? Any places referencing this equation will have their differential equation reset.`, - ) - ) { - removeDifferentialEquation(eq.id); - } - }} + onClick={() => removeDifferentialEquation(eq.id)} aria-label={`Delete equation ${eq.name}`} tooltip={isReadOnly ? UI_MESSAGES.READ_ONLY_MODE : undefined} > diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx index 4162df6867a..13eccd1c8ed 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/parameters-list.tsx @@ -187,14 +187,7 @@ const ParametersList: React.FC = () => { variant="ghost" colorScheme="red" disabled={isReadOnly} - onClick={() => { - if ( - // eslint-disable-next-line no-alert - window.confirm(`Delete parameter "${param.name}"?`) - ) { - removeParameter(param.id); - } - }} + onClick={() => removeParameter(param.id)} aria-label={`Delete ${param.name}`} tooltip={ isReadOnly ? UI_MESSAGES.READ_ONLY_MODE : undefined diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx index c1e91438a64..3e355ce8170 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/LeftSideBar/subviews/types-list.tsx @@ -158,16 +158,7 @@ const TypesSectionContent: React.FC = () => { variant="ghost" colorScheme="red" disabled={isReadOnly} - onClick={() => { - if ( - // eslint-disable-next-line no-alert - window.confirm( - `Delete token type "${type.name}"? All places using this type will have their type set to null.`, - ) - ) { - removeType(type.id); - } - }} + onClick={() => removeType(type.id)} aria-label={`Delete token type ${type.name}`} tooltip={isReadOnly ? UI_MESSAGES.READ_ONLY_MODE : undefined} > diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties/subviews/main.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties/subviews/main.tsx index 3a3e96b511a..51c8392050f 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties/subviews/main.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/place-properties/subviews/main.tsx @@ -329,16 +329,7 @@ const DeletePlaceAction: React.FC = () => { aria-label="Delete" size="xs" colorScheme="red" - onClick={() => { - if ( - // eslint-disable-next-line no-alert - window.confirm( - `Are you sure you want to delete "${place.name}"? All arcs connected to this place will also be removed.`, - ) - ) { - removePlace(place.id); - } - }} + onClick={() => removePlace(place.id)} disabled={isReadOnly} tooltip={isReadOnly ? UI_MESSAGES.READ_ONLY_MODE : "Delete"} > diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/transition-properties/subviews/main.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/transition-properties/subviews/main.tsx index 951b4c9aea9..25cb77f9ac6 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/transition-properties/subviews/main.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/transition-properties/subviews/main.tsx @@ -219,16 +219,7 @@ const DeleteTransitionAction: React.FC = () => { aria-label="Delete" size="xs" colorScheme="red" - onClick={() => { - if ( - // eslint-disable-next-line no-alert - window.confirm( - `Are you sure you want to delete "${transition.name}"? All arcs connected to this transition will also be removed.`, - ) - ) { - removeTransition(transition.id); - } - }} + onClick={() => removeTransition(transition.id)} disabled={isReadOnly} tooltip={isReadOnly ? UI_MESSAGES.READ_ONLY_MODE : "Delete"} > diff --git a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/type-properties/subviews/main.tsx b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/type-properties/subviews/main.tsx index d9064778195..f4a9964a405 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/type-properties/subviews/main.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/panels/PropertiesPanel/type-properties/subviews/main.tsx @@ -195,16 +195,7 @@ const TypeMainContent: React.FC = () => { } }; - const handleDeleteElement = (elementId: string, elementName: string) => { - // eslint-disable-next-line no-alert - const confirmed = window.confirm( - `Delete element "${elementName}"?\n\nThis cannot be undone.`, - ); - - if (!confirmed) { - return; - } - + const handleDeleteElement = (elementId: string) => { updateType(type.id, (existingType) => { const index = existingType.elements.findIndex( (elem) => elem.elementId === elementId, @@ -352,7 +343,7 @@ const TypeMainContent: React.FC = () => { {/* Delete button */} { - handleDeleteElement(element.elementId, element.name); + handleDeleteElement(element.elementId); }} disabled={isDisabled || type.elements.length === 1} size="xxs" From e1f5a0f242d0758d6db7786e9f227f31bb79ebf6 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 5 Mar 2026 19:18:29 +0100 Subject: [PATCH 20/24] Update changeset to patch --- .changeset/undo-redo-support.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/undo-redo-support.md b/.changeset/undo-redo-support.md index b7d25a4fe1f..cf0d145e644 100644 --- a/.changeset/undo-redo-support.md +++ b/.changeset/undo-redo-support.md @@ -1,5 +1,5 @@ --- -"@hashintel/petrinaut": minor +"@hashintel/petrinaut": patch --- Add optional undo/redo support with version history UI, keyboard shortcuts (Cmd+Z / Cmd+Shift+Z), and drag debouncing From 729fe1f4e03f37e4f1073909bbd5a9b9d9d435ed Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 5 Mar 2026 20:25:02 +0100 Subject: [PATCH 21/24] H-6281: Replace undo/redo buttons with version history menu in TopBar Remove standalone undo/redo icon buttons from the TopBar, keeping only the version history button. Rewrite VersionHistoryButton to use the Menu component instead of a custom Popover, with a checkmark icon on the current version and flex layout for menu item suffixes. Co-Authored-By: Claude Opus 4.6 --- .../petrinaut/src/components/menu.tsx | 3 + .../Editor/components/TopBar/top-bar.tsx | 26 +--- .../TopBar/version-history-button.tsx | 120 ++++++------------ 3 files changed, 40 insertions(+), 109 deletions(-) diff --git a/libs/@hashintel/petrinaut/src/components/menu.tsx b/libs/@hashintel/petrinaut/src/components/menu.tsx index 9e6a10d1df9..54f7a9e1bf0 100644 --- a/libs/@hashintel/petrinaut/src/components/menu.tsx +++ b/libs/@hashintel/petrinaut/src/components/menu.tsx @@ -147,6 +147,9 @@ const itemDescriptionStyle = css({ }); const itemSuffixStyle = css({ + display: "flex", + alignItems: "center", + gap: "1", marginLeft: "auto", fontSize: "xs", color: "neutral.s80", diff --git a/libs/@hashintel/petrinaut/src/views/Editor/components/TopBar/top-bar.tsx b/libs/@hashintel/petrinaut/src/views/Editor/components/TopBar/top-bar.tsx index 97a212e66b9..05ebb4f1245 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/components/TopBar/top-bar.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/components/TopBar/top-bar.tsx @@ -2,8 +2,6 @@ import { css } from "@hashintel/ds-helpers/css"; import { use } from "react"; import { FaBars } from "react-icons/fa6"; import { - TbArrowBackUp, - TbArrowForwardUp, TbLayoutSidebarLeftCollapse, TbLayoutSidebarRightCollapse, } from "react-icons/tb"; @@ -110,29 +108,7 @@ export const TopBar: React.FC = ({
- {undoRedo && ( - <> - - - - - - - - - )} + {undoRedo && }
); diff --git a/libs/@hashintel/petrinaut/src/views/Editor/components/TopBar/version-history-button.tsx b/libs/@hashintel/petrinaut/src/views/Editor/components/TopBar/version-history-button.tsx index 72ee3692cee..b5a65313363 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/components/TopBar/version-history-button.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/components/TopBar/version-history-button.tsx @@ -1,53 +1,10 @@ -import { css, cx } from "@hashintel/ds-helpers/css"; -import { use } from "react"; -import { TbClock } from "react-icons/tb"; +import { use, useMemo } from "react"; +import { LuCheck, LuHistory } from "react-icons/lu"; import { IconButton } from "../../../../components/icon-button"; -import { Popover } from "../../../../components/popover"; +import { Menu, type MenuItem } from "../../../../components/menu"; import { UndoRedoContext } from "../../../../state/undo-redo-context"; -const listStyle = css({ - display: "flex", - flexDirection: "column", - maxHeight: "[300px]", - overflowY: "auto", -}); - -const entryStyle = css({ - display: "flex", - alignItems: "center", - gap: "[8px]", - padding: "[6px_8px]", - fontSize: "[12px]", - color: "neutral.s100", - cursor: "pointer", - borderRadius: "[4px]", - border: "none", - backgroundColor: "[transparent]", - width: "[100%]", - textAlign: "left", - _hover: { - backgroundColor: "neutral.s10", - }, -}); - -const activeEntryStyle = css({ - backgroundColor: "neutral.s10", - fontWeight: "semibold", -}); - -const entryNumberStyle = css({ - color: "neutral.s60", - fontVariantNumeric: "tabular-nums", - flexShrink: 0, -}); - -const entryTimeStyle = css({ - color: "neutral.s60", - marginLeft: "auto", - flexShrink: 0, -}); - function formatRelativeTime(timestamp: string): string { const now = Date.now(); const then = new Date(timestamp).getTime(); @@ -70,49 +27,44 @@ function formatRelativeTime(timestamp: string): string { export const VersionHistoryButton = () => { const undoRedo = use(UndoRedoContext); + const menuItems: MenuItem[] = useMemo(() => { + if (!undoRedo) { + return []; + } + const { history, currentIndex, goToIndex } = undoRedo; + + return [...history].reverse().map((entry, reversedIdx) => { + const realIndex = history.length - 1 - reversedIdx; + const isCurrent = realIndex === currentIndex; + return { + id: `version-${String(realIndex)}`, + label: `Version ${realIndex + 1}`, + suffix: ( + <> + {formatRelativeTime(entry.timestamp)} + {isCurrent && } + + ), + selected: isCurrent, + onClick: () => goToIndex(realIndex), + }; + }); + }, [undoRedo]); + if (!undoRedo) { return null; } - const { history, currentIndex, goToIndex } = undoRedo; - return ( - - - - + + - - - Version History - - -
- {[...history].reverse().map((entry, reversedIdx) => { - const realIndex = history.length - 1 - reversedIdx; - const isCurrent = realIndex === currentIndex; - return ( - - ); - })} -
-
-
-
- + } + items={menuItems} + animated + placement="bottom-end" + /> ); }; From 0c4b53b9856d3be398a53bc8ef7e6a2d57c12b26 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 5 Mar 2026 21:11:35 +0100 Subject: [PATCH 22/24] H-6281: Fix undo/redo bugs from AI review - Skip undo/redo shortcut when focus is in input/textarea/contentEditable so native text undo works in form fields - Clear debounce timer on undo/redo/goToIndex to prevent history corruption when editing within a debounce window after undoing - Compute new SDCPN synchronously via produce instead of reading from a ref in queueMicrotask, which could read stale state Co-Authored-By: Claude Opus 4.6 --- .../petrinaut/demo-site/main/app.tsx | 51 ++++++------------- .../demo-site/main/app/use-undo-redo.ts | 10 ++++ .../BottomBar/use-keyboard-shortcuts.ts | 14 +++-- 3 files changed, 36 insertions(+), 39 deletions(-) diff --git a/libs/@hashintel/petrinaut/demo-site/main/app.tsx b/libs/@hashintel/petrinaut/demo-site/main/app.tsx index 45ced81c2c5..0181a032979 100644 --- a/libs/@hashintel/petrinaut/demo-site/main/app.tsx +++ b/libs/@hashintel/petrinaut/demo-site/main/app.tsx @@ -86,25 +86,6 @@ export const DevApp = () => { ); }; - const mutatePetriNetDefinitionBase = ( - definitionMutationFn: (draft: SDCPN) => void, - ) => { - if (!currentNetId) { - return; - } - - setStoredSDCPNs((prev) => - produce(prev, (draft) => { - if (draft[currentNetId]) { - draft[currentNetId].sdcpn = produce( - draft[currentNetId].sdcpn, - definitionMutationFn, - ); - } - }), - ); - }; - const emptySDCPN: SDCPN = { places: [], transitions: [], @@ -129,26 +110,26 @@ export const DevApp = () => { : emptySDCPN, ); - // A ref to access the latest storedSDCPNs in the microtask callback - const storedSDCPNsRef = useRef(storedSDCPNs); - storedSDCPNsRef.current = storedSDCPNs; - const mutatePetriNetDefinition = ( definitionMutationFn: (draft: SDCPN) => void, ) => { - mutatePetriNetDefinitionBase(definitionMutationFn); + if (!currentNetId || !currentNet || isOldFormatInLocalStorage(currentNet)) { + return; + } - // Push the new SDCPN onto the history stack after the state update settles - queueMicrotask(() => { - const netId = currentNetId; - if (!netId) { - return; - } - const latest = storedSDCPNsRef.current[netId]; - if (latest && !isOldFormatInLocalStorage(latest)) { - pushState(latest.sdcpn); - } - }); + // Compute the new SDCPN synchronously so we can push it to history + // without relying on async state reads. + const newSDCPN = produce(currentNet.sdcpn, definitionMutationFn); + + setStoredSDCPNs((prev) => + produce(prev, (draft) => { + if (draft[currentNetId]) { + draft[currentNetId].sdcpn = newSDCPN; + } + }), + ); + + pushState(newSDCPN); }; const prevNetIdRef = useRef(currentNetId); diff --git a/libs/@hashintel/petrinaut/demo-site/main/app/use-undo-redo.ts b/libs/@hashintel/petrinaut/demo-site/main/app/use-undo-redo.ts index a859a78ed38..514f740989d 100644 --- a/libs/@hashintel/petrinaut/demo-site/main/app/use-undo-redo.ts +++ b/libs/@hashintel/petrinaut/demo-site/main/app/use-undo-redo.ts @@ -90,10 +90,18 @@ export function useUndoRedo(initialSDCPN: SDCPN) { bump(); }; + const clearDebounce = () => { + if (debounceTimerRef.current !== null) { + clearTimeout(debounceTimerRef.current); + debounceTimerRef.current = null; + } + }; + const undo = (): SDCPN | null => { if (currentIndexRef.current <= 0) { return null; } + clearDebounce(); currentIndexRef.current -= 1; bump(); return historyRef.current[currentIndexRef.current]!.sdcpn; @@ -103,6 +111,7 @@ export function useUndoRedo(initialSDCPN: SDCPN) { if (currentIndexRef.current >= historyRef.current.length - 1) { return null; } + clearDebounce(); currentIndexRef.current += 1; bump(); return historyRef.current[currentIndexRef.current]!.sdcpn; @@ -112,6 +121,7 @@ export function useUndoRedo(initialSDCPN: SDCPN) { if (index < 0 || index >= historyRef.current.length) { return null; } + clearDebounce(); currentIndexRef.current = index; bump(); return historyRef.current[index]!.sdcpn; diff --git a/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts b/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts index 7e6463dc2c9..9f639d668ee 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts +++ b/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts @@ -17,13 +17,19 @@ export function useKeyboardShortcuts( function handleKeyDown(event: KeyboardEvent) { const target = event.target as HTMLElement; - // Handle undo/redo shortcuts before the input focus check, - // but let Monaco editors handle their own undo/redo. + // Handle undo/redo shortcuts, but let Monaco editors, inputs, + // textareas, and contentEditable elements handle their own undo/redo. + const isTextInput = + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable || + target.closest(".monaco-editor") !== null; + if ( undoRedo && + !isTextInput && (event.metaKey || event.ctrlKey) && - event.key.toLowerCase() === "z" && - !target.closest(".monaco-editor") + event.key.toLowerCase() === "z" ) { event.preventDefault(); if (event.shiftKey) { From 3eeab7b1d2ee7cf33ab3f02bab9998536077f3ad Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Mon, 9 Mar 2026 12:53:24 +0100 Subject: [PATCH 23/24] H-6281: Fix stale closure in multi-node drags and deduplicate input checks Use the setState updater form in mutatePetriNetDefinition so multiple calls before a re-render each see the latest state, fixing lost position updates during multi-node drag end. Merge duplicated isTextInput and isInputFocused checks into a single variable to prevent drift. Co-Authored-By: Claude Opus 4.6 --- .../petrinaut/demo-site/main/app.tsx | 34 ++++++++++++------- .../BottomBar/use-keyboard-shortcuts.ts | 19 +++-------- 2 files changed, 27 insertions(+), 26 deletions(-) diff --git a/libs/@hashintel/petrinaut/demo-site/main/app.tsx b/libs/@hashintel/petrinaut/demo-site/main/app.tsx index 0181a032979..5ac24a5e94f 100644 --- a/libs/@hashintel/petrinaut/demo-site/main/app.tsx +++ b/libs/@hashintel/petrinaut/demo-site/main/app.tsx @@ -113,23 +113,33 @@ export const DevApp = () => { const mutatePetriNetDefinition = ( definitionMutationFn: (draft: SDCPN) => void, ) => { - if (!currentNetId || !currentNet || isOldFormatInLocalStorage(currentNet)) { + if (!currentNetId) { return; } - // Compute the new SDCPN synchronously so we can push it to history - // without relying on async state reads. - const newSDCPN = produce(currentNet.sdcpn, definitionMutationFn); + let newSDCPN: SDCPN | undefined; - setStoredSDCPNs((prev) => - produce(prev, (draft) => { - if (draft[currentNetId]) { - draft[currentNetId].sdcpn = newSDCPN; - } - }), - ); + // Use the updater form so that multiple calls before a re-render + // (e.g. multi-node drag end) each see the latest state. + setStoredSDCPNs((prev) => { + const net = prev[currentNetId]; + if (!net || isOldFormatInLocalStorage(net)) { + return prev; + } + const updatedSDCPN = produce(net.sdcpn, definitionMutationFn); + newSDCPN = updatedSDCPN; + return { + ...prev, + [currentNetId]: { + ...net, + sdcpn: updatedSDCPN, + }, + }; + }); - pushState(newSDCPN); + if (newSDCPN) { + pushState(newSDCPN); + } }; const prevNetIdRef = useRef(currentNetId); diff --git a/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts b/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts index 9f639d668ee..df4edfeb770 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts +++ b/libs/@hashintel/petrinaut/src/views/Editor/components/BottomBar/use-keyboard-shortcuts.ts @@ -17,17 +17,17 @@ export function useKeyboardShortcuts( function handleKeyDown(event: KeyboardEvent) { const target = event.target as HTMLElement; - // Handle undo/redo shortcuts, but let Monaco editors, inputs, - // textareas, and contentEditable elements handle their own undo/redo. - const isTextInput = + const isInputFocused = target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable || - target.closest(".monaco-editor") !== null; + target.closest(".monaco-editor") !== null || + target.closest("#sentry-feedback") !== null; + // Handle undo/redo shortcuts, but let inputs handle their own undo/redo. if ( undoRedo && - !isTextInput && + !isInputFocused && (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "z" ) { @@ -40,15 +40,6 @@ export function useKeyboardShortcuts( return; } - // Don't trigger if focus is in an input, textarea, contentEditable, or Monaco editor - const isInputFocused = - target.tagName === "INPUT" || - target.tagName === "TEXTAREA" || - target.isContentEditable || - // Check if we're inside a Monaco editor - target.closest(".monaco-editor") !== null || - target.closest("#sentry-feedback") !== null; - if (isInputFocused) { return; } From b765bb1e9749e7a356376f996bb32c62a169bbc3 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Mon, 9 Mar 2026 18:43:42 +0100 Subject: [PATCH 24/24] H-6281: Improve version history menu UX Show HH:mm:ss timestamps instead of labels, add scrollable max height, and keep menu open on item selection. Co-Authored-By: Claude Opus 4.6 --- .changeset/undo-redo-support.md | 2 +- .../petrinaut/src/components/menu.tsx | 12 ++++++- .../TopBar/version-history-button.tsx | 35 ++++++------------- 3 files changed, 23 insertions(+), 26 deletions(-) diff --git a/.changeset/undo-redo-support.md b/.changeset/undo-redo-support.md index cf0d145e644..95152518693 100644 --- a/.changeset/undo-redo-support.md +++ b/.changeset/undo-redo-support.md @@ -2,4 +2,4 @@ "@hashintel/petrinaut": patch --- -Add optional undo/redo support with version history UI, keyboard shortcuts (Cmd+Z / Cmd+Shift+Z), and drag debouncing +Add optional undo/redo support with version history UI, keyboard shortcuts (Cmd|Ctrl+Z / Cmd|Ctrl+Shift+Z), and drag debouncing diff --git a/libs/@hashintel/petrinaut/src/components/menu.tsx b/libs/@hashintel/petrinaut/src/components/menu.tsx index 54f7a9e1bf0..b9aa1f85712 100644 --- a/libs/@hashintel/petrinaut/src/components/menu.tsx +++ b/libs/@hashintel/petrinaut/src/components/menu.tsx @@ -207,6 +207,10 @@ export interface MenuProps { items: MenuItem[] | MenuGroup[]; /** Whether to animate the menu open/close. Adapts direction automatically. */ animated?: boolean; + /** Maximum height of the menu content (enables scrolling). */ + maxHeight?: string; + /** Whether to close the menu when an item is selected. Defaults to true. */ + closeOnSelect?: boolean; /** Preferred placement of the menu relative to the trigger. */ placement?: | "top" @@ -268,6 +272,8 @@ export const Menu: React.FC = ({ trigger, items, animated, + maxHeight, + closeOnSelect, placement, }) => { const portalContainerRef = usePortalContainerRef(); @@ -277,12 +283,16 @@ export const Menu: React.FC = ({ {trigger} - + {groups.map((group, groupIndex) => (
{groupIndex > 0 &&
} diff --git a/libs/@hashintel/petrinaut/src/views/Editor/components/TopBar/version-history-button.tsx b/libs/@hashintel/petrinaut/src/views/Editor/components/TopBar/version-history-button.tsx index b5a65313363..113a6289ca9 100644 --- a/libs/@hashintel/petrinaut/src/views/Editor/components/TopBar/version-history-button.tsx +++ b/libs/@hashintel/petrinaut/src/views/Editor/components/TopBar/version-history-button.tsx @@ -5,23 +5,13 @@ import { IconButton } from "../../../../components/icon-button"; import { Menu, type MenuItem } from "../../../../components/menu"; import { UndoRedoContext } from "../../../../state/undo-redo-context"; -function formatRelativeTime(timestamp: string): string { - const now = Date.now(); - const then = new Date(timestamp).getTime(); - const diffSeconds = Math.round((now - then) / 1000); - - if (diffSeconds < 5) { - return "just now"; - } - if (diffSeconds < 60) { - return `${diffSeconds}s ago`; - } - const diffMinutes = Math.round(diffSeconds / 60); - if (diffMinutes < 60) { - return `${diffMinutes}m ago`; - } - const diffHours = Math.round(diffMinutes / 60); - return `${diffHours}h ago`; +function formatTime(timestamp: string): string { + const date = new Date(timestamp); + return date.toLocaleTimeString("en-GB", { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); } export const VersionHistoryButton = () => { @@ -38,13 +28,8 @@ export const VersionHistoryButton = () => { const isCurrent = realIndex === currentIndex; return { id: `version-${String(realIndex)}`, - label: `Version ${realIndex + 1}`, - suffix: ( - <> - {formatRelativeTime(entry.timestamp)} - {isCurrent && } - - ), + label: formatTime(entry.timestamp), + suffix: isCurrent && , selected: isCurrent, onClick: () => goToIndex(realIndex), }; @@ -64,6 +49,8 @@ export const VersionHistoryButton = () => { } items={menuItems} animated + maxHeight="310px" + closeOnSelect={false} placement="bottom-end" /> );