Skip to main content

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:

Get the sign right, because nothing will tell you

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 testthe 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 corpustests/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 comparepassing --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.

tip

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​

pathmeasurespriced with
resources.<id>.state.<State>server-seconds in that staterate
resources.<id>.servers⭐ a purchase — how many servers the group has, counted once per runeach
resources.<id>.queue⭐ work tied up waiting — entity-seconds in the group's queuerate
operators.<pool>.engagedperson-seconds actually workedrate
operators.<pool>.present⭐ paid presence — person-seconds on shift and not on a break, working or notrate
consumables.<id>.takenunits drawneach
consumables.<id>.producedunits put backeach
consumables.<id>.level⭐ capital tied up in stock — unit-seconds on handrate
entities.completedentities that finishedeach
entities.abandoned⭐ lost sales — the KPI a shortage is really abouteach
entities.scrappedentities destroyedeach
entities.reworkedrepeat passeseach
entities.preemptedinterruptionseach

⚠️⚠️ 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>.servers is 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, give each the share of the price that falls in the horizon. Under a capacity_policy it 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 — those installed at the start plus those commissioned — and not the ceiling, so two build plans differ in what they cost. The count is reported as built.
  • operators.<pool>.present is what a pool is paid for, which engaged is not: a shift that mostly waits is cheap in engaged and full price in present. A paid break is not in it; price it separately if your contract pays one.
  • consumables.<id>.level and resources.<id>.queue are 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. ⚠️ queue counts 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.
warning

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​

cwright simulate bakery.yaml
{
"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.