diff --git a/libs/@hashintel/petrinaut-cli/README.md b/libs/@hashintel/petrinaut-cli/README.md
new file mode 100644
index 00000000000..606871af06d
--- /dev/null
+++ b/libs/@hashintel/petrinaut-cli/README.md
@@ -0,0 +1,157 @@
+# `@hashintel/petrinaut-cli`
+
+Internal Unix-socket CLI for running one Petrinaut model repeatedly from
+scripts, Python optimization loops, or backend jobs.
+
+`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:
+
+```bash
+yarn workspace @hashintel/petrinaut-cli build
+```
+
+Start the model process:
+
+```bash
+yarn workspace @hashintel/petrinaut-cli serve --model ./examples/sir-model.json --socket /tmp/petrinaut.sock
+```
+
+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"]
+```
+
+Example JSON models copied from Petrinaut Core live in `examples/`.
+
+## Socket Protocol
+
+Send one JSON object per line to the socket. Read one JSON object per line back.
+
+Request:
+
+```json
+{ "id": 1, "method": "metadata" }
+```
+
+Response:
+
+```json
+{ "id": 1, "result": { "parameters": [], "places": [], "metrics": [] } }
+```
+
+Methods:
+
+- `healthz`: returns `{ "ok": true }`.
+- `metadata`: returns parameters, places and metrics.
+- `run`: runs one simulation. Pass the run config in `params`.
+
+## Run Request
+
+```json
+{
+ "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
+ }
+}
+```
+
+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
+logic, pass new `parameters`.
+
+## Output
+
+Metrics are evaluated only on the final frame and returned as scalar values:
+
+```json
+{
+ "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/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});",
+ "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});",
+ "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});",
+ "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});",
+ "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});",
+ "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});",
+ "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 );\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});",
+ "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});",
+ "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});",
+ "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});",
+ "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});",
+ "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});",
+ "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..0c702419a5f
--- /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 Unix-socket CLI 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 --socket /tmp/petrinaut.sock"
+ },
+ "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..f1bdfdbc888
--- /dev/null
+++ b/libs/@hashintel/petrinaut-cli/src/cli.ts
@@ -0,0 +1,57 @@
+import { parseArgs } from "node:util";
+
+import { serve } from "./commands/serve";
+
+function printUsage(): void {
+ process.stderr.write(`Usage:
+ petrinaut serve --model --socket
+
+Unix socket methods:
+ healthz
+ metadata
+ run
+`);
+}
+
+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" },
+ socket: { type: "string" },
+ help: { type: "boolean", short: "h" },
+ },
+ allowPositionals: false,
+ });
+
+ if (parsed.values.help) {
+ printUsage();
+ return;
+ }
+
+ if (!parsed.values.model) {
+ throw new Error("Missing required --model ");
+ }
+ if (!parsed.values.socket) {
+ throw new Error("Missing required --socket ");
+ }
+
+ await serve({
+ modelPath: parsed.values.model,
+ socketPath: parsed.values.socket,
+ });
+}
+
+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..df0aaa1f273
--- /dev/null
+++ b/libs/@hashintel/petrinaut-cli/src/commands/serve.ts
@@ -0,0 +1,170 @@
+import { existsSync, unlinkSync } from "node:fs";
+import { createServer, type Socket } from "node:net";
+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;
+};
+
+const MAX_BODY_BYTES = 10 * 1024 * 1024;
+
+function getErrorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error);
+}
+
+type SocketRequest = {
+ id?: unknown;
+ method?: unknown;
+ params?: unknown;
+};
+
+function writeResponse(socket: Socket, value: unknown): void {
+ socket.write(`${JSON.stringify(value)}\n`);
+}
+
+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 (socketRemoved) {
+ return;
+ }
+ socketRemoved = true;
+ try {
+ unlinkSync(options.socketPath);
+ } catch {
+ // Best-effort cleanup. The socket may already be gone.
+ }
+ };
+
+ const activeSockets = new Set();
+ const server = createServer((socket) => {
+ activeSockets.add(socket);
+ let buffer = "";
+
+ const handleLine = (line: string): void => {
+ if (line.trim() === "") {
+ 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) },
+ });
+ }
+ };
+
+ 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;
+ }
+
+ 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);
+
+ 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 => {
+ rejectListen(error);
+ };
+ server.once("error", onError);
+ server.once("listening", () => {
+ server.off("error", onError);
+ resolveListen();
+ });
+ server.listen(options.socketPath);
+ });
+
+ process.stderr.write(
+ `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);
+ });
+}
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"