Skip to content

Three conservation defects: fuel, battery energy, and the meter - #30

Merged
pshenok merged 2 commits into
mainfrom
fix/conservation-three
Aug 17, 2026
Merged

pshenok merged 2 commits into
mainfrom
fix/conservation-three

Conversation

@pshenok

@pshenok pshenok commented Aug 17, 2026

Copy link
Copy Markdown
Owner

560 → 570 tests. Three defects of the class this codebase has now shipped nine of: two numbers describing one physical quantity that disagree.

Two generators on nested standby burned fuel twice for the same racks. coveredByEarlierWave() walks a candidate's parents looking for an already-redelivered node, so it only ever detected a candidate below an earlier wave. When the deeper generator's wave ran first, the shallower one was an ancestor of the redelivered set, the walk never saw it, and both delivered. Measured on one room: 20 s of blackout burned 0.85 L with the shallow generator placed first and 1.70 L with the deep one first — exactly 2.0x for the same 12 kW, decided by nothing but STATE.buildings order. Now 0.85 L either way.

A UPS above a standby transfer point was debited for load it never carried. bufferOwedKws is documented as what actually left the battery, and it is exactly what the charger buys back on the meter — but phase 4 booked the whole pre-transfer subtree pull, and the standby wave then took part of that subtree onto a generator. Measured 144 kW.s booked against 84 kW.s that actually left — 1.71x over the bridge, 3.0x on the post-cutover ticks. Now 84 against 84.

The utility billed every kW during a blackout. batteryKw is the peak-shaving credit only; power.js sets it to 0 for the outage bridge. So with every feed dark, the meter still charged utility rates on diesel kW and battery kW — energy no utility delivered, and which already pays for itself twice over in fuel and in the recharge. $2.16 → $0.00 on a generator, $1.08 → $0.00 on a bridge.

That last one is a quantity, not a flag: STATE.gridKw, banked per node from the topology the way the battery credit already is. "Grid" enters the model in exactly one place — a grid_feed that is not dark — which is why a scoped outage and a generator-fed room on a perfectly healthy grid both come out right, where an if (gridOutage.active) special case gets both wrong. The second case was being overcharged too, and is now also fixed: $4.32 → $2.16 for half a room on a generator with nothing wrong with the grid.

No campaign verdict moved. Every objective margin is byte-identical across all thirteen pairs; only money moved, on dark_chain and fuel_clock, both of which were being overcharged. two_utilities and night_shift were predicted to move and did not — their rooms never draw off a battery or a generator, so they were already billed grid-sourced kW only.

And the floor, not just the ceiling. Every clause of the ledger invariant reads x > y — it catches charging too much and misses charging too little, which is free energy and the worse direction. Three topologies now pin the floor from below.

One defect found while pinning that floor is deliberately NOT fixed here: a bridge that runs its buffer to zero hands out one final tick it no longer has (carried 6 kW, booked 0). It is identical on main, so it is not this work, and it is written up rather than papered over.

All three are the class this repo has now shipped six of: two numbers
describing one physical quantity, disagreeing. Each was reproduced before
it was fixed and the figures below are measured, not estimated.

1. TWO GENERATORS ON NESTED STANDBY EDGES BURNED FUEL TWICE.
coveredByEarlierWave() walks a candidate's PARENTS for an already-
redelivered node, so it only ever saw a candidate BELOW an earlier wave.
With the deeper generator placed first its wave ran first, the shallower
generator's candidate was an ANCESTOR of the redelivered set, the walk
never saw it, and both machines carried — and bought diesel for — the same
racks. On the reference room (feed -> transformer -> pdu -> 2 racks, one
generator standby to each of the two links) 20 s of blackout burned 0.85 L
with the shallow generator placed first and 1.70 L with the deep one: the
same 12 kW of racks, exactly 2.0x the fuel, decided by nothing but
STATE.buildings order.

The wave now gathers every fueled generator's candidates BEFORE any of
them delivers and applies THE TOPMOST TRANSFER POINT WINS across
generators — the rule it already applied between one generator's own
nested candidates. Order-independent, and it loses no coverage because the
shallower point's subtree strictly contains the deeper one's. Readiness is
compared on post-decrement cutover clocks so the handover is atomic: a
generator still counting down cannot supersede one that is already
carrying, so wiring a second machine above a working one does not black
the room out for a cutover.

2. A UPS ABOVE A STANDBY TRANSFER POINT WAS DEBITED FOR THE WHOLE
PRE-TRANSFER LOAD. The bridge booked owed += subtreePull * drainedSec in
phase 4, before the standby wave had run, so a generator taking part of
that subtree left the battery owing for load it never handed over — and
the charger buys bufferOwedKws back on the meter, so the recharge was
billed on the inflated figure. On an 18 kW bridge whose generator took
12 kW: 144 kW.s booked against 84 kW.s actually delivered, 1.71x over the
whole bridge and 3.0x on the post-cutover ticks alone. Now 84 vs 84.

The seconds are untouched — a bridge spends them at the UPS's full rating
however little it carries, which is the question that path answers and
what every campaign level is proven against. Only the ENERGY follows the
load, booked in a new step 5c after the wave, off what the UPS ended up
carrying. The ancestor fixup gained the partial case it never had: a
bridge the wave only partly superseded now reports what it still feeds
instead of its pre-transfer figure (the inspector reads that field, so a
stale one told the player the battery was carrying 3x what it was).

3. THE METER BILLED EVERY kW DURING A TOTAL BLACKOUT. billedDrawKw was
totalDrawKw - batteryKw, and batteryKw is the peak-shaving credit only —
power.js sets it to 0 for the outage bridge. So with every feed dark the
utility still charged for the kW a diesel generator made and the kW that
came out of a battery. The generator already pays in fuel and the battery
already pays in the recharge: a straight double charge for energy no
utility delivered. Measured: $2.16 -> $0.00 over 12 s of blackout on a
generator, $1.08 -> $0.00 on a UPS bridge.

The fix is a quantity, not a flag. STATE.gridKw is the draw that actually
came through a LIVE GRID FEED, banked per node exactly like the battery
credit and summed at the end of the tick; demand.js bills it. "Grid"
enters the model in exactly one place, a grid_feed that is not dark, so a
SCOPED outage and a room on a generator with the grid perfectly healthy
both come out right — an `if (gridOutage.active)` special case gets both
wrong. That second case is real and was also being overcharged: half a
room on a generator, nothing wrong with the grid, $4.32 -> $2.16.
Shaving now displaces kW out of gridKw where it happens, so the meter
needs no separate credit subtraction; batteryKw stays as the HUD's signal.

THE CAMPAIGN. Fix 3 moves money on levels with outages and generators, so
all thirteen WIN/LOSE pairs were re-played before and after. Every
objective margin is byte-identical and no verdict moved; only money
changed, and only on dark_chain (WIN 1243.15 -> 1246.49, its two LOSE
cases unchanged at 1244.07) and fuel_clock (WIN 1289.31 -> 1299.92, LOSE
1263.40 -> 1264.97 both cases). two_utilities and night_shift were
expected to move and did not: their rooms never draw off a battery or a
generator, so they were already being billed only for grid-sourced kW.

TESTS. 560 -> 567. Each fix has a test named after its lesson, and the
per-tick guard in tests/peak-shaving.test.mjs gained the analogous
invariants for all three — fuel burned vs kW carried, bufferOwedKws vs the
energy that actually left the buffer, and money charged vs kW that came
off a feed — asserted on every tick of a 200 s run with outages, a scoped
outage, a brownout, a breaker trip, a service window and six generators.
The all-hazards facility gained the two shapes the invariants could not
otherwise see: two transfer switches in series with the deep one placed
first, and a bridge the wave only PARTLY takes.

The bridge ceiling is computed per UPS rather than pooled over the
facility on purpose. Pooling has to allow for peak-shaved kW being
battery-sourced and still carried by the feed above them, and that
allowance is exactly the room an inflated bridge hides in. Per UPS nothing
has to be allowed for. (That double-source is a separate, known finding
about resolvePower's two-phase design — peak shaving never reduces what
the chain above a UPS carries. It needs a restructure of the pull/deliver
split and is deliberately NOT fixed here.)

The money invariant reads the charge out of the player's wallet rather
than restating demand.js's formula: STATE.tariff multiplies the power line
and nothing else, so the same run played twice with the multiplier at zero
gives the power bill by subtraction, tick by tick.

MUTATION TESTED, all eight red:
  - dropping the cross-generator dedup: the two generator tests + the
    per-tick invariant (3 failed)
  - booking the bridge's energy on the pre-transfer pull again: the
    lesson test + the invariant (2 failed)
  - dropping the partial-transfer carry correction: both lesson tests +
    the invariant (3 failed)
  - billing (totalDrawKw - batteryKw) again: THE FORMULA + THE METER
    (2 failed)
  - letting any source count as grid, not just a grid_feed: both blackout
    tests + both invariants (4 failed)
  - billing a bridged load as metered: the blackout test + the invariant
    (2 failed)
  - not displacing metered kW when shaving: the two shaving-economics
    tests (2 failed)
  - burning fuel on demandedKw instead of actualKw: four generator/lab
    tests + the invariant (6 failed)

Docs: CONTRIBUTING/README test totals and ARCHITECTURE's tick-loop copy
count moved with the suite (44 -> 47 copies); the CONFIG comment claiming
powerCostPerKwh is paid on TOTAL facility draw is no longer true.
Every clause of the ledger invariant reads `x > y`. They catch a meter or
a booking that charges TOO MUCH — unfair — and say nothing about one that
charges too little, which is free energy and worse: a player who finds the
shape that drops kW out of gridKw runs the room off-meter for nothing.

Three topologies a player actually builds now pin the floor: a plain
grid-fed room, a UPS chain with shaving off (the charger is billed too),
and a generator branch beside a grid branch, where each kW must land on
its own source and no kW may land on neither. Halving gridKw kills all
three; billing the whole facility draw kills the third.

The chaotic run cannot carry this clause yet, and the reason is a real
defect that predates gridKw: a bridge that runs its buffer to zero hands
out one final tick it no longer has — carried 6 kW, booked 0 — which
shows here as an under-bill. Identical on main and on this branch, so it
is not this work. Written up rather than hidden under a tolerance wide
enough to swallow it.
@pshenok
pshenok merged commit 2701087 into main Aug 17, 2026
1 check passed
@pshenok
pshenok deleted the fix/conservation-three branch August 17, 2026 09:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant