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.
`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
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 assignmentstanding[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:
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.
## 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
`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
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.py234 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 andmain.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 throughdisagrees()), 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:452claims 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.
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
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
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.reasonbefore 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.
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.02026-09-06 00:00:49 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
app/main.py:1598'ssite_standing = float(sum(standing.values()))is the first argument of SAFETY-30's latching, site-stopping sign detector and thecommanded_whanded to SAFETY-18'slooks_frozen(). It was pinned by nothing.Coverage only — not one byte of
app/moves. The shippedsumis 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@f72c48dafter SAFETY-32 merged:list(standing.values())[0]sum(...) / max(1, len(standing))— the meanmax(standing.values(), key=abs)— largest shareThe 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_siteis the repository's only N≥2 site fixture. It runs atsite_sign_disagree_w: 320with shares of about −2500 W each — every one individually far over the gate. On numbers like thosesum, the mean andmax(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.pypart 9Two 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.
summax(…, key=abs)list(…)[0]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 frommax(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 andmax(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, sosum= 500 W rails it while 250, 280 and 220 do not.blind = BLIND_FROZEN,floor_w = 0.0— no charging while the billed quarter is unobservable;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()answersBLIND_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 magnitudesite_standingcarries and not byunchanged_salone.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_FROZENon that same value.Mutation ledger — 10 mutations, all red
Each on a fresh full-repo copy,
__pycache__/*.pycexcluded, mounted:ro, graded bytools/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. Shippedapp/main.py= 232 520 B.sum(...) / max(1, len(standing))— the meanmax(standing.values(), key=abs)list(standing.values())[0]min(standing.values(), key=abs)sum(abs(v) for v in ...)-sum(standing.values())float(self.target)sum(...) / 2.0sum(...) * 2.0sorted(standing.values())[-1]Every one is red in
test_watchdog.pyand 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.pyin the batch. Re-run in isolation three times and once more throughtools/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 untouchedrelease/1.0@f72c48d(verified, fullmutation_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: thedeviceP_SAFETY claim is filed per actuator, so it bounds each unit's share and never the site target, andclamp_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()sayscommanded_wis "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
standingitself —stood = inv.last_write if … else inv.targetatmain.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.RAIL_FRAC,rail_ceiling_w()'s device-bound conjunction andrail_unreachable()aretest_freeze.py's subject and are untouched.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.Suite
tools/mutation_run.shon the committed tree, fresh copy, bytecode purged, mounted:ro: 20/20 PASS.🤖 Generated with Claude Code
https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
cdc7f7ba30toce46a4b76dRound 2 —
ce46a4b, rebased ontorelease/1.0@86eaef2Both blockers closed. No
app/change in either — the shippedsumis correct; both holes were in the test and in the write-up.⚠️ The suite is 21 files now, not 20. The rebase onto
86eaef2brought in TEL-07'stest_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_standingand never pinned where it goes. Reproduced first, on the round-1 tree:The cost, from part 9a's own output under the mutation:
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:Measured on the fixed tree: the swap is red in
test_watchdog.pyon 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@86eaef2carrying only the[0]edit (+4 B, re-read from disk, bytecode purged):test_peakguard.py, 5 isolated runsMy 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.
standingis neither:standing = {}atmain.py:1554, filled by subscript assignmentstanding[inv.name] = stoodat 1558. A subscript store is not a binding 12j follows, sostandingnever enters the taint set andsum(standing.values())is not a roster site. Run 12j on the mutated tree and it enumerates:— 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
86eaef2Not carried forward from round 1's report. Fresh copy per mutation, mounted
:ro, purged bytools/mutation_run.sh, edit re-read from disk, byte delta printed."sign_site": SCOPE_SITE → SCOPE_UNIT"circulation": SCOPE_SITE → SCOPE_UNITM1/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.pyis 234 542 B at86eaef2, 2 022 more than thef72c48dround 1 measured on; the ledger now records the base it was measured against.Honest gaps — what I did NOT close (DECISIONS §6a)
circ_wddetector 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.CLAIM_SCOPEentries —loop,peak,arbitrage,safety,device,supervised,failsafe,maintenance. Ten entries; two mutated.actuator_of()'s body and then_units > 1guard on thesign_siteappend — not mutated.test_watchdog.pyonly. The "green in the other twenty files" half is re-verified on86eaef2for M3 and M11 (both full 21-file runs) and otherwise carried forward from round 1 onf72c48d. Weaker than round 1 claimed, and written down as such.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.
CI green on
checks / unit (pull_request). Not merging — over to you.🤖 Generated with Claude Code
https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
ce46a4b76dto97b59438dcTwo 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 @86eaef2carrying 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 on86eaef2, 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 onto86eaef2. 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 on86eaef2only 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_01BwRLw7eKYj2byVdwB6kbjURound 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_01BwRLw7eKYj2byVdwB6kbjU97b59438dcto357cb4f07fRound 3 — both blockers closed, and the enumeration that would have caught both
Head
357cb4f. Rebased ontorelease/1.0— first ontobbc903b(SAFETY-41 + the 0.7.0 bump), and then again onto
9e42cecwhen CI-06 (#54)merged mid-round; the only conflict was one
## Unreleasedhunk inCHANGELOG.md, resolved by keeping both entries.Full 21-file suite on the pushed commit: 21/21 PASS, rc=0 — purged, fresh
copy, mounted
:ro. Noapp/code changes:app/main.pyis byte- andfingerprint-identical to
release/1.0(61aa413f…).(The ten ledger re-measurements below were taken on
bbc903b. CI-06 touchestest_peakguard.pyandCHANGELOG.mdonly, neither of which gradessite_standing, so those verdicts stand on the tree they were taken on — notedin §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-32attribution stands in all four places.
BLOCKER 1 — the same zero-byte defect at
main.py:1560— CLOSEDevaluate(stood, against, …)→evaluate(against, stood, …).app/main.py234 542 bytes before and after, Δ 0, fingerprint61aa413f…→e57e6026…, bytecode purged, tree mounted:ro, the linere-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 < 0with anabs()gate on both — so the mutant takesidentical 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.
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, instatus_payload()'s headline, and insign_reason, which is persisted and readback 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_writeandinv.battoff the objects the plant moved, never off the arguments the detector was
handed, which would compare the swap against itself:
f"commanded {stood:+.0f} W against its own measured power {meas:+.0f} W", plus the persisted namespaced copy;f"commanded {stood:+.0f} W against a meter reading {grid:+.0f} W", plus the namespaced key and theflat rollback mirror;
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, GREENin 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.pyfingerprint printed,:ro, and thefull 21-file suite every time rather than
test_watchdog.pyalone:362374cf…86573e74…59fa8815…43d852be…58d26de8…test_watchdog.py, 3 checks407ddb5f…test_watchdog.py, 3 checks2a635a4e…b21eed89…0973d3ca…0e7b7a4f…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 thesign. Every other one changes only the magnitude — and
_sign_site, theonly 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.pyThe 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()issymmetric only through
disagrees()), and a single pass reports an emptyfamily 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:
rail_unreachable()'sdev_min/dev_maxatmain.py:3208:0 bytes, 21/21 green (
61aa413f…→2305b560…).f"only {inv.dev_min}...{inv.dev_max} W"atmain.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:
circ_reason— same shape, also persisted and re-readafter a restart, graded only by
"CIRCULATING_POWER" in reason.arbiter.scope_violations()—test_arbiter.py:452claims 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 intest_static_tariff.pyon two named checks, because the refusal is assertedas the contiguous literal
"22:00-23:00"— andwatchdog.py:667's own(cfg.min_w, cfg.hold_s)inside the(limits: …)clause, because the SAFETY-30section asserts the site instance's whole sentence with
==. Full-stringequality 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.pyon astatic_tariff.py-onlyedit. 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.pypasses four for four in isolation on that sametree. No claim here rests on it.
Sections:
DECISIONS/SAFETY-38-site-standing-pin.md§4 (⚠️ the correctedarithmetic), §4c (M12), §6b (round-3 open list), §7 (the family);
MUTATION.mdhabit 14 extended and new habit 15.Not merged, as asked.
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_01BwRLw7eKYj2byVdwB6kbjURound 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 wholedescribe()/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:
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.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 areArbitrageDecision(…, reason=f"…").+.0fbeside+.0f) and one that merely looks broken (.0fbeside a barestr). That is what gap 4 was already about, without a column a reader could use._innermost()replacedfor fn in walk(tree): for node in walk(fn), which classified every nested-function sentence once per enclosingdefand hid the double visit behind a de-dup.Both targets came back marked
***(reaches an operator and shares a format spec):2. Closed — three, each pinned on the ordered pair with a precondition
clamp.py:253ClampResult.describe(),requested_w↔applied_wmain.py:1366and ClampWatchdog reasonmain.py:2452test_clamp.py6d)peakguard.py:493PeakDecision.explain(),projected_w↔ceiling_wmain.py:3313test_peakguard.py13)arbiter.py:264c.source↔c.actuatorscope_errorstest_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) andhass.writes[-1](what the device was sent), neither aClampResultfield. For (b):_S38_PROJ.projected_woff the Projection handed in, andquarter_ceiling_w(CAP_W, obs_hi)[0]off the shipped helper — neither aPeakDecisionfield. Grading againstresult.requested_wwould 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'sUnitPlan.explain(), three acrossarbiter.py's twoexplain()s (lo↔hi,lo_src↔hi_src,winner↔bound_by), two intariff.py, two inarbitrage.py's trade reasons, one inmain.py:2652's SoC-floor notification.allocator.py:162is the one member the suite already caught —test_alloc.pyasserts 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 inapp/, every one listed. Then.format()/%/+/.join(), sentences that leave through a dict or dataclass,yield, message attributes outside its own set, and everything outsideapp/. The five over/under-report gaps stay.5. ⚠️ Two things I got wrong and one the ticket did
main.py:3299/3302— those are the floor-limited branch, whereexplain()returnsself.reasonbefore the idle literal is built. It reachesmain.py:3313and, 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.MUTATION.mdhabit 16 is the new entry, and I corrected that file's instance count to eleven rather than inflating it.site_standingentries"; §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 andlen(after) == len(before)after it — and every one required to change the emitted SENTENCE, diffed against the clean tree's render. Graded bytools/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, onrelease/1.0@9e42cec. Not merged.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