{
  "$defs": {
    "AttendanceIr": {
      "additionalProperties": false,
      "description": "How much of an operation a person has to be present for.\n\n⚠️ **Fractions, not a flat hold.** A person who starts a long automated step and\nwalks away is the normal case in a fab, and a model that holds them for the whole\noperation reports the pool at a multiple of its real load — which then makes\nlabour the binding constraint somewhere it is not.\n\n⚠️ **The Intel Minifab was originally cited as the evidence here, and that was a\nmisreading**: its \"Operator Load Fraction\" is `load_time / 600`, a share of a\nten-hour shift, so it wants a *full* hold. See `docs/traps.md`. The mechanism is\nright and independently tested; the example was not.\n\nThe attended part comes **first** within each phase: the operator starts the\njob and leaves.",
      "properties": {
        "load": {
          "default": 0.0,
          "description": "Fraction of the load phase, in `[0, 1]`.",
          "format": "double",
          "type": "number"
        },
        "process": {
          "default": 0.0,
          "description": "Fraction of the process phase, in `[0, 1]`.",
          "format": "double",
          "type": "number"
        },
        "unload": {
          "default": 0.0,
          "description": "Fraction of the unload phase, in `[0, 1]`.",
          "format": "double",
          "type": "number"
        }
      },
      "type": "object"
    },
    "BatchIr": {
      "additionalProperties": false,
      "description": "A step where several entities are processed together.\n\n⚠️ Sizes are in **pieces**, not entities, because that is what the equipment\nconstrains — a furnace holds 150 wafers whether they arrive as six lots of 25\nor five of 30. [`ProductIr::pieces`] converts.",
      "properties": {
        "group": {
          "default": null,
          "description": "Which lots may batch together. Absent means only lots at this exact step\nof this exact product.\n\nName the same group on several steps to let their lots share a load.\n\n⚠️ Pool only where the data says so, because the wrong reading is invisible.\nSMT2020's `crit_sameroutestep` is the per-step default — its own documentation:\n*\"only lots of the same product at the same process step can be used to form a\nbatch\"*. Pooled across its ten LVHM products by operation name instead, batches\nfilled far faster than the reference's: **0.94** days a lot waiting for a batch\nagainst its **4.21**, and the fab's cycle time looked right because a queue excess\nelsewhere cancelled it (#14).",
          "type": [
            "string",
            "null"
          ]
        },
        "max_pieces": {
          "description": "Most pieces a batch may hold.",
          "format": "uint32",
          "minimum": 0,
          "type": "integer"
        },
        "min_pieces": {
          "description": "Fewest pieces a batch may start with.\n\n⚠️ A hard floor with **no look-ahead**: the batcher never delays a load hoping\nfor an arrival that has not happened. It is also a hard floor in TIME unless\n[`BatchIr::timeout_seconds`] says otherwise — a batcher that starts a partial\nbatch after waiting produces a tidier operating curve than the real fab has,\nand destroys the low-load anomaly that is this model's best structural test\n(see #13), so it is opt-in and absent by default.",
          "format": "uint32",
          "minimum": 0,
          "type": "integer"
        },
        "timeout_seconds": {
          "description": "How long a load may wait for its minimum before running short. Absent means\n**for ever**, which is the behaviour every model written before this field\nexisted has.\n\n⚠️⚠️ **Waiting for ever is correct and it is also silent.** A furnace whose pool\nnever fills produces no queue, no wait and no utilisation, so there is nothing in\nany KPI to notice — and that is exactly how a mis-read batch group cost MIMAC's\nthird product a cycle time of 3.51 days against a published 1.9. Real equipment\nwaits a bounded time and then runs short, which is both the faithful model and a\nstructural defence: a mis-specified group then shows up as a fab full of\nundersized batches, which is loud.\n\n⭐ `0.0` means *never wait*: the load starts with whatever is present the moment a\nserver is free. ⚠️ That does **not** make the station an ordinary single-server\nqueue, because a pool holding several lots still runs them together in one service\ntime; what it removes is the holding, not the batching.\n\n⚠️ Measured from when the load's FIRST lot joined the pool, not from the last —\notherwise a trickle of arrivals would postpone the deadline indefinitely, which is\nthe one thing a timeout exists to prevent.\n\n⚠️⚠️ **`skip_serializing_if`, deliberately, and it is not cosmetic.** `model_hash` is\nFNV-1a over the SERIALISED document, so a field with a plain `#[serde(default)]`\nenters the canonical form of every model that declares a `batch` — as\n`\"timeout_seconds\": null` — and moves its hash whether or not anybody set it.\nMeasured: adding this field without the skip moved `tests/corpus/furnace.json` from\n`8148981a227d5728` to `ae8f7a458e3aa9fc` with **not one behavioural number\nchanging**, which orphans every stored result for every batching model in exchange\nfor nothing. `docs/traps.md` records the same mechanism costing five published\nhashes when `server_rule` was deleted. A document without the key and one with it\nset to `null` mean the same run, so they must hash the same.",
          "format": "double",
          "type": [
            "number",
            "null"
          ]
        }
      },
      "required": [
        "min_pieces",
        "max_pieces"
      ],
      "type": "object"
    },
    "BreakIr": {
      "additionalProperties": false,
      "description": "Time a person is simply not there.",
      "properties": {
        "duration_seconds": {
          "$ref": "#/$defs/DistIr"
        },
        "every_seconds": {
          "description": "Seconds between the **starts** of successive occurrences — a fixed grid, the\nsame convention as calendar maintenance.",
          "format": "double",
          "type": "number"
        },
        "first_at_seconds": {
          "description": "When the **last** person of the pool has their first one.\n\n⚠️ Occurrences are staggered evenly across the pool: two operators with a\n`first_at_seconds` of 360 minutes go at 180 and 360. **This is the only\nanti-synchronisation mechanism there is**, and without it a pool goes to\nlunch together and the fab stops twice a day — while still looking like a\nplausible model.",
          "format": "double",
          "type": "number"
        },
        "name": {
          "type": "string"
        }
      },
      "required": [
        "name",
        "every_seconds",
        "duration_seconds",
        "first_at_seconds"
      ],
      "type": "object"
    },
    "BreakdownIr": {
      "additionalProperties": false,
      "description": "Unplanned failure.",
      "properties": {
        "clock": {
          "$ref": "#/$defs/FailureClockIr",
          "description": "Which clock `mttf_seconds` is measured on. Defaults to calendar."
        },
        "mttf_seconds": {
          "$ref": "#/$defs/DistIr",
          "description": "Mean time to failure, on the clock [`BreakdownIr::clock`] names."
        },
        "mttr_seconds": {
          "$ref": "#/$defs/DistIr"
        }
      },
      "required": [
        "mttf_seconds",
        "mttr_seconds"
      ],
      "type": "object"
    },
    "CapacityPolicyIr": {
      "additionalProperties": false,
      "description": "Stations called in when the queue is long, and stood down when it is short (#120).\n\n⭐⭐ **A LEVEL, not an edge.** The target station count is recomputed from the queue every\ntime the resource is pumped, exactly as [`ResourceIr::roster_profile`] recomputes it from\nthe calendar — so a withdrawal that cannot happen now (the station is working) happens at\nthe next opportunity instead of being lost. `#119`'s pool half learned that the expensive\nway: applied once per boundary, `break_concurrency` read `[0.964, 0.036, 0, 0]` against a\nrequired `0.2857`.\n\n⚠️ **A called-in station is not free**, which is the whole reason this is a decision rather\nthan a lever an optimiser pulls to the stop. Price the stations above `servers` with a\ncharge; without a price, \"call somebody in whenever the queue is long\" improves every KPI\nat no cost and the recommendation is worthless.",
      "properties": {
        "add_above_queue": {
          "description": "Call a station in once the queue is strictly longer than this.",
          "format": "double",
          "type": "number"
        },
        "down_to": {
          "description": "The fewest stations that may be left on. `0` shuts the group entirely.\n\n⭐ Below `servers` this is the idle-shutdown direction the same object expresses — one\nmechanism, two ways: raising capacity spends money, lowering it spends latency.",
          "format": "uint32",
          "minimum": 0,
          "type": "integer"
        },
        "min_engaged_seconds": {
          "description": "The shortest time a called-in station may stay on, if the plant has one.\n\n⭐⭐ **The second half of hysteresis, and it binds where the thresholds and the call-up\ndelay cannot.** Separated thresholds stop a station flapping around ONE queue length;\n`ready_after_seconds` bounds the whole cycle at `(up_to - down_to) / mean`. Measured on\nthe development laptop (AMD Ryzen 7 7840U) over 200,000 s, sweeping both:\n\n| separation | `ready` | engagements per day |\n|---|---|---:|\n| 4.0 | 1 s | 45.4 |\n| 4.0 | 600 s | 35.9 |\n| 1.0 | 1 s | 142.1 |\n| 0.1 | 1 s | **245.8** |\n| 0.1 | 60 s, `up_to` 4 | **618.2** |\n\n⚠️ **Neither caps the rate at a number a roster could run.** At the narrowest\nseparation `validate` permits, a station is called in every six minutes. An agency\nshift with a four-hour minimum against a ten-minute call-up is `min_engaged > ready`,\nand that is the case nothing else here expresses.\n\n⭐ It constrains the **stand-down only**. A station is never kept past its shift for\nthis — the withdrawal simply waits, exactly as it waits for the lot in hand, so the\nlevel is re-asserted at the next `pump` rather than the transition being lost.\n\n⚠️⚠️ **It applies to whichever station the policy engaged, and the stations ROTATE.**\nThe engine withdraws any unoccupied station and calls in any non-scheduled one — it\nkeeps no notion of which station is \"the surge\" — so a declared server stood down under\na `down_to` below `servers` carries an engagement clock exactly like a called-in one\nfrom then on. Only a station that has never been stood down is transparent to the\nminimum.\n\n⭐ So the ceiling on the cycle rate is `stations / min_engaged_seconds`, not\n`(up_to - down_to) / min_engaged_seconds`. Measured: `servers: 1, up_to: 2` with a\none-hour minimum ran **1.75 engagements an hour** over 55.6 hours — between the one\nper hour a single surge station would allow and the two per hour two rotating stations\nallow, which is what rotation looks like and what one station alone cannot produce.",
          "format": "double",
          "type": [
            "number",
            "null"
          ]
        },
        "ready_after_seconds": {
          "$ref": "#/$defs/DistIr",
          "description": "How long a called-in station takes to become usable. **Required.**\n\n⚠️⚠️ **A call-up is not instantaneous, and there is no default**, because the\nomitted case is the flattering one. Measured on the development laptop (AMD Ryzen 7\n7840U), one declared server against a policy of `up_to: 2` / `down_to: 1` with\n`ready_after_seconds` of **60 s**, exponential service of 45 s against exponential\narrivals of 50 s over 200,000 s: the policy cut the mean wait from **340.981 s to\n82.466 s** — 4.135x — while the surge station was on for only **11.8%** of the run.\nAn instant call-up therefore reads as very nearly free, which\nis exactly the direction `docs/traps.md`'s opening line names as the most expensive\nclass available here. A model that omitted this field would get the optimistic answer\nby saying nothing.\n\n⭐ Drawn once per call-up from a stream keyed on the resource's **identity**, so\nadding an unused resource to the document cannot move the answer (ADR-0002, #57).\n\n⚠️ A mean that rounds to **zero ticks** is refused rather than treated as instant:\na station that is available the moment it is asked for is a station that was always\nthere, and that belongs in `servers`."
        },
        "remove_below_queue": {
          "description": "Stand a station down once the queue is strictly shorter than this.\n\n⚠️ Must be **below** `add_above_queue`. Equal thresholds are one threshold, and one\nthreshold flaps.",
          "format": "double",
          "type": "number"
        },
        "up_to": {
          "description": "The most stations that may ever be on, including the declared `servers`.\n\n⚠️ This is the array size, so it is a **ceiling on the model** rather than a hint: the\nengine allocates `up_to` stations and starts the surplus in `NonScheduled`, which is\noutside the availability denominator. A station above `servers` therefore costs\nnothing at all until the policy calls it in.",
          "format": "uint32",
          "minimum": 0,
          "type": "integer"
        }
      },
      "required": [
        "up_to",
        "down_to",
        "add_above_queue",
        "remove_below_queue",
        "ready_after_seconds"
      ],
      "type": "object"
    },
    "ChangeoverIr": {
      "additionalProperties": false,
      "description": "A changeover between two named setups.",
      "properties": {
        "from": {
          "type": "string"
        },
        "seconds": {
          "anyOf": [
            {
              "type": "number"
            },
            {
              "$ref": "#/$defs/DistIr"
            }
          ],
          "description": "Seconds — a bare number, or a [`DistIr`] when the changeover is not\ndeterministic.\n\n⚠️ Asymmetry is normal — A→B and B→A routinely differ, and a model that\nassumes symmetry gets the setup percentage wrong without ever looking wrong.\n\n⚠️⚠️ **A distribution here is not a refinement, it is the dataset's own\nspelling.** SMT2020's extended models declare a setup time exactly the way\nevery other duration in that archive is declared — a kind plus a second\nparameter — and **135 of 361 rows** across its datasets 3 and 4 say\n`uniform`, in four shapes: `24 ± 8`, `37.5 ± 22.5`, `45 ± 15` and\n`90 ± 37.5` minutes. ⚠️ The widest *relative* spread is 60% of its mean;\nthe longest changeover declared anywhere is **[52.5, 127.5] minutes**.\n\n⭐ **Read as its mean, the mean of one changeover is preserved exactly** and\nits variance is gone — which is what a queueing model is for.\n`docs/traps.md` records the same mistake in the other direction, where an\nexponential read as a constant made every breakdown in that fab perfectly\nperiodic while availability came out identical.\n\n⚠️⚠️ **But a tool's time in SETUP is not protected by that**, and saying so\nwas wrong in four places at once. `Setup%` is the changeover *count* times\nthe mean cost, and the count is an outcome of queueing rather than a\nproperty of the document: a changeover that occasionally runs long reshapes\nwhich jobs are waiting, and therefore how often the recipe changes. So a\nreported setup percentage **can** move when only the spread changes, and\n`docs/traps.md` carries the measurement."
        },
        "to": {
          "type": "string"
        }
      },
      "required": [
        "from",
        "to",
        "seconds"
      ],
      "type": "object"
    },
    "ChargeIr": {
      "additionalProperties": false,
      "description": "A price on something the run already reports.\n\n⭐⭐ **A pure read-out, and that is the design constraint rather than an implementation\ndetail.** A charge consumes no randomness, schedules no event and is computed from the\nfinished [`Outcome`], so adding a cost model to an existing study can only **price** its\nanswer, never change it. `trace_hash` is identical with and without a `charges` block,\nand a test asserts it. #120.\n\n⚠️ **Called `charge`, never `cost`.** `Feature::SetupCost` already exists and is a\n**dispatch ranking score**, not money. Two things named \"cost\", one of which is not a\ncost, is the `utilisation` ambiguity `docs/traps.md` records — three similarly-named\nquantities that turned out to be different quantities.",
      "properties": {
        "each": {
          "description": "Money per **unit or occurrence**, for a path that measures a count.",
          "format": "double",
          "type": [
            "number",
            "null"
          ]
        },
        "label": {
          "description": "What to call this line in the report. Defaults to `what`.",
          "type": [
            "string",
            "null"
          ]
        },
        "rate": {
          "description": "Money per **second**, for a path that measures time.\n\n⚠️ Exactly one of `rate` and `each` is allowed, and `validate` insists the one you\ngive matches the path's kind. Pricing server-seconds \"each\" is a category error that\nwould otherwise produce a number rather than a complaint.",
          "format": "double",
          "type": [
            "number",
            "null"
          ]
        },
        "what": {
          "description": "Which reported quantity this prices, as a dotted path.\n\n⚠️ **Every path must name something the run actually emits**, and `validate`\nresolves it against the real vocabulary rather than accepting a string. A charge on\na field that does not exist would price **zero**, silently, for ever — which is the\n`rank_RSETUP` shape this repository has hit five times.\n\n| path | what it measures | priced by |\n|---|---|---|\n| `resources.<id>.state.<State>` | server-seconds in that state | `rate` |\n| `resources.<id>.servers` | servers in the group, a purchase counted once per run | `each` |\n| `resources.<id>.queue` | entity-seconds waiting in the group's queue | `rate` |\n| `operators.<pool>.engaged` | person-seconds working | `rate` |\n| `operators.<pool>.present` | person-seconds on shift and not on a break | `rate` |\n| `consumables.<id>.taken` | units drawn | `each` |\n| `consumables.<id>.produced` | units put back | `each` |\n| `consumables.<id>.level` | unit-seconds of stock on hand | `rate` |\n| `entities.<outcome>` | a count — `completed`, `abandoned`, `scrapped`, `reworked`, `preempted` | `each` |",
          "type": "string"
        }
      },
      "required": [
        "what"
      ],
      "type": "object"
    },
    "CollisionIr": {
      "description": "What happens when a second downtime comes due on a server already down.\n\n⭐ **Settled 2026-09-11, by arithmetic.** What a tool's policies ask for needs no\nsimulator — it is `Σ duration / interval` for a calendar policy and\n`Σ duration × throughput / units` for a usage one. On SMT2020's LVHM the\nreference run delivers **exactly** what the policies ask (Planar: 18.09 asked,\n18.09 reported). Under [`CollisionIr::Overlap`] we delivered **0.799** of it,\nwith a median over 105 tool groups; under [`CollisionIr::Queue`], **1.001**.\n\n⚠️ **That is not a preference between two defensible models.** Overlap loses a\nfifth of the fab's maintenance, which is a fifth of its capacity handed back, and\nit is the flattering direction — a fab that maintains less produces more and\nqueues less.",
      "oneOf": [
        {
          "const": "queue",
          "description": "The second waits and runs after the first, so total time is the sum.\n\n⭐ The default, and the one that reproduces the published `PM%`.",
          "type": "string"
        },
        {
          "const": "overlap",
          "description": "Both run at once; the first to finish returns the server to service.\n\n⚠️ **Kept only so the measurement can be repeated.** It loses the longer\ndowntime, and on SMT2020 that is a fifth of all maintenance.",
          "type": "string"
        }
      ]
    },
    "CommissionIr": {
      "additionalProperties": false,
      "description": "Machines a step puts into service when it completes (#153).\n\n⭐ A fab's ramp as new tools are installed and a factory game's build order are the same\nmechanism: the step that installs is an ordinary step — it can draw its materials, need a\nperson, travel — and its completion is the moment capacity grows. Stations come in lowest\nindex first; a commission with nothing left to install is counted, never silent.",
      "properties": {
        "resource": {
          "type": "string"
        },
        "servers": {
          "format": "uint32",
          "minimum": 0,
          "type": "integer"
        }
      },
      "required": [
        "resource",
        "servers"
      ],
      "type": "object"
    },
    "ConsumableIr": {
      "additionalProperties": false,
      "description": "A quantity that is used up rather than borrowed.\n\n⚠️ Distinct from a resource, and the distinction is load-bearing: a resource\nconserves its own capacity, a consumable does not. That is why this carries a\nreplenishment policy and a resource does not, and it is what makes stockouts\nexpressible at all.",
      "properties": {
        "capacity": {
          "description": "The most that can be held.",
          "format": "double",
          "type": "number"
        },
        "id": {
          "type": "string"
        },
        "initial": {
          "default": null,
          "description": "Stock at the start of the run.",
          "format": "double",
          "type": [
            "number",
            "null"
          ]
        },
        "lead_time_profile": {
          "description": "A calendar the lead time observes, by `id` from `profiles`. Absent means the\nsupplier delivers around the clock, which is what every model written before\n#119 Phase 3 does.\n\n⭐⭐ **The fifth sense of a profile, and the first on a duration that holds no\nresource.** `rate_profile` bends an arrival stream, `scale_profile` a step's\nprocess time, and `roster_profile` staffs a resource or a pool — every one of\nthem attached to something the engine can suspend. A lead time holds nothing, so\nbefore this it ran straight through a closure: measured 2026-09-24 on the\ndevelopment laptop (AMD Ryzen 7 7840U), a 200-second lead placed at t=10 against\na shop open 100 seconds in every 1000 delivered at **210** either way, where\nobserving the calendar delivers at **2010**.\n\n⚠️ **The factor scales the DURATION, as `scale_profile` does and not as\n`rate_profile` does.** A factor of 2.0 is a supplier taking twice as long; a\nfactor of 0.0 is a segment in which the order makes no progress at all. The\nintegrator is shared — `Profile::real_duration` — so a lead that spans a boundary\nis integrated across it rather than fixed at the factor in force when the order\nwas placed.\n\n⚠️⚠️ **What this does NOT express, stated because conflating them is the error\n#119 was opened on.** It is not a due date and not a queue timer: those are\nabsolute ticks that legitimately burn closed hours, and a due date drives\n`Feature::CriticalRatio` dispatch. \"Two business days\" is a deadline, not a\nduration, and no profile makes it one.\n\n⚠️ `skip_serializing_if`, so absent stays absent and no existing model's hash\nmoves — `model_hash` is FNV-1a over the serialised struct, so a key present as\n`null` on every store would re-hash every model that has one.",
          "type": [
            "string",
            "null"
          ]
        },
        "lead_time_seconds": {
          "anyOf": [
            {
              "$ref": "#/$defs/DistIr"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "How long an order takes to arrive."
        },
        "max_outstanding": {
          "default": null,
          "description": "At most this many orders may be in flight at once. Absent means no limit.\n\n⭐⭐ **A supplier who will not accept another purchase order**, which is a real\nconstraint and was the engine's only behaviour until #71. It is expressible\nrather than implicit now, and the difference is not cosmetic: at `1` the\nreplenishment is a **single-server queue**, so the lead time queues and\nPollaczek–Khinchine applies to the wait; with no limit it is an **infinite-server**\none, so orders never queue behind each other and Palm's theorem applies to the\nnumber outstanding. Those are different models with different exact answers, and the\ntest suite pins both.\n\n⚠️ **Setting it to `1` is not a tuning knob, it is a modelling claim.** Measured\n2026-09-15 on the development laptop (AMD Ryzen 7 7840U), at `lambda = 0.5`,\nlead mean 1.0, `S = 2`: `P(stockout)` is **0.2500** capped at one order and\n**0.1001** uncapped, and the mean on-hand level **1.2500** against **1.5003**.",
          "format": "uint32",
          "minimum": 0,
          "type": [
            "integer",
            "null"
          ]
        },
        "reorder_at": {
          "default": null,
          "description": "Order when the inventory **position** falls to or below this. Absent means\nnever -- a consumable that is only replenished by something else in the model.\n\n⭐ **And since #118 there IS something else**: a step's\na step's `produces`. A store with no `reorder_at` is filled by whatever\nroute produces into it — a stacker moving cases from a storeroom to a shelf, a\nrepair task returning a refurbished unit to the pool, a charger filling a buffer.\n⚠️ This sentence was **false for as long as it had been written**: `Consumable::put`\nhad exactly one caller, the automatic reorder, so a reader who built that model\ndiscovered at run time that nothing could ever fill it.\n\n⭐⭐ **The policy is continuous-review `(s,Q)`, and the three fields below are\n`s`, `Q` and the lead time.** Position, not stock on hand: `level + on order`.\nThe distinction is the whole policy — reordering on the level alone would place\na fresh order at every demand until the first delivery landed, because placing\nan order does not move the level.\n\n⚠️⚠️ **What this shape forecloses, stated because #71 was opened by a limit\nnobody had written down.** Until then the engine allowed exactly **one** order in\nflight, which is a supplier who refuses a second purchase order rather than a\ntextbook policy; it is lifted, and any number may now be outstanding. Two\nrestrictions remain and are deliberate:\n\n- **`reorder_quantity` is a constant.** So order-up-to `(s,S)`, and any policy\n  whose order size depends on a forecast, cannot be expressed — which rules out\n  the bullwhip variance-amplification results as oracles.\n- **There is no forecast or demand state.** The policy sees the position and\n  nothing else.\n\n⭐ What it now permits, and the reason the boolean had to go: **base stock\n`(S-1,S)`** is `reorder_at = S - 1` with `reorder_quantity = 1`, which keeps many\norders outstanding at once. That is what makes **Palm's theorem** available —\nthe number outstanding is `Poisson(lambda * L)` *whatever* the lead-time\ndistribution, the same insensitivity the engine already validates for transport.",
          "format": "double",
          "type": [
            "number",
            "null"
          ]
        },
        "reorder_quantity": {
          "default": null,
          "description": "How much to order, `Q`. Constant — see `reorder_at`.",
          "format": "double",
          "type": [
            "number",
            "null"
          ]
        },
        "replenished_by": {
          "anyOf": [
            {
              "$ref": "#/$defs/ReplenishedByIr"
            },
            {
              "type": "null"
            }
          ],
          "description": "What the reorder point actually triggers. Absent means a purchase order, which\nis what every model written before #118 does.\n\n⭐⭐ **The two tiers of a store are the same policy with different suppliers.**\n`order` buys stock from outside the model and it arrives after `lead_time_seconds`;\n`task` releases an **entity** instead, and the store is filled by whatever that\nroute `produces` into it. A shelf whose stacker has to walk\nover, queue for a trolley and take ninety seconds is the second one, and its lead\ntime is an OUTCOME of the model rather than a number somebody typed.\n\n⚠️ `skip_serializing_if`, so absent stays absent and no existing model's hash\nmoves — `model_hash` is FNV-1a over the serialised struct."
        }
      },
      "required": [
        "id",
        "capacity"
      ],
      "type": "object"
    },
    "ConsumesIr": {
      "additionalProperties": false,
      "description": "How much of a consumable a step draws.",
      "properties": {
        "consumable": {
          "type": "string"
        },
        "quantity": {
          "$ref": "#/$defs/DistIr"
        }
      },
      "required": [
        "consumable",
        "quantity"
      ],
      "type": "object"
    },
    "ConwipClassIr": {
      "additionalProperties": false,
      "description": "A complete model.\nA closed release policy: a fixed population circulates for ever.\n\n⭐ **The reason to have it is the oracle.** A closed network has exact answers —\nGordon–Newell, Buzen's algorithm, mean-value analysis — and, better than any of\nthem, a **hard invariant**: the number of entities in the system is always\nexactly `population`. No sampling noise can excuse a violation of that, which\nmakes it the sharpest check in the repository.\n\nIt is also a real dispatch policy. CONWIP is how fabs actually cap\nwork-in-progress, and it is the standard alternative to releasing on a schedule.\nOne circulating class.",
      "properties": {
        "population": {
          "description": "How many entities of this class circulate. ⚠️ Constant for the whole run, by\ndefinition — a population that varies is not a closed network and has no\nproduct form.",
          "format": "uint32",
          "minimum": 0,
          "type": "integer"
        },
        "product": {
          "type": "string"
        }
      },
      "required": [
        "population",
        "product"
      ],
      "type": "object"
    },
    "ConwipIr": {
      "anyOf": [
        {
          "$ref": "#/$defs/ConwipClassIr",
          "description": "The original shape: one class, written as an object."
        },
        {
          "description": "A population per class.",
          "items": {
            "$ref": "#/$defs/ConwipClassIr"
          },
          "type": "array"
        }
      ],
      "description": "⭐ **One class or several**, and a document written before several were possible\nmeans exactly what it meant.\n\n⚠️ A real CONWIP fab caps work-in-progress across a **mix**, so the single class is\nthe special case rather than the general one. The two are genuinely different\nmodels: a second class needs a different normalising constant, and the per-class\nanswers do not follow from the pooled one.\n\n⚠️ **Two shapes, one meaning.** `{\"population\": 3, \"product\": \"p\"}` and\n`[{...}, {...}]` are both accepted, and the single form serialises back to exactly\nthe object it came from — the model identity hash is FNV-1a over the serialised\ndocument, so a shape that changed would orphan every stored closed-network result.\n\n⚠️ **`#[serde(untagged)]` was tried and removed.** It accepts both, but a document\nwith a real mistake in it — a class missing its `product` — reports *\"data did not\nmatch any variant of untagged enum ConwipIr\"*, which names neither the field nor the\nproblem. `docs/traps.md`: **when a check refuses, the message is the whole product of\nthat check.** The hand-written [`Deserialize`] below dispatches on the shape the\ndocument actually used and then lets the ordinary derive produce the ordinary error,\nso the message is `missing field \\`product\\`` again."
    },
    "DedicationIr": {
      "additionalProperties": false,
      "description": "A binding between an entity and one physical server, across steps.",
      "properties": {
        "chain": {
          "description": "A name shared by the step that binds and the steps that must honour it.",
          "type": "string"
        },
        "mode": {
          "$ref": "#/$defs/DedicationMode",
          "description": "`bind` records the server used here; `use` requires the recorded one."
        }
      },
      "required": [
        "chain",
        "mode"
      ],
      "type": "object"
    },
    "DedicationMode": {
      "oneOf": [
        {
          "const": "bind",
          "description": "Remember whichever server ran this step.",
          "type": "string"
        },
        {
          "const": "use",
          "description": "Run on the server remembered for this chain. ⚠️ If that server is down,\nthe entity waits -- it does not fall back, because a real tool lock does\nnot.",
          "type": "string"
        }
      ]
    },
    "DistIr": {
      "description": "A sampled quantity. Mirrors [`clockwright_model::Dist`] but is `serde`-shaped and\ntagged, so a malformed document fails at parse time with a field name rather\nthan at run time with a wrong number.",
      "oneOf": [
        {
          "additionalProperties": false,
          "properties": {
            "kind": {
              "const": "constant",
              "type": "string"
            },
            "value": {
              "format": "double",
              "type": "number"
            }
          },
          "required": [
            "kind",
            "value"
          ],
          "type": "object"
        },
        {
          "additionalProperties": false,
          "properties": {
            "kind": {
              "const": "exponential",
              "type": "string"
            },
            "mean": {
              "format": "double",
              "type": "number"
            }
          },
          "required": [
            "kind",
            "mean"
          ],
          "type": "object"
        },
        {
          "additionalProperties": false,
          "properties": {
            "hi": {
              "format": "double",
              "type": "number"
            },
            "kind": {
              "const": "uniform",
              "type": "string"
            },
            "lo": {
              "format": "double",
              "type": "number"
            }
          },
          "required": [
            "kind",
            "lo",
            "hi"
          ],
          "type": "object"
        },
        {
          "additionalProperties": false,
          "description": "The SMT2020 spelling: a mean with a symmetric offset.",
          "properties": {
            "kind": {
              "const": "uniform_offset",
              "type": "string"
            },
            "mean": {
              "format": "double",
              "type": "number"
            },
            "offset": {
              "format": "double",
              "type": "number"
            }
          },
          "required": [
            "kind",
            "mean",
            "offset"
          ],
          "type": "object"
        },
        {
          "additionalProperties": false,
          "properties": {
            "hi": {
              "format": "double",
              "type": "number"
            },
            "kind": {
              "const": "triangular",
              "type": "string"
            },
            "lo": {
              "format": "double",
              "type": "number"
            },
            "mode": {
              "format": "double",
              "type": "number"
            }
          },
          "required": [
            "kind",
            "lo",
            "mode",
            "hi"
          ],
          "type": "object"
        },
        {
          "additionalProperties": false,
          "properties": {
            "kind": {
              "const": "normal",
              "type": "string"
            },
            "mean": {
              "format": "double",
              "type": "number"
            },
            "rectify": {
              "default": false,
              "type": "boolean"
            },
            "sd": {
              "format": "double",
              "type": "number"
            }
          },
          "required": [
            "kind",
            "mean",
            "sd"
          ],
          "type": "object"
        },
        {
          "additionalProperties": false,
          "properties": {
            "k": {
              "format": "uint32",
              "minimum": 0,
              "type": "integer"
            },
            "kind": {
              "const": "erlang",
              "type": "string"
            },
            "mean": {
              "format": "double",
              "type": "number"
            }
          },
          "required": [
            "kind",
            "k",
            "mean"
          ],
          "type": "object"
        }
      ]
    },
    "DowntimeIr": {
      "additionalProperties": false,
      "description": "Everything that takes a server out of service.",
      "properties": {
        "breakdown": {
          "anyOf": [
            {
              "$ref": "#/$defs/BreakdownIr"
            },
            {
              "type": "null"
            }
          ],
          "default": null
        },
        "maintenance": {
          "default": [],
          "items": {
            "$ref": "#/$defs/MaintenanceIr"
          },
          "type": "array"
        },
        "on_collision": {
          "$ref": "#/$defs/CollisionIr",
          "default": "queue",
          "description": "What to do when two downtimes collide on one server. See [`CollisionIr`]."
        },
        "repair_operator": {
          "description": "A pool a person must come out of before a **breakdown** can be repaired.\n\n⚠️ On [`BreakdownIr`]'s behalf rather than on it, because `BreakdownIr` is\n`Copy` and a repair crew is a name. Same ordering rule as\n[`MaintenanceIr::operator`]: acquired before the tool stops.",
          "type": [
            "string",
            "null"
          ]
        }
      },
      "type": "object"
    },
    "FailureClockIr": {
      "description": "Which clock a failure accrues on.\n\n⚠️⚠️ **\"Unreliable server\" names two different models, and they differ by\nup to 39%.** At λ 0.2, μ 1.0, MTBF 5, MTTR 3 the exact `L` is **0.801** on the\ncalendar clock and **0.576** on the operation clock — same failure rate, same\nrepair time, same load. Both are plausible, and a dataset rarely says which it\nmeans. `docs/traps.md` carries the measured table.\n\n⭐ The ordering is the part that cannot be got wrong by accident: for the same\nMTBF, MTTR and load, **calendar always gives the larger `L`**, because a tool that\ncan also fail while idle spends more of its available time broken.",
      "oneOf": [
        {
          "const": "calendar",
          "description": "The clock runs whether or not the tool is working.\n\n⭐ The default, and what SMT2020 specifies. Every model written before this\nfield existed means this, so the default is not a preference — it is the only\nvalue that leaves those models saying what they said.",
          "type": "string"
        },
        {
          "const": "operation_dependent",
          "description": "The clock runs only while the tool is **utilised** — setup, load, process and\nunload, the same set [`clockwright_model::ServerState::is_utilised`] names.\n\n⚠️ A tool that is idle, blocked, waiting for a person or already down is\nnot wearing. That means a repair does not itself bring the next failure\ncloser, which is exactly what distinguishes this model from the calendar one.",
          "type": "string"
        }
      ]
    },
    "FeatureIr": {
      "description": "A feature a dispatch rule may score on.",
      "oneOf": [
        {
          "enum": [
            "priority",
            "age",
            "setup_cost",
            "slack",
            "remaining_work",
            "quantity"
          ],
          "type": "string"
        },
        {
          "const": "critical_ratio",
          "description": "Slack divided by work remaining. ⚠️ Derived, because a ratio is not a\nweighted sum — see `Feature::CriticalRatio`.",
          "type": "string"
        }
      ]
    },
    "MaintenanceIr": {
      "additionalProperties": false,
      "description": "One recurring maintenance policy. A tool group usually has several nested —\nSMT2020 has weekly, monthly and quarterly running independently.",
      "properties": {
        "drain_first": {
          "description": "Wait for the station to **empty** before going down.\n\nWhen an occurrence comes due while the station holds a lot, the station takes no new\nwork and the maintenance starts the moment the last lot leaves — after its unload. A\ncalendar policy keeps its grid: the next occurrence is due one period after this one\ncame due, not after it started.\n\n⚠️ **Off by default: a policy without it stops the tool at once**, and the lot inside is\nheld, suspended, for the whole maintenance and resumed after — which is what a\nbreakdown does, and what planned work in most fabs does not. SMT2020's reference run\ndrains: its interruptions equal its breakdowns on busy tools, and holding lots through\nevery PM put about 95 extra lots inside tools on its LVHM fab (#25).",
          "type": "boolean"
        },
        "duration_seconds": {
          "$ref": "#/$defs/DistIr"
        },
        "first_at_seconds": {
          "description": "When the **last** server of the group has its first occurrence, for a\n**calendar** policy. ⚠️ Not read for a usage policy, whose stagger is\n`first_at_units` on its trigger.\n\n⚠️ Occurrences are staggered evenly across the group: with four tools and\n`first_at_seconds` of 80 days, first maintenance falls on days 20, 40, 60\nand 80. **This is the only anti-synchronisation mechanism in the model.**\nOmitting it sends every tool in a group down together, which wrecks WIP\ndynamics while still looking like a plausible model.",
          "format": "double",
          "type": "number"
        },
        "name": {
          "type": "string"
        },
        "operator": {
          "description": "A pool a person must come out of before the tool can be taken down.\n\n⚠️ **Acquired BEFORE the tool stops.** Stopping the tool and then queueing\nfor a technician is the one ordering that can deadlock against a lot\nholding an operator — see `clockwright_model::labour`.",
          "type": [
            "string",
            "null"
          ]
        },
        "trigger": {
          "$ref": "#/$defs/PmTrigger"
        }
      },
      "required": [
        "name",
        "trigger",
        "duration_seconds",
        "first_at_seconds"
      ],
      "type": "object"
    },
    "MinRunIr": {
      "additionalProperties": false,
      "description": "A minimum campaign length for one setup.",
      "properties": {
        "lots": {
          "description": "Lots. ⚠️ Zero and one both mean \"no constraint\"; a campaign of one lot is\nwhat happens with no minimum at all.",
          "format": "uint32",
          "minimum": 0,
          "type": "integer"
        },
        "setup": {
          "type": "string"
        }
      },
      "required": [
        "setup",
        "lots"
      ],
      "type": "object"
    },
    "NeedsOperatorIr": {
      "additionalProperties": false,
      "description": "A step's labour requirement.",
      "properties": {
        "attendance": {
          "$ref": "#/$defs/AttendanceIr",
          "default": {
            "load": 1.0,
            "process": 1.0,
            "unload": 1.0
          }
        },
        "pool": {
          "$ref": "#/$defs/PoolsIr",
          "description": "Which pool, or which pools are acceptable."
        }
      },
      "required": [
        "pool"
      ],
      "type": "object"
    },
    "OperatorIr": {
      "additionalProperties": false,
      "description": "A pool of interchangeable people.\n\n⚠️ **Labour is a first-class resource, not a delay.** A tool that has a lot in\nit and nobody to run it is *standby*, not *busy* — the distinction is worth\nseveral points of reported utilisation in a fab that is short-staffed, and\nthere is no way to recover it after the fact from a model that folded the\noperator into the process time.",
      "properties": {
        "breaks": {
          "description": "Breaks, meals and training slots.\n\n⚠️ A person on a break is **unavailable**, not busy. Modelling it as work\nwould make a short-staffed pool look *more* utilised when it is in fact\n*less available*, which are opposite readings of the same fab.",
          "items": {
            "$ref": "#/$defs/BreakIr"
          },
          "type": "array"
        },
        "count": {
          "description": "How many people. ⚠️ Zero is refused: a pool nobody can be taken from stops\nevery step that needs it for ever, which reads as a fab that produces\nnothing rather than as a model error.",
          "format": "uint32",
          "minimum": 0,
          "type": "integer"
        },
        "id": {
          "type": "string"
        },
        "roster_profile": {
          "description": "A [`ProfileIr`] id whose factor is **how many of them are on shift** — a roster.\n\n⚠️⚠️ **A HEADCOUNT, not a multiplier.** The other two profile keys scale a rate or a\nduration; this one names an integer, because 2.4 people is not a shift. A factor\nthat is not a whole number, or that exceeds `count`, is refused rather than rounded.\n\n⭐ **Why this is not [`BreakIr`].** A break is deliberately **staggered** across the\npool — `first_at_seconds` says it is *\"the only anti-synchronisation mechanism there\nis, and without it a pool goes to lunch together and the fab stops twice a day.\"* A\nroster wants precisely what that prevents: everybody off together, phase-locked to a\ncycle. ⚠️ And a measurement of that needs the parameter doing the work: the stagger is\nover `first_at_seconds`, **not** over `every_seconds`. Three people, two days off in\nseven, `break_concurrency`:\n\n| `first_at_seconds` | stagger against the 172,800 s break | measured |\n|---:|---|---|\n| 432,000 | 144,000 s — adjacent pairs overlap | `[0.238, 0.667, 0.095, 0.000]` |\n| 604,800 | 201,600 s — all three disjoint | `[0.143, 0.857, 0.000, 0.000]` |\n\n⭐ Never all three in either, and never even two in the second. Both are exact:\n`2 x 28,800 / 604,800` and `1 - 3 x 2/7`. ⚠️⚠️ Stating only the first array cost two\nindependent readers a failed reproduction on 2026-09-24, because each assumed the\nstagger was `every_seconds / count`.\n\n⭐⭐ **A person finishes their task before going off — and so does a rostered-off\nmachine.** `Labour::start_break` defers to `pending_off` when everybody is busy, and\n[`ServerGroup::station_is_unoccupied`] gates every server withdrawal, so both halves\nwait. ⚠️⚠️ **The split is *roster versus downtime*, not *people versus machines*** — a\nbreakdown seizes the tool mid-operation and banks the remainder, where a shift end is\nplanned hours ahead and the lot in hand is finished.\n\n⚠️ This said the opposite until 2026-09-24, and the page that transcribed it said so\ntoo. Measured: one station, a 100 s cycle with 50 s off, one lot starting at t=30\nneeding 40 s — the lot's service is **40.0 s unbroken** and realised `NonScheduled` is\n**43.333%** against a declared 50%, because the station stays on until t=70. Banking\nwould give 90 s and the full 50%. A test in the suite pins it.",
          "type": [
            "string",
            "null"
          ]
        },
        "travel_seconds": {
          "anyOf": [
            {
              "$ref": "#/$defs/DistIr"
            },
            {
              "type": "null"
            }
          ],
          "description": "How long a person takes to reach the next job, charged to **them** and not to\nthe lot.\n\n⚠️⚠️ **Not [`StepIr::transport_seconds`]**, which moves the *lot* and occupies\nnothing else. In a fab the tools are tens of metres apart, so a person who has\njust finished a job is not available for the next one at the same instant — and\nthat is a cost to the POOL, which is where labour becomes the binding\nconstraint. The walk is taken after the attended work, on the way to whatever\ncomes next, so a lot's own time at a tool is unchanged.\n\n⭐ **The module invariant survives it unchanged**, which is why this is safe to\nadd at all: `clockwright_model::labour` avoids the two-resource deadlock by\nconstruction, on the grounds that *\"a hold on labour has a fixed duration and\nacquires nothing else\"*. A walk makes the hold `attended + walk` — still a fixed\nduration known when the person is taken, and it acquires nothing. ⚠️ A walk that\nhad to **reserve a path** would break it, which is why this is a duration and\nnot a resource.\n\n⭐ It applies to every kind of job the pool does, maintenance and repair\nincluded, because a technician walks too."
        }
      },
      "required": [
        "id",
        "count"
      ],
      "type": "object"
    },
    "PmTrigger": {
      "description": "What makes a maintenance event come due.",
      "oneOf": [
        {
          "additionalProperties": false,
          "description": "Every `every_seconds` of wall-clock time, whether or not the tool worked.",
          "properties": {
            "every_seconds": {
              "format": "double",
              "type": "number"
            },
            "kind": {
              "const": "calendar",
              "type": "string"
            }
          },
          "required": [
            "kind",
            "every_seconds"
          ],
          "type": "object"
        },
        {
          "additionalProperties": false,
          "description": "After `every_units` of work, counted **per individual server**.\n\n⚠️ Per server, not per group: a group-level counter would send every tool for\nmaintenance together.",
          "properties": {
            "counts": {
              "$ref": "#/$defs/UsageCount",
              "description": "What one unit is: a completed entity (the default) or one of its pieces. A\ndataset that counts wafers and runs lots of different sizes needs `pieces` (#154)."
            },
            "every_units": {
              "format": "double",
              "type": "number"
            },
            "first_at_units": {
              "description": "After how many units the **last** server of the group has its first\noccurrence; the others are staggered evenly before it, as `first_at_seconds`\nstaggers a calendar policy. With four tools and 40 units, first maintenance\ncomes after 10, 20, 30 and 40.\n\n⚠️ **Per-server counters do not stagger themselves.** Tools that share work\nevenly wear in lockstep, so without this a group goes down together. Absent,\nevery counter starts at zero.",
              "format": "double",
              "type": [
                "number",
                "null"
              ]
            },
            "kind": {
              "const": "usage",
              "type": "string"
            }
          },
          "required": [
            "kind",
            "every_units"
          ],
          "type": "object"
        }
      ]
    },
    "PoolsIr": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "items": {
            "type": "string"
          },
          "type": "array"
        }
      ],
      "description": "One pool, or any of several.\n\n⚠️ **Several is not a convenience.** The Intel Minifab's middle steps name\n`P01 & P02` — either production operator may run them — while its outer steps\nname one each. Collapsing that into a single pool of two would let both of\nthose steps run at once when only one person exists for them, and modelling it\nas a third disjoint pool would put the same person in two pools at once. The\nrequirement is genuinely \"anybody from this set\"."
    },
    "ProducesIr": {
      "additionalProperties": false,
      "description": "How much of a consumable a step puts back.\n\n⭐ The mirror of [`ConsumesIr`], and the two together are what makes a **second\ntier** expressible: a step that draws from the storeroom and fills the shelf is an\nordinary product with an ordinary route, so queueing for the stacker, competing with\nother duties, and the time it takes all fall out of mechanisms that already exist.\nBefore this, `Consumable::put` had exactly one caller — the automatic reorder — and\nnothing a model could author was able to increase a store. #118.",
      "properties": {
        "consumable": {
          "type": "string"
        },
        "quantity": {
          "$ref": "#/$defs/DistIr"
        }
      },
      "required": [
        "consumable",
        "quantity"
      ],
      "type": "object"
    },
    "ProductIr": {
      "additionalProperties": false,
      "description": "A product and the route it follows.",
      "properties": {
        "id": {
          "type": "string"
        },
        "pieces": {
          "default": 1,
          "description": "Pieces per entity — wafers per lot. ⚠️ Only [`BatchIr`] reads it; every\nother quantity in the model is per entity.",
          "format": "uint32",
          "minimum": 0,
          "type": "integer"
        },
        "steps": {
          "items": {
            "$ref": "#/$defs/StepIr"
          },
          "type": "array"
        }
      },
      "required": [
        "id",
        "steps"
      ],
      "type": "object"
    },
    "ProfileIr": {
      "additionalProperties": false,
      "description": "A named factor that repeats on a cycle — a trading week, a duck curve, a shift pattern.\n\n⭐⭐ **It consumes no randomness.** A profile is a pure function of simulation time, which\nis the whole reason it can exist here at all: [ADR-0002] keys every stream on\n`(replication, entity, purpose)`, and anything that drew from the RNG to decide *when* it\nwas would move every later draw and destroy common random numbers. `compare` reaches a\npairing efficiency above 4x because streams line up; a profile must not disturb that, and\nbeing deterministic is how it does not.\n\n⚠️ **The sense is stated by the FIELD that names the profile, never by the profile.**\n[`ReleaseIr::rate_profile`] multiplies a **rate**; [`StepIr::scale_profile`] multiplies a\n**duration**, and the two are reciprocal — \"1.6x busier\" *divides* an interarrival mean\nwhile \"1.6x slower\" *multiplies* a process time. A single `profile` key would be silently\nambiguous on exactly the field where getting it backwards is easiest, so there is\ndeliberately no such key: ⭐ **the same profile may be named by both, and means opposite\nthings to each, which is correct rather than a trap.**",
      "properties": {
        "cycle_seconds": {
          "format": "double",
          "type": "number"
        },
        "id": {
          "type": "string"
        },
        "segments": {
          "description": "Ordered, and the last must reach `cycle_seconds` exactly — a profile with a gap at the\nend would silently run at factor 1.0 for the remainder, which reads as intent.",
          "items": {
            "$ref": "#/$defs/SegmentIr"
          },
          "type": "array"
        }
      },
      "required": [
        "id",
        "cycle_seconds",
        "segments"
      ],
      "type": "object"
    },
    "QueueTimerIr": {
      "additionalProperties": false,
      "description": "A ceiling on how long an entity may sit between two steps.\n\n⚠️ **Recorded, not enforced.** The SMT2020 authors state plainly that critical\nqueue times are provided in the data but that no dispatching rule acts on them\nat the entrance to a segment. Inventing an enforcement policy here would make\nour numbers incomparable with the published ones -- so violations are counted\nand reported, and the policy is left to a later decision.",
      "properties": {
        "max_seconds": {
          "format": "double",
          "type": "number"
        },
        "until_step": {
          "description": "The step index whose **arrival** stops the clock.\n\n⚠️⚠️ **Arrival, not start of service.** The clock runs from the moment the entity\n*finishes* the step carrying the timer to the moment it *reaches* `until_step`, and\nit stops before that step's own queueing. So the window covers transport, material\nwaits, and the whole of every step in between — and **excludes** the wait for a\nserver at `until_step` itself.\n\n⚠️ **Two adjacent steps with no transport therefore give a window of exactly zero**,\nand such a timer can never fire however tight `max_seconds` is. Measured 2026-09-15:\non a saturated `M/M/1` where 29% of lots wait more than the ceiling,\n`until_step = step + 1` counts **0** violations. If you mean \"including the wait for\nthe next tool\", point `until_step` **past** that tool.\n\n⭐ SMT2020's `STEP_CQT` names a later step rather than the next one, so its timers\nspan real windows and this is not a degenerate case there.",
          "format": "uint",
          "minimum": 0,
          "type": "integer"
        }
      },
      "required": [
        "until_step",
        "max_seconds"
      ],
      "type": "object"
    },
    "RankIr": {
      "additionalProperties": false,
      "description": "One lexicographic rank: a weight per feature.\n\n⚠️ Ranks, not a single weighted sum. Real fab rules are lexicographic\n(*priority, then least setup, then FIFO*) and a single sum can only imitate\nthat with wildly separated magnitudes, which misranks near ties. The classical\nrules are ranks of one weight; a searched rule is one rank of many.",
      "properties": {
        "weights": {
          "default": [],
          "items": {
            "maxItems": 2,
            "minItems": 2,
            "prefixItems": [
              {
                "$ref": "#/$defs/FeatureIr"
              },
              {
                "format": "double",
                "type": "number"
              }
            ],
            "type": "array"
          },
          "type": "array"
        }
      },
      "type": "object"
    },
    "ReleaseIr": {
      "additionalProperties": false,
      "description": "How work enters the model.",
      "properties": {
        "due_after_seconds": {
          "default": null,
          "description": "Seconds from release to the due date. Absent means no due date.",
          "format": "double",
          "type": [
            "number",
            "null"
          ]
        },
        "interarrival_seconds": {
          "$ref": "#/$defs/DistIr"
        },
        "priority": {
          "default": 10.0,
          "format": "double",
          "type": "number"
        },
        "product": {
          "type": "string"
        },
        "quantity": {
          "default": 1,
          "description": "Entities per release.",
          "format": "uint32",
          "minimum": 0,
          "type": "integer"
        },
        "rate_profile": {
          "description": "A [`ProfileIr`] id whose factor multiplies this stream's arrival **rate**.\n\n⚠️ **On the release, not inside `interarrival_seconds`.** Two reasons, and the second\nis the one that matters: `DistIr` is `Copy`, so a `String` inside it would cost that\non every hot path — and a profile key inside a distribution could be written on a\n*duration*, where multiplying the rate is the reciprocal of what the author meant.\nHere the sense is structural: a release has one arrival process, and this scales it.",
          "type": [
            "string",
            "null"
          ]
        }
      },
      "required": [
        "product",
        "interarrival_seconds"
      ],
      "type": "object"
    },
    "ReplenishedByIr": {
      "description": "What a consumable's reorder point releases.",
      "oneOf": [
        {
          "additionalProperties": false,
          "description": "A purchase order: `reorder_quantity` arrives after `lead_time_seconds`.\n\n⭐ The default, and naming it explicitly changes nothing — it is spelled out so a\ndocument can say which of the two it means rather than relying on absence.",
          "properties": {
            "kind": {
              "const": "order",
              "type": "string"
            }
          },
          "required": [
            "kind"
          ],
          "type": "object"
        },
        {
          "additionalProperties": false,
          "description": "Release one entity of `product`, and let its route put the stock back.\n\n⚠️⚠️ **The lead time is then the model's answer, not an input.** A restock that\nqueues behind a busy stacker takes longer, and that is the entire point: the\ndifference between *the shelf is empty because nobody restocked it* and *the\nshelf is empty because nobody ordered any* is a staffing question, and it is only\nvisible when the restock competes for a real resource.\n\n⚠️ `reorder_quantity` and `lead_time_seconds` are NOT used here — the route\ndecides both — and `validate` says so rather than ignoring them.",
          "properties": {
            "kind": {
              "const": "task",
              "type": "string"
            },
            "product": {
              "type": "string"
            }
          },
          "required": [
            "kind",
            "product"
          ],
          "type": "object"
        }
      ]
    },
    "ResourceIr": {
      "additionalProperties": false,
      "description": "A group of interchangeable servers — a tool group.",
      "properties": {
        "capacity": {
          "default": 1,
          "description": "How many lots one station can hold at once.\n\n⭐ **A station, not a slot.** A machine that holds two lots is still one\nmachine: it goes down once, it reports one time-in-state, and it wears at\none rate. Modelling it as two servers would double its maintenance and make\nevery published percentage wrong.\n\n⚠️ Only meaningful with [`StepIr::start_interval_seconds`]. Without an\ninterval a station of capacity *c* simply starts *c* lots at once, which is\n*c* independent servers — and SMT2020's intervals are 70–90% of the process\ntime, so the interval is what actually binds.",
          "format": "uint32",
          "minimum": 0,
          "type": "integer"
        },
        "capacity_policy": {
          "anyOf": [
            {
              "$ref": "#/$defs/CapacityPolicyIr"
            },
            {
              "type": "null"
            }
          ],
          "description": "Capacity that reacts to the **queue** rather than to the clock (#120).\n\n⚠️⚠️ **Not [`ResourceIr::roster_profile`] with a different trigger — and the two are\nrefused together**, because both compute the same thing (how many stations should be\non right now) and two writers of one level is how `#119`'s pool half lost every\nwithdrawal it could not make at a boundary.\n\n⭐⭐ **The signal is queue LENGTH, and that is a correction to the shape `#120`\nproposed.** The ticket suggested a threshold on `mean_wait_seconds`, which is a running\naverage over the whole run: it barely moves late on, so a threshold on it fires once\nand never again. Queue length is the instantaneous state, it is what every exact\noracle in that ticket's own literature audit uses — any staffing rule `s(n)` depending\nonly on the current level keeps the chain solvable — and it is what makes a CTMC over\n`(queue, servers engaged)` the right oracle rather than an approximation.\n\n⚠️⚠️ **Hysteresis is mandatory, not a refinement.** One threshold makes a station flap\non and off around it once per arrival for ever, so `validate` refuses\n`remove_below_queue >= add_above_queue`."
        },
        "changeovers": {
          "default": [],
          "description": "Changeover costs. Absent pairs are free.",
          "items": {
            "$ref": "#/$defs/ChangeoverIr"
          },
          "type": "array"
        },
        "downtime": {
          "$ref": "#/$defs/DowntimeIr",
          "default": {
            "breakdown": null,
            "maintenance": [],
            "on_collision": "queue"
          },
          "description": "Scheduled and unscheduled downtime.\n\n⚠️ Absent means the tool **never** breaks and never goes for maintenance,\nwhich is an optimistic model rather than a neutral one: SMT2020's areas\nsit at 80–96.5% availability."
        },
        "entity_rule": {
          "$ref": "#/$defs/RuleIr",
          "default": {
            "ranks": [
              {
                "weights": [
                  [
                    "age",
                    1.0
                  ]
                ]
              }
            ]
          },
          "description": "Which waiting entity a free server takes."
        },
        "id": {
          "type": "string"
        },
        "installed": {
          "description": "How many of `servers` stand at the start; the rest are **not installed** until a step's\n`commissions` brings them into service. Absent means all of them.\n\n⚠️ An uninstalled station is `NonScheduled` — where SEMI E10 books installation — so it\nis outside the availability denominator and costs nothing until it arrives. Shift rosters\nand capacity policies also write `NonScheduled`, and on the same group either would bring\nan uninstalled station on shift, so `validate` refuses the combination.",
          "format": "uint32",
          "minimum": 0,
          "type": [
            "integer",
            "null"
          ]
        },
        "load_seconds": {
          "default": 0.0,
          "description": "Seconds to take work in and hand it back. Excluded from process time,\ncounted as utilised — the fab convention.",
          "format": "double",
          "type": "number"
        },
        "min_run": {
          "description": "Minimum campaign length per setup: once a tool carries this setup it must\nprocess this many lots before changing to another.\n\n⚠️⚠️ **ABSOLUTE: the tool WAITS rather than break a campaign**, even when no\nwork of its own setup is left. That is a measured reading rather than a\nconvenient one — letting a tool change when nothing of its kind happened to\nbe waiting put SMT2020's three `MINRUN` groups at `SETUP%` 20.26 / 17.55 /\n25.08 against a published 15.72 / 11.40 / 14.27, because the guard fired on\nnearly every campaign end and removed the constraint in the common case.\nAutoSched's tools wait. See `docs/traps.md`.\n\n⚠️ **So the hazard is real and is instrumented rather than designed away.** A\ntool three lots into a campaign of seven, with no more of that kind coming,\nstops the group for ever — and a stopped group does not crash, it quietly\nproduces less. [`ResourceOutcome::campaign_blocked_pct`] is what says so:\nmeasured, two lots against a minimum of five strand every other lot in the\nplant and that field reads **99.3**.\n\n⚠️ **This comment said the opposite until 2026-09-15**, describing the\nyielding reading the engine has never implemented — the difference between\nthe two is a plant that finishes 22 lots and one that finishes 2. Pinned by\n`renewal_reward.rs`.",
          "items": {
            "$ref": "#/$defs/MinRunIr"
          },
          "type": "array"
        },
        "preemptive": {
          "default": false,
          "description": "Whether a higher-priority arrival takes a server away from work in progress.\n\n⚠️ **Preempt-RESUME**: the interrupted entity keeps the service it has\nalready had and finishes the remainder later. Preempt-repeat is a different\ndiscipline with a different answer, and the closed form this is checked\nagainst is the resume one.\n\n⚠️ Ranked on `priority` alone, not on the full dispatch rule. A rule is a\nweight vector over several features and \"outranks\" has to be a total order on\none of them for preemption to mean anything; priority is the one every\npreemptive discipline in the literature uses.\n\n⚠️ Only work in **processing** is preemptible. A tool mid-setup, mid-load or\nmid-unload is not interrupted — no published result assumes it is, and\ninterrupting a changeover would need a policy for whether it must be redone.",
          "type": "boolean"
        },
        "queue_capacity": {
          "description": "How many entities may WAIT here. Absent means unbounded.\n\n⚠️ **Counted excluding the ones in service**, so a group with `servers: 1`\nand `queue_capacity: 4` is the queueing theorist's `M/M/1/5`. Getting that\nboundary wrong shifts every blocking probability by one term and still\nproduces a plausible-looking fab.\n\n⚠️⚠️ **That identity holds while the tool never stops, and not otherwise.**\nHolding capacity is the declared waiting room plus one position per slot that\ncould serve *right now*, so a station that is down, in setup, waiting for an\noperator or owing a campaign holds one fewer lot per unavailable slot — you\ncannot load a broken machine. Add a breakdown to the group above and it holds\n5 while up and 4 while down-and-empty, which is a different chain with a\ndifferent answer. See `ServerGroup::has_no_room_for` and #125.\n\n⚠️ **An arrival that finds the queue full is TURNED AWAY, not blocked\nupstream.** Those are different mechanisms with different answers, and only\nthe first has a closed form. A lot that cannot enter is counted in\n`Outcome::rejected` and leaves the model — it is **not** silently dropped,\nbecause a fab that quietly loses work reports better throughput than it has.\nWhich of the two a full queue does is [`ResourceIr::when_full`].",
          "format": "uint32",
          "minimum": 0,
          "type": [
            "integer",
            "null"
          ]
        },
        "roster_profile": {
          "description": "A [`ProfileIr`] id whose factor is **how many servers are staffed** — a roster.\n\n⚠️⚠️ **A COUNT, not a multiplier**, like [`OperatorIr::roster_profile`] and unlike\nthe other two senses: 2.4 servers is not a plant. A factor that is not a whole\nnumber, or that exceeds `servers`, is refused rather than rounded.\n\n⭐ A rostered-off server is [`ServerState::NonScheduled`] — **outside** the\navailability denominator, not counted against it. A closed weekend is non-scheduled\ntime under SEMI E10, not downtime, and reporting a perfect machine as 71% available\nbecause the fab shuts would send a planner shopping for maintenance when what they\nneed is headcount.\n\n⚠️ **Refused together with `downtime`, for now.** A station that is both rostered\noff and broken raises a question this does not yet answer — which state it returns\nto when the shift starts, and whether the failure clock ran while nobody was there.\nGuessing would put hours into `PM%`, the one column measured against a published\nreference. See #119.",
          "type": [
            "string",
            "null"
          ]
        },
        "servers": {
          "format": "uint32",
          "minimum": 0,
          "type": "integer"
        },
        "unload_seconds": {
          "default": 0.0,
          "format": "double",
          "type": "number"
        },
        "when_full": {
          "$ref": "#/$defs/WhenFullIr",
          "default": "reject",
          "description": "What happens to a lot that finds `queue_capacity`\nfull.\n\n⚠️ **Defaults to `Reject`, so no existing model changes.** The two are\ndifferent mechanisms with different answers:\n\n| | `Reject` | `Block` |\n|---|---|---|\n| the lot | leaves, counted in `Outcome::rejected` | stays in the upstream server |\n| the upstream tool | free to start the next lot | ⚠️ **held, and producing nothing** |\n| oracle | ⭐ `M/M/1/K`, exact | a saturated tandem, exact; nothing general |\n\n⚠️ **`Block` can deadlock**, and on a re-entrant route that is the normal\ntopology rather than an exotic one: a cycle of tools each holding a lot and\nwaiting for room in the next. The hazard is not designed away — it is\nmeasured, in `ServerState::Blocked` and `Outcome::stalled_resources`."
        }
      },
      "required": [
        "id",
        "servers"
      ],
      "type": "object"
    },
    "ReworkIr": {
      "additionalProperties": false,
      "description": "Send an entity back to an earlier step, with some probability.\n\n⚠️ Rework is a *loop in the route*, and it is re-evaluated on the repeat pass —\nso the number of extra visits is geometric, not one. Modelling it as a single\nretry understates load on the reworked segment.",
      "properties": {
        "excursion": {
          "description": "A sub-route to run instead, returning to the step after this one.\n\n⭐ Exactly one of `to_step` and `excursion`, and `validate` refuses both or\nneither. `to_step` can only send a lot back over operations this product's own\nroute already contains; an excursion is a detour onto operations it may never\nvisit. Measured 2026-09-19 on MIMAC, which is the reason this exists: `set3`'s\n`A1090` is `COAT5 -> PAL -> DEV -> SCOP2`, four operations on four tools, and\n**all 116** of that dataset's rework steps point into such a sequence.\n\n⚠️ The sub-route is a detour, not an exit: the lot that walks it is the same\nlot, and it resumes at the step after the one that sent it.",
          "items": {
            "$ref": "#/$defs/StepIr"
          },
          "type": [
            "array",
            "null"
          ]
        },
        "probability": {
          "description": "Probability in `[0, 1]` of going back.",
          "format": "double",
          "type": "number"
        },
        "to_step": {
          "description": "The step index to return to. ⚠️ Must be earlier than this one, or the\nroute would move forward and the \"rework\" would be a skip.\n\n⚠️ `skip_serializing_if`, not a bare `Option`: `model_hash` is FNV-1a over the\nSERIALISED document, so a key that appears as `null` on every model that does not\nuse it moves **every** model hash in the project and orphans every stored result.\nMeasured twice -- deleting `server_rule` and adding `BatchIr::timeout_seconds`.",
          "format": "uint",
          "minimum": 0,
          "type": [
            "integer",
            "null"
          ]
        }
      },
      "required": [
        "probability"
      ],
      "type": "object"
    },
    "RuleIr": {
      "additionalProperties": false,
      "properties": {
        "ranks": {
          "default": [],
          "items": {
            "$ref": "#/$defs/RankIr"
          },
          "type": "array"
        }
      },
      "type": "object"
    },
    "ScheduledLotIr": {
      "additionalProperties": false,
      "description": "One lot, released at a stated time.\n\n⚠️ **A schedule, not a distribution**, and the two are not interchangeable. A\ndataset that enumerates its lots is telling you each one's due date and priority\nas well as its start — and where the dispatch rule ranks on critical ratio, the\ndue date is what decides every choice the fab makes. SMT2020's LVHM ranks on\ncritical ratio at **all 106** of its tool groups.",
      "properties": {
        "at_seconds": {
          "format": "double",
          "type": "number"
        },
        "at_step": {
          "description": "⭐ Where on the route it starts. This is what makes initial\nwork-in-progress the *same* mechanism rather than a second one: SMT2020's\n`WIP.txt` is an order file like any other, and its only distinguishing\ncolumn is a step number.",
          "format": "uint",
          "minimum": 0,
          "type": [
            "integer",
            "null"
          ]
        },
        "due_seconds": {
          "description": "Absolute, not an offset — an enumerated schedule states the date.",
          "format": "double",
          "type": [
            "number",
            "null"
          ]
        },
        "pieces": {
          "format": "uint32",
          "minimum": 0,
          "type": [
            "integer",
            "null"
          ]
        },
        "priority": {
          "default": 0.0,
          "format": "double",
          "type": "number"
        },
        "product": {
          "type": "string"
        }
      },
      "required": [
        "product",
        "at_seconds"
      ],
      "type": "object"
    },
    "ScrapIr": {
      "additionalProperties": false,
      "description": "Pieces lost at a step.\n\n⚠️ Expressed as two independent probabilities because MIMAC does, and they mean\ndifferent things: `lot` destroys the whole entity, `wafer` thins it. A model that\ncollapses them cannot represent a fab where whole-lot loss is rare and per-wafer\nloss is routine — which is every fab.",
      "properties": {
        "entity": {
          "default": 0.0,
          "description": "Probability the entire entity is destroyed after this step.\n\n⚠️ `lot` is accepted as an alias so documents written before this field was\nnamed for the abstraction still parse. A semiconductor word in the public\nschema is a claim that this engine is for semiconductors, and it is not --\nthe same field is a car leaving a paint shop or a tray failing inspection.",
          "format": "double",
          "type": "number"
        },
        "lot": {
          "default": 0.0,
          "description": "Probability the entire entity is destroyed after this step.\n\n⚠️ `lot` is accepted as an alias so documents written before this field was\nnamed for the abstraction still parse. A semiconductor word in the public\nschema is a claim that this engine is for semiconductors, and it is not --\nthe same field is a car leaving a paint shop or a tray failing inspection.",
          "format": "double",
          "type": "number"
        },
        "piece": {
          "default": 0.0,
          "description": "Probability each remaining piece is destroyed after this step.\n\n⚠️ `wafer` is accepted as an alias, for the same reason as above. A \"piece\"\nis whatever [`ProductIr::pieces`] counts: wafers in a lot, bottles in a\ncrate, panels on a pallet.",
          "format": "double",
          "type": "number"
        },
        "wafer": {
          "default": 0.0,
          "description": "Probability each remaining piece is destroyed after this step.\n\n⚠️ `wafer` is accepted as an alias, for the same reason as above. A \"piece\"\nis whatever [`ProductIr::pieces`] counts: wafers in a lot, bottles in a\ncrate, panels on a pallet.",
          "format": "double",
          "type": "number"
        }
      },
      "type": "object"
    },
    "SegmentIr": {
      "additionalProperties": false,
      "description": "One step of a repeating, deterministic, piecewise-constant profile.\n\n⚠️ `until_seconds` is measured from the **start of the cycle**, not from the previous\nsegment, so a document states boundaries rather than durations and cannot drift.",
      "properties": {
        "factor": {
          "format": "double",
          "type": "number"
        },
        "until_seconds": {
          "format": "double",
          "type": "number"
        }
      },
      "required": [
        "until_seconds",
        "factor"
      ],
      "type": "object"
    },
    "StepIr": {
      "additionalProperties": false,
      "description": "One operation on a route.",
      "properties": {
        "batch": {
          "anyOf": [
            {
              "$ref": "#/$defs/BatchIr"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Process several entities together. Absent means one at a time.\n\n⚠️ Entities batch only with others at the **same step of the same product**,\nwhich is what SMT2020's `crit_sameroutestep` specifies. A batcher that\npools across steps would fill furnaces far faster than the real one."
        },
        "commissions": {
          "description": "Machines this step puts into service when it **completes** — a build, an installation,\na tool qualified for production. See `installed` on the resource.",
          "items": {
            "$ref": "#/$defs/CommissionIr"
          },
          "type": "array"
        },
        "consumes": {
          "default": [],
          "description": "Consumables this step draws down. ⚠️ Drawn **before** processing begins\nand never returned; an entity that cannot get its materials waits at the\nstep rather than occupying a server while short.",
          "items": {
            "$ref": "#/$defs/ConsumesIr"
          },
          "type": "array"
        },
        "dedication": {
          "anyOf": [
            {
              "$ref": "#/$defs/DedicationIr"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Bind this entity to a server, or require a previously bound one."
        },
        "name": {
          "default": "",
          "description": "A human-readable label, carried through to output.",
          "type": "string"
        },
        "operator": {
          "anyOf": [
            {
              "$ref": "#/$defs/NeedsOperatorIr"
            },
            {
              "type": "null"
            }
          ],
          "description": "A person this step needs, as well as the tool."
        },
        "patience_seconds": {
          "anyOf": [
            {
              "$ref": "#/$defs/DistIr"
            },
            {
              "type": "null"
            }
          ],
          "description": "How long an entity will wait in this step's queue before abandoning it.\n\n⚠️ **Abandonment is not scrap.** The entity leaves the queue and the model,\ncounted in `Outcome::abandoned`; it is not destroyed at a step and it does\nnot continue. A model that silently kept them would report a fab whose\ncustomers all wait for ever.\n\n⚠️ Only meaningful where something waits. An entity already in service\ncannot abandon — that is a different mechanism, and no queueing result this\nproject checks against assumes it."
        },
        "piece_interval_seconds": {
          "description": "A wafer cascade: the entity's pieces follow each other through the tool this far apart.\nThe step takes `process_seconds + (pieces - 1) × interval`, and on a station holding\nmore than one entity (`ResourceIr::capacity` above 1) the next may start\n`pieces × interval` after this one.\n\n⚠️ That start interval follows from the pieces, so this is refused beside\n`start_interval_seconds` (#154).",
          "format": "double",
          "type": [
            "number",
            "null"
          ]
        },
        "probability": {
          "default": 1.0,
          "description": "Probability in `[0, 1]` that the step happens at all. Metrology sampling.",
          "format": "double",
          "type": "number"
        },
        "process_per_piece": {
          "description": "`process_seconds` is per PIECE: the drawn time is multiplied by the pieces the entity\ncarries, which an enumerated schedule may set per lot (`ScheduledLotIr::pieces`).\n\n⚠️ One draw scaled, not one draw per piece. A batch step has no single entity to count,\nso the two together are refused (#154).",
          "type": "boolean"
        },
        "process_seconds": {
          "$ref": "#/$defs/DistIr"
        },
        "produces": {
          "description": "Consumables this step puts back, applied when the step **completes**.\n\n⚠️ **At completion, not at start — a half-stocked shelf is not stock.** And\nbefore the scrap and rework decisions, deliberately: a step that drew its\nmaterials and finished the work put those units somewhere, whatever becomes of\nthe entity afterwards. Producing later would let a scrapped restock task delete\nstock it had already taken out of the storeroom, which is a conservation break\nwearing a plausible name.\n\n⚠️ **Over-production is counted, never silently dropped.** A store is capped at\nits `capacity`, so a step may produce more than fits; the excess is reported as\n`ConsumableOutcome::total_spilled` and `validate` refuses the case it can see\nstatically — a constant quantity larger than the store itself.\n\n⭐ `skip_serializing_if` so that adding this field moved **no existing model's\nhash**: `model_hash` is FNV-1a over the serialised struct, so a field that\nalways appears would have orphaned every stored result and every published\nhash at once. Sixteen fields in this file already do the same.",
          "items": {
            "$ref": "#/$defs/ProducesIr"
          },
          "type": "array"
        },
        "queue_timer": {
          "anyOf": [
            {
              "$ref": "#/$defs/QueueTimerIr"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "A ceiling on the wait between finishing here and starting a later step."
        },
        "resource": {
          "description": "Which resource performs it.",
          "type": "string"
        },
        "rework": {
          "anyOf": [
            {
              "$ref": "#/$defs/ReworkIr"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Send the entity back to an earlier step, with some probability."
        },
        "scale_profile": {
          "description": "A [`ProfileIr`] id whose factor multiplies this step's **process duration**.\n\n⚠️⚠️ **The reciprocal of [`ReleaseIr::rate_profile`], and that is the whole reason\nthe two are separate keys.** A factor of `1.6` here means *takes 60% longer*; the\nsame `1.6` on a rate profile means *60% more arrivals*, which makes gaps **shorter**.\nA single `profile` key would be silently ambiguous on exactly the field where getting\nit backwards is easiest.\n\n⭐ **A duration that spans a boundary is integrated, not sampled at its start.** The\njob carries a fixed amount of *work* and accrues it at `1 / factor(t)` per second, so\na job begun before a slow segment finishes late rather than on its original estimate.\nMeasured on a saturated server at cycle 200 s, factors 1.0 and 3.0, work 60 s: the two\nreadings are **39.6%** apart, and only the integrating one matches the exact capacity\n`sum(width_i / factor_i) / work` per cycle.\n\n⚠️ On the step rather than inside `process_seconds`, for the reason\n[`ReleaseIr::rate_profile`] gives: `DistIr` is `Copy`, and a profile key inside a\ndistribution could be written where the sense is reversed.",
          "type": [
            "string",
            "null"
          ]
        },
        "scrap": {
          "anyOf": [
            {
              "$ref": "#/$defs/ScrapIr"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Wafers destroyed at this step. ⚠️ Scrap is **loss**, not rework: the pieces\ndo not go anywhere and must show up in the conservation check."
        },
        "setup": {
          "default": null,
          "description": "The setup this step needs. `None` means any.",
          "type": [
            "string",
            "null"
          ]
        },
        "setup_always": {
          "description": "Perform `setup` on every entity, even when the server is already in it — a\nrecalibration rather than a change (SMT2020's `WHEN = always`, #155). Priced by the\nresource's changeover row from any setup (`from: \"\"`) to this one, and by that row only:\nan explicit `from` into the same setup prices a change, never this.",
          "type": "boolean"
        },
        "start_interval_seconds": {
          "description": "The soonest the station may start ANOTHER lot after starting this one.\n\n⭐ Lot-level cascading, the same shape as a wafer-level part interval. With\n[`ResourceIr::capacity`] of *c*, a saturated station delivers one lot every\n`max(start_interval, process / c)`.\n\n⚠️ It belongs to the STEP because the interval is a property of the operation\nbeing run, not of the machine — although in SMT2020 the ratio\n`process / interval` turns out to be constant within a tool area, which is\nhow the column was recognised.",
          "format": "double",
          "type": [
            "number",
            "null"
          ]
        },
        "transport_operator": {
          "anyOf": [
            {
              "$ref": "#/$defs/TransportLabourIr"
            },
            {
              "type": "null"
            }
          ],
          "description": "A person who must accompany the lot during `transport_seconds`."
        },
        "transport_seconds": {
          "anyOf": [
            {
              "$ref": "#/$defs/DistIr"
            },
            {
              "type": "null"
            }
          ],
          "default": null,
          "description": "Time to travel to this step, before queueing for a server.\n\n⚠️ A delay, **not** tool time. Folding it into `process_seconds` was tried\nand charged every tool 7.5 minutes per visit: SMT2020's HVLM fab went from\n41,681 lots per two years to 30,033 and its cycle time from 26 days to 238.\nTransport occupies the lot, never the equipment."
        }
      },
      "required": [
        "resource",
        "process_seconds"
      ],
      "type": "object"
    },
    "TransportLabourIr": {
      "additionalProperties": false,
      "description": "A person who has to accompany a lot while it is handled or moved.\n\n⚠️ **The handling is NOT the tool's time.** MIMAC's load and unload happen at a\nload port while the machine is free for the next lot: charged to the tool they\nput the Intel Minifab's bottleneck at 130% of one machine at the dataset's own\nrelease rate, and the dataset's stated \"91.3% of maximum input rate\" is\nreproduced only by the other reading. See\n#22.\n\n⚠️ It is deliberately a **separate** thing from [`ResourceIr::load_seconds`],\nwhich blocks the server and counts as utilised — the fab convention SMT2020 is\nmeasured against. Merging the two would move a published number.",
      "properties": {
        "attendance": {
          "default": 1.0,
          "description": "Fraction of the delay a person is needed for, in `(0, 1]`. The attended part\ncomes first — the operator starts the move and lets it finish.",
          "format": "double",
          "type": "number"
        },
        "pool": {
          "$ref": "#/$defs/PoolsIr",
          "description": "Which pool, or which pools are acceptable."
        }
      },
      "required": [
        "pool"
      ],
      "type": "object"
    },
    "UsageCount": {
      "description": "What a usage-triggered maintenance counts.",
      "oneOf": [
        {
          "const": "entities",
          "description": "One unit per entity the server completes.",
          "type": "string"
        },
        {
          "const": "pieces",
          "description": "One unit per piece the completed entity carries.",
          "type": "string"
        }
      ]
    },
    "WhenFullIr": {
      "description": "What a full queue does to the lot that finds it full.",
      "oneOf": [
        {
          "const": "reject",
          "description": "Turn it away and count it. ⭐ `M/M/1/K`, and it has a closed form.",
          "type": "string"
        },
        {
          "const": "block",
          "description": "Hold it in the tool that finished it, which can then start nothing else.",
          "type": "string"
        }
      ]
    }
  },
  "$id": "https://clockwright.dev/schema/model.json",
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "additionalProperties": false,
  "properties": {
    "charges": {
      "description": "Prices on quantities the run already reports.\n\n⭐ Optional, and absent costs nothing: a model with no `charges` produces a\n**bit-identical** run, `trace_hash` included, because charges are read off the\nfinished outcome and touch no part of the simulation. `skip_serializing_if` keeps\nthat true of the model hash as well.",
      "items": {
        "$ref": "#/$defs/ChargeIr"
      },
      "type": "array"
    },
    "consumables": {
      "default": [],
      "items": {
        "$ref": "#/$defs/ConsumableIr"
      },
      "type": "array"
    },
    "conwip": {
      "anyOf": [
        {
          "$ref": "#/$defs/ConwipIr"
        },
        {
          "type": "null"
        }
      ],
      "description": "A fixed circulating population instead of an arrival process.\n\n⚠️ Mutually exclusive with `releases` and `schedule`: a closed network with\narrivals is an open network, and none of the closed-form answers apply."
    },
    "horizon_seconds": {
      "description": "How long to simulate, after warm-up.",
      "format": "double",
      "type": "number"
    },
    "name": {
      "type": "string"
    },
    "operators": {
      "description": "⚠️ Skipped when empty, deliberately. `model_hash` is taken over the\ncanonical serialisation, so a field that always appears would change the\nhash of every model ever written the moment it was added — and a hash that\nmoves for a feature a model does not use invalidates every cached result\nfor nothing.",
      "items": {
        "$ref": "#/$defs/OperatorIr"
      },
      "type": "array"
    },
    "products": {
      "items": {
        "$ref": "#/$defs/ProductIr"
      },
      "type": "array"
    },
    "profiles": {
      "description": "Named calendar profiles. ⚠️ `skip_serializing_if` is load-bearing: the model hash is\nFNV-1a over the **serialised** struct, so a field that always appears would re-hash\nevery model in existence and orphan every stored result. `docs/traps.md` records that\nhappening when `server_rule` was deleted.",
      "items": {
        "$ref": "#/$defs/ProfileIr"
      },
      "type": "array"
    },
    "releases": {
      "items": {
        "$ref": "#/$defs/ReleaseIr"
      },
      "type": "array"
    },
    "resources": {
      "items": {
        "$ref": "#/$defs/ResourceIr"
      },
      "type": "array"
    },
    "schedule": {
      "description": "Lots released at explicitly listed times, in addition to `releases`.\n\n⚠️ Sorted by `at_seconds` at compile time, so a document may list them in any\norder — but two lots at the same instant are released in **document order**,\nwhich is the only thing that makes a run reproducible.",
      "items": {
        "$ref": "#/$defs/ScheduledLotIr"
      },
      "type": "array"
    },
    "schema_version": {
      "default": 1,
      "format": "uint32",
      "minimum": 0,
      "type": "integer"
    },
    "tick_scale_per_second": {
      "default": 1000,
      "description": "Clock resolution. ⚠️ An integer tick, per ADR-0003; this says how fine.",
      "format": "int64",
      "type": "integer"
    },
    "warmup_seconds": {
      "default": 0.0,
      "description": "Discarded before statistics begin. ⚠️ A hint only — MSER-5 chooses the\nreal truncation from the observed series and records what it chose.",
      "format": "double",
      "type": "number"
    }
  },
  "required": [
    "name",
    "horizon_seconds",
    "resources",
    "products",
    "releases"
  ],
  "title": "clockwright model",
  "type": "object"
}
