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 Release queue\n {tokens.length} waiting; hotter cards are riskier\n {visible.map((deployment, index) => {\n const risk = Math.max(0, Math.min(1, deployment.risk));\n const size = Math.max(0.4, Math.min(2.2, deployment.size));\n const x = 18 + (index % 10) * 34;\n const y = 68 + Math.floor(index / 10) * 42;\n const fill = risk < 0.25 ? \"#22c55e\" : risk < 0.55 ? \"#f59e0b\" : \"#ef4444\";\n return (\n \n \n {Math.round(risk * 100)}\n \n );\n })}\n \n );\n});", + "showAsInitialState": true + }, + { + "id": "place__incident-being-investigated", + "name": "IncidentBeingInvestigated", + "colorId": "type__incident", + "dynamicsEnabled": true, + "differentialEquationId": "dynamics__incident_age", + "x": 675, + "y": 510, + "visualizerCode": "// Incident bridge: one bar per open incident, taller and darker with severity.\n// Because this place feeds an inhibitor arc into Start Deployment, any bar\n// shown here means the deployment gate is currently closed.\nexport default Visualization(({ tokens }) => {\n const width = 380;\n const height = 170;\n const visible = tokens.slice(0, 10);\n return (\n \n \n Incident bridge\n Inhibits new deployments while any card is present\n {visible.length === 0 && No active incident — deploy gate open}\n {visible.map((incident, index) => {\n const sev = Math.max(0, Math.min(1, incident.severity));\n const x = 20 + index * 34;\n const h = 24 + sev * 52;\n const y = 142 - h;\n return (\n \n \n S{Math.ceil(sev * 5)}\n \n );\n })}\n \n );\n});", + "showAsInitialState": true + }, + { + "id": "place__deployment-in-progress", + "name": "DeploymentInProgress", + "colorId": "type__deployment", + "dynamicsEnabled": true, + "differentialEquationId": "dynamics__deployment_age", + "x": 150, + "y": 315, + "visualizerCode": "// Deployment lane: shows the single in-progress deployment as a progress bar\n// (estimated from its age relative to size), coloured by risk. Empty when the\n// lane is idle — the inhibitor arc allows only one deployment at a time.\nexport default Visualization(({ tokens }) => {\n const width = 380;\n const height = 170;\n const deployment = tokens[0];\n const risk = deployment ? Math.max(0, Math.min(1, deployment.risk)) : 0;\n const age = deployment ? deployment.age : 0;\n const size = deployment ? deployment.size : 0;\n const progress = deployment ? Math.max(0.08, Math.min(1, age / Math.max(1, size * 6))) : 0;\n return (\n \n \n Deployment lane\n {!deployment && Idle — ready for next safe release}\n {deployment && (\n \n \n \n \n \n size {size.toFixed(2)} · risk {Math.round(risk * 100)}% · age {age.toFixed(1)}s\n \n )}\n \n );\n});", + "showAsInitialState": true + }, + { + "id": "place__completed-deployments", + "name": "CompletedDeployments", + "colorId": "type__deployment", + "dynamicsEnabled": false, + "differentialEquationId": null, + "x": 825, + "y": 210, + "visualizerCode": "// Successful releases: a tally of completed deployments (most recent last).\n// Each green check is one deployment that finished without causing an incident.\nexport default Visualization(({ tokens }) => {\n const width = 360;\n const height = 160;\n const visible = tokens.slice(-24);\n return (\n \n \n Successful releases\n {tokens.length} completed deployments\n {visible.map((deployment, index) => {\n const x = 18 + (index % 12) * 27;\n const y = 74 + Math.floor(index / 12) * 31;\n return (\n \n \n \n \n );\n })}\n \n );\n});", + "showAsInitialState": true + }, + { + "id": "place__resolved-incidents", + "name": "ResolvedIncidents", + "colorId": "type__incident", + "dynamicsEnabled": false, + "differentialEquationId": null, + "x": 1170, + "y": 510, + "visualizerCode": "// Closed incidents: a tally of resolved incident tickets (most recent last).\nexport default Visualization(({ tokens }) => {\n const width = 340;\n const height = 150;\n const visible = tokens.slice(-18);\n return (\n \n \n Closed incidents\n {tokens.length} resolved tickets\n {visible.map((incident, index) => {\n const x = 18 + (index % 9) * 34;\n const y = 72 + Math.floor(index / 9) * 30;\n return (\n \n \n \n \n );\n })}\n \n );\n});", + "showAsInitialState": true + }, + { + "id": "place__failed-deployments", + "name": "FailedDeployments", + "colorId": "type__deployment", + "dynamicsEnabled": false, + "differentialEquationId": null, + "visualizerCode": "// Failed releases: deployments that rolled back and opened an incident. The\n// red cross marks each failure and its outline thickens with the deployment's\n// risk, so the riskiest failures stand out.\nexport default Visualization(({ tokens }) => {\n const width = 360;\n const height = 160;\n const visible = tokens.slice(-18);\n return (\n \n \n Failed releases\n {tokens.length} rollback / incident-generating deploys\n {visible.map((deployment, index) => {\n const x = 18 + (index % 9) * 36;\n const y = 76 + Math.floor(index / 9) * 34;\n const risk = Math.max(0, Math.min(1, deployment.risk));\n return (\n \n \n \n \n \n );\n })}\n \n );\n});", + "showAsInitialState": true, + "x": 870, + "y": 330 + } + ], + "transitions": [ + { + "id": "transition__create-deployment", + "name": "Create Deployment", + "inputArcs": [], + "outputArcs": [ + { + "placeId": "place__deployment-ready", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "/**\n * Deployment arrivals.\n *\n * This stochastic lambda returns the expected number of new deployment\n * candidates created per simulation second. The transition has no input\n * places, so it behaves like an external arrival process feeding the\n * DeploymentReady queue.\n */\nexport default Lambda((input, parameters) => {\n return parameters.deployment_creation_rate;\n});", + "transitionKernelCode": "/**\n * Create one coloured Deployment token.\n *\n * - size is sampled from a lognormal distribution so most releases are\n * ordinary, with occasional large releases.\n * - risk is sampled from a Gaussian distribution and clamped to [0.02, 0.95]\n * so every deployment has some risk but never exceeds a probability-like cap.\n * - age starts at zero and is advanced by the Deployment Age dynamics while the\n * token waits or runs.\n */\nexport default TransitionKernel((input, parameters) => {\n const size = Distribution.Lognormal(Math.log(Math.max(0.1, parameters.mean_deployment_size)), 0.35);\n const rawRisk = Distribution.Gaussian(0.28 * parameters.deployment_risk_multiplier, 0.16);\n return {\n DeploymentReady: [\n {\n size,\n risk: rawRisk.map(r => Math.max(0.02, Math.min(0.95, r))),\n age: 0,\n },\n ],\n };\n});", + "x": -90, + "y": 0 + }, + { + "id": "transition__incident-raised", + "name": "Incident Raised", + "inputArcs": [], + "outputArcs": [ + { + "placeId": "place__incident-being-investigated", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "/**\n * External incident arrivals.\n *\n * This stochastic lambda models incidents raised independently of deployments,\n * such as infrastructure failures or customer-impacting issues. Each firing\n * creates one IncidentBeingInvestigated token, which then blocks the deployment\n * start gate via the inhibitor arc.\n */\nexport default Lambda((input, parameters) => {\n return parameters.incident_rate;\n});", + "transitionKernelCode": "/**\n * Create one coloured Incident token.\n *\n * Severity is sampled around the configured incident severity multiplier and\n * clamped to [0.05, 1]. Higher-severity incidents resolve more slowly in the\n * Close Incident transition, so this sampled attribute affects later dynamics.\n */\nexport default TransitionKernel((input, parameters) => {\n const rawSeverity = Distribution.Gaussian(0.45 * parameters.incident_severity_multiplier, 0.2);\n return {\n IncidentBeingInvestigated: [\n {\n severity: rawSeverity.map(s => Math.max(0.05, Math.min(1, s))),\n age: 0,\n },\n ],\n };\n});", + "x": 450, + "y": 585 + }, + { + "id": "transition__start-deployment", + "name": "Start Deployment", + "inputArcs": [ + { + "placeId": "place__deployment-ready", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__incident-being-investigated", + "weight": 1, + "type": "inhibitor" + }, + { + "placeId": "place__deployment-in-progress", + "weight": 1, + "type": "inhibitor" + } + ], + "outputArcs": [ + { + "placeId": "place__deployment-in-progress", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "/**\n * Safety-gated deployment start.\n *\n * The boolean logic is intentionally simple: if the transition is enabled by\n * the Petri-net arcs, it may fire. The important behaviour is in the arcs:\n * - DeploymentReady is a standard input arc, so one queued deployment is consumed.\n * - IncidentBeingInvestigated is an inhibitor arc, so any active incident blocks firing.\n * - DeploymentInProgress is an inhibitor arc, so only one deployment may run at a time.\n */\nexport default Lambda(() => true);", + "transitionKernelCode": "/**\n * Move a deployment from the ready queue into the in-progress lane.\n *\n * The deployment's size and risk are preserved. Age is reset to zero so the\n * in-progress visualizer and downstream completion/failure logic can interpret\n * age as time spent running rather than time spent waiting in the queue.\n */\nexport default TransitionKernel((input) => {\n const deployment = input.DeploymentReady[0];\n return {\n DeploymentInProgress: [\n {\n size: deployment.size,\n risk: deployment.risk,\n age: 0,\n },\n ],\n };\n});", + "x": 480, + "y": 0 + }, + { + "id": "transition__finish-deployment", + "name": "Finish Deployment", + "inputArcs": [ + { + "placeId": "place__deployment-in-progress", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__completed-deployments", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "/**\n * Successful deployment completion rate.\n *\n * The returned rate is in expected firings per simulation second for the\n * specific DeploymentInProgress token being considered. Larger deployments\n * complete more slowly, and risky deployments get a modest slowdown to represent\n * extra validation, caution, or late-stage friction.\n */\nexport default Lambda((input, parameters) => {\n const deployment = input.DeploymentInProgress[0];\n const sizePenalty = Math.max(0.25, deployment.size);\n const riskPenalty = Math.max(0.15, 1 - deployment.risk * 0.45);\n return parameters.deployment_finish_base_rate * riskPenalty / sizePenalty;\n});", + "transitionKernelCode": "/**\n * Record a successful deployment.\n *\n * The token's attributes are copied into CompletedDeployments so visualizers and\n * metrics can still inspect the size, risk, and elapsed run age of releases that\n * made it through the pipeline.\n */\nexport default TransitionKernel((input) => {\n const deployment = input.DeploymentInProgress[0];\n return {\n CompletedDeployments: [\n {\n size: deployment.size,\n risk: deployment.risk,\n age: deployment.age,\n },\n ],\n };\n});", + "x": 480, + "y": 165 + }, + { + "id": "transition__close-incident", + "name": "Close Incident", + "inputArcs": [ + { + "placeId": "place__incident-being-investigated", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__resolved-incidents", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "/**\n * Incident resolution rate.\n *\n * Each active incident is considered separately. High-severity incidents resolve\n * more slowly, because severity appears in the denominator. Once the final\n * incident leaves IncidentBeingInvestigated, the inhibitor arc on Start\n * Deployment no longer blocks the release gate.\n */\nexport default Lambda((input, parameters) => {\n const incident = input.IncidentBeingInvestigated[0];\n return parameters.incident_resolution_rate / Math.max(0.2, incident.severity);\n});", + "transitionKernelCode": "/**\n * Move an incident to the resolved archive.\n *\n * Severity and age are preserved so the resolved incident pile can show the\n * history of operational load, even though resolved incidents no longer block\n * deployments.\n */\nexport default TransitionKernel((input) => {\n const incident = input.IncidentBeingInvestigated[0];\n return {\n ResolvedIncidents: [\n {\n severity: incident.severity,\n age: incident.age,\n },\n ],\n };\n});", + "x": 930, + "y": 510 + }, + { + "id": "transition__deployment-causes-incident", + "name": "Deployment Causes Incident", + "inputArcs": [ + { + "placeId": "place__deployment-in-progress", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__failed-deployments", + "weight": 1 + }, + { + "placeId": "place__incident-being-investigated", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "/**\n * Deployment failure / incident-generation rate.\n *\n * This is the competing stochastic outcome to Finish Deployment. Riskier and\n * larger deployments are more likely to fail, and the scenario-level risk\n * multiplier can make the whole release environment safer or more dangerous.\n * When this fires, the in-progress deployment is consumed and an incident is\n * opened, which then blocks future starts through the inhibitor arc.\n */\nexport default Lambda((input, parameters) => {\n const deployment = input.DeploymentInProgress[0];\n const risk = Math.max(0, Math.min(1, deployment.risk));\n const size = Math.max(0.25, deployment.size);\n return parameters.deployment_failure_base_rate * parameters.deployment_risk_multiplier * risk * size;\n});", + "transitionKernelCode": "/**\n * Turn a failed deployment into both an audit record and an active incident.\n *\n * The failed deployment is copied to FailedDeployments for counting and display.\n * A new Incident token is created with severity derived from deployment risk,\n * plus Gaussian noise. The severity is clamped to [0.05, 1] so incident handling\n * remains numerically stable and visually interpretable.\n */\nexport default TransitionKernel((input, parameters) => {\n const deployment = input.DeploymentInProgress[0];\n const rawSeverity = Distribution.Gaussian(\n Math.min(1, 0.25 + deployment.risk * 0.65 * parameters.incident_severity_multiplier),\n 0.12\n );\n return {\n FailedDeployments: [\n {\n size: deployment.size,\n risk: deployment.risk,\n age: deployment.age,\n },\n ],\n IncidentBeingInvestigated: [\n {\n severity: rawSeverity.map(s => Math.max(0.05, Math.min(1, s))),\n age: 0,\n },\n ],\n };\n});", + "x": 450, + "y": 405 + } + ], + "types": [ + { + "id": "type__deployment", + "name": "Deployment", + "iconSlug": "rocket", + "displayColor": "#2563eb", + "elements": [ + { + "elementId": "deployment__size", + "name": "size", + "type": "real" + }, + { + "elementId": "deployment__risk", + "name": "risk", + "type": "real" + }, + { + "elementId": "deployment__age", + "name": "age", + "type": "real" + } + ] + }, + { + "id": "type__incident", + "name": "Incident", + "iconSlug": "alert-triangle", + "displayColor": "#dc2626", + "elements": [ + { + "elementId": "incident__severity", + "name": "severity", + "type": "real" + }, + { + "elementId": "incident__age", + "name": "age", + "type": "real" + } + ] + } + ], + "differentialEquations": [ + { + "id": "dynamics__deployment_age", + "name": "Deployment Age", + "colorId": "type__deployment", + "code": "// A simple clock: every deployment's age increases at rate 1 (size and risk\n// are fixed at creation, so their derivatives are 0). Age is used to estimate\n// progress in the deployment-lane visualizer.\nexport default Dynamics((tokens) => {\n return tokens.map(() => ({\n size: 0,\n risk: 0,\n age: 1,\n }));\n});" + }, + { + "id": "dynamics__incident_age", + "name": "Incident Age", + "colorId": "type__incident", + "code": "// Clock for incidents: age rises at rate 1 while severity stays fixed. Older,\n// higher-severity incidents take longer to resolve in the Close Incident rate.\nexport default Dynamics((tokens) => {\n return tokens.map(() => ({\n severity: 0,\n age: 1,\n }));\n});" + } + ], + "parameters": [ + { + "id": "param__deployment_creation_rate", + "name": "Deployment Creation Rate", + "variableName": "deployment_creation_rate", + "type": "real", + "defaultValue": "0.5" + }, + { + "id": "param__incident_rate", + "name": "Incident Rate", + "variableName": "incident_rate", + "type": "real", + "defaultValue": "0.1" + }, + { + "id": "param__incident_resolution_rate", + "name": "Incident Resolution Rate", + "variableName": "incident_resolution_rate", + "type": "real", + "defaultValue": "0.3" + }, + { + "id": "param__deployment_finish_base_rate", + "name": "Deployment Finish Base Rate", + "variableName": "deployment_finish_base_rate", + "type": "real", + "defaultValue": "0.35" + }, + { + "id": "param__deployment_failure_base_rate", + "name": "Deployment Failure Base Rate", + "variableName": "deployment_failure_base_rate", + "type": "real", + "defaultValue": "0.04" + }, + { + "id": "param__deployment_risk_multiplier", + "name": "Deployment Risk Multiplier", + "variableName": "deployment_risk_multiplier", + "type": "real", + "defaultValue": "1" + }, + { + "id": "param__mean_deployment_size", + "name": "Mean Deployment Size", + "variableName": "mean_deployment_size", + "type": "real", + "defaultValue": "1" + }, + { + "id": "param__incident_severity_multiplier", + "name": "Incident Severity Multiplier", + "variableName": "incident_severity_multiplier", + "type": "real", + "defaultValue": "1" + } + ], + "metrics": [ + { + "id": "metric__successful_deployments", + "name": "Successful deployments", + "description": "Cumulative number of deployments that finished without causing an incident.", + "code": "return state.places.CompletedDeployments.count;" + }, + { + "id": "metric__failed_deployments", + "name": "Failed deployments", + "description": "Cumulative number of deployments that rolled back and opened an incident.", + "code": "return state.places.FailedDeployments.count;" + }, + { + "id": "metric__release_queue_length", + "name": "Release queue length", + "description": "How many deployments are waiting behind the safety gate.", + "code": "return state.places.DeploymentReady.count;" + }, + { + "id": "metric__active_incidents", + "name": "Active incidents", + "description": "Number of incidents currently blocking the deployment gate.", + "code": "return state.places.IncidentBeingInvestigated.count;" + }, + { + "id": "metric__deployment_gate_blocked", + "name": "Deployment gate blocked", + "description": "Returns 1 when an incident or active deployment is preventing another deployment from starting; otherwise 0.", + "code": "const incidents = state.places.IncidentBeingInvestigated.count;\nconst inProgress = state.places.DeploymentInProgress.count;\nreturn incidents > 0 || inProgress > 0 ? 1 : 0;" + }, + { + "id": "metric__failure_share", + "name": "Failure share", + "description": "Share of completed-or-failed deployments that ended in failure.", + "code": "const failed = state.places.FailedDeployments.count;\nconst succeeded = state.places.CompletedDeployments.count;\nconst total = failed + succeeded;\nreturn total === 0 ? 0 : failed / total;" + } + ], + "scenarios": [ + { + "id": "scenario__baseline", + "name": "Baseline operations", + "description": "Balanced deployment rate, moderate risk, and normal incident flow. Good starting point for explaining the inhibitor gate.", + "scenarioParameters": [ + { + "type": "real", + "identifier": "deployment_rate", + "default": 0.5 + }, + { + "type": "real", + "identifier": "incident_rate", + "default": 0.1 + }, + { + "type": "real", + "identifier": "risk_multiplier", + "default": 1 + }, + { + "type": "real", + "identifier": "mean_size", + "default": 1 + }, + { + "type": "real", + "identifier": "resolution_rate", + "default": 0.3 + } + ], + "parameterOverrides": { + "param__deployment_creation_rate": "scenario.deployment_rate", + "param__incident_rate": "scenario.incident_rate", + "param__deployment_risk_multiplier": "scenario.risk_multiplier", + "param__mean_deployment_size": "scenario.mean_size", + "param__incident_resolution_rate": "scenario.resolution_rate" + }, + "initialState": { + "type": "per_place", + "content": {} + } + }, + { + "id": "scenario__incident_surge", + "name": "Incident surge", + "description": "External incidents arrive quickly and block the deployment gate, causing the release queue to pile up.", + "scenarioParameters": [ + { + "type": "real", + "identifier": "deployment_rate", + "default": 0.5 + }, + { + "type": "real", + "identifier": "incident_rate", + "default": 0.35 + }, + { + "type": "real", + "identifier": "severity_multiplier", + "default": 1.25 + }, + { + "type": "real", + "identifier": "resolution_rate", + "default": 0.22 + } + ], + "parameterOverrides": { + "param__deployment_creation_rate": "scenario.deployment_rate", + "param__incident_rate": "scenario.incident_rate", + "param__incident_severity_multiplier": "scenario.severity_multiplier", + "param__incident_resolution_rate": "scenario.resolution_rate" + }, + "initialState": { + "type": "per_place", + "content": {} + } + }, + { + "id": "scenario__high_velocity", + "name": "High deployment velocity", + "description": "A high release creation rate tests whether the single-deployment safety gate becomes the bottleneck.", + "scenarioParameters": [ + { + "type": "real", + "identifier": "deployment_rate", + "default": 1.2 + }, + { + "type": "real", + "identifier": "finish_rate", + "default": 0.45 + }, + { + "type": "real", + "identifier": "risk_multiplier", + "default": 1 + }, + { + "type": "real", + "identifier": "incident_rate", + "default": 0.08 + } + ], + "parameterOverrides": { + "param__deployment_creation_rate": "scenario.deployment_rate", + "param__deployment_finish_base_rate": "scenario.finish_rate", + "param__deployment_risk_multiplier": "scenario.risk_multiplier", + "param__incident_rate": "scenario.incident_rate" + }, + "initialState": { + "type": "per_place", + "content": {} + } + }, + { + "id": "scenario__risky_large_releases", + "name": "Risky large releases", + "description": "Larger, riskier releases take longer and more often create incidents, visibly closing the inhibitor gate.", + "scenarioParameters": [ + { + "type": "real", + "identifier": "deployment_rate", + "default": 0.45 + }, + { + "type": "real", + "identifier": "mean_size", + "default": 1.8 + }, + { + "type": "real", + "identifier": "risk_multiplier", + "default": 1.75 + }, + { + "type": "real", + "identifier": "failure_base_rate", + "default": 0.07 + }, + { + "type": "real", + "identifier": "severity_multiplier", + "default": 1.4 + } + ], + "parameterOverrides": { + "param__deployment_creation_rate": "scenario.deployment_rate", + "param__mean_deployment_size": "scenario.mean_size", + "param__deployment_risk_multiplier": "scenario.risk_multiplier", + "param__deployment_failure_base_rate": "scenario.failure_base_rate", + "param__incident_severity_multiplier": "scenario.severity_multiplier" + }, + "initialState": { + "type": "per_place", + "content": {} + } + } + ] +} diff --git a/libs/@hashintel/petrinaut-cli/examples/production-with-machine-failure.json b/libs/@hashintel/petrinaut-cli/examples/production-with-machine-failure.json new file mode 100644 index 00000000000..d7f0c5951e5 --- /dev/null +++ b/libs/@hashintel/petrinaut-cli/examples/production-with-machine-failure.json @@ -0,0 +1,418 @@ +{ + "title": "Production With Machine Failure", + "places": [ + { + "id": "place__d662407f-c56d-4a96-bcbb-ead785a9c594", + "name": "RawMaterial", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": -165, + "y": -405 + }, + { + "id": "place__2bdd959f-a5bc-404a-bd03-34fafcef66b8", + "name": "AvailableMachines", + "colorId": "type__1762560152725", + "dynamicsEnabled": false, + "differentialEquationId": null, + "showAsInitialState": true, + "x": -150, + "y": 75 + }, + { + "id": "place__81e551b4-11dc-4781-9cd7-dd882fd7e947", + "name": "MachinesProducing", + "colorId": "type__1762560154179", + "dynamicsEnabled": true, + "differentialEquationId": "ca26e5e2-0373-46a9-920e-a6eacadd92e8", + "x": 375, + "y": -225 + }, + { + "id": "place__d5f92ae2-c8c4-49cb-935e-4a35e4f7b5fe", + "name": "BadProduct", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "x": 1110, + "y": -105 + }, + { + "id": "place__7b695ff5-a397-4237-8e30-ddf8cbc9e2c4", + "name": "GoodProduct", + "colorId": null, + "dynamicsEnabled": false, + "differentialEquationId": null, + "x": 1110, + "y": -225 + }, + { + "id": "place__e5af0410-d80a-4c8b-b3bf-692918b98e6c", + "name": "BrokenMachines", + "colorId": "type__1762560152725", + "dynamicsEnabled": false, + "differentialEquationId": null, + "x": 1110, + "y": 90 + }, + { + "id": "place__17c65d6e-0c3e-48e6-a677-2914e28131ac", + "name": "MachinesBeingRepaired", + "colorId": "type__1762560152725", + "dynamicsEnabled": true, + "differentialEquationId": "5bfea547-faaf-4626-8662-6400d07c049e", + "x": -585, + "y": 405 + }, + { + "id": "place__4b72cf19-907b-4fc0-ac0a-555453e95d4b", + "name": "TechniciansComing", + "colorId": "type__1762560159263", + "dynamicsEnabled": true, + "differentialEquationId": "887245c3-183c-4dac-a1aa-d602d21b6450", + "x": 855, + "y": 795 + }, + { + "id": "place__eaca89b8-1db1-45fa-8c3a-6eb6f0419ffa", + "name": "AvailableTechnicians", + "colorId": "type__1762560159263", + "dynamicsEnabled": false, + "differentialEquationId": null, + "x": 1395, + "y": 795 + }, + { + "id": "place__9cb073fb-f1d7-4613-8b10-8d1b08796f24", + "name": "MachinesToRepair", + "colorId": "type__1762560152725", + "dynamicsEnabled": false, + "differentialEquationId": null, + "x": 1110, + "y": 585 + } + ], + "transitions": [ + { + "id": "transition__76f23aa1-404a-4696-ac14-5a634af01221", + "name": "Production Success", + "inputArcs": [ + { + "placeId": "place__81e551b4-11dc-4781-9cd7-dd882fd7e947", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__7b695ff5-a397-4237-8e30-ddf8cbc9e2c4", + "weight": 1 + }, + { + "placeId": "place__2bdd959f-a5bc-404a-bd03-34fafcef66b8", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "// Production finishes when the batch's transformation progress (advanced by\n// the Production Dynamics) reaches 100%.\nexport default Lambda((tokens) => {\n return tokens.MachinesProducing[0].transformation_progress >= 1;\n})", + "transitionKernelCode": "// On success the machine returns to the AvailableMachines pool, carrying its\n// accumulated damage with it. The GoodProduct output place is uncoloured, so\n// it simply gains a token (no attributes to set here).\nexport default TransitionKernel((tokensByPlace) => {\n return {\n AvailableMachines: [\n { machine_damage_ratio: tokensByPlace.MachinesProducing[0].machine_damage_ratio }\n ],\n };\n});", + "x": 720, + "y": -225 + }, + { + "id": "transition__b524484d-263e-4065-b8b2-7a8e49529260", + "name": "Machine Fail", + "inputArcs": [ + { + "placeId": "place__81e551b4-11dc-4781-9cd7-dd882fd7e947", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__d5f92ae2-c8c4-49cb-935e-4a35e4f7b5fe", + "weight": 1 + }, + { + "placeId": "place__e5af0410-d80a-4c8b-b3bf-692918b98e6c", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "// Failure competes with Production Success on the same producing machine.\n// Raising the damage ratio (in [0,1]) to the 100th power keeps the hazard\n// almost zero for healthy machines and only spikes as damage approaches 1,\n// so machines mostly fail when they are already badly worn.\nexport default Lambda((tokens) => {\n return tokens.MachinesProducing[0].machine_damage_ratio ** 100;\n})", + "transitionKernelCode": "// A failed machine moves to BrokenMachines, keeping its damage ratio so the\n// downstream repair flow knows how much damage to undo.\nexport default TransitionKernel((tokens) => {\n return {\n BrokenMachines: [\n {\n machine_damage_ratio: tokens.MachinesProducing[0].machine_damage_ratio\n }\n ],\n };\n});", + "x": 720, + "y": -105 + }, + { + "id": "transition__c4b30ba4-da08-4407-b97b-41e2db5d6879", + "name": "Start Production", + "inputArcs": [ + { + "placeId": "place__d662407f-c56d-4a96-bcbb-ead785a9c594", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__2bdd959f-a5bc-404a-bd03-34fafcef66b8", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__81e551b4-11dc-4781-9cd7-dd882fd7e947", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "// Always enabled: production starts as soon as the input arcs are satisfied\n// (one RawMaterial token and one AvailableMachine token).\nexport default Lambda(() => true)", + "transitionKernelCode": "// Move the chosen machine into MachinesProducing with progress reset to 0,\n// preserving whatever damage it has already accumulated.\nexport default TransitionKernel((tokensByPlace) => {\n return {\n MachinesProducing: [\n {\n machine_damage_ratio: tokensByPlace.AvailableMachines[0].machine_damage_ratio,\n transformation_progress: 0\n }\n ],\n };\n});", + "x": 90, + "y": -225 + }, + { + "id": "transition__cc61df1f-00f3-456f-8a80-03e8b68f3007", + "name": "Finish Repair", + "inputArcs": [ + { + "placeId": "place__17c65d6e-0c3e-48e6-a677-2914e28131ac", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__2bdd959f-a5bc-404a-bd03-34fafcef66b8", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "// Repair is complete once the Reparation Dynamics have driven the machine's\n// damage ratio down to 0 (or below).\nexport default Lambda((tokens) => {\n return tokens.MachinesBeingRepaired[0].machine_damage_ratio <= 0;\n})", + "transitionKernelCode": "// Return the fully-repaired machine to the AvailableMachines pool with its\n// damage reset to 0, ready to start producing again.\nexport default TransitionKernel((tokensByPlace, parameters) => {\n return {\n AvailableMachines: [\n { machine_damage_ratio: 0 }\n ],\n };\n});", + "x": -330, + "y": 405 + }, + { + "id": "transition__11f0b21a-d0f2-4bd5-b4c1-d23627f921c5", + "name": "Call Technician", + "inputArcs": [ + { + "placeId": "place__e5af0410-d80a-4c8b-b3bf-692918b98e6c", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__4b72cf19-907b-4fc0-ac0a-555453e95d4b", + "weight": 1 + }, + { + "placeId": "place__9cb073fb-f1d7-4613-8b10-8d1b08796f24", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "// Always enabled: as soon as a machine is broken we dispatch a technician.\nexport default Lambda(() => true)", + "transitionKernelCode": "// Park the broken machine in MachinesToRepair (passing the token straight\n// through) and dispatch a technician who starts 10 units away; the Technician\n// Travel Dynamics then count that distance down to 0.\nexport default TransitionKernel((tokens, parameters) => {\n return {\n MachinesToRepair: tokens.BrokenMachines,\n TechniciansComing: [\n { distance_to_site: 10 }\n ],\n };\n});", + "x": 570, + "y": 735 + }, + { + "id": "transition__514730c0-7ac5-47d5-8def-91446a248a83", + "name": "Technician Ready", + "inputArcs": [ + { + "placeId": "place__4b72cf19-907b-4fc0-ac0a-555453e95d4b", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__eaca89b8-1db1-45fa-8c3a-6eb6f0419ffa", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "// The technician has arrived once their remaining travel distance hits 0.\nexport default Lambda((tokens) => {\n return tokens.TechniciansComing[0].distance_to_site <= 0;\n})", + "transitionKernelCode": "// The arrived technician joins the AvailableTechnicians pool, ready to be\n// paired with a machine in the Start Repair transition.\nexport default TransitionKernel((tokensByPlace, parameters) => {\n return {\n AvailableTechnicians: [\n { distance_to_site: 0 }\n ],\n };\n});", + "x": 1125, + "y": 795 + }, + { + "id": "transition__0efcd1bf-b1ff-466f-8a8f-c329ddce0ce8", + "name": "Start Repair", + "inputArcs": [ + { + "placeId": "place__eaca89b8-1db1-45fa-8c3a-6eb6f0419ffa", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place__9cb073fb-f1d7-4613-8b10-8d1b08796f24", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place__17c65d6e-0c3e-48e6-a677-2914e28131ac", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "// Always enabled: repair begins as soon as both input arcs are satisfied,\n// i.e. an available technician AND a machine waiting to be repaired.\nexport default Lambda(() => true)", + "transitionKernelCode": "// Pair the technician with the waiting machine: move it into\n// MachinesBeingRepaired (carrying its current damage), where the Reparation\n// Dynamics will steadily reduce the damage until Finish Repair fires.\nexport default TransitionKernel((tokens) => {\n return {\n MachinesBeingRepaired: [\n { machine_damage_ratio: tokens.MachinesToRepair[0].machine_damage_ratio }\n ],\n };\n});", + "x": 1635, + "y": 480 + } + ], + "types": [ + { + "id": "type__1762560152725", + "name": "Machine", + "iconSlug": "circle", + "displayColor": "#3b82f6", + "elements": [ + { + "elementId": "element__1762560152725_3", + "name": "machine_damage_ratio", + "type": "real" + } + ] + }, + { + "id": "type__1762560154179", + "name": "Machine Producing Product", + "iconSlug": "circle", + "displayColor": "#733bf6ff", + "elements": [ + { + "elementId": "element__1762560154179_2", + "name": "machine_damage_ratio", + "type": "real" + }, + { + "elementId": "element__1762560154179_3", + "name": "transformation_progress", + "type": "real" + } + ] + }, + { + "id": "type__1762560159263", + "name": "Technician", + "iconSlug": "circle", + "displayColor": "#3bf689ff", + "elements": [ + { + "elementId": "element__1762560159263_2", + "name": "distance_to_site", + "type": "real" + } + ] + } + ], + "differentialEquations": [ + { + "id": "5bfea547-faaf-4626-8662-6400d07c049e", + "name": "Reparation Dynamics", + "colorId": "type__1762560152725", + "code": "// Applies to machines in the MachinesBeingRepaired place. Dynamics return the\n// DERIVATIVE of each attribute, so a negative value means the damage ratio\n// falls steadily at the repair rate until Finish Repair fires at 0.\nexport default Dynamics((tokens, parameters) => {\n return tokens.map(({ machine_damage_ratio }) => {\n return {\n machine_damage_ratio: -parameters.damage_reparation_per_second\n };\n });\n});" + }, + { + "id": "ca26e5e2-0373-46a9-920e-a6eacadd92e8", + "name": "Production Dynamics", + "colorId": "type__1762560154179", + "code": "// Applies to machines actively producing (the MachinesProducing place).\n// While producing, the machine accumulates damage at `damage_per_second` and\n// its transformation_progress advances at 0.5/sec (so a batch takes ~2 sec to\n// reach the progress >= 1 completion threshold). More time producing means\n// more accumulated damage, which feeds the Machine Fail hazard.\nexport default Dynamics((tokens, parameters) => {\n return tokens.map(({ machine_damage_ratio, transformation_progress }) => {\n return {\n machine_damage_ratio: parameters.damage_per_second,\n transformation_progress: 1 / 2\n };\n });\n});" + }, + { + "id": "887245c3-183c-4dac-a1aa-d602d21b6450", + "name": "Technician Travel Dynamics", + "colorId": "type__1762560159263", + "code": "// Applies to technicians in the TechniciansComing place. They close the gap\n// to the site at a constant 2 units/sec; once distance_to_site reaches 0 the\n// Technician Ready transition can fire. (Dynamics return derivatives.)\nexport default Dynamics((tokens, parameters) => {\n return tokens.map(({ distance_to_site }) => {\n return {\n distance_to_site: -2\n };\n });\n});" + } + ], + "parameters": [ + { + "id": "param__damage_per_second", + "name": "Damage Per Second", + "variableName": "damage_per_second", + "type": "real", + "defaultValue": "0.05" + }, + { + "id": "param__damage_reparation_per_second", + "name": "Damage Reparation Per Second", + "variableName": "damage_reparation_per_second", + "type": "real", + "defaultValue": "0.333" + } + ], + "metrics": [ + { + "id": "metric__good_products", + "name": "Good products", + "description": "Cumulative count of products that passed and shipped.", + "code": "return state.places.GoodProduct.count;" + }, + { + "id": "metric__defective_products", + "name": "Defective products", + "description": "Cumulative count of products that came out defective.", + "code": "return state.places.BadProduct.count;" + }, + { + "id": "metric__yield", + "name": "Yield", + "description": "Share of finished products that were good rather than defective.", + "code": "const good = state.places.GoodProduct.count;\nconst bad = state.places.BadProduct.count;\nconst total = good + bad;\nreturn total === 0 ? 1 : good / total;" + }, + { + "id": "metric__machines_down", + "name": "Machines down", + "description": "Machines that are broken, waiting for a technician, or being repaired (i.e. not producing).", + "code": "return (\n state.places.BrokenMachines.count +\n state.places.MachinesToRepair.count +\n state.places.MachinesBeingRepaired.count\n);" + }, + { + "id": "metric__average_machine_damage", + "name": "Average machine damage", + "description": "Mean damage ratio across machines that are available or currently producing.", + "code": "const fleet = state.places.AvailableMachines.tokens.concat(\n state.places.MachinesProducing.tokens,\n);\nif (fleet.length === 0) return 0;\nreturn fleet.reduce((sum, m) => sum + m.machine_damage_ratio, 0) / fleet.length;" + } + ], + "scenarios": [ + { + "id": "scenario__default_production", + "name": "Default Production", + "description": "Configurable raw material, machine count, and initial machine damage.", + "scenarioParameters": [ + { + "type": "integer", + "identifier": "raw_material", + "default": 10 + }, + { + "type": "integer", + "identifier": "machines_count", + "default": 3 + }, + { + "type": "ratio", + "identifier": "initial_machine_damage", + "default": 0 + } + ], + "parameterOverrides": {}, + "initialState": { + "type": "code", + "content": "return {\n RawMaterial: scenario.raw_material,\n AvailableMachines: Array.from(\n { length: scenario.machines_count },\n () => ({ machine_damage_ratio: scenario.initial_machine_damage }),\n ),\n};" + } + } + ] +} diff --git a/libs/@hashintel/petrinaut-cli/examples/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 {/* Background */}\n \n\n {/* Planet at center */}\n \n\n {/* Satellites */}\n {tokens.map(({ x, y, direction, velocity }, index) => {\n // Convert satellite coordinates to screen coordinates\n // Assuming satellite coordinates are relative to planet center\n const screenX = centerX + x;\n const screenY = centerY + y;\n\n return (\n \n {/* Satellite */}\n \n\n {/* Velocity vector indicator */}\n {velocity > 0 && (\n \n )}\n \n );\n })}\n\n {/* Arrow marker for velocity vectors */}\n \n \n \n \n \n \n );\n});", + "x": 15, + "y": 90 + }, + { + "id": "ea42ba61-03ea-4940-b2e2-b594d5331a71", + "name": "Debris", + "colorId": "f8e9d7c6-b5a4-3210-fedc-ba9876543210", + "dynamicsEnabled": false, + "differentialEquationId": null, + "x": 540, + "y": 90 + } + ], + "transitions": [ + { + "id": "d25015d8-7aac-45ff-82b0-afd943f1b7ec", + "name": "Collision", + "inputArcs": [ + { + "placeId": "3cbc7944-34cb-4eeb-b779-4e392a171fe1", + "weight": 2, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "ea42ba61-03ea-4940-b2e2-b594d5331a71", + "weight": 2 + } + ], + "lambdaType": "predicate", + "lambdaCode": "// Check if two satellites collide (are within collision threshold)\nexport default Lambda((tokens, parameters) => {\n const { 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 Inbound lanes\n \n {tokens.slice(0, 18).map((t, i) => {\n const x = 60 + (1 - Math.min(maxEta, Math.max(0, t.eta)) / maxEta) * 410;\n const y = 58 + (i % 3) * 24;\n const color = t.source < 1.5 ? \"#2563eb\" : \"#7c3aed\";\n return (\n \n \n \n \n \n );\n })}\n {tokens.length} shipments moving toward dock\n \n );\n});", + "showAsInitialState": true, + "x": 405, + "y": 300 + }, + { + "id": "place_wip", + "name": "WorkInProcess", + "colorId": "type_batch", + "dynamicsEnabled": true, + "differentialEquationId": "dyn_batch_processing", + "visualizerCode": "// Factory WIP: one card per in-process batch. The fill bar shows processing\n// progress (toward completion) and the % label is current quality; the bar\n// turns green when quality is above the minimum and red when it has decayed\n// below it (i.e. the batch is heading for scrap).\nexport default Visualization(({ tokens, parameters }) => {\n const width = 520;\n const height = 170;\n const maxTime = Math.max(1, parameters.production_time_mean || 3);\n return (\n \n \n Factory WIP\n {tokens.slice(0, 14).map((b, i) => {\n const progress = 1 - Math.min(maxTime, Math.max(0, b.processing_left)) / maxTime;\n const x = 24 + (i % 7) * 68;\n const y = 46 + Math.floor(i / 7) * 52;\n const fill = b.quality >= parameters.min_quality ? \"#22c55e\" : \"#ef4444\";\n return (\n \n \n \n {Math.round(b.quality * 100)}%\n \n );\n })}\n {tokens.length} batches being processed\n \n );\n});", + "showAsInitialState": true, + "x": 1515, + "y": 300 + }, + { + "id": "place_orders", + "name": "OpenOrders", + "colorId": "type_order", + "dynamicsEnabled": true, + "differentialEquationId": "dyn_order_age", + "visualizerCode": "// Customer queue: one dot per open order. Red = past its promised lead time\n// (late), orange = on-time VIP, pale orange = on-time standard. The bottom\n// bar grows with the queue length.\nexport default Visualization(({ tokens, parameters }) => {\n const width = 520;\n const height = 170;\n const urgent = tokens.filter(o => o.age > o.promised_lead_time).length;\n return (\n \n \n Customer queue\n {tokens.slice(0, 40).map((o, i) => {\n const x = 24 + (i % 20) * 23;\n const y = 48 + Math.floor(i / 20) * 34;\n const late = o.age > o.promised_lead_time;\n const fill = late ? \"#dc2626\" : (o.priority > 0.5 ? \"#f97316\" : \"#fdba74\");\n return ;\n })}\n {tokens.length} open orders · {urgent} late\n \n \n );\n});", + "showAsInitialState": true, + "x": 2085, + "y": 585 + }, + { + "id": "place_backorders", + "name": "Backorders", + "colorId": "type_order", + "dynamicsEnabled": true, + "differentialEquationId": "dyn_order_age", + "visualizerCode": "// Backorder heat: one bar per backorder, taller the longer it has waited.\n// Dark red bars are VIP orders. Tall bars signal customers about to cancel.\nexport default Visualization(({ tokens, parameters }) => {\n const width = 520;\n const height = 150;\n const maxAge = tokens.reduce((m, o) => Math.max(m, o.age), 0);\n return (\n \n \n Backorder heat\n {tokens.slice(0, 30).map((o, i) => {\n const h = Math.min(90, 12 + o.age * 6);\n const x = 22 + i * 16;\n return 0.5 ? \"#7f1d1d\" : \"#ef4444\"} opacity=\"0.85\" />;\n })}\n {tokens.length} waiting · oldest {maxAge.toFixed(1)} days\n \n );\n});", + "showAsInitialState": true, + "x": 2640, + "y": 585 + }, + { + "id": "place_outbound", + "name": "OutboundShipments", + "colorId": "type_shipment", + "dynamicsEnabled": true, + "differentialEquationId": "dyn_shipment_eta", + "visualizerCode": "// Last-mile deliveries: outbound shipments (vans) move left-to-right as\n// their ETA counts down to 0, at which point they are delivered (or lost).\nexport default Visualization(({ tokens, parameters }) => {\n const width = 520;\n const height = 150;\n const maxEta = Math.max(1, parameters.outbound_lead_time * 2 || 3);\n return (\n \n \n Last-mile deliveries\n \n {tokens.slice(0, 20).map((t, i) => {\n const x = 55 + (1 - Math.min(maxEta, Math.max(0, t.eta)) / maxEta) * 415;\n const y = 60 + (i % 2) * 30;\n return (\n \n \n \n \n );\n })}\n {tokens.length} customer shipments in transit\n \n );\n});", + "showAsInitialState": true, + "x": 3195, + "y": 420 + }, + { + "id": "place_machine_up", + "name": "MachineUp", + "colorId": "type_machine", + "dynamicsEnabled": true, + "differentialEquationId": "dyn_machine_health", + "visualizerCode": "export default Visualization(({ tokens, parameters }) => {\n const m = tokens[0] || { health: 0, wear: 1 };\n const health = Math.max(0, Math.min(1, m.health));\n const wear = Math.max(0, Math.min(1, m.wear));\n return (\n \n \n Machine condition\n \n 0.5 ? \"#22c55e\" : \"#f59e0b\"} />\n health\n \n \n wear\n \n );\n});", + "showAsInitialState": true, + "x": 960, + "y": 735 + }, + { + "id": "place_machine_down", + "name": "MachineDown", + "colorId": "type_machine", + "dynamicsEnabled": false, + "differentialEquationId": null, + "visualizerCode": "", + "showAsInitialState": true, + "x": 405, + "y": 705 + } + ], + "transitions": [ + { + "id": "trans_order_supplier_a", + "name": "Order from reliable supplier A", + "inputArcs": [ + { + "placeId": "place_supplier_a_available", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place_supplier_a_available", + "weight": 1 + }, + { + "placeId": "place_inbound", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "// Supplier A places orders at a constant configured rate (only while\n// Supplier A is in the Available place, which gates this transition).\nexport default Lambda((input, parameters) => {\n return parameters.supplier_a_order_rate;\n});", + "transitionKernelCode": "// Create one inbound Shipment token from Supplier A.\nexport default TransitionKernel((input, parameters) => {\n // Lead time is sampled from a Gaussian whose spread scales with the mean\n // (a coefficient of variation), then clamped so the ETA is always positive.\n const mean = Math.max(0.1, parameters.supplier_a_lead_time);\n const sd = Math.max(0.01, mean * parameters.lead_time_cv);\n const eta = Distribution.Gaussian(mean, sd).map(v => Math.max(0.1, v));\n return {\n InboundShipments: [{\n eta,\n // risk_score in [0,1]; the multiplier makes A more or less reliable.\n risk_score: Distribution.Uniform(0, 1).map(v => Math.min(1, v * parameters.supplier_a_risk_multiplier)),\n source: 1, // 1 = Supplier A (used by the inbound visualizer for colour).\n cost: parameters.supplier_a_cost,\n }],\n };\n});", + "x": 120, + "y": 180 + }, + { + "id": "trans_order_supplier_b", + "name": "Order from low-cost supplier B", + "inputArcs": [ + { + "placeId": "place_supplier_b_available", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place_supplier_b_available", + "weight": 1 + }, + { + "placeId": "place_inbound", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "// Low-cost Supplier B orders at its own rate while it is Available.\nexport default Lambda((input, parameters) => {\n return parameters.supplier_b_order_rate;\n});", + "transitionKernelCode": "// Same shipment-creation logic as Supplier A, but with B's lead time,\n// risk multiplier, and cost — typically cheaper but slower and riskier.\nexport default TransitionKernel((input, parameters) => {\n const mean = Math.max(0.1, parameters.supplier_b_lead_time);\n const sd = Math.max(0.01, mean * parameters.lead_time_cv);\n const eta = Distribution.Gaussian(mean, sd).map(v => Math.max(0.1, v));\n return {\n InboundShipments: [{\n eta,\n risk_score: Distribution.Uniform(0, 1).map(v => Math.min(1, v * parameters.supplier_b_risk_multiplier)),\n source: 2, // 2 = Supplier B.\n cost: parameters.supplier_b_cost,\n }],\n };\n});", + "x": 120, + "y": 525 + }, + { + "id": "trans_supplier_a_disrupts", + "name": "Supplier A disruption", + "inputArcs": [ + { + "placeId": "place_supplier_a_available", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place_supplier_a_down", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "// Supplier A randomly becomes disrupted: this moves the Supplier A token\n// from Available to Down, which then stops new Supplier A orders until it\n// recovers. The rate is constant (a memoryless time-to-failure).\nexport default Lambda((input, parameters) => {\n return parameters.supplier_a_disruption_rate;\n});", + "transitionKernelCode": "// This transition only routes/marks tokens — the destination place needs\n// no computed attributes — so the kernel returns no token data.\nexport default TransitionKernel(() => ({}));", + "x": 675, + "y": 150 + }, + { + "id": "trans_supplier_a_recovers", + "name": "Supplier A recovers", + "inputArcs": [ + { + "placeId": "place_supplier_a_down", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place_supplier_a_available", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "// A disrupted Supplier A recovers at this rate, returning to Available.\nexport default Lambda((input, parameters) => {\n return parameters.supplier_a_repair_rate;\n});", + "transitionKernelCode": "// This transition only routes/marks tokens — the destination place needs\n// no computed attributes — so the kernel returns no token data.\nexport default TransitionKernel(() => ({}));", + "x": 120, + "y": 90 + }, + { + "id": "trans_supplier_b_disrupts", + "name": "Supplier B disruption", + "inputArcs": [ + { + "placeId": "place_supplier_b_available", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place_supplier_b_down", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "// Supplier B disruption — same Available -> Down mechanism as Supplier A.\nexport default Lambda((input, parameters) => {\n return parameters.supplier_b_disruption_rate;\n});", + "transitionKernelCode": "// This transition only routes/marks tokens — the destination place needs\n// no computed attributes — so the kernel returns no token data.\nexport default TransitionKernel(() => ({}));", + "x": 675, + "y": 585 + }, + { + "id": "trans_supplier_b_recovers", + "name": "Supplier B recovers", + "inputArcs": [ + { + "placeId": "place_supplier_b_down", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place_supplier_b_available", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "// A disrupted Supplier B recovers at this rate, returning to Available.\nexport default Lambda((input, parameters) => {\n return parameters.supplier_b_repair_rate;\n});", + "transitionKernelCode": "// This transition only routes/marks tokens — the destination place needs\n// no computed attributes — so the kernel returns no token data.\nexport default TransitionKernel(() => ({}));", + "x": 1245, + "y": 195 + }, + { + "id": "trans_inbound_good", + "name": "Inbound shipment received", + "inputArcs": [ + { + "placeId": "place_inbound", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place_raw_materials", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "// An inbound shipment is received cleanly once it has arrived (eta counted\n// down to 0) AND its risk score is below the damage threshold. Higher-risk\n// shipments instead fire the competing \"damaged\" transition below.\nexport default Lambda((input, parameters) => {\n const shipment = input.InboundShipments[0];\n return shipment.eta <= 0 && shipment.risk_score < parameters.inbound_damage_threshold;\n});", + "transitionKernelCode": "// This transition only routes/marks tokens — the destination place needs\n// no computed attributes — so the kernel returns no token data.\nexport default TransitionKernel(() => ({}));", + "x": 675, + "y": 285 + }, + { + "id": "trans_inbound_damaged", + "name": "Inbound shipment damaged", + "inputArcs": [ + { + "placeId": "place_inbound", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place_damaged_inbound", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "// The mutually-exclusive partner of \"received\": an arrived shipment whose\n// risk score is at or above the threshold is scrapped as damaged inbound.\nexport default Lambda((input, parameters) => {\n const shipment = input.InboundShipments[0];\n return shipment.eta <= 0 && shipment.risk_score >= parameters.inbound_damage_threshold;\n});", + "transitionKernelCode": "// This transition only routes/marks tokens — the destination place needs\n// no computed attributes — so the kernel returns no token data.\nexport default TransitionKernel(() => ({}));", + "x": 675, + "y": 435 + }, + { + "id": "trans_start_production", + "name": "Start production batch", + "inputArcs": [ + { + "placeId": "place_raw_materials", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place_machine_up", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place_machine_up", + "weight": 1 + }, + { + "placeId": "place_wip", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "// Production only starts when the machine is healthy enough; below the\n// minimum-health threshold the rate is 0, so a worn machine effectively\n// stalls the line until it is repaired or maintained.\nexport default Lambda((input, parameters) => {\n const machine = input.MachineUp[0];\n return machine.health >= parameters.min_machine_health ? parameters.production_rate : 0;\n});", + "transitionKernelCode": "// Consume raw material + the machine, and emit a new WorkInProcess batch\n// while returning the (now slightly more worn) machine to MachineUp.\nexport default TransitionKernel((input, parameters) => {\n const machine = input.MachineUp[0];\n // Remaining processing time for the batch, sampled and floored at 0.25.\n const processing = Distribution.Gaussian(parameters.production_time_mean, parameters.production_time_sd).map(v => Math.max(0.25, v));\n // Each batch adds wear and removes a little health from the machine.\n const wear = Math.min(1, machine.wear + parameters.wear_per_pallet);\n const health = Math.max(0, machine.health - parameters.wear_per_pallet * 0.5);\n return {\n MachineUp: [{ health, wear }],\n WorkInProcess: [{\n processing_left: processing,\n // Starting quality degrades as the machine wears; clamped to [0, 1].\n quality: Distribution.Gaussian(0.96 - wear * parameters.wear_quality_penalty, 0.06).map(q => Math.max(0, Math.min(1, q))),\n source_mix: 0.5,\n cost: parameters.production_unit_cost + wear * parameters.wear_cost_penalty,\n }],\n };\n});", + "x": 1245, + "y": 300 + }, + { + "id": "trans_finish_good_batch", + "name": "Batch passes quality", + "inputArcs": [ + { + "placeId": "place_wip", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place_finished_goods", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "// A batch becomes a finished good once processing is complete AND its\n// (wear- and decay-affected) quality still meets the minimum standard.\nexport default Lambda((input, parameters) => {\n const batch = input.WorkInProcess[0];\n return batch.processing_left <= 0 && batch.quality >= parameters.min_quality;\n});", + "transitionKernelCode": "// This transition only routes/marks tokens — the destination place needs\n// no computed attributes — so the kernel returns no token data.\nexport default TransitionKernel(() => ({}));", + "x": 1800, + "y": 435 + }, + { + "id": "trans_scrap_bad_batch", + "name": "Batch fails quality", + "inputArcs": [ + { + "placeId": "place_wip", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place_scrap", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "// The competing outcome to \"passes quality\": a finished batch below the\n// minimum quality is sent to Scrap instead of FinishedGoods.\nexport default Lambda((input, parameters) => {\n const batch = input.WorkInProcess[0];\n return batch.processing_left <= 0 && batch.quality < parameters.min_quality;\n});", + "transitionKernelCode": "// This transition only routes/marks tokens — the destination place needs\n// no computed attributes — so the kernel returns no token data.\nexport default TransitionKernel(() => ({}));", + "x": 1800, + "y": 285 + }, + { + "id": "trans_machine_breakdown_random", + "name": "Machine breakdown", + "inputArcs": [ + { + "placeId": "place_machine_up", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place_machine_down", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "// Breakdown hazard rises with wear and with poor health: a worn or unhealthy\n// machine fails far more often than the base rate. This is what makes\n// preventive maintenance worthwhile.\nexport default Lambda((input, parameters) => {\n const machine = input.MachineUp[0];\n return parameters.machine_breakdown_rate * (1 + machine.wear * 3 + Math.max(0, 0.5 - machine.health));\n});", + "transitionKernelCode": "// Move the machine to the Down place, knocking off a chunk of health.\nexport default TransitionKernel((input) => {\n const machine = input.MachineUp[0];\n return { MachineDown: [{ health: Math.max(0, machine.health - 0.15), wear: machine.wear }] };\n});", + "x": 120, + "y": 720 + }, + { + "id": "trans_machine_repair", + "name": "Repair machine", + "inputArcs": [ + { + "placeId": "place_machine_down", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place_machine_up", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "// A broken machine is repaired at a constant rate (mean time-to-repair).\nexport default Lambda((input, parameters) => {\n return parameters.machine_repair_rate;\n});", + "transitionKernelCode": "// Repair restores most health and removes most wear, returning the machine\n// to the Up place — a large but incomplete reset compared with maintenance.\nexport default TransitionKernel((input) => {\n const machine = input.MachineDown[0];\n return { MachineUp: [{ health: Math.min(1, machine.health + 0.55), wear: Math.max(0, machine.wear * 0.45) }] };\n});", + "x": 675, + "y": 705 + }, + { + "id": "trans_preventive_maintenance", + "name": "Preventive maintenance", + "inputArcs": [ + { + "placeId": "place_machine_up", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place_machine_up", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "// Preventive maintenance happens on the running machine (Up -> Up). It is\n// performed much more eagerly once wear passes 25%, and only occasionally\n// otherwise, so the strategy is \"service it before it breaks\".\nexport default Lambda((input, parameters) => {\n const machine = input.MachineUp[0];\n return machine.wear > 0.25 ? parameters.maintenance_rate * (1 + machine.wear) : parameters.maintenance_rate * 0.2;\n});", + "transitionKernelCode": "// Maintenance boosts health and shaves off some wear without taking the\n// machine offline — cheaper and less disruptive than a full repair.\nexport default TransitionKernel((input, parameters) => {\n const machine = input.MachineUp[0];\n return { MachineUp: [{ health: Math.min(1, machine.health + parameters.maintenance_health_boost), wear: Math.max(0, machine.wear * 0.65) }] };\n});", + "x": 1245, + "y": 765 + }, + { + "id": "trans_customer_demand", + "name": "Customer demand arrives", + "inputArcs": [], + "outputArcs": [ + { + "placeId": "place_orders", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "// Customers arrive at a constant rate. This transition has no input arcs,\n// so it acts as an external (Poisson) arrival source for new orders.\nexport default Lambda((input, parameters) => {\n return parameters.demand_rate;\n});", + "transitionKernelCode": "// Create one new open order, classified as VIP or standard.\nexport default TransitionKernel((input, parameters) => {\n // Draw ONE uniform sample and reuse it via .map() so that priority and the\n // promised lead time stay consistent (a VIP both has priority 1 AND the\n // shorter promise). Sampling twice could give contradictory results.\n const priorityDraw = Distribution.Uniform(0, 1);\n return {\n OpenOrders: [{\n age: 0,\n priority: priorityDraw.map(v => v < parameters.vip_fraction ? 1 : 0),\n promised_lead_time: priorityDraw.map(v => v < parameters.vip_fraction ? 1.5 : 3.5),\n }],\n };\n});", + "x": 1800, + "y": 585 + }, + { + "id": "trans_fulfill_open_order", + "name": "Fulfill open order from stock", + "inputArcs": [ + { + "placeId": "place_orders", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place_finished_goods", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place_outbound", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "// Fulfil an order directly from stock. This transition needs both an open\n// order AND a finished good (two standard input arcs), so it only fires when\n// inventory is available. VIP orders are served faster via a rate boost.\nexport default Lambda((input, parameters) => {\n const order = input.OpenOrders[0];\n const priorityBoost = order.priority > 0.5 ? 1.6 : 1;\n return parameters.fulfillment_rate * priorityBoost;\n});", + "transitionKernelCode": "// Turn the fulfilled order into an outbound shipment.\nexport default TransitionKernel((input, parameters) => {\n const order = input.OpenOrders[0];\n const eta = Distribution.Gaussian(parameters.outbound_lead_time, parameters.outbound_lead_time * 0.25).map(v => Math.max(0.05, v));\n return {\n OutboundShipments: [{\n eta,\n risk_score: Distribution.Uniform(0, 1),\n source: order.priority > 0.5 ? 9 : 8, // 8/9 mark standard/VIP from-stock shipments.\n cost: parameters.outbound_cost + (order.priority > 0.5 ? 3 : 0),\n }],\n };\n});", + "x": 2355, + "y": 450 + }, + { + "id": "trans_convert_to_backorder", + "name": "Convert aging order to backorder", + "inputArcs": [ + { + "placeId": "place_orders", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place_finished_goods", + "weight": 1, + "type": "inhibitor" + } + ], + "outputArcs": [ + { + "placeId": "place_backorders", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "// An aging order with no stock to fill it becomes a backorder. The inhibitor\n// arc from FinishedGoods means this can ONLY fire when stock is empty, and we\n// also wait until the order has exceeded its promised lead time (rate 0 until\n// then). VIP orders are escalated to backorder sooner.\nexport default Lambda((input, parameters) => {\n const order = input.OpenOrders[0];\n if (order.age < order.promised_lead_time) return 0;\n return parameters.backorder_conversion_rate * (order.priority > 0.5 ? 1.8 : 1);\n});", + "transitionKernelCode": "// Carry the order's age, priority, and promise across to the Backorders place.\nexport default TransitionKernel((input) => {\n const order = input.OpenOrders[0];\n return { Backorders: [{ age: order.age, priority: order.priority, promised_lead_time: order.promised_lead_time }] };\n});", + "x": 2355, + "y": 585 + }, + { + "id": "trans_fulfill_backorder", + "name": "Fulfill backorder", + "inputArcs": [ + { + "placeId": "place_backorders", + "weight": 1, + "type": "standard" + }, + { + "placeId": "place_finished_goods", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place_outbound", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "// Once stock is replenished, backorders are cleared (again favouring VIPs).\n// Requires both a backorder and a finished good, so it competes with new\n// open-order fulfilment for the same inventory.\nexport default Lambda((input, parameters) => {\n const order = input.Backorders[0];\n return parameters.backorder_fulfillment_rate * (order.priority > 0.5 ? 1.7 : 1);\n});", + "transitionKernelCode": "// Ship the backordered item. Late backorders cost more (the age surcharge),\n// reflecting expediting and goodwill costs.\nexport default TransitionKernel((input, parameters) => {\n const order = input.Backorders[0];\n return {\n OutboundShipments: [{\n eta: Distribution.Gaussian(parameters.outbound_lead_time, parameters.outbound_lead_time * 0.35).map(v => Math.max(0.05, v)),\n risk_score: Distribution.Uniform(0, 1),\n source: order.priority > 0.5 ? 7 : 6, // 6/7 mark standard/VIP backorder shipments.\n cost: parameters.outbound_cost + 2 + order.age * 0.1,\n }],\n };\n});", + "x": 2925, + "y": 390 + }, + { + "id": "trans_cancel_backorder", + "name": "Customer cancels backorder", + "inputArcs": [ + { + "placeId": "place_backorders", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place_cancelled", + "weight": 1 + } + ], + "lambdaType": "stochastic", + "lambdaCode": "// Waiting customers may give up. The cancellation hazard grows with the\n// order's age, so long-unfilled backorders are increasingly likely to be\n// lost. VIPs are modelled as slightly more patient (lower multiplier).\nexport default Lambda((input, parameters) => {\n const order = input.Backorders[0];\n const vipPatience = order.priority > 0.5 ? 0.6 : 1;\n return vipPatience * (parameters.cancel_base_rate + order.age * parameters.cancel_age_factor);\n});", + "transitionKernelCode": "// This transition only routes/marks tokens — the destination place needs\n// no computed attributes — so the kernel returns no token data.\nexport default TransitionKernel(() => ({}));", + "x": 2925, + "y": 615 + }, + { + "id": "trans_outbound_delivered", + "name": "Outbound delivered", + "inputArcs": [ + { + "placeId": "place_outbound", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place_delivered", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "// Last-mile delivery succeeds when the outbound shipment has arrived and its\n// risk score is below the loss threshold — the demand-side mirror of the\n// inbound received/damaged split.\nexport default Lambda((input, parameters) => {\n const shipment = input.OutboundShipments[0];\n return shipment.eta <= 0 && shipment.risk_score < parameters.outbound_loss_threshold;\n});", + "transitionKernelCode": "// This transition only routes/marks tokens — the destination place needs\n// no computed attributes — so the kernel returns no token data.\nexport default TransitionKernel(() => ({}));", + "x": 3480, + "y": 540 + }, + { + "id": "trans_outbound_lost", + "name": "Outbound lost or damaged", + "inputArcs": [ + { + "placeId": "place_outbound", + "weight": 1, + "type": "standard" + } + ], + "outputArcs": [ + { + "placeId": "place_lost", + "weight": 1 + } + ], + "lambdaType": "predicate", + "lambdaCode": "// The competing outcome to delivery: an arrived shipment at or above the\n// loss threshold is lost in transit and never reaches the customer.\nexport default Lambda((input, parameters) => {\n const shipment = input.OutboundShipments[0];\n return shipment.eta <= 0 && shipment.risk_score >= parameters.outbound_loss_threshold;\n});", + "transitionKernelCode": "// This transition only routes/marks tokens — the destination place needs\n// no computed attributes — so the kernel returns no token data.\nexport default TransitionKernel(() => ({}));", + "x": 3480, + "y": 390 + } + ], + "types": [ + { + "id": "type_shipment", + "name": "Shipment", + "iconSlug": "truck", + "displayColor": "#2563eb", + "elements": [ + { + "elementId": "ship_eta", + "name": "eta", + "type": "real" + }, + { + "elementId": "ship_risk", + "name": "risk_score", + "type": "real" + }, + { + "elementId": "ship_source", + "name": "source", + "type": "integer" + }, + { + "elementId": "ship_cost", + "name": "cost", + "type": "real" + } + ] + }, + { + "id": "type_order", + "name": "Customer order", + "iconSlug": "clipboard", + "displayColor": "#f97316", + "elements": [ + { + "elementId": "order_age", + "name": "age", + "type": "real" + }, + { + "elementId": "order_priority", + "name": "priority", + "type": "real" + }, + { + "elementId": "order_promise", + "name": "promised_lead_time", + "type": "real" + } + ] + }, + { + "id": "type_batch", + "name": "Production batch", + "iconSlug": "box", + "displayColor": "#16a34a", + "elements": [ + { + "elementId": "batch_processing_left", + "name": "processing_left", + "type": "real" + }, + { + "elementId": "batch_quality", + "name": "quality", + "type": "real" + }, + { + "elementId": "batch_source_mix", + "name": "source_mix", + "type": "real" + }, + { + "elementId": "batch_cost", + "name": "cost", + "type": "real" + } + ] + }, + { + "id": "type_machine", + "name": "Factory machine", + "iconSlug": "cog", + "displayColor": "#64748b", + "elements": [ + { + "elementId": "machine_health", + "name": "health", + "type": "real" + }, + { + "elementId": "machine_wear", + "name": "wear", + "type": "real" + } + ] + } + ], + "differentialEquations": [ + { + "id": "dyn_shipment_eta", + "name": "Shipment ETA countdown", + "colorId": "type_shipment", + "code": "// Counts each shipment's ETA down by 1 per simulated unit of time. Dynamics\n// return DERIVATIVES, so eta: -1 means \"decrease eta at rate 1\"; once it\n// reaches 0 the derivative becomes 0 (it holds, ready for arrival predicates).\n// All other attributes are constant during transit (derivative 0).\nexport default Dynamics((tokens, parameters) => {\n return tokens.map(({ eta }) => ({\n eta: eta > 0 ? -1 : 0,\n risk_score: 0,\n cost: 0,\n }));\n});" + }, + { + "id": "dyn_order_age", + "name": "Order aging", + "colorId": "type_order", + "code": "// Every waiting order ages at a constant rate (age derivative = 1), driving\n// the backorder-conversion and cancellation hazards that depend on age.\nexport default Dynamics((tokens, parameters) => {\n return tokens.map(() => ({\n age: 1,\n priority: 0,\n promised_lead_time: 0,\n }));\n});" + }, + { + "id": "dyn_batch_processing", + "name": "Batch processing countdown", + "colorId": "type_batch", + "code": "// While a batch is in process, processing_left counts down toward 0 and its\n// quality slowly decays. So a batch that sits in WIP too long can drift below\n// the minimum quality and end up scrapped rather than finished.\nexport default Dynamics((tokens, parameters) => {\n return tokens.map(({ processing_left, quality, source_mix, cost }) => ({\n processing_left: processing_left > 0 ? -parameters.production_progress_rate : 0,\n // Decay stops at 0 so quality stays within its implied [0, 1] range\n // (a batch is scrapped once it falls below the minimum quality anyway).\n quality: quality > 0 ? -parameters.quality_decay_rate : 0,\n source_mix: 0,\n cost: 0,\n }));\n});" + }, + { + "id": "dyn_machine_health", + "name": "Machine health drift", + "colorId": "type_machine", + "code": "// The running machine continuously loses a little health and gains a little\n// wear even between batches. Combined with the per-batch wear in production,\n// this is the slow degradation that maintenance and repair counteract.\nexport default Dynamics((tokens, parameters) => {\n return tokens.map(({ health, wear }) => ({\n health: health > 0 ? -parameters.machine_health_decay : 0,\n // Wear accrues only up to 1 so it stays within its implied [0, 1] range.\n wear: wear < 1 ? parameters.machine_wear_rate : 0,\n }));\n});" + } + ], + "parameters": [ + { + "id": "param_demand_rate", + "name": "Customer Demand Rate", + "variableName": "demand_rate", + "type": "real", + "defaultValue": "0.55" + }, + { + "id": "param_vip_fraction", + "name": "VIP Order Fraction", + "variableName": "vip_fraction", + "type": "real", + "defaultValue": "0.12" + }, + { + "id": "param_supplier_a_order_rate", + "name": "Supplier A Order Rate", + "variableName": "supplier_a_order_rate", + "type": "real", + "defaultValue": "0.42" + }, + { + "id": "param_supplier_b_order_rate", + "name": "Supplier B Order Rate", + "variableName": "supplier_b_order_rate", + "type": "real", + "defaultValue": "0.28" + }, + { + "id": "param_supplier_a_lead_time", + "name": "Supplier A Mean Lead Time", + "variableName": "supplier_a_lead_time", + "type": "real", + "defaultValue": "4" + }, + { + "id": "param_supplier_b_lead_time", + "name": "Supplier B Mean Lead Time", + "variableName": "supplier_b_lead_time", + "type": "real", + "defaultValue": "7" + }, + { + "id": "param_lead_time_cv", + "name": "Inbound Lead Time CV", + "variableName": "lead_time_cv", + "type": "real", + "defaultValue": "0.35" + }, + { + "id": "param_supplier_a_disruption_rate", + "name": "Supplier A Disruption Rate", + "variableName": "supplier_a_disruption_rate", + "type": "real", + "defaultValue": "0.025" + }, + { + "id": "param_supplier_a_repair_rate", + "name": "Supplier A Recovery Rate", + "variableName": "supplier_a_repair_rate", + "type": "real", + "defaultValue": "0.18" + }, + { + "id": "param_supplier_b_disruption_rate", + "name": "Supplier B Disruption Rate", + "variableName": "supplier_b_disruption_rate", + "type": "real", + "defaultValue": "0.015" + }, + { + "id": "param_supplier_b_repair_rate", + "name": "Supplier B Recovery Rate", + "variableName": "supplier_b_repair_rate", + "type": "real", + "defaultValue": "0.14" + }, + { + "id": "param_supplier_a_cost", + "name": "Supplier A Unit Cost", + "variableName": "supplier_a_cost", + "type": "real", + "defaultValue": "16" + }, + { + "id": "param_supplier_b_cost", + "name": "Supplier B Unit Cost", + "variableName": "supplier_b_cost", + "type": "real", + "defaultValue": "12" + }, + { + "id": "param_supplier_a_risk_multiplier", + "name": "Supplier A Risk Multiplier", + "variableName": "supplier_a_risk_multiplier", + "type": "real", + "defaultValue": "0.75" + }, + { + "id": "param_supplier_b_risk_multiplier", + "name": "Supplier B Risk Multiplier", + "variableName": "supplier_b_risk_multiplier", + "type": "real", + "defaultValue": "1.15" + }, + { + "id": "param_inbound_damage_threshold", + "name": "Inbound Damage Threshold", + "variableName": "inbound_damage_threshold", + "type": "real", + "defaultValue": "0.88" + }, + { + "id": "param_production_rate", + "name": "Production Start Rate", + "variableName": "production_rate", + "type": "real", + "defaultValue": "0.9" + }, + { + "id": "param_production_time_mean", + "name": "Mean Production Time", + "variableName": "production_time_mean", + "type": "real", + "defaultValue": "2.5" + }, + { + "id": "param_production_time_sd", + "name": "Production Time Std Dev", + "variableName": "production_time_sd", + "type": "real", + "defaultValue": "0.65" + }, + { + "id": "param_production_progress_rate", + "name": "Production Progress Rate", + "variableName": "production_progress_rate", + "type": "real", + "defaultValue": "1" + }, + { + "id": "param_quality_decay_rate", + "name": "WIP Quality Decay Rate", + "variableName": "quality_decay_rate", + "type": "real", + "defaultValue": "0.01" + }, + { + "id": "param_min_quality", + "name": "Minimum Acceptable Quality", + "variableName": "min_quality", + "type": "real", + "defaultValue": "0.72" + }, + { + "id": "param_production_unit_cost", + "name": "Production Unit Cost", + "variableName": "production_unit_cost", + "type": "real", + "defaultValue": "8" + }, + { + "id": "param_wear_quality_penalty", + "name": "Wear Quality Penalty", + "variableName": "wear_quality_penalty", + "type": "real", + "defaultValue": "0.18" + }, + { + "id": "param_wear_per_pallet", + "name": "Wear Per Pallet", + "variableName": "wear_per_pallet", + "type": "real", + "defaultValue": "0.012" + }, + { + "id": "param_wear_cost_penalty", + "name": "Wear Cost Penalty", + "variableName": "wear_cost_penalty", + "type": "real", + "defaultValue": "4" + }, + { + "id": "param_min_machine_health", + "name": "Minimum Machine Health", + "variableName": "min_machine_health", + "type": "real", + "defaultValue": "0.18" + }, + { + "id": "param_machine_breakdown_rate", + "name": "Machine Breakdown Rate", + "variableName": "machine_breakdown_rate", + "type": "real", + "defaultValue": "0.012" + }, + { + "id": "param_machine_repair_rate", + "name": "Machine Repair Rate", + "variableName": "machine_repair_rate", + "type": "real", + "defaultValue": "0.22" + }, + { + "id": "param_machine_health_decay", + "name": "Machine Health Decay", + "variableName": "machine_health_decay", + "type": "real", + "defaultValue": "0.006" + }, + { + "id": "param_machine_wear_rate", + "name": "Machine Wear Drift", + "variableName": "machine_wear_rate", + "type": "real", + "defaultValue": "0.002" + }, + { + "id": "param_fulfillment_rate", + "name": "Same-Day Fulfillment Rate", + "variableName": "fulfillment_rate", + "type": "real", + "defaultValue": "1.4" + }, + { + "id": "param_backorder_conversion_rate", + "name": "Backorder Conversion Rate", + "variableName": "backorder_conversion_rate", + "type": "real", + "defaultValue": "1.0" + }, + { + "id": "param_backorder_fulfillment_rate", + "name": "Backorder Fulfillment Rate", + "variableName": "backorder_fulfillment_rate", + "type": "real", + "defaultValue": "0.75" + }, + { + "id": "param_cancel_base_rate", + "name": "Base Cancellation Rate", + "variableName": "cancel_base_rate", + "type": "real", + "defaultValue": "0.015" + }, + { + "id": "param_cancel_age_factor", + "name": "Cancellation Age Factor", + "variableName": "cancel_age_factor", + "type": "real", + "defaultValue": "0.006" + }, + { + "id": "param_outbound_lead_time", + "name": "Outbound Mean Lead Time", + "variableName": "outbound_lead_time", + "type": "real", + "defaultValue": "1.2" + }, + { + "id": "param_outbound_loss_threshold", + "name": "Outbound Loss Threshold", + "variableName": "outbound_loss_threshold", + "type": "real", + "defaultValue": "0.96" + }, + { + "id": "param_outbound_cost", + "name": "Outbound Shipment Cost", + "variableName": "outbound_cost", + "type": "real", + "defaultValue": "5" + }, + { + "id": "param_max_eta_visual", + "name": "Visualizer Max ETA", + "variableName": "max_eta_visual", + "type": "real", + "defaultValue": "10" + }, + { + "id": "param_maintenance_rate", + "name": "Preventive Maintenance Rate", + "variableName": "maintenance_rate", + "type": "real", + "defaultValue": "0.05" + }, + { + "id": "param_maintenance_health_boost", + "name": "Maintenance Health Boost", + "variableName": "maintenance_health_boost", + "type": "real", + "defaultValue": "0.28" + }, + { + "id": "param_initial_raw_materials", + "name": "Initial Raw Materials", + "variableName": "initial_raw_materials", + "type": "integer", + "defaultValue": "8" + }, + { + "id": "param_initial_finished_goods", + "name": "Initial Finished Goods", + "variableName": "initial_finished_goods", + "type": "integer", + "defaultValue": "12" + } + ], + "metrics": [ + { + "id": "metric_service_level", + "name": "Service level", + "description": "Fraction of completed demand outcomes that reached the customer instead of being lost or cancelled.", + "code": "// Of all orders that reached a terminal outcome, the share that were\n// delivered (vs. lost in transit or cancelled while waiting). Returns 1\n// before any outcome exists to avoid dividing by zero.\nconst delivered = state.places.Delivered.count;\nconst failed = state.places.Lost.count + state.places.Cancelled.count;\nconst total = delivered + failed;\nreturn total === 0 ? 1 : delivered / total;" + }, + { + "id": "metric_customer_pressure", + "name": "Customer pressure", + "description": "Total orders currently waiting either as open orders or backorders.", + "code": "return state.places.OpenOrders.count + state.places.Backorders.count;" + }, + { + "id": "metric_stock_position", + "name": "Stock position", + "description": "Finished goods inventory minus customer backorders.", + "code": "return state.places.FinishedGoods.count - state.places.Backorders.count;" + }, + { + "id": "metric_inbound_pipeline", + "name": "Inbound pipeline", + "description": "Number of raw-material shipments currently in transit from both suppliers.", + "code": "return state.places.InboundShipments.count;" + }, + { + "id": "metric_average_inbound_risk", + "name": "Average inbound risk", + "description": "Mean risk score of shipments currently in the inbound pipeline.", + "code": "const shipments = state.places.InboundShipments.tokens;\nif (shipments.length === 0) return 0;\nreturn shipments.reduce((sum, s) => sum + s.risk_score, 0) / shipments.length;" + }, + { + "id": "metric_factory_available", + "name": "Factory available", + "description": "1 when the production machine is up, 0 when it is down.", + "code": "return state.places.MachineUp.count > 0 ? 1 : 0;" + }, + { + "id": "metric_scrap_rate", + "name": "Scrap fraction", + "description": "Share of completed production batches that failed quality inspection.", + "code": "// Scrapped batches as a share of all completed batches. \"Good\" counts every\n// batch that passed quality, wherever it ended up downstream (in stock, in\n// transit, delivered, or even lost) — so this measures production quality,\n// not delivery success.\nconst scrap = state.places.Scrap.count;\nconst good = state.places.FinishedGoods.count + state.places.OutboundShipments.count + state.places.Delivered.count + state.places.Lost.count;\nconst total = scrap + good;\nreturn total === 0 ? 0 : scrap / total;" + }, + { + "id": "metric_supplier_outages", + "name": "Suppliers down", + "description": "How many of the two suppliers are currently disrupted.", + "code": "return state.places.SupplierADown.count + state.places.SupplierBDown.count;" + }, + { + "id": "metric_average_order_age", + "name": "Average waiting order age", + "description": "Average age in days of all unfulfilled customer orders.", + "code": "const orders = state.places.OpenOrders.tokens.concat(state.places.Backorders.tokens);\nif (orders.length === 0) return 0;\nreturn orders.reduce((sum, o) => sum + o.age, 0) / orders.length;" + } + ], + "scenarios": [ + { + "id": "scenario_balanced_dual_source", + "name": "Balanced dual source", + "description": "Baseline: moderate demand, split sourcing between reliable Supplier A and lower-cost Supplier B, normal factory reliability.", + "scenarioParameters": [ + { + "type": "real", + "identifier": "demand_multiplier", + "default": 1 + }, + { + "type": "ratio", + "identifier": "a_share_bias", + "default": 1 + }, + { + "type": "integer", + "identifier": "initial_raw_materials", + "default": 8 + }, + { + "type": "integer", + "identifier": "initial_finished_goods", + "default": 12 + } + ], + "parameterOverrides": { + "param_demand_rate": "parameters.demand_rate * scenario.demand_multiplier", + "param_supplier_a_order_rate": "parameters.supplier_a_order_rate * scenario.a_share_bias", + "param_supplier_b_order_rate": "parameters.supplier_b_order_rate * (2 - scenario.a_share_bias)", + "param_initial_raw_materials": "scenario.initial_raw_materials", + "param_initial_finished_goods": "scenario.initial_finished_goods" + }, + "initialState": { + "type": "per_place", + "content": { + "place_supplier_a_available": "1", + "place_supplier_a_down": "0", + "place_supplier_b_available": "1", + "place_supplier_b_down": "0", + "place_raw_materials": "scenario.initial_raw_materials", + "place_finished_goods": "scenario.initial_finished_goods", + "place_damaged_inbound": "0", + "place_scrap": "0", + "place_delivered": "0", + "place_lost": "0", + "place_cancelled": "0", + "place_machine_up": [[0.95, 0.08]], + "place_machine_down": [], + "place_inbound": [], + "place_wip": [], + "place_orders": [], + "place_backorders": [], + "place_outbound": [] + } + } + }, + { + "id": "scenario_demand_surge_port_congestion", + "name": "Demand surge and port congestion", + "description": "High demand coincides with slower, riskier inbound logistics. Useful for exploring backlog and service-level collapse.", + "scenarioParameters": [ + { + "type": "real", + "identifier": "demand_multiplier", + "default": 1.8 + }, + { + "type": "real", + "identifier": "lead_time_multiplier", + "default": 1.7 + }, + { + "type": "real", + "identifier": "damage_threshold", + "default": 0.78 + }, + { + "type": "integer", + "identifier": "initial_finished_goods", + "default": 18 + } + ], + "parameterOverrides": { + "param_demand_rate": "parameters.demand_rate * scenario.demand_multiplier", + "param_supplier_a_lead_time": "parameters.supplier_a_lead_time * scenario.lead_time_multiplier", + "param_supplier_b_lead_time": "parameters.supplier_b_lead_time * scenario.lead_time_multiplier", + "param_inbound_damage_threshold": "scenario.damage_threshold", + "param_initial_finished_goods": "scenario.initial_finished_goods" + }, + "initialState": { + "type": "per_place", + "content": { + "place_supplier_a_available": "1", + "place_supplier_a_down": "0", + "place_supplier_b_available": "1", + "place_supplier_b_down": "0", + "place_raw_materials": "6", + "place_finished_goods": "scenario.initial_finished_goods", + "place_damaged_inbound": "0", + "place_scrap": "0", + "place_delivered": "0", + "place_lost": "0", + "place_cancelled": "0", + "place_machine_up": [[0.9, 0.12]], + "place_machine_down": [], + "place_inbound": [], + "place_wip": [], + "place_orders": [], + "place_backorders": [], + "place_outbound": [] + } + } + }, + { + "id": "scenario_supplier_a_outage", + "name": "Reliable supplier outage", + "description": "Supplier A starts disrupted, forcing the network toward cheaper but slower Supplier B until A recovers.", + "scenarioParameters": [ + { + "type": "real", + "identifier": "a_recovery_rate", + "default": 0.08 + }, + { + "type": "real", + "identifier": "b_expedite_multiplier", + "default": 1.6 + }, + { + "type": "integer", + "identifier": "initial_finished_goods", + "default": 10 + } + ], + "parameterOverrides": { + "param_supplier_a_repair_rate": "scenario.a_recovery_rate", + "param_supplier_b_order_rate": "parameters.supplier_b_order_rate * scenario.b_expedite_multiplier", + "param_initial_finished_goods": "scenario.initial_finished_goods" + }, + "initialState": { + "type": "per_place", + "content": { + "place_supplier_a_available": "0", + "place_supplier_a_down": "1", + "place_supplier_b_available": "1", + "place_supplier_b_down": "0", + "place_raw_materials": "8", + "place_finished_goods": "scenario.initial_finished_goods", + "place_damaged_inbound": "0", + "place_scrap": "0", + "place_delivered": "0", + "place_lost": "0", + "place_cancelled": "0", + "place_machine_up": [[0.94, 0.1]], + "place_machine_down": [], + "place_inbound": [], + "place_wip": [], + "place_orders": [], + "place_backorders": [], + "place_outbound": [] + } + } + }, + { + "id": "scenario_low_cost_sourcing", + "name": "Low-cost sourcing strategy", + "description": "Procurement leans into Supplier B. Costs fall conceptually, but longer lead times and higher risk stress service performance.", + "scenarioParameters": [ + { + "type": "real", + "identifier": "a_order_multiplier", + "default": 0.35 + }, + { + "type": "real", + "identifier": "b_order_multiplier", + "default": 1.9 + }, + { + "type": "real", + "identifier": "b_risk_multiplier", + "default": 1.45 + }, + { + "type": "integer", + "identifier": "initial_raw_materials", + "default": 12 + } + ], + "parameterOverrides": { + "param_supplier_a_order_rate": "parameters.supplier_a_order_rate * scenario.a_order_multiplier", + "param_supplier_b_order_rate": "parameters.supplier_b_order_rate * scenario.b_order_multiplier", + "param_supplier_b_risk_multiplier": "scenario.b_risk_multiplier", + "param_initial_raw_materials": "scenario.initial_raw_materials" + }, + "initialState": { + "type": "per_place", + "content": { + "place_supplier_a_available": "1", + "place_supplier_a_down": "0", + "place_supplier_b_available": "1", + "place_supplier_b_down": "0", + "place_raw_materials": "scenario.initial_raw_materials", + "place_finished_goods": "8", + "place_damaged_inbound": "0", + "place_scrap": "0", + "place_delivered": "0", + "place_lost": "0", + "place_cancelled": "0", + "place_machine_up": [[0.96, 0.06]], + "place_machine_down": [], + "place_inbound": [], + "place_wip": [], + "place_orders": [], + "place_backorders": [], + "place_outbound": [] + } + } + }, + { + "id": "scenario_resilience_investment", + "name": "Resilience investment", + "description": "Higher safety stock, more preventive maintenance, and faster repair/recovery rates reduce tail-risk events at the cost of extra buffer.", + "scenarioParameters": [ + { + "type": "integer", + "identifier": "initial_raw_materials", + "default": 18 + }, + { + "type": "integer", + "identifier": "initial_finished_goods", + "default": 24 + }, + { + "type": "real", + "identifier": "maintenance_multiplier", + "default": 2.2 + }, + { + "type": "real", + "identifier": "supplier_recovery_multiplier", + "default": 1.8 + } + ], + "parameterOverrides": { + "param_maintenance_rate": "parameters.maintenance_rate * scenario.maintenance_multiplier", + "param_supplier_a_repair_rate": "parameters.supplier_a_repair_rate * scenario.supplier_recovery_multiplier", + "param_supplier_b_repair_rate": "parameters.supplier_b_repair_rate * scenario.supplier_recovery_multiplier", + "param_initial_raw_materials": "scenario.initial_raw_materials", + "param_initial_finished_goods": "scenario.initial_finished_goods" + }, + "initialState": { + "type": "per_place", + "content": { + "place_supplier_a_available": "1", + "place_supplier_a_down": "0", + "place_supplier_b_available": "1", + "place_supplier_b_down": "0", + "place_raw_materials": "scenario.initial_raw_materials", + "place_finished_goods": "scenario.initial_finished_goods", + "place_damaged_inbound": "0", + "place_scrap": "0", + "place_delivered": "0", + "place_lost": "0", + "place_cancelled": "0", + "place_machine_up": [[1, 0.03]], + "place_machine_down": [], + "place_inbound": [], + "place_wip": [], + "place_orders": [], + "place_backorders": [], + "place_outbound": [] + } + } + } + ] +} diff --git a/libs/@hashintel/petrinaut-cli/package.json b/libs/@hashintel/petrinaut-cli/package.json new file mode 100644 index 00000000000..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"