# sim.pflow.xyz — models you can check against their source Turn an informal description (a decision, a process, the mechanism a paper states, or source code via `sim_code_to_flow` where the deployment has an LLM configured) into an executable Petri-net model, then check it: against an event log with `sim_conformance` (fitness, precision, per-trace activities that could not fire), knob by knob with `sim_diagnose`, and run by run with `sim_receipt` (a signed result anyone can replay at `/api/receipts/verify`). Fidelity to a source document is a method here, not yet an engine output. The labels agree, agree-but-unblinded and split come from a daily arXiv model-match study: an LLM builds a model of a paper's stated mechanism with these tools, records its prediction before comparing, and labels each paper `agree`, `disagree`, `split` (at least one claim agrees and at least one disagrees) or `indeterminate`, with a blinding status. agree-but-unblinded means the model agrees but the paper's numbers were seen before the prediction was written, so the agreement may have been built in. No `sim_*` tool computes or returns these labels; they are the LLM's judgement, and a split is where a person has to look. The worked examples below are cost-benefit decisions: sim.pflow.xyz turns any decision with competing costs and benefits into a what-if machine. Should I hire another barista, add a van, or build this feature? Describe the decision as a Petri net — staffing, queues, equipment, patience, or a backlog gated by cost-benefit review — store it with one POST, and every model gets the same instruments: seeded scenario comparison, disruption injection, calibration against your own event log, synthetic event-log datasets, structural diagnostics that rank every knob by measured influence on the declared objective, signed run receipts, and one-click publishing into your own Google Sheets with live charts. The diagnostics pass is the point, not a side feature: it has found capacity that does nothing — a café's pantry stock, a hospital's ED bays — and even capacity that *hurts* the objective: a software team's engineering headcount past the point where the evaluation gate can use it well. "Add more" gets checked instead of assumed, across any domain you can express as tokens moving through places. Staffing a café and deciding what to build next are the same kind of question here: a flow with a cost, a benefit, and a knob someone wants to turn without knowing what it actually does to the outcome. Three properties hold everywhere: - **Content-addressed and immutable.** The id a POST returns is the hash of the model. A changed model is a new id; old ids stay valid forever. - **Pure, seeded reads.** Scenarios never mutate anything. Same model, same scenario body, same surface → byte-identical result. The surfaces fill unset fields differently (see "Defaults per surface" below), so compare runs across surfaces only with `hours`, `realizations` and `seed` set explicitly. - **Honest enforcement.** Every number comes from a discrete engine (stochastic simulation, SSA) that honors what most simulators quietly relax: read arcs gate without consuming, inhibitors block above thresholds, capacities bound post-firing, and a queue is a prerequisite, never an accelerant. Where a reading cannot enforce something, the response names it (`caveats`, `gating`) instead of proceeding silently. Built on the pflow.xyz Petri-net tooling (https://pflow.xyz). Sibling services: https://pilot.pflow.xyz (analysis and code generation MCP) and https://book.pflow.xyz (the book). ## Model format Over MCP, the resource `sim://docs/authoring` is the full guide, with a worked example. Flat JSON, rates per hour; a key the schema does not define is refused by name. Place and transition ids must be non-empty and unique, and no id may name both a place and a transition; beyond that they are free-form (the café uses `staff/available`). One exception: a place named in `simulation.objective` is read as a bare identifier (`[A-Za-z_][A-Za-z0-9_]*`), so give objective places plain names. ```json { "name": "helpdesk", "description": "Tickets arrive, agents resolve them, impatient users give up.", "places": [ { "id": "queue", "initial": 0 }, { "id": "agents", "initial": 2 }, { "id": "working", "initial": 0 }, { "id": "resolved", "initial": 0 }, { "id": "abandoned", "initial": 0 } ], "transitions": [ { "id": "arrive", "rate": 10 }, { "id": "pickup", "rate": 720 }, { "id": "resolve", "rate": 4 }, { "id": "give_up", "rate": 1.5 } ], "arcs": [ { "from": "arrive", "to": "queue" }, { "from": "queue", "to": "pickup", "kinetic": false }, { "from": "agents", "to": "pickup" }, { "from": "pickup", "to": "working" }, { "from": "working", "to": "resolve" }, { "from": "resolve", "to": "agents" }, { "from": "resolve", "to": "resolved" }, { "from": "queue", "to": "give_up" }, { "from": "give_up", "to": "abandoned" } ] } ``` Notes: - A source transition (no input arcs) is a demand: `rate` is arrivals per hour. - A service is two firings: an instant **pickup** (declared rate ≥ 100, its observed gap is the queue wait) that seizes a resource from a pool, and a kinetic completion that returns it. The pool is the staffing knob. The pickup's arc from the queue is `"kinetic": false`: a longer queue does not make a free agent pick up faster, so the queue is a prerequisite of the pickup, not a factor in its rate (the rate law is in `sim://docs/authoring`). - A **hazard** (abandonment, spoilage, timeout) competes for the same queue place with its own rate — patience is a knob too. - `{"from","to","weight","type":"read"}` gates without consuming; `"type":"inhibitor"` blocks while the place holds `weight` or more. `capacity` on a place bounds it after firing. - Tag a terminal place `outcome: loss`, `outcome: served`, or `outcome: none` (a meter, e.g. instance-hours) when structural inference would misread it. - The full field list is a JSON-LD context at https://sim.pflow.xyz/context/model/v1 with a glossary at https://sim.pflow.xyz/ns/v1. `GET /api/models/{id}` with `Accept: application/ld+json` (or `?ld=1`) returns the wrapped form; the plain form is what the id hashes. - Every other JSON-LD document (`sim_diagnose`, `sim_classify`, `/api/models/{id}/classification`, `/api/lineage/{id}`) carries https://sim.pflow.xyz/ns/v1/context as its `@context`; pass `inline_context: true` (MCP) or `?context=inline` (HTTP) to embed the map. - Two more vocabularies sit beside it: https://sim.pflow.xyz/ns/models/v1 (the seven model kinds `@type` may name) and https://sim.pflow.xyz/ns/predicates/v1 (the predicate registry: axis, subject and object kinds, inverse, transitivity, symmetry, writers, SKOS mapping, and a `registryVersion` content id). ## The declared metamodel Four optional blocks move knowledge out of scenarios and into the model, where every reading can see it. Each has a CC0 showcase in the commons. - **`simulation.objective`** — a numeric expression over the final marking, e.g. `"delivered - 0.2*van_hours"`. Diagnostics rank every knob on the objective instead of the inferred loss count, so tradeoffs (timeouts vs instance-hours, service vs overtime) become visible. Pair with `outcome: none` on the meter place. A per-unit cost meter consumes and returns the unit it bills: `vans -> meter -> vans` plus `meter -> van_hours`, so the meter fires at its rate times the number of vans. Not a read arc (`vans -> meter` with `"type": "read"`): a read arc gates but does not multiply, so it bills one unit-hour an hour whether the pool holds one van or six, and the objective ends up recommending the biggest fleet. Showcase: **delivery-fleet**. - **`parameters`** — named structural variables bound to arc weights (several arcs move together and must agree) or place capacities: batch sizes, shelf sizes, boat capacity. Scenarios set them with `"params": {...}`; diagnostics probe each one unit each way, because a *smaller* batch can be the win. Showcase: **bakery-oven**. - **`stages: k`** on a transition — Erlang-k (phase-type) service time via an exact structural expansion that folds back to the original vocabulary. Narrow by design: one kinetic weight-1 input from a dedicated place nothing else touches. Showcase: **car-wash**. - **`schedule`** on a transition — a piecewise-constant day shape (the lunch rush, the overnight lull) carried by the model. Scenario schedules override per transition; diagnostics probe a scheduled source by scaling the whole shape ×1.5. Showcase: **food-truck**. Also declared, not derived: `players` (a 1v1 turn-based game over the objective — `maximizes`, `turnPlace`, per-player transitions), `view` (the presentation intent as prose, hash-safe), and `views[]` (screens as validated projections with roles board | dashboard | controls | advisor | trust | log). The `/evaluate` reading and published apps consume these. ## HTTP API Everything under `/api/` is JSON, except `GET {id}/dataset`, which returns CSV unless `format=jsonl`. Public reads are CORS-open. Only `DELETE`, `/license`, `/publish`, `/publish/compare`, `/api/me/sheets` and `GET /api/models?scope=mine` require a Google sign-in (`/auth/google/login`). `POST /api/models`, `/calibrate` and `/compose` accept anonymous callers: signed in, the new model is yours; anonymous, it is stored **unowned for good** — reachable by id, listed for nobody, and nobody can delete, license or supersede it. Storing the identical model signed in later returns the same id, still unowned (it once made the model that caller's, which let anyone holding an anonymous model's id take it over). Sign in first to own what you create; a changed model (a new name is enough) is a new id. A `GET /api/models/{id}` for a name rather than an id is a `404` that says to search with `?q=` (a model created without sign-in is listed for nobody, so keep the id its create returned), and for a prompt or artifact id one that names `/api/artifacts/{id}`. `POST /api/models` decodes as sim_create_model does: a key the schema does not define is a `400` naming its path and the likely key, and the `201` adds sim_create_model's `warnings` when there are any. It does not carry sim_create_model's `mine` or `note`: the `201` is `id`, `url` and any `warnings`, whoever owns the id. `/receipt` needs no sign-in either. Every endpoint that runs the engine (scenario, compare, receipt, dataset, diagnose, crosscheck, evaluate, classification with `?verify=`) or stores a model (`POST /api/models`, compose, calibrate) is rate-limited per client IP — an IPv6 client per /64, so rotating addresses within one subscriber's block buys nothing — or per account when a bearer is sent: a token bucket of 20, refilled at 1/s by default. Costs: a scenario, receipt, dataset or verified classification 1; a comparison 1 **per scenario** (so a 20-scenario compare empties a full bucket); crosscheck and evaluate 2; a diagnosis 5; storing a model 1 (`POST /api/models`, compose), calibrate 3 (a log replay plus the store); a graph query 2 (`POST /api/query`: a bounded walk plus a stored Query and Result, cached or not), a derivation re-check 1 (`POST /api/derivation/check`), a refutation re-check 1 (`POST /api/refutation/check`), a reachability question 2 (`POST /api/models/{id}/reach`: a search plus a stored question and result, cached or not), its re-check 1 (`POST /api/reach/check`, by value or by stored result id), reading a stored query Result or reach result 1 (`GET /api/lineage/{id}` recomputes its currency, and a reach result's end-to-end re-check), a superseded model's migration offers 1 (`GET /api/models/{id}/migrations`), a System's view 1 (`GET /api/systems/{id}`), a port search 1 (`GET /api/models/{id}/bindable`) and the systems naming a model or binding 1 (`GET /api/systems?naming={id}`). Empty is a `429` with `Retry-After`. JSON bodies are decoded strictly: an unknown field is a `400` naming it, on scenario, compare, receipt, evaluate, compose, calibrate and `/api/receipts/verify` alike. ``` GET /api/catalog curated consoles + the commons ?type=<@type> ?feeds= GET /api/catalog/types tally of @type relations across the catalog GET /api/components subnet template registry GET /api/health liveness (use this, not /healthz, from outside) POST /api/models store a model → {"id","url"} (owned if signed in) GET /api/models models visible to you ?scope=curated|commons|mine&q= (sim_list_models' scope and query; any other parameter, or either one twice, is a 400; scope=mine without a session is a 401 whose login is /auth/google/login) GET /api/models/{id} the model (plain or JSON-LD) DELETE /api/models/{id} owner only; refused once dedicated POST /api/models/{id}/license dedicate: CC0-1.0 | CC-BY-4.0 | CC-BY-SA-4.0 (irrevocable) GET /api/models/{id}/rates declared knobs (pools, sources, patience, parameters) + caveats GET /api/models/{id}/ports declared port.input / port.output connection points GET /api/models/{id}/canonical isomorphism-invariant id (exact automorphism orbits) POST /api/models/{id}/scenario one hypothetical POST /api/models/{id}/scenario/compare several, on one server-enforced seed GET /api/models/{id}/dataset synthetic event log ?seed=&cases=&format=csv|jsonl GET /api/models/{id}/diagnose fitness gates + knob influence ?hours=(≤168)&realizations=(≤200)&maxRealizations=(≤200)&seed=&verbose=1 GET /api/models/{id}/classification parameter classes (fungible-set candidates or verified sets, roles, asserted classes) as JSON-LD ?verify=1&hours=(≤168) GET /api/models/{id}/lineage what this model was derived from GET /api/models/{id}/layout auto-layout coordinates POST /api/models/{id}/verify deadlock-free, bounded, mutex, invariants, reachability GET /api/models/{id}/invariants P-invariants (conservation laws) and T-invariants (cycles) GET /api/models/{id}/crosscheck every reading against every other; divergences named ?hours=(≤168)&realizations=(≤200) POST /api/models/{id}/evaluate score a player's next moves (method: play (default) | eliminate | incidence | search) POST /api/models/{id}/calibrate event log CSV → NEW model with learned rates + conformance POST /api/models/{id}/compose instantiate a component onto this model → NEW model POST /api/models/{id}/receipt scenario + Ed25519 run receipt POST /api/models/{id}/publish → the signed-in user's Google Sheets POST /api/models/{id}/publish/compare a multi-scenario comparison → the signed-in user's Google Sheets GET /api/receipts/key public verification key POST /api/receipts/verify check signature, replay, report drift GET /api/lineage/{id} any entity's JSON-LD: lineage, relation, witness, Query, Result (with currency; 1 token), ReachQuestion, ReachResult (with reachCheck and currency; 1 token), System GET /api/artifacts/{id} a stored prompt or artifact (sim_prompt's text replies) with its whole content and lineage; sim_get_artifact's document POST /api/query a sim_query question → {query, result, cached, stored} POST /api/derivation/check {"derivation": …} → {verdict, reason, hops} POST /api/refutation/check {"refutation": } → {verdict, reason}; 1 token POST /api/models/{id}/reach {"from": {...}, "to": {...}} → yes (trace) | no (invariant/exhaustive) | unknown, stored (question, result); 2 tokens POST /api/reach/check {"reach": } → {verdict, reason}, or {"result": } → {verdict, reason, parts, answer, currency}; 1 token GET /api/models/{id}/migrations what a superseded model's edges become ?predicate=&after=; 1 token GET /api/models/{id}/bindable what can bind to one of its ports ?port=&direction=&among=; 1 token GET /api/systems/{id} a System's view, computed now (checks, edges, shape); 1 token GET /api/systems?naming={id} the stored systems naming a model or binding; 1 token GET /app/{id} owner-published single-file app (CSP sandbox) GET /api/me/sheets sheets you have published ``` ### Scenario body ```json { "hours": 8, "realizations": 16, "seed": 7, "marking": { "agents": 3 }, "rates": { "arrive": 14 }, "schedule": { "arrive": [ {"until": 2, "value": 6}, {"until": 4, "value": 20}, {"until": 8, "value": 8} ] }, "params": { "batch": 6 } } ``` `marking` overrides initial tokens (staffing, stock; whole, non-negative numbers), `rates` overrides per-hour rates (demand, patience, equipment down = rate 0; finite and non-negative), `schedule` is piecewise constant per transition, `params` sets declared structural parameters. A schedule segment is `{"until", "value"}`: `until` is the segment's **end time** in hours from the start (absolute, strictly increasing — not a duration), `value` the per-hour rate until then; the last segment holds to the horizon. Other optional fields: `samples` (trajectory points, default 60), `engine` (`ssa`, the default, or `ode`), `summary` (drop the series). An unknown place, transition or parameter name is a 400, never a silent no-op. Caps (refused with a 400, never clamped): `realizations` 0..200, `hours` 0..10000, `samples` 0..5000, at most 1000000 tokens in any one place (set by the scenario or declared by the model), at most 4000000 tokens summed across all places (the model's marking with the scenario's overrides applied), at most 100 schedule segments summed across transitions, at most 20 scenarios per comparison. A stored model may declare at most 500 places, at most 1000000 tokens per place, 4000000 in total, and an arc weight of 0..1000000 (0 means 1; a negative weight is refused). A field the scenario shape does not declare (a misspelled `markings` or `horizon`) is refused too, on every surface, rather than ignored. A diagnosis, cross-check or classification verify run takes `hours` 0..168 and `realizations` / `maxRealizations` 0..200; a negative or unparseable query value is a 400. Work, not just counts, is bounded while a run is under way. One engine call (one scenario, or one measurement inside a diagnosis) shares a budget of 20000000 steps across all its realizations and schedule segments, and no place may grow past 1000000 tokens during the run; a run stopped by either comes back with `truncated: true` and a `reason` naming the budget or the place, never as a full-horizon answer. A whole request (or MCP tool call) has 55 seconds: past that it is refused with a 422 (a tool error over MCP) saying so and suggesting fewer realizations, hours or scenarios. Closing the connection stops the work. The response carries per-place trajectories, served/lost totals, utilisation, contended-time accounting (what the run actually waited on, classified structurally), `caveats` for anything this model says that the engine could not honour, and `assumptions` for what the engine assumes whatever the model says (SSA: exponential durations). Each `contended` entry carries a `kind`, and only two kinds are shortages: `conserved` (a fixed pool such as staff) and `bounded` (a shelf with a declared capacity). `queue` (filled only by the model's own flow: waiting customers, work in service) means the work had not arrived, and `state` (a marker of which phase of a cycle the model is in, such as a stoplight's colour) means the model was in another phase. Over HTTP the list comes back as the engine produced it, kinds and all. The MCP tools that return a scenario result (`sim_scenario`, `sim_compare`, `sim_run_pipeline`) split the same entries three ways: `contended` holds the two shortage kinds, `waitingForWork` holds `queue` and `state`, and `neverFired` holds a note for each entry whose place never held a token in any run while its transitions never fired, such as `"work" never fired in any run: its input "q" never held a token`. A `queue` or `state` entry with a note appears only as the note; a `conserved` or `bounded` one keeps its `contended` row too, since the run was short of it. The run cannot tell why a place stayed empty: it starts empty with nothing to fill it, its producer was switched off (a rate of 0, or a scenario marking of 0), or what fills it is rarer than the run's hours and realizations can sample (a once-a-month breakdown never happens in 8 hours). `sim_receipt` returns the unsplit list, because its hash covers the result exactly as returned. `/scenario/compare` takes `{"scenarios": [{"name": "today", "seed": 7, ...}, {"name": "one more", "marking": {...}}], "summary": true}`. There is no top-level seed: every scenario runs on the **first scenario's** `seed` (1 over HTTP when that is unset) — the server enforces the shared seed, so differences are the scenarios and not the dice. `"summary": true` returns totals only. ### Defaults per surface The same unset field is filled differently depending on where the scenario arrives, so set `hours`, `realizations` and `seed` whenever results are to be compared across surfaces: | Surface | unset `hours` | unset `realizations` | unset / 0 `seed` | |---|---|---|---| | HTTP `POST {id}/scenario`, `POST {id}/receipt` | 1 | 1 | 0 (a fixed seed, run as given) | | HTTP `POST {id}/scenario/compare` | 1 per scenario | 1 per scenario | 1 (first scenario's) | | MCP `sim_scenario`, `sim_receipt`, `sim_publish` | 8 | 16 | 20260809 | | MCP `sim_compare`, `sim_publish_compare` | 8 per scenario | 16 per scenario | 20260809 (first scenario's) | | HTTP `POST {id}/publish` | 8 | 16 | 20260809 | | HTTP `POST {id}/publish/compare` | 1 per scenario | 1 per scenario | 1 (first scenario's) | `samples` defaults to 60 everywhere. Over MCP a seed of 0 cannot be requested: it means "unset" and becomes 20260809. ### Quick start ```bash # store ID=$(curl -s -X POST https://sim.pflow.xyz/api/models \ -H 'Content-Type: application/json' -d @model.json | jq -r .id) # ask it a hypothetical (pure read, seeded) curl -s -X POST https://sim.pflow.xyz/api/models/$ID/scenario \ -d '{"hours":8,"realizations":16,"seed":7,"marking":{"agents":3}}' # does the model hold up before you trust its numbers? curl -s "https://sim.pflow.xyz/api/models/$ID/diagnose?hours=8&realizations=24" # a dataset you can process-mine curl -s "https://sim.pflow.xyz/api/models/$ID/dataset?seed=1&cases=400&format=csv" > log.csv ``` ## MCP server `https://sim.pflow.xyz/mcp` — Streamable HTTP, JSON-RPC, OAuth 2.1 with PKCE and dynamic client registration (`/oauth/register`, `/oauth/authorize`, `/oauth/token`; the authorize step signs in with Google). Access tokens last 24h, refresh tokens 90d. Listed on the MCP registry as `xyz.pflow.sim/whatif`. Every tool call is checked against that tool's declared input schema before it runs, and before it is charged: an argument the tool does not declare is refused naming it, the argument it most likely meant (`q` → `query`; a scenario field sent at the top level → `scenario.marking`) and what the tool takes, and a value of the wrong JSON type (a number for a string, `"yes"` for a boolean) is refused naming the type, never read as the default. A string argument that carries JSON (a scenario, scenarios, a model, properties, a marking) takes the object or array itself as well as a string holding it. Tools (all take a stored model `id` unless noted): - **sim_list_models(scope?, query?)** — the catalog (marked curated), the commons (with license), and your own (marked mine). The curated models are complete, working examples of the model JSON: copy one's shape (via sim_get_model) before writing a model for sim_create_model. `scope` is `curated`, `commons` (dedicated models, never a curated one) or `mine` (yours, dedicated or not; needs sign-in, refused rather than empty when signed out); `query` keeps the models whose name, listed description (first line, up to 200 bytes) or id contains every whitespace-separated term, any case, at most 256 bytes. Leave both unset to list everything, compactly: each description except a curated model's is cut to its first 64 bytes, ending in "…"; set either for the listed description in full. Free. - **sim_get_model** — a stored model's full JSON, by content id. A name instead of an id is refused saying to search with sim_list_models' `query`, and that a model created without sign-in is listed for nobody, so its id is the one its create returned; or naming the curated model's id when a curated model has exactly that name. An id of another stored kind (a prompt or artifact, a sim_reach question or answer, a system, binding, map, collection, witness, relation, or sim_query query or result) is refused naming what it is and the tool or route that reads it. - **sim_create_model** — store a model (no id) → content id, `url`, `mine` and, when there are any, `warnings`. Refused with every reason at once: a key the schema does not define, named by its path with the key it most likely meant (`unknown field places[0].intial (did you mean "initial"?)`), once for a key repeated across elements (`arcs[0..6].source`); a value of the wrong kind, by its path and the kind wanted (`places must be an array of objects like {"id": "queue", "initial": 0}, got an object`), the first one only, with the structure checked once every value has the right kind; and otherwise every structural defect, leaving out those a misspelt key caused (an arc whose `from` was misspelt is not also reported as naming nothing). A key with no likely meaning is to be removed, and the refusal says so; each kind is listed up to 20 with the rest counted. Warnings name what stores but probably is not what was meant: transitions with no rate (simulations run them at the default 1 per hour, and each stage of an unrated staged transition at it; `"rate": 0` reads as unset), a net where nothing can fire at its declared initial marking, a guard no simulation enforces (a bare name where `tokens("id")` was meant), an inhibitor arc drawn transition → place, and a pool one transition takes and gives back in the same firing beside the work it serves (`staff → serve → staff` with `queue → serve`): it is never busy, so its headcount multiplies `serve`'s rate, or through a `"kinetic": false` arc changes nothing, and never caps work in progress; hold it across two firings instead (`start → busy → finish`, `sim://docs/authoring`). It also warns about a pool a transition only reads, through a read arc beside the work it takes (`staff` at 2 read by `serve` beside `queue → serve`): a read arc takes nothing and stays out of the rate, so every count above the arc's weight runs exactly as the weight does, and the remedy is the same. At the weight (an open sign at 1) it is a gate and draws no warning. Each per-element warning is listed up to 10 times with the rest counted. Signed in, the model is yours (`mine: true`) and private until licensed, unless the identical model was stored before: its envelope stands, and the reply says whose it is (`mine: false`, `note`). Without sign-in it is stored unowned for good, as anonymous `POST /api/models` stores it — reachable by id, listed for nobody, and nobody can delete, license or supersede it, even after creating the identical model signed in — and the reply says so (`mine: false`, `note`). To own a model, sign in before creating it; a changed model (a new name is enough) is a new id. A bearer that has expired or lacks write scope is asked to sign in, not stored unowned. 1 token, from the account's bucket signed in and the client IP's anonymous. - **sim_delete_model** — delete a model you created (refused once dedicated). - **sim_license_model** — dedicate to the commons: CC0-1.0, CC-BY-4.0 or CC-BY-SA-4.0. Irrevocable; the model becomes publicly listed. - **sim_supersede_model** — hide an older version of your model from listings behind a newer id, and record a supersededBy edge (old → new) that sim_edges shows; id and dedication untouched. Re-pointing keeps the earlier edge too and edges carry no order: the result's `current` names the successor the listing follows. Nothing else moves: the result's `migration` counts the old model's edges that are offered and that stay (`sim_migration_offers` lists them). - **sim_migration_offers(model, predicate?, after?)** — sign-in, read scope, 1 token; anonymous over HTTP as `GET /api/models/{id}/migrations`, 1 token per client, with no `youMayAccept`. What a superseded model's edges become, computed on read and never stored. The successor is the **current** one: the supersession chain followed through fresh metadata (`chain`, `chainStatus`: `complete`, or `cycle`, `truncated` past 64 hops, `successor-deleted`, `successor-reowned` when a hop was deleted and stored again by another account, `unresolved` past 256 envelope reads — each an answer with no target, never a guess). Taxonomic edges sim_link may write (hasMember, @type, broader, related, closeMatch, cites, about) are `offered`: `proposed` is the edge with the model replaced by the current successor at every end that is the model (orientation kept); `check` is sim_link's verdict on it now (`passes`, `refused` with CheckLink's text, or `unchecked` when the store could not answer — unknown, not refused); `status` is `pending`, `accepted` (a `migrations` record matches) or `no-target`; `migrations` lists earlier acceptances; `suggest` is the sim_accept_migration call; `otherEndSuperseded` names a far end that has itself been superseded. Tool-written (from/to/feeds, produced, supersededBy), structural (isomorphicTo, subnetOf, preservesInvariant, refines — with their `witnesses` and a suggested sim_prove call) and legacy unregistered edges `stay`, each with its `reason`. 100 rows per page (`next` → `after`), `counts` over every page. Refused: a model that is not stored (404), not a model, never owned or not superseded (400), an unknown predicate, `narrower` (never stored: pass `broader`) or a stale `after` (400). (`migratedFrom` links two edges, never a model, so it is never a row.) - **sim_accept_migration(model, edges, successor?)** — sign-in, write scope, 1 token, MCP only. Carries 1-25 offered edges forward to the current successor. Accepting is for the model's owner (who recorded the supersession, so owns every version in the chain) for any offered edge, or for an edge's own author for that edge — neither gains a write: both could sim_link the bare edge; acceptance adds provenance, a claim about a version history only someone with a stake in it can make. A curated seed is owned by no person, so only authors accept there. Every edge is checked first (touches the model, offered, sim_link's check on the proposed edge, who may accept, and — since Phase 12d — the proposed edge's own write rule for you, as sim_link applies it: a `hasMember` edge moves only for the collection's owner; the offers' `youMayAccept` is false and `writeRule` says why when only that rule refuses); one failure refuses the call naming each reason and writes nothing. `successor` (the `current` the offers returned) refuses any other current successor (a re-point, or a chain that grew since). A call stopped by the deadline or an outage after some writes lists which edges were migrated and which were not; its deadline remedy is fewer edges per call. Each accepted edge is written attributed to you with a zero timestamp (reused, `existing: true`, when you already linked it), then `{new edge, migratedFrom, old edge}`, also untimed — so a re-run, a double submit or two instances converge on the same ids. The old edge stays. - **sim_components** — the subnet template registry (arrivals, service, hazard, inventory, decision, mailbox, datastore) with ports, params and the discipline notes explaining their shape. - **sim_compose** — instantiate a component, optionally onto an existing model's places, as a NEW model with lineage. - **sim_scenario** — one seeded what-if: marking, rates, schedule, params. `contended` holds only what the run was short of; `waitingForWork` and `neverFired` hold the rest of the engine's list (see *Scenario body*). `finalStdDev`, beside `final`, is each place's spread across realizations at the horizon, kept under `"summary": true`. Every unknown key in the scenario is refused at once by its path, with the key it most likely meant (`unknown field realisations (did you mean "realizations"?)`), as the HTTP scenario routes refuse it; so is a place or transition the model lacks (`no token place "Staff" … (did you mean "staff"?)`), in the same refusal as every other reason the scenario is refused for (params, caps). - **sim_compare** — several scenarios on one shared seed, side by side (summaries by default; `full=true` for series), each result split the same way and carrying `finalStdDev`. `scenarios` is an array; one object, or an object keyed by scenario name, is refused saying how to write it. - **sim_dataset** — synthetic event log (case-per-arrival) that replays cleanly against its own generating net. Returns CSV only (`cases` default 200, max 2000); JSONL and up to 10000 cases (default 500) are available from `GET /api/models/{id}/dataset?format=jsonl`. - **sim_calibrate** — upload your event log (`case_id, activity, timestamp`; activities = transition ids) → NEW model with learned per-hour rates plus a conformance report (`fittingPercent`, worst traces named). Instant pickups keep their declared rate. Read the fitness before trusting the rates. - **sim_diagnose** — fitness gates (mass balance, dormant sources, staffing knee, does anything bind) and every knob ranked by measured, signed influence under greedy relief (the leading constraint is relieved and the rest re-measured until nothing moves beyond noise); parameter classes discovered. `hours` ≤ 168; `realizations` / `maxRealizations` ≤ 200; leave `realizations` unset for the adaptive default. The knee finding gives two levels for the best pool, where its curve ends and the practical knee where it flattens to within the noise floor (the spread at the model's own level, so a smaller gain past it is not shown to be noise), and a `knee` object with the sweep; both are readings of the model, not recommendations. When that pool is taken and given back in one firing through a kinetic arc, the finding says the sweep measured a speed curve, not a staffing one. A structural reading that does not apply (`ctmcLumping`, `constrainedLumping`, an unfinished `siphons` search) is one `not applicable: ` refusal without its scope text; `verbose: true` (HTTP `?verbose=1`) returns it in full. - **sim_classify** — parameter classes (fungible-set candidates or verified sets, roles, asserted classes) as JSON-LD with empty annotation slots. - **sim_refine** — edit what the model says (tags such as `outcome:`, asserted classes) and re-derive; NEW model with lineage. - **sim_verify** — declared properties: deadlock-free, bounded, live, terminating, conserves, mutual exclusion (`places`, `bound`), invariant expressions (`expr`), reachable / unreachable targets (`target`); unset checks bounded and deadlock-free. A misspelt kind or field, a field the kind does not read, or a missing one is refused with its likely meaning (`mutex` → `mutual-exclusion`, `place` → `places`), and so is a place named twice in `places` (the counts are summed, so `["w1_crit", "w1_crit"]` would check `2*w1_crit <= 1`) and a place id in `places`, `target` or `expr` the model has no token place for (`properties[0].places: no token place "W2_crit" in model "m" (did you mean "w2_crit"?)`) or that names a data place (a value, not a count), rather than answered unknown; `POST /api/models/{id}/verify` refuses in the same words, with a 400. Each verdict's `method`: a structural bounded or conserves holds for every initial marking; a structural invariant or mutual exclusion rests on a P-invariant's value at the model's initial marking, so it holds from that marking and any other giving the P-invariant the same value (two workers sharing one lock token are proved exclusive, and the same net with two lock tokens is refuted); exhaustive and witness are about this initial marking; partial is truncated, sound only for a refutation. - **sim_invariants** — Farkas P-invariants (conservation laws a trust panel should show) and T-invariants (firing cycles). - **sim_crosscheck** — every applicable reading against the others: SSA means vs the mean-field ODE, algebraic invariants vs simulated means, game search vs elimination; divergence named with its reason. - **sim_evaluate** — for game-schema models, score a player's legal next moves by `play` (seeded SSA rollouts from each candidate — the only method this tool uses). The HTTP evaluate endpoint's `method` also takes `eliminate` (rate-0 ablation), `incidence` (closed-form live-drain counts) or `search` (memoized alpha-beta over the declared game); `play` is its default. `search` stops a line at depth 10000 (lower past 150 places, so depth × places stays within 1500000), at a position already on the line, or at a place count over 1000000, scores that position where it stands, and then reports `truncated: true` with a caveat naming the cut. - **sim_receipt** — run a scenario and get an Ed25519 receipt over (model id, canonical scenario, result hash, service revision); anyone can verify it at `/api/receipts/verify`. - **sim_publish** — publish a model into the signed-in user's Google Sheets. - **sim_publish_app** — publish the single-file HTML app generated from a model's `view` prompt (≤512 KB, must reference its model id, no external scripts); served at `/app/{id}` in a sandboxed origin. - **sim_publish_compare** — publish a multi-scenario comparison (one shared seed) into the signed-in user's Google Sheets. - **sim_my_sheets** — the sheets you have published, with URLs. More tools, grouped: - *Model editing and structure* — **sim_extend** (structural edit operations → NEW model with lineage), **sim_diff** (structural difference between two ids), **sim_canonical** (isomorphism-invariant id: are two differently-labelled models the same net?; one bounded search of at most 1,000,000 refinement steps, 1 compute token, refused past the budget), **sim_propose_types** (candidate `@type` values from a small diagnosis; assumption-grade, public). - *Structural edges with witnesses* — **sim_prove(subject, predicate, object, record?)** proves `isomorphicTo`, `subnetOf`, `preservesInvariant` or `refines` between two stored models. `isomorphicTo` (definition `coloured-net-v1`) is a place/transition bijection preserving every arc (direction, type, weight, kinetic flag), place sort, initial marking, capacity, rate or fixed delay, and `refine.*` tags (compared as maps) — what sim_canonical's id compares. It does not compare guards, stages, schedules, constraints, non-`refine.` tags (`outcome`, `port.*`), data-place types and initial values, objectives, players or asserted classes; each answer lists those the pair carries as `notCompared`, so proved is not "behaves the same". `subnetOf` (`induced-incidence-v1`) is an injective, sort-preserving map (token places to token places, data places to data places) whose image carries exactly the subject's arcs (no extra arc among image elements; arcs leaving the image are allowed); initial markings, capacities, rates, delays and tags are not compared. The answer is `proved` (with the map, in the caller's direction), `refuted` (exact: a count, or an exhaustive search) or `unknown` (the 1,000,000-step canonical-labelling budget, shared by both nets, or the 2,000,000-step embedding budget ran out; nothing is recorded, a retry gives the same answer, and unknown is not a no; tagging with sim_refine or a smaller subject narrows the search but asks about different models). On proved, the witness is stored first and the edge second, so an edge never exists without its witness. The edge id is a function of subject, predicate and object only, so every witness of the same edge proves one edge; `isomorphicTo` is stored with the smaller id as subject (the search runs in the caller's order; `storedAs` appears on a recorded answer whose witness was turned round). Needs sign-in, including with `record: false`; 3 compute tokens, 2 with `record: false`; `record` must be a JSON boolean or absent. **preservesInvariant and refines (Phase 12i)** are proved by the same tool and gate, with a witness that is more than a map. `preservesInvariant` (`invariant-pullback-v1`): an induced embedding as `subnetOf`'s along which every P-invariant of the object pulls back to one of the subject (the incidence the shared firing rule implies: consuming and producing arcs on token places); the witness adds a `cover` — per subject transition, rational coefficients of object transitions with the same incidence column — which proves it for every invariant at once; `invariantDimension` 0 means vacuous. Embeddings are tested one per image (embeddings onto one image differ by a symmetry of the subject), at most 16 images; budget 4,000,000 units (elimination priced before it runs, the embedding search, each test). `refines` (`id-simulation-v1`): every firing sequence of the subject from its initial marking, transitions the object has no transition of (by id) dropped as silent, is one of the object from its own (untimed; at least one shared transition id; a net with a guard or a fixed delay is unknown; rates not compared); the witness is a finite simulation relation closed under the subject's firings, so the claim is unbounded (at most 4,096 pairs and 20,000,000 firing-rule units; an answer counts it as `relationPairs`, `/api/lineage/` lists it). Refuted answers carry `refutation` `{predicate, subject, object, counterexample}`: a shortest trace the object cannot follow, or per embedding image an integer P-invariant of the object and the subject transition that breaks its pullback (that the images listed are all of them is the search's claim, not re-checked; no embedding at all carries none). Re-check with **sim_check_witness(refutation=…)** or `POST /api/refutation/check` (1 token; at most 256 KiB). Neither predicate is transitive: sim_query refuses closure and paths of two or more hops over them, and a one-hop row carries no derivation. **Promotion: sim_prove(subject, predicate, object, via)** records a fact sim_query derived, with no search. `via` is `{"derivation": }` or `{"result": "", "row": n}` (plus `"support": k` when the row carries more than one derivation; 0-based; an object or a string holding one, at most 16 KiB). The derivation must derive exactly this fact (either order for `isomorphicTo`) and have 2 to 8 steps; it is re-checked from the stored witnesses now (each witness, the chain, the composed digest, and the composed map against both end models with `morphism.Check`), and only `valid` is promoted — `invalid` or `uncheckable` is refused and nothing is written. The composed map is stored as one direct witness through the same write gate, so the edge is the ordinary `EdgeID(s, p, o)` edge, trusted exactly as a searched one; the witness carries the derivation as `promotedFrom` (the gate re-checks it composes to exactly that map), served at `/api/lineage/`. Idempotent: the same derivation gives the same witness and edge (`existing: true`). The answer carries `method: promoted…`, `derivationCheck` and no `work`. Signed in, `sim.models.write`, 2 compute tokens; `record: false` with `via` is refused. **sim_check_witness(id)** (public, free, no search) re-checks a witness id, or every witness of a structural edge id, against both stored models: `valid`, `invalid`, or `uncheckable` (a model was deleted, a net is malformed, or the predicate is now checked under a newer definition); an edge with no witness answers `unwitnessed`. `GET /api/lineage/` serves the witness as JSON-LD (`StructuralWitness`) with `witnessCheck` computed on that read; the edge's own JSON-LD at `/api/lineage/` lists `witnesses`, or `unwitnessed: true` when it has none; an outage on either read is a 503. **sim_check_witness(derivation=…)** re-checks a sim_query derivation instead: every step's witness against its stored models, that the steps chain from subject to object, the composed map's digest, and the composed map against both end models, answering `{verdict, reason, hops}` (`valid`, `invalid` naming the step, or `uncheckable` — also when the derivation names a definition other than the predicate's current one); 1 compute token. The same check, body and refusal texts as `POST /api/derivation/check`. **sim_check_witness(reach=…)** re-checks a sim_reach answer (1 token, `POST /api/reach/check`): a trace is replayed through the shared firing rule and the guards, an invariant re-checked against the stored model; an exhaustive no is `uncheckable` (re-run sim_reach: it is deterministic) and an unknown answer is refused. When the answer carries `via.translation` (component places, below) it also re-derives each named member's map from the embedding `via` lists — the witness re-checked now, or the derivation re-checked from scratch — and checks every entry: invalid beats uncheckable beats valid. Since Phase 12g every embedding `via` lists is re-checked with the answer, translation or not: the system is stored and holds the net, every other member has exactly one embedding, and each re-checks valid now (more than 64 embeddings is refused). **sim_check_witness(reachResult=…)** re-checks a stored sim_reach answer end to end (1 token; `POST /api/reach/check` with `{"result": …}`, or `GET /api/lineage/{id}`, whose ReachResult carries the same check as `reachCheck`): the record (the bytes hash to the id, the stored question asks what the answer answers under its budgets, `over` names what it rests on), via, and the witness; invalid beats uncheckable beats valid. An unknown answer's witness part is `uncheckable` and the reason says the answer is unknown, not no. `currency` says whether this server would compute the same result (format, definition, budgets, registry and view, every model still stored) and lists superseded models without flipping `current`. Give exactly one of `id`, `derivation`, `reach`, `reachResult` or `refutation`. - *Reachability* — **sim_reach(system, from, to)** (public, 2 compute tokens: a search plus a store; `POST /api/models/{id}/reach` with `{"from": …, "to": …}`; every answer, unknown included, is stored as a content-addressed question and result whose ids the reply names as `question` and `result`, served from a per-instance cache while the net and `via` it read are unchanged, and answered unstored with `storedNote` over 262,144 bytes) asks whether `to` can be reached from `from` in ONE stored net; a sim_compose result is one fused net, while a sim_bind binding or a collection links rates, not tokens, and answers `unknown` with the code `not-one-net`. A system id (sim_create_system) is answered on its one net — one member with every other embedded in it by a witnessed subnetOf/isomorphicTo edge, or a chain of them of one predicate through any stored model — and the answer carries `via` (the system, the net and each embedding's edge and witness, or for a chain its derivation, which sim_check_witness(derivation=…) re-checks); a system with bindings, or with no such member, answers `unknown` (`not-one-net`; `system-truncated` when its witness bound was reached or the chain search stopped at a bound) and never yes or no. On a one-net system a key of `from` or `to` may name a member's place as `:` (the member's content id, or its model name when no other member carries it; a key without `:` is the net's own place): it is translated into the net place the member's embedding (the witness the view re-checked, inverted for an isomorphicTo edge stored net-first, or a chain's composed map) sends it to; the answer's `from`/`to` are the translated question and `via.translation` lists `{field, key, model, place, netPlace, count}` per key. Refused, naming the key: a member or place the system lacks, a data place, a key that reads more than one way, a place two valid witnesses of the member's edge map differently, two keys on one net place with different counts; more than 16 other witnesses to compare answers `unknown` (`translation-budget`). `from` is a sparse override on the declared marking; `to` constrains only the places it names, each to exactly that count (name every token place for an exact marking); both are held to the scenario caps. The reading is untimed interleaving under the shared firing rule (read and inhibitor arcs, capacities, guards decidable from the marking); rates, schedules, delays and stages play no part, and `assumptions` says so. Answers: **no** with a P-invariant `y` (`coefficients`, `atFrom`, `atTarget`, `free`): `y·M` never changes, and `to` makes it differ from its value at `from` (`atTarget ≠ atFrom`) when `to` leaves no place of `y` free, or pushes it above (`atTarget > atFrom`, every free place weighted ≥ 0) when it does; read arcs, inhibitors, capacities and guards only remove firings, so the proof holds whatever they allow. **no (exhaustive)** when every marking reachable from `from` was searched (labelled `independent: false`). **yes** with a firing trace and the marking it ends on — shortest among the firings the search makes, and `reads` names what was skipped when it skipped any. **unknown** with `unknownBecause` codes (`state-budget`, `work-budget`, `token-cap`, `excluded-guard`, `lost-precondition`, `guard-error`, `algebra-budget`, `farkas-truncated`, `coefficient-too-large`, `replay-budget`, `checker-refused`) — never a quiet yes or no. Budgets: 100,000 markings, 20,000,000 work units, 20,000,000 elimination operations. On the composed café (2 baristas), `to: {"staff/available": 3}` answers no: `1·staff/available + 1·staff/busy` is 2 at from, but `to` already makes it at least 3 before counting `staff/busy`; `to: {"counter/orders_complete": 1}` answers yes with `counter/order_espresso, espresso_started, espresso_finished, counter/serve_espresso`. - *Queries over the graph* — **sim_query(query)** (sign-in, read scope; 2 compute tokens per call, cached or not; the same JSON anonymous over HTTP as `POST /api/query`, 2 tokens per client) answers bounded paths, closure over transitive predicates and conjunctive patterns: `{"select": ["?m"], "where": [{"subject": "?m", "predicate": "subnetOf", "object": "", "mode": "closure"}, {"subject": "?m", "predicate": "@type", "object": "QueueingSystem"}]}`. A term starting with `?` is a variable; a constant is a 24-hex content id (or, as `@type`'s object, a model kind). Modes: `edge` (default: one stored edge, read through the registry — `narrower` is `broader` turned round, `related`/`closeMatch`/ `isomorphicTo` match either way round), `path` (1..`maxHops` hops, `maxHops` 1-8 required; a row reads `path` — reachable, no edge claimed — unless the predicate is transitive) and `closure` (transitive predicates only: broader, narrower, supersededBy, migratedFrom, isomorphicTo, subnetOf; depth cap 8). Put the bound term in `object` to walk backwards. Legacy unregistered predicates are one stored edge in their stored direction, never walked. The request is normalized (inverse names rewritten, symmetric ends ordered, patterns sorted and de-duplicated; variables are **not** renamed, so other names are another query) into a content-addressed Query citing the evaluator (`sim-query-v1`), the registry version and every limit. A structural edge is walked only when one of its witnesses re-checks valid now; edges that do not are listed in `excluded` and never walked. A derived structural fact carries a `derivation` (the chain of witnesses, `inverse` where an `isomorphicTo` edge is read from its larger-id end, and the composed map's digest), re-checked with `morphism.Check` before it is reported, and never stored by the read: sim_prove stays the only writer of structural claims, and storing one is an explicit promotion (sim_prove with `via`). Closure runs per predicate (`isomorphicTo` hops never feed a `subnetOf` closure). `status: complete` means complete over eligible edges (and, for path, within `maxHops`; `hopLimitReached` names patterns with edges beyond); `truncated` names the bound (`work`, `intermediate`, `rows`, `hops`, `models`, `bytes`) and the remedy, leaves `rows` absent and puts what was found in `partialRows` (true, not all). A query with no variables answers `yes`, `no` or `unknown` (a bound stopped it, or a stored relation — for a structural question, a witness — could not be decoded, so the edge asked about cannot be ruled out). A `no`'s caveat says how far the search looked and never more: `no stored or derivable …` only for closure (or a predicate not declared transitive); `no single stored … edge; derived edges were not looked for` for edge mode on a transitive predicate; `no … path of at most N hops; edges continue past hop N (hopLimitReached)` for a path cut at `maxHops`; and a stored structural edge whose witness no longer re-checks is named, not denied. Absence is not a refutation. `unmatched` lists constants that touch no edge. Pattern numbers in a result (`support[].pattern`, `hopLimitReached`, `unmatched[].pattern`, `truncated.pattern` and the `query.normalized.where[i]` a truncation message names) are 0-based positions in `query.normalized.where` (sorted, de-duplicated); refusals number the patterns as sent, from 1. Limits: 4 patterns, 8 hops, 200 rows, 10,000 intermediate rows, 1,000,000 work units, 64 models probed, a 256 KiB result, a 16 KiB query, 55 s; a question over a limit is refused naming it, a run that reaches one is truncated, and both are deterministic. Each answer writes a Query and a Result (`GET /api/lineage/{id}`; a Result carries `currency`, computed on read for 1 token: not current once the graph it read, the predicate registry, the evaluator or the limits moved; its `superseded` lists models the Result names that have since been superseded, with `successor`, `current` and `chainStatus` — supersession never flips `current`, since re-asking gives the same Result id; ask with the current model in its place instead). Every answer, fresh or cached, carries the same `superseded` envelope field, never stored in the Result. A `supersededBy` closure is version history, including successors a model was re-pointed away from, not the current successor. The cache is keyed on the query id plus a digest of the relation-id set of every predicate it names (and, for structural ones, their witness-id sets), re-taken after a fresh listing on every call, and every model a cached answer checked is re-probed: another instance's write under a named predicate, a new witness or a deleted model forces a recompute. A run copies only the index lists it touches, each charged before it is copied. - *Fitting and optimisation* — **sim_conformance** (fitness, precision, generalization and simplicity of a model against an event log, without rewriting rates), **sim_conformance_stream** (online conformance: an ORDERED event stream replayed per case, running fitness and the first event that did not fit, with the blocking places and a repair hint; token replay, not alignment), **sim_optimize** (Monte Carlo over rate ranges with a Pareto flag; continuous engine), **sim_param_heatmap** (two-rate grid of an observable's final value; continuous engine), **sim_distill** (fit rate multipliers so the play scorer agrees with exact search). - *Pipelines between models* — **sim_bind** (record that one model's output port feeds another's input port; both models must be stored content ids; 1 token, a store; checked now by binds-port-v1, the rule sim_run_pipeline applies: the output is a token place tagged `port.output`, the input a rated (not delayed) transition tagged `port.input`, the two kinds are equal, and `port.unit` is equal where both ends declare one — a unit missing on either end is reported unchecked, never agreed; a numeric transform scales by at most 1000000; a self-binding is refused, with or without a delay (a model driving its own input is a loop inside one net, built with sim_extend); a refusal stores nothing; `delay` (hours, in (0, 10000], checked by binds-port-v3's check 12; 0 is no delay and keeps the id) makes the target receive the source's flow that many hours later, on its own declared rate before then, and is what lets a loop between models run; differing units bind only through a declared conversion, `convertFrom`, `convertTo` and `convertFactor` together (1 convertFrom = convertFactor convertTo), checked by binds-port-v2 — both ports declare `port.unit`, the two differ, the conversion names exactly those two (never inferred: "order" and "orders" are two units), the factor is in [1e-6, 1000000] and the transform times the factor is at most 1000000 — and applied by sim_run_pipeline as declared, after the numeric transform; a binding without a conversion is checked by binds-port-v1 as before and keeps its id; an accepted bind returns its `check` and records binding→from, binding→to and fromModel→toModel feeds edges; a recorded binding passes every run's per-binding check, and a run still applies the set-level checks and can stop on what only it decides — a resampled rate that overflows at a very short horizon, the step budget, the deadline), **sim_get_binding** and `GET /api/lineage/{id}` (the check computed now — `check`, or `portCheck` in the JSON-LD: ok, refused — a binding recorded before the check, flagged `legacy` with one text on both — or uncheckable when a model it names was deleted), **sim_list_bindings**, **sim_run_pipeline** (run a set of bindings in topological order on one seed and horizon; public; at most 64 binding ids; 1 token per model run, charged up front, before any model is read, for the most runs the set can take and not refunded when a loop settles early; defaults hours 8, seed 1, realizations 0 — set it, e.g. 16; the same check sim_bind applies — binds-port-v1, binds-port-v2 for a binding that declares a unit conversion, whose factor multiplies the resampled rate after any numeric transform, binds-port-v3 for one that declares a delay — so a legacy binding is refused by the same text, plus the set-level checks: no loop without a delayed binding, one binding per input port, and each binding becomes a schedule of up to 61 segments and a scenario holds at most 100, so bind at most one source into any one model; a source whose own run is scheduled is resampled onto at most 60 segments with each boundary counted once; a delayed binding is delivered `delay` hours later, its target on its declared rate before then, and `inTransit` reports the flow still on its way at the horizon. A loop of at most 4 models through at least one delayed binding (ROADMAP.md Phase 12j, `pipeline-loop-v1`) is solved by sweeps — one delayed binding, the cut, driven by the previous sweep, every other binding by the current one — until a sweep reproduces the previous one's schedule bit for bit: `loop.verdict` `fixed-point`, whose claim (the first assumption) is the unique fixed point of the coupling over the horizon at that seed, with four limits (mean-field across the seam; the delay is declared, not measured; the seed history before the delay; nothing past the horizon — a fixed point can be a loop that grows); `stopped` when a run hits the step budget or token cap (its remedy blames the loop's gain only from sweep 2 on), `not-settled` (a defect) past the sweeps the lag guarantees, neither with steps. Refused before anything is charged or runs, each set-level refusal's tool error leading with its code: `closes-loop`, `port-taken`, `segment-cap`, `loop-lag` (the loop's delay does not exceed one hours/60 interval per binding), `loop-horizon` (more than 24 sweeps) — both name the least delay and the most hours that run — `run-cap` (more than 48 model runs), `loop-too-large`; hours and realizations out of range are refused with sim_scenario's text for every set; a failing binds-port check and a delayed binding's seed-segment `segment-cap` read the models, so they come after the charge. `assumptions` carries one unit line per binding — units agree, converted by the declared factor, or unchecked — and a line saying a binding couples means: a driven model ran on its source's mean, so its `std_dev` is its own spread given that mean, not the joint system's. A driven model's resampled schedule keeps one random stream per realization across its segments, so splitting a run into segments adds nothing where no rate changes; the resampling itself is a piecewise-constant approximation of the source's mean, which smooths anything shorter than one resample window, hours/60). - *Systems* (ROADMAP.md Phase 11f) — **sim_create_system** (sign-in, write scope, 2 tokens, MCP only): store a content-addressed System, either a snapshot of a collection's model and binding members now (other members listed under `skipped`; a grown collection is a new system) or named `models` and `bindings` (JSON arrays); binding endpoints join the models (`implied`); every binding must pass binds-port-v1 now (binds-port-v2 for one that declares a unit conversion, binds-port-v3 for one that declares a delay; the system then records `bindingCheck` binds-port-v2 or -v3) and the set must pass the pipeline's set-level checks, so a stored System holds a loop only through a delayed binding, and its view lists it under `pipeline.loops` with `pipeline.maxHours`, the longest horizon sim_run_pipeline runs it at (0 when no horizon of 0.01 h or more does); at most 64 models and 64 bindings, and a snapshotted collection of at most 256 members; the id hashes the sorted lists, the name and the snapshotted collection (if any), so the same call gives the same id and a snapshot and an explicit list of the same ids are two systems; the view is computed before the write, so a definition stopped partway stores nothing; `pipeline.runnable` means every check sim_run_pipeline makes that does not depend on the hours passes, and a loop also needs hours at most `pipeline.maxHours`. **sim_system** (public, 1 token) in one of three forms: `{id}` → the view, computed now and never stored (`system-view-v2`: each model, each binding's binds-port check — v1, v2 with its `convert`, or v3 with its `delay` — and whether the set runs, the isomorphicTo/subnetOf edges among the models with up to 128 witnesses re-checked — past that `truncated` — and the shape: `one-net` with `net` and `embeddings` (a member embeds by a direct witnessed edge, or by a chain of one predicate through any stored model found by sim_query's closure under its bounds — 8 hops, 64 models, 1,000,000 work units for the whole view — named as a `derivation` sim_check_witness(derivation=…) re-checks; a chain the bounds cut off makes the shape `unknown`, code `closure-incomplete`, never `separate`), `pipeline`, `separate` with `notOneNet`, or `unknown`); `{model, port, direction?, among?}` → what can bind to that port: every model with a port sim_bind would accept against it without a unit conversion (a port counting a different unit is counted under `excluded` as `unit-differs` and binds only through sim_bind's convertFrom/convertTo/convertFactor), ranked units-agree first, then model id and port, each row with its sim_bind arguments; the universe is the visible catalog, or `among` (a collection's model members, at most 256, or a system's models, `bindable-v3`, where a binding that would take a bound port or close a loop of undelayed bindings is counted under `excluded`, never listed, and one that closes a loop already holding a delayed binding is listed with `closesLoop` — sim_bind with a delay can close an undelayed one; a catalog or collection search consults no stored binding, so sim_create_system or sim_run_pipeline is where such a loop is refused); every model in the universe is searched (`bindable-v2`, `bindable-v3` among a system) through a port index (each model's ports by direction and kind, read once per id, kept within 16 MiB — at most 2 MiB of it for models no catalog-wide search has listed, and at most 64 KiB for one model — the universe listed on every call; the call's token covers its first 64 reads and each further 64 cost one more, charged before they are read, `truncated` with bound `rate` when the bucket cannot cover them): at most 256 not-yet-indexed models read per call (`truncated` with bound `fetches` past it, `unscanned` says how many; ask again), 100000 candidate ports checked and 50 rows; `candidates`, `scanned` (`fromIndex` + `read`), `notKept` and `portChecks` say how far it searched (`partialRows` when `truncated`, or `incomplete` when a member could not be read, listed under `unreadable`); `{naming}` → the stored systems whose models or bindings include that id (`as`: model or binding), sorted by system id, from an index synced from the store's listing on every call (another instance's system is seen on the next read; each system fetched once per instance): at most 100 rows (`truncated` past it, `total` says how many) and 256 not-yet-read systems fetched per call (`incomplete` past it, `unindexed` says how many; ask again), the index kept within 32 MiB per instance (once full nothing more is indexed there: `incomplete`, and asking again does not change it; nor does it clear a stored system that cannot be decoded, listed under `unreadable`), the rows then in `partialSystems`; an id nothing stores while the answer is incomplete reads "cannot tell", not a deleted model; a system id is refused (systems do not nest), a collection refused naming the snapshot remedy, and a deleted model still answered (`kind: "absent"`) while a system names it. A superseded model's migration offers carry the same answer as `systems`, with a `systemsNote`: supersession changes no System. Systems are linkable and collectable: `hasMember`, `cites`, `about`, `broader` and `related` take a system (a collection snapshot still skips a system member, listed as `system`). A system holding bindings is a pipeline whatever else it holds: to ask sim_reach about tokens, define a new system without the bindings, from one net that contains every model (built with sim_extend, each model proved subnetOf it). `refines` and `preservesInvariant` are proved pair by pair (sim_prove; not transitive, so no closure): "what contains this pattern" is sim_query `{"select":["?m"],"where":[{"subject":"

","predicate":"subnetOf","object":"?m","mode":"closure"}]}` (`isomorphicTo` for copies; add `{"subject":"","predicate":"hasMember","object":"?m"}` to scope it), each row carrying a derivation sim_check_witness re-checks. A loop between models runs only through a delayed binding (Phase 12j); sim_reach on a system holding one still answers unknown (`not-one-net`: a fixed point of rates is not a marking). - *Relations, collections and maps* — **sim_link** (typed edge from a registered predicate vocabulary, https://sim.pflow.xyz/ns/predicates/v1: hasMember, @type, broader, related, closeMatch, cites, about; refuses unknown, tool-recorded (from/to/feeds/produced/supersededBy; migratedFrom only sim_accept_migration writes) and structural predicates — sim_prove writes those — and ids that are not stored; for `@type` the object is a type name from https://sim.pflow.xyz/ns/models/v1; 1 token, a store), **sim_edges** (every edge touching an id, each with its predicate's axis; a structural edge lists its `witnesses`, or `unwitnessed: true` when it has none and so proves nothing; an `isomorphicTo` edge is not a claim of equal behaviour — read its witness's `notCompared`), **sim_neighbors** (one hop, read through the registry: narrower is broader read backwards, related/closeMatch count both directions; ordered by timestamp, then id — tool-recorded edges carry none, so their order is not recording order), **sim_create_collection** (a collection owned by the caller: name and account together are its content id, so another account's "backlog" is a different collection; 1 token, a store), **sim_map_put**, **sim_map_get**, **sim_map_list**. Who may write each predicate is its `writeRule` in the registry (`writeRule` since Phase 12d; registry version `eedea398298e1e96bc9b5bab` since Phase 12i, published at `/ns/predicates/v1`): `hasMember` is `subject-owner` — only the collection's owner adds members, and a collection created before collections had owners takes no new ones (its membership is frozen, still readable; `GET /api/lineage/{id}` says so under `legacy`, and names an owned collection's owner as `wasAttributedTo`); every other predicate sim_link writes is `any-signed-in`, an assertion attributed to its author. `supersededBy` is `subject-and-object-owner` and `migratedFrom` `chain-owner-or-edge-author`, each enforced by its tool. A refusal names the rule. Edges are immutable: there is no retraction, so a member cannot be removed. - *LLM derivation* (need this deployment's LLM provider) — **sim_prompt** (derive a variant model, report or code from a stored entity, recorded with provenance), **sim_reroll** (a sibling attempt of a stored prompt), **sim_code_to_flow** (derive a model from source code). For sim_prompt and sim_reroll the LLM is told to answer a request for a new or changed model with only the complete model JSON (the shape sim_create_model takes) and anything else in plain text or markdown. A reply that passes sim_create_model's decode and validation is stored as a model you own (`kind: "model"`), with sim_create_model's `warnings` when there are any; any other reply is stored as an artifact (`kind: "artifact"`, `url: /api/artifacts/{id}`) and returned inline as `content`, cut at 16384 bytes with `truncated: true` and `contentBytes` when longer. When that reply was model JSON (an object with `places` or `transitions`) that failed to decode or validate, `modelErrors` lists every reason at once in sim_create_model's words and bounds. sim_code_to_flow answers differently: a model that passes sim_create_model's decode and the model validator is stored and returned as `stored: true` with `id`, `url`, `name`, `usage` and any `warnings`; otherwise nothing is stored and it returns `stored: false`, the LLM's JSON as `model`, `usage`, and either `error` (one string: the decode failure or the unknown-field refusal) or `errors` (the validator's list, each with `code`, `message` and `fix`). Its `name` argument names a model the LLM left unnamed, before any check. A reply with no JSON object is a tool error. The stored prompt, its `text` and `system` both, is readable without sign-in by anyone who has its `promptId` or the id of anything derived from it, since lineage names the prompt behind every result: leave out of `text` and `system` what you would not share with whoever gets a result's id. A prompt with no `requestTemplate`, stored before the description said so, is served with both withheld, and an artifact it produced with its `content` withheld (below). Only their author (the lineage's `wasAttributedTo`) gets them whole as a sim_prompt parent or may sim_reroll the prompt reusing them; anyone else's sim_prompt gets the withheld document as its parent, and their sim_reroll passes `text`, and `system` when it has one, of their own. - **sim_get_artifact(id)** — public, free: a stored prompt or artifact as JSON-LD (`@type` Prompt or Artifact), an artifact's whole `content`, a prompt's `text`, `system` and `requestTemplate` (the version of the instruction and settings sim_prompt wrapped them in), its `parent`, and `lineage` (wasDerivedFrom, activity, ancestry). A prompt with no `requestTemplate` was stored before sim_prompt said its records are readable, so its `text` and `system` are withheld, and so is the `content` of an artifact it produced: `withheld` names the fields left out and `note` says why. A model id, or an id of another stored kind, is refused naming what it is and where it is read, and sim_get_model refuses an artifact or prompt id naming sim_get_artifact. `GET /api/artifacts/{id}` serves the same document; a store that cannot be read is a 503, never a 404. Public tools (no sign-in; the ones that run the engine draw on the same bucket as the HTTP API, keyed per client IP — an IPv6 client per /64 — or per account when a bearer is sent): sim_list_models (scope "mine" asks for sign-in with `sim.models.read`), sim_get_model, sim_components, sim_scenario, sim_compare, sim_dataset, sim_diagnose, sim_classify, sim_propose_types, sim_verify, sim_invariants, sim_crosscheck, sim_evaluate, sim_run_pipeline (1 token per model run, charged up front), sim_check_witness (free for a witness or edge id, 1 token with a derivation, a reach answer, a stored reach result or a refutation), sim_reach (2 tokens), sim_system (1 token), sim_get_artifact (free). sim_create_model takes a call either way (1 token, on the client IP's bucket anonymous and the account's signed in): anonymous, the model is stored unowned for good; signed in with `sim.models.write`, it is yours. Every other tool asks for OAuth sign-in with a scope (`sim.models.read`, `sim.models.write`, `sim.runs` or `sim.publish`), and its refusal names the tool, the scope and why it needs them (`sim_delete_model needs sign-in with sim.models.write (it acts on models you own): sign in to sim.pflow.xyz through your MCP client's OAuth prompt for this server, ...`), with the OAuth challenge in the result's `mcp/www_authenticate`. The owner-only tools (sim_delete_model, sim_license_model, sim_supersede_model, sim_publish_app) add that a model created without sign-in belongs to nobody for good, so signing in will not let you act on it: to own a model, sign in first and create it again with something changed. The asymmetry between surfaces is deliberate: `sim_query` and `sim_canonical` need sign-in with read scope (every other MCP tool outside the public set does), while their HTTP forms (`POST /api/query`, `GET /api/models/{id}/canonical`) are anonymous and charged the same tokens per client address. Signed-in tools that run the engine are metered too, on the account's bucket (the same 20 tokens, refilled at 1/s): sim_receipt and sim_publish 1; sim_query 2 (cached or not); sim_canonical 1; sim_prove 3 (2 with `record: false`, 2 with `via`); sim_migration_offers 1; sim_accept_migration 1; sim_create_system 2; sim_publish_compare 1 per scenario; sim_conformance 2; sim_conformance_stream 2; sim_refine 2 (the store plus a verified re-classification); sim_calibrate 3; sim_optimize 1 per 50 samples and sim_param_heatmap 1 per 50 grid cells (both rounded up); sim_distill 5 at its defaults, scaled by its worst-case work (loss evaluations, which grow with iters and with the number of groups, × candidate moves × realizations × horizon); it takes at most 16 groups, each transition in one group, and refuses work over four default runs. sim_compare, sim_publish_compare, sim_distill, sim_optimize and sim_param_heatmap refuse a bad argument before they charge. Every other metered tool is charged when the call arrives, so a refused call still spends its tokens (a refused sim_diagnose costs 5), except one refused for an argument the tool does not declare or a value of the wrong type, which is refused before any charge. The MCP endpoint itself refuses a request body over 4 MiB with a `413`. Resources (`resources/read`): **`sim://docs/authoring`** — how to write a model: a worked example (a two-staff counter with arrivals, service and walk-outs), then the fields a simulation reads with their units and bounds, the mass-action rate law, and the patterns staffing and cost questions need; every model in it is stored and run by the test suite. sim_create_model points to it. **`sim://docs/classification`** — how to read sim_diagnose, sim_classify and sim_refine output (knob kinds, influence, noise floor, classes). Their tool descriptions point to it rather than repeating it. A productive loop: `sim_components` → `sim_compose` (two or three calls build a service-with-hazard) → `sim_diagnose` (do the gates pass? which knobs bind?) → `sim_compare` (price the candidate changes on one seed) → `sim_dataset` / `sim_calibrate` (meet reality) → `sim_license_model` → `sim_publish`. ## Google Sheets publishing A published spreadsheet is a first-class surface, not an export. `sim_publish` (or `POST /api/models/{id}/publish`, or the Publish to Sheets button on the landing page) creates a workbook in **your** Drive, under the `drive.file` grant — the service never owns user files — containing: - the **model itself** in sheet form (places, transitions, arcs, parameters, objective, players, views — rows that round-trip byte-identically back to the model); - a server-run **scenario** as data tabs (trajectory, totals, contention); - **native charts** for the trajectory and contention analysis; - for models honest to solve in cells (pure-kinetic, no inhibitors), a **live formula tab** you can re-solve by editing a rate; otherwise a refusal tab saying why. Published sheets link back to the model id and survive independently of the service. `sim_my_sheets` / `GET /api/me/sheets` lists yours. ## Consoles and apps - `https://sim.pflow.xyz/` — landing: the catalog with Open console / Open app / Dataset / Publish to Sheets per entry. - `https://sim.pflow.xyz/whatif/?id={id}` — the generic what-if console for any stored model: knobs from `/rates`, disruptions, schedules, seeded side-by-side comparison. The vet clinic is the reference: `https://sim.pflow.xyz/vet-clinic/` redirects to its console. - `https://sim.pflow.xyz/build/` — guided model builder (sign-in). - `https://sim.pflow.xyz/app/{id}` — owner-published apps, served with `Content-Security-Policy: sandbox allow-scripts` in an opaque origin: no cookies, no session, only the CORS-open public reads. ## The commons Models dedicated under CC0-1.0, CC-BY-4.0 or CC-BY-SA-4.0 appear in `/api/catalog` for everyone. Current showcases and classics (ids are content addresses; `/api/catalog` is the source of truth): | Model | Id | Demonstrates | |---|---|---| | feature-lab | `47db64e54d24ca0600360c8c` | cost-benefit for software decisions: evaluation gate, capacity-constrained build, telemetry validate/kill, evolutionary feedback loop | | delivery-fleet | `e38fc49777b731cfbc2764b9` | declared objective | | bakery-oven | `830937feca631188b3c421ed` | declared parameters (batch size) | | car-wash | `735dd8109ce265a6de2c37a3` | stages (Erlang-k service) | | food-truck | `5159060ebfc150ea61b8e7a6` | model-carried schedule | | machine-shop | `0cf9c060120daabde879d9ac` | operator-as-player: budget + decisions, `/evaluate` as advisor | | stockroom | `912d422e3401b804cfd037ef` | reorder point via inhibitor | | ward-flow | `f77fd416451ed8fe90e45aad` | ED bays vs ward beds | | outbreak | `7562102d5bd4d9dbecda892a` | stochastic SIR; fizzle probability SSA vs ODE | | bank-run | `340aba697b5ff61e416ee4d1` | contagion retold financially | | retry-storm | `d1b2e93309238d29e0cb7748` | spike + retries = collapse; capped retries recover | | build-farm | `5f40aa647867876483b18fc2` | flaky tests vs more runners | | autoscaler | `61ded8e1b68c47e38e6c0162` | elastic pool, instance-hours meter | | beer-game | `5a89df1945953e1fa41ddb4c` | where safety stock should live | | blood-bank | `51a1f196e033c9876076fc51` | donation drive vs storage | | triage-priority-clinic | `6125314dc1087f55e55c2317` | structural priority via inhibitor | | claims-desk, loan-book, invoice-collections | see catalog | operational-finance flows | | launch-plan | `089fb4145543503191914706` | schedule risk with a rework loop | | ferry-dock | `2e11344b4fd312aa0613295d` | batch departures, impatient queue | | tic-tac-toe | `5a08c67862a2eb55799f5229` | game schema: players, objective, `/evaluate` search | Curated seeds are content-addressed like everything else — their names are catalog entry names, not ids, so `/api/models/vet-clinic` is a 404. As of this revision: vet-clinic `ae6ab5aeb95dd994bc561479`, cafe `3ab9e15cde5c02b463c31354`, predator-prey `0b76c369b203dce8a78f6f0a`, second-car `bde7c003c638b4a73cd1b03e` (editing a seed's JSON moves its id, so `/api/catalog`'s `model` field is the source of truth). `https://sim.pflow.xyz/vet-clinic/` and `https://sim.pflow.xyz/cafe/` redirect to the current console, and `https://sim.pflow.xyz/second-car/` to its console with `hours=168`. **second-car** is a household decision rather than an operation: one car or two, against ride-shares. Over one week, 5 all-day commutes (car gone about 10 h) and 12 errands (about 1.5 h each) need the car; a trip that finds none waits (about 30 min for an errand, 15 for a commute) and then books a ride-share. Prices, all stated in the model's description and objective: ride-share $18 per errand and $30 per commute, driving $4 and $8, owning a car $0.50 an hour (about $84 a week). The objective is minus the week's spend. Run it with `hours: 168`; an 8-hour run rarely sees a commute come back. At these prices a second car costs about $22 a week more (±$2.6 standard error over 12 independent shared-seed comparisons of 64 runs each, seeds 1, 1001, …, 11001 so no two share a run; one 64-run comparison alone reads anywhere from $5 to $35, and seed 20260809 reads $22), and how long a trip waits for the car is below the diagnose noise floor (±$12 at 64 runs) either way. Its own dataset calibrates at 100% fitting. ## Modeling discipline, in brief - Model only the contested flow. A path that "pays on its own" reads as abandonment to the classifier. - Let the run build its own stock within the horizon; seeding a large initial stock breaks the conservation gate. - Token splits (one in, two out) fail mass balance; use read arcs for gates. - An influence below the reported noise is dice, not a small effect. Raise `realizations` or `hours` before concluding a model is inert. - Rate-0 transitions are dormant to scenarios and fire only when a player chooses them — that is how decisions enter a model. ## Operational notes - `/healthz` is reserved at the Cloud Run edge; external probes should use `/api/health` (or `/api/catalog`). - Over HTTP `/scenario`, seed 0 is a fixed seed by design, not "random". Over MCP, 0 means unset and becomes 20260809; HTTP compare turns an unset first seed into 1. See "Defaults per surface". - Receipts verified under a newer service revision that diverge are reported as engine drift, not forgery.