Charges
A charges block puts a price on quantities the run already reports.
charges:
- { what: resources.oven.state.Processing, rate: 0.04, label: "oven gas" }
- { what: operators.bakers.engaged, rate: 0.0075, label: wages }
- { what: consumables.flour.taken, each: 0.3, label: flour }
- { what: entities.completed, each: -2.5, label: sales }
A negative price is revenue, so the total is a net cost — and that sign is load-bearing:
The total is a net cost, so lower is better — which is what cwright compare --kpi money
assumes by default. ⚠️ Write revenue as a positive price and the total is costs plus
earnings: a number that is neither a cost nor a profit, with every line plausible and nothing
refusing it.
⭐ Measured, on this repository's own corpus: tests/corpus/job-board.json had revenue at
each: 45.0 and reported a total of 28462.94, against −13387.06 once the sign was
corrected. Worse than the magnitude, it inverted the direction — a policy that cut costs made
the total fall and read as an improvement (right by accident), while one that earned more made it
rise and read as worse.
Why nothing refuses a positive revenue price
⚠️ Because nothing can tell one from a deliberate profit convention. label is free text —
it is not validated, and duplicate and empty labels both pass — so a rule keyed on a label
containing "revenue" or "sales" would be guessing from a string the engine attaches no
meaning to. And an author who prices profit (earnings positive, costs negative) and reads
the total as higher is better is doing something legitimate that such a rule would refuse.
⭐ So the convention is held in three places that cannot guess wrong instead:
| an engine test | the same plant priced below and above its break-even must report a falling total that crosses zero, so the direction is a property of Compiled, not of a page |
| the golden corpus | tests/golden/job-board.json pins the sign for the one shipped model that prices anything; changing it fails the gate and prints the movement |
⭐ cwright compare | passing --higher-is-better with --kpi money prints a note saying the total is a net cost and the default is the other way — the one moment the tool knows the question is being asked |
Why it exists
⚠️ A control with no price is not a decision. "Call in another person when the queue is long" improves every KPI and costs nothing, so an optimiser handed that lever pulls it to the stop: infinite staff, zero queue, and a recommendation no business would act on.
This engine exists to be the verifier an agent optimises against, and an optimiser without an objective that trades things off is optimising the wrong thing, confidently. The question is never does overtime help — it always does — but at what queue length is it worth it.
⭐⭐ A charge cannot change the answer it prices
Charges are read off the finished run. They consume no randomness, schedule no event and
take no part in any decision, so a model with a charges block produces a bit-identical
run — same trace_hash, same completions, same per-entity cycle times — as the same model
without one.
That means a cost model can be added to a study whose answer is already published, and it can only price that answer. A test asserts it rather than the documentation claiming it.
⚠️ It also means a charge is not a dispatch rule. setup_cost already exists and is a
ranking score, not money — two things called "cost", one of which is not a cost, is exactly the
utilisation ambiguity that has cost this project real time. Which is why this is charges.
What can be priced
| path | measures | priced with |
|---|---|---|
resources.<id>.state.<State> | server-seconds in that state | rate |
resources.<id>.servers | ⭐ a purchase — how many servers the group has, counted once per run | each |
resources.<id>.queue | ⭐ work tied up waiting — entity-seconds in the group's queue | rate |
operators.<pool>.engaged | person-seconds actually worked | rate |
operators.<pool>.present | ⭐ paid presence — person-seconds on shift and not on a break, working or not | rate |
consumables.<id>.taken | units drawn | each |
consumables.<id>.produced | units put back | each |
consumables.<id>.level | ⭐ capital tied up in stock — unit-seconds on hand | rate |
entities.completed | entities that finished | each |
entities.abandoned | ⭐ lost sales — the KPI a shortage is really about | each |
entities.scrapped | entities destroyed | each |
entities.reworked | repeat passes | each |
entities.preempted | interruptions | each |
⚠️⚠️ validate refuses a path it cannot resolve, rather than pricing it at zero. A charge
on a field that does not exist would produce a line reading 0.00 for ever, which nobody would
question — and that inert-field shape has cost this project real time five separate times.
⚠ ️ The kind is checked too. A duration is priced with rate (money per second) and a count
with each (money per occurrence); offering the wrong one is refused rather than quietly
producing a number.
⭐ Every time-based quantity is over the reported window — after the warm-up, the window
every other figure in the results describes, and the money block reports it as
window_seconds. It starts at the first event at or after the warm-up, where the statistics
re-based, so it can be a little shorter than the horizon. ⚠️ Until #150, server-seconds were multiplied by
the whole run instead, overstating them by (warm-up + horizon) / horizon while the wage and
stock lines beside them were right.
What each of the newer paths is for
A decision between a second machine, one more shift and more stock needs all three priced, and the running costs alone price none of them:
resources.<id>.serversis the purchase. It is counted once per run, so it is set against the running costs of that run's horizon: to spread a price over a machine's life, giveeachthe share of the price that falls in the horizon. Under acapacity_policyit is the most the group can ever have,max(up_to, servers), because that is what has to be bought, even if the extra servers are mostly stood down. On a group that grows by commissioning it is the machines that were actually built — thoseinstalledat the start plus those commissioned — and not the ceiling, so two build plans differ in what they cost. The count is reported asbuilt.operators.<pool>.presentis what a pool is paid for, whichengagedis not: a shift that mostly waits is cheap inengagedand full price inpresent. A paid break is not in it; price it separately if your contract pays one.consumables.<id>.levelandresources.<id>.queueare tied-up capital: units of stock or entities of work, held for so many seconds. Price them at the holding cost per unit per second — the cost of capital on what one unit is worth, divided by the seconds in a year. ⚠️queuecounts work waiting for a server only: a lot waiting for a batch to fill, or one blocked on a server it has finished on, is not in it, so on a batching or blocking tool it prices less than all of the work tied up there.
resources.<id>.state.<State> is server-seconds for the whole group, not for one server.
state_pct is a mean across the group's servers, so a four-server group at 50% Processing
is four times the machine time of a one-server group at 50% — and pricing the mean alone would
charge them the same. The servers count is reported beside it so you can check the arithmetic.
⚠️ And operators.<pool>.engaged is the raw person-seconds worked, deliberately. It is
not utilisation × people × elapsed: operator utilisation is busy over available time, so
that product over-counts by exactly the break time.
What it reports
{
"money": {
"total": 4193.20,
"lines": [
{ "label": "oven gas", "amount": 12000.00 },
{ "label": "wages", "amount": 540.00 },
{ "label": "flour", "amount": 1800.00 },
{ "label": "sales", "amount": -10146.80 }
]
}
}
⭐ A model with no charges reports no money block at all, rather than a total of zero.
This run was free is a different claim from nobody asked what it cost.