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
61 KiB
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_wasks 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.pypart 3, the N≥2 per-unit trip:f"commanded {stood:+.0f} W against its own measured power {meas:+.0f} W", plus the persistedunit/inverter-b/sign_reasoncopy, 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.pypart 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
standingitself — i.e. inmain.py'sstood = 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_FRACconstant,rail_ceiling_w()'s device-bound conjunction andrail_unreachable()aretest_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):
- M11, the zero-byte argument swap — §4a. Red on one named check.
- 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, andtest_watchdog.py's part 9 header (which previously instructed future authors not to re-cover it). - 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).
- W1,
sign_siteclaim scope — §4b. Zero bytes, red, part 9a among the killers. - W2,
circulationclaim scope — §4b. Zero bytes, red, but entirely on pre-existing checks.
NOT closed, and not claimed:
- SAFETY-31's
circ_wddetector itself — its threshold, its hold, itscirculating_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 istest_watchdog.pypart 4's and is untouched here. - The other eight
CLAIM_SCOPEentries —loop,peak,arbitrage,safety(site) anddevice,supervised,failsafe,maintenance(unit).CLAIM_SCOPEhas ten entries; only the two named above were mutated.test_peakguard.pypart 10f grades the classification statically; that is a different instrument and this card did not re-measure it. actuator_of()'s own body and then_units > 1guard on thesign_siteappend atmain.py:1764were not mutated.- The ten ledger mutations were re-graded against
test_watchdog.pyonly. The "green in the other twenty files" half of the round-1 claim was re-verified on86eaef2for M3 and M11 (both full 21-file runs) and is otherwise carried forward from round 1's measurement onf72c48d. 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 cleanrelease/1.0@bbc903bunder 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 intest_watchdog.pyand 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:
- 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. - 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. - 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()'sdev_min/dev_maxatmain.py:3208and the literalf"only {inv.dev_min}...{inv.dev_max} W"atmain.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 totest_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 touchcirc_wd; §7d says what that costs. - Everything §6a lists stays open:
circ_wd's own thresholds, the eight otherCLAIM_SCOPEentries,actuator_of()'s body, and then_units > 1guard.
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:
clamp.py:253ClampResult.describe(), pairrequested_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 atmain.py:1366and the ClampWatchdog notification reason atmain.py:2452; notest_*.pymentioneddescribe()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.pypart 6d. 4 checks red under the transposition.peakguard.py:493PeakDecision.explain(), pairprojected_w/ceiling_w— §7e C7. Same measurement: 0 bytes, 21/21 green; the string"peak guard idle"was in notest_*.py. Pinned on the decision and on the shipped status row atmain.py:3313—test_peakguard.pypart 13. 3 checks red.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.pyred after. 2 checks red.- 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'sUnitPlan.explain()(C1 :154, C2 :160), three inarbiter.py's twoexplain()s (C4a, C4b :344, C5 :392), two intariff.py(C8, C9), two inarbitrage.py's trade reasons (B17, B18) and one inmain.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 countedallocator.py:162(C3) among the survivors when §7f records it as a KILL, red intest_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 sameexplain(), 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:1620on the round-1 tree, and M12 atmain.py:1560on 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_maxtransposed inrail_unreachable()'s NOT COVERED row: 0 bytes, 21/21 green. It is disclosed here rather than fixed, because closing it wants atest_freeze.pyor 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_minandinv.dev_maxtransposed inmain.py:3231's status row: 0 bytes, fingerprint61aa413f…→ac36a97b…, 21/21 green. The page then tells the operator the device accepts130…−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:452saysscope_violations"names the source and the actuator it does not belong on" and then asserts"peak" in r_mis.scope_errors[0]. Swapc.sourceandc.actuatorinarbiter.py:264and 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_reasonis persisted and re-read after a restart, exactly likesign_reason, and its two limits are graded by a bare"CIRCULATING_POWER" in reason. This card does not fix it; §6a already declines to touchcirc_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 outsideapp/, 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 insidetest_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 sameexplain()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 inapp/, 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 thereason-shaped set, and everything outsideapp/. "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.