Skip to main content

Resources

A resource is a group of interchangeable servers: four identical CNC machines, two paint booths, a crew of three inspectors. Work queues for the group, and the engine gives it to whichever server frees first.

id: grinding
servers: 3
# process time lives on the step, not here
A resource is a group, not a machine

servers: 3 is one resource with three servers, not three resources. Splitting them into three resources means work must choose before queueing, which is a different and usually worse plant.

Every key​

keytyperequiredwhat it means
idstringyesThe resource's name, referenced by every step that visits it.
serversintegeryesHow many identical machines or people this group holds. Work queues for the group, not for a particular server.
installedintegernoHow many of servers stand at the start; the rest arrive by commissioning. Absent means all of them.
capacityintegerno, default 1How many lots one station can hold at once. ⭐ A station, not a slot. A machine that holds two lots is still one machine: it goes down once, it reports one time-in-state, and it wears at one rate. Modelling it as two servers would double its maintenance and make every published percentage wrong. ⚠️ Only meaningful with a step's start_interval_seconds. Without an interval a station of capacity c simply starts c lots at once, which is c independent servers — and SMT2020's intervals are 70–90% of the process time, so the interval is what actually binds.
entity_ruleruleno, default FIFOWhich waiting entity a free server takes. FIFO means first in, first out: whoever has waited longest goes next.
preemptivebooleannoWhether a higher-priority arrival takes a server away from work in progress. ⚠️ Preempt-RESUME: the interrupted entity keeps the service it has already had and finishes the remainder later. Preempt-repeat is a different discipline with a different answer, and the closed form this is checked against is the resume one. ⚠️ Ranked on priority alone, not on the full dispatch rule. A rule is a weight vector over several features and "outranks" has to be a total order on one of them for preemption to mean anything; priority is the one every preemptive discipline in the literature uses. ⚠️ Only work in processing is preemptible. A tool mid-setup, mid-load or mid-unload is not interrupted — no published result assumes it is, and interrupting a changeover would need a policy for whether it must be redone.
queue_capacityintegernoHow many entities may WAIT here. Absent means unbounded. ⚠️ Counted excluding the ones in service, so a group with servers: 1 and queue_capacity: 4 is the queueing theorist's M/M/1/5 — ⚠️⚠️ while the tool never stops, and not otherwise. Holding capacity is the declared waiting room plus one position per slot that could serve right now, so a station that is down, in setup, waiting for an operator or owing a campaign holds one fewer lot per unavailable slot: you cannot load a broken machine. Add a breakdown to that group and it holds 5 while up and 4 while down-and-empty, which is a different chain. Getting that boundary wrong shifts every blocking probability by one term and still produces a plausible-looking fab. ⚠️ An arrival that finds the queue full is TURNED AWAY, not blocked upstream. Those are different mechanisms with different answers, and only the first has a closed form. A lot that cannot enter is counted in Outcome::rejected and leaves the model — it is not silently dropped, because a fab that quietly loses work reports better throughput than it has. Which of the two a full queue does is when_full, below.
when_fullwhenfullnoWhat happens to a lot that finds queue_capacity full. ⚠️ Defaults to Reject, so no existing model changes. The two are different mechanisms with different answers:
changeoversarray of changeovernoChangeover costs. Absent pairs are free.
min_runarray of minrunnoMinimum campaign length per setup: once a tool carries this setup it must process this many lots before changing to another. ⚠️ Absolute: the tool WAITS rather than break a campaign. A tool three lots into a campaign of seven with nothing left of that kind does not change over — it idles until such work arrives. The yielding reading was measured and rejected: it is almost no constraint at all (see Dispatch rules). The hazard is real and is instrumented rather than designed away — campaign_blocked_pct reports the time a server spent refusing work it could have taken. Measured on a booth with one lot of the held setup: 504 released, 2 completed, campaign_blocked_pct 99.8.
load_secondsnumbernoSeconds to take work in and hand it back. Excluded from process time, counted as utilised — the fab convention.
unload_secondsnumbernoSeconds to hand finished work back. Excluded from process time, counted as utilised, like load_seconds.
downtimedowntimenoScheduled and unscheduled downtime. ⚠️ Absent means the tool never breaks and never goes for maintenance, which is an optimistic model rather than a neutral one: SMT2020's areas sit at 80–96.5% availability.
roster_profilestringnoA profile id whose factor is how many servers are staffed. ⚠️⚠️ A count, not a multiplier, and refused if it is not a whole number or exceeds servers. A rostered-off server sits outside the availability denominator — see rosters, which also says what it may not be combined with.
capacity_policycapacitypolicynoCapacity that reacts to the queue rather than to the clock. ⚠️ It changes what servers means and it may not be combined with everything — see capacitypolicy.

What happens when the queue is full​

queue_capacity bounds the queue. when_full says what happens to an entity that arrives at a full one:

valuemeans
"reject"Turn it away and count it. ⭐ M/M/1/K, and it has a closed form.
"block"Hold it in the tool that finished it, which can then start nothing else.
warning

"block" needs somebody to block. It works by holding the upstream tool that would otherwise let the entity go — so validate refuses it wherever an entity can arrive here holding no tool at all, because the policy would silently fall back to rejecting and the model would be two mechanisms at once with nothing saying which applied where. Every way an entity can arrive holding nothing:

  • the first step of a route — an entity that has just been released holds nothing. Put a step in front of it, or use "reject".
  • a scheduled lot that starts part-way along its route (at_step) — same reason.
  • one fed by a batch — a released batch member holds no tool either. Change the batching resource, or use "reject" here. ⚠️ Fed by means any edge out of the batching step, not only the step after it: a member sent back by rework arrives here holding nothing just the same.
  • one that arrived over a transport leg — a lot in transit holds nothing.
  • ⚠️ one whose step here waits for material — a lot gives its tool up while it waits for a consumable and re-enters holding nothing. Draw the material at an earlier step, or use "reject". ⭐ Only a consumes on this resource's own step is the hazard; one upstream is fine, because that lot is dispatched to the upstream tool afterwards and still holds it when it arrives.

Each is refused with the reason and the name of the resource to change. Measured before the rule was written: a single tool with queue_capacity: 1 fed only by releases validated cleanly and then rejected 1,800 of 2,001 entities while reporting Blocked at 0.000% — the declared mechanism never operated once.

These refusals are the whole guard, deliberately

⭐ validate runs inside the compiler, before any run: Compiled::build refuses a model with any problem, so there is no way to simulate a document these rules have not seen. The engine therefore reports no separate counter for a broken block promise — owner decision, 2026-09-18: "trust the validation step completely; we want as little noise as possible."

⚠️ The cost of that choice is honest and worth knowing: the list above is the arrivals somebody enumerated, and it has grown four times. The material case and the rework case were both found by clean-context reviews — the first after a sweep over every model in the repository, 8 purpose-built models and 27 test binaries had found nothing, and the second after that. So if a future mechanism creates a further way in, nothing in a results document will say so.

⭐ What protects the list is a refusal test in the suite: one arm per arrival above, each paired with a control that the neighbouring shape stays legal — a consuming step upstream, a transport leg upstream, a rework on an ordinary step, a scheduled lot entering at the top of its route. ⚠️ Those controls guard the refusal's direction, which is the failure this list has actually had: a scan once asked the wrong end of the relation and refused the resource that was never in danger while accepting the one that was.

Dedication​

dedication binds an entity to the same physical server for later visits — the tool that did step 4 must do step 11.

keytyperequiredwhat it means
chainstringyesA name shared by the step that binds and the steps that must honour it.
mode"bind" or "use"yesbind records the server used here; use requires the recorded one.
valuemeans
"bind"Remember whichever server ran this step.
"use"Run on the server remembered for this chain. ⚠️ If that server is down, the entity waits -- it does not fall back, because a real tool lock does not.

Queue timers​

queue_timer records how long entities wait here against a limit. It is reported, not enforced: nothing changes behaviour, and violations appear in the results.

keytyperequiredwhat it means
until_stepintegeryesThe step index whose start stops the clock.
max_secondsnumberyesHow long an entity may wait here before it is counted as a timer violation. Reported, not enforced.

Types used above​

rule​

How a resource chooses which waiting job to serve next. A rule is a list of ranks, applied in order: the first rank sorts the candidates, and later ranks only break ties left by earlier ones.

ranks:
- weights:
- - priority
- 1.0
- weights:
- - age
- 1.0

That reads: highest priority first, and among equal priorities, whoever has waited longest. Every feature name and what it scores is on the dispatch rules page.

The default, if you say nothing, is FIFO — first in, first out.

when_full​

What happens when a queue limit is reached. Two values:

valuemeaning
"reject"the arrival is turned away and counted in rejected
"block"whoever would have sent it cannot let go, and is blocked — it stops working too

The difference matters: rejection loses work, blocking propagates the problem upstream. A warehouse with no space rejects; a machine with nowhere to put its output blocks.

changeover​

What it costs this resource to switch from one recipe to another. Asymmetry is normal — A to B and B to A routinely differ.

from: red
to: blue
seconds: 2700.0

An empty from means from any setup:

from: ""
to: blue
seconds: 2700.0

That row is also the price of a step's setup_always: a setup performed on every entity, even when the server is already in it. It is the only row that prices one: an explicit from into the same setup prices a change, never an always-setup. A pair declared twice is refused.

seconds may also be a distribution, for a changeover whose length is not fixed:

from: ""
to: blue
seconds: { kind: uniform_offset, mean: 2250.0, offset: 1350.0 }
tip

Both spellings are accepted and a fixed cost is always written back as a bare number, so 2700.0 and {"kind": "constant", "value": 2700.0} are the same model and share one model_hash.

warning

A changeover's mean is what the dispatch rule scores on when it ranks candidates by setup_cost, and the realised duration is drawn once, for the pair that won. That is not an approximation of convenience: drawing per candidate would make the number of random values a run consumes depend on how many jobs happened to be waiting, which breaks the pairing that cwright compare relies on.

⚠️⚠️ A spread changes the variance of one changeover and leaves its mean exactly where it was — but it does not leave the tool's reported Setup% alone, which is a different quantity. That percentage is the changeover count times the mean cost, and the count is an outcome of queueing: a changeover that occasionally runs long reshapes which jobs are waiting, and therefore how often the recipe changes. So a reported setup percentage can move when only the spread changes.

caution

For normal with rectify: true, the declared mean is not the mean of the durations the engine produces. Rectifying clamps the negative tail to zero, so the realised mean is mean·Φ(mean/sd) + sd·φ(mean/sd), which is always the larger — for mean: 300, sd: 1000 it is 567 s against a declared 300. The declared value is what the dispatch rule scores on, so a tool that takes 567 s is ranked as if it took 300. Prefer uniform_offset or triangular for a changeover, or keep mean at least three standard deviations above zero.

warning

A pair you do not list costs nothing. That is deliberate — most pairs in most plants are free — but it means a misspelled recipe name silently removes the changeover rather than raising an error. validate refuses a step whose setup appears in none of its resource's changeover rows — as either from or to, so a wildcard row (from: "") counts. ⚠️ A resource that declares no changeovers at all is exempt, so it catches no typo there: setups are uniformly inert in that case, and cwright validate refuses a dispatch rule that can only score a constant, which is where that shows up instead.

min_run​

The shortest campaign: once this resource takes on a setup, it must run at least this many jobs of it before changing again.

setup: blue
lots: 7
warning

This constraint can make a resource refuse work it could do, because it is waiting for more of the current recipe to arrive. That is the intended behaviour and it is also a hazard — if the work never comes, the resource waits indefinitely. The results document reports campaign_blocked_pct so you can see how much time that cost.

capacity_policy​

A roster is a calendar: the shift starts at eight whatever the queue is doing. A capacity policy is a controller: it watches the queue and calls a station in when work piles up.

up_to: 4
down_to: 2
add_above_queue: 6.0
remove_below_queue: 1.0
ready_after_seconds: { kind: constant, value: 1800.0 }
min_engaged_seconds: 7200.0
keytyperequiredwhat it means
up_tointegeryesThe most stations that may be on at once. ⚠️⚠️ This is what gets allocated, so it is the number the results document reports as servers and the denominator of every state_pct.
down_tointegeryesThe fewest. Stations between down_to and up_to are stood down into NonScheduled — the same bucket a roster uses, and outside the availability denominator, because a station nobody called in is not an unavailable station.
add_above_queuenumberyesCall a station in while the queue is longer than this.
remove_below_queuenumberyesStand one down while the queue is shorter than this. ⚠️ Must be below add_above_queue, or the two rules fight at every event.
ready_after_secondsdistyesHow long a called-in station takes to become usable. ⚠️⚠️ Required, with no default. A station that is usable in the instant the queue crossed the threshold is a station that was always there, which is servers — so validate refuses a call-up whose mean rounds to under one tick rather than letting a policy quietly mean nothing.
min_engaged_secondsnumbernoThe least time a station stays on once called in. Absent means it may be stood down at the next event.
The policy is a level, not an edge

Both rules are re-derived from the queue every time the resource is pumped, so a stand-down that cannot be applied — every station is busy — is not lost. It is simply applied at the next moment a station is free, which is the same discipline rosters use for sending a person home.

A busy station is never withdrawn, and both directions are guarded

The decision: work in progress is never interrupted. A stand-down applies only to a station that is standing idle, so a lot in service is never taken off a machine by a capacity policy. That is the opposite of downtime — both a failure and a maintenance policy seize the tool mid-operation and bank the remaining work — and it is the same reading a roster uses.

⚠️⚠️ Both directions of the transition carry a guard, and the call-in one matters more. Standing a station down checks that it is unoccupied. Calling one in checks that it is genuinely off — because a call-up that landed on a station already in service and marked it available would be a tool reported idle while it was working, which the dispatcher would then hand a second lot. That is the largest correctness defect this engine has had: it produced 44% of extra capacity out of nothing, reported a shorter cycle time for it, and broke no invariant. The arithmetic that catches it is completed x service <= elapsed x busy, and it is asserted across a policy oscillating between one and three stations.

The call-up delay is the whole mechanism. Measured on one declared server with up_to: 2, down_to: 1, exponential service of 45 s against exponential arrivals of 50 s, over 200,000 s on the development laptop (AMD Ryzen 7 7840U), mean wait in seconds:

mean wait
one server, always340.981
the policy, ready_after_seconds 1 s74.677
the policy, 60 s82.466
the policy, 300 s105.656
the policy, 1800 s159.757
two servers, always10.766

A policy buys most of the second server's benefit, and how much depends monotonically on how long the call-up takes — a span of 2.14× across the four rows, bracketed by the two static plants. A model that declares a delay of zero is claiming a plant that can conjure a machine, which is why the field has no default.

A minimum engagement bounds the flapping, and stations rotate

Without min_engaged_seconds a policy on a bursty queue cycles constantly. Measured on one group over the same horizon, engagements fell from 477 to 97 when a minimum was declared. ⚠️ The bound is stations × window / min_engaged_seconds, not (up_to − down_to) × window / min_engaged_seconds: the policy stands down any unoccupied station and calls in any stood-down one, so over a long run every station takes turns rather than one station doing all the flapping.

The results document reports capacity_engagements per resource: how many times a station was called in. ⭐ It is printed for every resource, zero included, because a reader who never sees the key cannot tell nothing happened from this engine does not say.