From c6ed396d1764558f5877514777e2f62718df3573 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Wed, 8 Jul 2026 02:10:19 +0200 Subject: [PATCH 1/2] Add Petrinaut CLI server --- libs/@hashintel/petrinaut-cli/README.md | 102 ++ .../examples/deployment-pipeline.json | 522 ++++++ .../production-with-machine-failure.json | 418 +++++ .../examples/satellites-launcher.json | 342 ++++ .../petrinaut-cli/examples/sir-model.json | 229 +++ .../supply-chain-with-disruption.json | 1463 +++++++++++++++++ libs/@hashintel/petrinaut-cli/package.json | 30 + libs/@hashintel/petrinaut-cli/src/cli.ts | 69 + .../petrinaut-cli/src/commands/serve.ts | 150 ++ .../petrinaut-cli/src/runtime/load-model.ts | 58 + .../petrinaut-cli/src/runtime/run-request.ts | 178 ++ libs/@hashintel/petrinaut-cli/tsconfig.json | 22 + libs/@hashintel/petrinaut-cli/turbo.json | 10 + libs/@hashintel/petrinaut-cli/vite.config.ts | 31 + libs/@hashintel/petrinaut-core/src/index.ts | 10 + .../src/simulation/compiled-model.ts | 365 ++++ .../petrinaut-core/src/simulation/index.ts | 12 + yarn.lock | 16 + 18 files changed, 4027 insertions(+) create mode 100644 libs/@hashintel/petrinaut-cli/README.md create mode 100644 libs/@hashintel/petrinaut-cli/examples/deployment-pipeline.json create mode 100644 libs/@hashintel/petrinaut-cli/examples/production-with-machine-failure.json create mode 100644 libs/@hashintel/petrinaut-cli/examples/satellites-launcher.json create mode 100644 libs/@hashintel/petrinaut-cli/examples/sir-model.json create mode 100644 libs/@hashintel/petrinaut-cli/examples/supply-chain-with-disruption.json create mode 100644 libs/@hashintel/petrinaut-cli/package.json create mode 100644 libs/@hashintel/petrinaut-cli/src/cli.ts create mode 100644 libs/@hashintel/petrinaut-cli/src/commands/serve.ts create mode 100644 libs/@hashintel/petrinaut-cli/src/runtime/load-model.ts create mode 100644 libs/@hashintel/petrinaut-cli/src/runtime/run-request.ts create mode 100644 libs/@hashintel/petrinaut-cli/tsconfig.json create mode 100644 libs/@hashintel/petrinaut-cli/turbo.json create mode 100644 libs/@hashintel/petrinaut-cli/vite.config.ts create mode 100644 libs/@hashintel/petrinaut-core/src/simulation/compiled-model.ts diff --git a/libs/@hashintel/petrinaut-cli/README.md b/libs/@hashintel/petrinaut-cli/README.md new file mode 100644 index 00000000000..32c159a357e --- /dev/null +++ b/libs/@hashintel/petrinaut-cli/README.md @@ -0,0 +1,102 @@ +# `@hashintel/petrinaut-cli` + +Internal CLI/server for running one Petrinaut model repeatedly from scripts, +Python optimization loops, or backend jobs. + +## Start + +Build first: + +```bash +yarn workspace @hashintel/petrinaut-cli build +``` + +Serve over a Unix socket: + +```bash +petrinaut serve --model ./model.sdcpn.json --socket /tmp/petrinaut.sock +``` + +Or over loopback HTTP: + +```bash +petrinaut serve --model ./model.sdcpn.json --host 127.0.0.1 --port 8765 +``` + +The server loads Petrinaut Core once, compiles the model once, then accepts many +run requests. + +Example JSON models copied from Petrinaut Core live in `examples/`. + +## Endpoints + +`GET /healthz` + +```json +{ "ok": true } +``` + +`GET /metadata` + +Returns parameters, places and metrics. Use this from Python to discover: + +- parameter variable names, ids, display names and types; +- place ids, display names and colour fields; +- metric ids and names. + +`POST /runs` + +```json +{ + "parameters": { + "infection_rate": 1.5, + "recovery_rate": 0.8 + }, + "initialState": { + "Susceptible": 990, + "Infected": 10, + "Recovered": 0 + }, + "metrics": ["Infected Fraction", "Throughput"], + "maxSteps": 100, + "dt": 0.1, + "seed": 4242 +} +``` + +Fields: + +- `parameters`: parameter values. Keys may be parameter variable name, + parameter id, or display name. +- `initialState`: initial markings. Keys may be place id or display name. +- `metrics`: metric names/ids evaluated on the final frame. +- `maxSteps`: number of simulation steps. `dt` defaults to `1` if omitted. + +Transition predicates are part of the model structure, not the run request. To +change predicate logic, edit the model; to change values used by predicate +logic, pass new `parameters`. + +## Output + +Metrics are evaluated only on the final frame and returned as scalar values: + +```json +{ + "seed": 4242, + "status": "complete", + "completionReason": "maxSteps", + "frameCount": 101, + "finalTime": 10, + "finalPlaceTokenCounts": { + "place__susceptible": 900, + "place__infected": 40 + }, + "metrics": { + "Infected Fraction": 0.04, + "Throughput": 12.3 + } +} +``` + +The first version does not return metric distributions or full frame histories. +It is intentionally summary-first for optimization loops. diff --git a/libs/@hashintel/petrinaut-cli/examples/deployment-pipeline.json b/libs/@hashintel/petrinaut-cli/examples/deployment-pipeline.json new file mode 100644 index 00000000000..1a9e7571f44 --- /dev/null +++ b/libs/@hashintel/petrinaut-cli/examples/deployment-pipeline.json @@ -0,0 +1,522 @@ +{ + "title": "Deployment Pipeline", + "places": [ + { + "id": "place__deployment-ready", + "name": "DeploymentReady", + "colorId": "type__deployment", + "dynamicsEnabled": true, + "differentialEquationId": "dynamics__deployment_age", + "x": 180, + "y": 0, + "visualizerCode": "// Release queue: one card per deployment waiting to start. Card colour goes\n// green -> amber -> red with the deployment's risk, and width scales with its\n// size, so a glance shows how risky/large the backlog is.\nexport default Visualization(({ tokens }) => {\n const width = 380;\n const height = 170;\n const visible = tokens.slice(0, 20);\n return (\n \n \n Release queue\n {tokens.length} waiting; hotter cards are riskier\n {visible.map((deployment, index) => {\n const risk = Math.max(0, Math.min(1, deployment.risk));\n const size = Math.max(0.4, Math.min(2.2, deployment.size));\n const x = 18 + (index % 10) * 34;\n const y = 68 + Math.floor(index / 10) * 42;\n const fill = risk < 0.25 ? \"#22c55e\" : risk < 0.55 ? \"#f59e0b\" : \"#ef4444\";\n return (\n \n \n {Math.round(risk * 100)}\n \n );\n })}\n \n );\n});", + "showAsInitialState": true + }, + { + "id": "place__incident-being-investigated", + "name": "IncidentBeingInvestigated", + "colorId": "type__incident", + "dynamicsEnabled": true, + "differentialEquationId": "dynamics__incident_age", + "x": 675, + "y": 510, + "visualizerCode": "// Incident bridge: one bar per open incident, taller and darker with severity.\n// Because this place feeds an inhibitor arc into Start Deployment, any bar\n// shown here means the deployment gate is currently closed.\nexport default Visualization(({ tokens }) => {\n const width = 380;\n const height = 170;\n const visible = tokens.slice(0, 10);\n return (\n \n \n Incident bridge\n Inhibits new deployments while any card is present\n {visible.length === 0 && No active incident — deploy gate open}\n {visible.map((incident, index) => {\n const sev = Math.max(0, Math.min(1, incident.severity));\n const x = 20 + index * 34;\n const h = 24 + sev * 52;\n const y = 142 - h;\n return (\n \n \n S{Math.ceil(sev * 5)}\n \n );\n })}\n \n );\n});", + "showAsInitialState": true + }, + { + "id": "place__deployment-in-progress", + "name": "DeploymentInProgress", + "colorId": "type__deployment", + "dynamicsEnabled": true, + "differentialEquationId": "dynamics__deployment_age", + "x": 150, + "y": 315, + "visualizerCode": "// Deployment lane: shows the single in-progress deployment as a progress bar\n// (estimated from its age relative to size), coloured by risk. Empty when the\n// lane is idle — the inhibitor arc allows only one deployment at a time.\nexport default Visualization(({ tokens }) => {\n const width = 380;\n const height = 170;\n const deployment = tokens[0];\n const risk = deployment ? Math.max(0, Math.min(1, deployment.risk)) : 0;\n const age = deployment ? deployment.age : 0;\n const size = deployment ? deployment.size : 0;\n const progress = deployment ? Math.max(0.08, Math.min(1, age / Math.max(1, size * 6))) : 0;\n return (\n \n \n Deployment lane\n {!deployment && Idle — ready for next safe release}\n {deployment && (\n \n \n \n \n \n size {size.toFixed(2)} · risk {Math.round(risk * 100)}% · age {age.toFixed(1)}s\n \n )}\n \n );\n});", + "showAsInitialState": true + }, + { + "id": "place__completed-deployments", + "name": "CompletedDeployments", + "colorId": "type__deployment", + "dynamicsEnabled": false, + "differentialEquationId": null, + "x": 825, + "y": 210, + "visualizerCode": "// Successful releases: a tally of completed deployments (most recent last).\n// Each green check is one deployment that finished without causing an incident.\nexport default Visualization(({ tokens }) => {\n const width = 360;\n const height = 160;\n const visible = tokens.slice(-24);\n return (\n \n \n Successful releases\n {tokens.length} completed deployments\n {visible.map((deployment, index) => {\n const x = 18 + (index % 12) * 27;\n const y = 74 + Math.floor(index / 12) * 31;\n return (\n \n \n \n \n );\n })}\n \n );\n});", + "showAsInitialState": true + }, + { + "id": "place__resolved-incidents", + "name": "ResolvedIncidents", + "colorId": "type__incident", + "dynamicsEnabled": false, + "differentialEquationId": null, + "x": 1170, + "y": 510, + "visualizerCode": "// Closed incidents: a tally of resolved incident tickets (most recent last).\nexport default Visualization(({ tokens }) => {\n const width = 340;\n const height = 150;\n const visible = tokens.slice(-18);\n return (\n \n \n Closed incidents\n {tokens.length} resolved tickets\n {visible.map((incident, index) => {\n const x = 18 + (index % 9) * 34;\n const y = 72 + Math.floor(index / 9) * 30;\n return (\n \n \n \n \n );\n })}\n \n );\n});", + "showAsInitialState": true + }, + { + "id": "place__failed-deployments", + "name": "FailedDeployments", + "colorId": "type__deployment", + "dynamicsEnabled": false, + "differentialEquationId": null, + "visualizerCode": "// Failed releases: deployments that rolled back and opened an incident. The\n// red cross marks each failure and its outline thickens with the deployment's\n// risk, so the riskiest failures stand out.\nexport default Visualization(({ tokens }) => {\n const width = 360;\n const height = 160;\n const visible = tokens.slice(-18);\n return (\n \n \n Failed releases\n {tokens.length} rollback / incident-generating deploys\n {visible.map((deployment, index) => {\n const x = 18 + (index % 9) * 36;\n const y = 76 + Math.floor(index / 9) * 34;\n const risk = Math.max(0, Math.min(1, deployment.risk));\n return (\n \n \n \n \n \n );\n })}\n \n );\n});", + "showAsInitialState": true, + "x": 870, + "y": 330 + } + ], + "transitions": [ + { + "id": "transition__create-deployment", + "name": "Create Deployment", + "inputArcs": [], + "outputArcs": [ + { + "placeId": "place__deployment-ready", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "/**\n * Deployment arrivals.\n *\n * This stochastic lambda returns the expected number of new deployment\n * candidates created per simulation second. The transition has no input\n * places, so it behaves like an external arrival process feeding the\n * DeploymentReady queue.\n */\nexport default Lambda((input, parameters) => {\n return parameters.deployment_creation_rate;\n});", + "transitionKernelCode": "/**\n * Create one coloured Deployment token.\n *\n * - size is sampled from a lognormal distribution so most releases are\n * ordinary, with occasional large releases.\n * - risk is sampled from a Gaussian distribution and clamped to [0.02, 0.95]\n * so every deployment has some risk but never exceeds a probability-like cap.\n * - age starts at zero and is advanced by the Deployment Age dynamics while the\n * token waits or runs.\n */\nexport default TransitionKernel((input, parameters) => {\n const size = Distribution.Lognormal(Math.log(Math.max(0.1, parameters.mean_deployment_size)), 0.35);\n const rawRisk = Distribution.Gaussian(0.28 * parameters.deployment_risk_multiplier, 0.16);\n return {\n DeploymentReady: [\n {\n size,\n risk: rawRisk.map(r => Math.max(0.02, Math.min(0.95, r))),\n age: 0,\n },\n ],\n };\n});", + "x": -90, + "y": 0 + }, + { + "id": "transition__incident-raised", + "name": "Incident Raised", + "inputArcs": [], + "outputArcs": [ + { + "placeId": "place__incident-being-investigated", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "/**\n * External incident arrivals.\n *\n * This stochastic lambda models incidents raised independently of deployments,\n * such as infrastructure failures or customer-impacting issues. Each firing\n * creates one IncidentBeingInvestigated token, which then blocks the deployment\n * start gate via the inhibitor arc.\n */\nexport default Lambda((input, parameters) => {\n return parameters.incident_rate;\n});", + "transitionKernelCode": "/**\n * Create one coloured Incident token.\n *\n * Severity is sampled around the configured incident severity multiplier and\n * clamped to [0.05, 1]. Higher-severity incidents resolve more slowly in the\n * Close Incident transition, so this sampled attribute affects later dynamics.\n */\nexport default TransitionKernel((input, parameters) => {\n const rawSeverity = Distribution.Gaussian(0.45 * parameters.incident_severity_multiplier, 0.2);\n return {\n IncidentBeingInvestigated: [\n {\n severity: rawSeverity.map(s => Math.max(0.05, Math.min(1, s))),\n age: 0,\n },\n ],\n };\n});", + "x": 450, + "y": 585 + }, + { + "id": "transition__start-deployment", + "name": "Start Deployment", + "inputArcs": [ + { + "placeId": "place__deployment-ready", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__incident-being-investigated", + "weight": 1, + "type": "inhibitor" + }, + { + "placeId": "place__deployment-in-progress", + "weight": 1, + "type": "inhibitor" + } + ], + "outputArcs": [ + { + "placeId": "place__deployment-in-progress", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "/**\n * Safety-gated deployment start.\n *\n * The boolean logic is intentionally simple: if the transition is enabled by\n * the Petri-net arcs, it may fire. The important behaviour is in the arcs:\n * - DeploymentReady is a standard input arc, so one queued deployment is consumed.\n * - IncidentBeingInvestigated is an inhibitor arc, so any active incident blocks firing.\n * - DeploymentInProgress is an inhibitor arc, so only one deployment may run at a time.\n */\nexport default Lambda(() => true);", + "transitionKernelCode": "/**\n * Move a deployment from the ready queue into the in-progress lane.\n *\n * The deployment's size and risk are preserved. Age is reset to zero so the\n * in-progress visualizer and downstream completion/failure logic can interpret\n * age as time spent running rather than time spent waiting in the queue.\n */\nexport default TransitionKernel((input) => {\n const deployment = input.DeploymentReady[0];\n return {\n DeploymentInProgress: [\n {\n size: deployment.size,\n risk: deployment.risk,\n age: 0,\n },\n ],\n };\n});", + "x": 480, + "y": 0 + }, + { + "id": "transition__finish-deployment", + "name": "Finish Deployment", + "inputArcs": [ + { + "placeId": "place__deployment-in-progress", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__completed-deployments", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "/**\n * Successful deployment completion rate.\n *\n * The returned rate is in expected firings per simulation second for the\n * specific DeploymentInProgress token being considered. Larger deployments\n * complete more slowly, and risky deployments get a modest slowdown to represent\n * extra validation, caution, or late-stage friction.\n */\nexport default Lambda((input, parameters) => {\n const deployment = input.DeploymentInProgress[0];\n const sizePenalty = Math.max(0.25, deployment.size);\n const riskPenalty = Math.max(0.15, 1 - deployment.risk * 0.45);\n return parameters.deployment_finish_base_rate * riskPenalty / sizePenalty;\n});", + "transitionKernelCode": "/**\n * Record a successful deployment.\n *\n * The token's attributes are copied into CompletedDeployments so visualizers and\n * metrics can still inspect the size, risk, and elapsed run age of releases that\n * made it through the pipeline.\n */\nexport default TransitionKernel((input) => {\n const deployment = input.DeploymentInProgress[0];\n return {\n CompletedDeployments: [\n {\n size: deployment.size,\n risk: deployment.risk,\n age: deployment.age,\n },\n ],\n };\n});", + "x": 480, + "y": 165 + }, + { + "id": "transition__close-incident", + "name": "Close Incident", + "inputArcs": [ + { + "placeId": "place__incident-being-investigated", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__resolved-incidents", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "/**\n * Incident resolution rate.\n *\n * Each active incident is considered separately. High-severity incidents resolve\n * more slowly, because severity appears in the denominator. Once the final\n * incident leaves IncidentBeingInvestigated, the inhibitor arc on Start\n * Deployment no longer blocks the release gate.\n */\nexport default Lambda((input, parameters) => {\n const incident = input.IncidentBeingInvestigated[0];\n return parameters.incident_resolution_rate / Math.max(0.2, incident.severity);\n});", + "transitionKernelCode": "/**\n * Move an incident to the resolved archive.\n *\n * Severity and age are preserved so the resolved incident pile can show the\n * history of operational load, even though resolved incidents no longer block\n * deployments.\n */\nexport default TransitionKernel((input) => {\n const incident = input.IncidentBeingInvestigated[0];\n return {\n ResolvedIncidents: [\n {\n severity: incident.severity,\n age: incident.age,\n },\n ],\n };\n});", + "x": 930, + "y": 510 + }, + { + "id": "transition__deployment-causes-incident", + "name": "Deployment Causes Incident", + "inputArcs": [ + { + "placeId": "place__deployment-in-progress", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__failed-deployments", + "weight": 1 + }, + { + "placeId": "place__incident-being-investigated", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "/**\n * Deployment failure / incident-generation rate.\n *\n * This is the competing stochastic outcome to Finish Deployment. Riskier and\n * larger deployments are more likely to fail, and the scenario-level risk\n * multiplier can make the whole release environment safer or more dangerous.\n * When this fires, the in-progress deployment is consumed and an incident is\n * opened, which then blocks future starts through the inhibitor arc.\n */\nexport default Lambda((input, parameters) => {\n const deployment = input.DeploymentInProgress[0];\n const risk = Math.max(0, Math.min(1, deployment.risk));\n const size = Math.max(0.25, deployment.size);\n return parameters.deployment_failure_base_rate * parameters.deployment_risk_multiplier * risk * size;\n});", + "transitionKernelCode": "/**\n * Turn a failed deployment into both an audit record and an active incident.\n *\n * The failed deployment is copied to FailedDeployments for counting and display.\n * A new Incident token is created with severity derived from deployment risk,\n * plus Gaussian noise. The severity is clamped to [0.05, 1] so incident handling\n * remains numerically stable and visually interpretable.\n */\nexport default TransitionKernel((input, parameters) => {\n const deployment = input.DeploymentInProgress[0];\n const rawSeverity = Distribution.Gaussian(\n Math.min(1, 0.25 + deployment.risk * 0.65 * parameters.incident_severity_multiplier),\n 0.12\n );\n return {\n FailedDeployments: [\n {\n size: deployment.size,\n risk: deployment.risk,\n age: deployment.age,\n },\n ],\n IncidentBeingInvestigated: [\n {\n severity: rawSeverity.map(s => Math.max(0.05, Math.min(1, s))),\n age: 0,\n },\n ],\n };\n});", + "x": 450, + "y": 405 + } + ], + "types": [ + { + "id": "type__deployment", + "name": "Deployment", + "iconSlug": "rocket", + "displayColor": "#2563eb", + "elements": [ + { + "elementId": "deployment__size", + "name": "size", + "type": "real" + }, + { + "elementId": "deployment__risk", + "name": "risk", + "type": "real" + }, + { + "elementId": "deployment__age", + "name": "age", + "type": "real" + } + ] + }, + { + "id": "type__incident", + "name": "Incident", + "iconSlug": "alert-triangle", + "displayColor": "#dc2626", + "elements": [ + { + "elementId": "incident__severity", + "name": "severity", + "type": "real" + }, + { + "elementId": "incident__age", + "name": "age", + "type": "real" + } + ] + } + ], + "differentialEquations": [ + { + "id": "dynamics__deployment_age", + "name": "Deployment Age", + "colorId": "type__deployment", + "code": "// A simple clock: every deployment's age increases at rate 1 (size and risk\n// are fixed at creation, so their derivatives are 0). Age is used to estimate\n// progress in the deployment-lane visualizer.\nexport default Dynamics((tokens) => {\n return tokens.map(() => ({\n size: 0,\n risk: 0,\n age: 1,\n }));\n});" + }, + { + "id": "dynamics__incident_age", + "name": "Incident Age", + "colorId": "type__incident", + "code": "// Clock for incidents: age rises at rate 1 while severity stays fixed. Older,\n// higher-severity incidents take longer to resolve in the Close Incident rate.\nexport default Dynamics((tokens) => {\n return tokens.map(() => ({\n severity: 0,\n age: 1,\n }));\n});" + } + ], + "parameters": [ + { + "id": "param__deployment_creation_rate", + "name": "Deployment Creation Rate", + "variableName": "deployment_creation_rate", + "type": "real", + "defaultValue": "0.5" + }, + { + "id": "param__incident_rate", + "name": "Incident Rate", + "variableName": "incident_rate", + "type": "real", + "defaultValue": "0.1" + }, + { + "id": "param__incident_resolution_rate", + "name": "Incident Resolution Rate", + "variableName": "incident_resolution_rate", + "type": "real", + "defaultValue": "0.3" + }, + { + "id": "param__deployment_finish_base_rate", + "name": "Deployment Finish Base Rate", + "variableName": "deployment_finish_base_rate", + "type": "real", + "defaultValue": "0.35" + }, + { + "id": "param__deployment_failure_base_rate", + "name": "Deployment Failure Base Rate", + "variableName": "deployment_failure_base_rate", + "type": "real", + "defaultValue": "0.04" + }, + { + "id": "param__deployment_risk_multiplier", + "name": "Deployment Risk Multiplier", + "variableName": "deployment_risk_multiplier", + "type": "real", + "defaultValue": "1" + }, + { + "id": "param__mean_deployment_size", + "name": "Mean Deployment Size", + "variableName": "mean_deployment_size", + "type": "real", + "defaultValue": "1" + }, + { + "id": "param__incident_severity_multiplier", + "name": "Incident Severity Multiplier", + "variableName": "incident_severity_multiplier", + "type": "real", + "defaultValue": "1" + } + ], + "metrics": [ + { + "id": "metric__successful_deployments", + "name": "Successful deployments", + "description": "Cumulative number of deployments that finished without causing an incident.", + "code": "return state.places.CompletedDeployments.count;" + }, + { + "id": "metric__failed_deployments", + "name": "Failed deployments", + "description": "Cumulative number of deployments that rolled back and opened an incident.", + "code": "return state.places.FailedDeployments.count;" + }, + { + "id": "metric__release_queue_length", + "name": "Release queue length", + "description": "How many deployments are waiting behind the safety gate.", + "code": "return state.places.DeploymentReady.count;" + }, + { + "id": "metric__active_incidents", + "name": "Active incidents", + "description": "Number of incidents currently blocking the deployment gate.", + "code": "return state.places.IncidentBeingInvestigated.count;" + }, + { + "id": "metric__deployment_gate_blocked", + "name": "Deployment gate blocked", + "description": "Returns 1 when an incident or active deployment is preventing another deployment from starting; otherwise 0.", + "code": "const incidents = state.places.IncidentBeingInvestigated.count;\nconst inProgress = state.places.DeploymentInProgress.count;\nreturn incidents > 0 || inProgress > 0 ? 1 : 0;" + }, + { + "id": "metric__failure_share", + "name": "Failure share", + "description": "Share of completed-or-failed deployments that ended in failure.", + "code": "const failed = state.places.FailedDeployments.count;\nconst succeeded = state.places.CompletedDeployments.count;\nconst total = failed + succeeded;\nreturn total === 0 ? 0 : failed / total;" + } + ], + "scenarios": [ + { + "id": "scenario__baseline", + "name": "Baseline operations", + "description": "Balanced deployment rate, moderate risk, and normal incident flow. Good starting point for explaining the inhibitor gate.", + "scenarioParameters": [ + { + "type": "real", + "identifier": "deployment_rate", + "default": 0.5 + }, + { + "type": "real", + "identifier": "incident_rate", + "default": 0.1 + }, + { + "type": "real", + "identifier": "risk_multiplier", + "default": 1 + }, + { + "type": "real", + "identifier": "mean_size", + "default": 1 + }, + { + "type": "real", + "identifier": "resolution_rate", + "default": 0.3 + } + ], + "parameterOverrides": { + "param__deployment_creation_rate": "scenario.deployment_rate", + "param__incident_rate": "scenario.incident_rate", + "param__deployment_risk_multiplier": "scenario.risk_multiplier", + "param__mean_deployment_size": "scenario.mean_size", + "param__incident_resolution_rate": "scenario.resolution_rate" + }, + "initialState": { + "type": "per_place", + "content": {} + } + }, + { + "id": "scenario__incident_surge", + "name": "Incident surge", + "description": "External incidents arrive quickly and block the deployment gate, causing the release queue to pile up.", + "scenarioParameters": [ + { + "type": "real", + "identifier": "deployment_rate", + "default": 0.5 + }, + { + "type": "real", + "identifier": "incident_rate", + "default": 0.35 + }, + { + "type": "real", + "identifier": "severity_multiplier", + "default": 1.25 + }, + { + "type": "real", + "identifier": "resolution_rate", + "default": 0.22 + } + ], + "parameterOverrides": { + "param__deployment_creation_rate": "scenario.deployment_rate", + "param__incident_rate": "scenario.incident_rate", + "param__incident_severity_multiplier": "scenario.severity_multiplier", + "param__incident_resolution_rate": "scenario.resolution_rate" + }, + "initialState": { + "type": "per_place", + "content": {} + } + }, + { + "id": "scenario__high_velocity", + "name": "High deployment velocity", + "description": "A high release creation rate tests whether the single-deployment safety gate becomes the bottleneck.", + "scenarioParameters": [ + { + "type": "real", + "identifier": "deployment_rate", + "default": 1.2 + }, + { + "type": "real", + "identifier": "finish_rate", + "default": 0.45 + }, + { + "type": "real", + "identifier": "risk_multiplier", + "default": 1 + }, + { + "type": "real", + "identifier": "incident_rate", + "default": 0.08 + } + ], + "parameterOverrides": { + "param__deployment_creation_rate": "scenario.deployment_rate", + "param__deployment_finish_base_rate": "scenario.finish_rate", + "param__deployment_risk_multiplier": "scenario.risk_multiplier", + "param__incident_rate": "scenario.incident_rate" + }, + "initialState": { + "type": "per_place", + "content": {} + } + }, + { + "id": "scenario__risky_large_releases", + "name": "Risky large releases", + "description": "Larger, riskier releases take longer and more often create incidents, visibly closing the inhibitor gate.", + "scenarioParameters": [ + { + "type": "real", + "identifier": "deployment_rate", + "default": 0.45 + }, + { + "type": "real", + "identifier": "mean_size", + "default": 1.8 + }, + { + "type": "real", + "identifier": "risk_multiplier", + "default": 1.75 + }, + { + "type": "real", + "identifier": "failure_base_rate", + "default": 0.07 + }, + { + "type": "real", + "identifier": "severity_multiplier", + "default": 1.4 + } + ], + "parameterOverrides": { + "param__deployment_creation_rate": "scenario.deployment_rate", + "param__mean_deployment_size": "scenario.mean_size", + "param__deployment_risk_multiplier": "scenario.risk_multiplier", + "param__deployment_failure_base_rate": "scenario.failure_base_rate", + "param__incident_severity_multiplier": "scenario.severity_multiplier" + }, + "initialState": { + "type": "per_place", + "content": {} + } + } + ] +} diff --git a/libs/@hashintel/petrinaut-cli/examples/production-with-machine-failure.json b/libs/@hashintel/petrinaut-cli/examples/production-with-machine-failure.json new file mode 100644 index 00000000000..d7f0c5951e5 --- /dev/null +++ b/libs/@hashintel/petrinaut-cli/examples/production-with-machine-failure.json @@ -0,0 +1,418 @@ +{ + "title": "Production With Machine Failure", + "places": [ + { + "id": "place__d662407f-c56d-4a96-bcbb-ead785a9c594", + "name": "RawMaterial", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": -165, + "y": -405 + }, + { + "id": "place__2bdd959f-a5bc-404a-bd03-34fafcef66b8", + "name": "AvailableMachines", + "colorId": "type__1762560152725", + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": -150, + "y": 75 + }, + { + "id": "place__81e551b4-11dc-4781-9cd7-dd882fd7e947", + "name": "MachinesProducing", + "colorId": "type__1762560154179", + "dynamicsEnabled": true, + "differentialEquationId": "ca26e5e2-0373-46a9-920e-a6eacadd92e8", + "x": 375, + "y": -225 + }, + { + "id": "place__d5f92ae2-c8c4-49cb-935e-4a35e4f7b5fe", + "name": "BadProduct", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "x": 1110, + "y": -105 + }, + { + "id": "place__7b695ff5-a397-4237-8e30-ddf8cbc9e2c4", + "name": "GoodProduct", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "x": 1110, + "y": -225 + }, + { + "id": "place__e5af0410-d80a-4c8b-b3bf-692918b98e6c", + "name": "BrokenMachines", + "colorId": "type__1762560152725", + "dynamicsEnabled": false, + "differentialEquationId": null, + "x": 1110, + "y": 90 + }, + { + "id": "place__17c65d6e-0c3e-48e6-a677-2914e28131ac", + "name": "MachinesBeingRepaired", + "colorId": "type__1762560152725", + "dynamicsEnabled": true, + "differentialEquationId": "5bfea547-faaf-4626-8662-6400d07c049e", + "x": -585, + "y": 405 + }, + { + "id": "place__4b72cf19-907b-4fc0-ac0a-555453e95d4b", + "name": "TechniciansComing", + "colorId": "type__1762560159263", + "dynamicsEnabled": true, + "differentialEquationId": "887245c3-183c-4dac-a1aa-d602d21b6450", + "x": 855, + "y": 795 + }, + { + "id": "place__eaca89b8-1db1-45fa-8c3a-6eb6f0419ffa", + "name": "AvailableTechnicians", + "colorId": "type__1762560159263", + "dynamicsEnabled": false, + "differentialEquationId": null, + "x": 1395, + "y": 795 + }, + { + "id": "place__9cb073fb-f1d7-4613-8b10-8d1b08796f24", + "name": "MachinesToRepair", + "colorId": "type__1762560152725", + "dynamicsEnabled": false, + "differentialEquationId": null, + "x": 1110, + "y": 585 + } + ], + "transitions": [ + { + "id": "transition__76f23aa1-404a-4696-ac14-5a634af01221", + "name": "Production Success", + "inputArcs": [ + { + "placeId": "place__81e551b4-11dc-4781-9cd7-dd882fd7e947", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__7b695ff5-a397-4237-8e30-ddf8cbc9e2c4", + "weight": 1 + }, + { + "placeId": "place__2bdd959f-a5bc-404a-bd03-34fafcef66b8", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "// Production finishes when the batch's transformation progress (advanced by\n// the Production Dynamics) reaches 100%.\nexport default Lambda((tokens) => {\n return tokens.MachinesProducing[0].transformation_progress >= 1;\n})", + "transitionKernelCode": "// On success the machine returns to the AvailableMachines pool, carrying its\n// accumulated damage with it. The GoodProduct output place is uncoloured, so\n// it simply gains a token (no attributes to set here).\nexport default TransitionKernel((tokensByPlace) => {\n return {\n AvailableMachines: [\n { machine_damage_ratio: tokensByPlace.MachinesProducing[0].machine_damage_ratio }\n ],\n };\n});", + "x": 720, + "y": -225 + }, + { + "id": "transition__b524484d-263e-4065-b8b2-7a8e49529260", + "name": "Machine Fail", + "inputArcs": [ + { + "placeId": "place__81e551b4-11dc-4781-9cd7-dd882fd7e947", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__d5f92ae2-c8c4-49cb-935e-4a35e4f7b5fe", + "weight": 1 + }, + { + "placeId": "place__e5af0410-d80a-4c8b-b3bf-692918b98e6c", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "// Failure competes with Production Success on the same producing machine.\n// Raising the damage ratio (in [0,1]) to the 100th power keeps the hazard\n// almost zero for healthy machines and only spikes as damage approaches 1,\n// so machines mostly fail when they are already badly worn.\nexport default Lambda((tokens) => {\n return tokens.MachinesProducing[0].machine_damage_ratio ** 100;\n})", + "transitionKernelCode": "// A failed machine moves to BrokenMachines, keeping its damage ratio so the\n// downstream repair flow knows how much damage to undo.\nexport default TransitionKernel((tokens) => {\n return {\n BrokenMachines: [\n {\n machine_damage_ratio: tokens.MachinesProducing[0].machine_damage_ratio\n }\n ],\n };\n});", + "x": 720, + "y": -105 + }, + { + "id": "transition__c4b30ba4-da08-4407-b97b-41e2db5d6879", + "name": "Start Production", + "inputArcs": [ + { + "placeId": "place__d662407f-c56d-4a96-bcbb-ead785a9c594", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__2bdd959f-a5bc-404a-bd03-34fafcef66b8", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__81e551b4-11dc-4781-9cd7-dd882fd7e947", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "// Always enabled: production starts as soon as the input arcs are satisfied\n// (one RawMaterial token and one AvailableMachine token).\nexport default Lambda(() => true)", + "transitionKernelCode": "// Move the chosen machine into MachinesProducing with progress reset to 0,\n// preserving whatever damage it has already accumulated.\nexport default TransitionKernel((tokensByPlace) => {\n return {\n MachinesProducing: [\n {\n machine_damage_ratio: tokensByPlace.AvailableMachines[0].machine_damage_ratio,\n transformation_progress: 0\n }\n ],\n };\n});", + "x": 90, + "y": -225 + }, + { + "id": "transition__cc61df1f-00f3-456f-8a80-03e8b68f3007", + "name": "Finish Repair", + "inputArcs": [ + { + "placeId": "place__17c65d6e-0c3e-48e6-a677-2914e28131ac", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__2bdd959f-a5bc-404a-bd03-34fafcef66b8", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "// Repair is complete once the Reparation Dynamics have driven the machine's\n// damage ratio down to 0 (or below).\nexport default Lambda((tokens) => {\n return tokens.MachinesBeingRepaired[0].machine_damage_ratio <= 0;\n})", + "transitionKernelCode": "// Return the fully-repaired machine to the AvailableMachines pool with its\n// damage reset to 0, ready to start producing again.\nexport default TransitionKernel((tokensByPlace, parameters) => {\n return {\n AvailableMachines: [\n { machine_damage_ratio: 0 }\n ],\n };\n});", + "x": -330, + "y": 405 + }, + { + "id": "transition__11f0b21a-d0f2-4bd5-b4c1-d23627f921c5", + "name": "Call Technician", + "inputArcs": [ + { + "placeId": "place__e5af0410-d80a-4c8b-b3bf-692918b98e6c", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__4b72cf19-907b-4fc0-ac0a-555453e95d4b", + "weight": 1 + }, + { + "placeId": "place__9cb073fb-f1d7-4613-8b10-8d1b08796f24", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "// Always enabled: as soon as a machine is broken we dispatch a technician.\nexport default Lambda(() => true)", + "transitionKernelCode": "// Park the broken machine in MachinesToRepair (passing the token straight\n// through) and dispatch a technician who starts 10 units away; the Technician\n// Travel Dynamics then count that distance down to 0.\nexport default TransitionKernel((tokens, parameters) => {\n return {\n MachinesToRepair: tokens.BrokenMachines,\n TechniciansComing: [\n { distance_to_site: 10 }\n ],\n };\n});", + "x": 570, + "y": 735 + }, + { + "id": "transition__514730c0-7ac5-47d5-8def-91446a248a83", + "name": "Technician Ready", + "inputArcs": [ + { + "placeId": "place__4b72cf19-907b-4fc0-ac0a-555453e95d4b", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__eaca89b8-1db1-45fa-8c3a-6eb6f0419ffa", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "// The technician has arrived once their remaining travel distance hits 0.\nexport default Lambda((tokens) => {\n return tokens.TechniciansComing[0].distance_to_site <= 0;\n})", + "transitionKernelCode": "// The arrived technician joins the AvailableTechnicians pool, ready to be\n// paired with a machine in the Start Repair transition.\nexport default TransitionKernel((tokensByPlace, parameters) => {\n return {\n AvailableTechnicians: [\n { distance_to_site: 0 }\n ],\n };\n});", + "x": 1125, + "y": 795 + }, + { + "id": "transition__0efcd1bf-b1ff-466f-8a8f-c329ddce0ce8", + "name": "Start Repair", + "inputArcs": [ + { + "placeId": "place__eaca89b8-1db1-45fa-8c3a-6eb6f0419ffa", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__9cb073fb-f1d7-4613-8b10-8d1b08796f24", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__17c65d6e-0c3e-48e6-a677-2914e28131ac", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "// Always enabled: repair begins as soon as both input arcs are satisfied,\n// i.e. an available technician AND a machine waiting to be repaired.\nexport default Lambda(() => true)", + "transitionKernelCode": "// Pair the technician with the waiting machine: move it into\n// MachinesBeingRepaired (carrying its current damage), where the Reparation\n// Dynamics will steadily reduce the damage until Finish Repair fires.\nexport default TransitionKernel((tokens) => {\n return {\n MachinesBeingRepaired: [\n { machine_damage_ratio: tokens.MachinesToRepair[0].machine_damage_ratio }\n ],\n };\n});", + "x": 1635, + "y": 480 + } + ], + "types": [ + { + "id": "type__1762560152725", + "name": "Machine", + "iconSlug": "circle", + "displayColor": "#3b82f6", + "elements": [ + { + "elementId": "element__1762560152725_3", + "name": "machine_damage_ratio", + "type": "real" + } + ] + }, + { + "id": "type__1762560154179", + "name": "Machine Producing Product", + "iconSlug": "circle", + "displayColor": "#733bf6ff", + "elements": [ + { + "elementId": "element__1762560154179_2", + "name": "machine_damage_ratio", + "type": "real" + }, + { + "elementId": "element__1762560154179_3", + "name": "transformation_progress", + "type": "real" + } + ] + }, + { + "id": "type__1762560159263", + "name": "Technician", + "iconSlug": "circle", + "displayColor": "#3bf689ff", + "elements": [ + { + "elementId": "element__1762560159263_2", + "name": "distance_to_site", + "type": "real" + } + ] + } + ], + "differentialEquations": [ + { + "id": "5bfea547-faaf-4626-8662-6400d07c049e", + "name": "Reparation Dynamics", + "colorId": "type__1762560152725", + "code": "// Applies to machines in the MachinesBeingRepaired place. Dynamics return the\n// DERIVATIVE of each attribute, so a negative value means the damage ratio\n// falls steadily at the repair rate until Finish Repair fires at 0.\nexport default Dynamics((tokens, parameters) => {\n return tokens.map(({ machine_damage_ratio }) => {\n return {\n machine_damage_ratio: -parameters.damage_reparation_per_second\n };\n });\n});" + }, + { + "id": "ca26e5e2-0373-46a9-920e-a6eacadd92e8", + "name": "Production Dynamics", + "colorId": "type__1762560154179", + "code": "// Applies to machines actively producing (the MachinesProducing place).\n// While producing, the machine accumulates damage at `damage_per_second` and\n// its transformation_progress advances at 0.5/sec (so a batch takes ~2 sec to\n// reach the progress >= 1 completion threshold). More time producing means\n// more accumulated damage, which feeds the Machine Fail hazard.\nexport default Dynamics((tokens, parameters) => {\n return tokens.map(({ machine_damage_ratio, transformation_progress }) => {\n return {\n machine_damage_ratio: parameters.damage_per_second,\n transformation_progress: 1 / 2\n };\n });\n});" + }, + { + "id": "887245c3-183c-4dac-a1aa-d602d21b6450", + "name": "Technician Travel Dynamics", + "colorId": "type__1762560159263", + "code": "// Applies to technicians in the TechniciansComing place. They close the gap\n// to the site at a constant 2 units/sec; once distance_to_site reaches 0 the\n// Technician Ready transition can fire. (Dynamics return derivatives.)\nexport default Dynamics((tokens, parameters) => {\n return tokens.map(({ distance_to_site }) => {\n return {\n distance_to_site: -2\n };\n });\n});" + } + ], + "parameters": [ + { + "id": "param__damage_per_second", + "name": "Damage Per Second", + "variableName": "damage_per_second", + "type": "real", + "defaultValue": "0.05" + }, + { + "id": "param__damage_reparation_per_second", + "name": "Damage Reparation Per Second", + "variableName": "damage_reparation_per_second", + "type": "real", + "defaultValue": "0.333" + } + ], + "metrics": [ + { + "id": "metric__good_products", + "name": "Good products", + "description": "Cumulative count of products that passed and shipped.", + "code": "return state.places.GoodProduct.count;" + }, + { + "id": "metric__defective_products", + "name": "Defective products", + "description": "Cumulative count of products that came out defective.", + "code": "return state.places.BadProduct.count;" + }, + { + "id": "metric__yield", + "name": "Yield", + "description": "Share of finished products that were good rather than defective.", + "code": "const good = state.places.GoodProduct.count;\nconst bad = state.places.BadProduct.count;\nconst total = good + bad;\nreturn total === 0 ? 1 : good / total;" + }, + { + "id": "metric__machines_down", + "name": "Machines down", + "description": "Machines that are broken, waiting for a technician, or being repaired (i.e. not producing).", + "code": "return (\n state.places.BrokenMachines.count +\n state.places.MachinesToRepair.count +\n state.places.MachinesBeingRepaired.count\n);" + }, + { + "id": "metric__average_machine_damage", + "name": "Average machine damage", + "description": "Mean damage ratio across machines that are available or currently producing.", + "code": "const fleet = state.places.AvailableMachines.tokens.concat(\n state.places.MachinesProducing.tokens,\n);\nif (fleet.length === 0) return 0;\nreturn fleet.reduce((sum, m) => sum + m.machine_damage_ratio, 0) / fleet.length;" + } + ], + "scenarios": [ + { + "id": "scenario__default_production", + "name": "Default Production", + "description": "Configurable raw material, machine count, and initial machine damage.", + "scenarioParameters": [ + { + "type": "integer", + "identifier": "raw_material", + "default": 10 + }, + { + "type": "integer", + "identifier": "machines_count", + "default": 3 + }, + { + "type": "ratio", + "identifier": "initial_machine_damage", + "default": 0 + } + ], + "parameterOverrides": {}, + "initialState": { + "type": "code", + "content": "return {\n RawMaterial: scenario.raw_material,\n AvailableMachines: Array.from(\n { length: scenario.machines_count },\n () => ({ machine_damage_ratio: scenario.initial_machine_damage }),\n ),\n};" + } + } + ] +} diff --git a/libs/@hashintel/petrinaut-cli/examples/satellites-launcher.json b/libs/@hashintel/petrinaut-cli/examples/satellites-launcher.json new file mode 100644 index 00000000000..2c6eab6e78b --- /dev/null +++ b/libs/@hashintel/petrinaut-cli/examples/satellites-launcher.json @@ -0,0 +1,342 @@ +{ + "title": "Probabilistic Satellite Launcher", + "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 }) => {\n const { satellite_radius, planet_radius } = parameters;\n\n const width = 800;\n const height = 600;\n\n const centerX = width / 2;\n const centerY = height / 2;\n\n return (\n \n {/* Background */}\n \n\n {/* Planet at center */}\n \n\n {/* Satellites */}\n {tokens.map(({ x, y, direction, velocity }, index) => {\n // Convert satellite coordinates to screen coordinates\n // Assuming satellite coordinates are relative to planet center\n const screenX = centerX + x;\n const screenY = centerY + y;\n\n return (\n \n {/* Satellite */}\n \n\n {/* Velocity vector indicator */}\n {velocity > 0 && (\n \n )}\n \n );\n })}\n\n {/* Arrow marker for velocity vectors */}\n \n \n \n \n \n \n );\n});", + "x": 15, + "y": 90 + }, + { + "id": "ea42ba61-03ea-4940-b2e2-b594d5331a71", + "name": "Debris", + "colorId": "f8e9d7c6-b5a4-3210-fedc-ba9876543210", + "dynamicsEnabled": false, + "differentialEquationId": null, + "x": 540, + "y": 90 + } + ], + "transitions": [ + { + "id": "d25015d8-7aac-45ff-82b0-afd943f1b7ec", + "name": "Collision", + "inputArcs": [ + { + "placeId": "3cbc7944-34cb-4eeb-b779-4e392a171fe1", + "weight": 2, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "ea42ba61-03ea-4940-b2e2-b594d5331a71", + "weight": 2 + } + ], + "lambdaType": "predicate", + "lambdaCode": "// Check if two satellites collide (are within collision threshold)\nexport default Lambda((tokens, parameters) => {\n const { satellite_radius } = parameters;\n\n // Get the two satellites\n const [a, b] = tokens.Space;\n\n // Calculate distance between satellites\n const distance = Math.hypot(b.x - a.x, b.y - a.y);\n\n // Collision occurs if distance is less than threshold\n return distance < satellite_radius;\n})", + "transitionKernelCode": "// When satellites collide, they become debris (lose velocity)\nexport default TransitionKernel((tokens) => {\n // Both satellites become stationary debris at their collision point\n return {\n Debris: [\n // Position preserved, direction and velocity zeroed\n {\n x: tokens.Space[0].x,\n y: tokens.Space[0].y,\n velocity: 0,\n direction: 0\n },\n {\n x: tokens.Space[1].x,\n y: tokens.Space[1].y,\n velocity: 0,\n direction: 0\n },\n ]\n };\n})", + "x": 270, + "y": 180 + }, + { + "id": "716fe1e5-9b35-413f-83fe-99b28ba73945", + "name": "Crash", + "inputArcs": [ + { + "placeId": "3cbc7944-34cb-4eeb-b779-4e392a171fe1", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "ea42ba61-03ea-4940-b2e2-b594d5331a71", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "// Check if satellite crashes into planet (within crash threshold of origin)\nexport default Lambda((tokens, parameters) => {\n const { planet_radius, crash_threshold, satellite_radius } = parameters;\n\n // Get satellite position\n const { x, y } = tokens.Space[0];\n\n // Calculate distance from planet center (origin)\n const distance = Math.hypot(x, y);\n\n // Crash occurs if satellite is too close to planet\n return distance < planet_radius + crash_threshold + satellite_radius;\n})", + "transitionKernelCode": "// When satellite crashes into planet, it becomes debris at crash site\nexport default TransitionKernel((tokens) => {\n return {\n Debris: [\n {\n // Position preserved, direction and velocity zeroed\n x: tokens.Space[0].x,\n y: tokens.Space[0].y,\n direction: 0,\n velocity: 0\n },\n ]\n };\n})", + "x": 270, + "y": 15 + }, + { + "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) => {\n return parameters.launch_rate;\n});", + "transitionKernelCode": "export default TransitionKernel((tokensByPlace, parameters) => {\n const { planet_radius, altitude, initial_velocity } = parameters;\n\n const distance = planet_radius + altitude;\n const angle = Distribution.Uniform(0, Math.PI * 2);\n\n return {\n Space: [\n {\n x: angle.map(a => Math.cos(a) * distance),\n y: angle.map(a => Math.sin(a) * distance),\n direction: Distribution.Uniform(0, Math.PI * 2),\n velocity: Distribution.Gaussian(initial_velocity, initial_velocity * 0.1)\n }\n ],\n };\n});", + "x": -255, + "y": 30 + } + ], + "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)\nexport default Dynamics((tokens, parameters) => {\n const mu = parameters.gravitational_constant; // Gravitational parameter\n\n // Process each token (satellite)\n return tokens.map(({ x, y, direction, velocity }) => {\n const r = Math.hypot(x, y); // Distance to planet center\n\n // Gravitational acceleration vector (points toward origin)\n const ax = (-mu * x) / (r * r * r);\n const ay = (-mu * y) / (r * r * r);\n\n // Return derivatives for this token\n return {\n x: velocity * Math.cos(direction),\n y: velocity * Math.sin(direction),\n direction:\n (-ax * Math.sin(direction) + ay * Math.cos(direction)) / velocity,\n velocity:\n ax * Math.cos(direction) + ay * Math.sin(direction),\n }\n })\n})" + } + ], + "parameters": [ + { + "id": "6f7a8b9c-0d1e-2f3a-4b5c-6d7e8f9a0b1c", + "name": "Planet Radius", + "variableName": "planet_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" + }, + { + "id": "1e2f3a4b-5c6d-7e8f-9a0b-1c2d3e4f5a6b", + "name": "Altitude", + "variableName": "altitude", + "type": "real", + "defaultValue": "40.0" + }, + { + "id": "2f3a4b5c-6d7e-8f9a-0b1c-2d3e4f5a6b7c", + "name": "Launch Rate", + "variableName": "launch_rate", + "type": "real", + "defaultValue": "0.5" + }, + { + "id": "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d", + "name": "Initial Velocity", + "variableName": "initial_velocity", + "type": "real", + "defaultValue": "67.0" + } + ], + "metrics": [ + { + "id": "metric__satellites_in_orbit", + "name": "Satellites in orbit", + "description": "Number of satellites currently in orbit (the Space place).", + "code": "return state.places.Space.count;" + }, + { + "id": "metric__debris", + "name": "Debris objects", + "description": "Number of defunct objects produced by collisions and crashes.", + "code": "return state.places.Debris.count;" + }, + { + "id": "metric__average_orbital_radius", + "name": "Average orbital radius", + "description": "Mean distance of orbiting satellites from the planet centre (the origin).", + "code": "const sats = state.places.Space.tokens;\nif (sats.length === 0) return 0;\nreturn sats.reduce((sum, s) => sum + Math.hypot(s.x, s.y), 0) / sats.length;" + }, + { + "id": "metric__average_orbital_speed", + "name": "Average orbital speed", + "description": "Mean speed of the satellites currently in orbit.", + "code": "const sats = state.places.Space.tokens;\nif (sats.length === 0) return 0;\nreturn sats.reduce((sum, s) => sum + s.velocity, 0) / sats.length;" + } + ], + "scenarios": [ + { + "id": "scenario__moon_orbit", + "name": "Moon Orbit", + "description": "Low gravity, small body. Satellites drift in gentle arcs around a lunar-mass body.", + "scenarioParameters": [ + { + "type": "real", + "identifier": "launch_rate", + "default": 0.3 + }, + { + "type": "real", + "identifier": "satellite_initial_altitude", + "default": 20 + }, + { + "type": "real", + "identifier": "satellite_initial_velocity", + "default": 11 + } + ], + "parameterOverrides": { + "0d1e2f3a-4b5c-6d7e-8f9a-0b1c2d3e4f5a": "5000", + "6f7a8b9c-0d1e-2f3a-4b5c-6d7e8f9a0b1c": "14", + "2f3a4b5c-6d7e-8f9a-0b1c-2d3e4f5a6b7c": "scenario.launch_rate", + "1e2f3a4b-5c6d-7e8f-9a0b-1c2d3e4f5a6b": "scenario.satellite_initial_altitude", + "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d": "scenario.satellite_initial_velocity" + }, + "initialState": { + "type": "per_place", + "content": {} + } + }, + { + "id": "scenario__earth_orbit", + "name": "Earth Orbit", + "description": "Standard Earth gravity. High orbital velocities with frequent launches into low orbit.", + "scenarioParameters": [ + { + "type": "real", + "identifier": "launch_rate", + "default": 0.5 + }, + { + "type": "real", + "identifier": "satellite_initial_altitude", + "default": 40 + }, + { + "type": "real", + "identifier": "satellite_initial_velocity", + "default": 67 + } + ], + "parameterOverrides": { + "0d1e2f3a-4b5c-6d7e-8f9a-0b1c2d3e4f5a": "400000", + "6f7a8b9c-0d1e-2f3a-4b5c-6d7e8f9a0b1c": "50", + "2f3a4b5c-6d7e-8f9a-0b1c-2d3e4f5a6b7c": "scenario.launch_rate", + "1e2f3a4b-5c6d-7e8f-9a0b-1c2d3e4f5a6b": "scenario.satellite_initial_altitude", + "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d": "scenario.satellite_initial_velocity" + }, + "initialState": { + "type": "per_place", + "content": {} + } + }, + { + "id": "scenario__mars_orbit", + "name": "Mars Orbit", + "description": "Intermediate gravity between Moon and Earth. Moderate orbital speeds with a thin atmosphere margin.", + "scenarioParameters": [ + { + "type": "real", + "identifier": "launch_rate", + "default": 0.4 + }, + { + "type": "real", + "identifier": "satellite_initial_altitude", + "default": 25 + }, + { + "type": "real", + "identifier": "satellite_initial_velocity", + "default": 29 + } + ], + "parameterOverrides": { + "0d1e2f3a-4b5c-6d7e-8f9a-0b1c2d3e4f5a": "43000", + "6f7a8b9c-0d1e-2f3a-4b5c-6d7e8f9a0b1c": "27", + "2f3a4b5c-6d7e-8f9a-0b1c-2d3e4f5a6b7c": "scenario.launch_rate", + "1e2f3a4b-5c6d-7e8f-9a0b-1c2d3e4f5a6b": "scenario.satellite_initial_altitude", + "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d": "scenario.satellite_initial_velocity" + }, + "initialState": { + "type": "per_place", + "content": {} + } + }, + { + "id": "scenario__solar_orbit", + "name": "Solar Orbit", + "description": "Massive central body with extreme gravity. Satellites need very high velocities to maintain distant orbits.", + "scenarioParameters": [ + { + "type": "real", + "identifier": "launch_rate", + "default": 0.6 + }, + { + "type": "real", + "identifier": "satellite_initial_altitude", + "default": 50 + }, + { + "type": "real", + "identifier": "satellite_initial_velocity", + "default": 196 + } + ], + "parameterOverrides": { + "0d1e2f3a-4b5c-6d7e-8f9a-0b1c2d3e4f5a": "5000000", + "6f7a8b9c-0d1e-2f3a-4b5c-6d7e8f9a0b1c": "80", + "2f3a4b5c-6d7e-8f9a-0b1c-2d3e4f5a6b7c": "scenario.launch_rate", + "1e2f3a4b-5c6d-7e8f-9a0b-1c2d3e4f5a6b": "scenario.satellite_initial_altitude", + "3a4b5c6d-7e8f-9a0b-1c2d-3e4f5a6b7c8d": "scenario.satellite_initial_velocity" + }, + "initialState": { + "type": "per_place", + "content": {} + } + } + ] +} diff --git a/libs/@hashintel/petrinaut-cli/examples/sir-model.json b/libs/@hashintel/petrinaut-cli/examples/sir-model.json new file mode 100644 index 00000000000..5af8f80cc7c --- /dev/null +++ b/libs/@hashintel/petrinaut-cli/examples/sir-model.json @@ -0,0 +1,229 @@ +{ + "title": "SIR Epidemic Model", + "places": [ + { + "id": "place__susceptible", + "name": "Susceptible", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": -435, + "y": 150 + }, + { + "id": "place__infected", + "name": "Infected", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": -195, + "y": 285 + }, + { + "id": "place__recovered", + "name": "Recovered", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "x": 375, + "y": 195 + } + ], + "transitions": [ + { + "id": "transition__infection", + "name": "Infection", + "inputArcs": [ + { + "placeId": "place__susceptible", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__infected", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__infected", + "weight": 2 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "// Mass-action infection: fires at the configured infection rate whenever a\n// Susceptible and an Infected are both present (the two standard input arcs).\nexport default Lambda((tokens, parameters) => parameters.infection_rate)", + "transitionKernelCode": "// Consumes 1 Susceptible + 1 Infected and produces 2 Infected (the output\n// arc has weight 2), encoding the S + I -> 2I reaction: the susceptible has\n// become newly infected. Places are untyped, so tokens carry no attributes.\nexport default TransitionKernel(() => {\n return {\n Infected: [{}, {}],\n };\n});", + "x": -150, + "y": 75 + }, + { + "id": "transition__recovery", + "name": "Recovery", + "inputArcs": [ + { + "placeId": "place__infected", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__recovered", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "// Each Infected recovers at the configured recovery rate. The ratio of\n// infection_rate to recovery_rate sets the basic reproduction number R0.\nexport default Lambda((tokens, parameters) => parameters.recovery_rate)", + "transitionKernelCode": "// Move one Infected to Recovered (1-to-1). Recovered individuals are immune,\n// so they never re-enter the Susceptible or Infected places.\nexport default TransitionKernel(() => {\n return {\n Recovered: [{}],\n };\n});", + "x": 90, + "y": 240 + } + ], + "types": [], + "differentialEquations": [], + "parameters": [ + { + "id": "param__infection_rate", + "name": "Infection Rate", + "variableName": "infection_rate", + "type": "real", + "defaultValue": "3" + }, + { + "id": "param__recovery_rate", + "name": "Recovery Rate", + "variableName": "recovery_rate", + "type": "real", + "defaultValue": "1" + } + ], + "scenarios": [ + { + "id": "scenario__seasonal_flu", + "name": "Seasonal Flu", + "description": "Moderate outbreak with R₀ ≈ 1.5. Models a typical seasonal influenza wave in a small community.", + "scenarioParameters": [ + { + "type": "integer", + "identifier": "population", + "default": 1000 + }, + { + "type": "ratio", + "identifier": "infected_ratio", + "default": 0.01 + } + ], + "parameterOverrides": { + "param__infection_rate": "1.5", + "param__recovery_rate": "0.8" + }, + "initialState": { + "type": "per_place", + "content": { + "place__susceptible": "scenario.population * (1 - scenario.infected_ratio)", + "place__infected": "scenario.population * scenario.infected_ratio", + "place__recovered": "0" + } + } + }, + { + "id": "scenario__high_virulence", + "name": "High Virulence Outbreak", + "description": "Aggressive pathogen with R₀ ≈ 6 and slow recovery, modelling rapid spread before interventions.", + "scenarioParameters": [ + { + "type": "integer", + "identifier": "population", + "default": 10000 + }, + { + "type": "ratio", + "identifier": "infected_ratio", + "default": 0.0001 + } + ], + "parameterOverrides": { + "param__infection_rate": "6", + "param__recovery_rate": "0.5" + }, + "initialState": { + "type": "per_place", + "content": { + "place__susceptible": "scenario.population * (1 - scenario.infected_ratio)", + "place__infected": "scenario.population * scenario.infected_ratio", + "place__recovered": "0" + } + } + }, + { + "id": "scenario__contained_outbreak", + "name": "Contained Outbreak", + "description": "Sub-threshold spread with R₀ < 1 (recovery outpaces infection), so the outbreak fizzles out instead of taking off.", + "scenarioParameters": [ + { + "type": "integer", + "identifier": "population", + "default": 1000 + }, + { + "type": "ratio", + "identifier": "infected_ratio", + "default": 0.05 + } + ], + "parameterOverrides": { + "param__infection_rate": "0.6", + "param__recovery_rate": "1.2" + }, + "initialState": { + "type": "per_place", + "content": { + "place__susceptible": "scenario.population * (1 - scenario.infected_ratio)", + "place__infected": "scenario.population * scenario.infected_ratio", + "place__recovered": "0" + } + } + }, + { + "id": "scenario__pandemic_wave", + "name": "Pandemic Wave", + "description": "A large, mostly-susceptible population seeded with a few cases and R₀ ≈ 5 — useful for watching the classic epidemic curve build and burn out at scale.", + "scenarioParameters": [ + { + "type": "integer", + "identifier": "population", + "default": 100000 + }, + { + "type": "ratio", + "identifier": "infected_ratio", + "default": 0.00005 + } + ], + "parameterOverrides": { + "param__infection_rate": "2.5", + "param__recovery_rate": "0.5" + }, + "initialState": { + "type": "per_place", + "content": { + "place__susceptible": "scenario.population * (1 - scenario.infected_ratio)", + "place__infected": "scenario.population * scenario.infected_ratio", + "place__recovered": "0" + } + } + } + ], + "metrics": [ + { + "id": "metric__infected_fraction", + "name": "Infected Fraction", + "description": "Share of the population currently infected.", + "code": "const s = state.places.Susceptible.count;\nconst i = state.places.Infected.count;\nconst r = state.places.Recovered.count;\nconst total = s + i + r;\nreturn total === 0 ? 0 : i / total;" + } + ] +} diff --git a/libs/@hashintel/petrinaut-cli/examples/supply-chain-with-disruption.json b/libs/@hashintel/petrinaut-cli/examples/supply-chain-with-disruption.json new file mode 100644 index 00000000000..abfc1271f91 --- /dev/null +++ b/libs/@hashintel/petrinaut-cli/examples/supply-chain-with-disruption.json @@ -0,0 +1,1463 @@ +{ + "title": "Supply Chain With Disruption", + "places": [ + { + "id": "place_supplier_a_available", + "name": "SupplierAAvailable", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "visualizerCode": "", + "showAsInitialState": true, + "x": 405, + "y": 150 + }, + { + "id": "place_supplier_a_down", + "name": "SupplierADown", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "visualizerCode": "", + "showAsInitialState": true, + "x": 960, + "y": 90 + }, + { + "id": "place_supplier_b_available", + "name": "SupplierBAvailable", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "visualizerCode": "", + "showAsInitialState": true, + "x": 405, + "y": 525 + }, + { + "id": "place_supplier_b_down", + "name": "SupplierBDown", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "visualizerCode": "", + "showAsInitialState": true, + "x": 960, + "y": 585 + }, + { + "id": "place_raw_materials", + "name": "RawMaterials", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "visualizerCode": "", + "showAsInitialState": true, + "x": 960, + "y": 285 + }, + { + "id": "place_damaged_inbound", + "name": "DamagedInbound", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "visualizerCode": "", + "showAsInitialState": true, + "x": 960, + "y": 435 + }, + { + "id": "place_finished_goods", + "name": "FinishedGoods", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "visualizerCode": "", + "showAsInitialState": true, + "x": 2085, + "y": 435 + }, + { + "id": "place_scrap", + "name": "Scrap", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "visualizerCode": "", + "showAsInitialState": true, + "x": 2085, + "y": 285 + }, + { + "id": "place_delivered", + "name": "Delivered", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "visualizerCode": "", + "showAsInitialState": true, + "x": 3765, + "y": 540 + }, + { + "id": "place_lost", + "name": "Lost", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "visualizerCode": "", + "showAsInitialState": true, + "x": 3765, + "y": 390 + }, + { + "id": "place_cancelled", + "name": "Cancelled", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "visualizerCode": "", + "showAsInitialState": true, + "x": 3195, + "y": 615 + }, + { + "id": "place_inbound", + "name": "InboundShipments", + "colorId": "type_shipment", + "dynamicsEnabled": true, + "differentialEquationId": "dyn_shipment_eta", + "visualizerCode": "// Inbound lanes: each shipment is a truck whose horizontal position shows\n// how close it is to arriving (right edge = ETA 0). Blue = Supplier A,\n// purple = Supplier B (decoded from the token's `source` field).\nexport default Visualization(({ tokens, parameters }) => {\n const width = 520;\n const height = 170;\n const maxEta = Math.max(1, parameters.max_eta_visual || 10);\n return (\n \n \n Inbound lanes\n \n {tokens.slice(0, 18).map((t, i) => {\n const x = 60 + (1 - Math.min(maxEta, Math.max(0, t.eta)) / maxEta) * 410;\n const y = 58 + (i % 3) * 24;\n const color = t.source < 1.5 ? \"#2563eb\" : \"#7c3aed\";\n return (\n \n \n \n \n \n );\n })}\n {tokens.length} shipments moving toward dock\n \n );\n});", + "showAsInitialState": true, + "x": 405, + "y": 300 + }, + { + "id": "place_wip", + "name": "WorkInProcess", + "colorId": "type_batch", + "dynamicsEnabled": true, + "differentialEquationId": "dyn_batch_processing", + "visualizerCode": "// Factory WIP: one card per in-process batch. The fill bar shows processing\n// progress (toward completion) and the % label is current quality; the bar\n// turns green when quality is above the minimum and red when it has decayed\n// below it (i.e. the batch is heading for scrap).\nexport default Visualization(({ tokens, parameters }) => {\n const width = 520;\n const height = 170;\n const maxTime = Math.max(1, parameters.production_time_mean || 3);\n return (\n \n \n Factory WIP\n {tokens.slice(0, 14).map((b, i) => {\n const progress = 1 - Math.min(maxTime, Math.max(0, b.processing_left)) / maxTime;\n const x = 24 + (i % 7) * 68;\n const y = 46 + Math.floor(i / 7) * 52;\n const fill = b.quality >= parameters.min_quality ? \"#22c55e\" : \"#ef4444\";\n return (\n \n \n \n {Math.round(b.quality * 100)}%\n \n );\n })}\n {tokens.length} batches being processed\n \n );\n});", + "showAsInitialState": true, + "x": 1515, + "y": 300 + }, + { + "id": "place_orders", + "name": "OpenOrders", + "colorId": "type_order", + "dynamicsEnabled": true, + "differentialEquationId": "dyn_order_age", + "visualizerCode": "// Customer queue: one dot per open order. Red = past its promised lead time\n// (late), orange = on-time VIP, pale orange = on-time standard. The bottom\n// bar grows with the queue length.\nexport default Visualization(({ tokens, parameters }) => {\n const width = 520;\n const height = 170;\n const urgent = tokens.filter(o => o.age > o.promised_lead_time).length;\n return (\n \n \n Customer queue\n {tokens.slice(0, 40).map((o, i) => {\n const x = 24 + (i % 20) * 23;\n const y = 48 + Math.floor(i / 20) * 34;\n const late = o.age > o.promised_lead_time;\n const fill = late ? \"#dc2626\" : (o.priority > 0.5 ? \"#f97316\" : \"#fdba74\");\n return ;\n })}\n {tokens.length} open orders · {urgent} late\n \n \n );\n});", + "showAsInitialState": true, + "x": 2085, + "y": 585 + }, + { + "id": "place_backorders", + "name": "Backorders", + "colorId": "type_order", + "dynamicsEnabled": true, + "differentialEquationId": "dyn_order_age", + "visualizerCode": "// Backorder heat: one bar per backorder, taller the longer it has waited.\n// Dark red bars are VIP orders. Tall bars signal customers about to cancel.\nexport default Visualization(({ tokens, parameters }) => {\n const width = 520;\n const height = 150;\n const maxAge = tokens.reduce((m, o) => Math.max(m, o.age), 0);\n return (\n \n \n Backorder heat\n {tokens.slice(0, 30).map((o, i) => {\n const h = Math.min(90, 12 + o.age * 6);\n const x = 22 + i * 16;\n return 0.5 ? \"#7f1d1d\" : \"#ef4444\"} opacity=\"0.85\" />;\n })}\n {tokens.length} waiting · oldest {maxAge.toFixed(1)} days\n \n );\n});", + "showAsInitialState": true, + "x": 2640, + "y": 585 + }, + { + "id": "place_outbound", + "name": "OutboundShipments", + "colorId": "type_shipment", + "dynamicsEnabled": true, + "differentialEquationId": "dyn_shipment_eta", + "visualizerCode": "// Last-mile deliveries: outbound shipments (vans) move left-to-right as\n// their ETA counts down to 0, at which point they are delivered (or lost).\nexport default Visualization(({ tokens, parameters }) => {\n const width = 520;\n const height = 150;\n const maxEta = Math.max(1, parameters.outbound_lead_time * 2 || 3);\n return (\n \n \n Last-mile deliveries\n \n {tokens.slice(0, 20).map((t, i) => {\n const x = 55 + (1 - Math.min(maxEta, Math.max(0, t.eta)) / maxEta) * 415;\n const y = 60 + (i % 2) * 30;\n return (\n \n \n \n \n );\n })}\n {tokens.length} customer shipments in transit\n \n );\n});", + "showAsInitialState": true, + "x": 3195, + "y": 420 + }, + { + "id": "place_machine_up", + "name": "MachineUp", + "colorId": "type_machine", + "dynamicsEnabled": true, + "differentialEquationId": "dyn_machine_health", + "visualizerCode": "export default Visualization(({ tokens, parameters }) => {\n const m = tokens[0] || { health: 0, wear: 1 };\n const health = Math.max(0, Math.min(1, m.health));\n const wear = Math.max(0, Math.min(1, m.wear));\n return (\n \n \n Machine condition\n \n 0.5 ? \"#22c55e\" : \"#f59e0b\"} />\n health\n \n \n wear\n \n );\n});", + "showAsInitialState": true, + "x": 960, + "y": 735 + }, + { + "id": "place_machine_down", + "name": "MachineDown", + "colorId": "type_machine", + "dynamicsEnabled": false, + "differentialEquationId": null, + "visualizerCode": "", + "showAsInitialState": true, + "x": 405, + "y": 705 + } + ], + "transitions": [ + { + "id": "trans_order_supplier_a", + "name": "Order from reliable supplier A", + "inputArcs": [ + { + "placeId": "place_supplier_a_available", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place_supplier_a_available", + "weight": 1 + }, + { + "placeId": "place_inbound", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "// Supplier A places orders at a constant configured rate (only while\n// Supplier A is in the Available place, which gates this transition).\nexport default Lambda((input, parameters) => {\n return parameters.supplier_a_order_rate;\n});", + "transitionKernelCode": "// Create one inbound Shipment token from Supplier A.\nexport default TransitionKernel((input, parameters) => {\n // Lead time is sampled from a Gaussian whose spread scales with the mean\n // (a coefficient of variation), then clamped so the ETA is always positive.\n const mean = Math.max(0.1, parameters.supplier_a_lead_time);\n const sd = Math.max(0.01, mean * parameters.lead_time_cv);\n const eta = Distribution.Gaussian(mean, sd).map(v => Math.max(0.1, v));\n return {\n InboundShipments: [{\n eta,\n // risk_score in [0,1]; the multiplier makes A more or less reliable.\n risk_score: Distribution.Uniform(0, 1).map(v => Math.min(1, v * parameters.supplier_a_risk_multiplier)),\n source: 1, // 1 = Supplier A (used by the inbound visualizer for colour).\n cost: parameters.supplier_a_cost,\n }],\n };\n});", + "x": 120, + "y": 180 + }, + { + "id": "trans_order_supplier_b", + "name": "Order from low-cost supplier B", + "inputArcs": [ + { + "placeId": "place_supplier_b_available", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place_supplier_b_available", + "weight": 1 + }, + { + "placeId": "place_inbound", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "// Low-cost Supplier B orders at its own rate while it is Available.\nexport default Lambda((input, parameters) => {\n return parameters.supplier_b_order_rate;\n});", + "transitionKernelCode": "// Same shipment-creation logic as Supplier A, but with B's lead time,\n// risk multiplier, and cost — typically cheaper but slower and riskier.\nexport default TransitionKernel((input, parameters) => {\n const mean = Math.max(0.1, parameters.supplier_b_lead_time);\n const sd = Math.max(0.01, mean * parameters.lead_time_cv);\n const eta = Distribution.Gaussian(mean, sd).map(v => Math.max(0.1, v));\n return {\n InboundShipments: [{\n eta,\n risk_score: Distribution.Uniform(0, 1).map(v => Math.min(1, v * parameters.supplier_b_risk_multiplier)),\n source: 2, // 2 = Supplier B.\n cost: parameters.supplier_b_cost,\n }],\n };\n});", + "x": 120, + "y": 525 + }, + { + "id": "trans_supplier_a_disrupts", + "name": "Supplier A disruption", + "inputArcs": [ + { + "placeId": "place_supplier_a_available", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place_supplier_a_down", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "// Supplier A randomly becomes disrupted: this moves the Supplier A token\n// from Available to Down, which then stops new Supplier A orders until it\n// recovers. The rate is constant (a memoryless time-to-failure).\nexport default Lambda((input, parameters) => {\n return parameters.supplier_a_disruption_rate;\n});", + "transitionKernelCode": "// This transition only routes/marks tokens — the destination place needs\n// no computed attributes — so the kernel returns no token data.\nexport default TransitionKernel(() => ({}));", + "x": 675, + "y": 150 + }, + { + "id": "trans_supplier_a_recovers", + "name": "Supplier A recovers", + "inputArcs": [ + { + "placeId": "place_supplier_a_down", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place_supplier_a_available", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "// A disrupted Supplier A recovers at this rate, returning to Available.\nexport default Lambda((input, parameters) => {\n return parameters.supplier_a_repair_rate;\n});", + "transitionKernelCode": "// This transition only routes/marks tokens — the destination place needs\n// no computed attributes — so the kernel returns no token data.\nexport default TransitionKernel(() => ({}));", + "x": 120, + "y": 90 + }, + { + "id": "trans_supplier_b_disrupts", + "name": "Supplier B disruption", + "inputArcs": [ + { + "placeId": "place_supplier_b_available", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place_supplier_b_down", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "// Supplier B disruption — same Available -> Down mechanism as Supplier A.\nexport default Lambda((input, parameters) => {\n return parameters.supplier_b_disruption_rate;\n});", + "transitionKernelCode": "// This transition only routes/marks tokens — the destination place needs\n// no computed attributes — so the kernel returns no token data.\nexport default TransitionKernel(() => ({}));", + "x": 675, + "y": 585 + }, + { + "id": "trans_supplier_b_recovers", + "name": "Supplier B recovers", + "inputArcs": [ + { + "placeId": "place_supplier_b_down", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place_supplier_b_available", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "// A disrupted Supplier B recovers at this rate, returning to Available.\nexport default Lambda((input, parameters) => {\n return parameters.supplier_b_repair_rate;\n});", + "transitionKernelCode": "// This transition only routes/marks tokens — the destination place needs\n// no computed attributes — so the kernel returns no token data.\nexport default TransitionKernel(() => ({}));", + "x": 1245, + "y": 195 + }, + { + "id": "trans_inbound_good", + "name": "Inbound shipment received", + "inputArcs": [ + { + "placeId": "place_inbound", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place_raw_materials", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "// An inbound shipment is received cleanly once it has arrived (eta counted\n// down to 0) AND its risk score is below the damage threshold. Higher-risk\n// shipments instead fire the competing \"damaged\" transition below.\nexport default Lambda((input, parameters) => {\n const shipment = input.InboundShipments[0];\n return shipment.eta <= 0 && shipment.risk_score < parameters.inbound_damage_threshold;\n});", + "transitionKernelCode": "// This transition only routes/marks tokens — the destination place needs\n// no computed attributes — so the kernel returns no token data.\nexport default TransitionKernel(() => ({}));", + "x": 675, + "y": 285 + }, + { + "id": "trans_inbound_damaged", + "name": "Inbound shipment damaged", + "inputArcs": [ + { + "placeId": "place_inbound", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place_damaged_inbound", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "// The mutually-exclusive partner of \"received\": an arrived shipment whose\n// risk score is at or above the threshold is scrapped as damaged inbound.\nexport default Lambda((input, parameters) => {\n const shipment = input.InboundShipments[0];\n return shipment.eta <= 0 && shipment.risk_score >= parameters.inbound_damage_threshold;\n});", + "transitionKernelCode": "// This transition only routes/marks tokens — the destination place needs\n// no computed attributes — so the kernel returns no token data.\nexport default TransitionKernel(() => ({}));", + "x": 675, + "y": 435 + }, + { + "id": "trans_start_production", + "name": "Start production batch", + "inputArcs": [ + { + "placeId": "place_raw_materials", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place_machine_up", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place_machine_up", + "weight": 1 + }, + { + "placeId": "place_wip", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "// Production only starts when the machine is healthy enough; below the\n// minimum-health threshold the rate is 0, so a worn machine effectively\n// stalls the line until it is repaired or maintained.\nexport default Lambda((input, parameters) => {\n const machine = input.MachineUp[0];\n return machine.health >= parameters.min_machine_health ? parameters.production_rate : 0;\n});", + "transitionKernelCode": "// Consume raw material + the machine, and emit a new WorkInProcess batch\n// while returning the (now slightly more worn) machine to MachineUp.\nexport default TransitionKernel((input, parameters) => {\n const machine = input.MachineUp[0];\n // Remaining processing time for the batch, sampled and floored at 0.25.\n const processing = Distribution.Gaussian(parameters.production_time_mean, parameters.production_time_sd).map(v => Math.max(0.25, v));\n // Each batch adds wear and removes a little health from the machine.\n const wear = Math.min(1, machine.wear + parameters.wear_per_pallet);\n const health = Math.max(0, machine.health - parameters.wear_per_pallet * 0.5);\n return {\n MachineUp: [{ health, wear }],\n WorkInProcess: [{\n processing_left: processing,\n // Starting quality degrades as the machine wears; clamped to [0, 1].\n quality: Distribution.Gaussian(0.96 - wear * parameters.wear_quality_penalty, 0.06).map(q => Math.max(0, Math.min(1, q))),\n source_mix: 0.5,\n cost: parameters.production_unit_cost + wear * parameters.wear_cost_penalty,\n }],\n };\n});", + "x": 1245, + "y": 300 + }, + { + "id": "trans_finish_good_batch", + "name": "Batch passes quality", + "inputArcs": [ + { + "placeId": "place_wip", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place_finished_goods", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "// A batch becomes a finished good once processing is complete AND its\n// (wear- and decay-affected) quality still meets the minimum standard.\nexport default Lambda((input, parameters) => {\n const batch = input.WorkInProcess[0];\n return batch.processing_left <= 0 && batch.quality >= parameters.min_quality;\n});", + "transitionKernelCode": "// This transition only routes/marks tokens — the destination place needs\n// no computed attributes — so the kernel returns no token data.\nexport default TransitionKernel(() => ({}));", + "x": 1800, + "y": 435 + }, + { + "id": "trans_scrap_bad_batch", + "name": "Batch fails quality", + "inputArcs": [ + { + "placeId": "place_wip", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place_scrap", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "// The competing outcome to \"passes quality\": a finished batch below the\n// minimum quality is sent to Scrap instead of FinishedGoods.\nexport default Lambda((input, parameters) => {\n const batch = input.WorkInProcess[0];\n return batch.processing_left <= 0 && batch.quality < parameters.min_quality;\n});", + "transitionKernelCode": "// This transition only routes/marks tokens — the destination place needs\n// no computed attributes — so the kernel returns no token data.\nexport default TransitionKernel(() => ({}));", + "x": 1800, + "y": 285 + }, + { + "id": "trans_machine_breakdown_random", + "name": "Machine breakdown", + "inputArcs": [ + { + "placeId": "place_machine_up", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place_machine_down", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "// Breakdown hazard rises with wear and with poor health: a worn or unhealthy\n// machine fails far more often than the base rate. This is what makes\n// preventive maintenance worthwhile.\nexport default Lambda((input, parameters) => {\n const machine = input.MachineUp[0];\n return parameters.machine_breakdown_rate * (1 + machine.wear * 3 + Math.max(0, 0.5 - machine.health));\n});", + "transitionKernelCode": "// Move the machine to the Down place, knocking off a chunk of health.\nexport default TransitionKernel((input) => {\n const machine = input.MachineUp[0];\n return { MachineDown: [{ health: Math.max(0, machine.health - 0.15), wear: machine.wear }] };\n});", + "x": 120, + "y": 720 + }, + { + "id": "trans_machine_repair", + "name": "Repair machine", + "inputArcs": [ + { + "placeId": "place_machine_down", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place_machine_up", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "// A broken machine is repaired at a constant rate (mean time-to-repair).\nexport default Lambda((input, parameters) => {\n return parameters.machine_repair_rate;\n});", + "transitionKernelCode": "// Repair restores most health and removes most wear, returning the machine\n// to the Up place — a large but incomplete reset compared with maintenance.\nexport default TransitionKernel((input) => {\n const machine = input.MachineDown[0];\n return { MachineUp: [{ health: Math.min(1, machine.health + 0.55), wear: Math.max(0, machine.wear * 0.45) }] };\n});", + "x": 675, + "y": 705 + }, + { + "id": "trans_preventive_maintenance", + "name": "Preventive maintenance", + "inputArcs": [ + { + "placeId": "place_machine_up", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place_machine_up", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "// Preventive maintenance happens on the running machine (Up -> Up). It is\n// performed much more eagerly once wear passes 25%, and only occasionally\n// otherwise, so the strategy is \"service it before it breaks\".\nexport default Lambda((input, parameters) => {\n const machine = input.MachineUp[0];\n return machine.wear > 0.25 ? parameters.maintenance_rate * (1 + machine.wear) : parameters.maintenance_rate * 0.2;\n});", + "transitionKernelCode": "// Maintenance boosts health and shaves off some wear without taking the\n// machine offline — cheaper and less disruptive than a full repair.\nexport default TransitionKernel((input, parameters) => {\n const machine = input.MachineUp[0];\n return { MachineUp: [{ health: Math.min(1, machine.health + parameters.maintenance_health_boost), wear: Math.max(0, machine.wear * 0.65) }] };\n});", + "x": 1245, + "y": 765 + }, + { + "id": "trans_customer_demand", + "name": "Customer demand arrives", + "inputArcs": [], + "outputArcs": [ + { + "placeId": "place_orders", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "// Customers arrive at a constant rate. This transition has no input arcs,\n// so it acts as an external (Poisson) arrival source for new orders.\nexport default Lambda((input, parameters) => {\n return parameters.demand_rate;\n});", + "transitionKernelCode": "// Create one new open order, classified as VIP or standard.\nexport default TransitionKernel((input, parameters) => {\n // Draw ONE uniform sample and reuse it via .map() so that priority and the\n // promised lead time stay consistent (a VIP both has priority 1 AND the\n // shorter promise). Sampling twice could give contradictory results.\n const priorityDraw = Distribution.Uniform(0, 1);\n return {\n OpenOrders: [{\n age: 0,\n priority: priorityDraw.map(v => v < parameters.vip_fraction ? 1 : 0),\n promised_lead_time: priorityDraw.map(v => v < parameters.vip_fraction ? 1.5 : 3.5),\n }],\n };\n});", + "x": 1800, + "y": 585 + }, + { + "id": "trans_fulfill_open_order", + "name": "Fulfill open order from stock", + "inputArcs": [ + { + "placeId": "place_orders", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place_finished_goods", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place_outbound", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "// Fulfil an order directly from stock. This transition needs both an open\n// order AND a finished good (two standard input arcs), so it only fires when\n// inventory is available. VIP orders are served faster via a rate boost.\nexport default Lambda((input, parameters) => {\n const order = input.OpenOrders[0];\n const priorityBoost = order.priority > 0.5 ? 1.6 : 1;\n return parameters.fulfillment_rate * priorityBoost;\n});", + "transitionKernelCode": "// Turn the fulfilled order into an outbound shipment.\nexport default TransitionKernel((input, parameters) => {\n const order = input.OpenOrders[0];\n const eta = Distribution.Gaussian(parameters.outbound_lead_time, parameters.outbound_lead_time * 0.25).map(v => Math.max(0.05, v));\n return {\n OutboundShipments: [{\n eta,\n risk_score: Distribution.Uniform(0, 1),\n source: order.priority > 0.5 ? 9 : 8, // 8/9 mark standard/VIP from-stock shipments.\n cost: parameters.outbound_cost + (order.priority > 0.5 ? 3 : 0),\n }],\n };\n});", + "x": 2355, + "y": 450 + }, + { + "id": "trans_convert_to_backorder", + "name": "Convert aging order to backorder", + "inputArcs": [ + { + "placeId": "place_orders", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place_finished_goods", + "weight": 1, + "type": "inhibitor" + } + ], + "outputArcs": [ + { + "placeId": "place_backorders", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "// An aging order with no stock to fill it becomes a backorder. The inhibitor\n// arc from FinishedGoods means this can ONLY fire when stock is empty, and we\n// also wait until the order has exceeded its promised lead time (rate 0 until\n// then). VIP orders are escalated to backorder sooner.\nexport default Lambda((input, parameters) => {\n const order = input.OpenOrders[0];\n if (order.age < order.promised_lead_time) return 0;\n return parameters.backorder_conversion_rate * (order.priority > 0.5 ? 1.8 : 1);\n});", + "transitionKernelCode": "// Carry the order's age, priority, and promise across to the Backorders place.\nexport default TransitionKernel((input) => {\n const order = input.OpenOrders[0];\n return { Backorders: [{ age: order.age, priority: order.priority, promised_lead_time: order.promised_lead_time }] };\n});", + "x": 2355, + "y": 585 + }, + { + "id": "trans_fulfill_backorder", + "name": "Fulfill backorder", + "inputArcs": [ + { + "placeId": "place_backorders", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place_finished_goods", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place_outbound", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "// Once stock is replenished, backorders are cleared (again favouring VIPs).\n// Requires both a backorder and a finished good, so it competes with new\n// open-order fulfilment for the same inventory.\nexport default Lambda((input, parameters) => {\n const order = input.Backorders[0];\n return parameters.backorder_fulfillment_rate * (order.priority > 0.5 ? 1.7 : 1);\n});", + "transitionKernelCode": "// Ship the backordered item. Late backorders cost more (the age surcharge),\n// reflecting expediting and goodwill costs.\nexport default TransitionKernel((input, parameters) => {\n const order = input.Backorders[0];\n return {\n OutboundShipments: [{\n eta: Distribution.Gaussian(parameters.outbound_lead_time, parameters.outbound_lead_time * 0.35).map(v => Math.max(0.05, v)),\n risk_score: Distribution.Uniform(0, 1),\n source: order.priority > 0.5 ? 7 : 6, // 6/7 mark standard/VIP backorder shipments.\n cost: parameters.outbound_cost + 2 + order.age * 0.1,\n }],\n };\n});", + "x": 2925, + "y": 390 + }, + { + "id": "trans_cancel_backorder", + "name": "Customer cancels backorder", + "inputArcs": [ + { + "placeId": "place_backorders", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place_cancelled", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "// Waiting customers may give up. The cancellation hazard grows with the\n// order's age, so long-unfilled backorders are increasingly likely to be\n// lost. VIPs are modelled as slightly more patient (lower multiplier).\nexport default Lambda((input, parameters) => {\n const order = input.Backorders[0];\n const vipPatience = order.priority > 0.5 ? 0.6 : 1;\n return vipPatience * (parameters.cancel_base_rate + order.age * parameters.cancel_age_factor);\n});", + "transitionKernelCode": "// This transition only routes/marks tokens — the destination place needs\n// no computed attributes — so the kernel returns no token data.\nexport default TransitionKernel(() => ({}));", + "x": 2925, + "y": 615 + }, + { + "id": "trans_outbound_delivered", + "name": "Outbound delivered", + "inputArcs": [ + { + "placeId": "place_outbound", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place_delivered", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "// Last-mile delivery succeeds when the outbound shipment has arrived and its\n// risk score is below the loss threshold — the demand-side mirror of the\n// inbound received/damaged split.\nexport default Lambda((input, parameters) => {\n const shipment = input.OutboundShipments[0];\n return shipment.eta <= 0 && shipment.risk_score < parameters.outbound_loss_threshold;\n});", + "transitionKernelCode": "// This transition only routes/marks tokens — the destination place needs\n// no computed attributes — so the kernel returns no token data.\nexport default TransitionKernel(() => ({}));", + "x": 3480, + "y": 540 + }, + { + "id": "trans_outbound_lost", + "name": "Outbound lost or damaged", + "inputArcs": [ + { + "placeId": "place_outbound", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place_lost", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "// The competing outcome to delivery: an arrived shipment at or above the\n// loss threshold is lost in transit and never reaches the customer.\nexport default Lambda((input, parameters) => {\n const shipment = input.OutboundShipments[0];\n return shipment.eta <= 0 && shipment.risk_score >= parameters.outbound_loss_threshold;\n});", + "transitionKernelCode": "// This transition only routes/marks tokens — the destination place needs\n// no computed attributes — so the kernel returns no token data.\nexport default TransitionKernel(() => ({}));", + "x": 3480, + "y": 390 + } + ], + "types": [ + { + "id": "type_shipment", + "name": "Shipment", + "iconSlug": "truck", + "displayColor": "#2563eb", + "elements": [ + { + "elementId": "ship_eta", + "name": "eta", + "type": "real" + }, + { + "elementId": "ship_risk", + "name": "risk_score", + "type": "real" + }, + { + "elementId": "ship_source", + "name": "source", + "type": "integer" + }, + { + "elementId": "ship_cost", + "name": "cost", + "type": "real" + } + ] + }, + { + "id": "type_order", + "name": "Customer order", + "iconSlug": "clipboard", + "displayColor": "#f97316", + "elements": [ + { + "elementId": "order_age", + "name": "age", + "type": "real" + }, + { + "elementId": "order_priority", + "name": "priority", + "type": "real" + }, + { + "elementId": "order_promise", + "name": "promised_lead_time", + "type": "real" + } + ] + }, + { + "id": "type_batch", + "name": "Production batch", + "iconSlug": "box", + "displayColor": "#16a34a", + "elements": [ + { + "elementId": "batch_processing_left", + "name": "processing_left", + "type": "real" + }, + { + "elementId": "batch_quality", + "name": "quality", + "type": "real" + }, + { + "elementId": "batch_source_mix", + "name": "source_mix", + "type": "real" + }, + { + "elementId": "batch_cost", + "name": "cost", + "type": "real" + } + ] + }, + { + "id": "type_machine", + "name": "Factory machine", + "iconSlug": "cog", + "displayColor": "#64748b", + "elements": [ + { + "elementId": "machine_health", + "name": "health", + "type": "real" + }, + { + "elementId": "machine_wear", + "name": "wear", + "type": "real" + } + ] + } + ], + "differentialEquations": [ + { + "id": "dyn_shipment_eta", + "name": "Shipment ETA countdown", + "colorId": "type_shipment", + "code": "// Counts each shipment's ETA down by 1 per simulated unit of time. Dynamics\n// return DERIVATIVES, so eta: -1 means \"decrease eta at rate 1\"; once it\n// reaches 0 the derivative becomes 0 (it holds, ready for arrival predicates).\n// All other attributes are constant during transit (derivative 0).\nexport default Dynamics((tokens, parameters) => {\n return tokens.map(({ eta }) => ({\n eta: eta > 0 ? -1 : 0,\n risk_score: 0,\n cost: 0,\n }));\n});" + }, + { + "id": "dyn_order_age", + "name": "Order aging", + "colorId": "type_order", + "code": "// Every waiting order ages at a constant rate (age derivative = 1), driving\n// the backorder-conversion and cancellation hazards that depend on age.\nexport default Dynamics((tokens, parameters) => {\n return tokens.map(() => ({\n age: 1,\n priority: 0,\n promised_lead_time: 0,\n }));\n});" + }, + { + "id": "dyn_batch_processing", + "name": "Batch processing countdown", + "colorId": "type_batch", + "code": "// While a batch is in process, processing_left counts down toward 0 and its\n// quality slowly decays. So a batch that sits in WIP too long can drift below\n// the minimum quality and end up scrapped rather than finished.\nexport default Dynamics((tokens, parameters) => {\n return tokens.map(({ processing_left, quality, source_mix, cost }) => ({\n processing_left: processing_left > 0 ? -parameters.production_progress_rate : 0,\n // Decay stops at 0 so quality stays within its implied [0, 1] range\n // (a batch is scrapped once it falls below the minimum quality anyway).\n quality: quality > 0 ? -parameters.quality_decay_rate : 0,\n source_mix: 0,\n cost: 0,\n }));\n});" + }, + { + "id": "dyn_machine_health", + "name": "Machine health drift", + "colorId": "type_machine", + "code": "// The running machine continuously loses a little health and gains a little\n// wear even between batches. Combined with the per-batch wear in production,\n// this is the slow degradation that maintenance and repair counteract.\nexport default Dynamics((tokens, parameters) => {\n return tokens.map(({ health, wear }) => ({\n health: health > 0 ? -parameters.machine_health_decay : 0,\n // Wear accrues only up to 1 so it stays within its implied [0, 1] range.\n wear: wear < 1 ? parameters.machine_wear_rate : 0,\n }));\n});" + } + ], + "parameters": [ + { + "id": "param_demand_rate", + "name": "Customer Demand Rate", + "variableName": "demand_rate", + "type": "real", + "defaultValue": "0.55" + }, + { + "id": "param_vip_fraction", + "name": "VIP Order Fraction", + "variableName": "vip_fraction", + "type": "real", + "defaultValue": "0.12" + }, + { + "id": "param_supplier_a_order_rate", + "name": "Supplier A Order Rate", + "variableName": "supplier_a_order_rate", + "type": "real", + "defaultValue": "0.42" + }, + { + "id": "param_supplier_b_order_rate", + "name": "Supplier B Order Rate", + "variableName": "supplier_b_order_rate", + "type": "real", + "defaultValue": "0.28" + }, + { + "id": "param_supplier_a_lead_time", + "name": "Supplier A Mean Lead Time", + "variableName": "supplier_a_lead_time", + "type": "real", + "defaultValue": "4" + }, + { + "id": "param_supplier_b_lead_time", + "name": "Supplier B Mean Lead Time", + "variableName": "supplier_b_lead_time", + "type": "real", + "defaultValue": "7" + }, + { + "id": "param_lead_time_cv", + "name": "Inbound Lead Time CV", + "variableName": "lead_time_cv", + "type": "real", + "defaultValue": "0.35" + }, + { + "id": "param_supplier_a_disruption_rate", + "name": "Supplier A Disruption Rate", + "variableName": "supplier_a_disruption_rate", + "type": "real", + "defaultValue": "0.025" + }, + { + "id": "param_supplier_a_repair_rate", + "name": "Supplier A Recovery Rate", + "variableName": "supplier_a_repair_rate", + "type": "real", + "defaultValue": "0.18" + }, + { + "id": "param_supplier_b_disruption_rate", + "name": "Supplier B Disruption Rate", + "variableName": "supplier_b_disruption_rate", + "type": "real", + "defaultValue": "0.015" + }, + { + "id": "param_supplier_b_repair_rate", + "name": "Supplier B Recovery Rate", + "variableName": "supplier_b_repair_rate", + "type": "real", + "defaultValue": "0.14" + }, + { + "id": "param_supplier_a_cost", + "name": "Supplier A Unit Cost", + "variableName": "supplier_a_cost", + "type": "real", + "defaultValue": "16" + }, + { + "id": "param_supplier_b_cost", + "name": "Supplier B Unit Cost", + "variableName": "supplier_b_cost", + "type": "real", + "defaultValue": "12" + }, + { + "id": "param_supplier_a_risk_multiplier", + "name": "Supplier A Risk Multiplier", + "variableName": "supplier_a_risk_multiplier", + "type": "real", + "defaultValue": "0.75" + }, + { + "id": "param_supplier_b_risk_multiplier", + "name": "Supplier B Risk Multiplier", + "variableName": "supplier_b_risk_multiplier", + "type": "real", + "defaultValue": "1.15" + }, + { + "id": "param_inbound_damage_threshold", + "name": "Inbound Damage Threshold", + "variableName": "inbound_damage_threshold", + "type": "real", + "defaultValue": "0.88" + }, + { + "id": "param_production_rate", + "name": "Production Start Rate", + "variableName": "production_rate", + "type": "real", + "defaultValue": "0.9" + }, + { + "id": "param_production_time_mean", + "name": "Mean Production Time", + "variableName": "production_time_mean", + "type": "real", + "defaultValue": "2.5" + }, + { + "id": "param_production_time_sd", + "name": "Production Time Std Dev", + "variableName": "production_time_sd", + "type": "real", + "defaultValue": "0.65" + }, + { + "id": "param_production_progress_rate", + "name": "Production Progress Rate", + "variableName": "production_progress_rate", + "type": "real", + "defaultValue": "1" + }, + { + "id": "param_quality_decay_rate", + "name": "WIP Quality Decay Rate", + "variableName": "quality_decay_rate", + "type": "real", + "defaultValue": "0.01" + }, + { + "id": "param_min_quality", + "name": "Minimum Acceptable Quality", + "variableName": "min_quality", + "type": "real", + "defaultValue": "0.72" + }, + { + "id": "param_production_unit_cost", + "name": "Production Unit Cost", + "variableName": "production_unit_cost", + "type": "real", + "defaultValue": "8" + }, + { + "id": "param_wear_quality_penalty", + "name": "Wear Quality Penalty", + "variableName": "wear_quality_penalty", + "type": "real", + "defaultValue": "0.18" + }, + { + "id": "param_wear_per_pallet", + "name": "Wear Per Pallet", + "variableName": "wear_per_pallet", + "type": "real", + "defaultValue": "0.012" + }, + { + "id": "param_wear_cost_penalty", + "name": "Wear Cost Penalty", + "variableName": "wear_cost_penalty", + "type": "real", + "defaultValue": "4" + }, + { + "id": "param_min_machine_health", + "name": "Minimum Machine Health", + "variableName": "min_machine_health", + "type": "real", + "defaultValue": "0.18" + }, + { + "id": "param_machine_breakdown_rate", + "name": "Machine Breakdown Rate", + "variableName": "machine_breakdown_rate", + "type": "real", + "defaultValue": "0.012" + }, + { + "id": "param_machine_repair_rate", + "name": "Machine Repair Rate", + "variableName": "machine_repair_rate", + "type": "real", + "defaultValue": "0.22" + }, + { + "id": "param_machine_health_decay", + "name": "Machine Health Decay", + "variableName": "machine_health_decay", + "type": "real", + "defaultValue": "0.006" + }, + { + "id": "param_machine_wear_rate", + "name": "Machine Wear Drift", + "variableName": "machine_wear_rate", + "type": "real", + "defaultValue": "0.002" + }, + { + "id": "param_fulfillment_rate", + "name": "Same-Day Fulfillment Rate", + "variableName": "fulfillment_rate", + "type": "real", + "defaultValue": "1.4" + }, + { + "id": "param_backorder_conversion_rate", + "name": "Backorder Conversion Rate", + "variableName": "backorder_conversion_rate", + "type": "real", + "defaultValue": "1.0" + }, + { + "id": "param_backorder_fulfillment_rate", + "name": "Backorder Fulfillment Rate", + "variableName": "backorder_fulfillment_rate", + "type": "real", + "defaultValue": "0.75" + }, + { + "id": "param_cancel_base_rate", + "name": "Base Cancellation Rate", + "variableName": "cancel_base_rate", + "type": "real", + "defaultValue": "0.015" + }, + { + "id": "param_cancel_age_factor", + "name": "Cancellation Age Factor", + "variableName": "cancel_age_factor", + "type": "real", + "defaultValue": "0.006" + }, + { + "id": "param_outbound_lead_time", + "name": "Outbound Mean Lead Time", + "variableName": "outbound_lead_time", + "type": "real", + "defaultValue": "1.2" + }, + { + "id": "param_outbound_loss_threshold", + "name": "Outbound Loss Threshold", + "variableName": "outbound_loss_threshold", + "type": "real", + "defaultValue": "0.96" + }, + { + "id": "param_outbound_cost", + "name": "Outbound Shipment Cost", + "variableName": "outbound_cost", + "type": "real", + "defaultValue": "5" + }, + { + "id": "param_max_eta_visual", + "name": "Visualizer Max ETA", + "variableName": "max_eta_visual", + "type": "real", + "defaultValue": "10" + }, + { + "id": "param_maintenance_rate", + "name": "Preventive Maintenance Rate", + "variableName": "maintenance_rate", + "type": "real", + "defaultValue": "0.05" + }, + { + "id": "param_maintenance_health_boost", + "name": "Maintenance Health Boost", + "variableName": "maintenance_health_boost", + "type": "real", + "defaultValue": "0.28" + }, + { + "id": "param_initial_raw_materials", + "name": "Initial Raw Materials", + "variableName": "initial_raw_materials", + "type": "integer", + "defaultValue": "8" + }, + { + "id": "param_initial_finished_goods", + "name": "Initial Finished Goods", + "variableName": "initial_finished_goods", + "type": "integer", + "defaultValue": "12" + } + ], + "metrics": [ + { + "id": "metric_service_level", + "name": "Service level", + "description": "Fraction of completed demand outcomes that reached the customer instead of being lost or cancelled.", + "code": "// Of all orders that reached a terminal outcome, the share that were\n// delivered (vs. lost in transit or cancelled while waiting). Returns 1\n// before any outcome exists to avoid dividing by zero.\nconst delivered = state.places.Delivered.count;\nconst failed = state.places.Lost.count + state.places.Cancelled.count;\nconst total = delivered + failed;\nreturn total === 0 ? 1 : delivered / total;" + }, + { + "id": "metric_customer_pressure", + "name": "Customer pressure", + "description": "Total orders currently waiting either as open orders or backorders.", + "code": "return state.places.OpenOrders.count + state.places.Backorders.count;" + }, + { + "id": "metric_stock_position", + "name": "Stock position", + "description": "Finished goods inventory minus customer backorders.", + "code": "return state.places.FinishedGoods.count - state.places.Backorders.count;" + }, + { + "id": "metric_inbound_pipeline", + "name": "Inbound pipeline", + "description": "Number of raw-material shipments currently in transit from both suppliers.", + "code": "return state.places.InboundShipments.count;" + }, + { + "id": "metric_average_inbound_risk", + "name": "Average inbound risk", + "description": "Mean risk score of shipments currently in the inbound pipeline.", + "code": "const shipments = state.places.InboundShipments.tokens;\nif (shipments.length === 0) return 0;\nreturn shipments.reduce((sum, s) => sum + s.risk_score, 0) / shipments.length;" + }, + { + "id": "metric_factory_available", + "name": "Factory available", + "description": "1 when the production machine is up, 0 when it is down.", + "code": "return state.places.MachineUp.count > 0 ? 1 : 0;" + }, + { + "id": "metric_scrap_rate", + "name": "Scrap fraction", + "description": "Share of completed production batches that failed quality inspection.", + "code": "// Scrapped batches as a share of all completed batches. \"Good\" counts every\n// batch that passed quality, wherever it ended up downstream (in stock, in\n// transit, delivered, or even lost) — so this measures production quality,\n// not delivery success.\nconst scrap = state.places.Scrap.count;\nconst good = state.places.FinishedGoods.count + state.places.OutboundShipments.count + state.places.Delivered.count + state.places.Lost.count;\nconst total = scrap + good;\nreturn total === 0 ? 0 : scrap / total;" + }, + { + "id": "metric_supplier_outages", + "name": "Suppliers down", + "description": "How many of the two suppliers are currently disrupted.", + "code": "return state.places.SupplierADown.count + state.places.SupplierBDown.count;" + }, + { + "id": "metric_average_order_age", + "name": "Average waiting order age", + "description": "Average age in days of all unfulfilled customer orders.", + "code": "const orders = state.places.OpenOrders.tokens.concat(state.places.Backorders.tokens);\nif (orders.length === 0) return 0;\nreturn orders.reduce((sum, o) => sum + o.age, 0) / orders.length;" + } + ], + "scenarios": [ + { + "id": "scenario_balanced_dual_source", + "name": "Balanced dual source", + "description": "Baseline: moderate demand, split sourcing between reliable Supplier A and lower-cost Supplier B, normal factory reliability.", + "scenarioParameters": [ + { + "type": "real", + "identifier": "demand_multiplier", + "default": 1 + }, + { + "type": "ratio", + "identifier": "a_share_bias", + "default": 1 + }, + { + "type": "integer", + "identifier": "initial_raw_materials", + "default": 8 + }, + { + "type": "integer", + "identifier": "initial_finished_goods", + "default": 12 + } + ], + "parameterOverrides": { + "param_demand_rate": "parameters.demand_rate * scenario.demand_multiplier", + "param_supplier_a_order_rate": "parameters.supplier_a_order_rate * scenario.a_share_bias", + "param_supplier_b_order_rate": "parameters.supplier_b_order_rate * (2 - scenario.a_share_bias)", + "param_initial_raw_materials": "scenario.initial_raw_materials", + "param_initial_finished_goods": "scenario.initial_finished_goods" + }, + "initialState": { + "type": "per_place", + "content": { + "place_supplier_a_available": "1", + "place_supplier_a_down": "0", + "place_supplier_b_available": "1", + "place_supplier_b_down": "0", + "place_raw_materials": "scenario.initial_raw_materials", + "place_finished_goods": "scenario.initial_finished_goods", + "place_damaged_inbound": "0", + "place_scrap": "0", + "place_delivered": "0", + "place_lost": "0", + "place_cancelled": "0", + "place_machine_up": [[0.95, 0.08]], + "place_machine_down": [], + "place_inbound": [], + "place_wip": [], + "place_orders": [], + "place_backorders": [], + "place_outbound": [] + } + } + }, + { + "id": "scenario_demand_surge_port_congestion", + "name": "Demand surge and port congestion", + "description": "High demand coincides with slower, riskier inbound logistics. Useful for exploring backlog and service-level collapse.", + "scenarioParameters": [ + { + "type": "real", + "identifier": "demand_multiplier", + "default": 1.8 + }, + { + "type": "real", + "identifier": "lead_time_multiplier", + "default": 1.7 + }, + { + "type": "real", + "identifier": "damage_threshold", + "default": 0.78 + }, + { + "type": "integer", + "identifier": "initial_finished_goods", + "default": 18 + } + ], + "parameterOverrides": { + "param_demand_rate": "parameters.demand_rate * scenario.demand_multiplier", + "param_supplier_a_lead_time": "parameters.supplier_a_lead_time * scenario.lead_time_multiplier", + "param_supplier_b_lead_time": "parameters.supplier_b_lead_time * scenario.lead_time_multiplier", + "param_inbound_damage_threshold": "scenario.damage_threshold", + "param_initial_finished_goods": "scenario.initial_finished_goods" + }, + "initialState": { + "type": "per_place", + "content": { + "place_supplier_a_available": "1", + "place_supplier_a_down": "0", + "place_supplier_b_available": "1", + "place_supplier_b_down": "0", + "place_raw_materials": "6", + "place_finished_goods": "scenario.initial_finished_goods", + "place_damaged_inbound": "0", + "place_scrap": "0", + "place_delivered": "0", + "place_lost": "0", + "place_cancelled": "0", + "place_machine_up": [[0.9, 0.12]], + "place_machine_down": [], + "place_inbound": [], + "place_wip": [], + "place_orders": [], + "place_backorders": [], + "place_outbound": [] + } + } + }, + { + "id": "scenario_supplier_a_outage", + "name": "Reliable supplier outage", + "description": "Supplier A starts disrupted, forcing the network toward cheaper but slower Supplier B until A recovers.", + "scenarioParameters": [ + { + "type": "real", + "identifier": "a_recovery_rate", + "default": 0.08 + }, + { + "type": "real", + "identifier": "b_expedite_multiplier", + "default": 1.6 + }, + { + "type": "integer", + "identifier": "initial_finished_goods", + "default": 10 + } + ], + "parameterOverrides": { + "param_supplier_a_repair_rate": "scenario.a_recovery_rate", + "param_supplier_b_order_rate": "parameters.supplier_b_order_rate * scenario.b_expedite_multiplier", + "param_initial_finished_goods": "scenario.initial_finished_goods" + }, + "initialState": { + "type": "per_place", + "content": { + "place_supplier_a_available": "0", + "place_supplier_a_down": "1", + "place_supplier_b_available": "1", + "place_supplier_b_down": "0", + "place_raw_materials": "8", + "place_finished_goods": "scenario.initial_finished_goods", + "place_damaged_inbound": "0", + "place_scrap": "0", + "place_delivered": "0", + "place_lost": "0", + "place_cancelled": "0", + "place_machine_up": [[0.94, 0.1]], + "place_machine_down": [], + "place_inbound": [], + "place_wip": [], + "place_orders": [], + "place_backorders": [], + "place_outbound": [] + } + } + }, + { + "id": "scenario_low_cost_sourcing", + "name": "Low-cost sourcing strategy", + "description": "Procurement leans into Supplier B. Costs fall conceptually, but longer lead times and higher risk stress service performance.", + "scenarioParameters": [ + { + "type": "real", + "identifier": "a_order_multiplier", + "default": 0.35 + }, + { + "type": "real", + "identifier": "b_order_multiplier", + "default": 1.9 + }, + { + "type": "real", + "identifier": "b_risk_multiplier", + "default": 1.45 + }, + { + "type": "integer", + "identifier": "initial_raw_materials", + "default": 12 + } + ], + "parameterOverrides": { + "param_supplier_a_order_rate": "parameters.supplier_a_order_rate * scenario.a_order_multiplier", + "param_supplier_b_order_rate": "parameters.supplier_b_order_rate * scenario.b_order_multiplier", + "param_supplier_b_risk_multiplier": "scenario.b_risk_multiplier", + "param_initial_raw_materials": "scenario.initial_raw_materials" + }, + "initialState": { + "type": "per_place", + "content": { + "place_supplier_a_available": "1", + "place_supplier_a_down": "0", + "place_supplier_b_available": "1", + "place_supplier_b_down": "0", + "place_raw_materials": "scenario.initial_raw_materials", + "place_finished_goods": "8", + "place_damaged_inbound": "0", + "place_scrap": "0", + "place_delivered": "0", + "place_lost": "0", + "place_cancelled": "0", + "place_machine_up": [[0.96, 0.06]], + "place_machine_down": [], + "place_inbound": [], + "place_wip": [], + "place_orders": [], + "place_backorders": [], + "place_outbound": [] + } + } + }, + { + "id": "scenario_resilience_investment", + "name": "Resilience investment", + "description": "Higher safety stock, more preventive maintenance, and faster repair/recovery rates reduce tail-risk events at the cost of extra buffer.", + "scenarioParameters": [ + { + "type": "integer", + "identifier": "initial_raw_materials", + "default": 18 + }, + { + "type": "integer", + "identifier": "initial_finished_goods", + "default": 24 + }, + { + "type": "real", + "identifier": "maintenance_multiplier", + "default": 2.2 + }, + { + "type": "real", + "identifier": "supplier_recovery_multiplier", + "default": 1.8 + } + ], + "parameterOverrides": { + "param_maintenance_rate": "parameters.maintenance_rate * scenario.maintenance_multiplier", + "param_supplier_a_repair_rate": "parameters.supplier_a_repair_rate * scenario.supplier_recovery_multiplier", + "param_supplier_b_repair_rate": "parameters.supplier_b_repair_rate * scenario.supplier_recovery_multiplier", + "param_initial_raw_materials": "scenario.initial_raw_materials", + "param_initial_finished_goods": "scenario.initial_finished_goods" + }, + "initialState": { + "type": "per_place", + "content": { + "place_supplier_a_available": "1", + "place_supplier_a_down": "0", + "place_supplier_b_available": "1", + "place_supplier_b_down": "0", + "place_raw_materials": "scenario.initial_raw_materials", + "place_finished_goods": "scenario.initial_finished_goods", + "place_damaged_inbound": "0", + "place_scrap": "0", + "place_delivered": "0", + "place_lost": "0", + "place_cancelled": "0", + "place_machine_up": [[1, 0.03]], + "place_machine_down": [], + "place_inbound": [], + "place_wip": [], + "place_orders": [], + "place_backorders": [], + "place_outbound": [] + } + } + } + ] +} diff --git a/libs/@hashintel/petrinaut-cli/package.json b/libs/@hashintel/petrinaut-cli/package.json new file mode 100644 index 00000000000..0f636cb33f6 --- /dev/null +++ b/libs/@hashintel/petrinaut-cli/package.json @@ -0,0 +1,30 @@ +{ + "name": "@hashintel/petrinaut-cli", + "version": "0.0.0-private", + "private": true, + "description": "Internal CLI and local server for Petrinaut simulations", + "license": "(MIT OR Apache-2.0)", + "bin": { + "petrinaut": "./dist/cli.js", + "petrinaut_cli": "./dist/cli.js" + }, + "type": "module", + "scripts": { + "build": "vite build", + "fix:eslint": "oxlint --fix --type-aware --report-unused-disable-directives-severity=error .", + "lint:eslint": "oxlint --type-aware --report-unused-disable-directives-severity=error .", + "lint:tsc": "tsgo --noEmit", + "serve": "node ./dist/cli.js serve", + "serve:example": "node ./dist/cli.js serve --model ./examples/sir-model.json --host 127.0.0.1 --port 8765" + }, + "dependencies": { + "@hashintel/petrinaut-core": "workspace:*" + }, + "devDependencies": { + "@types/node": "22.18.13", + "@typescript/native-preview": "7.0.0-dev.20260511.1", + "oxlint": "1.63.0", + "oxlint-tsgolint": "0.22.1", + "vite": "8.1.0" + } +} diff --git a/libs/@hashintel/petrinaut-cli/src/cli.ts b/libs/@hashintel/petrinaut-cli/src/cli.ts new file mode 100644 index 00000000000..5c0b9e26f71 --- /dev/null +++ b/libs/@hashintel/petrinaut-cli/src/cli.ts @@ -0,0 +1,69 @@ +import { parseArgs } from "node:util"; + +import { serve } from "./commands/serve"; + +function printUsage(): void { + process.stderr.write(`Usage: + petrinaut serve --model --socket + petrinaut serve --model --host 127.0.0.1 --port 8765 + +Endpoints: + GET /healthz + GET /metadata + POST /runs +`); +} + +async function main(): Promise { + const [command, ...args] = process.argv.slice(2); + if (command !== "serve") { + printUsage(); + process.exitCode = 1; + return; + } + + const parsed = parseArgs({ + args, + options: { + model: { type: "string" }, + structure: { type: "string" }, + socket: { type: "string" }, + host: { type: "string", default: "127.0.0.1" }, + port: { type: "string" }, + help: { type: "boolean", short: "h" }, + }, + allowPositionals: false, + }); + + if (parsed.values.help) { + printUsage(); + return; + } + + const modelPath = parsed.values.model ?? parsed.values.structure; + if (!modelPath) { + throw new Error("Missing required --model "); + } + + const port = + parsed.values.port === undefined + ? undefined + : Number.parseInt(parsed.values.port, 10); + if (parsed.values.port !== undefined && !Number.isInteger(port)) { + throw new Error(`Invalid --port value: ${parsed.values.port}`); + } + + await serve({ + modelPath, + ...(parsed.values.socket ? { socketPath: parsed.values.socket } : {}), + host: parsed.values.host ?? "127.0.0.1", + ...(port !== undefined ? { port } : {}), + }); +} + +main().catch((error: unknown) => { + process.stderr.write( + `${error instanceof Error ? error.message : String(error)}\n`, + ); + process.exitCode = 1; +}); diff --git a/libs/@hashintel/petrinaut-cli/src/commands/serve.ts b/libs/@hashintel/petrinaut-cli/src/commands/serve.ts new file mode 100644 index 00000000000..8a0c3684add --- /dev/null +++ b/libs/@hashintel/petrinaut-cli/src/commands/serve.ts @@ -0,0 +1,150 @@ +import { existsSync, unlinkSync } from "node:fs"; +import { + createServer, + type IncomingMessage, + type ServerResponse, +} from "node:http"; +import { resolve } from "node:path"; + +import { compilePetrinautModel } from "@hashintel/petrinaut-core"; + +import { loadSdcpnModel } from "../runtime/load-model"; +import { + parseServerRunRequest, + toPetrinautRunConfig, +} from "../runtime/run-request"; + +type ServeOptions = { + modelPath: string; + socketPath?: string; + host: string; + port?: number; +}; + +const MAX_BODY_BYTES = 10 * 1024 * 1024; + +function sendJson( + response: ServerResponse, + statusCode: number, + value: unknown, +): void { + response.writeHead(statusCode, { + "content-type": "application/json; charset=utf-8", + }); + response.end(`${JSON.stringify(value)}\n`); +} + +async function readJsonBody(request: IncomingMessage): Promise { + const chunks: Buffer[] = []; + let byteLength = 0; + + for await (const chunk of request) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + byteLength += buffer.byteLength; + if (byteLength > MAX_BODY_BYTES) { + throw new Error("Request body is too large"); + } + chunks.push(buffer); + } + + if (chunks.length === 0) { + return {}; + } + + return JSON.parse(Buffer.concat(chunks).toString("utf8")); +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export async function serve(options: ServeOptions): Promise { + const modelPath = resolve(options.modelPath); + const sdcpn = await loadSdcpnModel(modelPath); + const model = compilePetrinautModel({ sdcpn }); + let socketRemoved = false; + + const removeSocket = (): void => { + if (!options.socketPath || socketRemoved) { + return; + } + socketRemoved = true; + try { + unlinkSync(options.socketPath); + } catch { + // Best-effort cleanup. The socket may already be gone. + } + }; + + const server = createServer(async (request, response) => { + try { + const url = new URL(request.url ?? "/", "http://localhost"); + + if (request.method === "GET" && url.pathname === "/healthz") { + sendJson(response, 200, { ok: true }); + return; + } + + if (request.method === "GET" && url.pathname === "/metadata") { + sendJson(response, 200, model.metadata); + return; + } + + if (request.method === "POST" && url.pathname === "/runs") { + const body = await readJsonBody(request); + const runRequest = parseServerRunRequest(body); + const result = model.run( + toPetrinautRunConfig(model.metadata, runRequest), + ); + sendJson(response, 200, result); + return; + } + + sendJson(response, 404, { error: "Not found" }); + } catch (error) { + sendJson(response, 400, { error: getErrorMessage(error) }); + } + }); + server.on("close", removeSocket); + + const shutdown = (): void => { + server.close(() => process.exit(0)); + }; + process.once("SIGINT", shutdown); + process.once("SIGTERM", shutdown); + + await new Promise((resolveListen, rejectListen) => { + const onError = (error: Error): void => { + rejectListen(error); + }; + server.once("error", onError); + server.once("listening", () => { + server.off("error", onError); + resolveListen(); + }); + + if (options.socketPath) { + if (existsSync(options.socketPath)) { + throw new Error( + `Socket path already exists: ${options.socketPath}. Remove it if no server is using it.`, + ); + } + server.listen(options.socketPath); + return; + } + + if (options.port === undefined) { + throw new Error("Serve requires --socket or --port"); + } + server.listen(options.port, options.host); + }); + + const address = server.address(); + const location = + typeof address === "string" + ? `unix:${address}` + : `${address?.address ?? options.host}:${address?.port ?? options.port}`; + process.stderr.write( + `Petrinaut server ready at ${location} for model ${modelPath}\n`, + ); +} diff --git a/libs/@hashintel/petrinaut-cli/src/runtime/load-model.ts b/libs/@hashintel/petrinaut-cli/src/runtime/load-model.ts new file mode 100644 index 00000000000..a5f4de3d922 --- /dev/null +++ b/libs/@hashintel/petrinaut-cli/src/runtime/load-model.ts @@ -0,0 +1,58 @@ +import { readFile } from "node:fs/promises"; + +import { parseSDCPNFile } from "@hashintel/petrinaut-core"; + +import type { SDCPN } from "@hashintel/petrinaut-core"; + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseRawSdcpn(value: unknown): SDCPN | null { + if (!isObject(value)) { + return null; + } + if (!Array.isArray(value.places) || !Array.isArray(value.transitions)) { + return null; + } + + return { + places: value.places as SDCPN["places"], + transitions: value.transitions as SDCPN["transitions"], + types: Array.isArray(value.types) ? (value.types as SDCPN["types"]) : [], + differentialEquations: Array.isArray(value.differentialEquations) + ? (value.differentialEquations as SDCPN["differentialEquations"]) + : [], + parameters: Array.isArray(value.parameters) + ? (value.parameters as SDCPN["parameters"]) + : [], + scenarios: Array.isArray(value.scenarios) + ? (value.scenarios as SDCPN["scenarios"]) + : [], + metrics: Array.isArray(value.metrics) + ? (value.metrics as SDCPN["metrics"]) + : [], + subnets: Array.isArray(value.subnets) + ? (value.subnets as SDCPN["subnets"]) + : [], + componentInstances: Array.isArray(value.componentInstances) + ? (value.componentInstances as SDCPN["componentInstances"]) + : [], + }; +} + +export async function loadSdcpnModel(path: string): Promise { + const text = await readFile(path, "utf8"); + const data: unknown = JSON.parse(text); + const parsed = parseSDCPNFile(data); + if (parsed.ok) { + return parsed.sdcpn; + } + + const raw = parseRawSdcpn(data); + if (raw) { + return raw; + } + + throw new Error(parsed.error); +} diff --git a/libs/@hashintel/petrinaut-cli/src/runtime/run-request.ts b/libs/@hashintel/petrinaut-cli/src/runtime/run-request.ts new file mode 100644 index 00000000000..5e86b638ff2 --- /dev/null +++ b/libs/@hashintel/petrinaut-cli/src/runtime/run-request.ts @@ -0,0 +1,178 @@ +import type { + InitialMarking, + InitialPlaceMarking, + PetrinautCompiledModelMetadata, + PetrinautRunConfig, +} from "@hashintel/petrinaut-core"; + +type JsonRecord = Record; + +const RUN_REQUEST_KEYS = new Set([ + "parameters", + "initialState", + "metrics", + "maxSteps", + "dt", + "maxTime", + "seed", +]); + +export type ServerRunRequest = { + parameters?: JsonRecord; + initialState?: JsonRecord; + metrics?: string[]; + maxSteps?: number; + dt?: number; + maxTime?: number | null; + seed?: number; +}; + +function isObject(value: unknown): value is JsonRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function asRecord(value: unknown, fieldName: string): JsonRecord { + if (value === undefined) { + return {}; + } + if (!isObject(value)) { + throw new Error(`${fieldName} must be an object`); + } + return value; +} + +function stringifyParameterValue(value: unknown, key: string): string { + if (typeof value === "string") { + return value; + } + if (typeof value === "number" && Number.isFinite(value)) { + return String(value); + } + if (typeof value === "boolean") { + return value ? "true" : "false"; + } + throw new Error(`Parameter "${key}" must be a string, number or boolean`); +} + +function normalizeParameterValues( + metadata: PetrinautCompiledModelMetadata, + request: ServerRunRequest, +): Record { + const byInputName = new Map(); + for (const parameter of metadata.parameters) { + byInputName.set(parameter.variableName, parameter.variableName); + byInputName.set(parameter.id, parameter.variableName); + byInputName.set(parameter.name, parameter.variableName); + } + + const values: Record = {}; + for (const [key, value] of Object.entries( + asRecord(request.parameters, "parameters"), + )) { + const variableName = byInputName.get(key); + if (!variableName) { + throw new Error(`Unknown parameter "${key}"`); + } + values[variableName] = stringifyParameterValue(value, key); + } + + return values; +} + +function resolvePlaceId( + metadata: PetrinautCompiledModelMetadata, + key: string, +): string { + const place = metadata.places.find( + (candidate) => candidate.id === key || candidate.name === key, + ); + if (!place) { + throw new Error(`Place "${key}" does not exist`); + } + return place.id; +} + +function normalizePlaceMarking( + metadata: PetrinautCompiledModelMetadata, + placeId: string, + value: unknown, +): InitialPlaceMarking { + const place = metadata.places.find((candidate) => candidate.id === placeId); + if (!place) { + throw new Error(`Place "${placeId}" does not exist`); + } + + if (Array.isArray(value)) { + return value as InitialPlaceMarking; + } + + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new Error( + `Initial marking for place "${place.name}" must be a number or token array`, + ); + } + + const tokenCount = Math.max(0, Math.round(value)); + if (!place.color) { + return tokenCount; + } + + return Array.from({ length: tokenCount }, () => ({})); +} + +function normalizeInitialMarking( + metadata: PetrinautCompiledModelMetadata, + request: ServerRunRequest, +): InitialMarking { + const marking: InitialMarking = {}; + for (const [key, value] of Object.entries( + asRecord(request.initialState, "initialState"), + )) { + const placeId = resolvePlaceId(metadata, key); + marking[placeId] = normalizePlaceMarking(metadata, placeId, value); + } + return marking; +} + +function normalizeMetrics(request: ServerRunRequest): string[] { + return request.metrics ?? []; +} + +export function parseServerRunRequest(value: unknown): ServerRunRequest { + const data = asRecord(value, "request body"); + for (const key of Object.keys(data)) { + if (!RUN_REQUEST_KEYS.has(key)) { + throw new Error(`Unknown run request field "${key}"`); + } + } + + return { + parameters: isObject(data.parameters) ? data.parameters : undefined, + initialState: isObject(data.initialState) ? data.initialState : undefined, + metrics: Array.isArray(data.metrics) + ? (data.metrics as string[]) + : undefined, + maxSteps: typeof data.maxSteps === "number" ? data.maxSteps : undefined, + dt: typeof data.dt === "number" ? data.dt : undefined, + maxTime: + typeof data.maxTime === "number" || data.maxTime === null + ? data.maxTime + : undefined, + seed: typeof data.seed === "number" ? data.seed : undefined, + }; +} + +export function toPetrinautRunConfig( + metadata: PetrinautCompiledModelMetadata, + request: ServerRunRequest, +): PetrinautRunConfig { + return { + initialMarking: normalizeInitialMarking(metadata, request), + parameterValues: normalizeParameterValues(metadata, request), + ...(request.seed !== undefined ? { seed: request.seed } : {}), + ...(request.dt !== undefined ? { dt: request.dt } : {}), + ...(request.maxTime !== undefined ? { maxTime: request.maxTime } : {}), + ...(request.maxSteps !== undefined ? { maxSteps: request.maxSteps } : {}), + metrics: normalizeMetrics(request), + }; +} diff --git a/libs/@hashintel/petrinaut-cli/tsconfig.json b/libs/@hashintel/petrinaut-cli/tsconfig.json new file mode 100644 index 00000000000..a4554243c0d --- /dev/null +++ b/libs/@hashintel/petrinaut-cli/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "es2024", + "lib": ["ESNext"], + "types": ["node"], + "module": "preserve", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "resolveJsonModule": true, + "noEmit": true, + "skipLibCheck": true, + "isolatedModules": true, + "paths": { + "@hashintel/petrinaut-core": ["../petrinaut-core/src/index.ts"] + } + }, + "include": ["src", "vite.config.ts", "../petrinaut-core/src/vite-types.d.ts"] +} diff --git a/libs/@hashintel/petrinaut-cli/turbo.json b/libs/@hashintel/petrinaut-cli/turbo.json new file mode 100644 index 00000000000..6d9a1d1f9e5 --- /dev/null +++ b/libs/@hashintel/petrinaut-cli/turbo.json @@ -0,0 +1,10 @@ +{ + "extends": ["//"], + "tasks": { + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**"], + "cache": false + } + } +} diff --git a/libs/@hashintel/petrinaut-cli/vite.config.ts b/libs/@hashintel/petrinaut-cli/vite.config.ts new file mode 100644 index 00000000000..cec2e567ac7 --- /dev/null +++ b/libs/@hashintel/petrinaut-cli/vite.config.ts @@ -0,0 +1,31 @@ +import { builtinModules } from "node:module"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { defineConfig } from "vite"; + +const packageRoot = dirname(fileURLToPath(import.meta.url)); +const nodeBuiltins = new Set([ + ...builtinModules, + ...builtinModules.map((name) => `node:${name}`), +]); + +export default defineConfig({ + build: { + lib: { + entry: resolve(packageRoot, "src/cli.ts"), + fileName: () => "cli.js", + formats: ["es"], + }, + minify: false, + sourcemap: true, + emptyOutDir: true, + rolldownOptions: { + external: (id) => + id === "@hashintel/petrinaut-core" || nodeBuiltins.has(id), + output: { + banner: "#!/usr/bin/env node", + }, + }, + }, +}); diff --git a/libs/@hashintel/petrinaut-core/src/index.ts b/libs/@hashintel/petrinaut-core/src/index.ts index 79377fe7d18..1ba2c0e3622 100644 --- a/libs/@hashintel/petrinaut-core/src/index.ts +++ b/libs/@hashintel/petrinaut-core/src/index.ts @@ -148,6 +148,7 @@ export type { // --- Simulation --- export { addAllMonteCarloMetricValues, + compilePetrinautModel, createMonteCarloExperiment, createMonteCarloMetricHistogramAccumulator, createMonteCarloMetricNumericAccumulator, @@ -159,6 +160,7 @@ export { } from "./simulation"; export type { BackpressureConfig, + CompilePetrinautModelConfig, CreateMonteCarloExperimentConfig, CreateSimulationConfig, Simulation, @@ -176,6 +178,14 @@ export type { InitialMarking, InitialPlaceMarking, InitialTokenAttributeValue, + PetrinautCompiledModel, + PetrinautCompiledModelMetadata, + PetrinautCompiledModelMetricMetadata, + PetrinautCompiledModelParameterMetadata, + PetrinautCompiledModelPlaceMetadata, + PetrinautRunCompletionReason, + PetrinautRunConfig, + PetrinautRunResult, MonteCarloAdvanceResult, MonteCarloActiveRunPlaceCountsVisitor, MonteCarloExperiment, diff --git a/libs/@hashintel/petrinaut-core/src/simulation/compiled-model.ts b/libs/@hashintel/petrinaut-core/src/simulation/compiled-model.ts new file mode 100644 index 00000000000..1baf335ea81 --- /dev/null +++ b/libs/@hashintel/petrinaut-core/src/simulation/compiled-model.ts @@ -0,0 +1,365 @@ +import { compileHirArtifacts } from "../hir"; +import { buildSimulation } from "./engine/build-simulation"; +import { computeNextFrame } from "./engine/compute-next-frame"; +import { readTokenRecord } from "./engine/token-layout"; +import { createHirMetricEvaluator } from "./frames/hir-metric"; +import { readEngineFrame } from "./frames/internal-frame"; + +import type { PetrinautExtensionSettings } from "../extensions"; +import type { HirArtifacts } from "../hir-runtime"; +import type { Color, Metric, Place, SDCPN, TokenRecord } from "../types/sdcpn"; +import type { + InitialMarking, + SimulationFrameReader, + SimulationFrameState, +} from "./api"; +import type { SimulationCompletionReason } from "./engine/compute-next-frame"; +import type { EngineFrame, EngineFrameLayout } from "./frames/internal-frame"; + +export type PetrinautRunCompletionReason = + | SimulationCompletionReason + | "maxSteps"; + +export type PetrinautCompiledModelParameterMetadata = { + id: string; + name: string; + variableName: string; + type: "real" | "integer" | "boolean"; + defaultValue: string; + valueRange: null; +}; + +export type PetrinautCompiledModelPlaceMetadata = { + id: string; + name: string; + index: number; + color: { + id: string; + name: string; + elements: { + elementId: string; + name: string; + type: Color["elements"][number]["type"]; + valueRange: null; + }[]; + } | null; +}; + +export type PetrinautCompiledModelMetricMetadata = { + id: string; + name: string; + description?: string; + optimizationObjective: null; +}; + +export type PetrinautCompiledModelMetadata = { + parameters: PetrinautCompiledModelParameterMetadata[]; + places: PetrinautCompiledModelPlaceMetadata[]; + metrics: PetrinautCompiledModelMetricMetadata[]; +}; + +export type PetrinautRunConfig = { + initialMarking?: InitialMarking; + parameterValues?: Record; + seed?: number; + dt?: number; + maxTime?: number | null; + /** Number of simulation steps to execute. Stops before `maxTime` if lower. */ + maxSteps?: number; + /** Metric ids or names to evaluate on the final frame. */ + metrics?: readonly string[]; +}; + +export type PetrinautRunResult = { + seed: number; + status: "complete"; + completionReason: PetrinautRunCompletionReason; + frameCount: number; + finalTime: number; + finalPlaceTokenCounts: Record; + metrics: Record; +}; + +export type PetrinautCompiledModel = { + readonly metadata: PetrinautCompiledModelMetadata; + run(config?: PetrinautRunConfig): PetrinautRunResult; +}; + +export type CompilePetrinautModelConfig = { + sdcpn: SDCPN; + extensions?: PetrinautExtensionSettings; + hirArtifacts?: HirArtifacts; +}; + +const MAX_SEED = 2_147_483_647; + +function generateSeed(): number { + return Math.floor(Math.random() * MAX_SEED) + 1; +} + +function createMetadata(sdcpn: SDCPN): PetrinautCompiledModelMetadata { + const colorsById = new Map(sdcpn.types.map((color) => [color.id, color])); + + return { + parameters: sdcpn.parameters.map((parameter) => ({ + id: parameter.id, + name: parameter.name, + variableName: parameter.variableName, + type: parameter.type, + defaultValue: parameter.defaultValue, + valueRange: null, + })), + places: sdcpn.places.map((place, index) => { + const color = place.colorId ? colorsById.get(place.colorId) : undefined; + + return { + id: place.id, + name: place.name, + index, + color: color + ? { + id: color.id, + name: color.name, + elements: color.elements.map((element) => ({ + elementId: element.elementId, + name: element.name, + type: element.type, + valueRange: null, + })), + } + : null, + }; + }), + metrics: (sdcpn.metrics ?? []).map((metric) => ({ + id: metric.id, + name: metric.name, + ...(metric.description !== undefined + ? { description: metric.description } + : {}), + optimizationObjective: null, + })), + }; +} + +function findMetric(sdcpn: SDCPN, metricNameOrId: string): Metric { + const metric = (sdcpn.metrics ?? []).find( + (candidate) => + candidate.id === metricNameOrId || candidate.name === metricNameOrId, + ); + if (!metric) { + throw new Error(`Metric "${metricNameOrId}" does not exist in the model`); + } + + return metric; +} + +function createFrameReader(args: { + layout: EngineFrameLayout; + frame: EngineFrame; + number: number; + time: number; + places: ReadonlyMap; + stringPool: { get(id: number): string }; +}): SimulationFrameReader { + const { layout, frame, number, time, places, stringPool } = args; + const frameView = readEngineFrame(layout, frame); + + return { + number, + time, + getPlaceTokenCount(placeId) { + return frameView.getPlaceState(placeId)?.count ?? 0; + }, + getRawView() { + return { + ...frameView.tokenViews, + placeCounts: frameView.placeCounts, + placeOffsets: frameView.placeByteOffsets, + placeIndexById: layout.placeIndexById, + stringPool, + }; + }, + getPlaceTokens(place) { + const placeState = frameView.getPlaceState(place.id); + const placeIndex = layout.placeIndexById.get(place.id); + const tokenLayout = + placeIndex === undefined ? null : layout.placeTokenLayouts[placeIndex]; + if (!placeState || !tokenLayout || placeState.count === 0) { + return []; + } + + const tokens: TokenRecord[] = []; + for (let tokenIndex = 0; tokenIndex < placeState.count; tokenIndex++) { + tokens.push( + readTokenRecord( + tokenLayout, + frameView.tokenViews, + placeState.byteOffset + tokenIndex * placeState.strideBytes, + stringPool, + ), + ); + } + + return tokens; + }, + getTransitionState(transitionId) { + return frameView.getTransitionState(transitionId); + }, + toFrameState() { + const framePlaces: SimulationFrameState["places"] = {}; + for (const [placeId, placeData] of frameView.getPlaceEntries()) { + if (!places.has(placeId)) { + continue; + } + framePlaces[placeId] = { tokenCount: placeData.count }; + } + + return { + number, + places: framePlaces, + }; + }, + }; +} + +function evaluateMetrics(args: { + sdcpn: SDCPN; + artifacts: HirArtifacts; + metricNamesOrIds: readonly string[]; + frame: SimulationFrameReader; +}): Record { + const { sdcpn, artifacts, metricNamesOrIds, frame } = args; + const values: Record = {}; + + for (const metricNameOrId of metricNamesOrIds) { + const metric = findMetric(sdcpn, metricNameOrId); + const artifact = artifacts.metrics[metric.id]; + if (!artifact) { + throw new Error( + `Metric "${metric.name}" has not been compiled. Check the model's metric diagnostics.`, + ); + } + + const evaluate = createHirMetricEvaluator({ + metricName: metric.name, + artifact, + places: sdcpn.places, + }); + values[metric.name] = evaluate(frame); + } + + return values; +} + +/** + * Compiles a Petrinaut model once and returns a reusable runner. + * + * The first implementation caches the SDCPN snapshot and HIR artifacts. Each + * run still creates fresh per-run engine state, which keeps parameter values, + * initial marking, string pool and seeded RNG isolated. + */ +export function compilePetrinautModel( + config: CompilePetrinautModelConfig, +): PetrinautCompiledModel { + const compiled = config.hirArtifacts + ? { artifacts: config.hirArtifacts, failures: [] } + : compileHirArtifacts(config.sdcpn, config.extensions); + const { artifacts, failures } = compiled; + + if (failures.length > 0) { + const details = failures + .map((failure) => { + const messages = failure.diagnostics + .map((diagnostic) => diagnostic.message) + .join("; "); + return `${failure.itemType} ${failure.itemId}: ${messages}`; + }) + .join("\n"); + throw new Error(`Model HIR compilation failed:\n${details}`); + } + + const sdcpn = config.sdcpn; + const metadata = createMetadata(sdcpn); + + return { + metadata, + run(runConfig = {}) { + const seed = runConfig.seed ?? generateSeed(); + const dt = runConfig.dt ?? 1; + const maxTime = runConfig.maxTime ?? null; + const maxSteps = runConfig.maxSteps; + if (maxTime === null && maxSteps === undefined) { + throw new Error("Run config requires either maxTime or maxSteps"); + } + if ( + maxSteps !== undefined && + (!Number.isInteger(maxSteps) || maxSteps < 0) + ) { + throw new Error("Run config maxSteps must be a non-negative integer"); + } + + let simulation = buildSimulation({ + sdcpn, + extensions: config.extensions, + initialMarking: runConfig.initialMarking ?? {}, + parameterValues: runConfig.parameterValues ?? {}, + seed, + dt, + maxTime, + hirArtifacts: artifacts, + }); + + let completionReason: PetrinautRunCompletionReason | null = null; + let steps = 0; + while (completionReason === null) { + if (maxSteps !== undefined && steps >= maxSteps) { + completionReason = "maxSteps"; + break; + } + + const result = computeNextFrame(simulation); + simulation = result.simulation; + completionReason = result.completionReason; + steps++; + } + + const finalFrame = simulation.frames[simulation.currentFrameNumber]; + if (!finalFrame) { + throw new Error("Simulation produced no final frame"); + } + + const places = new Map(simulation.places); + const finalFrameReader = createFrameReader({ + layout: simulation.frameLayout, + frame: finalFrame, + number: simulation.currentFrameNumber, + time: simulation.currentTime, + places, + stringPool: simulation.stringPool, + }); + + const finalPlaceTokenCounts: Record = {}; + for (const placeId of simulation.frameLayout.placeIds) { + finalPlaceTokenCounts[placeId] = + finalFrameReader.getPlaceTokenCount(placeId); + } + + return { + seed, + status: "complete", + completionReason, + frameCount: simulation.currentFrameNumber + 1, + finalTime: simulation.currentTime, + finalPlaceTokenCounts, + metrics: runConfig.metrics + ? evaluateMetrics({ + sdcpn, + artifacts, + metricNamesOrIds: runConfig.metrics, + frame: finalFrameReader, + }) + : {}, + }; + }, + }; +} diff --git a/libs/@hashintel/petrinaut-core/src/simulation/index.ts b/libs/@hashintel/petrinaut-core/src/simulation/index.ts index 71f1cd82472..2968d8488df 100644 --- a/libs/@hashintel/petrinaut-core/src/simulation/index.ts +++ b/libs/@hashintel/petrinaut-core/src/simulation/index.ts @@ -26,6 +26,18 @@ export { createMonteCarloUserDefinedMetricConfigsFromSpecs, createMonteCarloUserDefinedMetric, } from "./monte-carlo"; +export { compilePetrinautModel } from "./compiled-model"; +export type { + CompilePetrinautModelConfig, + PetrinautCompiledModel, + PetrinautCompiledModelMetadata, + PetrinautCompiledModelMetricMetadata, + PetrinautCompiledModelParameterMetadata, + PetrinautCompiledModelPlaceMetadata, + PetrinautRunCompletionReason, + PetrinautRunConfig, + PetrinautRunResult, +} from "./compiled-model"; export type { CreateMonteCarloExperimentConfig, MonteCarloAdvanceResult, diff --git a/yarn.lock b/yarn.lock index 6569eb5f388..eaf8a192bc2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6692,6 +6692,22 @@ __metadata: languageName: node linkType: hard +"@hashintel/petrinaut-cli@workspace:libs/@hashintel/petrinaut-cli": + version: 0.0.0-use.local + resolution: "@hashintel/petrinaut-cli@workspace:libs/@hashintel/petrinaut-cli" + dependencies: + "@hashintel/petrinaut-core": "workspace:*" + "@types/node": "npm:22.18.13" + "@typescript/native-preview": "npm:7.0.0-dev.20260511.1" + oxlint: "npm:1.63.0" + oxlint-tsgolint: "npm:0.22.1" + vite: "npm:8.1.0" + bin: + petrinaut: ./dist/cli.js + petrinaut_cli: ./dist/cli.js + languageName: unknown + linkType: soft + "@hashintel/petrinaut-core@workspace:*, @hashintel/petrinaut-core@workspace:^, @hashintel/petrinaut-core@workspace:libs/@hashintel/petrinaut-core": version: 0.0.0-use.local resolution: "@hashintel/petrinaut-core@workspace:libs/@hashintel/petrinaut-core" From 7ac9ad3f29ac82d72c7f1c59f9a8b48e981c0da5 Mon Sep 17 00:00:00 2001 From: Chris Feijoo Date: Thu, 9 Jul 2026 01:32:40 +0200 Subject: [PATCH 2/2] Use Unix socket protocol for Petrinaut CLI --- libs/@hashintel/petrinaut-cli/README.md | 149 +++++++++----- libs/@hashintel/petrinaut-cli/package.json | 4 +- libs/@hashintel/petrinaut-cli/src/cli.ts | 30 +-- .../petrinaut-cli/src/commands/serve.ts | 188 ++++++++++-------- 4 files changed, 217 insertions(+), 154 deletions(-) diff --git a/libs/@hashintel/petrinaut-cli/README.md b/libs/@hashintel/petrinaut-cli/README.md index 32c159a357e..606871af06d 100644 --- a/libs/@hashintel/petrinaut-cli/README.md +++ b/libs/@hashintel/petrinaut-cli/README.md @@ -1,9 +1,13 @@ # `@hashintel/petrinaut-cli` -Internal CLI/server for running one Petrinaut model repeatedly from scripts, -Python optimization loops, or backend jobs. +Internal Unix-socket CLI for running one Petrinaut model repeatedly from +scripts, Python optimization loops, or backend jobs. -## Start +`petrinaut serve` is a long-lived process. It loads Petrinaut Core once, +compiles one model once, then accepts one simulation request per parameter set. +It does not expose HTTP or TCP. + +## Example Flow Build first: @@ -11,66 +15,105 @@ Build first: yarn workspace @hashintel/petrinaut-cli build ``` -Serve over a Unix socket: +Start the model process: ```bash -petrinaut serve --model ./model.sdcpn.json --socket /tmp/petrinaut.sock +yarn workspace @hashintel/petrinaut-cli serve --model ./examples/sir-model.json --socket /tmp/petrinaut.sock ``` -Or over loopback HTTP: - -```bash -petrinaut serve --model ./model.sdcpn.json --host 127.0.0.1 --port 8765 +Connect from the caller once, ask for model metadata, then send `run` requests +for each parameter set: + +```python +import json +import socket + +sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +sock.connect("/tmp/petrinaut.sock") +stream = sock.makefile("rwb") + +def request(payload): + stream.write((json.dumps(payload) + "\n").encode()) + stream.flush() + response = json.loads(stream.readline()) + if "error" in response: + raise RuntimeError(response["error"]["message"]) + return response["result"] + +metadata = request({"id": 1, "method": "metadata"}) + +result = request({ + "id": 2, + "method": "run", + "params": { + "parameters": {"infection_rate": 1.5, "recovery_rate": 0.8}, + "initialState": {"Susceptible": 990, "Infected": 10, "Recovered": 0}, + "metrics": ["Infected Fraction"], + "maxSteps": 100, + "dt": 0.1, + "seed": 4242, + }, +}) + +objective = result["metrics"]["Infected Fraction"] ``` -The server loads Petrinaut Core once, compiles the model once, then accepts many -run requests. - Example JSON models copied from Petrinaut Core live in `examples/`. -## Endpoints +## Socket Protocol -`GET /healthz` +Send one JSON object per line to the socket. Read one JSON object per line back. + +Request: ```json -{ "ok": true } +{ "id": 1, "method": "metadata" } ``` -`GET /metadata` +Response: + +```json +{ "id": 1, "result": { "parameters": [], "places": [], "metrics": [] } } +``` -Returns parameters, places and metrics. Use this from Python to discover: +Methods: -- parameter variable names, ids, display names and types; -- place ids, display names and colour fields; -- metric ids and names. +- `healthz`: returns `{ "ok": true }`. +- `metadata`: returns parameters, places and metrics. +- `run`: runs one simulation. Pass the run config in `params`. -`POST /runs` +## Run Request ```json { - "parameters": { - "infection_rate": 1.5, - "recovery_rate": 0.8 - }, - "initialState": { - "Susceptible": 990, - "Infected": 10, - "Recovered": 0 - }, - "metrics": ["Infected Fraction", "Throughput"], - "maxSteps": 100, - "dt": 0.1, - "seed": 4242 + "id": 2, + "method": "run", + "params": { + "parameters": { + "infection_rate": 1.5, + "recovery_rate": 0.8 + }, + "initialState": { + "Susceptible": 990, + "Infected": 10, + "Recovered": 0 + }, + "metrics": ["Infected Fraction"], + "maxSteps": 100, + "dt": 0.1, + "seed": 4242 + } } ``` -Fields: +Run config fields: - `parameters`: parameter values. Keys may be parameter variable name, parameter id, or display name. - `initialState`: initial markings. Keys may be place id or display name. - `metrics`: metric names/ids evaluated on the final frame. - `maxSteps`: number of simulation steps. `dt` defaults to `1` if omitted. +- `seed`: optional random seed. Transition predicates are part of the model structure, not the run request. To change predicate logic, edit the model; to change values used by predicate @@ -82,21 +125,33 @@ Metrics are evaluated only on the final frame and returned as scalar values: ```json { - "seed": 4242, - "status": "complete", - "completionReason": "maxSteps", - "frameCount": 101, - "finalTime": 10, - "finalPlaceTokenCounts": { - "place__susceptible": 900, - "place__infected": 40 - }, - "metrics": { - "Infected Fraction": 0.04, - "Throughput": 12.3 + "id": 2, + "result": { + "seed": 4242, + "status": "complete", + "completionReason": "maxSteps", + "frameCount": 101, + "finalTime": 10, + "finalPlaceTokenCounts": { + "place__susceptible": 900, + "place__infected": 40 + }, + "metrics": { + "Infected Fraction": 0.04 + } } } ``` +`metrics` may contain multiple names. Each requested metric is evaluated on the +final frame and returned as a scalar under `result.metrics`. For an optimizer +that needs one objective value, read the one metric key it requested. + +Errors return one JSON-line response: + +```json +{ "id": 2, "error": { "message": "Unknown parameter \"x\"" } } +``` + The first version does not return metric distributions or full frame histories. It is intentionally summary-first for optimization loops. diff --git a/libs/@hashintel/petrinaut-cli/package.json b/libs/@hashintel/petrinaut-cli/package.json index 0f636cb33f6..0c702419a5f 100644 --- a/libs/@hashintel/petrinaut-cli/package.json +++ b/libs/@hashintel/petrinaut-cli/package.json @@ -2,7 +2,7 @@ "name": "@hashintel/petrinaut-cli", "version": "0.0.0-private", "private": true, - "description": "Internal CLI and local server for Petrinaut simulations", + "description": "Internal Unix-socket CLI for Petrinaut simulations", "license": "(MIT OR Apache-2.0)", "bin": { "petrinaut": "./dist/cli.js", @@ -15,7 +15,7 @@ "lint:eslint": "oxlint --type-aware --report-unused-disable-directives-severity=error .", "lint:tsc": "tsgo --noEmit", "serve": "node ./dist/cli.js serve", - "serve:example": "node ./dist/cli.js serve --model ./examples/sir-model.json --host 127.0.0.1 --port 8765" + "serve:example": "node ./dist/cli.js serve --model ./examples/sir-model.json --socket /tmp/petrinaut.sock" }, "dependencies": { "@hashintel/petrinaut-core": "workspace:*" diff --git a/libs/@hashintel/petrinaut-cli/src/cli.ts b/libs/@hashintel/petrinaut-cli/src/cli.ts index 5c0b9e26f71..f1bdfdbc888 100644 --- a/libs/@hashintel/petrinaut-cli/src/cli.ts +++ b/libs/@hashintel/petrinaut-cli/src/cli.ts @@ -5,12 +5,11 @@ import { serve } from "./commands/serve"; function printUsage(): void { process.stderr.write(`Usage: petrinaut serve --model --socket - petrinaut serve --model --host 127.0.0.1 --port 8765 -Endpoints: - GET /healthz - GET /metadata - POST /runs +Unix socket methods: + healthz + metadata + run `); } @@ -26,10 +25,7 @@ async function main(): Promise { args, options: { model: { type: "string" }, - structure: { type: "string" }, socket: { type: "string" }, - host: { type: "string", default: "127.0.0.1" }, - port: { type: "string" }, help: { type: "boolean", short: "h" }, }, allowPositionals: false, @@ -40,24 +36,16 @@ async function main(): Promise { return; } - const modelPath = parsed.values.model ?? parsed.values.structure; - if (!modelPath) { + if (!parsed.values.model) { throw new Error("Missing required --model "); } - - const port = - parsed.values.port === undefined - ? undefined - : Number.parseInt(parsed.values.port, 10); - if (parsed.values.port !== undefined && !Number.isInteger(port)) { - throw new Error(`Invalid --port value: ${parsed.values.port}`); + if (!parsed.values.socket) { + throw new Error("Missing required --socket "); } await serve({ - modelPath, - ...(parsed.values.socket ? { socketPath: parsed.values.socket } : {}), - host: parsed.values.host ?? "127.0.0.1", - ...(port !== undefined ? { port } : {}), + modelPath: parsed.values.model, + socketPath: parsed.values.socket, }); } diff --git a/libs/@hashintel/petrinaut-cli/src/commands/serve.ts b/libs/@hashintel/petrinaut-cli/src/commands/serve.ts index 8a0c3684add..df0aaa1f273 100644 --- a/libs/@hashintel/petrinaut-cli/src/commands/serve.ts +++ b/libs/@hashintel/petrinaut-cli/src/commands/serve.ts @@ -1,9 +1,5 @@ import { existsSync, unlinkSync } from "node:fs"; -import { - createServer, - type IncomingMessage, - type ServerResponse, -} from "node:http"; +import { createServer, type Socket } from "node:net"; import { resolve } from "node:path"; import { compilePetrinautModel } from "@hashintel/petrinaut-core"; @@ -16,46 +12,23 @@ import { type ServeOptions = { modelPath: string; - socketPath?: string; - host: string; - port?: number; + socketPath: string; }; const MAX_BODY_BYTES = 10 * 1024 * 1024; -function sendJson( - response: ServerResponse, - statusCode: number, - value: unknown, -): void { - response.writeHead(statusCode, { - "content-type": "application/json; charset=utf-8", - }); - response.end(`${JSON.stringify(value)}\n`); +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); } -async function readJsonBody(request: IncomingMessage): Promise { - const chunks: Buffer[] = []; - let byteLength = 0; - - for await (const chunk of request) { - const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); - byteLength += buffer.byteLength; - if (byteLength > MAX_BODY_BYTES) { - throw new Error("Request body is too large"); - } - chunks.push(buffer); - } - - if (chunks.length === 0) { - return {}; - } - - return JSON.parse(Buffer.concat(chunks).toString("utf8")); -} +type SocketRequest = { + id?: unknown; + method?: unknown; + params?: unknown; +}; -function getErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); +function writeResponse(socket: Socket, value: unknown): void { + socket.write(`${JSON.stringify(value)}\n`); } export async function serve(options: ServeOptions): Promise { @@ -65,7 +38,7 @@ export async function serve(options: ServeOptions): Promise { let socketRemoved = false; const removeSocket = (): void => { - if (!options.socketPath || socketRemoved) { + if (socketRemoved) { return; } socketRemoved = true; @@ -76,42 +49,90 @@ export async function serve(options: ServeOptions): Promise { } }; - const server = createServer(async (request, response) => { - try { - const url = new URL(request.url ?? "/", "http://localhost"); + const activeSockets = new Set(); + const server = createServer((socket) => { + activeSockets.add(socket); + let buffer = ""; - if (request.method === "GET" && url.pathname === "/healthz") { - sendJson(response, 200, { ok: true }); + const handleLine = (line: string): void => { + if (line.trim() === "") { return; } - if (request.method === "GET" && url.pathname === "/metadata") { - sendJson(response, 200, model.metadata); - return; + let id: unknown = null; + try { + const request = JSON.parse(line) as SocketRequest; + id = request.id ?? null; + if (typeof request.method !== "string") { + throw new Error("Request method must be a string"); + } + + switch (request.method) { + case "healthz": + writeResponse(socket, { id, result: { ok: true } }); + return; + case "metadata": + writeResponse(socket, { id, result: model.metadata }); + return; + case "run": { + const runRequest = parseServerRunRequest(request.params ?? {}); + const result = model.run( + toPetrinautRunConfig(model.metadata, runRequest), + ); + writeResponse(socket, { id, result }); + return; + } + default: + throw new Error(`Unknown method "${request.method}"`); + } + } catch (error) { + writeResponse(socket, { + id, + error: { message: getErrorMessage(error) }, + }); } + }; - if (request.method === "POST" && url.pathname === "/runs") { - const body = await readJsonBody(request); - const runRequest = parseServerRunRequest(body); - const result = model.run( - toPetrinautRunConfig(model.metadata, runRequest), - ); - sendJson(response, 200, result); + socket.setEncoding("utf8"); + socket.on("data", (chunk) => { + buffer += chunk; + if (Buffer.byteLength(buffer, "utf8") > MAX_BODY_BYTES) { + writeResponse(socket, { + id: null, + error: { message: "Request line is too large" }, + }); + socket.destroy(); return; } - sendJson(response, 404, { error: "Not found" }); - } catch (error) { - sendJson(response, 400, { error: getErrorMessage(error) }); - } + let newlineIndex = buffer.indexOf("\n"); + while (newlineIndex !== -1) { + const line = buffer.slice(0, newlineIndex); + buffer = buffer.slice(newlineIndex + 1); + handleLine(line); + newlineIndex = buffer.indexOf("\n"); + } + }); + + socket.on("end", () => { + if (buffer.trim() !== "") { + handleLine(buffer); + } + }); + socket.on("error", () => { + // Per-connection errors are reported through the socket lifecycle. + }); + socket.on("close", () => { + activeSockets.delete(socket); + }); }); server.on("close", removeSocket); - const shutdown = (): void => { - server.close(() => process.exit(0)); - }; - process.once("SIGINT", shutdown); - process.once("SIGTERM", shutdown); + if (existsSync(options.socketPath)) { + throw new Error( + `Socket path already exists: ${options.socketPath}. Remove it if no Petrinaut process is using it.`, + ); + } await new Promise((resolveListen, rejectListen) => { const onError = (error: Error): void => { @@ -122,29 +143,28 @@ export async function serve(options: ServeOptions): Promise { server.off("error", onError); resolveListen(); }); - - if (options.socketPath) { - if (existsSync(options.socketPath)) { - throw new Error( - `Socket path already exists: ${options.socketPath}. Remove it if no server is using it.`, - ); - } - server.listen(options.socketPath); - return; - } - - if (options.port === undefined) { - throw new Error("Serve requires --socket or --port"); - } - server.listen(options.port, options.host); + server.listen(options.socketPath); }); - const address = server.address(); - const location = - typeof address === "string" - ? `unix:${address}` - : `${address?.address ?? options.host}:${address?.port ?? options.port}`; process.stderr.write( - `Petrinaut server ready at ${location} for model ${modelPath}\n`, + `Petrinaut socket ready at ${options.socketPath} for model ${modelPath}\n`, ); + + await new Promise((resolveShutdown) => { + let shuttingDown = false; + const shutdown = (): void => { + if (shuttingDown) { + return; + } + shuttingDown = true; + for (const socket of activeSockets) { + socket.destroy(); + } + server.close(() => { + resolveShutdown(); + }); + }; + process.once("SIGINT", shutdown); + process.once("SIGTERM", shutdown); + }); }