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`) diff --git a/libs/@hashintel/petrinaut/src/checker/lib/create-sdcpn-language-service.ts b/libs/@hashintel/petrinaut/src/checker/lib/create-sdcpn-language-service.ts index 02064505ca5..88d55b4d77c 100644 --- a/libs/@hashintel/petrinaut/src/checker/lib/create-sdcpn-language-service.ts +++ b/libs/@hashintel/petrinaut/src/checker/lib/create-sdcpn-language-service.ts @@ -1,236 +1,13 @@ import ts from "typescript"; import type { SDCPN } from "../../core/types/sdcpn"; -import { - createLanguageServiceHost, - type VirtualFile, -} from "./create-language-service-host"; -import { getItemFilePath } from "./file-paths"; +import { createLanguageServiceHost } from "./create-language-service-host"; +import { generateVirtualFiles } from "./generate-virtual-files"; export type SDCPNLanguageService = ts.LanguageService & { updateFileContent: (fileName: string, content: string) => void; }; -/** - * 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; -} - /** * Adjusts diagnostic positions to account for injected prefix */ diff --git a/libs/@hashintel/petrinaut/src/checker/lib/file-paths.ts b/libs/@hashintel/petrinaut/src/checker/lib/file-paths.ts index 3d81b931a29..197dda2fb6b 100644 --- a/libs/@hashintel/petrinaut/src/checker/lib/file-paths.ts +++ b/libs/@hashintel/petrinaut/src/checker/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/checker/lib/generate-virtual-files.ts b/libs/@hashintel/petrinaut/src/checker/lib/generate-virtual-files.ts new file mode 100644 index 00000000000..24242d9a348 --- /dev/null +++ b/libs/@hashintel/petrinaut/src/checker/lib/generate-virtual-files.ts @@ -0,0 +1,235 @@ +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;`, + `}`, + ].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/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/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..c4f80e9808b --- /dev/null +++ b/libs/@hashintel/petrinaut/src/simulation/simulator/distribution.ts @@ -0,0 +1,110 @@ +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: "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 }); + } + }; +`; + +/** + * 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 + 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 = 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 "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 7049fecc5f6..40de1edc54e 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"; @@ -209,6 +210,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",