A bridge handed out one tick of power it did not have — and the ledger gains its floor - #33
Merged
Merged
Conversation
The outage bridge is entered on `bufferLeft > 0`, however little is left,
and then granted the whole subtree pull for the whole tick. A buffer with
4 ms left in it powered a 50 ms tick. Measured on one bridge run to
exhaustion: 161 bridging ticks, 160 of them booking exactly what they
carry, and the last one carrying 6 kW while booking 0.
Small in absolute terms and exactly the class tests/peak-shaving.test.mjs
exists to prevent — energy the facility used and no source produced.
Peak shaving has capped its grant by the charge left since the day it was
written ("THE GRANT IS CAPPED BY THE CHARGE LEFT: a sliver of battery
shaves a sliver, the grid carries the rest"). The bridge never did. It
does now, scaled to the tick-AVERAGE rate so the energy delivered across
the tick is exactly subtreePull * drainedSec: the energy that really
left. The booking moves from drainedSec to dt to stay consistent with a
rate that is now an average rather than a peak.
What is NOT changed: bufferLeft still counts SECONDS spent at the UPS's
full rating however little it carries. That is the ride-through model
every campaign level is balanced against, and a test now pins it so the
next person cannot buy honesty by shortening the bridge.
Both expressions differ from the old ones only when drainedSec < dt,
which is exactly when bufferLeft < dt — the single tick that empties a
buffer. On every other tick they are identical by arithmetic, not by
approximation. And a campaign level that is WON by a UPS carrying an
outage does not reach that tick by definition: a buffer that empties is
the LOSE case.
THE PAYOFF: the ledger invariant gains its FLOOR. Its three meter clauses
all read `gridKw > x` — they catch a meter charging too much, which is
unfair, and say nothing about one charging too little, which is free
energy. The fourth clause could not be written last night because this
defect fired it on the exhaustion tick. It now holds across the whole
chaotic run, and the class is closed from both sides.
Also sets an explicit testTimeout for the sim project. THE PAIR plays
four full timed sessions: 2.3 s idle, over 5 s when the rest of the suite
runs beside it. Vitest's default is 5 s — a default, not a budget anyone
chose for a suite that plays whole games — so the slowest honest test in
the repo sat one busy CI runner away from a spurious red. Reproduced at
1 failure in 16 full runs before, 0 in 11 after.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #31. 570 → 572 tests.
The defect
The outage bridge is entered on
bufferLeft > 0— however little is left — and then grants the whole subtree pull for the whole tick:A buffer with 4 ms left in it powered a 50 ms tick. Measured on one bridge run to exhaustion: 161 bridging ticks, 160 of them booking exactly what they carry, and the last one carrying 6 kW while booking 0.
Small in absolute terms, and exactly the class
tests/peak-shaving.test.mjsexists to prevent: energy the facility used that no source produced.Peak shaving has capped its grant by the charge left since the day it was written — "THE GRANT IS CAPPED BY THE CHARGE LEFT: a sliver of battery shaves a sliver, the grid carries the rest." The bridge never did.
The fix
outKw = subtreePull * (drainedSec / dt)— the tick-average rate, so the energy delivered across the tick is exactlysubtreePull * drainedSec: the energy that really left. The booking moves fromdrainedSectodtto stay consistent with a rate that is now an average rather than a peak.What is deliberately not changed:
bufferLeftstill counts SECONDS spent at the UPS's full rating however little it carries. That is the ride-through model every campaign level is balanced against, and there is now a test pinning it, so the next person cannot buy honesty by shortening the bridge.On campaign safety. Both expressions differ from the old ones only when
drainedSec < dt, which is exactly whenbufferLeft < dt— the single tick that empties a buffer. On every other tick they are identical by arithmetic, not by approximation. And a level that is won by a UPS carrying an outage does not reach that tick by definition: a buffer that empties is the LOSE case. All 13 machine-played pairs pass.The payoff
The ledger invariant gains its floor. Its three meter clauses all read
gridKw > x— they catch a meter charging too much, which is unfair, and say nothing about one charging too little, which is free energy and worse. I wrote that fourth clause last night and could not ship it: this defect fired it on the exhaustion tick, and I filed #31 rather than widen a tolerance until it passed.It now holds across the whole chaotic run — outages, brownouts, trips, both generators, the toggle flipping — and the conservation class is closed from both sides.
One thing found on the way
An explicit
testTimeoutfor thesimproject.THE PAIRplays four full timed sessions: 2.3 s on an idle machine, over 5 s when the rest of the suite runs beside it. Vitest's default is 5 s — a default, not a budget anyone chose for a suite that plays whole games — so the slowest honest test in the repo sat one busy CI runner away from a spurious red. Reproduced at 1 failure in 16 full runs before the change, 0 in 11 after. 20 s still catches a real hang, which is the only thing a timeout is for here: nothing in these tests waits on IO, a timer, or a clock.Mutation checks
Reverting the fix to the true original code (full grant and booking by drained seconds) turns red exactly the two tests that should die: the targeted last-tick test, and the ledger invariant's new floor clause. Halving the booking kills four. Reverting only half the change produces a different bug — an over-booking — and the suite catches that too.