Files
goodwe-addon/goodwe_controller/DECISIONS/SAFETY-38-site-standing-pin.md
glenn schrooyenandClaude Opus 5 d5b9317d57
checks / unit (pull_request) Successful in 5m11s
SAFETY-38: correct the survivor count, which was stated four ways
Review found the summary lines miscounted, and one of the miscounts filed
a KILL among the survivors. The authoritative tables in §7e and §7f were
right throughout; only the sentences a reader actually relies on were
wrong.

Arithmetic, from §7f's own ledger:
  14 measured = 10 surviving
              +  1 already caught  (C3 allocator.py:162, red in test_alloc.py)
              +  3 caught by round 4 (C6 clamp.py:253, C7 peakguard.py:493,
                                      B12 arbiter.py:264)
Survivors by file: TWO in allocator.py (C1 :154, C2 :160), three in
arbiter.py (C4a/C4b :344, C5 :392), two in tariff.py (C8, C9), two in
arbitrage.py (B17, B18), one in main.py (B19). Two, not three, in
allocator.py - C3 is the kill.

Before: DECISIONS §6c said "Nine" while listing eleven; the CHANGELOG said
"11 SURVIVORS" while listing eleven. Both counted C3 as a survivor.

⚠️ KEPT VISIBLE RATHER THAN QUIETLY FIXED. This is this card's own
recurring defect in prose rather than in code - a claim wider than the
measurement beneath it - and it is the second false statement this ticket
has put into its own decision record. The correction says so at the line,
because a document whose whole value is that it can be trusted should
record when it could not be.

No app/ change; no test change. Full suite: 21 passed, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-05 23:10:30 +02:00

61 KiB
Raw Permalink Blame History

SAFETY-38 — pinning site_standing, the one quantity both site guards are asked about

Status: DECIDED 2026-09-05. app/main.py's site_standing = float(sum(standing.values())) is correct and is not changed. What this card adds is the thing it never had: a behavioural fixture whose per-unit shares cannot answer for the site, so that a change of meaning in that reduction goes red instead of green.

Filed with SAFETY-38, card 6a9ac95f2b78646e5a76d2e9. It is a coverage card: not one byte of app/ moves.


0. The line, and its two consumers

site_standing = float(sum(standing.values()))          # app/main.py:1598

standing is {unit name -> the value THIS controller last put on that unit's setpoint entity} — inv.last_write, i.e. the number after clamp_to_rating() has applied max_w and the device's own [dev_min, dev_max]. Two safety inputs are derived from it and nothing else in cycle() is:

consumer where what it does with it
SAFETY-30's site sign detector self.site_sign_wd.evaluate(site_standing, self.grid, …) (main.py:1619) latches and stops the whole site when the total command opposes the meter for site_sign_disagree_s
SAFETY-18's freeze conjunction looks_frozen(unchanged_s, site_standing, max_w, …) inside peakguard.decide(frozen=…) (main.py:1926) takes the peak guard blind and files floor_w = 0.0 — the battery may not import while the billed quarter is unmeasurable

Both are P0 paths. Neither was pinned to the reduction.

1. What survived, and what the rescope measured

The card opened with three demonstrated live mutants, all green across the whole suite. Re-measured in round 2 against release/1.0 @ 86eaef2:

mutation of site_standing on release/1.0 @ 86eaef2 closed by
list(standing.values())[0] — one unit's share SURVIVES, full suite 21/21 green part 9a + 9b (this card)
sum(...) / max(1, len(standing)) — the mean SURVIVES, full suite 21/21 green part 9a + 9b (this card)
max(standing.values(), key=abs) — the largest share SURVIVES, full suite 21/21 green part 9a + 9b (this card)

⚠️ ROUND 1 SAID [0] WAS ALREADY CLOSED BY SAFETY-32. THAT WAS FALSE.

Round 1 of this card recorded [0] as CAUGHT by SAFETY-32's package-wide structural pin, wrote that claim into four places, and told future authors "DO NOT RE-COVER THAT ONE HERE". It is wrong, and how it went wrong is the part worth keeping.

How the error was made. The mutation was measured once. That single run happened to hit test_peakguard.py's CI-06 flake — a measured ~20% false-red rate — and the red was recorded as a mutation kill. Nothing else was checked, because a red is the answer you are hoping for and it did not look like it needed a second run.

The correct measurement. On release/1.0 @ 86eaef2 carrying only the [0] edit (+4 bytes, re-read from disk, bytecode purged by tools/mutation_run.sh): the full 21-file suite is green, and test_peakguard.py run five times in isolation on that same mutated tree is PASS PASS PASS PASS PASS. The mutation survives. (The reviewer's own independent re-measurement of the same tree hit the flake once — PASS PASS PASS FAIL PASS — which is the same verdict plus a live demonstration of the mechanism that produced the original error.)

Why SAFETY-32 cannot see it, read off 12j's own enumeration rather than argued. Part 12j taints names bound to roster-carrying values and lists accumulated in a loop over one. standing is neither: it is a dict created empty at main.py:1554 and filled by subscript assignment (standing[inv.name] = stood) inside the roster loop, and a subscript store is not a binding 12j follows. So standing never enters its taint set and sum(standing.values()) is not a roster site at all. Run 12j against a tree carrying the [0] mutation and its enumeration lists main.py:1553, 1555 and 1690 — the three self.inverters sites bracketing this code — and not 1599. No shape pin reaches this line; only the behavioural fixture below does.

The rule this cost. test_peakguard.py flakes at a measured ~20%. A single red from it proves nothing. Re-run it in isolation at least five times and name the failing check before believing any red from it. This card is the third measurement CI-06 has corrupted.

SAFETY-32's own disclosed blind spot says why the other two were always out of reach:

SEMANTICS. min → max → the mean are edits invisible to any shape pin, permanently. Only the behavioural sets can see them.

sum → mean and sum → max(key=abs) are semantic: identical shape, different meaning. No shape pin will ever close them. That disclosure was accurate, and is now confirmed by measurement rather than taken on trust.

[0] is the one that looks structural and therefore looks covered — and that appearance is exactly what produced the false attribution above. It is out of reach here for a different reason from the other two: not because the shape is invisible, but because the value never becomes a roster in the analysis's eyes. All three are closed by the same behavioural fixture, and all three needed it.

2. Why the suite could not see it — habit 4, inside the site half of the very card that names it

test_watchdog.py's _sign_site is the only N≥2 site fixture in the repository. It runs at site_sign_disagree_w: 320 with shares of roughly −2500 W each. Every one of them is individually far over the gate, so on those numbers

sum  =  −5000 W   over        mean =  −2500 W   over        max =  −2500 W   over

— all three reductions agree, and a check whose two candidate answers happen to be equal cannot observe a change that swaps them. That is MUTATION.md's third failure mode, and this is its ninth instance.

At a roster of one it is not a fixture defect but arithmetic: sum == mean == max == the one share, identically, so all three mutations are genuinely equivalent on a shipped install. No N=1 fixture can ever separate them; §3c asserts that rather than leaving it to the reader.

3. The fixture, and the numbers it is built on

Two simulated units rated 220 W and 280 W, both obeying exactly, inverted gain, house importing 2000 W. Both stand at their own rails, so standing is {-220, -280} in roster order and the meter reads +2500 W.

reduction value vs the SHIPPED 300 W gate
sum −500 W OVER → trips
sum / len (the mean) −250 W under → silent
max(…, key=abs) −280 W under → silent
list(…)[0] −220 W under → silent

Four distinct magnitudes — 500, 250, 280, 220 — of which exactly one clears the gate. The asymmetry is load-bearing twice: equal shares would collapse mean, max and first onto one number, and putting the smaller unit first is what keeps [0] distinct from max(key=abs). The gate is left at its shipped default so the fixture is graded against the magnitude an install runs; only the hold is shortened (325 s → 2 s), because the hold is a wall clock and the campaign behind 325 s is measured on the rig, not here.

⚠️ 220 W and 280 W are SIMULATED ratings chosen for that arithmetic and for nothing else. They are not measurements. In particular they are not inverter B, which does not physically exist; B is a GW5048D-ES, serial 55048ESU22AW0992, 4600 W from its datasheet (goodwe/NOTES.md §1.1b).

3a. The site sign detector

Shipped tree: the site latches, commands 0 W on every inverter, and the sentence an operator reads is SIGN_DISAGREEMENT: commanded -500 W against a meter reading +2500 W …. No per-unit watchdog trips — every unit is faithfully executing its share, which is the asymmetry the site detector exists for.

Under the mean and under max(key=abs): nothing trips, ever. The reviewer's tree was still commanding −500 W of charge into a +2500 W import at t = 595 s with following=True.

Two counterfactuals sit beside it so the trip is attributable:

  • the same inverted-gain site whose shares sum to −250 W — under the gate — does not trip, so the trip above is the sum crossing 300 W and not the mere presence of a second inverter;
  • a site whose device range is narrower than max_w asks for −500 W while only −250 W stands, and does not trip — see §5.

3b. The second consumer, covered rather than scoped out

site_standing is also looks_frozen()'s commanded_w. The same discrimination applies at a different threshold: the site rail is RAIL_FRAC * site_max_w = 0.95 × 500 = 475 W.

reduction value vs the 475 W rail
sum 500 W RAILED → BLIND_FROZEN, floor_w = 0.0
the mean 250 W under → silent
max(…, key=abs) 280 W under → silent
list(…)[0] 220 W under → silent

The sign is flipped from §3a on purpose: the gain is the shipped positive one and the frozen meter reads import, so the site discharges +500 W against a +2500 W import — the two agree in sign, SAFETY-30 has nothing to say, and this half grades SAFETY-18 alone. Reusing §3a's inverted gain would have the site sign latch park everything at 0 W, and the freeze conjunction could then never be true: a check measuring the other guard's remedy.

⚠️ The open quarter is backfilled with the same stuck reading, and that is not decoration. Without it the block is mostly uncovered, blindness() answers BLIND_COVERAGE, and the mutant reaches the same action by a different route — a check on the action alone could not then see the difference it names. Seeded, the measurement is honest:

  • shipped: blind = BLIND_FROZEN, floor_w = 0.0 — no charging while the billed quarter is unobservable;
  • both mutations: not blind at all, floor_w = None — the site may go on importing through a quarter whose billed average cannot be measured. That is the capacity-tariff hazard SAFETY-18 exists for, silently disarmed.

The counterfactual comes from the same run: on the earlier cycles the command had not yet reached the rail and the guard did not report SAFETY-18, with the meter equally frozen throughout — so the verdict is produced by the command magnitude site_standing carries and not by unchanged_s alone. A low gain (0.05, the schema floor) is what buys those below-the-rail cycles.

3c. At N=1, nothing changes — asserted

A real one-inverter controller is driven through the same frozen-meter scenario. Its standing has a single entry, so sum, the mean, max(key=abs) and [0] are printed and asserted to be one number; the site sign detector is still absent (part 7 counts the calls on the object); and SAFETY-18 still reaches BLIND_FROZEN on that same value. The claim "the shipped install is unaffected" now rests on the roster rather than on the reader.

4. Mutation ledger

Every run: a fresh full-repo copy, __pycache__ and *.pyc excluded, mounted :ro, graded by tools/mutation_run.sh (which purges, prints the tree fingerprint, and verifies no cache appears during the run). The mutated line was re-read from disk after the edit and the byte delta printed. Shipped app/main.py is 234 542 bytes at 86eaef2.

# mutation of site_standing Δ bytes verdict
M1 sum(...) / max(1, len(standing)) — the mean +24 RED
M2 max(standing.values(), key=abs) — largest share +9 RED
M3 list(standing.values())[0] — one share +4 RED — and survives on release/1.0, see §1
M4 min(standing.values(), key=abs) — smallest share +9 RED
M5 sum(abs(v) for v in ...) — magnitude, sign lost +16 RED
M6 -sum(standing.values()) — sign flipped +1 RED
M7 float(self.target) — the arbiter's site target −11 was a SURVIVOR — now RED, see §5
M8 sum(standing.values()) / 2.0 +6 RED
M9 sum(standing.values()) * 2.0 +6 RED
M10 sorted(standing.values())[-1] — largest signed +7 RED
M11 argument SWAP at the SITE call site: evaluate(site_standing, self.grid, …) → evaluate(self.grid, site_standing, …) (main.py:1620) 0 was a SURVIVOR in round 1 — now RED, see §4a
M12 argument SWAP at the PER-UNIT call site: evaluate(stood, against, …) → evaluate(against, stood, …) (main.py:1560) 0 was a SURVIVOR in round 2 — now RED, see §4c

Every one of the ten site_standing entries is red in test_watchdog.py. Re-measured in round 2 on release/1.0 @ 86eaef2 (where app/main.py is 234 542 bytes, 2 022 more than the f72c48d the round-1 figures were taken on): all ten still red, at the byte deltas in the table, each on a fresh copy with the edit re-read from disk. Adding assertions cannot turn a red green, so those verdicts carry forward to the round-3 tree unchanged.

⚠️ ROUND 2 SAID THREE OF THESE SURVIVED. EIGHT DID.

Round 2 wrote: "Before part 9 existed, three of these left the whole suite green — M1, M2 and M3 — and the remaining seven were caught by SAFETY-30's own N≥2 checks." Round 1's wording was vaguer and harmless; naming three made it specific and wrong, and it is the same species of error the round was written to fix — a survivor recorded as a kill. This file contradicted it three times on its own pages: the ledger row calls M7 "a SURVIVOR — now RED", §5 says M7 survived the whole suite, and CHANGELOG.md says so too.

Re-measured in round 3, one mutation at a time, on clean release/1.0 @ bbc903b — a fresh copy each, bytecode purged (0 remaining, printed), the line re-read from disk, byte delta and app/main.py fingerprint printed, mounted :ro, and the FULL 21-FILE SUITE run each time rather than test_watchdog.py alone. (release/1.0 has since moved on to 9e42cec, which merged CI-06; that commit touches test_peakguard.py and CHANGELOG.md only, neither of which grades site_standing, so the ten verdicts below stand on the tree they were taken on and this branch is rebased onto the newer tip.)

# Δ bytes fingerprint of app/main.py suite before part 9
M1 +24 362374cfb6486378 21/21 PASS — SURVIVOR
M2 +9 86573e7494f1c933 21/21 PASS — SURVIVOR
M3 +4 59fa8815e6fda4cf 21/21 PASS — SURVIVOR
M4 +9 43d852be3037f861 21/21 PASS — SURVIVOR
M5 +16 58d26de82af29a1a RED — test_watchdog.py, 3 checks
M6 +1 407ddb5f375cc217 RED — test_watchdog.py, 3 checks
M7 −11 2a635a4eca0a790d 21/21 PASS — SURVIVOR
M8 +6 b21eed89ff4c9240 21/21 PASS — SURVIVOR
M9 +6 0973d3caf680db65 21/21 PASS — SURVIVOR
M10 +7 0e7b7a4fed7787d0 21/21 PASS — SURVIVOR

So: EIGHT of the ten left the whole suite green, and TWO were caught — not three and seven, and not the four and six a reviewer proposed either. The correct sentence is: before part 9 existed, eight of these ten left the whole 21-file suite green, and the only two SAFETY-30's own N≥2 checks caught were M5 and M6.

⚠️ AND THE TWO THAT WERE CAUGHT ARE CAUGHT FOR A REASON THAT MAKES THE CARD'S CASE RATHER THAN WEAKENING IT. M5 (sum(abs(v) …)) and M6 (-sum(…)) are the only two entries in the table that reverse the SIGN of site_standing. Every other entry changes only its magnitude — and _sign_site, the suite's only N≥2 site fixture before this card, runs shares of about −2500 W each against a 320 W gate. sum, the mean, max(key=abs), min(key=abs), sorted(...)[-1], [0], self.target, half the sum and twice the sum all land on −2500, −2500, −2500, −2500, −2500, −2500, −2500, −1250 and −5000 W: nine different numbers, all on the same side of the same threshold. The three checks that fired on M5 and M6 grade "the site trips and no unit does", the claim it trips through, and where the latch is written — none of them grades the quantity, and the sign flip reaches them only because it stops the trip happening at all.

That is the whole thesis of this card measured rather than argued: the pre-existing coverage could see a sign, and nothing else. Part 9's fixture — per-unit shares each below the gate whose sum clears it — is the only arrangement in which the magnitude is load-bearing, and it is what takes the other eight red.

⚠️ ONE FALSE RED, AND IT IS CI-06's. In the batch run M10 also reported FAIL test_peakguard.py. Re-run in isolation three times and once more through tools/mutation_run.sh — four clean passes, zero failures. The batch script had discarded the failure detail, so the failing check cannot be named here; what can be said is that the red did not reproduce in four attempts and that no claim in this card rests on it. test_peakguard.py is the file CI-06 records as flaky, and this is the behaviour it records. Believe no red from it without three isolated re-runs.

⚠️ ROUND 3 CAN NAME ONE, AND IT IS THE SAME FILE. Round 3's own batch — the ten full-suite runs above, plus §7's sibling swaps — produced no red from test_peakguard.py on M10 or on any other ledger entry. It produced exactly one elsewhere, on a static_tariff.py-only edit (§7b, A4), and this time the failing check was captured: "one cycle of the intent form is exactly the delivered form plus the command standing on the wire" — which is CI-06's mechanism 1 by name, the cross-time projection. test_peakguard.py then passed four for four in isolation on that same tree. That is the first time this card has been able to put a name to one of these reds, and it is the name CI-06 predicts.

⚠️ M3 IS NOT "FOR COMPLETENESS", AND ROUND 1 SAID IT WAS. Round 1 recorded M3 as already closed structurally by SAFETY-32 and disclaimed it. §1 above has the correction and the mechanism: [0] survives a full green 21-file suite on release/1.0 @ 86eaef2, because standing is a dict filled by subscript assignment and never enters 12j's taint set. M3 is a real kill by this card's fixture and by nothing else in the repository. It is claimed here.

4a. ⚠️ The zero-byte survivor one line below the one this card pinned

Round 1 pinned the value site_standing and never pinned where it goes. Swapping the detector's first two arguments at main.py:1620 is a zero-byte edit — the same characters, reordered — and it left the round-1 tree 21/21 green.

What it costs is the banner. SignWatchdog.evaluate() formats commanded_w first and against_w second, so under the swap part 9a's own output reads:

SIGN_DISAGREEMENT: commanded +2500 W against a meter reading -500 W for 2 s …

The site is standing at −500 W of charge into a +2500 W import and the latching, site-stopping banner tells the operator the exact opposite — which instrument read what, inverted. watchdog.py:658-665 names this failure in its own words: "a false statement about which instrument produced the evidence, on the one line somebody woken by this alarm reads first."

Why round 1's check could not see it, and this is the general lesson. It asserted

f"{sum(_share38):.0f} W" in _c38.site_sign_wd.reason

— that "-500 W" appears somewhere in the sentence. Under the swap it still does: in the meter slot. A substring test cannot reach a failure that is purely positional, and round 1's own comment claimed the opposite ("would put a different number in the store, the action log and the banner, and this is where that shows"). It shows the number, never its position. This is MUTATION.md's "a check whose data cannot reach the failure it names", in the card that exists to complain about exactly that.

The fix is one line and touches no app/ code — assert the ordered pair:

f"commanded {sum(_share38):+.0f} W against a meter reading "
f"{_mid38['grid']:+.0f} W" in _c38.site_sign_wd.reason

Measured: with that line, M11 is red in test_watchdog.py and green in the other twenty files — one file, one check.

4b. Claim wiring, re-measured rather than assumed

Round 1's reviewer disclosed it had not mutated the arbiter claim wiring or SAFETY-31/circulation. Both were mutated in round 2. Both are zero-byte edits to arbiter.CLAIM_SCOPE:

# mutation Δ bytes verdict
W1 "sign_site": SCOPE_SITE → SCOPE_UNIT 0 RED — test_watchdog.py (parts 5 and 9a), test_arbiter.py, test_peakguard.py
W2 "circulation": SCOPE_SITE → SCOPE_UNIT 0 RED — test_watchdog.py, test_arbiter.py, test_peakguard.py

Part 9a's "…and it stops the WHOLE site, through the sign_site claim by name" is one of the two checks that kills W1, so this card contributes to that coverage rather than merely inheriting it. W2 is caught entirely by pre-existing checks — SAFETY-38 adds nothing to circulation, and does not claim to.

4c. ⚠️ The SAME zero-byte defect one call site over — and it is the one the shipped install can see

Round 2 closed §4a by asserting the ordered pair on the site banner at main.py:1620. It did not touch main.py:1560, the per-unit call, and the identical swap there —

if inv.sign_wd.evaluate(stood, against, self.following, now)      # shipped
if inv.sign_wd.evaluate(against, stood, self.following, now)      # M12

— left the round-2 tree 21/21 green. Measured: app/main.py 234 542 bytes before and after, Δ 0 bytes, fingerprint 61aa413f… → e57e6026…, bytecode purged, tree mounted :ro, the line re-read from disk.

Nothing behavioural can catch it, and that is structural rather than unlucky. watchdog.disagrees() is symmetric in its first two arguments — commanded_w * against_w < 0, with an abs() gate on both — so the swap cannot change a single trip decision on any input. What it changes is the sentence. Measured by diffing the suite's own printed banners between the round-3 tree and the same tree carrying only M12: 9 emitted banner lines invert, in 3 distinct sentences.

shipped:  SIGN_DISAGREEMENT: commanded -2000 W against a meter reading +2000 W …
M12:      SIGN_DISAGREEMENT: commanded +2000 W against a meter reading -2000 W …

shipped:  … commanded +1000 W against its own measured power -1000 W …
M12:      … commanded -1000 W against its own measured power +1000 W …

shipped:  … commanded -2000 W against a meter reading +4000 W …      (the N=1 arm)
M12:      … commanded +4000 W against a meter reading -2000 W …

⚠️ AND IT IS WORSE THAN THE ONE ROUND 2 CLOSED. Part 9c asserts the SITE detector is not armed at a roster of one, and inverter B does not physically exist. So on the shipped install the per-unit banner is the only sign-disagreement sentence that can ever be printed — in the action log, in _LOG.error, in status_payload()'s headline banner, and in sign_reason, which is persisted and read back after the restart the latch is designed to survive. Round 2 pinned the N≥2-only call site and left the shipping one open.

Closed by the ORDERED PAIR on both per-unit banners, with both operands read off the observed cycle — inv.last_write and inv.batt off the objects the plant moved, never off the arguments the detector was handed, which would be comparing the swap against itself:

  • test_watchdog.py part 3, the N≥2 per-unit trip: f"commanded {stood:+.0f} W against its own measured power {meas:+.0f} W", plus the persisted unit/inverter-b/sign_reason copy, plus a precondition that the two operands are different numbers of opposite sign — without which a transposition would produce the same string and the check would be decoration;
  • test_watchdog.py part 7, the N=1 controller-driven trip: f"commanded {stood:+.0f} W against a meter reading {grid:+.0f} W", plus the namespaced key and the flat rollback mirror, plus the same precondition.

Measured after the fix: M12 is RED on four named checks in test_watchdog.py and GREEN in the other twenty files — one file, four checks. M11 re-measured on the same tree: still red.

⚠️ Neither §6a nor MUTATION.md disclosed it. §6a's "what was NOT closed" enumerated circ_wd, the eight other CLAIM_SCOPE entries, actuator_of()'s body and the n_units > 1 guard — not this. MUTATION.md's own closing rule, in the file round 2 edited, is "a variant is not covered because a near-miss of it is." §7 is the enumeration that would have caught both instances at once.

5. The survivor this card went looking for, and closed

M7 is not one of the card's three. It was found by asking the question the recurring defect demands — what mutation would my new checks NOT see? — and it is the answer: −11 bytes, and it survived every check above and the whole 20-file suite, because in every fixture built so far the arbiter's site target and the sum of what actually stands on the wire are the same number.

They are not the same number when a setpoint device's own reported range is narrower than its max_w. The device P_SAFETY claim is filed per actuator (actuator_of("device", inv)), so it bounds each unit's share and never the site target, and clamp_to_rating() clips again on the way out. On two units rated 220/280 W whose devices report ±120/±130 W, the site asks for −500 W and −250 W stands.

Which one the guards mean is already written down, in both of them: SAFETY-02 grades "the command standing on the wire", and looks_frozen()'s docstring says commanded_w is "the EMITTED setpoint — what the arbiter resolved and write_setpoint() put on the wire — not the loop's raw wish". SAFETY-20 is the whole card about the difference. Those sentences are now made to bite: on the site target the detector fires on a site standing at −250 W against a +2250 W meter — under its own 300 W gate — which is a latching, site-wide false trip on an install whose inverters simply report a narrower range than the operator typed.

6. Honest gaps

  • A mutation that changes the SIGN CONVENTION of standing itself — i.e. in main.py's stood = inv.last_write if inv.last_write is not None else inv.target, not on the reduction line — is not in this ledger. It is a different line and a different card; §3a's obedience precondition would see some of it, but that is not a measured claim and is not made here.
  • The freeze half is graded at ONE roster length (2) and one rail. The RAIL_FRAC constant, rail_ceiling_w()'s device-bound conjunction and rail_unreachable() are test_freeze.py's subject and are untouched here.
  • The site hold is 2 s, not the shipped 325 s. The window's derivation is a rig campaign (DECISIONS/SAFETY-30-per-unit-sign.md §3B) and this file does not re-grade it. The gate is the shipped 300 W, which is the coordinate this card is about.
  • Everything about inverter B here is simulated and labelled so. No number in this document or in the fixture is a measurement of a device that does not yet exist.

6a. Round 2: what was closed, and what was NOT

Round 1's reviewer disclosed that it had not mutated SAFETY-31/circulation, the arbiter claim wiring, or ledger entries M4/M5/M6/M8/M9/M10. Enumerated, with the gaps left open stated rather than rounded off:

Closed in round 2 (measured, on 86eaef2, bytecode purged, edit re-read from disk, byte delta printed):

  1. M11, the zero-byte argument swap — §4a. Red on one named check.
  2. M3, the [0] reduction — re-measured, survives on release/1.0, closed by part 9a and 9b. The false attribution corrected in all four places it was committed: this file §1 and §4, CHANGELOG.md, and test_watchdog.py's part 9 header (which previously instructed future authors not to re-cover it).
  3. M1, M2, M4, M5, M6, M7, M8, M9, M10 — every ledger entry re-run independently in round 2 rather than carried forward from round 1's report. All ten red; M1/M2/M4 re-run in the exact wrapped form the table records so the byte deltas are the table's own (+24/+9/+9).
  4. W1, sign_site claim scope — §4b. Zero bytes, red, part 9a among the killers.
  5. W2, circulation claim scope — §4b. Zero bytes, red, but entirely on pre-existing checks.

NOT closed, and not claimed:

  • SAFETY-31's circ_wd detector itself — its threshold, its hold, its circulating_w() reduction over the roster. Only its claim scope was mutated (W2), and that kill belongs to checks this card did not write. Circulation's own behavioural coverage is test_watchdog.py part 4's and is untouched here.
  • The other eight CLAIM_SCOPE entries — loop, peak, arbitrage, safety (site) and device, supervised, failsafe, maintenance (unit). CLAIM_SCOPE has ten entries; only the two named above were mutated. test_peakguard.py part 10f grades the classification statically; that is a different instrument and this card did not re-measure it.
  • actuator_of()'s own body and the n_units > 1 guard on the sign_site append at main.py:1764 were not mutated.
  • The ten ledger mutations were re-graded against test_watchdog.py only. The "green in the other twenty files" half of the round-1 claim was re-verified on 86eaef2 for M3 and M11 (both full 21-file runs) and is otherwise carried forward from round 1's measurement on f72c48d. It is a weaker statement than round 1 made and is written down as such. ⚠️ Round 3 closed the other half of this, but only in the before direction: all ten were re-run on clean release/1.0 @ bbc903b under the full 21-file suite to establish which of them the pre-existing coverage could see — see §4's ⚠️ paragraph, and the answer was eight survivors, not three. The after direction — "each of the ten is red in test_watchdog.py and green in the other twenty on the round-3 tree" — is still measured end-to-end only for M3, M11 and M12. For the other seven, "red" carries forward from round 2 (which adding assertions cannot undo) and "green elsewhere" carries forward from round 1.
  • test_peakguard.py's CI-06 flake is not fixed by this card and is not this card's subject. It is characterised here (five isolated runs on the M3 tree, all PASS) only far enough to overturn the false kill it produced.

6b. Round 3: what was closed, and what is still open

Closed in round 3:

  1. M12, the zero-byte argument swap at the per-unit call site main.py:1560 — §4c. It was a survivor of the round-2 tree at 21/21 green, and it is the only sign-disagreement banner a shipped one-inverter install can print. Red on four named checks, green in the other twenty files.
  2. The ledger arithmetic in §4, which round 2 made specific and wrong. See §4's own ⚠️ paragraph: re-measured mutation by mutation on clean release/1.0 @ bbc903b, full 21-file suite each time.
  3. The family itself is now enumerated by machine — tools/symmetric_message_scan.py, §7 — rather than by hand for a third time.

Open, named, and NOT claimed:

  • A3 and B1 — the only two status-page rows in which the freeze detector ever names a device range: the NOT COVERED row and the armed row's "below max_w because …" clause. rail_unreachable()'s dev_min/dev_max at main.py:3208 and the literal f"only {inv.dev_min}...{inv.dev_max} W" at main.py:3231. Both measured: 0 bytes each, 21/21 green each. Real, open members of the family. They are status-page rows rather than latching alarms, and closing them wants a fixture with a device whose two bounds are asymmetric — every fixture in this repository uses ±d, so the transposition is currently invisible by construction — which belongs to test_freeze.py's card, not to this one.
  • Twelve census-B sites (§7c, B2–B12) determined unpinned at the source level — the distinguishing literal appears in no test_*.py — and not mutated. Weaker evidence than A1–A4 and B1, and written down as such — §7d says the same thing with the same list.
  • B6, SAFETY-31's circ_reason, is the same shape as the defect this card closed for SAFETY-02/30 and is also persisted and re-read after a restart. §6a already declines to touch circ_wd; §7d says what that costs.
  • Everything §6a lists stays open: circ_wd's own thresholds, the eight other CLAIM_SCOPE entries, actuator_of()'s body, and the n_units > 1 guard.

6c. Round 4: what was closed, and what is still open

The blocker round 3 shipped. The enumeration read as complete and covered one syntactic sink. Every one of census B's 16 members was a sentence that is appended, assigned, or passed as a log argument (problems.append, out.append({"text": …}), self.reason = …, _LOG.info). Not one was a sentence that is RETURNED. Census A has a return bucket, but only for symmetric parameters, so a method formatting its own attributes and handing the string back was invisible to both — and describe() / explain() / the reason-builders are exactly where this codebase keeps its operator sentences. Worse than the gap: it was in none of the five blind spots the tool prints about itself. A tool that asserts a completeness it does not have is the recurring defect wearing a new hat.

Closed in round 4:

  1. clamp.py:253 ClampResult.describe(), pair requested_w / applied_w — §7e C6. A survivor of the round-3 tree at 21/21 green, 0 bytes. It is the §15 action-log row at main.py:1366 and the ClampWatchdog notification reason at main.py:2452; no test_*.py mentioned describe() at all. Pinned on the ordered pair, on the contiguous literal, at both consumers, with a precondition that the two operands render differently — test_clamp.py part 6d. 4 checks red under the transposition.
  2. peakguard.py:493 PeakDecision.explain(), pair projected_w / ceiling_w — §7e C7. Same measurement: 0 bytes, 21/21 green; the string "peak guard idle" was in no test_*.py. Pinned on the decision and on the shipped status row at main.py:3313 — test_peakguard.py part 13. 3 checks red.
  3. arbiter.py:264 (B12) — the check whose claim was wider than its assertion, carried forward from §7d. Measured this round rather than left at source level: 0 bytes, 21/21 green before, test_arbiter.py red after. 2 checks red.
  4. The tool's sinks are now stated rather than implied. It prints what it scans as a MESSAGE, that it now scans the RETURN sink through the returning method's call sites, and — the part that was missing — an explicit list of what it does not scan at all, largest hole first.

⚠️ The correction round 4 owes the ticket. The brief placed PeakDecision.explain()'s idle sentence at main.py:3299/3302. Those two lines are the floor-limited branch, where explain() returns self.reason before the idle literal is ever built. The idle sentence reaches the page at main.py:3313 and the action log at main.py:1951 (edge-logged, so it is the RELEASE edge that writes it). The pin is on 3313; 1951 is disclosed below, unpinned.

Open, named, and measured — see §7f for the ledger:

  • TEN further zero-byte survivors found by the widened scan, each measured at 21/21 green on this tree: two in allocator.py's UnitPlan.explain() (C1 :154, C2 :160), three in arbiter.py's two explain()s (C4a, C4b :344, C5 :392), two in tariff.py (C8, C9), two in arbitrage.py's trade reasons (B17, B18) and one in main.py's SoC-floor notification (B19). None is a control decision; every one is a sentence an operator reads. ⚠️ CORRECTED after review, and the error is worth keeping visible. This bullet first said "Nine" while listing eleven, and the CHANGELOG said eleven while listing eleven — and BOTH counted allocator.py:162 (C3) among the survivors when §7f records it as a KILL, red in test_alloc.py. The authoritative tables in §7e and §7f were right throughout; only the summary sentences a reader actually relies on were wrong. That is this card's own recurring defect in prose rather than in code: a claim wider than the measurement beneath it. Arithmetic, from §7f: 14 measured = 10 surviving + 1 already caught (C3) + 3 caught by this round (C6, C7, B12).
  • The action-log surface of the peak-guard idle line (main.py:1951) is not pinned. The status row (3313) is, and both come off the same explain(), so the sentence is closed and the second call site is not — which is precisely the distinction §4c was written about, recorded rather than assumed.
  • Everything §6a and §6b list stays open.

7. The symmetric-predicate family — every call whose argument order is load-bearing for a MESSAGE

Why this section exists, and it is the most useful thing in the card. Round 2 pinned the operand order of SignWatchdog.evaluate() at main.py:1620. Round 3 found the identical zero-byte defect at main.py:1560, one call site over — and that one is worse, because test_watchdog.py part 9c asserts the site detector is never armed at a roster of one, so on the shipped install the per-unit banner is the only sign-disagreement sentence that can ever be printed. Twice in one card the enumeration was written by hand and twice it was short. MUTATION.md habit 12: a hand-written scope is the same defect as a hand-written fixture. So the family is now computed, by tools/symmetric_message_scan.py.

7a. What makes this family invisible, and why it is not a fixture problem

Every other defect in this document is a fixture problem: no fixture happens to separate the mutant from the shipped code. Build the right fixture and it goes red. This one is not that.

Where a callee is symmetric in two arguments, a swap at a call site cannot change any action on any input. watchdog.disagrees() is commanded_w * against_w < 0 with an abs() gate on both sides: the product commutes and the two gates are the same gate. There is nothing for a behavioural check to observe, ever — not "no fixture separates them" but no fixture can. The only surface that moves is the sentence, and the only check that can see it is one asserting the ordered pair (MUTATION.md habit 14) at that call site.

Two consequences the earlier rounds got wrong:

  • The unit of coverage is the CALL SITE, not the function. evaluate()'s operand order was pinned and the defect still shipped, because the pin was on the other call.
  • A behavioural suite is not weak evidence here, it is NO evidence. "21/21 green" is what this mutation is guaranteed to produce. Measured, twice: M11 at main.py:1620 on the round-1 tree, and M12 at main.py:1560 on the round-2 tree.

7b. Census A — the callee formats its own parameters

tools/symmetric_message_scan.py renames each parameter pair, erases every formatted string, canonicalises the commutative operators (*, +, and, or, ==, min/max, iterated tuple literals) and compares the bodies; it iterates to a fixpoint, because the symmetry is usually one call deep — evaluate() is symmetric only through disagrees(), and a single pass reports an empty family on a tree whose answer is already known. It then classifies each sentence by where it goes.

Fixpoint on this tree: 3 rounds, 17 interchangeable positional and 5 keyword pairs across 14 functions. 45 parameter pairs reach a sentence; 8 are symmetric — 5 whose sentence goes only to an operator, and 3 whose sentence is the return value and therefore has to be classified by hand, because a returned string is sometimes a KEY and sometimes a SENTENCE.

# scan row pair called at swap costs reaches an operator via pinned?
A1 watchdog.py:613 SignWatchdog.evaluate() (commanded_w, against_w) main.py:1560 — per-unit, and the N=1 detector 0 B sign_reason (persisted), the action log, _LOG.error, status_payload()'s headline banner YES — round 3. test_watchdog.py part 3 (its own measured power) and part 7 (a meter reading), on the contiguous literal with both operands read off the observed cycle. Measured: 0 B, red on 4 named checks, green in the other 20 files.
A2 same row, second call site (commanded_w, against_w) main.py:1620 — site 0 B site_sign_reason, the action log, the status page YES — round 2, part 9a. Re-measured on the round-3 tree: still red.
A3 freeze.py:405 FreezeWatchdog.evaluate() and freeze.py:311 rail_unreachable() — both symmetric through rail_ceiling_w()'s for bound in (dev_min, dev_max) (dev_min, dev_max) main.py:3208 (and 1928, 2755, 3196, 3219) 0 B the status page's freeze detector: NOT COVERED — the setpoint device reports a range of … row NO. Measured: swapped at main.py:3208-3209, 0 bytes, main.py fingerprint 61aa413f… → 2305b560…, 21/21 green. Disclosed in §7d, not closed. ⚠️ rail_unreachable() is the row the scan puts in its RETURN bucket: its returned string is a sentence, not a key, so it is a family member after all — which is exactly why that bucket says read by hand.
A4 static_tariff.py:522 _raise_gap() (start, end) static_tariff.py:509, :513 0 B the ProfileInvalid refusal an operator reads at startup YES — pre-existing. Measured: both call sites swapped, 0 bytes, static_tariff.py fingerprint caef7fde… → 50e9a768…, red in test_static_tariff.py on two named checks ("…and names the uncovered range so the operator can fix it", "…and the refusal names the uncovered range"), because _hhmm(start)}-{_hhmm(end) is asserted as the contiguous literal "22:00-23:00". ⚠️ That batch run also reported FAIL test_peakguard.py on a static_tariff.py-only edit; the failing check is "one cycle of the intent form is exactly the delivered form plus the command standing on the wire", which is CI-06's mechanism 1 by name, and test_peakguard.py passes four for four in isolation on that same tree. No claim here rests on it.
A5–A6 static_tariff.py:522 _raise_gap() (start, weekday), (end, weekday) — — — not real — the scan's one over-report. It erases the sentence, so it cannot see that DAY_NAMES[start] with start = 1320 raises IndexError. A mixed-type pair fails loudly, which is the opposite of this family.
A7–A8 store.py:177 unit_key(), mqtt.py:222 unit_state_key() (unit, key), (slug, key) many 0 B — not this family. The formatted string is the return value and it is a KEY: a swap renames a store key and an MQTT topic, which is behaviour every ordinary check can see. MAINT-05 is the card about it.

7c. Census B — the caller builds the sentence

Census A only sees a callee formatting its own parameters, which was gap 2 of its own honest-gap list. The other half never crosses a function boundary: an operator sentence built where it is printed, interpolating two values that are interchangeable by shape. No predicate is involved at all, so census A's symmetry test has nothing to test. The scan pairs interpolated expressions that are one leaf apart — inv.dev_min / inv.dev_max, cfg.min_w / cfg.hold_s — which is exactly what a transposition swaps, and it keeps only sentences whose sink is a message: an f-string that becomes a URL or a store key is data, and a swap there is an ordinary behavioural change. 16 candidates in round 3, every one of them a zero-byte edit — and 22 after round 4 widened the sink classifier (B17–B22 below):

# site pair reaches an operator as pinned?
B1 main.py:3231 inv.dev_min / inv.dev_max status page, … accepts only −120…130 W NO — MEASURED. Transposed in place, 0 bytes, main.py fingerprint 61aa413f… → ac36a97b…, purged, :ro, 21/21 green. The row then tells the operator the device accepts 130…−120 W.
B2 main.py:3362 inv.dev_min / inv.dev_max status page, setpoint … (range −120…130 W) NO — the literal setpoint … (range is in no test_*.py.
B3 main.py:3435 sign_wd.cfg.hold_s / .cfg.min_w status page, sign watchdog: armed (trips after 180 s opposed, both over 300 W) NO — s opposed is in no test_*.py.
B4 main.py:3460 site_sign_wd.cfg.hold_s / .min_w status page, the site sign row NO — armed against the sum is in no test_*.py.
B5 main.py:3491 circ_wd.cfg.hold_s / .min_w status page, the circulation row NO — circulation watchdog: armed is in no test_*.py.
B6 circulation.py:317 cfg.min_w / cfg.hold_s circ_reason — persisted, and re-read after a restart NO — test_watchdog.py asserts only "CIRCULATING_POWER" in reason. This is SAFETY-31's twin of the exact defect this card just closed for SAFETY-02/30, on a string with the same persistence.
B7 main.py:2454 self.site_max_w / self.target the clamp action-log line NO — was clamped to is in no test_*.py.
B8 main.py:3386 clamp_wd.engagements / .episodes status page, engaged N time(s) in M episode(s) NO — time(s) in is in no test_*.py.
B9–B11 main.py:3169 (three pairs) p1.net_w / .samples / .parse_errors status page, the P1 row NO — P1 meter ( is in no test_*.py.
B12 arbiter.py:264 c.source / c.actuator scope_errors, the arbiter's mis-routing refusal ROUND 4: MEASURED, THEN CLOSED. Round 3 left it at source level; the transposition is 0 bytes, arbiter.py fdb4ed0520eeb721 → 9111f627ef639ecc, and it was 21/21 green. Now red in test_arbiter.py on 2 checks. Round 3's reasoning was right: test_arbiter.py:452 says it "names the source and the actuator it does not belong on" and then asserts "peak" in r_mis.scope_errors[0]. Under the swap the sentence becomes "inverter-b: a SITE claim filed on unit 'peak'" — and "peak" in … is still true. A claim wider than its assertion, which is habit 14's own shape.
B13 validate.py:723 meter_phases / meter_max_age_s the startup refusal NO for the ordered pair.
B14 validate.py:1211 arbitrage_soc_floor / arbitrage_soc_ceiling the startup refusal NO for the ordered pair.
B15 watchdog.py:667 cfg.min_w / cfg.hold_s the SIGN_DISAGREEMENT sentence's (limits: both over 300 W, held over 180 s) clause YES, and it is worth naming because it shows the habit works. test_watchdog.py's SAFETY-30 section asserts the site instance's whole sentence with == against a literal, the (limits: …) clause included. Full-string equality is habit 14 taken to its limit.
B16 main.py:237 i.name / i.max_w the roster log line not real — a str beside a :.0f format. The same over-report as A5–A6: a mixed-type transposition raises rather than lying.
B17 arbitrage.py:377 v.spread_eur_kwh / v.required_spread_eur_kwh the DISCHARGE decision's reason ROUND 4, NEW, and MEASURED: 0 bytes, 21/21 green. Not visible to round 3 at all — the sentence is handed to a constructor as reason=, and the sink walker scored only assignments. The row then reads "0.0400 above the cheapest and clear of the 0.1200 this cycle had to cover" with the two swapped: a trade justified by a spread it did not clear.
B18 arbitrage.py:393 same pair, CHARGE branch the CHARGE decision's reason ROUND 4, NEW, MEASURED: 0 bytes, 21/21 green.
B19 main.py:2652 g.reason / g.soc_floor_reason the goodwe_peak_soc_floor notification body ROUND 4, NEW, MEASURED: 0 bytes, 21/21 green. The alarm then opens with the reason the floor bound and closes with the guard's own conclusion, i.e. both halves of a two-part explanation in the wrong order, in the one message SAFETY-24/25 exist to send.
B20–B22 main.py:2558, main.py:2652 (two more pairs) g.reason / g.ceiling_w, g.ceiling_w / g.soc_floor_reason the same two notifications new in round 4, NOT measured — mixed types (str beside :.0f), the same over-report class as B16.

⚠️ Six of these twenty-two were invisible to round 3, and the reason is one line of the sink classifier. It scored a sentence a MESSAGE when it was appended, logged, raised, or assigned to a reason-shaped name. This codebase mostly builds operator sentences as Decision(…, reason=f"…") and out.append({"text": f"…"}) — a keyword argument and a dict key, which that walker stepped straight past. Both are message sinks now.

7d. What this enumeration does NOT close, with sizes

  • A3 is a measured, open survivor. dev_min/dev_max transposed in rail_unreachable()'s NOT COVERED row: 0 bytes, 21/21 green. It is disclosed here rather than fixed, because closing it wants a test_freeze.py or status-page fixture with a device whose two bounds are asymmetric — every fixture in the repository uses ±d — and that is a different file's card. It is a status-page row, not a latching alarm.
  • B1 is a second measured, open survivor. inv.dev_min and inv.dev_max transposed in main.py:3231's status row: 0 bytes, fingerprint 61aa413f… → ac36a97b…, 21/21 green. The page then tells the operator the device accepts 130…−120 W. Same disposition as A3: A3 is the row the freeze detector prints when the rail is UNREACHABLE, B1 the clause the ARMED row prints when the device is what set the rail — the two rows in which that detector ever names a device range, and neither one's operand order is asserted anywhere.
  • Thirteen further census-B sites are unpinned and UNMEASURED. B2–B14 above are determined at the source level — the distinguishing literal appears in no test_*.py, or the check that exists asserts a substring wider than its own claim — and were not mutated. That is a weaker statement than A1–A4 and B1, and is written down as one.
  • B12 is a check whose CLAIM is wider than its ASSERTION, which is habit 14's own shape one module over. test_arbiter.py:452 says scope_violations "names the source and the actuator it does not belong on" and then asserts "peak" in r_mis.scope_errors[0]. Swap c.source and c.actuator in arbiter.py:264 and the sentence becomes "inverter-b: a SITE claim filed on unit 'peak'" — and the substring is still found. Source-level, not mutated, and not fixed here.
  • B6 is the closest thing to a repeat. SAFETY-31's circ_reason is persisted and re-read after a restart, exactly like sign_reason, and its two limits are graded by a bare "CIRCULATING_POWER" in reason. This card does not fix it; §6a already declines to touch circ_wd, and this is the precise shape of what that declining costs.
  • The instrument's own blind spots are printed by the instrument, every run: semantic symmetry it cannot spell (it under-reports), census B pairs that are the same kind but not the same shape, %s-style logging and non-f-string payloads, anything outside app/, and the one way it over-reports — it erases the sentence, so a swap that would make the formatting itself raise is scored as changing no decision (A5–A6 above).

7e. Census C — the sentence an object returns about itself (round 4)

Why there is a third census. A and B between them covered one syntactic sink. Everything either of them reported was a sentence that is appended, assigned, or logged; none was returned. Census A's RETURN bucket only ever held a callee formatting its own parameters, so a method formatting its own attributes and handing the string back fell between them — and that is the whole describe() / explain() / reason-builder family, which is where the operator sentences in this codebase actually live.

The test is census B's — one f-string, two interpolations one leaf apart — run over the RETURN sink, plus one thing B never needed: a returned string is not yet a message. store.unit_key() and mqtt.unit_state_key() return f-strings too, and swapping there renames a store key and an MQTT topic, which every ordinary check can see (MAINT-05). So the sink of a returned sentence is resolved through its call sites: it is in this family only when some caller in app/ hands it to an operator. That resolution matches on the bare method name, which over-collects — at least four unrelated classes have an explain() — so every verdict is printed with the sites that produced it, and a MESSAGE verdict is a prompt to read them, not a proof.

The scan reports 53 returned pairs, 39 reaching an operator, 16 of those sharing a format spec (two values rendered alike, so a transposition is silent rather than visibly broken). It rediscovered both members this round was sent to fix without being told where to look, which is the only reason to trust the rest of the list.

# site pair reaches an operator as Δ bytes verdict
C6 clamp.py:253 ClampResult.describe() requested_w / applied_w §15 action log (main.py:1366) and the ClampWatchdog notification reason (main.py:2452) 0 was a SURVIVOR at 21/21 — now RED, test_clamp.py part 6d, 4 checks
C7 peakguard.py:493 PeakDecision.explain() projected_w / ceiling_w status row main.py:3313; action log main.py:1951 on the release edge 0 was a SURVIVOR at 21/21 — now RED, test_peakguard.py part 13, 3 checks
C1 allocator.py:154 UnitPlan.explain() self.span / self.why the arbiter/allocator log line 0 SURVIVOR, 21/21 green
C2 allocator.py:160 self.span / self.why the ENVELOPE VIOLATED line 0 SURVIVOR, 21/21 green
C3 allocator.py:162 self.span / self.why the ordinary in-envelope line 0 RED — test_alloc.py already asserts that whole literal. The one member of this census the suite could already see, and it says the habit works.
C4a arbiter.py:344 UnitBounds.explain() self.lo / self.hi CONTRADICTION within the unit (device lo=… > peak hi=…) 0 SURVIVOR, 21/21 green
C4b arbiter.py:344 self.lo_src / self.hi_src the same sentence, the two SOURCE names 0 SURVIVOR, 21/21 green
C5 arbiter.py:392 Resolution.explain() self.winner / self.bound_by main.py:2236's ARBITER: action-log row and self.reason at main.py:2329 0 SURVIVOR, 21/21 green — the winner and the limiter swap names, on the line §10.1 exists to make legible
C8 tariff.py:279 PeakReading.explain() self.month / self.detail the PEAK UNREADABLE row 0 SURVIVOR, 21/21 green
C9 tariff.py:528 Projection.explain() self.projected_w / self.elapsed_s the projection line 0 SURVIVOR, 21/21 green — watts printed as seconds and seconds as watts, both :.0f

Not in the family, and the scan says why rather than dropping them silently: inverter.py:338 __repr__() and static_tariff.py:378 words are reported UNRESOLVED — no call site inside app/ (a __repr__ is called by the interpreter, not by a name the scan can match); price_forecast.py:421/428 and tariff.py:214 month_key() resolve to OTHER — month_key is a store key, which is MAINT-05's case and a swap there is behaviour every check sees.

7f. Round 4's mutation ledger

Same protocol as §4: a fresh full-repo copy per mutation, :ro, bytecode purged and the purge count printed before the edit, the edited file re-read from disk, both sides' length and sha256 printed, and the full 21-file suite via tools/mutation_run.sh. Every row is a pure transposition, so every Δ is 0 bytes — verified, not assumed: len(old) == len(new) is asserted before the write and len(after) == len(before) after it.

⚠️ And every row was also required to change the ANSWER. A byte delta says the file moved; it does not say the sentence did. Each mutated tree is rendered through a scratch probe that evaluates the shipped f-string with distinct operands and diffs it against the same render of the clean tree. The first cut of that probe numbered the operands by ORDER OF APPEARANCE — so after a transposition the other leaf took the same number and all thirteen diffs came back empty, which reads exactly like "the edit is inert". Keying the value to the name fixed it. That is the same defect as everything else in this card, in the instrument this time: a measurement comparing the swap against itself.

mutation file, before → after (sha256, 16) Δ verdict
C1 allocator.py:154 span↔why 10c1b26ed0099654 → ac0c174de1ec28ad 0 SURVIVED 21/21
C2 allocator.py:160 span↔why 10c1b26ed0099654 → 9b10730f4659befd 0 SURVIVED 21/21
C3 allocator.py:162 span↔why 10c1b26ed0099654 → 07097bf5da31f0c6 0 RED — test_alloc.py
C4a arbiter.py:344 lo↔hi fdb4ed0520eeb721 → 009820de7ef0eff0 0 SURVIVED 21/21
C4b arbiter.py:344 lo_src↔hi_src fdb4ed0520eeb721 → 04cb8cf69dfc7432 0 SURVIVED 21/21
C5 arbiter.py:392 winner↔bound_by fdb4ed0520eeb721 → 9238c3db920da8ad 0 SURVIVED 21/21
C6 clamp.py:253 requested↔applied 0153133741497fa5 → 47e4e371043e3201 0 SURVIVED 21/21 before — RED after, test_clamp.py, 4 checks
C7 peakguard.py:493 projected↔ceiling d95df61a51716c26 → 2d2ee3d0bcd51251 0 SURVIVED 21/21 before — RED after, test_peakguard.py, 3 checks
C8 tariff.py:279 month↔detail 82790523dd5227c9 → 4b2147ca17a28425 0 SURVIVED 21/21
C9 tariff.py:528 projected↔elapsed 82790523dd5227c9 → 517e9148d26a243a 0 SURVIVED 21/21
B17 arbitrage.py:377 spread↔required 67fbf949b2938349 → cddd8045bdeb5e29 0 SURVIVED 21/21
B18 arbitrage.py:393 spread↔required 67fbf949b2938349 → 04a4f3ad3d255de1 0 SURVIVED 21/21
B19 main.py:2652 reason↔soc_floor_reason 61aa413fb890433e → bbcbb28f476a98d0 0 SURVIVED 21/21
B12 arbiter.py:264 source↔actuator fdb4ed0520eeb721 → 9111f627ef639ecc 0 SURVIVED 21/21 before — RED after, test_arbiter.py, 2 checks

14 measured, 12 survived the full suite before this round's checks, 1 was already caught, 3 are caught now. The tree these were taken on is SAFETY-38 @ 357cb4f rebased on release/1.0 @ 9e42cec; app/main.py is 234 542 bytes.

7g. What round 4 does NOT close, with sizes

  • Eleven measured zero-byte survivors are left open — C1, C2, C4a, C4b, C5, C8, C9, B17, B18, B19 and the three already open from round 3 (A3, B1, and the B2–B11/B13–B14 source-level set). Each is a sentence an operator reads and none is a control decision. They are disclosed with their measurement rather than pinned because pinning each one wants a fixture in the file that owns the module — test_alloc.py, test_arbiter.py, test_tariff.py, test_arbitrage.py — and this card has now spent four rounds inside test_watchdog.py's subject. An enumerated list with honest gaps beats a fifth round finding a fifth instance; what would NOT be honest is a fifth round finding one that is not on this list.
  • main.py:1951, the action-log surface of the peak-guard idle line, is unpinned. The status row off the same explain() is pinned; the second call site is not, and §4c is the card about exactly that costing a round.
  • The largest remaining hole is not in any census: %s-style lazy logging. _LOG.error("… %s … %s", a, b) puts the two operands in arguments, not in an f-string, so nothing here looks at them and the transposition is the same zero-byte edit. The tool now says so first in its NOT-SCANNED list, with the size measured rather than asserted: 31 such calls across 8 modules in app/, each one listed.
  • The tool's own blind-spot list was the round-4 blocker and is now two lists. "What this scans, and what it does not" enumerates its message sinks from the constants themselves and then names, in order, what it never looks at: %s-style logging, .format()/%/+/.join() sentences, a sentence that leaves through a dict or dataclass and is rendered elsewhere, yielded sentences, attributes outside the reason-shaped set, and everything outside app/. "What it can see but gets wrong" keeps the five gaps round 3 printed, with the over-report gap now backed by a printed format-spec column so a reader can tell a silent same-typed transposition from a noisy mixed-type one.