Work Order Scheduling
Proposed Factory Scheduling Buffers

Work Order Scheduling: Keep Headroom for What the Factory Is Waiting On

One invariant that keeps a factory of shared machines from locking up, without assigning work early, without undoing any run that has started, and without a rule about how the factory is wired.

◎ Overview

Operations are assigned to machines late — when at least one of their inputs exists — and an assigned operation holds a reservation on that machine's input and output buffer capacity until its run has consumed its inputs and its outputs have left. Nothing decides how much of a machine's capacity one kind of work may take. Where a machine sits on two paths of a work order, the first kind of work to arrive can take all of it, and the second kind — which the first is waiting on — never gets any.

The answer is a single refusal predicate on assignment: never take the last capacity that a run of a recipe the factory is already waiting on needs. Nothing is reserved on behalf of unassigned work, nothing is assigned early, and no started run is undone. The other half of the guarantee is that a held output always has somewhere to go: its consumer's reservation, or the machine whose demand for it ranks highest — and one machine wants every component kind and makes nothing of it, so it always has room.

Aa Vocabulary

type = shared definition · instance = a particular thing in a particular factory at a particular moment.

ConceptType or instanceKeyed by
Processtypemachine type (each type carries out one or more)
Recipetypeinputs → process → outputs, with counts; shared by all work orders
Component kindtypewhat fungibility is over
Machine typetypeits processes, its default buffer sizes
Work orderinstancea DAG of operations
Operationinstanceone node of one work order; has a recipe
Runinstance (an event)one operation executing on one machine
Machineinstanceits type, its input/output buffer capacity, its routing restrictions, its assigned operations, its demands
Componentinstancefungible with any other of its kind when the plan is written; once written, a reference is to one producer operation's output or one existing component
Source, sinkinstancenot kinds of machine but shapes of recipe. A source is a machine whose recipe has no inputs: always eligible, so it supplies as fast as its cycle and its output buffer allow. A sink is a machine whose recipe has no outputs: a run needs no output room, so its input buffer always drains. The scheduler branches on neither
Demandinstanceper machine, zero or more: a recipe, how many, a priority, a source policy — see Demand below
Claimed / unclaimedinstancea component is claimed while an operation references it — assigned, or still in the backlog with the edge to it in its work order — or while a machine's demand covers it, up to the demand's count, at the demand's priority. A component nothing references is unclaimed, wherever it is
Assignmentinstance → instanceoperation → machine
Reservationinstanceper assigned operation on its machine, sized by its recipe
Reachabilityinstance × instancemachine pair, from restrictions; cached
Priorityinstanceon a demand; a work order inherits the priority of the demand that rooted it, and its operations inherit the work order's

Two operations with the same recipe are interchangeable for coverage; two with different recipes are not, even when those recipes use the same process. "Capacity" unqualified always means input and output buffer capacity. "A machine" always means one specific machine, usually named M; a set of machines is always named: an operation's or an entry's eligible machines.

# The scheme's quantities

QuantityKeyed byDerived from
Eligible machinesoperation → set of machineevery machine that carries out the recipe's process and that the operation's components can get to and from
Entryrecipe × eligible machinesone recipe and one set of eligible machines; without restrictions, just a recipe
Needed setset of entriesthe operations on the up/down paths of assigned operations that have not started, grouped into entries
Run headroom(machine, recipe)that machine's free buffer capacity, plus what operations of that recipe assigned there reserve or hold, against one run of the recipe
The refusal check(operation, machine)would this assignment leave any entry with no eligible machine that has run headroom for its recipe?

⊘ The lock

A machine's input and output buffer capacity is released only as the operations occupying it progress: input capacity when a run consumes its inputs, output capacity when the output components leave. Output components leave only once a consuming operation has been assigned with input buffer capacity for them. If every machine able to run a recipe has its capacity held by operations or components whose progress depends — somewhere up or down their work order — on a run of that recipe, nothing on those machines can progress, so that recipe never gets capacity.

It needs no cycle in any work order. It needs only a work order that passes through one machine more than once: a machine feeding itself, a machine the work order comes back to through another, or two branches that rejoin.

The smallest factory that locks

Recipe A1 ore → Smelt → 1 ingot
Recipe B1 ingot → Roll → 1 plate
Machine Mcarries out Smelt and Roll; input buffer 2, output buffer 2
Sourceore, unlimited
Production linea machine wanting plates, without limit
Work orderone, for 2 plates: operations A1 → B1 and A2 → B2

Operations are named recipe + instance: A1 and A2 are the two operations of recipe A in this work order, B1 and B2 the two of recipe B. The number is not a work order id — there is one work order.

Step through it. Only the ledger is consulted — an assignment goes ahead whenever the operation's own reservation fits.

A fix that only fits this picture. Here B1's input is the very ingot blocking M's output, so a rule that lets a consumer of a machine's own output be assigned when moving that output frees what the consumer needs would unlock it. It answers this shape and no other: put the second stage on another machine and the capacity that must be kept is on M while the component that would free it is on N. The rule has to be about the work order's path, not about one machine's buffers.

The same lock, two machines

Recipe A1 ore → Smelt → 1 ingot — on M
Recipe B1 ingot → Draw → 1 rod — on N
Recipe C1 rod → Roll → 1 plate — on M
Machine Mcarries out Smelt and Roll; input buffer 2, output buffer 2
Machine Ncarries out Draw; input buffer 1, output buffer 1
Work orderone, for 3 plates: A1 → B1 → C1, A2 → B2 → C2, A3 → B3 → C3

The work order leaves M, passes through N, and comes back to M. Ledger only, as before.

Neither refused operation wants anything its own machine's output holds: B2's ingot is on M, C1's rod is on N. Each machine is full of what the other is waiting for, and no rule that looks at one machine can see it. With the invariant, A1's assignment puts B and C in the needed set, and A2 is refused the moment it is requested, because M would have no headroom for C — the same refusal as above, one hop further from the reason. It is stepped through in the section with the invariant.

≡ The invariant

Setting
An assigned operation holds a reservation on one machine: its recipe's input count against that machine's input buffer capacity and its output count against its output buffer capacity, both taken at assignment. The input reservation is released when the run consumes its inputs. The output reservation becomes held components when the run resolves, and is released as each component leaves. An operation counts as assigned from assignment until its last output component has left. Free capacity = capacity − reservations − held − inbound, where a held output component whose consuming operation is assigned is departing — it has room reserved at its destination — and does not count.
Eligible machinesof an operation — a set
The set of machines the operation could be assigned to: each one carries out its recipe's process, and the operation's components can get there and away — inputs from wherever they will be, outputs to wherever they must go.
Needed set
Everything the assigned operations still depend on. From each assigned operation, follow its work order both ways — back through inputs it is still owed, unless a component the work order may claim already covers them; forward through outputs that have not left — and on from every operation reached. The operations reached that have not started form the needed set, grouped into entries. One set for the whole factory; a work order with nothing assigned contributes nothing.
Entryone element of the needed set
A recipe and the machines an operation of it could go to. Operations of one recipe with the same eligible machines share an entry — one run serves any of them. Different eligible machines, different entries. Without routing restrictions, an entry is just a recipe.
Run headroomof one machine, for one recipe
Machine M has run headroom for recipe R when M can meet R's input count and output count, each buffer on its own. The input count is met from M's free input buffer capacity, plus the input buffer capacity reserved by operations of R assigned to M that have not yet started. The output count is met from M's free output buffer capacity, plus the output buffer capacity reserved or occupied by operations of R assigned to M. An operation that has started no longer counts on the input side: its input reservation was released when the run began, and that capacity is free like any other.
Coveredan entry
An entry is covered when one of its eligible machines has run headroom for its recipe — with no capacity counted toward two entries: one machine is chosen per entry, and on every machine the chosen entries' counts must fit together.
After every assignment, every entry of the needed set is covered.

The check

Assigning operation O to one machine M asks two separate questions.

  1. 1The ledger. Does M have free capacity for O's own reservation? If not, O cannot go on M. This is ordinary bookkeeping, not the invariant.
  2. 2The invariant. Once O's reservation is taken, is every entry of the needed set still covered? The set is evaluated as if O were assigned: the paths of every operation already assigned, plus O's own. If any entry would be uncovered, O is refused and never joins the set.

The second question is about other operations, not O: O's own reservation fits by question 1, and O is refused when it would take the last headroom some already-assigned operation's path depends on. Nothing is reserved on behalf of unassigned work — the check protects free capacity, and which machine hosts the needed run is decided late, as usual. Refusal is the only action, and the check runs on every assignment.

Eligibility

An operation is eligible for assignment when at least one of its inputs exists — and not while an upstream operation of the same recipe in the same work order is unassigned. A recipe on a cycle can appear twice on one path of a work order, the later run needing what the earlier one makes; if the later run were assigned first, it would hold the machine's room for that recipe while waiting on the run that needs it. The run that comes first in the plan takes the machine first. Nothing is held while waiting, and the earlier run never depends on the later, so the wait always ends.

∴ Why checking at assignment is sufficient

Coverage can break in only two ways: free capacity goes down, or the needed set grows. Both happen only at an assignment or a checked re-plan (Held outputs, rule 3).

Free capacity decreases only when a reservation is taken

Every other event leaves it unchanged or raises it: a component arriving converts inbound to held (already counted); a run consuming its inputs releases; a run resolving converts its output reservation to held (already counted); a component leaving releases.

The needed set grows only when an operation is assigned

Its paths join the set at that moment. It shrinks as inputs are delivered and outputs leave.

A run starting is a wash: the input buffer capacity it releases is exactly what it stops standing for, so coverage is unchanged by it. So if every assignment preserves coverage, coverage holds at every moment. No other trigger is needed.

The one exception is a factory edit by the player — a machine deleted, a buffer shrunk, a routing restriction added. That is not an assignment and can break the invariant. Re-check coverage after an edit and evict until it holds; the eviction policy is open (last in, first out is the candidate).

▶ The same factory, with the invariant

Same recipes, machine, buffers and work order as above. Now every assignment answers both questions, and the needed set is shown after each step.

Step 2 is the whole mechanism. A2's own reservation fits the ledger; it is refused because A1's path needs a B and A2 would take the last headroom a B could use. One assignment refused, nothing assigned early, nothing evicted. Work then runs serially through the kept headroom and never locks.

Two machines, with the invariant

The two-machine factory from above: M, N, and a work order that comes back to M. From the first assignment the needed set reaches across both machines.

Step 7 is the one to read twice. When C1 is assigned, the rod is still physically in N's output, and it is the departing rule — a held component whose consumer is assigned has room reserved at its destination, so it does not count — that keeps B covered on N. It is what lets C1 be assigned at all.

◇ Demand

A machine may want things. A demand says what: I want this many results of this recipe, at a priority, and either the factory may make what is needed for it (make) or may only use components nothing else has claimed (unclaimed only). How a machine picks the number, and when it changes it, is the machine's own business. Most machines want nothing: a smelter runs only operations that other machines' demands were planned down to.

A demand is met by completed runs of its recipe — the arm is met by assembled parts, and a completed run cannot be taken away, so the demand is finished when the count is reached. The one exception is a recipe that only moves a component, K → K: it is met by the K the machine holds, and those can be taken, so that demand is ongoing and refills. That is the whole difference between a storage and an arm. Whatever else a recipe makes is an ordinary held output: claimed by an operation that references it, otherwise surplus. The arm's shavings are surplus.

Two things follow from a demand, and nothing else does:

  • Asking. Whenever fewer results meet the demand than it wants, counting the operations already planned for it, the planner adds operations of the recipe to make up the difference. These are ordinary operations: they reference the components they will take, are assigned when an input exists, reserve capacity, and run. For a make demand the planner sources their inputs — from a component the work order may claim if one exists, else from production planned down to sources. For an unclaimed only demand nothing is planned beneath them; each waits for a spare component of its kind to exist.
  • Keeping. A demand whose recipe is K → K claims the K the machine holds, up to its count, at its priority. An operation may take one only if its own priority is higher. This is what stops the airlock emptying a storage, and what lets an urgent work order draw on one — after which the storage has one fewer, and asks for one more.

There is no other kind of machine. What the player sees as a production line, a storage or an airlock is a machine with these values:

MachinePlayer-facing nameRecipeCountPriorityPolicy
a machine demanding results of a recipe, which may be made for itproduction line armK1 … Kn → nothing (the part's assembly)per the orderits ownmake
a machine demanding results of a recipe, which may be made for itstorage, from scratchK → Kas it decidesits ownmake
a machine demanding results of a recipe, from unclaimed components onlystorage, from surplusK → Kas it decidesits ownunclaimed only
a machine demanding results of a recipe, from unclaimed components only, for every kindairlockany → nothingunlimitedlowestunclaimed only
A component moves only when an assigned operation claims it. A machine's own demand claims the components it holds of its recipe's kind, up to its count. When an operation attempts to claim a component, the priority of its claim must exceed the priority of the holder's demand, if any. A claim made by an operation stands until the component is consumed.
Demand { recipe, count, priority, policy }
met(d)         = components held,  if d.recipe is K → K
               = completed runs,   otherwise
shortfall(d)   = d.count − met(d) − outstanding(d)
outstanding(d) = operations of d.recipe not yet run; the planner keeps it == shortfall(d)
// a K → K demand claims the machine's held K, up to d.count, at d.priority

The claim is not stored: it is a check, from the demand's count and priority and the held count, when another operation would claim a held component. A component under a demand's claim only ever moves to a higher priority, so this always settles. A smelter has no demand, so its ingots are open to any operation; a storage demanding 7 held ingots at priority 3 keeps its first 7 from operations at 3 or below and loses any of them to one at 10; an 8th is open to all.

Every demand roots a work order. The scheduler never branches on which machine is asking.

⇢ Held outputs

An output stays in the machine's output buffer until it departs. Where it departs to:

  1. 1A held output claimed by an assigned operation stays until it departs into that operation's reservation.
  2. 2An unclaimed held output — surplus — goes to the highest-priority unclaimed-only demand for its kind with room for it.
  3. 3A held output claimed by an operation still in the backlog stays for it until the stall threshold passes. Then the claim is dropped — it is unclaimed and goes as in rule 2 — and that operation's input is re-sourced: from a component the work order may claim if one exists — none references it, and no demand of priority at or above the work order's claims it — else by planning a producer chain. Planning operations into a work order that already has assigned operations is a checked event, like an assignment: it goes ahead only when the needed set with them is covered, and is retried on every release of capacity until it does. Until then the operation has no source for that input and is not eligible for assignment.

The threshold is a duration today; it can be made more sophisticated later — for instance, counted only while a run on that machine is blocked for the space.

There is always a demand

If no operation in a work order wants a held output, some unclaimed-only demand does: one machine wants every component kind at the lowest priority, and its recipe consumes any component kind and makes nothing — a run of it needs no output room, so it can always start, and its input buffer always drains. Every other unclaimed-only demand outranks it, so a component goes there last.

Moving a held output to the machine that wants it is an ordinary operation of that machine's recipe, through the same assignment check, so the induction is unchanged. The machine that wants every kind and makes nothing is exempt from the "no capacity counted toward two entries" accounting: its input buffer drains on its own.

✓ Preconditions

  • Buffer sizes. Every machine's input buffer fits the sum of the input counts of the recipes it can carry out, and its output buffer the sum of their output counts. A property of the machine type: a content check, not something the scheduler evaluates.
  • Reachability. From every machine, every component kind can reach a machine that wants every component kind and turns it into nothing (i.e. the airlock).
Consequence. No output buffer is ever permanently blocked. Recipes may produce any counts of any number of kinds.

⇒ Corollaries

Why one run per recipe is enough

Headroom is recycled: an operation of R is assigned into it, runs, its outputs leave, and the capacity is free again — so any number of operations of R pass through it one at a time, and it does not matter which goes first. The outputs do leave: their consumer's entry is covered, so it can be assigned and its reservation is where they go — or, failing that, to the highest unclaimed-only demand — and its outputs leave the same way, until a machine consumes them or holds them. This is why the needed set is the closure, a consumer's other inputs included. A consumer that needs twelve R-outputs accumulates them in its own reserved input; R's headroom stays one run. Taking the headroom does not call for a second: the assigned operation's own reservations are R's coverage — on both buffers until it starts, on the output buffer after — and whatever it releases is free capacity from then on.

Cheap superset for the needed set

All recipes of not-yet-started operations in every work order with at least one assigned operation. Counters; over-reserves slightly; untested.

? Open

  • Player edits. Deleting a machine, shrinking a buffer or adding a routing restriction removes capacity without an assignment, so it is the one event that can break the invariant. Needed: re-run the coverage check after an edit and unassign operations that have received nothing until it holds again. Which to unassign first is undecided; last assigned, first unassigned is the candidate.
  • Recipe cycles. The eligibility rule prevents the one lock found: a later run of a recipe taking the machine's room ahead of an earlier run it depends on. No argument covers cycles in general.
  • Routing restrictions. Eligible machines account for them by construction, but the argument has not been made with restrictions on. Two things to settle: whether refusing an assignment that would leave an entry uncovered can itself lock a restricted factory, and the next item.
  • Eligible machines before the producers are known. A planned operation whose producers are not yet assigned does not know where its inputs will come from. Its eligible machines are taken over the routing graph — any machine a possible producer could send to that can also send on to the consumer — per machine pair, cached. Whether that over- or under-approximates in a restricted factory is untested.
  • Re-pointing references. Letting an assigned operation take an identical component that another operation references — the sandbox's swap — would let work move sooner. It needs an upstream-first rule to stay lock-free. Later.
  • Pre-emption. Undoing the assignment of an operation that has received nothing to free capacity for higher-priority work, or handing components an operation has received to another on the same machine that wants them. Either would let higher-priority work move sooner; neither is needed for any guarantee here, and neither is part of this iteration.