diff --git a/libs/@hashintel/petrinaut-cli/LICENSE-APACHE.md b/libs/@hashintel/petrinaut-cli/LICENSE-APACHE.md
new file mode 100644
index 00000000000..4b43328a923
--- /dev/null
+++ b/libs/@hashintel/petrinaut-cli/LICENSE-APACHE.md
@@ -0,0 +1,189 @@
+# Apache License
+
+_Version 2.0, January 2004_
+_<>_
+
+### Terms and Conditions for use, reproduction, and distribution
+
+#### 1. Definitions
+
+“License” shall mean the terms and conditions for use, reproduction, and
+distribution as defined by Sections 1 through 9 of this document.
+
+“Licensor” shall mean the copyright owner or entity authorized by the copyright
+owner that is granting the License.
+
+“Legal Entity” shall mean the union of the acting entity and all other entities
+that control, are controlled by, or are under common control with that entity.
+For the purposes of this definition, “control” means **(i)** the power, direct or
+indirect, to cause the direction or management of such entity, whether by
+contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the
+outstanding shares, or **(iii)** beneficial ownership of such entity.
+
+“You” (or “Your”) shall mean an individual or Legal Entity exercising
+permissions granted by this License.
+
+“Source” form shall mean the preferred form for making modifications, including
+but not limited to software source code, documentation source, and configuration
+files.
+
+“Object” form shall mean any form resulting from mechanical transformation or
+translation of a Source form, including but not limited to compiled object code,
+generated documentation, and conversions to other media types.
+
+“Work” shall mean the work of authorship, whether in Source or Object form, made
+available under the License, as indicated by a copyright notice that is included
+in or attached to the work (an example is provided in the Appendix below).
+
+“Derivative Works” shall mean any work, whether in Source or Object form, that
+is based on (or derived from) the Work and for which the editorial revisions,
+annotations, elaborations, or other modifications represent, as a whole, an
+original work of authorship. For the purposes of this License, Derivative Works
+shall not include works that remain separable from, or merely link (or bind by
+name) to the interfaces of, the Work and Derivative Works thereof.
+
+“Contribution” shall mean any work of authorship, including the original version
+of the Work and any modifications or additions to that Work or Derivative Works
+thereof, that is intentionally submitted to Licensor for inclusion in the Work
+by the copyright owner or by an individual or Legal Entity authorized to submit
+on behalf of the copyright owner. For the purposes of this definition,
+“submitted” means any form of electronic, verbal, or written communication sent
+to the Licensor or its representatives, including but not limited to
+communication on electronic mailing lists, source code control systems, and
+issue tracking systems that are managed by, or on behalf of, the Licensor for
+the purpose of discussing and improving the Work, but excluding communication
+that is conspicuously marked or otherwise designated in writing by the copyright
+owner as “Not a Contribution.”
+
+“Contributor” shall mean Licensor and any individual or Legal Entity on behalf
+of whom a Contribution has been received by Licensor and subsequently
+incorporated within the Work.
+
+#### 2. Grant of Copyright License
+
+Subject to the terms and conditions of this License, each Contributor hereby
+grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,
+irrevocable copyright license to reproduce, prepare Derivative Works of,
+publicly display, publicly perform, sublicense, and distribute the Work and such
+Derivative Works in Source or Object form.
+
+#### 3. Grant of Patent License
+
+Subject to the terms and conditions of this License, each Contributor hereby
+grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,
+irrevocable (except as stated in this section) patent license to make, have
+made, use, offer to sell, sell, import, and otherwise transfer the Work, where
+such license applies only to those patent claims licensable by such Contributor
+that are necessarily infringed by their Contribution(s) alone or by combination
+of their Contribution(s) with the Work to which such Contribution(s) was
+submitted. If You institute patent litigation against any entity (including a
+cross-claim or counterclaim in a lawsuit) alleging that the Work or a
+Contribution incorporated within the Work constitutes direct or contributory
+patent infringement, then any patent licenses granted to You under this License
+for that Work shall terminate as of the date such litigation is filed.
+
+#### 4. Redistribution
+
+You may reproduce and distribute copies of the Work or Derivative Works thereof
+in any medium, with or without modifications, and in Source or Object form,
+provided that You meet the following conditions:
+
+- **(a)** You must give any other recipients of the Work or Derivative Works a copy of
+ this License; and
+- **(b)** You must cause any modified files to carry prominent notices stating that You
+ changed the files; and
+- **(c)** You must retain, in the Source form of any Derivative Works that You distribute,
+ all copyright, patent, trademark, and attribution notices from the Source form
+ of the Work, excluding those notices that do not pertain to any part of the
+ Derivative Works; and
+- **(d)** If the Work includes a “NOTICE” text file as part of its distribution, then any
+ Derivative Works that You distribute must include a readable copy of the
+ attribution notices contained within such NOTICE file, excluding those notices
+ that do not pertain to any part of the Derivative Works, in at least one of the
+ following places: within a NOTICE text file distributed as part of the
+ Derivative Works; within the Source form or documentation, if provided along
+ with the Derivative Works; or, within a display generated by the Derivative
+ Works, if and wherever such third-party notices normally appear. The contents of
+ the NOTICE file are for informational purposes only and do not modify the
+ License. You may add Your own attribution notices within Derivative Works that
+ You distribute, alongside or as an addendum to the NOTICE text from the Work,
+ provided that such additional attribution notices cannot be construed as
+ modifying the License.
+
+You may add Your own copyright statement to Your modifications and may provide
+additional or different license terms and conditions for use, reproduction, or
+distribution of Your modifications, or for any such Derivative Works as a whole,
+provided Your use, reproduction, and distribution of the Work otherwise complies
+with the conditions stated in this License.
+
+#### 5. Submission of Contributions
+
+Unless You explicitly state otherwise, any Contribution intentionally submitted
+for inclusion in the Work by You to the Licensor shall be under the terms and
+conditions of this License, without any additional terms or conditions.
+Notwithstanding the above, nothing herein shall supersede or modify the terms of
+any separate license agreement you may have executed with Licensor regarding
+such Contributions.
+
+#### 6. Trademarks
+
+This License does not grant permission to use the trade names, trademarks,
+service marks, or product names of the Licensor, except as required for
+reasonable and customary use in describing the origin of the Work and
+reproducing the content of the NOTICE file.
+
+#### 7. Disclaimer of Warranty
+
+Unless required by applicable law or agreed to in writing, Licensor provides the
+Work (and each Contributor provides its Contributions) on an “AS IS” BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied,
+including, without limitation, any warranties or conditions of TITLE,
+NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are
+solely responsible for determining the appropriateness of using or
+redistributing the Work and assume any risks associated with Your exercise of
+permissions under this License.
+
+#### 8. Limitation of Liability
+
+In no event and under no legal theory, whether in tort (including negligence),
+contract, or otherwise, unless required by applicable law (such as deliberate
+and grossly negligent acts) or agreed to in writing, shall any Contributor be
+liable to You for damages, including any direct, indirect, special, incidental,
+or consequential damages of any character arising as a result of this License or
+out of the use or inability to use the Work (including but not limited to
+damages for loss of goodwill, work stoppage, computer failure or malfunction, or
+any and all other commercial damages or losses), even if such Contributor has
+been advised of the possibility of such damages.
+
+#### 9. Accepting Warranty or Additional Liability
+
+While redistributing the Work or Derivative Works thereof, You may choose to
+offer, and charge a fee for, acceptance of support, warranty, indemnity, or
+other liability obligations and/or rights consistent with this License. However,
+in accepting such obligations, You may act only on Your own behalf and on Your
+sole responsibility, not on behalf of any other Contributor, and only if You
+agree to indemnify, defend, and hold each Contributor harmless for any liability
+incurred by, or claims asserted against, such Contributor by reason of your
+accepting any such warranty or additional liability.
+
+_END OF TERMS AND CONDITIONS_
+
+### APPENDIX: Apply the Apache License to a specific file
+
+To apply the Apache License to an individual file, attach the following notice.
+The text should be enclosed in the appropriate comment syntax for the file
+format.
+
+ Copyright © 2025–, HASH
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/libs/@hashintel/petrinaut-cli/LICENSE-MIT.md b/libs/@hashintel/petrinaut-cli/LICENSE-MIT.md
new file mode 100644
index 00000000000..d85585ee20d
--- /dev/null
+++ b/libs/@hashintel/petrinaut-cli/LICENSE-MIT.md
@@ -0,0 +1,21 @@
+# MIT License
+
+Copyright © 2025–, HASH
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/libs/@hashintel/petrinaut-cli/LICENSE.md b/libs/@hashintel/petrinaut-cli/LICENSE.md
new file mode 100644
index 00000000000..6dad94c0e5a
--- /dev/null
+++ b/libs/@hashintel/petrinaut-cli/LICENSE.md
@@ -0,0 +1,3 @@
+# License
+
+Licensed under either of the [Apache License, Version 2.0](LICENSE-APACHE.md) or [MIT license](LICENSE-MIT.md) at your option.
diff --git a/libs/@hashintel/petrinaut-cli/MODEL_EXAMPLES.md b/libs/@hashintel/petrinaut-cli/MODEL_EXAMPLES.md
new file mode 100644
index 00000000000..6ecb8d03ac7
--- /dev/null
+++ b/libs/@hashintel/petrinaut-cli/MODEL_EXAMPLES.md
@@ -0,0 +1,168 @@
+# Petrinaut CLI model examples
+
+These examples use `PetrinautClient` from
+[`examples/python_stdio.py`](./examples/python_stdio.py). Build the CLI first:
+
+```bash
+turbo --filter @hashintel/petrinaut-cli build
+```
+
+From a script run at the HASH repository root, import the example wrapper with:
+
+```python
+import sys
+from pathlib import Path
+
+examples = Path("libs/@hashintel/petrinaut-cli/examples").resolve()
+sys.path.insert(0, str(examples))
+
+from python_stdio import PetrinautClient
+```
+
+Each client owns one long-lived CLI process and one compiled model. Create a
+new client when changing models.
+
+## SIR: parameters and uncolored tokens
+
+The SIR model has two parameters, three uncolored places, and one metric.
+Uncolored initial states are supplied as token counts.
+
+```python
+with PetrinautClient(
+ model=Path("libs/@hashintel/petrinaut-cli/examples/sir-model.json")
+) as client:
+ result = client.run(
+ 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,
+ )
+
+ infected_fraction = result["metrics"]["Infected Fraction"]
+```
+
+## Satellite launcher: multiple colored tokens
+
+`Space` and `Debris` contain `Satellite` tokens. Each token supplies the
+attributes defined by that color: `x`, `y`, `direction`, and `velocity`.
+
+```python
+with PetrinautClient(
+ model=Path(
+ "libs/@hashintel/petrinaut-cli/examples/satellites-launcher.json"
+ )
+) as client:
+ result = client.run(
+ parameters={
+ "gravitational_constant": 400_000,
+ "satellite_radius": 4,
+ },
+ initialState={
+ "Space": [
+ {"x": 90, "y": 0, "direction": 1.5708, "velocity": 67},
+ {"x": -90, "y": 0, "direction": -1.5708, "velocity": 67},
+ ],
+ "Debris": [],
+ },
+ metrics=["Satellites in orbit", "Average orbital speed"],
+ maxSteps=100,
+ dt=0.1,
+ seed=4242,
+ )
+
+ satellites_in_orbit = result["metrics"]["Satellites in orbit"]
+```
+
+An array represents multiple tokens in the same place. All tokens in one place
+use that place's color schema.
+
+## Supply chain: uncolored and different colored places
+
+This model combines token counts with several colors. `InboundShipments`,
+`OpenOrders`, and `MachineUp` each use a different token schema.
+
+```python
+with PetrinautClient(
+ model=Path(
+ "libs/@hashintel/petrinaut-cli/examples/supply-chain-with-disruption.json"
+ )
+) as client:
+ result = client.run(
+ parameters={
+ "demand_rate": 0.5,
+ "production_rate": 0.9,
+ "machine_breakdown_rate": 0.01,
+ },
+ initialState={
+ # Uncolored places use counts.
+ "SupplierAAvailable": 1,
+ "SupplierBAvailable": 1,
+ "RawMaterials": 8,
+ "FinishedGoods": 12,
+ # Shipment tokens.
+ "InboundShipments": [
+ {"eta": 2, "risk_score": 0.2, "source": 1, "cost": 16},
+ {"eta": 4, "risk_score": 0.4, "source": 2, "cost": 12},
+ ],
+ # Customer order tokens.
+ "OpenOrders": [
+ {"age": 1, "priority": 2, "promised_lead_time": 3},
+ ],
+ # Factory machine tokens.
+ "MachineUp": [
+ {"health": 0.95, "wear": 0.05},
+ ],
+ },
+ metrics=["Service level", "Factory available"],
+ maxSteps=100,
+ dt=0.1,
+ seed=4242,
+ )
+
+ service_level = result["metrics"]["Service level"]
+```
+
+## Discovering the expected inputs
+
+Call `metadata` after starting a client:
+
+```python
+metadata = client.metadata()
+
+for parameter in metadata["parameters"]:
+ print(parameter["variableName"], parameter["type"])
+
+for place in metadata["places"]:
+ print(place["name"], place["color"])
+
+for metric in metadata["metrics"]:
+ print(metric["id"], metric["name"])
+```
+
+Parameters, places, and metrics may be referenced by their IDs or names where
+documented. Prefer parameter variable names and place/metric display names in
+small Python integrations because they are easier to read.
+
+## Direct JSON-lines usage
+
+The Python wrapper sends the same protocol that can be used directly:
+
+```bash
+printf '%s\n' \
+ '{"id":1,"method":"run","params":{"initialState":{"Susceptible":990,"Infected":10},"metrics":["Infected Fraction"],"maxSteps":10,"seed":4242}}' \
+ | node libs/@hashintel/petrinaut-cli/dist/cli.js serve \
+ --model libs/@hashintel/petrinaut-cli/examples/sir-model.json \
+ --stdio
+```
+
+The process remains available until stdin is closed, so a Python integration
+should keep it open and send many requests rather than starting it per run.
diff --git a/libs/@hashintel/petrinaut-cli/PYTHON_INTEGRATION.md b/libs/@hashintel/petrinaut-cli/PYTHON_INTEGRATION.md
new file mode 100644
index 00000000000..4f0c253ac3c
--- /dev/null
+++ b/libs/@hashintel/petrinaut-cli/PYTHON_INTEGRATION.md
@@ -0,0 +1,172 @@
+# Using Petrinaut CLI from Python
+
+Petrinaut CLI is a long-lived Node.js process. Start it once for one model,
+then reuse it for every Optuna trial. The model and its TypeScript/HIR code are
+compiled, type-checked, and engine-preflighted before the CLI becomes ready.
+Every advertised metric must also compile; every `run` gets fresh simulation
+state.
+
+See [Petrinaut CLI model examples](./MODEL_EXAMPLES.md) for complete requests
+using parameters, uncolored tokens, and several colored token schemas.
+
+## Build
+
+From the HASH repository root:
+
+```bash
+turbo --filter @hashintel/petrinaut-cli build
+```
+
+The executable is then available at:
+
+```text
+libs/@hashintel/petrinaut-cli/dist/cli.js
+```
+
+## Python wrapper
+
+The CLI uses JSON Lines over stdin/stdout. Send one JSON object per line and
+read one response per line. Keep stdout for protocol messages; CLI lifecycle
+messages are written to stderr.
+
+```python
+import json
+import subprocess
+
+
+class PetrinautClient:
+ def __init__(self, cli_path: str, model_path: str):
+ self.process = subprocess.Popen(
+ ["node", cli_path, "serve", "--model", model_path, "--stdio"],
+ stdin=subprocess.PIPE,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ bufsize=1,
+ )
+ self.next_id = 1
+
+ def request(self, method: str, params=None):
+ request = {"id": self.next_id, "method": method}
+ self.next_id += 1
+ if params is not None:
+ request["params"] = params
+
+ self.process.stdin.write(json.dumps(request) + "\n")
+ self.process.stdin.flush()
+
+ line = self.process.stdout.readline()
+ if not line:
+ raise RuntimeError(self.process.stderr.read() or "Petrinaut exited")
+
+ response = json.loads(line)
+ if "error" in response:
+ raise RuntimeError(response["error"]["message"])
+ return response["result"]
+
+ def metadata(self):
+ return self.request("metadata")
+
+ def run(self, **params):
+ return self.request("run", params)
+
+ def close(self):
+ self.process.stdin.close()
+ self.process.wait(timeout=5)
+```
+
+Start one client and reuse it:
+
+```python
+client = PetrinautClient(
+ cli_path="libs/@hashintel/petrinaut-cli/dist/cli.js",
+ model_path="libs/@hashintel/petrinaut-cli/examples/sir-model.json",
+)
+
+metadata = client.metadata()
+
+result = client.run(
+ 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_value = result["metrics"]["Infected Fraction"]
+client.close()
+```
+
+## Optuna usage
+
+The client must be created outside the objective so Node and the model are not
+restarted for every trial:
+
+```python
+import optuna
+
+client = PetrinautClient(CLI_PATH, MODEL_PATH)
+
+def objective(trial):
+ result = client.run(
+ parameters={
+ "infection_rate": trial.suggest_float("infection_rate", 0.1, 2.0),
+ "recovery_rate": trial.suggest_float("recovery_rate", 0.1, 1.0),
+ },
+ initialState={"Susceptible": 990, "Infected": 10, "Recovered": 0},
+ metrics=["Infected Fraction"],
+ maxSteps=100,
+ dt=0.1,
+ seed=4242,
+ )
+ return result["metrics"]["Infected Fraction"]
+
+study = optuna.create_study(direction="minimize")
+study.optimize(objective, n_trials=100, n_jobs=1)
+client.close()
+```
+
+One CLI process executes requests serially, so use `n_jobs=1` with one client.
+For parallel trials, create one client/process per Optuna worker.
+
+## Inputs and outputs
+
+- `parameters`: keys may be parameter variable names, IDs, or display names.
+ Exact IDs take priority, then variable names, then display names; duplicate
+ aliases use the model's last entry.
+- `initialState`: keys may be place IDs or display names. Exact IDs take
+ priority; duplicate display names use the model's last entry.
+- Uncolored places accept an integer token count from `0` through
+ `4,294,967,295`, for example `"Infected": 10`.
+- Colored places accept one object per token. Different places may use
+ different color schemas:
+
+ ```python
+ initialState={
+ "InboundShipments": [
+ {"eta": 1, "risk_score": 0.2, "source": 1, "cost": 10},
+ {"eta": 2, "risk_score": 0.4, "source": 2, "cost": 12},
+ ],
+ "MachineUp": [
+ {"health": 0.9, "wear": 0.1},
+ ],
+ }
+ ```
+
+- `metrics`: model metric names or IDs. Multiple metrics may be requested;
+ metric display names must be unique because results are keyed by name.
+- `maxSteps` or `maxTime`: simulation stopping condition. When `maxTime` stops
+ the run, the last step is shortened so `finalTime` equals it exactly.
+- `dt`: simulation time step; defaults to `1`.
+- `seed`: optional deterministic integer seed from `0` through
+ `2,147,483,647`.
+
+Metrics are evaluated on the final frame. The response also includes the seed,
+completion reason, final time, frame count, and final token count for each root
+place advertised by `metadata`. Internal subnet places are not returned. Use
+`metadata` first to discover the model's parameters, places, colors, and
+available metrics.
+
+A runnable version of the wrapper is available in
+[`examples/python_stdio.py`](./examples/python_stdio.py).
diff --git a/libs/@hashintel/petrinaut-cli/README.md b/libs/@hashintel/petrinaut-cli/README.md
new file mode 100644
index 00000000000..6e77fa6ccb4
--- /dev/null
+++ b/libs/@hashintel/petrinaut-cli/README.md
@@ -0,0 +1,219 @@
+# `@hashintel/petrinaut-cli`
+
+Internal JSON-lines 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 supports stdin/stdout by default or an explicit Unix socket. It does not
+expose HTTP or TCP.
+
+The process reports ready only after TypeScript/HIR compilation and an engine
+preflight have succeeded, including every metric advertised by `metadata`.
+
+See [Using Petrinaut CLI from Python](./PYTHON_INTEGRATION.md) for a compact
+stdio wrapper and Optuna example.
+
+## Transports
+
+With no transport flag, JSON lines are read from stdin and written to stdout:
+
+```bash
+petrinaut serve --model ./model.json
+```
+
+The explicit equivalent is `--stdio`. To use a Unix socket instead, pass
+`--socket `:
+
+```bash
+petrinaut serve --model ./model.json --socket /tmp/petrinaut.sock
+```
+
+`--stdio` and `--socket` are mutually exclusive; passing both is an error.
+
+## Example Flow
+
+Build first:
+
+```bash
+turbo --filter @hashintel/petrinaut-cli build
+```
+
+Start the model process with the Unix-socket transport:
+
+```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/`.
+
+### Python stdio client
+
+`examples/python_stdio.py` launches the CLI using its native JSON-lines stdio
+transport. It uses only the Python standard library.
+
+After building the CLI, run the complete SIR example:
+
+```bash
+python3 libs/@hashintel/petrinaut-cli/examples/python_stdio.py --demo
+```
+
+Or forward raw protocol requests over stdio:
+
+```bash
+printf '%s\n' \
+ '{"id":1,"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}}' \
+ | python3 libs/@hashintel/petrinaut-cli/examples/python_stdio.py \
+ --model libs/@hashintel/petrinaut-cli/examples/sir-model.json
+```
+
+The `PetrinautClient` class can also be imported directly into an Optuna
+script and reused across trials.
+
+## JSON-lines Protocol
+
+Send one JSON object per line and read one JSON object per line back over the
+selected transport.
+
+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. Exact ids take priority, followed by variable
+ names and then display names; duplicate aliases use the model's last entry.
+ Real values must be finite, integers must be integral, and booleans must be
+ JSON booleans or the strings `"true"` and `"false"`.
+- `initialState`: initial markings. Keys may be place id or display name.
+ Exact ids take priority; duplicate display names use the model's last entry.
+ Uncolored token counts must be integers from `0` through `4,294,967,295`.
+- `metrics`: metric names/ids evaluated on the final frame.
+- `maxSteps`: optional maximum number of simulation steps.
+- `maxTime`: optional maximum simulation time. At least one of `maxSteps` or
+ `maxTime` is required. The last step is shortened when necessary so a
+ `maxTime` completion returns that exact `finalTime`. `dt` defaults to `1` if
+ omitted.
+- `seed`: optional deterministic integer seed from `0` through
+ `2,147,483,647`.
+
+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.
+Metric display names must be unique because result values are keyed by name.
+
+`finalPlaceTokenCounts` contains the root places advertised by `metadata`.
+Internal places created by subnet expansion are not part of the CLI contract.
+
+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/python_stdio.py b/libs/@hashintel/petrinaut-cli/examples/python_stdio.py
new file mode 100644
index 00000000000..f6c8565c428
--- /dev/null
+++ b/libs/@hashintel/petrinaut-cli/examples/python_stdio.py
@@ -0,0 +1,193 @@
+#!/usr/bin/env python3
+"""Small Python client for the Petrinaut CLI's native stdio transport.
+
+The wrapper starts the CLI, sends one JSON request per stdin line, and writes
+one JSON response per stdout line. It has no third-party dependencies.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import subprocess
+import sys
+from pathlib import Path
+from typing import Any
+
+
+PACKAGE_ROOT = Path(__file__).resolve().parent.parent
+DEFAULT_CLI = PACKAGE_ROOT / "dist" / "cli.js"
+DEFAULT_MODEL = PACKAGE_ROOT / "examples" / "sir-model.json"
+
+
+class PetrinautClient:
+ """Owns one Petrinaut process connected over stdin/stdout."""
+
+ def __init__(
+ self,
+ model: Path,
+ cli: Path = DEFAULT_CLI,
+ node: str = "node",
+ ) -> None:
+ self.model = model.resolve()
+ self.cli = cli.resolve()
+ self.node = node
+ self._process: subprocess.Popen[str] | None = None
+ self._next_id = 1
+
+ def __enter__(self) -> "PetrinautClient":
+ self.start()
+ return self
+
+ def __exit__(self, *_args: object) -> None:
+ self.close()
+
+ def start(self) -> None:
+ if not self.cli.is_file():
+ raise FileNotFoundError(
+ f"Petrinaut CLI not found at {self.cli}. Build it first."
+ )
+ if not self.model.is_file():
+ raise FileNotFoundError(f"Model not found at {self.model}")
+
+ self._process = subprocess.Popen(
+ [
+ self.node,
+ str(self.cli),
+ "serve",
+ "--model",
+ str(self.model),
+ "--stdio",
+ ],
+ stdin=subprocess.PIPE,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ bufsize=1,
+ )
+ if self._process.stderr is None:
+ raise RuntimeError("Petrinaut stderr is unavailable")
+ status = self._process.stderr.readline()
+ if not status.startswith("Petrinaut stdio ready"):
+ details = status + self._process.stderr.read()
+ self.close()
+ raise RuntimeError(f"Petrinaut failed to start:\n{details.strip()}")
+
+ def exchange(self, request: dict[str, Any]) -> dict[str, Any]:
+ """Send one raw protocol request and return its raw response."""
+ if (
+ self._process is None
+ or self._process.stdin is None
+ or self._process.stdout is None
+ ):
+ raise RuntimeError("PetrinautClient has not been started")
+
+ self._process.stdin.write(json.dumps(request) + "\n")
+ self._process.stdin.flush()
+ line = self._process.stdout.readline()
+ if not line:
+ stderr = (
+ self._process.stderr.read() if self._process.stderr is not None else ""
+ )
+ raise RuntimeError(
+ f"Petrinaut exited without a response:\n{stderr.strip()}"
+ )
+
+ response = json.loads(line)
+ if not isinstance(response, dict):
+ raise RuntimeError("Petrinaut returned a non-object response")
+ return response
+
+ def request(self, method: str, params: dict[str, Any] | None = None) -> Any:
+ """Call one method and raise a Python exception for protocol errors."""
+ request: dict[str, Any] = {"id": self._next_id, "method": method}
+ self._next_id += 1
+ if params is not None:
+ request["params"] = params
+
+ response = self.exchange(request)
+ if "error" in response:
+ error = response["error"]
+ message = error.get("message", error) if isinstance(error, dict) else error
+ raise RuntimeError(str(message))
+ return response.get("result")
+
+ def metadata(self) -> dict[str, Any]:
+ return self.request("metadata")
+
+ def run(self, **params: Any) -> dict[str, Any]:
+ return self.request("run", params)
+
+ def close(self) -> None:
+ if self._process is not None:
+ if self._process.stdin is not None:
+ self._process.stdin.close()
+ if self._process.poll() is None:
+ try:
+ self._process.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ self._process.terminate()
+ self._process.wait()
+ if self._process.stdout is not None:
+ self._process.stdout.close()
+ if self._process.stderr is not None:
+ self._process.stderr.close()
+ self._process = None
+
+
+def run_sir_demo(client: PetrinautClient) -> None:
+ metadata = client.metadata()
+ result = client.run(
+ 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,
+ )
+ print(
+ json.dumps(
+ {
+ "metricNames": [metric["name"] for metric in metadata["metrics"]],
+ "result": result,
+ },
+ indent=2,
+ )
+ )
+
+
+def bridge_stdio(client: PetrinautClient) -> None:
+ for line in sys.stdin:
+ if not line.strip():
+ continue
+ try:
+ request = json.loads(line)
+ if not isinstance(request, dict):
+ raise ValueError("Request must be a JSON object")
+ response = client.exchange(request)
+ except Exception as error: # Keep the bridge alive for the next request.
+ response = {"id": None, "error": {"message": str(error)}}
+ print(json.dumps(response), flush=True)
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--model", type=Path, default=DEFAULT_MODEL)
+ parser.add_argument("--cli", type=Path, default=DEFAULT_CLI)
+ parser.add_argument("--node", default="node")
+ parser.add_argument(
+ "--demo",
+ action="store_true",
+ help="Run a complete SIR example instead of forwarding stdin.",
+ )
+ args = parser.parse_args()
+
+ with PetrinautClient(model=args.model, cli=args.cli, node=args.node) as client:
+ if args.demo:
+ run_sir_demo(client)
+ else:
+ bridge_stdio(client)
+
+
+if __name__ == "__main__":
+ main()
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..9c00d9f4707
--- /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 { collision_threshold, 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 when the satellite surfaces are within the threshold\n return distance < satellite_radius * 2 + collision_threshold;\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..64c59bb5c06
--- /dev/null
+++ b/libs/@hashintel/petrinaut-cli/package.json
@@ -0,0 +1,33 @@
+{
+ "name": "@hashintel/petrinaut-cli",
+ "version": "0.0.0-private",
+ "private": true,
+ "description": "Internal JSON-lines 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",
+ "test:unit": "vitest"
+ },
+ "dependencies": {
+ "@hashintel/petrinaut-core": "workspace:*",
+ "typescript": "5.9.3"
+ },
+ "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",
+ "vitest": "4.1.8"
+ }
+}
diff --git a/libs/@hashintel/petrinaut-cli/src/cli.ts b/libs/@hashintel/petrinaut-cli/src/cli.ts
new file mode 100644
index 00000000000..6fdcc814941
--- /dev/null
+++ b/libs/@hashintel/petrinaut-cli/src/cli.ts
@@ -0,0 +1,69 @@
+import { parseArgs } from "node:util";
+
+import { serve } from "./commands/serve";
+import { serveStdio } from "./commands/stdio";
+
+function printUsage(): void {
+ process.stderr.write(`Usage:
+ petrinaut serve --model [--stdio | --socket ]
+
+Transports:
+ --stdio JSON lines over stdin/stdout (default)
+ --socket JSON lines over a 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" },
+ stdio: { type: "boolean" },
+ help: { type: "boolean", short: "h" },
+ },
+ allowPositionals: false,
+ });
+
+ if (parsed.values.stdio && parsed.values.socket !== undefined) {
+ throw new Error("--stdio and --socket cannot be used together");
+ }
+ if (parsed.values.help) {
+ printUsage();
+ return;
+ }
+ if (!parsed.values.model) {
+ throw new Error("Missing required --model ");
+ }
+
+ if (parsed.values.socket !== undefined) {
+ if (parsed.values.socket.trim() === "") {
+ throw new Error("--socket requires a non-empty path");
+ }
+ await serve({
+ modelPath: parsed.values.model,
+ socketPath: parsed.values.socket,
+ });
+ } else {
+ await serveStdio({ modelPath: parsed.values.model });
+ }
+}
+
+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..2bd5c4d9783
--- /dev/null
+++ b/libs/@hashintel/petrinaut-cli/src/commands/serve.ts
@@ -0,0 +1,149 @@
+import { existsSync, unlinkSync } from "node:fs";
+import { createServer, type Socket } from "node:net";
+import { resolve } from "node:path";
+
+import { compilePetrinautModel } from "@hashintel/petrinaut-core/compiled-model";
+
+import { consumeBufferedJsonLines } from "../runtime/json-lines";
+import { loadSdcpnModel } from "../runtime/load-model";
+import {
+ handleProtocolLine,
+ MAX_REQUEST_LINE_BYTES,
+} from "../runtime/protocol";
+
+import type { Writable } from "node:stream";
+
+type ServeOptions = {
+ modelPath: string;
+ socketPath: string;
+ signal?: AbortSignal;
+ errorOutput?: Writable;
+};
+
+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 = "";
+
+ socket.setEncoding("utf8");
+ socket.on("data", (chunk) => {
+ buffer += chunk;
+ const bufferedLines = consumeBufferedJsonLines(
+ buffer,
+ MAX_REQUEST_LINE_BYTES,
+ );
+ buffer = bufferedLines.remainder;
+
+ for (const line of bufferedLines.lines) {
+ handleProtocolLine(model, line, (value) =>
+ writeResponse(socket, value),
+ );
+ }
+
+ if (bufferedLines.requestTooLarge) {
+ buffer = "";
+ socket.pause();
+ socket.end(
+ `${JSON.stringify({
+ id: null,
+ error: { message: "Request line is too large" },
+ })}\n`,
+ );
+ }
+ });
+
+ socket.on("end", () => {
+ if (buffer.trim() !== "") {
+ if (Buffer.byteLength(buffer, "utf8") > MAX_REQUEST_LINE_BYTES) {
+ writeResponse(socket, {
+ id: null,
+ error: { message: "Request line is too large" },
+ });
+ return;
+ }
+ handleProtocolLine(model, buffer, (value) =>
+ writeResponse(socket, value),
+ );
+ }
+ });
+ 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);
+ });
+
+ (options.errorOutput ?? process.stderr).write(
+ `Petrinaut socket ready at ${options.socketPath} for model ${modelPath}\n`,
+ );
+
+ await new Promise((resolveShutdown) => {
+ let shuttingDown = false;
+ const removeShutdownListeners = (): void => {
+ process.off("SIGINT", shutdown);
+ process.off("SIGTERM", shutdown);
+ options.signal?.removeEventListener("abort", shutdown);
+ };
+ const shutdown = (): void => {
+ if (shuttingDown) {
+ return;
+ }
+ shuttingDown = true;
+ for (const socket of activeSockets) {
+ socket.destroy();
+ }
+ server.close(() => {
+ removeShutdownListeners();
+ resolveShutdown();
+ });
+ };
+ process.once("SIGINT", shutdown);
+ process.once("SIGTERM", shutdown);
+ options.signal?.addEventListener("abort", shutdown, { once: true });
+ if (options.signal?.aborted) {
+ shutdown();
+ }
+ });
+}
diff --git a/libs/@hashintel/petrinaut-cli/src/commands/stdio.ts b/libs/@hashintel/petrinaut-cli/src/commands/stdio.ts
new file mode 100644
index 00000000000..4bea7323c4f
--- /dev/null
+++ b/libs/@hashintel/petrinaut-cli/src/commands/stdio.ts
@@ -0,0 +1,46 @@
+import { resolve } from "node:path";
+import { createInterface } from "node:readline";
+
+import { compilePetrinautModel } from "@hashintel/petrinaut-core/compiled-model";
+
+import { loadSdcpnModel } from "../runtime/load-model";
+import {
+ handleProtocolLine,
+ MAX_REQUEST_LINE_BYTES,
+} from "../runtime/protocol";
+
+import type { Readable, Writable } from "node:stream";
+
+type ServeStdioOptions = {
+ modelPath: string;
+ input?: Readable;
+ output?: Writable;
+ errorOutput?: Writable;
+};
+
+function writeResponse(output: Writable, value: unknown): void {
+ output.write(`${JSON.stringify(value)}\n`);
+}
+
+export async function serveStdio(options: ServeStdioOptions): Promise {
+ const modelPath = resolve(options.modelPath);
+ const sdcpn = await loadSdcpnModel(modelPath);
+ const model = compilePetrinautModel({ sdcpn });
+ const input = options.input ?? process.stdin;
+ const output = options.output ?? process.stdout;
+ const errorOutput = options.errorOutput ?? process.stderr;
+ const lines = createInterface({ input, crlfDelay: Infinity });
+
+ errorOutput.write(`Petrinaut stdio ready for model ${modelPath}\n`);
+
+ for await (const line of lines) {
+ if (Buffer.byteLength(line, "utf8") > MAX_REQUEST_LINE_BYTES) {
+ writeResponse(output, {
+ id: null,
+ error: { message: "Request line is too large" },
+ });
+ continue;
+ }
+ handleProtocolLine(model, line, (value) => writeResponse(output, value));
+ }
+}
diff --git a/libs/@hashintel/petrinaut-cli/src/commands/transports.test.ts b/libs/@hashintel/petrinaut-cli/src/commands/transports.test.ts
new file mode 100644
index 00000000000..cac97782d1b
--- /dev/null
+++ b/libs/@hashintel/petrinaut-cli/src/commands/transports.test.ts
@@ -0,0 +1,221 @@
+import { existsSync } from "node:fs";
+import { mkdtemp, rm } from "node:fs/promises";
+import { createConnection } from "node:net";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { PassThrough } from "node:stream";
+import { fileURLToPath } from "node:url";
+
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { MAX_REQUEST_LINE_BYTES } from "../runtime/protocol";
+import { serve } from "./serve";
+import { serveStdio } from "./stdio";
+
+const modelPath = fileURLToPath(
+ new URL("../../examples/sir-model.json", import.meta.url),
+);
+const coloredModelPath = fileURLToPath(
+ new URL("../../examples/satellites-launcher.json", import.meta.url),
+);
+const temporaryDirectories: string[] = [];
+
+function parseResponses(output: string): unknown[] {
+ return output
+ .trim()
+ .split("\n")
+ .filter(Boolean)
+ .map((line) => JSON.parse(line));
+}
+
+afterEach(async () => {
+ await Promise.all(
+ temporaryDirectories
+ .splice(0)
+ .map((path) => rm(path, { recursive: true, force: true })),
+ );
+});
+
+describe("CLI transports", () => {
+ it("exchanges metadata and run requests over stdio", async () => {
+ const input = new PassThrough();
+ const output = new PassThrough();
+ const errorOutput = new PassThrough();
+ let stdout = "";
+ output.setEncoding("utf8");
+ output.on("data", (chunk: string) => {
+ stdout += chunk;
+ });
+
+ const serving = serveStdio({ modelPath, input, output, errorOutput });
+ input.end(
+ [
+ JSON.stringify({ id: 1, method: "metadata" }),
+ JSON.stringify({
+ id: 2,
+ method: "run",
+ params: { maxSteps: 0, seed: 42 },
+ }),
+ "",
+ ].join("\n"),
+ );
+ await serving;
+
+ const responses = parseResponses(stdout);
+ expect(responses).toHaveLength(2);
+ expect(responses[0]).toMatchObject({
+ id: 1,
+ result: { parameters: expect.any(Array), places: expect.any(Array) },
+ });
+ expect(responses[1]).toMatchObject({
+ id: 2,
+ result: { seed: 42, completionReason: "maxSteps" },
+ });
+ });
+
+ it("simulates multiple colored tokens over stdio", async () => {
+ const input = new PassThrough();
+ const output = new PassThrough();
+ let stdout = "";
+ output.setEncoding("utf8");
+ output.on("data", (chunk: string) => {
+ stdout += chunk;
+ });
+
+ const serving = serveStdio({
+ modelPath: coloredModelPath,
+ input,
+ output,
+ errorOutput: new PassThrough(),
+ });
+ input.end(
+ `${JSON.stringify({
+ id: 1,
+ method: "run",
+ params: {
+ initialState: {
+ Space: [
+ { x: 90, y: 0, direction: 1.5708, velocity: 67 },
+ { x: -90, y: 0, direction: -1.5708, velocity: 67 },
+ ],
+ Debris: [],
+ },
+ metrics: ["Satellites in orbit"],
+ maxSteps: 1,
+ dt: 0.1,
+ seed: 4242,
+ },
+ })}\n`,
+ );
+ await serving;
+
+ expect(parseResponses(stdout)).toMatchObject([
+ {
+ id: 1,
+ result: {
+ seed: 4242,
+ finalPlaceTokenCounts: {
+ "3cbc7944-34cb-4eeb-b779-4e392a171fe1": 2,
+ "ea42ba61-03ea-4940-b2e2-b594d5331a71": 0,
+ },
+ metrics: { "Satellites in orbit": 2 },
+ },
+ },
+ ]);
+ });
+
+ it("handles chunked and trailing requests over a Unix socket", async () => {
+ const directory = await mkdtemp(join(tmpdir(), "petrinaut-cli-"));
+ temporaryDirectories.push(directory);
+ const socketPath = join(directory, "petrinaut.sock");
+ const controller = new AbortController();
+ const serving = serve({
+ modelPath,
+ socketPath,
+ signal: controller.signal,
+ errorOutput: new PassThrough(),
+ });
+
+ try {
+ await vi.waitFor(() => expect(existsSync(socketPath)).toBe(true));
+
+ const output = await new Promise((resolveOutput, reject) => {
+ const socket = createConnection(socketPath);
+ let response = "";
+ socket.setEncoding("utf8");
+ socket.on("data", (chunk) => {
+ response += chunk;
+ });
+ socket.once("error", reject);
+ socket.once("end", () => resolveOutput(response));
+ socket.once("connect", () => {
+ socket.write('{"id":1,"method":"meta');
+ socket.write('data"}\n');
+ socket.end(
+ JSON.stringify({
+ id: 2,
+ method: "run",
+ params: { maxSteps: 0, seed: 42 },
+ }),
+ );
+ });
+ });
+
+ const responses = parseResponses(output);
+ expect(responses).toHaveLength(2);
+ expect(responses[0]).toMatchObject({ id: 1, result: {} });
+ expect(responses[1]).toMatchObject({
+ id: 2,
+ result: { seed: 42, completionReason: "maxSteps" },
+ });
+ } finally {
+ controller.abort();
+ await serving;
+ }
+
+ expect(existsSync(socketPath)).toBe(false);
+ });
+
+ it("flushes queued socket responses before closing an oversized request", async () => {
+ const directory = await mkdtemp(join(tmpdir(), "petrinaut-cli-"));
+ temporaryDirectories.push(directory);
+ const socketPath = join(directory, "petrinaut.sock");
+ const controller = new AbortController();
+ const serving = serve({
+ modelPath,
+ socketPath,
+ signal: controller.signal,
+ errorOutput: new PassThrough(),
+ });
+
+ try {
+ await vi.waitFor(() => expect(existsSync(socketPath)).toBe(true));
+
+ const output = await new Promise((resolveOutput, reject) => {
+ const socket = createConnection(socketPath);
+ let response = "";
+ socket.setEncoding("utf8");
+ socket.on("data", (chunk) => {
+ response += chunk;
+ });
+ socket.once("error", reject);
+ socket.once("end", () => resolveOutput(response));
+ socket.once("connect", () => {
+ socket.write(`${JSON.stringify({ id: 1, method: "metadata" })}\n`);
+ socket.write("x".repeat(MAX_REQUEST_LINE_BYTES + 1));
+ });
+ });
+
+ const responses = parseResponses(output);
+ expect(responses).toHaveLength(2);
+ expect(responses[0]).toMatchObject({ id: 1, result: {} });
+ expect(responses[1]).toEqual({
+ id: null,
+ error: { message: "Request line is too large" },
+ });
+ } finally {
+ controller.abort();
+ await serving;
+ }
+ });
+});
diff --git a/libs/@hashintel/petrinaut-cli/src/runtime/json-lines.test.ts b/libs/@hashintel/petrinaut-cli/src/runtime/json-lines.test.ts
new file mode 100644
index 00000000000..7c77a6be814
--- /dev/null
+++ b/libs/@hashintel/petrinaut-cli/src/runtime/json-lines.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it } from "vitest";
+
+import { consumeBufferedJsonLines } from "./json-lines";
+
+describe("consumeBufferedJsonLines", () => {
+ it("applies the byte limit to each line instead of the combined buffer", () => {
+ expect(consumeBufferedJsonLines("12345678\nabcdefgh\n", 10)).toEqual({
+ lines: ["12345678", "abcdefgh"],
+ remainder: "",
+ requestTooLarge: false,
+ });
+ });
+
+ it("rejects an oversized complete or partial line", () => {
+ expect(consumeBufferedJsonLines("ok\n123456", 5)).toEqual({
+ lines: ["ok"],
+ remainder: "123456",
+ requestTooLarge: true,
+ });
+ expect(consumeBufferedJsonLines("123456\n", 5)).toEqual({
+ lines: [],
+ remainder: "",
+ requestTooLarge: true,
+ });
+ });
+});
diff --git a/libs/@hashintel/petrinaut-cli/src/runtime/json-lines.ts b/libs/@hashintel/petrinaut-cli/src/runtime/json-lines.ts
new file mode 100644
index 00000000000..50410dc089e
--- /dev/null
+++ b/libs/@hashintel/petrinaut-cli/src/runtime/json-lines.ts
@@ -0,0 +1,31 @@
+export type BufferedJsonLines = {
+ lines: string[];
+ remainder: string;
+ requestTooLarge: boolean;
+};
+
+export function consumeBufferedJsonLines(
+ buffer: string,
+ maxLineBytes: number,
+): BufferedJsonLines {
+ const lines: string[] = [];
+ let lineStart = 0;
+ let newlineIndex = buffer.indexOf("\n", lineStart);
+
+ while (newlineIndex !== -1) {
+ const line = buffer.slice(lineStart, newlineIndex);
+ if (Buffer.byteLength(line, "utf8") > maxLineBytes) {
+ return { lines, remainder: "", requestTooLarge: true };
+ }
+ lines.push(line);
+ lineStart = newlineIndex + 1;
+ newlineIndex = buffer.indexOf("\n", lineStart);
+ }
+
+ const remainder = buffer.slice(lineStart);
+ return {
+ lines,
+ remainder,
+ requestTooLarge: Buffer.byteLength(remainder, "utf8") > maxLineBytes,
+ };
+}
diff --git a/libs/@hashintel/petrinaut-cli/src/runtime/load-model.test.ts b/libs/@hashintel/petrinaut-cli/src/runtime/load-model.test.ts
new file mode 100644
index 00000000000..5929c579198
--- /dev/null
+++ b/libs/@hashintel/petrinaut-cli/src/runtime/load-model.test.ts
@@ -0,0 +1,33 @@
+import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+import { afterEach, describe, expect, it } from "vitest";
+
+import { loadSdcpnModel } from "./load-model";
+
+const temporaryDirectories: string[] = [];
+
+afterEach(async () => {
+ await Promise.all(
+ temporaryDirectories
+ .splice(0)
+ .map((path) => rm(path, { recursive: true, force: true })),
+ );
+});
+
+describe("loadSdcpnModel", () => {
+ it("rejects unsupported versioned files instead of loading them as raw snapshots", async () => {
+ const directory = await mkdtemp(join(tmpdir(), "petrinaut-cli-"));
+ temporaryDirectories.push(directory);
+ const path = join(directory, "future.json");
+ await writeFile(
+ path,
+ JSON.stringify({ version: 999, places: [], transitions: [] }),
+ );
+
+ await expect(loadSdcpnModel(path)).rejects.toThrow(
+ "Unsupported SDCPN file format version",
+ );
+ });
+});
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..6cbc88f253e
--- /dev/null
+++ b/libs/@hashintel/petrinaut-cli/src/runtime/load-model.ts
@@ -0,0 +1,16 @@
+import { readFile } from "node:fs/promises";
+
+import { parseSDCPNFile } from "@hashintel/petrinaut-core";
+
+import type { SDCPN } from "@hashintel/petrinaut-core";
+
+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;
+ }
+
+ throw new Error(parsed.error);
+}
diff --git a/libs/@hashintel/petrinaut-cli/src/runtime/protocol.test.ts b/libs/@hashintel/petrinaut-cli/src/runtime/protocol.test.ts
new file mode 100644
index 00000000000..4feba4674f0
--- /dev/null
+++ b/libs/@hashintel/petrinaut-cli/src/runtime/protocol.test.ts
@@ -0,0 +1,262 @@
+import { describe, expect, it, vi } from "vitest";
+
+import { handleProtocolLine } from "./protocol";
+
+import type { PetrinautCompiledModel } from "@hashintel/petrinaut-core/compiled-model";
+
+const metadata: PetrinautCompiledModel["metadata"] = {
+ parameters: [
+ {
+ id: "rate-id",
+ name: "Rate",
+ variableName: "rate",
+ type: "real",
+ defaultValue: "1",
+ valueRange: null,
+ },
+ {
+ id: "count-id",
+ name: "Count",
+ variableName: "count",
+ type: "integer",
+ defaultValue: "1",
+ valueRange: null,
+ },
+ {
+ id: "enabled-id",
+ name: "Enabled",
+ variableName: "enabled",
+ type: "boolean",
+ defaultValue: "false",
+ valueRange: null,
+ },
+ ],
+ places: [
+ { id: "plain", name: "Plain", index: 0, color: null },
+ {
+ id: "colored",
+ name: "Colored",
+ index: 1,
+ color: {
+ id: "color",
+ name: "Color",
+ elements: [
+ {
+ elementId: "value-id",
+ name: "value",
+ type: "real",
+ valueRange: null,
+ },
+ ],
+ },
+ },
+ ],
+ metrics: [
+ {
+ id: "metric-id",
+ name: "Metric",
+ optimizationObjective: null,
+ },
+ ],
+};
+
+function createModel(modelMetadata = metadata) {
+ const runMock = vi.fn(() => ({
+ seed: 42,
+ status: "complete" as const,
+ completionReason: "maxSteps" as const,
+ frameCount: 2,
+ finalTime: 1,
+ finalPlaceTokenCounts: { plain: 2, colored: 1 },
+ metrics: { Metric: 1 },
+ }));
+ return {
+ metadata: modelMetadata,
+ run: runMock,
+ runMock,
+ };
+}
+
+function send(model: PetrinautCompiledModel, request: unknown): unknown {
+ const responses: unknown[] = [];
+ handleProtocolLine(model, JSON.stringify(request), (response) => {
+ responses.push(response);
+ });
+ expect(responses).toHaveLength(1);
+ return responses[0];
+}
+
+describe("handleProtocolLine", () => {
+ it("handles healthz and metadata requests", () => {
+ const model = createModel();
+
+ expect(send(model, { id: 1, method: "healthz" })).toEqual({
+ id: 1,
+ result: { ok: true },
+ });
+ expect(send(model, { id: 2, method: "metadata" })).toEqual({
+ id: 2,
+ result: metadata,
+ });
+ });
+
+ it("normalizes a representative run request", () => {
+ const model = createModel();
+
+ expect(
+ send(model, {
+ id: "run-1",
+ method: "run",
+ params: {
+ parameters: { rate: 1.5, count: 3, enabled: true },
+ initialState: {
+ Plain: 2,
+ Colored: [{ value: 4 }],
+ },
+ metrics: ["Metric"],
+ maxSteps: 1,
+ dt: 0.25,
+ seed: 42,
+ },
+ }),
+ ).toMatchObject({ id: "run-1", result: { seed: 42 } });
+ expect(model.runMock).toHaveBeenCalledWith({
+ parameterValues: { rate: "1.5", count: "3", enabled: "true" },
+ initialMarking: { plain: 2, colored: [{ value: 4 }] },
+ metrics: ["Metric"],
+ maxSteps: 1,
+ dt: 0.25,
+ seed: 42,
+ });
+ });
+
+ it("prioritizes exact ids and uses last-wins name aliases", () => {
+ const model = createModel({
+ ...metadata,
+ parameters: [
+ {
+ id: "exact-parameter",
+ name: "Shared parameter",
+ variableName: "first_parameter",
+ type: "real",
+ defaultValue: "1",
+ valueRange: null,
+ },
+ {
+ id: "later-parameter",
+ name: "exact-parameter",
+ variableName: "later_parameter",
+ type: "real",
+ defaultValue: "1",
+ valueRange: null,
+ },
+ {
+ id: "last-parameter",
+ name: "Shared parameter",
+ variableName: "last_parameter",
+ type: "real",
+ defaultValue: "1",
+ valueRange: null,
+ },
+ ],
+ places: [
+ { id: "first-place", name: "Shared place", index: 0, color: null },
+ { id: "exact-place", name: "Other place", index: 1, color: null },
+ { id: "last-place", name: "Shared place", index: 2, color: null },
+ { id: "other-place", name: "exact-place", index: 3, color: null },
+ ],
+ });
+
+ expect(
+ send(model, {
+ id: 3,
+ method: "run",
+ params: {
+ parameters: {
+ "exact-parameter": 2,
+ "Shared parameter": 3,
+ },
+ initialState: {
+ "exact-place": 4,
+ "Shared place": 5,
+ },
+ maxSteps: 0,
+ },
+ }),
+ ).toMatchObject({ id: 3, result: { seed: 42 } });
+ expect(model.runMock).toHaveBeenCalledWith({
+ parameterValues: { first_parameter: "2", last_parameter: "3" },
+ initialMarking: { "exact-place": 4, "last-place": 5 },
+ metrics: [],
+ maxSteps: 0,
+ });
+ });
+
+ it.each([
+ [{}, "Run config requires either maxTime or maxSteps"],
+ [{ maxTime: null }, "Run config requires either maxTime or maxSteps"],
+ [{ parameters: [] }, "parameters must be an object"],
+ [{ initialState: "invalid" }, "initialState must be an object"],
+ [{ metrics: ["Metric", 1] }, "metrics must be an array of strings"],
+ [{ seed: "42" }, "seed must be a finite number"],
+ [{ dt: "0.1" }, "dt must be a finite number"],
+ [{ maxSteps: 1.5 }, "maxSteps must be a non-negative integer"],
+ ])("rejects invalid run field %#", (params, message) => {
+ const response = send(createModel(), { id: 7, method: "run", params });
+
+ expect(response).toEqual({ id: 7, error: { message } });
+ });
+
+ it("rejects markings incompatible with a place's color", () => {
+ expect(
+ send(createModel(), {
+ id: 8,
+ method: "run",
+ params: { initialState: { Colored: 1 }, maxSteps: 1 },
+ }),
+ ).toEqual({
+ id: 8,
+ error: {
+ message:
+ 'Initial marking for colored place "Colored" must be a token array',
+ },
+ });
+
+ expect(
+ send(createModel(), {
+ id: 9,
+ method: "run",
+ params: { initialState: { Plain: [{}] }, maxSteps: 1 },
+ }),
+ ).toEqual({
+ id: 9,
+ error: {
+ message:
+ 'Initial marking for uncolored place "Plain" must be a non-negative integer',
+ },
+ });
+ });
+
+ it("rejects uncolored token counts that overflow engine storage", () => {
+ expect(
+ send(createModel(), {
+ id: 10,
+ method: "run",
+ params: { initialState: { Plain: 4_294_967_296 }, maxSteps: 1 },
+ }),
+ ).toEqual({
+ id: 10,
+ error: {
+ message:
+ 'Initial marking for uncolored place "Plain" must not exceed 4294967295',
+ },
+ });
+ });
+
+ it("preserves request ids on protocol errors", () => {
+ expect(send(createModel(), { id: "bad", method: "unknown" })).toEqual({
+ id: "bad",
+ error: { message: 'Unknown method "unknown"' },
+ });
+ });
+});
diff --git a/libs/@hashintel/petrinaut-cli/src/runtime/protocol.ts b/libs/@hashintel/petrinaut-cli/src/runtime/protocol.ts
new file mode 100644
index 00000000000..7cbeb84c9d8
--- /dev/null
+++ b/libs/@hashintel/petrinaut-cli/src/runtime/protocol.ts
@@ -0,0 +1,62 @@
+import { parseServerRunRequest, toPetrinautRunConfig } from "./run-request";
+
+import type { PetrinautCompiledModel } from "@hashintel/petrinaut-core/compiled-model";
+
+export const MAX_REQUEST_LINE_BYTES = 10 * 1024 * 1024;
+
+type ProtocolRequest = {
+ id?: unknown;
+ method?: unknown;
+ params?: unknown;
+};
+
+function getErrorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : String(error);
+}
+
+export function handleProtocolLine(
+ model: PetrinautCompiledModel,
+ line: string,
+ writeResponse: (value: unknown) => void,
+): void {
+ if (line.trim() === "") {
+ return;
+ }
+
+ let id: unknown = null;
+ try {
+ const value: unknown = JSON.parse(line);
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
+ throw new Error("Request must be a JSON object");
+ }
+ const request = value as ProtocolRequest;
+ id = request.id ?? null;
+ if (typeof request.method !== "string") {
+ throw new Error("Request method must be a string");
+ }
+
+ switch (request.method) {
+ case "healthz":
+ writeResponse({ id, result: { ok: true } });
+ return;
+ case "metadata":
+ writeResponse({ id, result: model.metadata });
+ return;
+ case "run": {
+ const runRequest = parseServerRunRequest(request.params ?? {});
+ const result = model.run(
+ toPetrinautRunConfig(model.metadata, runRequest),
+ );
+ writeResponse({ id, result });
+ return;
+ }
+ default:
+ throw new Error(`Unknown method "${request.method}"`);
+ }
+ } catch (error) {
+ writeResponse({
+ id,
+ error: { message: getErrorMessage(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..b7a579ea721
--- /dev/null
+++ b/libs/@hashintel/petrinaut-cli/src/runtime/run-request.ts
@@ -0,0 +1,281 @@
+import type {
+ InitialMarking,
+ InitialPlaceMarking,
+} from "@hashintel/petrinaut-core";
+import type {
+ PetrinautCompiledModelMetadata,
+ PetrinautRunConfig,
+} from "@hashintel/petrinaut-core/compiled-model";
+
+type JsonRecord = Record;
+
+const RUN_REQUEST_KEYS = new Set([
+ "parameters",
+ "initialState",
+ "metrics",
+ "maxSteps",
+ "dt",
+ "maxTime",
+ "seed",
+]);
+
+const MAX_UNCOLORED_TOKEN_COUNT = 0xffff_ffff;
+
+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 parseOptionalFiniteNumber(
+ value: unknown,
+ fieldName: string,
+): number | undefined {
+ if (value === undefined) {
+ return undefined;
+ }
+ if (typeof value !== "number" || !Number.isFinite(value)) {
+ throw new Error(`${fieldName} must be a finite number`);
+ }
+ return 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" && typeof value !== "number") {
+ throw new Error(`Parameter "${key}" must be a number or numeric string`);
+ }
+ const number = typeof value === "number" ? value : Number(value);
+ if (!Number.isFinite(number)) {
+ throw new Error(`Parameter "${key}" must be a finite number`);
+ }
+ return String(number);
+}
+
+function normalizeParameterValue(
+ parameter: PetrinautCompiledModelMetadata["parameters"][number],
+ value: unknown,
+ key: string,
+): string {
+ if (parameter.type === "boolean") {
+ if (typeof value === "boolean") {
+ return value ? "true" : "false";
+ }
+ if (value === "true" || value === "false") {
+ return value;
+ }
+ throw new Error(`Boolean parameter "${key}" must be true or false`);
+ }
+
+ const normalized = stringifyParameterValue(value, key);
+ if (parameter.type === "integer" && !Number.isInteger(Number(normalized))) {
+ throw new Error(`Integer parameter "${key}" must be an integer`);
+ }
+ return normalized;
+}
+
+function normalizeParameterValues(
+ metadata: PetrinautCompiledModelMetadata,
+ request: ServerRunRequest,
+): Record {
+ const values: Record = {};
+ for (const [key, value] of Object.entries(
+ asRecord(request.parameters, "parameters"),
+ )) {
+ const parameter =
+ metadata.parameters.find((candidate) => candidate.id === key) ??
+ metadata.parameters.findLast(
+ (candidate) => candidate.variableName === key,
+ ) ??
+ metadata.parameters.findLast((candidate) => candidate.name === key);
+ if (!parameter) {
+ throw new Error(`Unknown parameter "${key}"`);
+ }
+ values[parameter.variableName] = normalizeParameterValue(
+ parameter,
+ value,
+ key,
+ );
+ }
+
+ return values;
+}
+
+function resolvePlaceId(
+ metadata: PetrinautCompiledModelMetadata,
+ key: string,
+): string {
+ const place =
+ metadata.places.find((candidate) => candidate.id === key) ??
+ metadata.places.findLast((candidate) => 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 (place.color) {
+ if (!Array.isArray(value)) {
+ throw new Error(
+ `Initial marking for colored place "${place.name}" must be a token array`,
+ );
+ }
+ for (const [index, token] of value.entries()) {
+ if (!isObject(token)) {
+ throw new Error(
+ `Token ${index} for colored place "${place.name}" must be an object`,
+ );
+ }
+ }
+ return value as InitialPlaceMarking;
+ }
+
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
+ throw new Error(
+ `Initial marking for uncolored place "${place.name}" must be a non-negative integer`,
+ );
+ }
+ if (value > MAX_UNCOLORED_TOKEN_COUNT) {
+ throw new Error(
+ `Initial marking for uncolored place "${place.name}" must not exceed ${MAX_UNCOLORED_TOKEN_COUNT}`,
+ );
+ }
+ return value;
+}
+
+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 ?? [];
+}
+
+function parseOptionalObject(
+ value: unknown,
+ fieldName: string,
+): JsonRecord | undefined {
+ if (value === undefined) {
+ return undefined;
+ }
+ if (!isObject(value)) {
+ throw new Error(`${fieldName} must be an object`);
+ }
+ return value;
+}
+
+function parseOptionalMetrics(value: unknown): string[] | undefined {
+ if (value === undefined) {
+ return undefined;
+ }
+ if (
+ !Array.isArray(value) ||
+ !value.every((metric) => typeof metric === "string")
+ ) {
+ throw new Error("metrics must be an array of strings");
+ }
+ return value;
+}
+
+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}"`);
+ }
+ }
+
+ const maxSteps = parseOptionalFiniteNumber(data.maxSteps, "maxSteps");
+ if (maxSteps !== undefined && (!Number.isInteger(maxSteps) || maxSteps < 0)) {
+ throw new Error("maxSteps must be a non-negative integer");
+ }
+ const dt = parseOptionalFiniteNumber(data.dt, "dt");
+ if (dt !== undefined && dt <= 0) {
+ throw new Error("dt must be a positive number");
+ }
+ const maxTime =
+ data.maxTime === null
+ ? null
+ : parseOptionalFiniteNumber(data.maxTime, "maxTime");
+ if (maxTime !== undefined && maxTime !== null && maxTime < 0) {
+ throw new Error("maxTime must be a non-negative number or null");
+ }
+
+ return {
+ parameters: parseOptionalObject(data.parameters, "parameters"),
+ initialState: parseOptionalObject(data.initialState, "initialState"),
+ metrics: parseOptionalMetrics(data.metrics),
+ maxSteps,
+ dt,
+ maxTime,
+ seed: parseOptionalFiniteNumber(data.seed, "seed"),
+ };
+}
+
+export function toPetrinautRunConfig(
+ metadata: PetrinautCompiledModelMetadata,
+ request: ServerRunRequest,
+): PetrinautRunConfig {
+ const common = {
+ initialMarking: normalizeInitialMarking(metadata, request),
+ parameterValues: normalizeParameterValues(metadata, request),
+ ...(request.seed !== undefined ? { seed: request.seed } : {}),
+ ...(request.dt !== undefined ? { dt: request.dt } : {}),
+ metrics: normalizeMetrics(request),
+ };
+
+ if (request.maxTime !== undefined && request.maxTime !== null) {
+ return {
+ ...common,
+ maxTime: request.maxTime,
+ ...(request.maxSteps !== undefined ? { maxSteps: request.maxSteps } : {}),
+ };
+ }
+
+ if (request.maxSteps === undefined) {
+ throw new Error("Run config requires either maxTime or maxSteps");
+ }
+
+ return {
+ ...common,
+ maxSteps: request.maxSteps,
+ ...(request.maxTime === null ? { maxTime: null } : {}),
+ };
+}
diff --git a/libs/@hashintel/petrinaut-cli/tsconfig.json b/libs/@hashintel/petrinaut-cli/tsconfig.json
new file mode 100644
index 00000000000..5392dc108b9
--- /dev/null
+++ b/libs/@hashintel/petrinaut-cli/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "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"],
+ "@hashintel/petrinaut-core/compiled-model": [
+ "../petrinaut-core/src/compiled-model.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..bc797f13913
--- /dev/null
+++ b/libs/@hashintel/petrinaut-cli/vite.config.ts
@@ -0,0 +1,33 @@
+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" ||
+ id.startsWith("@hashintel/petrinaut-core/") ||
+ nodeBuiltins.has(id),
+ output: {
+ banner: "#!/usr/bin/env node",
+ },
+ },
+ },
+});
diff --git a/libs/@hashintel/petrinaut-core/package.json b/libs/@hashintel/petrinaut-core/package.json
index bfa2afdc25d..e033be465cb 100644
--- a/libs/@hashintel/petrinaut-core/package.json
+++ b/libs/@hashintel/petrinaut-core/package.json
@@ -30,6 +30,10 @@
"types": "./dist/ai.d.d.ts",
"import": "./dist/ai.js"
},
+ "./compiled-model": {
+ "types": "./dist/compiled-model.d.d.ts",
+ "import": "./dist/compiled-model.js"
+ },
"./examples": {
"types": "./dist/examples/index.d.d.ts",
"import": "./dist/examples/index.js"
diff --git a/libs/@hashintel/petrinaut-core/src/action-schemas.ts b/libs/@hashintel/petrinaut-core/src/action-schemas.ts
index 6c9d6a7b26f..0305a6d1bd4 100644
--- a/libs/@hashintel/petrinaut-core/src/action-schemas.ts
+++ b/libs/@hashintel/petrinaut-core/src/action-schemas.ts
@@ -10,6 +10,7 @@ import {
idSchema,
inputArcSchema,
nodePositionCommitSchema,
+ parameterObjectSchema,
parameterSchema,
placeSchema,
positionSchema,
@@ -90,7 +91,7 @@ export const differentialEquationUpdateSchema = differentialEquationSchema
"Fields to assign to an existing differential equation. Omitted fields are left unchanged.",
});
-export const parameterUpdateSchema = parameterSchema
+export const parameterUpdateSchema = parameterObjectSchema
.omit({ id: true })
.partial()
.meta({
diff --git a/libs/@hashintel/petrinaut-core/src/compiled-model.ts b/libs/@hashintel/petrinaut-core/src/compiled-model.ts
new file mode 100644
index 00000000000..8f2534bc089
--- /dev/null
+++ b/libs/@hashintel/petrinaut-core/src/compiled-model.ts
@@ -0,0 +1,14 @@
+// Node/tooling-only model compilation entry. Keep this separate from the main
+// browser-facing entry because compiling HIR requires the TypeScript compiler.
+export { compilePetrinautModel } from "./simulation/compiled-model";
+export type {
+ CompilePetrinautModelConfig,
+ PetrinautCompiledModel,
+ PetrinautCompiledModelMetadata,
+ PetrinautCompiledModelMetricMetadata,
+ PetrinautCompiledModelParameterMetadata,
+ PetrinautCompiledModelPlaceMetadata,
+ PetrinautRunCompletionReason,
+ PetrinautRunConfig,
+ PetrinautRunResult,
+} from "./simulation/compiled-model";
diff --git a/libs/@hashintel/petrinaut-core/src/examples/satellites-launcher.ts b/libs/@hashintel/petrinaut-core/src/examples/satellites-launcher.ts
index 42bc887ae36..9813c189a69 100644
--- a/libs/@hashintel/petrinaut-core/src/examples/satellites-launcher.ts
+++ b/libs/@hashintel/petrinaut-core/src/examples/satellites-launcher.ts
@@ -149,7 +149,7 @@ export const probabilisticSatellitesSDCPN: {
lambdaType: "predicate",
lambdaCode: `// Check if two satellites collide (are within collision threshold)
export default Lambda((tokens, parameters) => {
- const { satellite_radius } = parameters;
+ const { collision_threshold, satellite_radius } = parameters;
// Get the two satellites
const [a, b] = tokens.Space;
@@ -157,8 +157,8 @@ export default Lambda((tokens, parameters) => {
// Calculate distance between satellites
const distance = Math.hypot(b.x - a.x, b.y - a.y);
- // Collision occurs if distance is less than threshold
- return distance < satellite_radius;
+ // Collision occurs when the satellite surfaces are within the threshold
+ return distance < satellite_radius * 2 + collision_threshold;
})`,
transitionKernelCode: `// When satellites collide, they become debris (lose velocity)
export default TransitionKernel((tokens) => {
diff --git a/libs/@hashintel/petrinaut-core/src/file-format/parse-sdcpn-file.test.ts b/libs/@hashintel/petrinaut-core/src/file-format/parse-sdcpn-file.test.ts
index 0a627a34889..8c0c9602f6f 100644
--- a/libs/@hashintel/petrinaut-core/src/file-format/parse-sdcpn-file.test.ts
+++ b/libs/@hashintel/petrinaut-core/src/file-format/parse-sdcpn-file.test.ts
@@ -61,6 +61,35 @@ describe("parseSDCPNFile", () => {
expect(result.sdcpn.differentialEquations).toEqual([]);
});
+ it.each([
+ ["real", "not-a-number", "Default value must be a finite number"],
+ ["integer", "1.5", "Default value must be an integer"],
+ ["boolean", "1", 'Default value must be "true" or "false"'],
+ ] as const)(
+ "rejects an invalid %s parameter default",
+ (type, defaultValue, message) => {
+ const result = parseSDCPNFile({
+ version: 1,
+ meta: { generator: "Petrinaut" },
+ ...minimalSDCPN,
+ parameters: [
+ {
+ id: "parameter",
+ name: "Parameter",
+ variableName: "parameter",
+ type,
+ defaultValue,
+ },
+ ],
+ });
+
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ expect(result.error).toContain(message);
+ }
+ },
+ );
+
it("preserves read arc types during import", () => {
const result = parseSDCPNFile({
version: 1,
diff --git a/libs/@hashintel/petrinaut-core/src/file-format/types.ts b/libs/@hashintel/petrinaut-core/src/file-format/types.ts
index 6fa64719525..c857c1b1680 100644
--- a/libs/@hashintel/petrinaut-core/src/file-format/types.ts
+++ b/libs/@hashintel/petrinaut-core/src/file-format/types.ts
@@ -1,5 +1,6 @@
import { z } from "zod";
+import { getParameterValueError } from "../parameter-values";
import {
colorElementSchema as currentColorElementSchema,
colorSchema as currentColorSchema,
@@ -80,11 +81,25 @@ const differentialEquationSchema = z.object({
colorId: z.string().nullable(),
});
-const parameterSchema = z.object({
- ...currentParameterSchema.shape,
- id: z.string(),
- name: z.string(),
-});
+const parameterSchema = z
+ .object({
+ ...currentParameterSchema.shape,
+ id: z.string(),
+ name: z.string(),
+ })
+ .superRefine((parameter, context) => {
+ const error = getParameterValueError(
+ parameter.type,
+ parameter.defaultValue,
+ );
+ if (error) {
+ context.addIssue({
+ code: "custom",
+ path: ["defaultValue"],
+ message: `Default value ${error}`,
+ });
+ }
+ });
const scenarioParameterSchema = z.object({
...currentScenarioParameterSchema.shape,
diff --git a/libs/@hashintel/petrinaut-core/src/index.ts b/libs/@hashintel/petrinaut-core/src/index.ts
index 79377fe7d18..162350b31a5 100644
--- a/libs/@hashintel/petrinaut-core/src/index.ts
+++ b/libs/@hashintel/petrinaut-core/src/index.ts
@@ -309,7 +309,9 @@ export { GRID_SIZE } from "./grid-size";
export {
type DefaultParameterValues,
deriveDefaultParameterValues,
+ getParameterValueError,
mergeParameterValues,
+ parseParameterValue,
} from "./parameter-values";
export { SDCPNItemError } from "./errors";
export { isSDCPNEqual } from "./lib/deep-equal";
diff --git a/libs/@hashintel/petrinaut-core/src/parameter-values.test.ts b/libs/@hashintel/petrinaut-core/src/parameter-values.test.ts
new file mode 100644
index 00000000000..450d9cc44c2
--- /dev/null
+++ b/libs/@hashintel/petrinaut-core/src/parameter-values.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from "vitest";
+
+import { mergeParameterValues } from "./parameter-values";
+
+describe("mergeParameterValues", () => {
+ it("preserves boolean parameter overrides as booleans", () => {
+ expect(
+ mergeParameterValues(
+ { enabled: "true", disabled: "false" },
+ { enabled: false, disabled: true },
+ ),
+ ).toEqual({ enabled: true, disabled: false });
+ });
+
+ it("rejects incompatible boolean and numeric overrides", () => {
+ expect(() =>
+ mergeParameterValues({ enabled: "1" }, { enabled: false }),
+ ).toThrow('Boolean parameter "enabled" must be "true" or "false"');
+ expect(() =>
+ mergeParameterValues({ rate: "invalid" }, { rate: 1 }),
+ ).toThrow('Parameter "rate" must be a finite number');
+ });
+});
diff --git a/libs/@hashintel/petrinaut-core/src/parameter-values.ts b/libs/@hashintel/petrinaut-core/src/parameter-values.ts
index d6433be1379..21e133d1f08 100644
--- a/libs/@hashintel/petrinaut-core/src/parameter-values.ts
+++ b/libs/@hashintel/petrinaut-core/src/parameter-values.ts
@@ -5,6 +5,48 @@ import type { Parameter } from "./types/sdcpn";
*/
export type DefaultParameterValues = Record;
+export function getParameterValueError(
+ type: Parameter["type"],
+ value: string,
+): string | null {
+ if (type === "boolean") {
+ return value === "true" || value === "false"
+ ? null
+ : 'must be "true" or "false"';
+ }
+
+ if (value.trim() === "") {
+ return type === "integer"
+ ? "must be an integer"
+ : "must be a finite number";
+ }
+
+ const numericValue = Number(value);
+ if (!Number.isFinite(numericValue)) {
+ return type === "integer"
+ ? "must be an integer"
+ : "must be a finite number";
+ }
+ if (type === "integer" && !Number.isInteger(numericValue)) {
+ return "must be an integer";
+ }
+ return null;
+}
+
+export function parseParameterValue(
+ parameter: Pick,
+ value: string,
+): number | boolean {
+ const error = getParameterValueError(parameter.type, value);
+ if (error) {
+ const prefix =
+ parameter.type === "boolean" ? "Boolean parameter" : "Parameter";
+ throw new Error(`${prefix} "${parameter.variableName}" ${error}`);
+ }
+
+ return parameter.type === "boolean" ? value === "true" : Number(value);
+}
+
/**
* Pure function to derive default parameter values from a list of parameters.
* This can be used in non-React contexts or for testing.
@@ -18,16 +60,10 @@ export function deriveDefaultParameterValues(
const parameterValues: DefaultParameterValues = {};
for (const param of parameters) {
- const value = param.defaultValue;
-
- if (param.type === "real") {
- parameterValues[param.variableName] = Number.parseFloat(value);
- } else if (param.type === "integer") {
- parameterValues[param.variableName] = Number.parseInt(value, 10);
- } else {
- // boolean
- parameterValues[param.variableName] = value === "true";
- }
+ parameterValues[param.variableName] = parseParameterValue(
+ param,
+ param.defaultValue,
+ );
}
return parameterValues;
@@ -44,13 +80,21 @@ export function deriveDefaultParameterValues(
export function mergeParameterValues(
simulationStoreValues: Record,
defaultValues: DefaultParameterValues,
+ parameters: readonly Parameter[] = [],
): DefaultParameterValues {
const merged: DefaultParameterValues = { ...defaultValues };
+ const parameterTypes = new Map(
+ parameters.map((parameter) => [parameter.variableName, parameter.type]),
+ );
// Override with SimulationStore values where they exist
for (const [key, value] of Object.entries(simulationStoreValues)) {
if (value !== "") {
- merged[key] = Number(value);
+ const defaultValue = defaultValues[key];
+ const type =
+ parameterTypes.get(key) ??
+ (typeof defaultValue === "boolean" ? "boolean" : "real");
+ merged[key] = parseParameterValue({ type, variableName: key }, value);
}
}
diff --git a/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts b/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts
index 180a7f00d8e..fd48c8705db 100644
--- a/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts
+++ b/libs/@hashintel/petrinaut-core/src/schemas/entity-schemas.ts
@@ -1,5 +1,6 @@
import { z } from "zod";
+import { getParameterValueError } from "../parameter-values";
import { COLOR_ELEMENT_TYPES } from "../simulation/engine/type-policies";
import { displayNameSchema } from "../validation/display-name";
import { entityNameSchema } from "../validation/entity-name";
@@ -316,24 +317,38 @@ export const differentialEquationSchema = z
"A differential equation for continuous dynamics on coloured tokens. The `colorId` MUST match the colour of every place that references this equation via `differentialEquationId`, and returned derivatives only update that colour's real-valued elements.",
}) satisfies z.ZodType;
-export const parameterSchema = z
- .strictObject({
- id: idSchema,
- name: displayNameSchema.meta({
- description: "Human-readable parameter name.",
- }),
- variableName: variableNameSchema.meta({
- description:
- "lower_snake_case identifier used DIRECTLY in user code as `parameters.` (e.g. `parameters.crash_threshold`, NOT `parameters.crashThreshold`). Must start with a lowercase letter; only `[a-z0-9_]` allowed.",
- }),
- type: z.enum(["real", "integer", "boolean"]).meta({
- description:
- "Primitive parameter type. Note: parameter values are stored numerically (booleans coerce via `Number()`); the type is primarily a documentation/UI hint.",
- }),
- defaultValue: z.string().meta({
- description:
- 'Default parameter value as a plain numeric string (e.g. `"3"`, `"0.05"`). Parsed via `Number()` with a `|| 0` fallback, so non-numeric strings silently become 0. Expressions are NOT supported here — use scenario `parameterOverrides` for expressions.',
- }),
+export const parameterObjectSchema = z.strictObject({
+ id: idSchema,
+ name: displayNameSchema.meta({
+ description: "Human-readable parameter name.",
+ }),
+ variableName: variableNameSchema.meta({
+ description:
+ "lower_snake_case identifier used DIRECTLY in user code as `parameters.` (e.g. `parameters.crash_threshold`, NOT `parameters.crashThreshold`). Must start with a lowercase letter; only `[a-z0-9_]` allowed.",
+ }),
+ type: z.enum(["real", "integer", "boolean"]).meta({
+ description:
+ 'Primitive parameter type. Real and integer values use numeric strings; boolean values use the literal strings `"true"` and `"false"`.',
+ }),
+ defaultValue: z.string().meta({
+ description:
+ 'Default parameter value as a plain string: numeric for real/integer parameters (e.g. `"3"`, `"0.05"`) and `"true"` or `"false"` for booleans. Expressions are NOT supported here — use scenario `parameterOverrides` for expressions.',
+ }),
+});
+
+export const parameterSchema = parameterObjectSchema
+ .superRefine((parameter, context) => {
+ const error = getParameterValueError(
+ parameter.type,
+ parameter.defaultValue,
+ );
+ if (error) {
+ context.addIssue({
+ code: "custom",
+ path: ["defaultValue"],
+ message: `Default value ${error}`,
+ });
+ }
})
.meta({
description:
diff --git a/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/compile-scenario.test.ts b/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/compile-scenario.test.ts
index 44a35219ad4..5ca0b6ac2f2 100644
--- a/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/compile-scenario.test.ts
+++ b/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/compile-scenario.test.ts
@@ -10,11 +10,12 @@ const param = (
id: string,
variableName: string,
defaultValue: string,
+ type: Parameter["type"] = "real",
): Parameter => ({
id,
name: variableName,
variableName,
- type: "real",
+ type,
defaultValue,
});
@@ -77,6 +78,44 @@ describe("compileScenario", () => {
}
});
+ it("preserves boolean defaults and overrides as boolean strings", () => {
+ const withDefault = compileScenario(scenario(), [
+ param("enabled", "enabled", "true", "boolean"),
+ ]);
+ const withOverride = compileScenario(
+ scenario({ parameterOverrides: { enabled: "false" } }),
+ [param("enabled", "enabled", "true", "boolean")],
+ );
+
+ expect(withDefault).toMatchObject({
+ ok: true,
+ result: { parameterValues: { enabled: "true" } },
+ });
+ expect(withOverride).toMatchObject({
+ ok: true,
+ result: { parameterValues: { enabled: "false" } },
+ });
+ });
+
+ it("rejects fractional integer parameter overrides", () => {
+ const result = compileScenario(
+ scenario({ parameterOverrides: { count: "1.5" } }),
+ [param("count", "count", "1", "integer")],
+ );
+
+ expect(result).toEqual({
+ ok: false,
+ errors: [
+ {
+ source: "parameterOverride",
+ itemId: "count",
+ message:
+ 'Parameter "count" expression evaluated to 1.5, expected an integer.',
+ },
+ ],
+ });
+ });
+
it("evaluates an initial state expression", () => {
const result = compileScenario(
scenario({
@@ -146,6 +185,23 @@ describe("compileScenario", () => {
}
});
+ it("exposes boolean scenario parameters as booleans to bindings", () => {
+ const result = compileScenario(
+ scenario({
+ scenarioParameters: [
+ { type: "boolean", identifier: "enabled", default: 1 },
+ ],
+ parameterOverrides: { enabled: "scenario.enabled" },
+ }),
+ [param("enabled", "enabled", "false", "boolean")],
+ );
+
+ expect(result).toMatchObject({
+ ok: true,
+ result: { parameterValues: { enabled: "true" } },
+ });
+ });
+
it("uses supplied scenario parameter values over defaults", () => {
const result = compileScenario(
scenario({
diff --git a/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/compile-scenario.ts b/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/compile-scenario.ts
index da16d670900..7a843c3c6d7 100644
--- a/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/compile-scenario.ts
+++ b/libs/@hashintel/petrinaut-core/src/simulation/authoring/scenario/compile-scenario.ts
@@ -1,3 +1,4 @@
+import { parseParameterValue } from "../../../parameter-values";
import { coerceTokenRecord } from "../../engine/token-values";
import { TYPE_POLICIES } from "../../engine/type-policies";
import { runSandboxed, SHADOWED_GLOBALS } from "../sandbox";
@@ -60,7 +61,9 @@ export interface CompileScenarioOptions {
* Severs the prototype chain so `obj.constructor.constructor("return globalThis")()`
* cannot escape to globals.
*/
-function createSafeObject(obj: Record): Record {
+type NetParameterValues = Record;
+
+function createSafeObject(obj: T): T {
return Object.freeze(Object.assign(Object.create(null), obj));
}
@@ -77,15 +80,15 @@ function createSafeObject(obj: Record): Record {
*/
function evaluateExpression(
expression: string,
- parameters: Record,
- scenario: Record,
+ parameters: NetParameterValues,
+ scenario: NetParameterValues,
): unknown {
// eslint-disable-next-line no-new-func,typescript-eslint/no-implied-eval -- intentional: user-authored expressions
const fn = new Function(
"parameters",
"scenario",
`"use strict"; var ${SHADOWED_GLOBALS}; return (${expression});`,
- ) as (p: Record, s: Record) => unknown;
+ ) as (p: NetParameterValues, s: NetParameterValues) => unknown;
return runSandboxed(() =>
fn(createSafeObject(parameters), createSafeObject(scenario)),
);
@@ -177,7 +180,7 @@ export function compileScenario(
// ── Step 1: Build the `scenario` object from scenario parameter defaults ──
- const scenarioObj: Record = {};
+ const scenarioObj: NetParameterValues = {};
for (const sp of scenario.scenarioParameters) {
if (sp.identifier.trim() === "") {
continue;
@@ -191,11 +194,12 @@ export function compileScenario(
itemId: sp.identifier,
message: `Scenario parameter "${sp.identifier}" must be a finite number.`,
});
- scenarioObj[sp.identifier] = sp.default;
+ scenarioObj[sp.identifier] =
+ sp.type === "boolean" ? sp.default !== 0 : sp.default;
continue;
}
- scenarioObj[sp.identifier] = value;
+ scenarioObj[sp.identifier] = sp.type === "boolean" ? value !== 0 : value;
}
// ── Step 2: Evaluate parameter overrides ──
@@ -203,9 +207,20 @@ export function compileScenario(
// Start with net-level defaults, then apply each override expression.
// Expressions have access to the base `parameters` and `scenario`.
- const parametersObj: Record = {};
+ const parametersObj: NetParameterValues = {};
for (const param of netParameters) {
- parametersObj[param.variableName] = Number(param.defaultValue) || 0;
+ try {
+ parametersObj[param.variableName] = parseParameterValue(
+ param,
+ param.defaultValue,
+ );
+ } catch (error) {
+ errors.push({
+ source: "parameterOverride",
+ itemId: param.id,
+ message: error instanceof Error ? error.message : String(error),
+ });
+ }
}
// Build a lookup: paramId → Parameter
@@ -225,15 +240,35 @@ export function compileScenario(
}
try {
const value = evaluateExpression(trimmed, parametersObj, scenarioObj);
- if (typeof value !== "number" || Number.isNaN(value)) {
- errors.push({
- source: "parameterOverride",
- itemId: paramId,
- message: `Parameter "${param.name}" expression evaluated to ${String(value)}, expected a number.`,
- });
- continue;
+ if (param.type === "boolean") {
+ if (typeof value !== "boolean") {
+ errors.push({
+ source: "parameterOverride",
+ itemId: paramId,
+ message: `Parameter "${param.name}" expression evaluated to ${String(value)}, expected a boolean.`,
+ });
+ continue;
+ }
+ parametersObj[param.variableName] = value;
+ } else {
+ if (typeof value !== "number" || !Number.isFinite(value)) {
+ errors.push({
+ source: "parameterOverride",
+ itemId: paramId,
+ message: `Parameter "${param.name}" expression evaluated to ${String(value)}, expected a number.`,
+ });
+ continue;
+ }
+ if (param.type === "integer" && !Number.isInteger(value)) {
+ errors.push({
+ source: "parameterOverride",
+ itemId: paramId,
+ message: `Parameter "${param.name}" expression evaluated to ${String(value)}, expected an integer.`,
+ });
+ continue;
+ }
+ parametersObj[param.variableName] = value;
}
- parametersObj[param.variableName] = value;
} catch (err) {
errors.push({
source: "parameterOverride",
@@ -261,7 +296,7 @@ export function compileScenario(
"parameters",
"scenario",
`"use strict"; var ${SHADOWED_GLOBALS}; ${code}`,
- ) as (p: Record, s: Record) => unknown;
+ ) as (p: NetParameterValues, s: NetParameterValues) => unknown;
const result = runSandboxed(() =>
fn(createSafeObject(parametersObj), createSafeObject(scenarioObj)),
);
diff --git a/libs/@hashintel/petrinaut-core/src/simulation/compiled-model.test.ts b/libs/@hashintel/petrinaut-core/src/simulation/compiled-model.test.ts
new file mode 100644
index 00000000000..dc617a20733
--- /dev/null
+++ b/libs/@hashintel/petrinaut-core/src/simulation/compiled-model.test.ts
@@ -0,0 +1,403 @@
+import { beforeAll, describe, expect, it } from "vitest";
+
+import { sirModel } from "../examples";
+import { compilePetrinautModel } from "./compiled-model";
+
+import type { PetrinautCompiledModel } from "./compiled-model";
+
+describe("compilePetrinautModel", () => {
+ let model: PetrinautCompiledModel;
+
+ beforeAll(() => {
+ model = compilePetrinautModel({ sdcpn: sirModel.petriNetDefinition });
+ });
+
+ it("generates a valid seed when one is not supplied", () => {
+ const result = model.run({ maxSteps: 0 });
+
+ expect(result.seed).toBeGreaterThanOrEqual(1);
+ expect(result.seed).toBeLessThanOrEqual(2_147_483_647);
+ });
+
+ it.each([-1, 1.5, 2_147_483_648, Number.NaN, Number.POSITIVE_INFINITY])(
+ "rejects invalid seed %s",
+ (seed) => {
+ expect(() => model.run({ maxSteps: 0, seed })).toThrow(
+ "Run config seed must be an integer between 0 and 2147483647",
+ );
+ },
+ );
+
+ it("accepts the full seeded RNG range", () => {
+ expect(model.run({ maxSteps: 0, seed: 0 }).seed).toBe(0);
+ expect(model.run({ maxSteps: 0, seed: 2_147_483_647 }).seed).toBe(
+ 2_147_483_647,
+ );
+ });
+
+ it.each([Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY])(
+ "rejects non-finite maxTime %s",
+ (maxTime) => {
+ expect(() => model.run({ maxTime })).toThrow(
+ "Run config maxTime must be a finite non-negative number or null",
+ );
+ },
+ );
+
+ it("rejects negative maxTime", () => {
+ expect(() => model.run({ maxTime: -1 })).toThrow(
+ "Run config maxTime must be a finite non-negative number or null",
+ );
+ });
+
+ it.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY])(
+ "rejects invalid dt %s",
+ (dt) => {
+ expect(() => model.run({ maxTime: 1, dt })).toThrow(
+ "Run config dt must be a finite positive number",
+ );
+ },
+ );
+
+ it("requires at least one stopping condition", () => {
+ expect(() => Reflect.apply(model.run, model, [])).toThrow(
+ "Run config requires either maxTime or maxSteps",
+ );
+ });
+
+ it.each([-1, 1.5, Number.NaN, Number.POSITIVE_INFINITY])(
+ "rejects invalid maxSteps %s",
+ (maxSteps) => {
+ expect(() => model.run({ maxSteps })).toThrow(
+ "Run config maxSteps must be a non-negative integer",
+ );
+ },
+ );
+
+ it("keeps repeated seeded runs isolated and deterministic", () => {
+ const config = {
+ initialMarking: {
+ place__susceptible: 20,
+ place__infected: 2,
+ place__recovered: 0,
+ },
+ parameterValues: { infection_rate: "1.5", recovery_rate: "0.8" },
+ metrics: ["Infected Fraction"],
+ maxSteps: 10,
+ dt: 0.1,
+ seed: 4242,
+ } as const;
+
+ expect(model.run(config)).toEqual(model.run(config));
+ });
+
+ it.each([
+ { dt: 1, maxTime: 0.5, expectedFrames: 2 },
+ { dt: 0.1, maxTime: 1, expectedFrames: 11 },
+ ])(
+ "stops exactly at maxTime with dt=$dt",
+ ({ dt, maxTime, expectedFrames }) => {
+ const result = model.run({
+ initialMarking: {
+ place__susceptible: 20,
+ place__infected: 2,
+ place__recovered: 0,
+ },
+ maxTime,
+ dt,
+ seed: 42,
+ });
+
+ expect(result.completionReason).toBe("maxTime");
+ expect(result.finalTime).toBe(maxTime);
+ expect(result.frameCount).toBe(expectedFrames);
+ },
+ );
+
+ it("validates requested metrics before constructing a run", () => {
+ expect(() =>
+ model.run({
+ initialMarking: { missing_place: 1 },
+ metrics: ["Missing metric"],
+ maxSteps: 0,
+ }),
+ ).toThrow('Metric "Missing metric" does not exist in the model');
+ });
+
+ it("uses the extension-sanitized model for metadata", () => {
+ const restrictedModel = compilePetrinautModel({
+ sdcpn: {
+ places: [
+ {
+ id: "place",
+ name: "Place",
+ colorId: "color",
+ dynamicsEnabled: false,
+ differentialEquationId: null,
+ x: 0,
+ y: 0,
+ },
+ ],
+ transitions: [],
+ types: [
+ {
+ id: "color",
+ name: "Color",
+ iconSlug: "circle",
+ displayColor: "#000000",
+ elements: [{ elementId: "value", name: "value", type: "real" }],
+ },
+ ],
+ differentialEquations: [],
+ parameters: [
+ {
+ id: "parameter",
+ name: "Parameter",
+ variableName: "parameter",
+ type: "real",
+ defaultValue: "1",
+ },
+ ],
+ scenarios: [],
+ metrics: [],
+ },
+ extensions: {
+ colors: false,
+ stochasticity: true,
+ dynamics: false,
+ parameters: false,
+ subnets: true,
+ },
+ });
+
+ expect(restrictedModel.metadata.parameters).toEqual([]);
+ expect(
+ restrictedModel.metadata.places.every((place) => place.color === null),
+ ).toBe(true);
+ });
+
+ it("rejects duplicate metric names before runs can overwrite results", () => {
+ const metric = sirModel.petriNetDefinition.metrics?.[0];
+ expect(metric).toBeDefined();
+
+ expect(() =>
+ compilePetrinautModel({
+ sdcpn: {
+ ...sirModel.petriNetDefinition,
+ metrics: [metric!, { ...metric!, id: "duplicate-metric" }],
+ },
+ }),
+ ).toThrow(
+ 'Model metric names must be unique because run results are keyed by name: "Infected Fraction"',
+ );
+ });
+
+ it("rejects duplicate metric ids before HIR artifacts can be overwritten", () => {
+ const metric = sirModel.petriNetDefinition.metrics?.[0];
+ expect(metric).toBeDefined();
+
+ expect(() =>
+ compilePetrinautModel({
+ sdcpn: {
+ ...sirModel.petriNetDefinition,
+ metrics: [metric!, { ...metric!, name: "Duplicate id" }],
+ },
+ }),
+ ).toThrow(`Model metric ids must be unique: "${metric!.id}"`);
+ });
+
+ it("rejects metrics that have no compiled artifact", () => {
+ expect(() =>
+ compilePetrinautModel({
+ sdcpn: {
+ ...sirModel.petriNetDefinition,
+ metrics: [{ id: "empty", name: "Empty", code: "" }],
+ },
+ }),
+ ).toThrow(
+ 'Metric "Empty" has not been compiled. Check the model\'s metric diagnostics.',
+ );
+ });
+
+ it("returns metric names such as __proto__ as own result properties", () => {
+ const protoMetricModel = compilePetrinautModel({
+ sdcpn: {
+ ...sirModel.petriNetDefinition,
+ metrics: [
+ {
+ id: "proto-metric",
+ name: "__proto__",
+ code: "return 7;",
+ },
+ ],
+ },
+ });
+
+ const result = protoMetricModel.run({
+ metrics: ["__proto__"],
+ maxSteps: 0,
+ seed: 1,
+ });
+ expect(Object.hasOwn(result.metrics, "__proto__")).toBe(true);
+ expect(result.metrics.__proto__).toBe(7);
+ });
+
+ it("validates model references before returning a ready runner", () => {
+ expect(() =>
+ compilePetrinautModel({
+ sdcpn: {
+ places: [
+ {
+ id: "place",
+ name: "Place",
+ colorId: "missing-color",
+ dynamicsEnabled: false,
+ differentialEquationId: null,
+ x: 0,
+ y: 0,
+ },
+ ],
+ transitions: [],
+ types: [],
+ differentialEquations: [],
+ parameters: [],
+ },
+ }),
+ ).toThrow(
+ "Type with ID missing-color referenced by place place does not exist in SDCPN",
+ );
+ });
+
+ it("enforces integer parameter types for direct Core callers", () => {
+ const integerModel = compilePetrinautModel({
+ sdcpn: {
+ places: [],
+ transitions: [],
+ types: [],
+ differentialEquations: [],
+ parameters: [
+ {
+ id: "count",
+ name: "Count",
+ variableName: "count",
+ type: "integer",
+ defaultValue: "1",
+ },
+ ],
+ },
+ });
+
+ expect(() =>
+ integerModel.run({
+ parameterValues: { count: "1.5" },
+ maxSteps: 0,
+ }),
+ ).toThrow('Parameter "count" must be an integer');
+ });
+
+ it.each([-1, 1.5, 4_294_967_296])(
+ "rejects invalid uncolored markings for direct Core callers: %s",
+ (count) => {
+ expect(() =>
+ model.run({
+ initialMarking: { place__susceptible: count },
+ maxSteps: 0,
+ }),
+ ).toThrow(
+ "Initial marking for uncolored place place__susceptible must be an integer between 0 and 4294967295",
+ );
+ },
+ );
+
+ it("returns token counts only for root places advertised by metadata", () => {
+ const place = (id: string) => ({
+ id,
+ name: id,
+ colorId: null,
+ dynamicsEnabled: false,
+ differentialEquationId: null,
+ x: 0,
+ y: 0,
+ });
+ const componentModel = compilePetrinautModel({
+ sdcpn: {
+ places: [place("root")],
+ transitions: [],
+ types: [],
+ differentialEquations: [],
+ parameters: [],
+ componentInstances: [
+ {
+ id: "worker",
+ name: "Worker",
+ subnetId: "worker-subnet",
+ parameterValues: {},
+ x: 0,
+ y: 0,
+ },
+ ],
+ subnets: [
+ {
+ id: "worker-subnet",
+ name: "Worker subnet",
+ places: [place("internal")],
+ transitions: [],
+ types: [],
+ differentialEquations: [],
+ parameters: [],
+ componentInstances: [],
+ },
+ ],
+ },
+ });
+
+ expect(componentModel.metadata.places.map(({ id }) => id)).toEqual([
+ "root",
+ ]);
+ expect(
+ componentModel.run({
+ initialMarking: { root: 2 },
+ maxSteps: 0,
+ seed: 1,
+ }).finalPlaceTokenCounts,
+ ).toEqual({ root: 2 });
+ });
+
+ it("accepts record arrays for colors with no elements", () => {
+ const emptyColorModel = compilePetrinautModel({
+ sdcpn: {
+ places: [
+ {
+ id: "empty-colored-place",
+ name: "EmptyColoredPlace",
+ colorId: "empty-color",
+ dynamicsEnabled: false,
+ differentialEquationId: null,
+ x: 0,
+ y: 0,
+ },
+ ],
+ transitions: [],
+ types: [
+ {
+ id: "empty-color",
+ name: "EmptyColor",
+ iconSlug: "circle",
+ displayColor: "#000000",
+ elements: [],
+ },
+ ],
+ differentialEquations: [],
+ parameters: [],
+ },
+ });
+
+ expect(
+ emptyColorModel.run({
+ initialMarking: { "empty-colored-place": [{}, {}] },
+ maxSteps: 0,
+ seed: 1,
+ }).finalPlaceTokenCounts,
+ ).toEqual({ "empty-colored-place": 2 });
+ });
+});
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..c8b0a85eece
--- /dev/null
+++ b/libs/@hashintel/petrinaut-core/src/simulation/compiled-model.ts
@@ -0,0 +1,497 @@
+import { v4 as generateUuid } from "uuid";
+
+import {
+ DEFAULT_PETRINAUT_EXTENSIONS,
+ sanitizeSDCPNForExtensions,
+} from "../extensions";
+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[];
+};
+
+type PetrinautRunOptions = {
+ initialMarking?: InitialMarking;
+ parameterValues?: Record;
+ seed?: number;
+ dt?: number;
+ /** Metric ids or names to evaluate on the final frame. */
+ metrics?: readonly string[];
+};
+
+export type PetrinautRunConfig = PetrinautRunOptions &
+ (
+ | {
+ /** Number of simulation steps to execute. Stops before `maxTime` if lower. */
+ maxSteps: number;
+ maxTime?: number | null;
+ }
+ | {
+ maxTime: number;
+ /** Number of simulation steps to execute. Stops before `maxTime` if lower. */
+ maxSteps?: number;
+ }
+ );
+
+export type PetrinautRunResult = {
+ seed: number;
+ status: "complete";
+ completionReason: PetrinautRunCompletionReason;
+ frameCount: number;
+ finalTime: number;
+ finalPlaceTokenCounts: Record;
+ metrics: Record;
+};
+
+export type PetrinautCompiledModel = {
+ readonly metadata: PetrinautCompiledModelMetadata;
+ run(this: void, config: PetrinautRunConfig): PetrinautRunResult;
+};
+
+export type CompilePetrinautModelConfig = {
+ sdcpn: SDCPN;
+ extensions?: PetrinautExtensionSettings;
+ hirArtifacts?: HirArtifacts;
+};
+
+const MAX_SEED = 2_147_483_647;
+
+function generateSeed(): number {
+ const randomWord = Number.parseInt(generateUuid().slice(0, 8), 16);
+ return (randomWord % MAX_SEED) + 1;
+}
+
+function validateMetricIdentities(sdcpn: SDCPN): void {
+ const ids = new Set();
+ const names = new Set();
+ for (const metric of sdcpn.metrics ?? []) {
+ if (ids.has(metric.id)) {
+ throw new Error(`Model metric ids must be unique: "${metric.id}"`);
+ }
+ if (names.has(metric.name)) {
+ throw new Error(
+ `Model metric names must be unique because run results are keyed by name: "${metric.name}"`,
+ );
+ }
+ ids.add(metric.id);
+ names.add(metric.name);
+ }
+}
+
+function validateCompiledMetrics(sdcpn: SDCPN, artifacts: HirArtifacts): void {
+ for (const metric of sdcpn.metrics ?? []) {
+ if (!artifacts.metrics[metric.id]) {
+ throw new Error(
+ `Metric "${metric.name}" has not been compiled. Check the model's metric diagnostics.`,
+ );
+ }
+ }
+}
+
+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,
+ };
+ },
+ };
+}
+
+type ResolvedMetric = {
+ metric: Metric;
+ evaluate: ReturnType;
+};
+
+function resolveMetrics(args: {
+ sdcpn: SDCPN;
+ artifacts: HirArtifacts;
+ metricNamesOrIds: readonly string[];
+}): ResolvedMetric[] {
+ const { sdcpn, artifacts, metricNamesOrIds } = args;
+
+ return metricNamesOrIds.map((metricNameOrId) => {
+ 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.`,
+ );
+ }
+
+ return {
+ metric,
+ evaluate: createHirMetricEvaluator({
+ metricName: metric.name,
+ artifact,
+ places: sdcpn.places,
+ }),
+ };
+ });
+}
+
+function evaluateMetrics(
+ metrics: readonly ResolvedMetric[],
+ frame: SimulationFrameReader,
+): Record {
+ return Object.fromEntries(
+ metrics.map(({ metric, evaluate }) => [metric.name, evaluate(frame)]),
+ );
+}
+
+function timesAreEqual(left: number, right: number): boolean {
+ const scale = Math.max(1, Math.abs(left), Math.abs(right));
+ return Math.abs(left - right) <= Number.EPSILON * scale * 16;
+}
+
+/**
+ * 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 extensions = config.extensions ?? DEFAULT_PETRINAUT_EXTENSIONS;
+ const sdcpn = sanitizeSDCPNForExtensions(config.sdcpn, extensions);
+ validateMetricIdentities(sdcpn);
+ const compiled = config.hirArtifacts
+ ? { artifacts: config.hirArtifacts, failures: [] }
+ : compileHirArtifacts(sdcpn, 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}`);
+ }
+
+ validateCompiledMetrics(sdcpn, artifacts);
+
+ // Instantiate the engine once before advertising readiness. This catches
+ // invalid model references and stale supplied HIR artifacts at startup.
+ buildSimulation({
+ sdcpn,
+ extensions,
+ initialMarking: {},
+ parameterValues: {},
+ seed: 1,
+ dt: 1,
+ maxTime: 0,
+ hirArtifacts: artifacts,
+ });
+
+ const metadata = createMetadata(sdcpn);
+
+ return {
+ metadata,
+ run(runConfig) {
+ const runtimeConfig: Partial & {
+ maxTime?: number | null;
+ maxSteps?: number;
+ } = (runConfig as PetrinautRunConfig | undefined) ?? {};
+ const seed = runtimeConfig.seed ?? generateSeed();
+ const dt = runtimeConfig.dt ?? 1;
+ const maxTime = runtimeConfig.maxTime ?? null;
+ const maxSteps = runtimeConfig.maxSteps;
+ if (!Number.isInteger(seed) || seed < 0 || seed > MAX_SEED) {
+ throw new Error(
+ `Run config seed must be an integer between 0 and ${MAX_SEED}`,
+ );
+ }
+ if (maxTime === null && maxSteps === undefined) {
+ throw new Error("Run config requires either maxTime or maxSteps");
+ }
+ if (maxTime !== null && (!Number.isFinite(maxTime) || maxTime < 0)) {
+ throw new Error(
+ "Run config maxTime must be a finite non-negative number or null",
+ );
+ }
+ if (!Number.isFinite(dt) || dt <= 0) {
+ throw new Error("Run config dt must be a finite positive number");
+ }
+ if (
+ maxSteps !== undefined &&
+ (!Number.isInteger(maxSteps) || maxSteps < 0)
+ ) {
+ throw new Error("Run config maxSteps must be a non-negative integer");
+ }
+
+ const metrics = resolveMetrics({
+ sdcpn,
+ artifacts,
+ metricNamesOrIds: runtimeConfig.metrics ?? [],
+ });
+
+ let simulation = buildSimulation({
+ sdcpn,
+ extensions,
+ initialMarking: runtimeConfig.initialMarking ?? {},
+ parameterValues: runtimeConfig.parameterValues ?? {},
+ seed,
+ dt,
+ maxTime,
+ hirArtifacts: artifacts,
+ });
+
+ let completionReason: PetrinautRunCompletionReason | null =
+ maxTime !== null && maxTime <= simulation.currentTime
+ ? "maxTime"
+ : null;
+ let steps = 0;
+ while (completionReason === null) {
+ if (maxSteps !== undefined && steps >= maxSteps) {
+ completionReason = "maxSteps";
+ break;
+ }
+
+ if (maxTime !== null) {
+ const remainingTime = maxTime - simulation.currentTime;
+ if (
+ remainingTime < 0 ||
+ timesAreEqual(simulation.currentTime, maxTime)
+ ) {
+ simulation = { ...simulation, currentTime: maxTime };
+ completionReason = "maxTime";
+ break;
+ }
+ simulation = {
+ ...simulation,
+ dt: Math.min(dt, remainingTime),
+ };
+ }
+
+ const result = computeNextFrame(simulation);
+ const currentFrame =
+ result.simulation.frames[result.simulation.currentFrameNumber];
+ if (!currentFrame) {
+ throw new Error("Simulation produced no current frame");
+ }
+ simulation = {
+ ...result.simulation,
+ dt,
+ frames: [currentFrame],
+ currentFrameNumber: 0,
+ };
+ completionReason = result.completionReason;
+ if (
+ maxTime !== null &&
+ timesAreEqual(simulation.currentTime, maxTime)
+ ) {
+ simulation = { ...simulation, currentTime: maxTime };
+ completionReason = "maxTime";
+ }
+ steps++;
+ }
+
+ const finalFrame = simulation.frames[0];
+ 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: steps,
+ time: simulation.currentTime,
+ places,
+ stringPool: simulation.stringPool,
+ });
+
+ const finalPlaceTokenCounts = Object.fromEntries(
+ metadata.places.map((place) => [
+ place.id,
+ finalFrameReader.getPlaceTokenCount(place.id),
+ ]),
+ );
+
+ return {
+ seed,
+ status: "complete",
+ completionReason,
+ frameCount: steps + 1,
+ finalTime: simulation.currentTime,
+ finalPlaceTokenCounts,
+ metrics: evaluateMetrics(metrics, finalFrameReader),
+ };
+ },
+ };
+}
diff --git a/libs/@hashintel/petrinaut-core/src/simulation/engine/build-simulation.ts b/libs/@hashintel/petrinaut-core/src/simulation/engine/build-simulation.ts
index d12c7799de8..d0d485c29ee 100644
--- a/libs/@hashintel/petrinaut-core/src/simulation/engine/build-simulation.ts
+++ b/libs/@hashintel/petrinaut-core/src/simulation/engine/build-simulation.ts
@@ -129,13 +129,18 @@ function packInitialPlaceMarking(
return { bytes: EMPTY_BYTES, count: 0 };
}
- if (tokenLayout === null || tokenLayout.strideBytes === 0) {
- if (typeof value !== "number") {
+ if (tokenLayout === null) {
+ if (
+ typeof value !== "number" ||
+ !Number.isInteger(value) ||
+ value < 0 ||
+ value > 0xffff_ffff
+ ) {
throw new Error(
- `Initial marking for uncolored place ${place.id} must be a token count number`,
+ `Initial marking for uncolored place ${place.id} must be an integer between 0 and 4294967295`,
);
}
- return { bytes: EMPTY_BYTES, count: Math.max(0, Math.round(value)) };
+ return { bytes: EMPTY_BYTES, count: value };
}
if (!Array.isArray(value)) {
@@ -144,6 +149,10 @@ function packInitialPlaceMarking(
);
}
+ if (tokenLayout.strideBytes === 0) {
+ return { bytes: EMPTY_BYTES, count: value.length };
+ }
+
const type = sdcpn.types.find((tp) => tp.id === place.colorId);
if (!type) {
throw new Error(
@@ -589,7 +598,11 @@ export function buildSimulation(input: SimulationInput): SimulationInstance {
sanitizedSdcpn.parameters,
);
const rootParameterValues = extensions.parameters
- ? mergeParameterValues(inputParameterValues, defaultParameterValues)
+ ? mergeParameterValues(
+ inputParameterValues,
+ defaultParameterValues,
+ sanitizedSdcpn.parameters,
+ )
: {};
const flattened = flattenComponentInstancesForSimulation({
sdcpn: sanitizedSdcpn,
diff --git a/libs/@hashintel/petrinaut-core/src/simulation/runtime/simulation.ts b/libs/@hashintel/petrinaut-core/src/simulation/runtime/simulation.ts
index 6d30061e430..e2de82ea7a3 100644
--- a/libs/@hashintel/petrinaut-core/src/simulation/runtime/simulation.ts
+++ b/libs/@hashintel/petrinaut-core/src/simulation/runtime/simulation.ts
@@ -99,7 +99,11 @@ export function createSimulation(
sanitizedSdcpn.parameters,
);
const rootParameterValues = extensions.parameters
- ? mergeParameterValues(config.parameterValues, defaultParameterValues)
+ ? mergeParameterValues(
+ config.parameterValues,
+ defaultParameterValues,
+ sanitizedSdcpn.parameters,
+ )
: {};
const { sdcpn: flattenedSdcpn } = flattenComponentInstancesForSimulation({
sdcpn: sanitizedSdcpn,
diff --git a/libs/@hashintel/petrinaut-core/vite.config.ts b/libs/@hashintel/petrinaut-core/vite.config.ts
index 23aebb484f1..a2560a3cbab 100644
--- a/libs/@hashintel/petrinaut-core/vite.config.ts
+++ b/libs/@hashintel/petrinaut-core/vite.config.ts
@@ -14,6 +14,9 @@ export default defineConfig(({ command }) => ({
index: resolve(packageRoot, "src/index.ts"),
// Dedicated edge-safe entry exposing only the AI prompt + tool schemas.
ai: resolve(packageRoot, "src/ai.ts"),
+ // Node/tooling-only reusable model compiler. This depends on the
+ // TypeScript-powered HIR compiler and must stay out of the main entry.
+ "compiled-model": resolve(packageRoot, "src/compiled-model.ts"),
// HIR compiler (bundles the TypeScript frontend — heavy; used by the
// LSP worker internally and by tooling/playgrounds).
hir: resolve(packageRoot, "src/hir.ts"),
diff --git a/libs/@hashintel/petrinaut/docs/scenarios.md b/libs/@hashintel/petrinaut/docs/scenarios.md
index 92b9754ef44..ce65878d8aa 100644
--- a/libs/@hashintel/petrinaut/docs/scenarios.md
+++ b/libs/@hashintel/petrinaut/docs/scenarios.md
@@ -30,7 +30,7 @@ You will need scenarios when you want to:
1. Switch to **Simulate** mode and open the **Scenarios** tab.
2. Click **Create**. The Create Scenario drawer opens.
3. Fill in **Name** (required, must be unique among scenarios) and an optional description.
-4. Add **Scenario parameters** if you need variables scoped to this scenario. Each parameter has an identifier (snake_case, lowercase; the form auto-converts on blur), a type (Real / Integer / Boolean / Ratio), and a default value. Ratios are clamped to `[0, 1]`; booleans are stored as `1` or `0`.
+4. Add **Scenario parameters** if you need variables scoped to this scenario. Each parameter has an identifier (snake_case, lowercase; the form auto-converts on blur), a type (Real / Integer / Boolean / Ratio), and a default value. Ratios are clamped to `[0, 1]`; booleans are stored as `1` or `0` and exposed to expressions as `true` or `false`.
5. Set **Parameter bindings** for any net-level parameters whose default you want to override. Each binding is a TypeScript expression; leaving it empty keeps the net default (shown in the placeholder).
6. Configure **Initial state** for each place that should start with tokens.
7. Click **Create**. Save is blocked while the form has validation or LSP errors -- hover the disabled button to see why.
diff --git a/libs/@hashintel/petrinaut/docs/simulation.md b/libs/@hashintel/petrinaut/docs/simulation.md
index e68ca36ae94..b4cf1cc9868 100644
--- a/libs/@hashintel/petrinaut/docs/simulation.md
+++ b/libs/@hashintel/petrinaut/docs/simulation.md
@@ -31,7 +31,7 @@ Quick-action buttons next to the picker let you edit the selected scenario, crea
Override values for this run:
-- With **No scenario** selected: each [net-level parameter](petri-net-extensions.md#global-parameters) shows its name, variable name, and a value input pre-filled with the default.
+- With **No scenario** selected: each [net-level parameter](petri-net-extensions.md#global-parameters) shows its name and variable name. Boolean parameters use a toggle; real and integer parameters use a numeric input pre-filled with the default.
- With a scenario selected: the **scenario parameters** are shown instead, pre-filled with that scenario's defaults. Net-level parameter values are fixed by the scenario's [parameter bindings](scenarios.md#parameter-bindings) and are not editable here.
Changes here do not modify the parameter definition or the scenario -- they only apply to the simulation. Parameter values are locked while a simulation is running. Reset the simulation to change them.
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-settings.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-settings.tsx
index e9f2c7bb45a..c4227bf43f3 100644
--- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-settings.tsx
+++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/BottomPanel/subviews/simulation-settings.tsx
@@ -307,19 +307,29 @@ const SimulationSettingsContent: React.FC = () => {
{param.variableName}
- {param.type === "boolean" && selectedScenario ? (
+ {param.type === "boolean" ? (
- setScenarioParameterValue(
- param.variableName,
- checked ? "1" : "0",
- )
+ selectedScenario
+ ? (scenarioParameterValues[param.variableName] ??
+ param.defaultValue) !== "0"
+ : (parameterValues[param.variableName] ??
+ param.defaultValue) === "true"
}
+ onChange={(checked) => {
+ if (selectedScenario) {
+ setScenarioParameterValue(
+ param.variableName,
+ checked ? "1" : "0",
+ );
+ } else {
+ setParameterValue(
+ param.variableName,
+ checked ? "true" : "false",
+ );
+ }
+ }}
disabled={isSimulationActive}
/>
) : param.type === "ratio" && selectedScenario ? (
diff --git a/yarn.lock b/yarn.lock
index 82f9702d404..3f3909fe6ac 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6733,6 +6733,24 @@ __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"
+ typescript: "npm:5.9.3"
+ vite: "npm:8.1.0"
+ vitest: "npm:4.1.8"
+ 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"