diff --git a/.changeset/probabilistic-transition-kernels.md b/.changeset/probabilistic-transition-kernels.md
new file mode 100644
index 00000000000..bce32a50814
--- /dev/null
+++ b/.changeset/probabilistic-transition-kernels.md
@@ -0,0 +1,5 @@
+---
+"@hashintel/petrinaut": patch
+---
+
+Add probability distribution support to transition kernels (`Distribution.Gaussian`, `Distribution.Uniform`, `Distribution.Lognormal`)
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..7a86f00676d
--- /dev/null
+++ b/libs/@hashintel/petrinaut/src/examples/satellites-launcher.ts
@@ -0,0 +1,344 @@
+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 (
+
+ );
+});`,
+ x: 30,
+ y: 90,
+ },
+ {
+ id: "ea42ba61-03ea-4940-b2e2-b594d5331a71",
+ name: "Debris",
+ colorId: "f8e9d7c6-b5a4-3210-fedc-ba9876543210",
+ dynamicsEnabled: false,
+ differentialEquationId: null,
+ x: 510,
+ y: 75,
+ },
+ ],
+ 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,
+ },
+ {
+ 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, crash_threshold, satellite_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 + crash_threshold + satellite_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,
+ },
+ {
+ 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,
+ },
+ ],
+ 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/examples/satellites.ts b/libs/@hashintel/petrinaut/src/examples/satellites.ts
index 9042b7ae74d..0683e6a0d75 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..bb85d7a64de 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 quality threshold
+export default Lambda((tokens, parameters) => {
+ const { quality_threshold } = parameters;
+ return tokens.QAQueue[0].quality >= quality_threshold;
+});`,
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 quality threshold
+export default Lambda((tokens, parameters) => {
+ const { quality_threshold } = parameters;
+ return tokens.QAQueue[0].quality < quality_threshold;
+});`,
+ 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__quality_threshold",
+ name: "Quality Threshold",
+ variableName: "quality_threshold",
+ type: "real",
+ defaultValue: "0.2",
+ },
+ ],
},
};
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..3e2aa7006fa
--- /dev/null
+++ b/libs/@hashintel/petrinaut/src/lsp/lib/generate-virtual-files.ts
@@ -0,0 +1,236 @@
+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 = { 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;`,
+ ` function Uniform(min: number, max: number): Distribution;`,
+ ` function Lognormal(mu: number, sigma: 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(`Probabilistic`)
+ .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;
+}
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..2a2a12840de 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,10 +254,26 @@ export function computePossibleTransition(
);
}
- // Convert token objects back to number arrays in correct order
- const tokenArrays = outputTokens.map((token) => {
- return type.elements.map((element) => token[element.name]!);
- });
+ // Convert token objects back to number arrays in correct order,
+ // sampling any Distribution values using the RNG
+ 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(
+ raw,
+ currentRngState,
+ );
+ currentRngState = nextRng;
+ values.push(sampled);
+ } else {
+ values.push(raw);
+ }
+ }
+ tokenArrays.push(values);
+ }
addMap[outputArc.placeId] = tokenArrays;
}
@@ -275,7 +294,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..80a3b889131
--- /dev/null
+++ b/libs/@hashintel/petrinaut/src/simulation/simulator/distribution.ts
@@ -0,0 +1,129 @@
+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 =
+ | (DistributionBase & {
+ type: "gaussian";
+ mean: number;
+ deviation: number;
+ })
+ | (DistributionBase & { type: "uniform"; min: number; max: number })
+ | (DistributionBase & {
+ type: "lognormal";
+ mu: number;
+ sigma: number;
+ })
+ | (DistributionBase & {
+ type: "mapped";
+ inner: RuntimeDistribution;
+ fn: (value: number) => 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 &&
+ (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 = `
+ 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 __addMap({ __brand: "distribution", type: "gaussian", mean: mean, deviation: deviation });
+ },
+ 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 });
+ }
+ };
+`;
+
+/**
+ * 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]
+ */
+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
+ // 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(1 - u1)) * Math.cos(2 * Math.PI * u2);
+ value = distribution.mean + z * distribution.deviation;
+ nextRng = rng2;
+ break;
+ }
+ case "uniform": {
+ const [sample, newRng] = nextRandom(rngState);
+ value = distribution.min + sample * (distribution.max - distribution.min);
+ nextRng = newRng;
+ break;
+ }
+ 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(1 - 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,
+ rngState,
+ );
+ value = distribution.fn(innerValue);
+ nextRng = newRng;
+ break;
+ }
+ }
+
+ // 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 59585c59c61..ee185bd6ff8 100644
--- a/libs/@hashintel/petrinaut/src/simulation/simulator/types.ts
+++ b/libs/@hashintel/petrinaut/src/simulation/simulator/types.ts
@@ -7,6 +7,7 @@
import type { Color, Place, SDCPN, Transition } from "../../core/types/sdcpn";
import type { SimulationFrame } from "../context";
+import type { RuntimeDistribution } from "./distribution";
/**
* Runtime parameter values used during simulation execution.
@@ -39,7 +40,7 @@ export type LambdaFn = (
export type TransitionKernelFn = (
tokenValues: Record[]>,
parameters: ParameterValues,
-) => Record[]>;
+) => Record[]>;
/**
* Input configuration for building a new simulation instance.
diff --git a/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx b/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx
index cb9fc732aa8..c6c635b6794 100644
--- a/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx
+++ b/libs/@hashintel/petrinaut/src/views/Editor/editor-view.tsx
@@ -5,7 +5,10 @@ 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 { 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";
@@ -172,6 +175,22 @@ export const EditorView = ({
id: "load-example",
label: "Load example",
submenu: [
+ {
+ 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",
@@ -180,6 +199,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",