SAFETY-38: pin site_standing with shares that cannot answer for the site #53

Merged
admin merged 6 commits from SAFETY-38 into release/1.0 2026-09-06 00:00:49 +02:00
Owner

app/main.py:1598's site_standing = float(sum(standing.values())) is the first argument of SAFETY-30's latching, site-stopping sign detector and the commanded_w handed to SAFETY-18's looks_frozen(). It was pinned by nothing.

Coverage only — not one byte of app/ moves. The shipped sum is correct: the site detector must compare the SUM of standing commands against the meter, which is what no per-unit check can see. This card pins it; it does not "fix" it.

What survived, and what SAFETY-32 already closed

Re-measured on release/1.0 @ f72c48d after SAFETY-32 merged:

mutation verdict before this PR
list(standing.values())[0] CAUGHT by SAFETY-32's package-wide structural pin — not re-covered here
sum(...) / max(1, len(standing)) — the mean SURVIVES
max(standing.values(), key=abs) — largest share SURVIVES

The two survivors are semantic — identical shape, different meaning — which is precisely the blind spot SAFETY-32 disclosed in its own words ("SEMANTICS. min → max → the mean are edits invisible to any shape pin, permanently"). No shape pin will ever close them. That disclosure is now confirmed by measurement rather than taken on trust.

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

test_watchdog.py's _sign_site is the repository's only N≥2 site fixture. It runs at site_sign_disagree_w: 320 with shares of about −2500 W each — every one individually far over the gate. On numbers like those sum, the mean and max(key=abs) all agree, and a check whose two candidate answers happen to be equal cannot observe a change that swaps them.

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.

The fixture — test_watchdog.py part 9

Two simulated units rated 220 W and 280 W, both obeying exactly, inverted gain, house importing 2000 W. Both stand at their own rails, meter reads +2500 W.

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

Four distinct magnitudes, exactly one over the gate. The asymmetry is load-bearing twice: equal shares collapse mean, max and first onto one number, and the smaller unit is first so [0] stays distinct from max(key=abs). Both facts are asserted as preconditions, not described. The gate is the shipped default; only the hold is shortened (325 s → 2 s), because the hold is a wall clock and the campaign behind 325 s is a rig measurement this file does not re-grade.

⚠️ 220/280 W are SIMULATED and are not any real inverter's. Inverter B does not physically exist; its 4600 W is from its datasheet (goodwe/NOTES.md §1.1b). No number here is a measurement.

9a — the site sign detector. Shipped: the site latches, parks every inverter, and the operator reads 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 detector exists for. Under the mean and max(key=abs): nothing trips, ever. A check also pins the −500 W digit in the reason string, so a mutation that still tripped but on a different reduction is caught too.

9b — the second consumer, covered rather than scoped out. Same discrimination at SAFETY-18's threshold: the site rail is 0.95 × 500 = 475 W, so sum = 500 W rails it while 250, 280 and 220 do not.

  • 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.

The sign is flipped from 9a on purpose (shipped positive gain, frozen meter reading import) so SAFETY-30 has nothing to say and this half grades SAFETY-18 alone; reusing 9a's inverted gain would have the site latch park everything at 0 W and the freeze conjunction could never be true — a check measuring the other guard's remedy. ⚠️ The open quarter is deliberately backfilled with the stuck reading: without it blindness() answers BLIND_COVERAGE, the mutant reaches the same action by a different route, and a check on the action alone could not see the difference it names. The counterfactual comes from the same run — earlier cycles, below the rail, meter equally frozen, no SAFETY-18 verdict — so the answer is produced by the command magnitude site_standing carries and not by unchanged_s alone.

9c — at N=1 nothing changes, asserted. A real one-inverter controller is driven through the same scenario; the four reductions are printed and checked to be one number, the site detector is still absent, and SAFETY-18 still reaches BLIND_FROZEN on that same value.

Mutation ledger — 10 mutations, all red

Each on a fresh full-repo copy, __pycache__/*.pyc excluded, mounted :ro, graded by tools/mutation_run.sh (purge, tree fingerprint, and a check that no cache appears during the run). The edited line was re-read from disk and the byte delta printed. Shipped app/main.py = 232 520 B.

# mutation Δ B verdict
M1 sum(...) / max(1, len(standing)) — the mean +24 RED
M2 max(standing.values(), key=abs) +9 RED
M3 list(standing.values())[0] +4 RED (SAFETY-32 already owns this one)
M4 min(standing.values(), key=abs) +9 RED
M5 sum(abs(v) for v in ...) +16 RED
M6 -sum(standing.values()) +1 RED
M7 float(self.target) −11 was a SURVIVOR — now RED
M8 sum(...) / 2.0 +6 RED
M9 sum(...) * 2.0 +6 RED
M10 sorted(standing.values())[-1] +7 RED

Every one is red in test_watchdog.py and in nothing else — the other 19 files are green under all ten, which is the measurement of how unpinned this line was.

⚠️ One false red, CI-06's. M10 also reported FAIL test_peakguard.py in the batch. 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; what can be said is that it did not reproduce in four attempts and no claim in this PR rests on it. Recorded rather than quietly dropped.

The survivor this card went looking for, and closed

Asking the question the recurring defect demands — what mutation would my new checks NOT see? — produced M7, float(self.target): the arbiter's site target instead of the sum of what actually stands on the wire. −11 bytes, and it survives all 20 files on untouched release/1.0 @ f72c48d (verified, full mutation_run.sh), because every fixture in the repository was built where the two are the same number.

They separate when a setpoint device's reported range is narrower than its max_w: the device P_SAFETY claim is filed per actuator, so it bounds each unit's share and never the site target, and clamp_to_rating() clips again on the way out. On units rated 220/280 W whose devices report ±120/±130 W, the site asks for −500 W and −250 W stands. Both guards already say which one they mean — SAFETY-02 grades "the command standing on the wire"; looks_frozen() says commanded_w is "the EMITTED setpoint … not the loop's raw wish". 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: a latching, site-wide false trip on an install whose inverters simply report a narrower range than the operator typed. Closed by 9a's device-clamped counterfactual.

Honest gaps

  • A mutation of the SIGN CONVENTION of standing itself — stood = inv.last_write if … else inv.target at main.py:1556, a different line — is not in this ledger. 9a'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. RAIL_FRAC, rail_ceiling_w()'s device-bound conjunction and rail_unreachable() are test_freeze.py's subject and are untouched.
  • 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 is not re-graded. The gate is shipped, which is the coordinate this card is about.
  • The M10 false red could not be named. See above.
  • Everything about inverter B is simulated and labelled so. No number in this PR or in the fixture is a measurement of a device that does not yet exist.

Suite

tools/mutation_run.sh on the committed tree, fresh copy, bytecode purged, mounted :ro: 20/20 PASS.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU

`app/main.py:1598`'s `site_standing = float(sum(standing.values()))` is the first argument of **SAFETY-30's latching, site-stopping** sign detector *and* the `commanded_w` handed to **SAFETY-18's `looks_frozen()`**. It was pinned by **nothing**. **Coverage only — not one byte of `app/` moves.** The shipped `sum` is correct: the site detector must compare the SUM of standing commands against the meter, which is what no per-unit check can see. This card pins it; it does not "fix" it. ## What survived, and what SAFETY-32 already closed Re-measured on `release/1.0` @ `f72c48d` after SAFETY-32 merged: | mutation | verdict before this PR | |---|---| | `list(standing.values())[0]` | **CAUGHT** by SAFETY-32's package-wide structural pin — not re-covered here | | `sum(...) / max(1, len(standing))` — the mean | **SURVIVES** | | `max(standing.values(), key=abs)` — largest share | **SURVIVES** | The two survivors are **semantic** — identical shape, different meaning — which is precisely the blind spot SAFETY-32 disclosed in its own words (*"SEMANTICS. `min` → `max` → the mean are edits invisible to any shape pin, permanently"*). No shape pin will ever close them. That disclosure is now confirmed by measurement rather than taken on trust. ## Why the suite could not see it — habit 4, inside the site half of the card that names the rule `test_watchdog.py`'s `_sign_site` is the repository's **only** N≥2 site fixture. It runs at `site_sign_disagree_w: 320` with shares of about **−2500 W each** — every one individually far over the gate. On numbers like those `sum`, the mean and `max(key=abs)` **all agree**, and a check whose two candidate answers happen to be equal cannot observe a change that swaps them. 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. ## The fixture — `test_watchdog.py` part 9 Two **simulated** units rated 220 W and 280 W, both obeying exactly, inverted gain, house importing 2000 W. Both stand at their own rails, meter reads +2500 W. | reduction | value | vs the **SHIPPED** 300 W gate | |---|---|---| | `sum` | **−500 W** | **OVER** → trips | | mean | −250 W | under → silent | | `max(…, key=abs)` | −280 W | under → silent | | `list(…)[0]` | −220 W | under → silent | **Four distinct magnitudes, exactly one over the gate.** The asymmetry is load-bearing twice: equal shares collapse mean, max and first onto one number, and the **smaller** unit is first so `[0]` stays distinct from `max(key=abs)`. Both facts are asserted as preconditions, not described. The **gate is the shipped default**; only the hold is shortened (325 s → 2 s), because the hold is a wall clock and the campaign behind 325 s is a rig measurement this file does not re-grade. ⚠️ **220/280 W are SIMULATED and are not any real inverter's.** Inverter B does not physically exist; its 4600 W is **from its datasheet** (`goodwe/NOTES.md` §1.1b). No number here is a measurement. **9a — the site sign detector.** Shipped: the site latches, parks every inverter, and the operator reads `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 detector exists for. Under the mean and `max(key=abs)`: **nothing trips, ever.** A check also pins the **−500 W digit** in the reason string, so a mutation that still tripped but on a different reduction is caught too. **9b — the second consumer, covered rather than scoped out.** Same discrimination at SAFETY-18's threshold: the site rail is `0.95 × 500 = 475 W`, so `sum` = 500 W rails it while 250, 280 and 220 do not. * 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. The sign is flipped from 9a on purpose (shipped **positive** gain, frozen meter reading import) so SAFETY-30 has nothing to say and this half grades SAFETY-18 alone; reusing 9a's inverted gain would have the site latch park everything at 0 W and the freeze conjunction could never be true — a check measuring the *other* guard's remedy. ⚠️ The open quarter is deliberately backfilled with the stuck reading: without it `blindness()` answers `BLIND_COVERAGE`, the mutant reaches the *same action by a different route*, and a check on the action alone could not see the difference it names. The counterfactual comes from the **same run** — earlier cycles, below the rail, meter equally frozen, no SAFETY-18 verdict — so the answer is produced by the command magnitude `site_standing` carries and not by `unchanged_s` alone. **9c — at N=1 nothing changes, asserted.** A real one-inverter controller is driven through the same scenario; the four reductions are printed and checked to be **one number**, the site detector is still absent, and SAFETY-18 still reaches `BLIND_FROZEN` on that same value. ## Mutation ledger — 10 mutations, all red Each on a **fresh full-repo copy**, `__pycache__`/`*.pyc` excluded, mounted `:ro`, graded by `tools/mutation_run.sh` (purge, tree fingerprint, and a check that no cache appears during the run). The edited line was **re-read from disk** and the byte delta printed. Shipped `app/main.py` = 232 520 B. | # | mutation | Δ B | verdict | |---|---|---|---| | M1 | `sum(...) / max(1, len(standing))` — the mean | +24 | RED | | M2 | `max(standing.values(), key=abs)` | +9 | RED | | M3 | `list(standing.values())[0]` | +4 | RED *(SAFETY-32 already owns this one)* | | M4 | `min(standing.values(), key=abs)` | +9 | RED | | M5 | `sum(abs(v) for v in ...)` | +16 | RED | | M6 | `-sum(standing.values())` | +1 | RED | | M7 | `float(self.target)` | **−11** | **was a SURVIVOR — now RED** | | M8 | `sum(...) / 2.0` | +6 | RED | | M9 | `sum(...) * 2.0` | +6 | RED | | M10 | `sorted(standing.values())[-1]` | +7 | RED | **Every one is red in `test_watchdog.py` and in nothing else** — the other 19 files are green under all ten, which is the measurement of how unpinned this line was. ⚠️ **One false red, CI-06's.** M10 also reported `FAIL test_peakguard.py` in the batch. 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**; what can be said is that it did not reproduce in four attempts and **no claim in this PR rests on it**. Recorded rather than quietly dropped. ## The survivor this card went looking for, and closed Asking the question the recurring defect demands — *what mutation would my new checks NOT see?* — produced **M7, `float(self.target)`**: the arbiter's site *target* instead of the sum of what actually stands on the wire. **−11 bytes, and it survives all 20 files on untouched `release/1.0` @ `f72c48d`** (verified, full `mutation_run.sh`), because every fixture in the repository was built where the two are the same number. They separate when a setpoint device's reported range is narrower than its `max_w`: the `device` P_SAFETY claim is filed **per actuator**, so it bounds each unit's **share** and never the site target, and `clamp_to_rating()` clips again on the way out. On units rated 220/280 W whose devices report ±120/±130 W, the site asks for **−500 W** and **−250 W** stands. Both guards already say which one they mean — SAFETY-02 grades *"the command standing on the wire"*; `looks_frozen()` says `commanded_w` is *"the EMITTED setpoint … not the loop's raw wish"*. 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**: a latching, site-wide **false trip** on an install whose inverters simply report a narrower range than the operator typed. Closed by 9a's device-clamped counterfactual. ## Honest gaps * **A mutation of the SIGN CONVENTION of `standing` itself** — `stood = inv.last_write if … else inv.target` at `main.py:1556`, a different line — is **not** in this ledger. 9a'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.** `RAIL_FRAC`, `rail_ceiling_w()`'s device-bound conjunction and `rail_unreachable()` are `test_freeze.py`'s subject and are untouched. * **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 is not re-graded. The **gate** is shipped, which is the coordinate this card is about. * **The M10 false red could not be named.** See above. * **Everything about inverter B is simulated and labelled so.** No number in this PR or in the fixture is a measurement of a device that does not yet exist. ## Suite `tools/mutation_run.sh` on the committed tree, fresh copy, bytecode purged, mounted `:ro`: **20/20 PASS**. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
admin force-pushed SAFETY-38 from cdc7f7ba30 to ce46a4b76d 2026-09-05 17:51:16 +02:00 Compare
Author
Owner

Round 2 — ce46a4b, rebased onto release/1.0 @ 86eaef2

Both blockers closed. No app/ change in either — the shipped sum is correct; both holes were in the test and in the write-up.

⚠️ The suite is 21 files now, not 20. The rebase onto 86eaef2 brought in TEL-07's test_mqtt.py. Every count below is out of 21.


Blocker 1 — the zero-byte survivor at the call site

Round 1 pinned the value site_standing and never pinned where it goes. Reproduced first, on the round-1 tree:

main.py:1620  evaluate(site_standing, self.grid, ...)  ->  evaluate(self.grid, site_standing, ...)
byte delta: 0   (234542 -> 234542)   [re-read from disk]
full suite: 21/21 PASS

The cost, from part 9a's own output under the mutation:

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

The site is standing at −500 W of charge into a +2500 W import and the latching, site-stopping banner says the exact opposite — which instrument read what, inverted, on the one line somebody woken by the alarm reads first.

Why the check missed it. It asserted f"{sum(_share38):.0f} W" in ...reason — that "-500 W" appears somewhere. Under the swap it still does: in the meter slot. A substring test grades presence, never arrangement, and round 1's 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.

Fix, one line, no app/ change — 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 on the fixed tree: the swap is red in test_watchdog.py on exactly that one check, and green in the other twenty files.


Blocker 2 — the M3 attribution, corrected

You were right, and I re-measured rather than take it. On release/1.0 @ 86eaef2 carrying only the [0] edit (+4 B, re-read from disk, bytecode purged):

measurement result
full 21-file suite 21/21 PASS — the mutation SURVIVES
test_peakguard.py, 5 isolated runs PASS PASS PASS PASS PASS

My five isolated runs did not reproduce the flake; yours did (PASS PASS PASS FAIL PASS). Same verdict — a survivor — and I've written both down rather than reporting your numbers as mine.

The mechanism, read off 12j's own enumeration rather than argued. 12j taints names bound to roster values and lists accumulated in a loop. standing is neither: standing = {} at main.py:1554, filled by subscript assignment standing[inv.name] = stood at 1558. A subscript store is not a binding 12j follows, so standing never enters the taint set and sum(standing.values()) is not a roster site. Run 12j on the mutated tree and it enumerates:

main.py:1553  Controller.cycle   len()      self.inverters
main.py:1555  Controller.cycle   for stmt   self.inverters
main.py:1690  Controller.cycle   any()      self.inverters

— and not 1599. No shape pin reaches this line.

Coverage: part 9a and 9b already kill it (4 checks red). The [0] case is now named in the check text instead of being an incidental consequence.

Corrected in all four places it was committed — DECISIONS §1 table, DECISIONS §4, CHANGELOG.md, and the part 9 header, which previously instructed future authors not to re-cover it. Each says plainly that it was measured once, that the run hit CI-06's ~20% flake, and that the red was recorded as a kill.


Ledger, re-run independently on 86eaef2

Not carried forward from round 1's report. Fresh copy per mutation, mounted :ro, purged by tools/mutation_run.sh, edit re-read from disk, byte delta printed.

# mutation Δ B verdict
M1–M10 the ten from round 1 +24/+9/+4/+9/+16/+1/−11/+6/+6/+7 all 10 RED
M11 argument swap at main.py:1620 0 round-1 SURVIVOR → RED
W1 "sign_site": SCOPE_SITE → SCOPE_UNIT 0 RED — part 9a is one of the two killers
W2 "circulation": SCOPE_SITE → SCOPE_UNIT 0 RED — but entirely on pre-existing checks

M1/M2/M4 were re-run in the exact wrapped form the table records so the deltas are the table's own (+24/+9/+9). Control: 21/21 green before and after.

app/main.py is 234 542 B at 86eaef2, 2 022 more than the f72c48d round 1 measured on; the ledger now records the base it was measured against.


Honest gaps — what I did NOT close (DECISIONS §6a)

  • SAFETY-31's circ_wd detector itself — threshold, hold, circulating_w()'s roster reduction. Only its claim scope was mutated (W2), and that kill belongs to checks this card did not write. SAFETY-38 adds nothing to circulation and does not claim it.
  • The other eight CLAIM_SCOPE entries — loop, peak, arbitrage, safety, device, supervised, failsafe, maintenance. Ten entries; two mutated.
  • actuator_of()'s body and the n_units > 1 guard on the sign_site append — not mutated.
  • The ten ledger mutations were re-graded against test_watchdog.py only. The "green in the other twenty files" half is re-verified on 86eaef2 for M3 and M11 (both full 21-file runs) and otherwise carried forward from round 1 on f72c48d. Weaker than round 1 claimed, and written down as such.
  • CI-06's flake is not fixed here and is not this card's subject — only characterised far enough to overturn the false kill it produced.

MUTATION.md — habit 14

Recorded as the tenth instance of the family, and note where it was found: inside the fix written to close the ninth — the second time that has happened in that list.

14. An in test against a formatted sentence grades PRESENCE, never ARRANGEMENT — so assert the ORDERED PAIR. Two values that both appear in one sentence can always be transposed by an edit that costs nothing, and that no byte delta, no fingerprint and no shape pin can see.


CI green on checks / unit (pull_request). Not merging — over to you.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU

## Round 2 — `ce46a4b`, rebased onto `release/1.0` @ `86eaef2` Both blockers closed. **No `app/` change in either** — the shipped `sum` is correct; both holes were in the test and in the write-up. ⚠️ **The suite is 21 files now, not 20.** The rebase onto `86eaef2` brought in TEL-07's `test_mqtt.py`. Every count below is out of 21. --- ### Blocker 1 — the zero-byte survivor at the call site Round 1 pinned the **value** `site_standing` and never pinned **where it goes**. Reproduced first, on the round-1 tree: ``` main.py:1620 evaluate(site_standing, self.grid, ...) -> evaluate(self.grid, site_standing, ...) byte delta: 0 (234542 -> 234542) [re-read from disk] full suite: 21/21 PASS ``` The cost, from part 9a's own output under the mutation: ``` SIGN_DISAGREEMENT: commanded +2500 W against a meter reading -500 W for 2 s continuously ``` The site is standing at **−500 W of charge into a +2500 W import** and the latching, site-stopping banner says the exact opposite — which instrument read what, inverted, on the one line somebody woken by the alarm reads first. **Why the check missed it.** It asserted `f"{sum(_share38):.0f} W" in ...reason` — that `"-500 W"` appears **somewhere**. Under the swap it still does: **in the meter slot**. A substring test grades *presence*, never *arrangement*, and round 1's 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**. **Fix**, one line, no `app/` change — assert the **ordered pair**: ```python f"commanded {sum(_share38):+.0f} W against a meter reading " f"{_mid38['grid']:+.0f} W" in _c38.site_sign_wd.reason ``` Measured on the fixed tree: the swap is **red in `test_watchdog.py` on exactly that one check, and green in the other twenty files**. --- ### Blocker 2 — the M3 attribution, corrected You were right, and I re-measured rather than take it. On `release/1.0` @ `86eaef2` carrying **only** the `[0]` edit (+4 B, re-read from disk, bytecode purged): | measurement | result | |---|---| | full 21-file suite | **21/21 PASS — the mutation SURVIVES** | | `test_peakguard.py`, 5 isolated runs | **PASS PASS PASS PASS PASS** | My five isolated runs did **not** reproduce the flake; yours did (PASS PASS PASS FAIL PASS). Same verdict — a survivor — and I've written both down rather than reporting your numbers as mine. **The mechanism, read off 12j's own enumeration rather than argued.** 12j taints names *bound* to roster values and lists *accumulated* in a loop. `standing` is neither: `standing = {}` at `main.py:1554`, filled by **subscript assignment** `standing[inv.name] = stood` at 1558. A subscript store is not a binding 12j follows, so `standing` never enters the taint set and `sum(standing.values())` is not a roster site. Run 12j on the mutated tree and it enumerates: ``` main.py:1553 Controller.cycle len() self.inverters main.py:1555 Controller.cycle for stmt self.inverters main.py:1690 Controller.cycle any() self.inverters ``` — and **not 1599**. No shape pin reaches this line. **Coverage:** part 9a and 9b already kill it (4 checks red). The `[0]` case is now **named** in the check text instead of being an incidental consequence. **Corrected in all four places it was committed** — DECISIONS §1 table, DECISIONS §4, `CHANGELOG.md`, and the part 9 header, which previously *instructed future authors not to re-cover it*. Each says plainly that it was measured once, that the run hit CI-06's ~20% flake, and that the red was recorded as a kill. --- ### Ledger, re-run independently on `86eaef2` Not carried forward from round 1's report. Fresh copy per mutation, mounted `:ro`, purged by `tools/mutation_run.sh`, edit re-read from disk, byte delta printed. | # | mutation | Δ B | verdict | |---|---|---|---| | M1–M10 | the ten from round 1 | +24/+9/+4/+9/+16/+1/−11/+6/+6/+7 | **all 10 RED** | | **M11** | argument **swap** at main.py:1620 | **0** | round-1 **SURVIVOR → RED** | | **W1** | `"sign_site": SCOPE_SITE → SCOPE_UNIT` | **0** | **RED** — part 9a is one of the two killers | | **W2** | `"circulation": SCOPE_SITE → SCOPE_UNIT` | **0** | **RED** — but *entirely* on pre-existing checks | M1/M2/M4 were re-run in the exact wrapped form the table records so the deltas are the table's own (+24/+9/+9). Control: **21/21 green** before and after. `app/main.py` is **234 542 B** at `86eaef2`, 2 022 more than the `f72c48d` round 1 measured on; the ledger now records the base it was measured against. --- ### Honest gaps — what I did NOT close (DECISIONS §6a) * **SAFETY-31's `circ_wd` detector itself** — threshold, hold, `circulating_w()`'s roster reduction. Only its **claim scope** was mutated (W2), and **that kill belongs to checks this card did not write.** SAFETY-38 adds nothing to circulation and does not claim it. * **The other eight `CLAIM_SCOPE` entries** — `loop`, `peak`, `arbitrage`, `safety`, `device`, `supervised`, `failsafe`, `maintenance`. Ten entries; two mutated. * **`actuator_of()`'s body** and the `n_units > 1` guard on the `sign_site` append — not mutated. * **The ten ledger mutations were re-graded against `test_watchdog.py` only.** The *"green in the other twenty files"* half is re-verified on `86eaef2` for **M3** and **M11** (both full 21-file runs) and otherwise carried forward from round 1 on `f72c48d`. Weaker than round 1 claimed, and written down as such. * **CI-06's flake is not fixed here** and is not this card's subject — only characterised far enough to overturn the false kill it produced. --- ### MUTATION.md — habit 14 Recorded as the **tenth** instance of the family, and note where it was found: **inside the fix written to close the ninth** — the second time that has happened in that list. > **14. An `in` test against a formatted sentence grades PRESENCE, never ARRANGEMENT — so assert the ORDERED PAIR.** Two values that both appear in one sentence can always be transposed by an edit that costs **nothing**, and that no byte delta, no fingerprint and no shape pin can see. --- CI green on `checks / unit (pull_request)`. Not merging — over to you. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
admin force-pushed SAFETY-38 from ce46a4b76d to 97b59438dc 2026-09-05 20:38:52 +02:00 Compare
admin added 4 commits 2026-09-05 20:45:23 +02:00
`app/main.py:1598`'s `site_standing = float(sum(standing.values()))` is the
first argument of SAFETY-30's latching, site-stopping sign detector AND the
`commanded_w` handed to SAFETY-18's `looks_frozen()`. It was pinned by nothing.
Coverage only: not one byte of `app/` moves - the shipped `sum` is correct.

WHY THE SUITE COULD NOT SEE IT. `_sign_site`, the only N>=2 site fixture in the
repository, runs shares of about -2500 W EACH - every one individually over its
320 W gate - so sum, mean and max(key=abs) all agree on it. Its values happen to
be equal, and a check comparing two things that happen to be equal cannot see
the difference it names. MUTATION.md habit 4, ninth instance, inside the site
half of the card that names the rule in its own text. At N=1 it is arithmetic
rather than a fixture defect: sum == mean == max == the one share, identically.

THE FIXTURE (test_watchdog.py part 9). Two SIMULATED units rated 220 W and
280 W, both obeying exactly, inverted gain, house importing 2000 W: the site
commands -500 W against a +2500 W meter and NO SINGLE SHARE clears the shipped
300 W gate. Four candidate reductions, four distinct answers, one over the gate
- sum -500, mean -250, max(key=abs) -280, [0] -220 - asserted as a precondition.
The asymmetry is load-bearing, and the smaller unit is first so [0] stays
distinct from max(key=abs). Gate shipped; only the hold is shortened.

BOTH CONSUMERS. 9b drives the same discrimination at SAFETY-18's threshold: the
site rail is 0.95 x 500 = 475 W, so the sum rails it while 250, 280 and 220 do
not. Shipped: BLIND_FROZEN, floor 0 W. Under both surviving mutations: not blind
at all, no floor filed - the battery may keep importing through a quarter whose
billed average is unobservable. The open quarter is backfilled with the stuck
reading so the mutant cannot reach the same action by the coverage route and
mask the difference. 9c asserts the N=1 identity on a real one-inverter tree.

SAFETY-32 already closes `list(standing.values())[0]` structurally and this card
does not claim it. Its disclosed blind spot - semantics are invisible to a shape
pin, permanently - is why the mean and max(key=abs) needed a behavioural set.

AND THE SEARCH FOR THE NEXT SURVIVOR FOUND ONE. `float(self.target)` - the
arbiter's site target instead of the sum of what stands on the wire - is -11 B
and survives all 20 files on untouched release/1.0 @ f72c48d, because every
fixture that existed was built where the two are the same number. They separate
when a device's reported range is narrower than its max_w: the `device` claim is
filed per actuator and never bounds the site target. Closed here by a
counterfactual that asks for -500 W while -250 W stands.

10 mutations of that one line, each on a fresh full-repo copy mounted :ro,
purged by tools/mutation_run.sh, the edit re-read from disk and the byte delta
printed. Ledger and honest gaps in DECISIONS/SAFETY-38-site-standing-pin.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
All ten mutations of `site_standing` are red in test_watchdog.py and in nothing
else - the other 19 files are green under every one of them, which is the
measurement of how unpinned the line was.

One exception, recorded rather than dropped: in the batch run M10
(`sorted(standing.values())[-1]`) also reported FAIL test_peakguard.py. Re-run
in isolation three times and once more through tools/mutation_run.sh - four
clean passes. The batch script had discarded the failure detail so the check
cannot be named; what can be said is that it did not reproduce in four attempts
and that no claim in this card rests on it. That is CI-06's file behaving the
way CI-06 records.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
Two blockers, one of them mine to fix and one a mis-measurement to undo. No
`app/` change in either: the shipped `sum` is correct.

1. A ZERO-BYTE SURVIVOR AT THE CALL SITE ONE LINE BELOW THE PINNED VALUE.
   Round 1 pinned `site_standing` and never pinned WHERE IT GOES. Swapping the
   detector's first two arguments at main.py:1620 -
   `evaluate(site_standing, self.grid, ...)` -> `evaluate(self.grid, site_standing, ...)`
   - is a ZERO-BYTE edit and left the round-1 tree fully green. The cost is the
   banner: the site stands at -500 W of charge into a +2500 W import and the
   LATCHING, SITE-STOPPING alarm reads "commanded +2500 W against a meter
   reading -500 W" - which instrument read what, inverted, on the one line
   somebody woken by it reads first (watchdog.py:658-665).

   Round 1's check could not reach it. It asserted `"-500 W"` appears SOMEWHERE
   in the sentence; under the swap it still does, IN THE METER SLOT. A substring
   test grades PRESENCE, never ARRANGEMENT. It now asserts the ORDERED PAIR:
   `f"commanded {sum:+.0f} W against a meter reading {grid:+.0f} W"`. Measured:
   red in test_watchdog.py on that one named check, green in the other twenty.

2. THE M3 ATTRIBUTION WAS FALSE, AND HOW IT BECAME FALSE IS THE USEFUL PART.
   Round 1 recorded `list(standing.values())[0]` as closed by SAFETY-32's
   package-wide pin and told future authors NOT TO RE-COVER IT. Re-measured on
   release/1.0 @ 86eaef2 carrying only that edit: the full 21-file suite is
   GREEN and test_peakguard.py is PASS PASS PASS PASS PASS in isolation. THE
   MUTATION SURVIVES. It was originally measured once, that run hit CI-06's
   ~20% flake, and the red was recorded as a kill.

   The mechanism, read off 12j's own enumeration rather than argued: 12j taints
   names BOUND to roster values and lists ACCUMULATED in a loop. `standing` is a
   dict filled by SUBSCRIPT assignment (main.py:1554/1558), so it never enters
   the taint set and 1599 is not a roster site. On the mutated tree 12j lists
   main.py:1553, 1555 and 1690 - not 1599. Part 9a and 9b close it.

   Corrected in all four places it was committed: DECISIONS section 1 and
   section 4, CHANGELOG, and the part 9 header that carried the instruction.

MEASUREMENT, all on 86eaef2, fresh copy per mutation, mounted :ro, bytecode
purged by tools/mutation_run.sh, edit re-read from disk, byte delta printed:
  * M1-M10 re-run INDEPENDENTLY rather than carried forward - all 10 red, at
    the deltas the table records (M1/M2/M4 re-run in the exact wrapped form).
  * M11, the argument swap: 0 bytes, red on one check in one file.
  * W1 `"sign_site": SCOPE_SITE -> SCOPE_UNIT`: 0 bytes, red; part 9a is one of
    the two killers.
  * W2 `"circulation": SCOPE_SITE -> SCOPE_UNIT`: 0 bytes, red - but ENTIRELY
    on pre-existing checks. This card adds nothing to circulation and does not
    claim it.
Control: 21/21 green before and after. The suite is 21 files, not 20 - TEL-07
added test_mqtt.py in the rebase onto 86eaef2.

DECISIONS section 6a enumerates what was NOT closed: SAFETY-31's circ_wd
detector itself, the other eight CLAIM_SCOPE entries, actuator_of()'s body, the
n_units > 1 guard, and the fact that the "green in the other twenty files" half
of the ledger was re-verified on 86eaef2 only for M3 and M11.

MUTATION.md gains habit 14 - assert the ordered pair, not the substring - and
records this as the tenth instance of the family, found INSIDE THE FIX WRITTEN
TO CLOSE THE NINTH, for the second time in that list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
Round 2 pinned the operand order of `SignWatchdog.evaluate()` at main.py:1620 -
the SITE detector. Part 9c asserts that detector is never armed at a roster of
one, so on every install this add-on has, the PER-UNIT banner is the only
sign-disagreement sentence that can ever be printed, and its call site is a
different line. main.py:1560 was left unpinned.

1. THE SAME ZERO-BYTE DEFECT ONE CALL SITE OVER.
   `evaluate(stood, against, ...)` -> `evaluate(against, stood, ...)`:
   app/main.py 234 542 bytes before and after, delta 0, fingerprint
   61aa413f... -> e57e6026..., bytecode purged, tree mounted :ro, line re-read
   from disk. On the round-2 tree the FULL 21-FILE SUITE PASSES.

   Nothing behavioural could ever have caught it. `watchdog.disagrees()` is
   SYMMETRIC in its first two arguments - `commanded_w * against_w < 0` with an
   abs() gate on both - so the mutant takes identical actions on every input.
   Not "no fixture happens to separate them": no fixture CAN.

   What moves is the sentence. Measured by diffing the suite's own printed
   banners between the round-3 tree and the same tree carrying only this edit:
   9 emitted banner lines invert, in 3 distinct sentences.

     shipped  commanded -2000 W against a meter reading +2000 W
     swapped  commanded +2000 W against a meter reading -2000 W
     shipped  commanded +1000 W against its own measured power -1000 W
     swapped  commanded -1000 W against its own measured power +1000 W
     shipped  commanded -2000 W against a meter reading +4000 W   (the N=1 arm)
     swapped  commanded +4000 W against a meter reading -2000 W

   Persisted to `sign_reason` and re-read after the restart the latch exists to
   survive; also the action log, `_LOG.error` and `status_payload()`'s headline.

   CLOSED by the ORDERED PAIR on both per-unit banners, 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
   compare the swap against itself:
     - part 3, the N>=2 trip: "commanded {stood:+.0f} W against its own
       measured power {meas:+.0f} W", plus the persisted namespaced copy;
     - part 7, the N=1 controller-driven trip: "commanded {stood:+.0f} W
       against a meter reading {grid:+.0f} W", plus the namespaced key AND the
       flat rollback mirror;
     - a PRECONDITION on each that the two operands are different numbers of
       opposite sign - without it a transposition produces the same string and
       the check is decoration.

   Measured after the fix: RED on four named checks in test_watchdog.py, GREEN
   in the other twenty files. M11 (the site swap) re-measured on this tree:
   still red. Round 1's and round 2's kills are intact.

2. A FALSE STATEMENT ROUND 2 INTRODUCED, AND IT WAS WORSE THAN REPORTED.
   Section 4 said "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 vaguer wording was harmless; naming three made it specific and wrong.

   Re-measured mutation by mutation on clean release/1.0 @ bbc903b, each on a
   fresh copy, bytecode purged, byte delta and fingerprint printed, FULL 21-FILE
   SUITE each time:

     M1  +24 B  fp 362374cf...  21/21 PASS - SURVIVOR
     M2   +9 B  fp 86573e74...  21/21 PASS - SURVIVOR
     M3   +4 B  fp 59fa8815...  21/21 PASS - SURVIVOR
     M4   +9 B  fp 43d852be...  21/21 PASS - SURVIVOR
     M5  +16 B  fp 58d26de8...  RED  test_watchdog.py, 3 checks
     M6   +1 B  fp 407ddb5f...  RED  test_watchdog.py, 3 checks
     M7  -11 B  fp 2a635a4e...  21/21 PASS - SURVIVOR
     M8   +6 B  fp b21eed89...  21/21 PASS - SURVIVOR
     M9   +6 B  fp 0973d3ca...  21/21 PASS - SURVIVOR
     M10  +7 B  fp 0e7b7a4f...  21/21 PASS - SURVIVOR

   EIGHT survived, not three - and not the four a reviewer proposed either.
   The corrected sentence is in DECISIONS section 4.

   AND THE TWO THAT WERE CAUGHT MAKE THE CARD'S CASE. M5 (`sum(abs ...)`) and
   M6 (`-sum`) are the only two entries that reverse the SIGN. Every other one
   changes only the 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, so sum / mean / max / min / sorted[-1] / [0] / self.target / half /
   double land on -2500, -2500, -2500, -2500, -2500, -2500, -2500, -1250 and
   -5000 W: nine numbers, ALL ON THE SAME SIDE OF THE SAME THRESHOLD. The
   pre-existing coverage could see a sign and nothing else.

3. THEN THE THING THAT WOULD HAVE CAUGHT BOTH: the SYMMETRIC-PREDICATE FAMILY,
   enumerated by machine rather than by hand for a third time.
   `tools/symmetric_message_scan.py` renames each parameter pair, erases every
   formatted string, canonicalises the commutative operators and iterates to a
   FIXPOINT - the symmetry is usually one call deep, and a single pass reports
   an EMPTY family on a tree whose answer is already known. A second census
   covers sentences the CALLER builds. Both print their own blind spots,
   including the one way the scan OVER-reports.

   It found one open survivor this card does not close and now discloses:
   rail_unreachable()'s dev_min/dev_max at main.py:3208 - 0 bytes, 21/21 green,
   a status-page NOT COVERED row - and twelve unpinned caller-built sentences,
   including SAFETY-31's `circ_reason`, which is the same shape and is also
   persisted and re-read after a restart. DECISIONS section 7 has the list, the
   pinned status of every member, and what the instrument cannot see.

No app/ code changes. MUTATION.md gains habit 15.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
admin force-pushed SAFETY-38 from 97b59438dc to 357cb4f07f 2026-09-05 20:45:23 +02:00 Compare
Author
Owner

Round 3 — both blockers closed, and the enumeration that would have caught both

Head 357cb4f. Rebased onto release/1.0 — first onto bbc903b
(SAFETY-41 + the 0.7.0 bump), and then again onto 9e42cec when CI-06 (#54)
merged mid-round; the only conflict was one ## Unreleased hunk in
CHANGELOG.md, resolved by keeping both entries.
Full 21-file suite on the pushed commit: 21/21 PASS, rc=0 — purged, fresh
copy, mounted :ro. No app/ code changes: app/main.py is byte- and
fingerprint-identical to release/1.0 (61aa413f…).

(The ten ledger re-measurements below were taken on bbc903b. CI-06 touches
test_peakguard.py and CHANGELOG.md only, neither of which grades
site_standing, so those verdicts stand on the tree they were taken on — noted
in §4 rather than silently carried.)

Round 2's work is intact and re-measured: the site arg-swap (M11) is still red
on this tree, the [0] case is still covered, and the corrected SAFETY-32
attribution stands in all four places.


BLOCKER 1 — the same zero-byte defect at main.py:1560 — CLOSED

evaluate(stood, against, …) → evaluate(against, stood, …).
app/main.py 234 542 bytes before and after, Δ 0, fingerprint
61aa413f… → e57e6026…, bytecode purged, tree mounted :ro, the line
re-read from disk. On the round-2 tree the full 21-file suite passes.

Nothing behavioural could ever have caught it, and that is structural:
watchdog.disagrees() is symmetric in its first two arguments —
commanded_w * against_w < 0 with an abs() gate on both — so the mutant takes
identical actions on every input. Not "no fixture happens to separate them":
no fixture can.

What moves is the sentence. Measured by diffing the suite's own printed banners
between this tree and the same tree carrying only that edit: 9 emitted banner
lines invert, in 3 distinct sentences.

shipped   commanded -2000 W against a meter reading +2000 W
swapped   commanded +2000 W against a meter reading -2000 W

shipped   commanded +1000 W against its own measured power -1000 W
swapped   commanded -1000 W against its own measured power +1000 W

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

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
— in the action log, in _LOG.error, in
status_payload()'s headline, and in sign_reason, which is persisted and read
back after the restart the latch exists to survive.

Fix (a), as preferred. 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 compare the swap against itself:

  • part 3, the N≥2 trip — f"commanded {stood:+.0f} W against its own measured power {meas:+.0f} W", plus the persisted namespaced copy;
  • 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;
  • a precondition on each that the two operands are different numbers of
    opposite sign — without it a transposition produces the same string and the
    check is decoration.

Measured after the fix: RED on four named checks in test_watchdog.py, GREEN
in the other twenty files.


BLOCKER 2 — the false ledger sentence — CORRECTED, and it was worse than reported

Round 2 wrote "three of these left the whole suite green — M1, M2 and M3 — and
the remaining seven were caught"
. Eight did. Re-measured one at a time on
clean release/1.0 @ bbc903b, fresh copy each, bytecode purged (0 remaining,
printed), byte delta and main.py fingerprint printed, :ro, and the
full 21-file suite every time rather than test_watchdog.py alone:

# Δ B fingerprint before part 9
M1 +24 362374cf… 21/21 PASS — SURVIVOR
M2 +9 86573e74… 21/21 PASS — SURVIVOR
M3 +4 59fa8815… 21/21 PASS — SURVIVOR
M4 +9 43d852be… 21/21 PASS — SURVIVOR
M5 +16 58d26de8… RED — test_watchdog.py, 3 checks
M6 +1 407ddb5f… RED — test_watchdog.py, 3 checks
M7 −11 2a635a4e… 21/21 PASS — SURVIVOR
M8 +6 b21eed89… 21/21 PASS — SURVIVOR
M9 +6 0973d3ca… 21/21 PASS — SURVIVOR
M10 +7 0e7b7a4f… 21/21 PASS — SURVIVOR

So it is not three-and-seven, and it is not the four-and-six proposed on the
card either — thank you for saying to re-verify rather than take it on trust.

And the two that ARE caught make the card's case rather than weakening it.
M5 (sum(abs …)) and M6 (-sum) are the only two entries that reverse the
sign. Every other one changes only the magnitude — and _sign_site, the
only N≥2 site fixture before this card, runs shares of ~−2500 W each against a
320 W gate, so sum, mean, max(key=abs), min(key=abs), sorted[-1], [0],
self.target, half and double land on −2500, −2500, −2500, −2500, −2500, −2500,
−2500, −1250 and −5000 W: nine numbers, all on the same side of the same
threshold.
The pre-existing coverage could see a sign and nothing else.


THEN THE THING THAT WOULD HAVE CAUGHT BOTH — tools/symmetric_message_scan.py

The family was enumerated by hand twice and was short both times, so it is now
computed (MUTATION.md habit 12). It renames each parameter pair, erases
every formatted string, canonicalises the commutative operators and iterates to
a fixpoint — 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
, which is what the first
version of the file did. A second census covers sentences the caller builds,
and every family member is printed with its call sites and their receivers,
because the unit of coverage is the call site and that is the whole lesson here.

Census A — 45 parameter pairs reach a sentence, 8 symmetric, 5 message-only.
Census B — 16 caller-built candidates, all zero-byte.

Two measured, open survivors this card does not close and now discloses:

  • A3 — rail_unreachable()'s dev_min/dev_max at main.py:3208:
    0 bytes, 21/21 green (61aa413f… → 2305b560…).
  • B1 — f"only {inv.dev_min}...{inv.dev_max} W" at main.py:3231:
    0 bytes, 21/21 green (61aa413f… → ac36a97b…).

Those are 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 — and neither operand order is asserted anywhere. Closing them
wants a fixture with asymmetric device bounds; every fixture here uses ±d,
so the transposition is invisible by construction, and that belongs to
test_freeze.py's card rather than this one.

Thirteen further census-B sites are unpinned at the source level and were
not mutated; that is weaker evidence and is written down as such. Two are
worth naming:

  • B6, SAFETY-31's circ_reason — same shape, also persisted and re-read
    after a restart, graded only by "CIRCULATING_POWER" in reason.
  • B12, arbiter.scope_violations() — test_arbiter.py:452 claims it
    "names the source and the actuator it does not belong on" and then asserts
    "peak" in scope_errors[0], which is still true under the swap.

Two members came back already pinned, which is the encouraging half:
static_tariff._raise_gap()'s (start, end) — measured, 0 bytes, red in
test_static_tariff.py on two named checks
, because the refusal is asserted
as the contiguous literal "22:00-23:00" — and watchdog.py:667's own
(cfg.min_w, cfg.hold_s) inside the (limits: …) clause, because the SAFETY-30
section asserts the site instance's whole sentence with ==. Full-string
equality is habit 14 taken to its limit.

The instrument prints its own blind spots on every run, including 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.


One CI-06 red, named rather than dropped

The A4 batch run 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"
— CI-06's mechanism 1
by name
. test_peakguard.py passes four for four in isolation on that same
tree. No claim here rests on it.

Sections: DECISIONS/SAFETY-38-site-standing-pin.md §4 (⚠️ the corrected
arithmetic), §4c (M12), §6b (round-3 open list), §7 (the family);
MUTATION.md habit 14 extended and new habit 15.

Not merged, as asked.

## Round 3 — both blockers closed, and the enumeration that would have caught both Head **`357cb4f`**. Rebased onto `release/1.0` — first onto `bbc903b` (SAFETY-41 + the 0.7.0 bump), and then again onto **`9e42cec`** when CI-06 (#54) merged mid-round; the only conflict was one `## Unreleased` hunk in `CHANGELOG.md`, resolved by keeping **both** entries. **Full 21-file suite on the pushed commit: 21/21 PASS, rc=0** — purged, fresh copy, mounted `:ro`. No `app/` code changes: `app/main.py` is byte- and fingerprint-identical to `release/1.0` (`61aa413f…`). *(The ten ledger re-measurements below were taken on `bbc903b`. CI-06 touches `test_peakguard.py` and `CHANGELOG.md` only, neither of which grades `site_standing`, so those verdicts stand on the tree they were taken on — noted in §4 rather than silently carried.)* Round 2's work is intact and re-measured: the site arg-swap (M11) is still red on this tree, the `[0]` case is still covered, and the corrected SAFETY-32 attribution stands in all four places. --- ### BLOCKER 1 — the same zero-byte defect at `main.py:1560` — CLOSED `evaluate(stood, against, …)` → `evaluate(against, stood, …)`. `app/main.py` **234 542 bytes before and after, Δ 0**, fingerprint `61aa413f…` → `e57e6026…`, bytecode purged, tree mounted `:ro`, the line re-read from disk. **On the round-2 tree the full 21-file suite passes.** Nothing behavioural could ever have caught it, and that is structural: `watchdog.disagrees()` is **symmetric** in its first two arguments — `commanded_w * against_w < 0` with an `abs()` gate on both — so the mutant takes **identical actions on every input**. Not "no fixture happens to separate them": no fixture *can*. What moves is the sentence. Measured by diffing the suite's own printed banners between this tree and the same tree carrying only that edit: **9 emitted banner lines invert, in 3 distinct sentences.** ``` shipped commanded -2000 W against a meter reading +2000 W swapped commanded +2000 W against a meter reading -2000 W shipped commanded +1000 W against its own measured power -1000 W swapped commanded -1000 W against its own measured power +1000 W shipped commanded -2000 W against a meter reading +4000 W (the N=1 arm) swapped commanded +4000 W against a meter reading -2000 W ``` 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** — in the action log, in `_LOG.error`, in `status_payload()`'s headline, and in `sign_reason`, which is persisted and read back after the restart the latch exists to survive. **Fix (a), as preferred.** 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 compare the swap against itself: * **part 3**, the N≥2 trip — `f"commanded {stood:+.0f} W against its own measured power {meas:+.0f} W"`, plus the persisted namespaced copy; * **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; * a **precondition** on each that the two operands are different numbers of opposite sign — without it a transposition produces the same string and the check is decoration. **Measured after the fix: RED on four named checks in `test_watchdog.py`, GREEN in the other twenty files.** --- ### BLOCKER 2 — the false ledger sentence — CORRECTED, and it was worse than reported Round 2 wrote *"three of these left the whole suite green — M1, M2 and M3 — and the remaining seven were caught"*. **Eight did.** Re-measured one at a time on clean `release/1.0` @ `bbc903b`, fresh copy each, bytecode purged (0 remaining, printed), byte delta **and** `main.py` fingerprint printed, `:ro`, and the **full 21-file suite** every time rather than `test_watchdog.py` alone: | # | Δ B | fingerprint | before part 9 | |---|---|---|---| | M1 | +24 | `362374cf…` | **21/21 PASS — SURVIVOR** | | M2 | +9 | `86573e74…` | **21/21 PASS — SURVIVOR** | | M3 | +4 | `59fa8815…` | **21/21 PASS — SURVIVOR** | | M4 | +9 | `43d852be…` | **21/21 PASS — SURVIVOR** | | M5 | +16 | `58d26de8…` | RED — `test_watchdog.py`, 3 checks | | M6 | +1 | `407ddb5f…` | RED — `test_watchdog.py`, 3 checks | | M7 | **−11** | `2a635a4e…` | **21/21 PASS — SURVIVOR** | | M8 | +6 | `b21eed89…` | **21/21 PASS — SURVIVOR** | | M9 | +6 | `0973d3ca…` | **21/21 PASS — SURVIVOR** | | M10 | +7 | `0e7b7a4f…` | **21/21 PASS — SURVIVOR** | So it is not three-and-seven, and it is not the four-and-six proposed on the card either — thank you for saying to re-verify rather than take it on trust. **And the two that ARE caught make the card's case rather than weakening it.** M5 (`sum(abs …)`) and M6 (`-sum`) are the only two entries that reverse the **sign**. Every other one changes only the **magnitude** — and `_sign_site`, the only N≥2 site fixture before this card, runs shares of ~−2500 W each against a 320 W gate, so `sum`, mean, `max(key=abs)`, `min(key=abs)`, `sorted[-1]`, `[0]`, `self.target`, half and double land on −2500, −2500, −2500, −2500, −2500, −2500, −2500, −1250 and −5000 W: **nine numbers, all on the same side of the same threshold.** The pre-existing coverage could see a sign and nothing else. --- ### THEN THE THING THAT WOULD HAVE CAUGHT BOTH — `tools/symmetric_message_scan.py` The family was enumerated by hand twice and was short both times, so it is now **computed** (MUTATION.md habit 12). It renames each parameter pair, erases every formatted string, canonicalises the commutative operators and iterates to a **fixpoint** — 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**, which is what the first version of the file did. A second census covers sentences the **caller** builds, and every family member is printed **with its call sites and their receivers**, because the unit of coverage is the call site and that is the whole lesson here. **Census A** — 45 parameter pairs reach a sentence, 8 symmetric, 5 message-only. **Census B** — 16 caller-built candidates, all zero-byte. Two **measured, open survivors this card does not close and now discloses**: * **A3** — `rail_unreachable()`'s `dev_min`/`dev_max` at `main.py:3208`: **0 bytes, 21/21 green** (`61aa413f…` → `2305b560…`). * **B1** — `f"only {inv.dev_min}...{inv.dev_max} W"` at `main.py:3231`: **0 bytes, 21/21 green** (`61aa413f…` → `ac36a97b…`). Those are 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 — and neither operand order is asserted anywhere. Closing them wants a fixture with **asymmetric** device bounds; every fixture here uses `±d`, so the transposition is invisible by construction, and that belongs to `test_freeze.py`'s card rather than this one. Thirteen further census-B sites are unpinned at the **source level** and were **not** mutated; that is weaker evidence and is written down as such. Two are worth naming: * **B6**, SAFETY-31's `circ_reason` — same shape, also persisted and re-read after a restart, graded only by `"CIRCULATING_POWER" in reason`. * **B12**, `arbiter.scope_violations()` — `test_arbiter.py:452` *claims* it "names the source and the actuator it does not belong on" and then asserts `"peak" in scope_errors[0]`, which is still true under the swap. Two members came back **already pinned**, which is the encouraging half: `static_tariff._raise_gap()`'s `(start, end)` — measured, **0 bytes, red in `test_static_tariff.py` on two named checks**, because the refusal is asserted as the contiguous literal `"22:00-23:00"` — and `watchdog.py:667`'s own `(cfg.min_w, cfg.hold_s)` inside the `(limits: …)` clause, because the SAFETY-30 section asserts the site instance's **whole sentence with `==`**. Full-string equality is habit 14 taken to its limit. The instrument prints its own blind spots on every run, including 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. --- ### One CI-06 red, named rather than dropped The A4 batch run 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"* — **CI-06's mechanism 1 by name**. `test_peakguard.py` passes **four for four** in isolation on that same tree. No claim here rests on it. Sections: `DECISIONS/SAFETY-38-site-standing-pin.md` §4 (⚠️ the corrected arithmetic), §4c (M12), §6b (round-3 open list), **§7** (the family); `MUTATION.md` habit 14 extended and new **habit 15**. Not merged, as asked.
admin added 1 commit 2026-09-05 22:38:47 +02:00
The scanner's two censuses between them scored ONE syntactic sink and called it
the family. Every one of census B's 16 members was a sentence that is APPENDED,
ASSIGNED to a `reason`-shaped name, or passed as a LOG ARGUMENT. Not one was
RETURNED - and 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 was invisible to both. That is the whole describe() / explain() /
reason-builder family, which is where this repository actually keeps its
operator sentences. Worse than the gap: it was in NONE of the five blind spots
the tool printed about itself.

THE SCANNER, WIDENED - and it rediscovered both members without being told:

  * CENSUS C, the return sink, resolved through the returning method's CALL
    SITES so a returned KEY (store.unit_key, mqtt.unit_state_key - MAINT-05's
    card) is not mistaken for a returned SENTENCE. 53 returned pairs, 39
    reaching an operator, 16 of those sharing a format spec.
  * A sentence handed to a constructor as `reason=` or placed under a "text"
    dict key is a MESSAGE. That one line of the sink walker was hiding SIX more
    caller-built members, census B 16 -> 22.
  * Every census B and C row now prints whether the two interpolations share a
    FORMAT SPEC - the difference between a transposition that is silent and one
    that merely looks broken.
  * The blind-spot list is now TWO lists: what it scans (printed from the
    constants, so they cannot drift) and WHAT IT DOES NOT SCAN AT ALL, largest
    hole first - `%s`-style lazy logging, MEASURED at 31 calls across 8 modules
    and each one listed, not asserted.

CLOSED, both zero-byte survivors of a green 21-file suite:

  * clamp.py:253 ClampResult.describe(), requested_w <-> applied_w. No
    test_*.py mentioned describe() at all. It is the SS15 action-log row
    (main.py:1366) AND the ClampWatchdog notification reason (main.py:2452) -
    the alarm SAFETY-40 and SAFETY-41 are about. Under the swap the guard
    reports a setpoint clamped UPWARD, to +9999 W by a bound whose rating the
    same sentence gives as 2000 W. Pinned on the ORDERED PAIR on the contiguous
    literal at BOTH consumers, operands read off ctl.target and
    hass.writes[-1] - what the loop asked and what the device was sent - not
    off the ClampResult the swap lives in. 4 checks red.
  * peakguard.py:493 PeakDecision.explain(), projected_w <-> ceiling_w. "peak
    guard idle" was in no test_*.py. The row then reads "projects 5013 W under
    a 2300 W ceiling" - 2713 W OVER - on a row whose own words say idle and
    whose own flag says ok. Pinned on the decision and on the shipped status
    row. 3 checks red.
  * test_arbiter.py's scope-violation check, whose CLAIM was wider than its
    ASSERTION. Measured this round rather than left at source level: 0 bytes,
    21/21 green before, red now. 2 checks.

DISCLOSED, NOT CLOSED - 11 measured zero-byte survivors, each with its file
fingerprint and its 21/21 verdict, in the decision doc SS7e-SS7g. Three in
allocator.py's UnitPlan.explain(), three across arbiter.py's two explain()s,
two in tariff.py, two in arbitrage.py's trade reasons, one in main.py's
SoC-floor notification. An enumerated list with honest gaps beats a fifth round
finding a fifth instance.

CORRECTIONS THIS ROUND OWES:

  * The idle sentence does NOT reach main.py:3299/3302 - those are the
    floor-limited branch, where explain() returns self.reason before the idle
    literal is built. It reaches main.py:3313 and, on the release edge, 1951.
    1951 is disclosed unpinned.
  * SS4's "Every one of the ten is red" sat under a twelve-row table: it is the
    ten site_standing entries.
  * SS6b said "weaker evidence than A1-A3"; A4 was measured, so SS7d's
    "A1-A4 and B1" is the right list and SS6b now says it.
  * AND THE PROBE THAT GRADES THESE MUTATIONS WAS THE DEFECT, ONCE. Its first
    cut numbered the operands BY ORDER OF APPEARANCE, so after a transposition
    the other leaf took the same number and all thirteen mutant renders came
    back byte-identical - which reads exactly like "the edit is inert". Keying
    the value to the NAME fixed it. Habit 4, in the measuring instrument:
    MUTATION.md habit 16 is the new entry, and the count there is corrected to
    eleven rather than inflated.

14 mutations measured: fresh full-repo copy each, mounted :ro, bytecode purged
and the purge COUNT printed, the edit re-read from disk, length and sha256
printed on both sides, and every one required to change the EMITTED SENTENCE
and not merely the bytes. 12 survived the pre-round suite, 1 was already
caught, 3 are caught now. Full 21-file suite green on the final tree.

NOT ONE BYTE OF app/ MOVES. This card changes coverage only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
Author
Owner

Round 4 — the blocker: the enumeration covered one syntactic sink

Confirmed, and the diagnosis was exactly right. Every one of census B's 16 members was a sentence that is appended, assigned to a reason-shaped name, or passed as a log argument. None was RETURNED, and 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 the two. That is the whole describe() / explain() / reason-builder family, which is where this codebase actually keeps its operator sentences. And it was in none of the five blind spots the tool printed about itself.

No app/ file changes. git diff --stat -- goodwe_controller/app/ is empty.

1. The scanner, widened — and it rediscovered both members on its own

I did not hand-add the two. I extended the sink model and re-ran:

  • Census C — the return sink, resolved through the returning method's call sites, so a returned key (store.unit_key, mqtt.unit_state_key — MAINT-05's card) is not mistaken for a returned sentence. 53 returned pairs, 39 reaching an operator, 16 of those sharing a format spec.
  • A sentence handed to a constructor as reason=, or placed under a "text" dict key, is a MESSAGE. That one line of the sink walker was hiding six more caller-built members — census B 16 → 22. arbitrage.py's two trade reasons were scored OTHER purely because they are ArbitrageDecision(…, reason=f"…").
  • Every census B and C row now prints whether the two interpolations share a FORMAT SPEC — the difference between a transposition that is silent (+.0f beside +.0f) and one that merely looks broken (.0f beside a bare str). That is what gap 4 was already about, without a column a reader could use.
  • _innermost() replaced for fn in walk(tree): for node in walk(fn), which classified every nested-function sentence once per enclosing def and hid the double visit behind a de-dup.

Both targets came back marked *** (reaches an operator and shares a format spec):

  clamp.py:253  describe()   verdict: MESSAGE
    *** self.requested_w   <->   self.applied_w
        format spec: '+.0f' / '+.0f'  -> SAME - a transposition is SILENT
  peakguard.py:493  explain()   verdict: MESSAGE
    *** self.projected_w   <->   self.ceiling_w
        format spec: '.0f' / '.0f'  -> SAME - a transposition is SILENT

2. Closed — three, each pinned on the ordered pair with a precondition

site surfaces pinned before after
(a) clamp.py:253 ClampResult.describe(), requested_w↔applied_w §15 action log main.py:1366 and ClampWatchdog reason main.py:2452 0 bytes, 21/21 green red, 4 checks (test_clamp.py 6d)
(b) peakguard.py:493 PeakDecision.explain(), projected_w↔ceiling_w the decision, and the status row main.py:3313 0 bytes, 21/21 green red, 3 checks (test_peakguard.py 13)
B12 arbiter.py:264 c.source↔c.actuator scope_errors 0 bytes, 21/21 green red, 2 checks (test_arbiter.py)

Round 3's pattern followed exactly: the ordered pair on the contiguous literal, plus a precondition that the two operands render differently — equal operands make a transposition produce the same string and the check decoration — plus a counterfactual asserting the transposed sentence is not present.

Operands are read off the plant, not off the object the swap lives in. For (a): ctl.target (what the loop asked) and hass.writes[-1] (what the device was sent), neither a ClampResult field. For (b): _S38_PROJ.projected_w off the Projection handed in, and quarter_ceiling_w(CAP_W, obs_hi)[0] off the shipped helper — neither a PeakDecision field. Grading against result.requested_w would compare the swap against itself.

The measured lie for (a): asked +9999 W → device saw +2000 W, so the transposed row reads "the computed setpoint +2000 W was clamped to +9999 W by the max_w bound (rating 2000 W)" — a clamp upward, past the rating printed four words later. For (b): "projects 5013 W under a 2300 W ceiling" — 2713 W over — on a row whose own words say idle and whose own flag says ok.

3. Disclosed, not closed — 11 measured zero-byte survivors

§7e–§7g of the decision doc, each with file fingerprint and 21/21 verdict: three in allocator.py's UnitPlan.explain(), three across arbiter.py's two explain()s (lo↔hi, lo_src↔hi_src, winner↔bound_by), two in tariff.py, two in arbitrage.py's trade reasons, one in main.py:2652's SoC-floor notification. allocator.py:162 is the one member the suite already caught — test_alloc.py asserts that whole literal — which says the habit works where it has been applied.

4. The tool now states what it does NOT scan

Two lists, not one. "What it scans" is printed from the constants themselves, so the doc and the code cannot drift. "What it does not scan at all" is ordered by size, and the largest hole is measured rather than asserted: %s-style lazy logging, 31 calls across 8 modules in app/, every one listed. Then .format()/%/+/.join(), sentences that leave through a dict or dataclass, yield, message attributes outside its own set, and everything outside app/. The five over/under-report gaps stay.

5. ⚠️ Two things I got wrong and one the ticket did

  • The ticket's line reference. (b) does not reach main.py:3299/3302 — those are the floor-limited branch, where explain() returns self.reason before the idle literal is built. It reaches main.py:3313 and, on the release edge, main.py:1951. The pin is on 3313; 1951 is disclosed unpinned — same call-site distinction §4c cost a round over.
  • The probe that grades these mutations was itself the defect. Its first cut numbered each interpolated operand by order of appearance, so after a transposition the other leaf took the same number and all thirteen mutant renders came back byte-identical — reading exactly like the edit is inert. Keying the value to the name fixed it. Habit 4 in the measuring instrument; MUTATION.md habit 16 is the new entry, and I corrected that file's instance count to eleven rather than inflating it.
  • Non-blocking items all applied: §4 → "the ten site_standing entries"; §6b → "A1–A4 and B1"; test_arbiter.py's claim-wider-than-assertion now asserts the ordered pair.

6. Protocol

14 mutations: fresh full-repo copy each, mounted :ro, bytecode purged with the count printed, the edit re-read from disk, length and sha256 printed on both sides, len(old) == len(new) asserted before the write and len(after) == len(before) after it — and every one required to change the emitted SENTENCE, diffed against the clean tree's render. Graded by tools/mutation_run.sh, which counts the 21 files.

12 survived the pre-round suite, 1 was already caught, 3 are caught now. Final tree: 21/21 PASS. Head e2efc76, on release/1.0 @ 9e42cec. Not merged.

## Round 4 — the blocker: the enumeration covered one syntactic sink Confirmed, and the diagnosis was exactly right. Every one of census B's 16 members was a sentence that is **appended, assigned to a `reason`-shaped name, or passed as a log argument**. **None was RETURNED**, and 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 the two. That is the whole `describe()` / `explain()` / reason-builder family, which is where this codebase actually keeps its operator sentences. And it was in **none of the five blind spots the tool printed about itself**. **No `app/` file changes.** `git diff --stat -- goodwe_controller/app/` is empty. ### 1. The scanner, widened — and it rediscovered both members on its own I did not hand-add the two. I extended the sink model and re-ran: * **Census C — the return sink**, resolved through the returning method's **call sites**, so a returned *key* (`store.unit_key`, `mqtt.unit_state_key` — MAINT-05's card) is not mistaken for a returned *sentence*. **53 returned pairs, 39 reaching an operator, 16 of those sharing a format spec.** * **A sentence handed to a constructor as `reason=`, or placed under a `"text"` dict key, is a MESSAGE.** That one line of the sink walker was hiding **six more caller-built members** — census B **16 → 22**. `arbitrage.py`'s two trade reasons were scored OTHER purely because they are `ArbitrageDecision(…, reason=f"…")`. * **Every census B and C row now prints whether the two interpolations share a FORMAT SPEC** — the difference between a transposition that is *silent* (`+.0f` beside `+.0f`) and one that merely looks broken (`.0f` beside a bare `str`). That is what gap 4 was already about, without a column a reader could use. * **`_innermost()`** replaced `for fn in walk(tree): for node in walk(fn)`, which classified every nested-function sentence once per enclosing `def` and hid the double visit behind a de-dup. Both targets came back marked `***` (reaches an operator **and** shares a format spec): ``` clamp.py:253 describe() verdict: MESSAGE *** self.requested_w <-> self.applied_w format spec: '+.0f' / '+.0f' -> SAME - a transposition is SILENT peakguard.py:493 explain() verdict: MESSAGE *** self.projected_w <-> self.ceiling_w format spec: '.0f' / '.0f' -> SAME - a transposition is SILENT ``` ### 2. Closed — three, each pinned on the ordered pair with a precondition | | site | surfaces pinned | before | after | |---|---|---|---|---| | **(a)** | `clamp.py:253` `ClampResult.describe()`, `requested_w`↔`applied_w` | §15 action log `main.py:1366` **and** ClampWatchdog reason `main.py:2452` | **0 bytes, 21/21 green** | **red, 4 checks** (`test_clamp.py` 6d) | | **(b)** | `peakguard.py:493` `PeakDecision.explain()`, `projected_w`↔`ceiling_w` | the decision, and the status row `main.py:3313` | **0 bytes, 21/21 green** | **red, 3 checks** (`test_peakguard.py` 13) | | B12 | `arbiter.py:264` `c.source`↔`c.actuator` | `scope_errors` | **0 bytes, 21/21 green** | **red, 2 checks** (`test_arbiter.py`) | Round 3's pattern followed exactly: the **ordered pair on the contiguous literal**, plus a **precondition that the two operands render differently** — equal operands make a transposition produce the same string and the check decoration — plus a **counterfactual** asserting the transposed sentence is *not* present. **Operands are read off the plant, not off the object the swap lives in.** For (a): `ctl.target` (what the loop asked) and `hass.writes[-1]` (what the device was sent), neither a `ClampResult` field. For (b): `_S38_PROJ.projected_w` off the Projection handed *in*, and `quarter_ceiling_w(CAP_W, obs_hi)[0]` off the shipped helper — neither a `PeakDecision` field. Grading against `result.requested_w` would compare the swap against itself. The measured lie for (a): `asked +9999 W → device saw +2000 W`, so the transposed row reads *"the computed setpoint +2000 W was clamped to +9999 W by the max_w bound (rating 2000 W)"* — a clamp **upward**, past the rating printed four words later. For (b): *"projects 5013 W under a 2300 W ceiling"* — **2713 W over** — on a row whose own words say *idle* and whose own flag says **ok**. ### 3. Disclosed, not closed — 11 measured zero-byte survivors §7e–§7g of the decision doc, each with file fingerprint and 21/21 verdict: three in `allocator.py`'s `UnitPlan.explain()`, three across `arbiter.py`'s two `explain()`s (`lo`↔`hi`, `lo_src`↔`hi_src`, `winner`↔`bound_by`), two in `tariff.py`, two in `arbitrage.py`'s trade reasons, one in `main.py:2652`'s SoC-floor notification. `allocator.py:162` is the one member the suite **already** caught — `test_alloc.py` asserts that whole literal — which says the habit works where it has been applied. ### 4. The tool now states what it does NOT scan Two lists, not one. "What it scans" is printed **from the constants themselves**, so the doc and the code cannot drift. "What it does not scan at all" is ordered by size, and the largest hole is **measured rather than asserted**: `%s`-style lazy logging, **31 calls across 8 modules in `app/`, every one listed**. Then `.format()`/`%`/`+`/`.join()`, sentences that leave through a dict or dataclass, `yield`, message attributes outside its own set, and everything outside `app/`. The five over/under-report gaps stay. ### 5. ⚠️ Two things I got wrong and one the ticket did * **The ticket's line reference.** (b) does **not** reach `main.py:3299/3302` — those are the **floor-limited** branch, where `explain()` returns `self.reason` *before* the idle literal is built. It reaches **`main.py:3313`** and, on the release edge, `main.py:1951`. The pin is on 3313; **1951 is disclosed unpinned** — same call-site distinction §4c cost a round over. * **The probe that grades these mutations was itself the defect.** Its first cut numbered each interpolated operand **by order of appearance**, so after a transposition the other leaf took the same number and **all thirteen mutant renders came back byte-identical** — reading exactly like *the edit is inert*. Keying the value to the **name** fixed it. Habit 4 in the measuring instrument; `MUTATION.md` habit 16 is the new entry, and I corrected that file's instance count to **eleven** rather than inflating it. * Non-blocking items all applied: §4 → "the ten `site_standing` entries"; §6b → "A1–A4 and B1"; `test_arbiter.py`'s claim-wider-than-assertion now asserts the ordered pair. ### 6. Protocol 14 mutations: fresh full-repo copy each, mounted `:ro`, bytecode **purged with the count printed**, the edit **re-read from disk**, **length and sha256 printed on both sides**, `len(old) == len(new)` asserted before the write and `len(after) == len(before)` after it — and **every one required to change the emitted SENTENCE**, diffed against the clean tree's render. Graded by `tools/mutation_run.sh`, which counts the **21** files. **12 survived the pre-round suite, 1 was already caught, 3 are caught now. Final tree: 21/21 PASS.** Head `e2efc76`, on `release/1.0` @ `9e42cec`. Not merged.
admin added 1 commit 2026-09-05 23:10:33 +02:00
SAFETY-38: correct the survivor count, which was stated four ways
checks / unit (pull_request) Successful in 5m11s
d5b9317d57
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
admin merged commit 24c3d87d60 into release/1.0 2026-09-06 00:00:49 +02:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: admin/goodwe-addon#53