checks / unit (pull_request) Successful in 5m11s
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
926 lines
61 KiB
Markdown
926 lines
61 KiB
Markdown
# SAFETY-38 — pinning `site_standing`, the one quantity both site guards are asked about
|
||
|
||
**Status: DECIDED 2026-09-05.** `app/main.py`'s
|
||
`site_standing = float(sum(standing.values()))` is **correct and is not
|
||
changed**. What this card adds is the thing it never had: a **behavioural**
|
||
fixture whose per-unit shares **cannot** answer for the site, so that a change
|
||
of *meaning* in that reduction goes red instead of green.
|
||
|
||
Filed with SAFETY-38, card `6a9ac95f2b78646e5a76d2e9`. It is a **coverage**
|
||
card: not one byte of `app/` moves.
|
||
|
||
---
|
||
|
||
## 0. The line, and its two consumers
|
||
|
||
```python
|
||
site_standing = float(sum(standing.values())) # app/main.py:1598
|
||
```
|
||
|
||
`standing` is `{unit name -> the value THIS controller last put on that unit's
|
||
setpoint entity}` — `inv.last_write`, i.e. the number **after**
|
||
`clamp_to_rating()` has applied `max_w` and the device's own
|
||
`[dev_min, dev_max]`. Two safety inputs are derived from it and nothing else in
|
||
`cycle()` is:
|
||
|
||
| consumer | where | what it does with it |
|
||
|---|---|---|
|
||
| **[[SAFETY-30]]**'s site sign detector | `self.site_sign_wd.evaluate(site_standing, self.grid, …)` (main.py:1619) | **latches and stops the whole site** when the total command opposes the meter for `site_sign_disagree_s` |
|
||
| **[[SAFETY-18]]**'s freeze conjunction | `looks_frozen(unchanged_s, site_standing, max_w, …)` inside `peakguard.decide(frozen=…)` (main.py:1926) | takes the peak guard **blind** and files `floor_w = 0.0` — the battery may not import while the billed quarter is unmeasurable |
|
||
|
||
Both are P0 paths. **Neither was pinned to the reduction.**
|
||
|
||
## 1. What survived, and what the rescope measured
|
||
|
||
The card opened with three demonstrated live mutants, all green across the whole
|
||
suite. Re-measured in round 2 against `release/1.0` @ `86eaef2`:
|
||
|
||
| mutation of `site_standing` | on release/1.0 @ 86eaef2 | closed by |
|
||
|---|---|---|
|
||
| `list(standing.values())[0]` — one unit's share | **SURVIVES**, full suite 21/21 green | part 9a + 9b (this card) |
|
||
| `sum(...) / max(1, len(standing))` — the mean | **SURVIVES**, full suite 21/21 green | part 9a + 9b (this card) |
|
||
| `max(standing.values(), key=abs)` — the largest share | **SURVIVES**, full suite 21/21 green | part 9a + 9b (this card) |
|
||
|
||
### ⚠️ ROUND 1 SAID `[0]` WAS ALREADY CLOSED BY SAFETY-32. THAT WAS FALSE.
|
||
|
||
Round 1 of this card recorded `[0]` as **CAUGHT by SAFETY-32's package-wide
|
||
structural pin**, wrote that claim into four places, and told future authors
|
||
*"DO NOT RE-COVER THAT ONE HERE"*. It is wrong, and **how** it went wrong is the
|
||
part worth keeping.
|
||
|
||
**How the error was made.** The mutation was measured **once**. That single run
|
||
happened to hit `test_peakguard.py`'s [[CI-06]] flake — a **measured ~20%**
|
||
false-red rate — and the red was recorded as a **mutation kill**. Nothing else
|
||
was checked, because a red is the answer you are hoping for and it did not look
|
||
like it needed a second run.
|
||
|
||
**The correct measurement.** On `release/1.0` @ `86eaef2` carrying **only** the
|
||
`[0]` edit (+4 bytes, re-read from disk, bytecode purged by
|
||
`tools/mutation_run.sh`): the **full 21-file suite is green**, and
|
||
`test_peakguard.py` run **five times in isolation** on that same mutated tree is
|
||
**PASS PASS PASS PASS PASS**. The mutation **survives**. (The reviewer's own
|
||
independent re-measurement of the same tree hit the flake once —
|
||
PASS PASS PASS FAIL PASS — which is the same verdict plus a live demonstration
|
||
of the mechanism that produced the original error.)
|
||
|
||
**Why SAFETY-32 cannot see it, read off 12j's own enumeration rather than
|
||
argued.** Part 12j taints names *bound* to roster-carrying values and lists
|
||
*accumulated* in a loop over one. `standing` is neither: it is a dict created
|
||
empty at `main.py:1554` and filled by **subscript assignment**
|
||
(`standing[inv.name] = stood`) inside the roster loop, and a subscript store is
|
||
not a binding 12j follows. So `standing` never enters its taint set and
|
||
`sum(standing.values())` is **not a roster site** at all. Run 12j against a tree
|
||
carrying the `[0]` mutation and its enumeration lists `main.py:1553`, `1555` and
|
||
`1690` — the three `self.inverters` sites bracketing this code — and **not
|
||
`1599`**. No shape pin reaches this line; only the behavioural fixture below
|
||
does.
|
||
|
||
**The rule this cost.** `test_peakguard.py` flakes at a measured ~20%. A single
|
||
red from it proves nothing. Re-run it in isolation **at least five times** and
|
||
**name the failing check** before believing any red from it. This card is the
|
||
**third** measurement CI-06 has corrupted.
|
||
|
||
SAFETY-32's own disclosed blind spot says why the other two were always out of
|
||
reach:
|
||
|
||
> *SEMANTICS. `min` → `max` → the mean are edits invisible to any shape pin,
|
||
> permanently. Only the behavioural sets can see them.*
|
||
|
||
`sum` → mean and `sum` → `max(key=abs)` are **semantic**: identical shape,
|
||
different meaning. **No shape pin will ever close them.** That disclosure was
|
||
accurate, and is now confirmed by measurement rather than taken on trust.
|
||
|
||
`[0]` is the one that *looks* structural and therefore *looks* covered — and
|
||
that appearance is exactly what produced the false attribution above. It is
|
||
out of reach here for a different reason from the other two: not because the
|
||
shape is invisible, but because **the value never becomes a roster** in the
|
||
analysis's eyes. All three are closed by the same behavioural fixture, and
|
||
**all three needed it.**
|
||
|
||
## 2. Why the suite could not see it — habit 4, inside the site half of the very card that names it
|
||
|
||
`test_watchdog.py`'s `_sign_site` is the **only** N≥2 site fixture in the
|
||
repository. It runs at `site_sign_disagree_w: 320` with shares of roughly
|
||
−2500 W **each**. Every one of them is individually far over the gate, so on
|
||
those numbers
|
||
|
||
```
|
||
sum = −5000 W over mean = −2500 W over max = −2500 W over
|
||
```
|
||
|
||
— **all three reductions agree**, and a check whose two candidate answers happen
|
||
to be equal cannot observe a change that swaps them. That is `MUTATION.md`'s
|
||
third failure mode, and this is its ninth instance.
|
||
|
||
At a roster of **one** it is not a fixture defect but arithmetic:
|
||
`sum == mean == max == the one share`, identically, so all three mutations are
|
||
genuinely **equivalent** on a shipped install. No N=1 fixture can ever separate
|
||
them; §3c asserts that rather than leaving it to the reader.
|
||
|
||
## 3. The fixture, and the numbers it is built on
|
||
|
||
Two **simulated** units rated 220 W and 280 W, both obeying exactly, inverted
|
||
gain, house importing 2000 W. Both stand at their own rails, so `standing` is
|
||
`{-220, -280}` in roster order and the meter reads +2500 W.
|
||
|
||
| reduction | value | vs the SHIPPED 300 W gate |
|
||
|---|---|---|
|
||
| `sum` | **−500 W** | **OVER** → trips |
|
||
| `sum / len` (the mean) | −250 W | under → silent |
|
||
| `max(…, key=abs)` | −280 W | under → silent |
|
||
| `list(…)[0]` | −220 W | under → silent |
|
||
|
||
**Four distinct magnitudes — 500, 250, 280, 220 — of which exactly one clears
|
||
the gate.** The asymmetry is load-bearing twice: equal shares would collapse
|
||
mean, max and first onto one number, and putting the **smaller** unit first is
|
||
what keeps `[0]` distinct from `max(key=abs)`. The gate is left at its shipped
|
||
default so the fixture is graded against the magnitude an install runs; only the
|
||
**hold** is shortened (325 s → 2 s), because the hold is a wall clock and the
|
||
campaign behind 325 s is measured on the rig, not here.
|
||
|
||
⚠️ **220 W and 280 W are SIMULATED ratings chosen for that arithmetic and for
|
||
nothing else.** They are not measurements. In particular they are **not**
|
||
inverter B, which does not physically exist; B is a GW5048D-ES, serial
|
||
`55048ESU22AW0992`, **4600 W from its datasheet** (`goodwe/NOTES.md` §1.1b).
|
||
|
||
### 3a. The site sign detector
|
||
|
||
Shipped tree: the site latches, commands 0 W on every inverter, and the sentence
|
||
an operator reads is
|
||
`SIGN_DISAGREEMENT: commanded -500 W against a meter reading +2500 W …`. No
|
||
per-unit watchdog trips — every unit is faithfully executing its share, which is
|
||
the asymmetry the site detector exists for.
|
||
|
||
Under the mean and under `max(key=abs)`: **nothing trips, ever.** The reviewer's
|
||
tree was still commanding −500 W of charge into a +2500 W import at t = 595 s
|
||
with `following=True`.
|
||
|
||
Two counterfactuals sit beside it so the trip is attributable:
|
||
|
||
* the **same** inverted-gain site whose shares sum to −250 W — under the gate —
|
||
does **not** trip, so the trip above is the sum crossing 300 W and not the
|
||
mere presence of a second inverter;
|
||
* a site whose **device range is narrower than `max_w`** asks for −500 W while
|
||
only −250 W stands, and does **not** trip — see §5.
|
||
|
||
### 3b. The second consumer, covered rather than scoped out
|
||
|
||
`site_standing` is also `looks_frozen()`'s `commanded_w`. The same
|
||
discrimination applies at a different threshold: the site rail is
|
||
`RAIL_FRAC * site_max_w` = 0.95 × 500 = **475 W**.
|
||
|
||
| reduction | value | vs the 475 W rail |
|
||
|---|---|---|
|
||
| `sum` | **500 W** | **RAILED** → `BLIND_FROZEN`, `floor_w = 0.0` |
|
||
| the mean | 250 W | under → silent |
|
||
| `max(…, key=abs)` | 280 W | under → silent |
|
||
| `list(…)[0]` | 220 W | under → silent |
|
||
|
||
The sign is flipped from §3a on purpose: the gain is the **shipped positive**
|
||
one and the frozen meter reads import, so the site *discharges* +500 W against a
|
||
+2500 W import — the two agree in sign, SAFETY-30 has nothing to say, and this
|
||
half grades SAFETY-18 alone. Reusing §3a's inverted gain would have the site
|
||
sign latch park everything at 0 W, and the freeze conjunction could then never
|
||
be true: a check measuring the *other* guard's remedy.
|
||
|
||
⚠️ **The open quarter is backfilled with the same stuck reading, and that is
|
||
not decoration.** Without it the block is mostly uncovered, `blindness()`
|
||
answers `BLIND_COVERAGE`, and the mutant reaches *the same action by a different
|
||
route* — a check on the action alone could not then see the difference it names.
|
||
Seeded, the measurement is honest:
|
||
|
||
* shipped: `blind = BLIND_FROZEN`, `floor_w = 0.0` — no charging while the
|
||
billed quarter is unobservable;
|
||
* both mutations: **not blind at all**, `floor_w = None` — the site may go on
|
||
importing through a quarter whose billed average cannot be measured. That is
|
||
the capacity-tariff hazard SAFETY-18 exists for, silently disarmed.
|
||
|
||
The counterfactual comes from the **same run**: on the earlier cycles the
|
||
command had not yet reached the rail and the guard did not report SAFETY-18,
|
||
with the meter equally frozen throughout — so the verdict is produced by the
|
||
command magnitude `site_standing` carries and not by `unchanged_s` alone. A low
|
||
gain (0.05, the schema floor) is what buys those below-the-rail cycles.
|
||
|
||
### 3c. At N=1, nothing changes — asserted
|
||
|
||
A real one-inverter controller is driven through the same frozen-meter
|
||
scenario. Its `standing` has a single entry, so `sum`, the mean,
|
||
`max(key=abs)` and `[0]` are printed and asserted to be **one number**; the
|
||
site sign detector is still absent (part 7 counts the calls on the object); and
|
||
SAFETY-18 still reaches `BLIND_FROZEN` on that same value. The claim "the
|
||
shipped install is unaffected" now rests on the roster rather than on the
|
||
reader.
|
||
|
||
## 4. Mutation ledger
|
||
|
||
Every run: a **fresh full-repo copy**, `__pycache__` and `*.pyc` excluded,
|
||
mounted `:ro`, graded by `tools/mutation_run.sh` (which purges, prints the tree
|
||
fingerprint, and verifies no cache appears during the run). The mutated line was
|
||
**re-read from disk** after the edit and the byte delta printed. Shipped
|
||
`app/main.py` is 234 542 bytes at `86eaef2`.
|
||
|
||
| # | mutation of `site_standing` | Δ bytes | verdict |
|
||
|---|---|---|---|
|
||
| M1 | `sum(...) / max(1, len(standing))` — the mean | +24 | **RED** |
|
||
| M2 | `max(standing.values(), key=abs)` — largest share | +9 | **RED** |
|
||
| M3 | `list(standing.values())[0]` — one share | +4 | **RED** — and **survives on release/1.0**, see §1 |
|
||
| M4 | `min(standing.values(), key=abs)` — smallest share | +9 | **RED** |
|
||
| M5 | `sum(abs(v) for v in ...)` — magnitude, sign lost | +16 | **RED** |
|
||
| M6 | `-sum(standing.values())` — sign flipped | +1 | **RED** |
|
||
| M7 | `float(self.target)` — the arbiter's site target | **−11** | **was a SURVIVOR — now RED**, see §5 |
|
||
| M8 | `sum(standing.values()) / 2.0` | +6 | **RED** |
|
||
| M9 | `sum(standing.values()) * 2.0` | +6 | **RED** |
|
||
| M10 | `sorted(standing.values())[-1]` — largest signed | +7 | **RED** |
|
||
| **M11** | **argument SWAP** at the SITE call site: `evaluate(site_standing, self.grid, …)` → `evaluate(self.grid, site_standing, …)` (main.py:1620) | **0** | **was a SURVIVOR in round 1 — now RED**, see §4a |
|
||
| **M12** | **argument SWAP** at the PER-UNIT call site: `evaluate(stood, against, …)` → `evaluate(against, stood, …)` (**main.py:1560**) | **0** | **was a SURVIVOR in round 2 — now RED**, see §4c |
|
||
|
||
**Every one of the ten `site_standing` entries is red in `test_watchdog.py`.** Re-measured in round 2
|
||
on `release/1.0` @ `86eaef2` (where `app/main.py` is 234 542 bytes, 2 022 more
|
||
than the `f72c48d` the round-1 figures were taken on): all ten still red, at the
|
||
byte deltas in the table, each on a fresh copy with the edit re-read from disk.
|
||
Adding assertions cannot turn a red green, so those verdicts carry forward to
|
||
the round-3 tree unchanged.
|
||
|
||
### ⚠️ ROUND 2 SAID THREE OF THESE SURVIVED. **EIGHT DID.**
|
||
|
||
Round 2 wrote: *"Before part 9 existed, three of these left the whole suite
|
||
green — M1, M2 and M3 — and the remaining seven were caught by SAFETY-30's own
|
||
N≥2 checks."* Round 1's wording was vaguer and harmless; naming three made it
|
||
**specific and wrong**, and it is the same species of error the round was
|
||
written to fix — **a survivor recorded as a kill**. This file contradicted it
|
||
three times on its own pages: the ledger row calls M7 *"a SURVIVOR — now RED"*,
|
||
§5 says M7 survived the whole suite, and `CHANGELOG.md` says so too.
|
||
|
||
**Re-measured in round 3, one mutation at a time, on clean `release/1.0` @
|
||
`bbc903b`** — a fresh copy each, bytecode purged (`0` remaining, printed), the
|
||
line re-read from disk, byte delta **and** `app/main.py` fingerprint printed,
|
||
mounted `:ro`, and the **FULL 21-FILE SUITE** run each time rather than
|
||
`test_watchdog.py` alone. (`release/1.0` has since moved on to `9e42cec`, which
|
||
merged [[CI-06]]; that commit touches `test_peakguard.py` and `CHANGELOG.md`
|
||
only, neither of which grades `site_standing`, so the ten verdicts below stand
|
||
on the tree they were taken on and this branch is rebased onto the newer tip.)
|
||
|
||
| # | Δ bytes | fingerprint of `app/main.py` | suite before part 9 |
|
||
|---|---|---|---|
|
||
| M1 | +24 | `362374cfb6486378` | **21/21 PASS — SURVIVOR** |
|
||
| M2 | +9 | `86573e7494f1c933` | **21/21 PASS — SURVIVOR** |
|
||
| M3 | +4 | `59fa8815e6fda4cf` | **21/21 PASS — SURVIVOR** |
|
||
| M4 | +9 | `43d852be3037f861` | **21/21 PASS — SURVIVOR** |
|
||
| M5 | +16 | `58d26de82af29a1a` | **RED** — `test_watchdog.py`, 3 checks |
|
||
| M6 | +1 | `407ddb5f375cc217` | **RED** — `test_watchdog.py`, 3 checks |
|
||
| M7 | **−11** | `2a635a4eca0a790d` | **21/21 PASS — SURVIVOR** |
|
||
| M8 | +6 | `b21eed89ff4c9240` | **21/21 PASS — SURVIVOR** |
|
||
| M9 | +6 | `0973d3caf680db65` | **21/21 PASS — SURVIVOR** |
|
||
| M10 | +7 | `0e7b7a4fed7787d0` | **21/21 PASS — SURVIVOR** |
|
||
|
||
**So: EIGHT of the ten left the whole suite green, and TWO were caught** — not
|
||
three and seven, and not the four and six a reviewer proposed either. The
|
||
correct sentence is: *before part 9 existed, eight of these ten left the whole
|
||
21-file suite green, and the only two SAFETY-30's own N≥2 checks caught were M5
|
||
and M6.*
|
||
|
||
**⚠️ AND THE TWO THAT WERE CAUGHT ARE CAUGHT FOR A REASON THAT MAKES THE CARD'S
|
||
CASE RATHER THAN WEAKENING IT.** M5 (`sum(abs(v) …)`) and M6 (`-sum(…)`) are
|
||
the only two entries in the table that **reverse the SIGN** of `site_standing`.
|
||
Every other entry changes only its **magnitude** — and `_sign_site`, the suite's
|
||
only N≥2 site fixture before this card, runs shares of about **−2500 W each**
|
||
against a **320 W** gate. `sum`, the mean, `max(key=abs)`, `min(key=abs)`,
|
||
`sorted(...)[-1]`, `[0]`, `self.target`, half the sum and twice the sum all land
|
||
on **−2500, −2500, −2500, −2500, −2500, −2500, −2500, −1250 and −5000 W**: nine
|
||
different numbers, all on the **same side of the same threshold**. The three
|
||
checks that fired on M5 and M6 grade *"the site trips and no unit does"*, the
|
||
claim it trips through, and where the latch is written — none of them grades the
|
||
**quantity**, and the sign flip reaches them only because it stops the trip
|
||
happening at all.
|
||
|
||
That is the whole thesis of this card measured rather than argued: **the
|
||
pre-existing coverage could see a sign, and nothing else.** Part 9's fixture —
|
||
per-unit shares each **below** the gate whose **sum** clears it — is the only
|
||
arrangement in which the magnitude is load-bearing, and it is what takes the
|
||
other eight red.
|
||
|
||
⚠️ **ONE FALSE RED, AND IT IS [[CI-06]]'s.** In the batch run M10 also reported
|
||
`FAIL test_peakguard.py`. Re-run in isolation **three times** and once more
|
||
through `tools/mutation_run.sh` — **four clean passes, zero failures**. The
|
||
batch script had discarded the failure detail, so the failing check cannot be
|
||
named here; what can be said is that the red did not reproduce in four attempts
|
||
and that **no claim in this card rests on it**. `test_peakguard.py` is the file
|
||
CI-06 records as flaky, and this is the behaviour it records. Believe no red
|
||
from it without three isolated re-runs.
|
||
|
||
⚠️ **ROUND 3 CAN NAME ONE, AND IT IS THE SAME FILE.** Round 3's own batch — the
|
||
ten full-suite runs above, plus §7's sibling swaps — produced **no** red from
|
||
`test_peakguard.py` on M10 or on any other ledger entry. It produced exactly one
|
||
elsewhere, on a **`static_tariff.py`-only** edit (§7b, A4), and this time the
|
||
failing check *was* captured: *"one cycle of the intent form is exactly the
|
||
delivered form plus the command standing on the wire"* — which is CI-06's
|
||
**mechanism 1 by name**, the cross-time projection. `test_peakguard.py` then
|
||
passed **four for four** in isolation on that same tree. That is the first time
|
||
this card has been able to put a name to one of these reds, and it is the name
|
||
CI-06 predicts.
|
||
|
||
⚠️ **M3 IS NOT "FOR COMPLETENESS", AND ROUND 1 SAID IT WAS.** Round 1 recorded
|
||
M3 as already closed structurally by SAFETY-32 and disclaimed it. §1 above has
|
||
the correction and the mechanism: `[0]` **survives** a full green 21-file suite
|
||
on `release/1.0` @ `86eaef2`, because `standing` is a dict filled by subscript
|
||
assignment and never enters 12j's taint set. **M3 is a real kill by this card's
|
||
fixture and by nothing else in the repository.** It is claimed here.
|
||
|
||
### 4a. ⚠️ The zero-byte survivor one line below the one this card pinned
|
||
|
||
Round 1 pinned the **value** `site_standing` and never pinned **where it goes**.
|
||
Swapping the detector's first two arguments at `main.py:1620` is a **zero-byte**
|
||
edit — the same characters, reordered — and it left the round-1 tree **21/21
|
||
green**.
|
||
|
||
What it costs is the banner. `SignWatchdog.evaluate()` formats
|
||
`commanded_w` first and `against_w` second, so under the swap part 9a's own
|
||
output reads:
|
||
|
||
```
|
||
SIGN_DISAGREEMENT: commanded +2500 W against a meter reading -500 W for 2 s …
|
||
```
|
||
|
||
The site is standing at **−500 W of charge into a +2500 W import** and the
|
||
**latching, site-stopping** banner tells the operator **the exact opposite** —
|
||
which instrument read what, inverted. `watchdog.py:658-665` names this failure
|
||
in its own words: *"a false statement about which instrument produced the
|
||
evidence, on the one line somebody woken by this alarm reads first."*
|
||
|
||
**Why round 1's check could not see it, and this is the general lesson.** It
|
||
asserted
|
||
|
||
```python
|
||
f"{sum(_share38):.0f} W" in _c38.site_sign_wd.reason
|
||
```
|
||
|
||
— that `"-500 W"` appears **somewhere** in the sentence. Under the swap it still
|
||
does: in the **meter slot**. A substring test cannot reach a failure that is
|
||
purely **positional**, and round 1's own comment claimed the opposite ("*would
|
||
put a different number in the store, the action log and the banner, and this is
|
||
where that shows*"). It shows the **number**, never its **position**. This is
|
||
MUTATION.md's *"a check whose data cannot reach the failure it names"*, in the
|
||
card that exists to complain about exactly that.
|
||
|
||
**The fix is one line and touches no `app/` code** — assert the **ordered
|
||
pair**:
|
||
|
||
```python
|
||
f"commanded {sum(_share38):+.0f} W against a meter reading "
|
||
f"{_mid38['grid']:+.0f} W" in _c38.site_sign_wd.reason
|
||
```
|
||
|
||
Measured: with that line, M11 is **red in `test_watchdog.py` and green in the
|
||
other twenty files** — one file, one check.
|
||
|
||
### 4b. Claim wiring, re-measured rather than assumed
|
||
|
||
Round 1's reviewer disclosed it had **not** mutated the arbiter claim wiring or
|
||
[[SAFETY-31]]/circulation. Both were mutated in round 2. Both are **zero-byte**
|
||
edits to `arbiter.CLAIM_SCOPE`:
|
||
|
||
| # | mutation | Δ bytes | verdict |
|
||
|---|---|---|---|
|
||
| W1 | `"sign_site": SCOPE_SITE` → `SCOPE_UNIT` | **0** | **RED** — `test_watchdog.py` (parts 5 **and 9a**), `test_arbiter.py`, `test_peakguard.py` |
|
||
| W2 | `"circulation": SCOPE_SITE` → `SCOPE_UNIT` | **0** | **RED** — `test_watchdog.py`, `test_arbiter.py`, `test_peakguard.py` |
|
||
|
||
Part 9a's *"…and it stops the WHOLE site, through the `sign_site` claim by name"*
|
||
is one of the two checks that kills W1, so this card contributes to that
|
||
coverage rather than merely inheriting it. **W2 is caught entirely by
|
||
pre-existing checks** — SAFETY-38 adds nothing to circulation, and does not
|
||
claim to.
|
||
|
||
### 4c. ⚠️ The SAME zero-byte defect one call site over — and it is the one the shipped install can see
|
||
|
||
Round 2 closed §4a by asserting the ordered pair on the **site** banner at
|
||
`main.py:1620`. It did not touch `main.py:1560`, the **per-unit** call, and the
|
||
identical swap there —
|
||
|
||
```python
|
||
if inv.sign_wd.evaluate(stood, against, self.following, now) # shipped
|
||
if inv.sign_wd.evaluate(against, stood, self.following, now) # M12
|
||
```
|
||
|
||
— left the round-2 tree **21/21 green**. Measured: `app/main.py` 234 542 bytes
|
||
before and after, **Δ 0 bytes**, fingerprint `61aa413f…` → `e57e6026…`, bytecode
|
||
purged, tree mounted `:ro`, the line re-read from disk.
|
||
|
||
**Nothing behavioural can catch it, and that is structural rather than
|
||
unlucky.** `watchdog.disagrees()` is *symmetric* in its first two arguments —
|
||
`commanded_w * against_w < 0`, with an `abs()` gate on **both** — so the swap
|
||
cannot change a single trip decision on any input. What it changes is the
|
||
**sentence**. Measured by diffing the suite's own printed banners between the
|
||
round-3 tree and the same tree carrying only M12: **9 emitted banner lines
|
||
invert, in 3 distinct sentences.**
|
||
|
||
```
|
||
shipped: SIGN_DISAGREEMENT: commanded -2000 W against a meter reading +2000 W …
|
||
M12: SIGN_DISAGREEMENT: commanded +2000 W against a meter reading -2000 W …
|
||
|
||
shipped: … commanded +1000 W against its own measured power -1000 W …
|
||
M12: … commanded -1000 W against its own measured power +1000 W …
|
||
|
||
shipped: … commanded -2000 W against a meter reading +4000 W … (the N=1 arm)
|
||
M12: … commanded +4000 W against a meter reading -2000 W …
|
||
```
|
||
|
||
**⚠️ AND IT IS WORSE THAN THE ONE ROUND 2 CLOSED.** Part 9c asserts the SITE
|
||
detector is **not armed at a roster of one**, and inverter B does not physically
|
||
exist. So on the shipped install the **per-unit banner is the only
|
||
sign-disagreement sentence that can ever be printed** — in the action log, in
|
||
`_LOG.error`, in `status_payload()`'s headline banner, and in `sign_reason`,
|
||
which is persisted and read back after the restart the latch is designed to
|
||
survive. Round 2 pinned the N≥2-only call site and left the shipping one open.
|
||
|
||
**Closed by the ORDERED PAIR on both per-unit banners**, with both operands read
|
||
off the **observed cycle** — `inv.last_write` and `inv.batt` off the objects the
|
||
plant moved, never off the arguments the detector was handed, which would be
|
||
comparing the swap against itself:
|
||
|
||
* `test_watchdog.py` **part 3**, the N≥2 per-unit trip:
|
||
`f"commanded {stood:+.0f} W against its own measured power {meas:+.0f} W"`,
|
||
plus the persisted `unit/inverter-b/sign_reason` copy, plus a **precondition**
|
||
that the two operands are different numbers of opposite sign — without which
|
||
a transposition would produce the same string and the check would be
|
||
decoration;
|
||
* `test_watchdog.py` **part 7**, the **N=1** controller-driven trip:
|
||
`f"commanded {stood:+.0f} W against a meter reading {grid:+.0f} W"`, plus the
|
||
namespaced key **and** the flat rollback mirror, plus the same precondition.
|
||
|
||
**Measured after the fix: M12 is RED on four named checks in
|
||
`test_watchdog.py` and GREEN in the other twenty files** — one file, four
|
||
checks. M11 re-measured on the same tree: still red.
|
||
|
||
**⚠️ Neither §6a nor MUTATION.md disclosed it.** §6a's "what was NOT closed"
|
||
enumerated `circ_wd`, the eight other `CLAIM_SCOPE` entries, `actuator_of()`'s
|
||
body and the `n_units > 1` guard — not this. MUTATION.md's own closing rule, in
|
||
the file round 2 edited, is *"a variant is not covered because a near-miss of it
|
||
is."* §7 is the enumeration that would have caught both instances at once.
|
||
|
||
## 5. The survivor this card went looking for, and closed
|
||
|
||
M7 is not one of the card's three. It was found by asking the question the
|
||
recurring defect demands — *what mutation would my new checks NOT see?* — and it
|
||
is the answer: **−11 bytes**, and it survived every check above **and the whole
|
||
20-file suite**, because in every fixture built so far the arbiter's site target
|
||
and the sum of what actually stands on the wire are **the same number**.
|
||
|
||
They are not the same number when a setpoint device's own reported range is
|
||
narrower than its `max_w`. The `device` P_SAFETY claim is filed **per actuator**
|
||
(`actuator_of("device", inv)`), so it bounds each unit's **share** and never the
|
||
site target, and `clamp_to_rating()` clips again on the way out. On two units
|
||
rated 220/280 W whose devices report ±120/±130 W, the site asks for **−500 W**
|
||
and **−250 W** stands.
|
||
|
||
Which one the guards mean is already written down, in both of them: SAFETY-02
|
||
grades *"the command standing on the wire"*, and `looks_frozen()`'s docstring
|
||
says `commanded_w` is *"the EMITTED setpoint — what the arbiter resolved and
|
||
`write_setpoint()` put on the wire — not the loop's raw wish"*. [[SAFETY-20]] is
|
||
the whole card about the difference. Those sentences are now made to bite: on
|
||
the site target the detector fires on a site standing at −250 W against a
|
||
+2250 W meter — **under its own 300 W gate** — which is a latching, site-wide
|
||
false trip on an install whose inverters simply report a narrower range than the
|
||
operator typed.
|
||
|
||
## 6. Honest gaps
|
||
|
||
* **A mutation that changes the SIGN CONVENTION of `standing` itself** — i.e. in
|
||
`main.py`'s `stood = inv.last_write if inv.last_write is not None else
|
||
inv.target`, not on the reduction line — is **not** in this ledger. It is a
|
||
different line and a different card; §3a's obedience precondition would see
|
||
some of it, but that is not a measured claim and is not made here.
|
||
* **The freeze half is graded at ONE roster length (2) and one rail.** The
|
||
`RAIL_FRAC` constant, `rail_ceiling_w()`'s device-bound conjunction and
|
||
`rail_unreachable()` are `test_freeze.py`'s subject and are untouched here.
|
||
* **The site hold is 2 s, not the shipped 325 s.** The window's derivation is a
|
||
rig campaign (`DECISIONS/SAFETY-30-per-unit-sign.md` §3B) and this file does
|
||
not re-grade it. The **gate** is the shipped 300 W, which is the coordinate
|
||
this card is about.
|
||
* **Everything about inverter B here is simulated and labelled so.** No number
|
||
in this document or in the fixture is a measurement of a device that does not
|
||
yet exist.
|
||
|
||
### 6a. Round 2: what was closed, and what was NOT
|
||
|
||
Round 1's reviewer disclosed that it had not mutated SAFETY-31/circulation, the
|
||
arbiter claim wiring, or ledger entries M4/M5/M6/M8/M9/M10. Enumerated, with the
|
||
gaps left open stated rather than rounded off:
|
||
|
||
**Closed in round 2 (measured, on `86eaef2`, bytecode purged, edit re-read from
|
||
disk, byte delta printed):**
|
||
|
||
1. **M11**, the zero-byte argument swap — §4a. Red on one named check.
|
||
2. **M3**, the `[0]` reduction — re-measured, **survives on release/1.0**, closed
|
||
by part 9a and 9b. The false attribution corrected in all four places it was
|
||
committed: this file §1 and §4, `CHANGELOG.md`, and `test_watchdog.py`'s part
|
||
9 header (which previously *instructed future authors not to re-cover it*).
|
||
3. **M1, M2, M4, M5, M6, M7, M8, M9, M10** — every ledger entry re-run
|
||
independently in round 2 rather than carried forward from round 1's report.
|
||
All ten red; M1/M2/M4 re-run in the exact wrapped form the table records so
|
||
the byte deltas are the table's own (+24/+9/+9).
|
||
4. **W1**, `sign_site` claim scope — §4b. Zero bytes, red, part 9a among the
|
||
killers.
|
||
5. **W2**, `circulation` claim scope — §4b. Zero bytes, red, but **entirely on
|
||
pre-existing checks**.
|
||
|
||
**NOT closed, and not claimed:**
|
||
|
||
* **SAFETY-31's `circ_wd` detector itself** — its threshold, its hold, its
|
||
`circulating_w()` reduction over the roster. Only its **claim scope** was
|
||
mutated (W2), and that kill belongs to checks this card did not write.
|
||
Circulation's own behavioural coverage is `test_watchdog.py` part 4's and is
|
||
untouched here.
|
||
* **The other eight `CLAIM_SCOPE` entries** — `loop`, `peak`, `arbitrage`,
|
||
`safety` (site) and `device`, `supervised`, `failsafe`, `maintenance` (unit).
|
||
`CLAIM_SCOPE` has ten entries; only the two named above were mutated. `test_peakguard.py` part 10f grades the
|
||
classification statically; that is a different instrument and this card did
|
||
not re-measure it.
|
||
* **`actuator_of()`'s own body** and the `n_units > 1` guard on the `sign_site`
|
||
append at `main.py:1764` were **not** mutated.
|
||
* **The ten ledger mutations were re-graded against `test_watchdog.py` only.**
|
||
The "green in the other twenty files" half of the round-1 claim was
|
||
re-verified on `86eaef2` for **M3** and **M11** (both full 21-file runs) and is
|
||
otherwise carried forward from round 1's measurement on `f72c48d`. It is a
|
||
weaker statement than round 1 made and is written down as such.
|
||
⚠️ **Round 3 closed the other half of this**, but only in the *before*
|
||
direction: all ten were re-run on clean `release/1.0` @ `bbc903b` under the
|
||
**full 21-file suite** to establish which of them the pre-existing coverage
|
||
could see — see §4's ⚠️ paragraph, and the answer was **eight survivors, not
|
||
three**. The *after* direction — "each of the ten is red in
|
||
`test_watchdog.py` and green in the other twenty **on the round-3 tree**" —
|
||
is still measured end-to-end only for **M3, M11 and M12**. For the other
|
||
seven, "red" carries forward from round 2 (which adding assertions cannot
|
||
undo) and "green elsewhere" carries forward from round 1.
|
||
* **`test_peakguard.py`'s CI-06 flake is not fixed by this card** and is not
|
||
this card's subject. It is *characterised* here (five isolated runs on the M3
|
||
tree, all PASS) only far enough to overturn the false kill it produced.
|
||
|
||
### 6b. Round 3: what was closed, and what is still open
|
||
|
||
**Closed in round 3:**
|
||
|
||
1. **M12**, the zero-byte argument swap at the **per-unit** call site
|
||
`main.py:1560` — §4c. It was a survivor of the round-2 tree at **21/21
|
||
green**, and it is the **only** sign-disagreement banner a shipped
|
||
one-inverter install can print. Red on four named checks, green in the other
|
||
twenty files.
|
||
2. **The ledger arithmetic in §4**, which round 2 made specific and wrong. See
|
||
§4's own ⚠️ paragraph: re-measured mutation by mutation on clean
|
||
`release/1.0` @ `bbc903b`, full 21-file suite each time.
|
||
3. **The family itself is now enumerated by machine** —
|
||
`tools/symmetric_message_scan.py`, §7 — rather than by hand for a third time.
|
||
|
||
**Open, named, and NOT claimed:**
|
||
|
||
* **A3 and B1** — the only two status-page rows in which the freeze detector
|
||
ever names a device range: the **NOT COVERED** row and the **armed** row's
|
||
"below max_w because …" clause. `rail_unreachable()`'s `dev_min`/`dev_max` at `main.py:3208` and the
|
||
literal `f"only {inv.dev_min}...{inv.dev_max} W"` at `main.py:3231`. Both
|
||
measured: **0 bytes each, 21/21 green each.** Real, open members of the
|
||
family. They are **status-page rows rather than latching alarms**, and closing
|
||
them wants a fixture with a device whose two bounds are **asymmetric** — every
|
||
fixture in this repository uses `±d`, so the transposition is currently
|
||
invisible by construction — which belongs to `test_freeze.py`'s card, not to
|
||
this one.
|
||
* **Twelve census-B sites** (§7c, B2–B12) determined unpinned at the **source
|
||
level** — the distinguishing literal appears in no `test_*.py` — and **not**
|
||
mutated. Weaker evidence than A1–A4 and B1, and written down as such
|
||
— §7d says the same thing with the same list.
|
||
* **B6**, SAFETY-31's `circ_reason`, is the same shape as the defect this card
|
||
closed for SAFETY-02/30 and is *also* persisted and re-read after a restart.
|
||
§6a already declines to touch `circ_wd`; §7d says what that costs.
|
||
* Everything §6a lists stays open: `circ_wd`'s own thresholds, the eight other
|
||
`CLAIM_SCOPE` entries, `actuator_of()`'s body, and the `n_units > 1` guard.
|
||
|
||
### 6c. Round 4: what was closed, and what is still open
|
||
|
||
**The blocker round 3 shipped.** The enumeration read as complete and covered
|
||
**one syntactic sink**. Every one of census B's 16 members was a sentence that
|
||
is **appended, assigned, or passed as a log argument** (`problems.append`,
|
||
`out.append({"text": …})`, `self.reason = …`, `_LOG.info`). **Not one was a
|
||
sentence that is RETURNED.** Census A has a return bucket, but only for
|
||
symmetric **parameters**, so a method formatting its **own attributes** and
|
||
handing the string back was invisible to both — and `describe()` / `explain()` /
|
||
the reason-builders are exactly where this codebase keeps its operator
|
||
sentences. Worse than the gap: it was in **none of the five blind spots the tool
|
||
prints about itself**. A tool that asserts a completeness it does not have is
|
||
the recurring defect wearing a new hat.
|
||
|
||
**Closed in round 4:**
|
||
|
||
1. **`clamp.py:253` `ClampResult.describe()`**, pair `requested_w` /
|
||
`applied_w` — §7e C6. A survivor of the round-3 tree at **21/21 green**,
|
||
**0 bytes**. It is the §15 action-log row at `main.py:1366` **and** the
|
||
ClampWatchdog notification reason at `main.py:2452`; **no `test_*.py`
|
||
mentioned `describe()` at all.** Pinned on the ordered pair, on the
|
||
contiguous literal, at **both** consumers, with a precondition that the two
|
||
operands render differently — `test_clamp.py` part 6d. **4 checks red** under
|
||
the transposition.
|
||
2. **`peakguard.py:493` `PeakDecision.explain()`**, pair `projected_w` /
|
||
`ceiling_w` — §7e C7. Same measurement: **0 bytes, 21/21 green**; the string
|
||
`"peak guard idle"` was in no `test_*.py`. Pinned on the decision and on the
|
||
shipped status row at `main.py:3313` — `test_peakguard.py` part 13. **3
|
||
checks red.**
|
||
3. **`arbiter.py:264` (B12)** — the check whose **claim was wider than its
|
||
assertion**, carried forward from §7d. Measured this round rather than left
|
||
at source level: **0 bytes, 21/21 green** before, **`test_arbiter.py` red**
|
||
after. **2 checks red.**
|
||
4. **The tool's sinks are now stated rather than implied.** It prints what it
|
||
scans as a MESSAGE, that it now scans the RETURN sink through the returning
|
||
method's call sites, and — the part that was missing — **an explicit list of
|
||
what it does not scan at all**, largest hole first.
|
||
|
||
**⚠️ The correction round 4 owes the ticket.** The brief placed
|
||
`PeakDecision.explain()`'s idle sentence at `main.py:3299/3302`. Those two lines
|
||
are the **floor-limited** branch, where `explain()` returns `self.reason` before
|
||
the idle literal is ever built. The idle sentence reaches the page at
|
||
**`main.py:3313`** and the action log at **`main.py:1951`** (edge-logged, so it
|
||
is the RELEASE edge that writes it). The pin is on 3313; 1951 is disclosed
|
||
below, unpinned.
|
||
|
||
**Open, named, and measured — see §7f for the ledger:**
|
||
|
||
* **TEN further zero-byte survivors** found by the widened scan, each measured
|
||
at 21/21 green on this tree: **two** in `allocator.py`'s `UnitPlan.explain()`
|
||
(C1 :154, C2 :160), three in `arbiter.py`'s two `explain()`s (C4a, C4b :344,
|
||
C5 :392), two in `tariff.py` (C8, C9), two in `arbitrage.py`'s trade reasons
|
||
(B17, B18) and one in `main.py`'s SoC-floor notification (B19).
|
||
None is a control decision; every one is a sentence an operator reads.
|
||
⚠️ **CORRECTED after review, and the error is worth keeping visible.** This
|
||
bullet first said "Nine" while listing eleven, and the CHANGELOG said eleven
|
||
while listing eleven — and BOTH counted `allocator.py:162` (C3) among the
|
||
survivors when §7f records it as a KILL, red in `test_alloc.py`. The
|
||
authoritative tables in §7e and §7f were right throughout; only the summary
|
||
sentences a reader actually relies on were wrong. That is this card's own
|
||
recurring defect in prose rather than in code: a claim wider than the
|
||
measurement beneath it. Arithmetic, from §7f: 14 measured = 10 surviving +
|
||
1 already caught (C3) + 3 caught by this round (C6, C7, B12).
|
||
* **The action-log surface of the peak-guard idle line** (`main.py:1951`) is
|
||
not pinned. The status row (3313) is, and both come off the same `explain()`,
|
||
so the *sentence* is closed and the *second call site* is not — which is
|
||
precisely the distinction §4c was written about, recorded rather than assumed.
|
||
* Everything §6a and §6b list stays open.
|
||
|
||
## 7. The symmetric-predicate family — every call whose argument order is load-bearing for a MESSAGE
|
||
|
||
**Why this section exists, and it is the most useful thing in the card.** Round 2
|
||
pinned the operand order of `SignWatchdog.evaluate()` at `main.py:1620`. Round 3
|
||
found the **identical zero-byte defect at `main.py:1560`**, one call site over —
|
||
and that one is worse, because `test_watchdog.py` part 9c asserts the site
|
||
detector is **never armed at
|
||
a roster of one**, so on the shipped install the per-unit banner is the **only**
|
||
sign-disagreement sentence that can ever be printed. Twice in one card the
|
||
enumeration was written by hand and twice it was short. MUTATION.md habit 12: *a
|
||
hand-written scope is the same defect as a hand-written fixture.* So the family
|
||
is now **computed**, by `tools/symmetric_message_scan.py`.
|
||
|
||
### 7a. What makes this family invisible, and why it is not a fixture problem
|
||
|
||
Every other defect in this document is a fixture problem: *no fixture happens to
|
||
separate the mutant from the shipped code.* Build the right fixture and it goes
|
||
red. This one is not that.
|
||
|
||
Where a callee is **symmetric** in two arguments, a swap at a call site cannot
|
||
change **any** action on **any** input. `watchdog.disagrees()` is
|
||
`commanded_w * against_w < 0` with an `abs()` gate on **both** sides: the
|
||
product commutes and the two gates are the same gate. There is nothing for a
|
||
behavioural check to observe, ever — **not "no fixture separates them" but no
|
||
fixture *can*.** The only surface that moves is the **sentence**, and the only
|
||
check that can see it is one asserting the **ordered pair** (MUTATION.md habit
|
||
14) at **that** call site.
|
||
|
||
Two consequences the earlier rounds got wrong:
|
||
|
||
* **The unit of coverage is the CALL SITE, not the function.** `evaluate()`'s
|
||
operand order was pinned and the defect still shipped, because the pin was on
|
||
the *other* call.
|
||
* **A behavioural suite is not weak evidence here, it is NO evidence.** "21/21
|
||
green" is what this mutation is *guaranteed* to produce. Measured, twice: M11
|
||
at `main.py:1620` on the round-1 tree, and M12 at `main.py:1560` on the
|
||
round-2 tree.
|
||
|
||
### 7b. Census A — the callee formats its own parameters
|
||
|
||
`tools/symmetric_message_scan.py` renames each parameter pair, erases every
|
||
formatted string, canonicalises the commutative operators (`*`, `+`, `and`,
|
||
`or`, `==`, `min`/`max`, iterated tuple literals) and compares the **bodies**;
|
||
it iterates to a **fixpoint**, because the symmetry is usually one call deep —
|
||
`evaluate()` is symmetric only *through* `disagrees()`, and a single pass
|
||
reports an empty family on a tree whose answer is already known. It then
|
||
classifies each sentence by **where it goes**.
|
||
|
||
Fixpoint on this tree: **3 rounds, 17 interchangeable positional and 5 keyword
|
||
pairs across 14 functions**. 45 parameter pairs reach a sentence; **8** are
|
||
symmetric — **5** whose sentence goes only to an operator, and **3** whose
|
||
sentence is the **return value** and therefore has to be classified by hand,
|
||
because a returned string is sometimes a KEY and sometimes a SENTENCE.
|
||
|
||
| # | scan row | pair | called at | swap costs | reaches an operator via | pinned? |
|
||
|---|---|---|---|---|---|---|
|
||
| **A1** | `watchdog.py:613` `SignWatchdog.evaluate()` | `(commanded_w, against_w)` | **`main.py:1560`** — per-unit, **and the N=1 detector** | **0 B** | `sign_reason` (persisted), the action log, `_LOG.error`, `status_payload()`'s headline banner | **YES — round 3.** `test_watchdog.py` part 3 (`its own measured power`) and part 7 (`a meter reading`), on the contiguous literal with both operands read off the observed cycle. **Measured: 0 B, red on 4 named checks, green in the other 20 files.** |
|
||
| **A2** | same row, second call site | `(commanded_w, against_w)` | **`main.py:1620`** — site | **0 B** | `site_sign_reason`, the action log, the status page | **YES — round 2**, part 9a. Re-measured on the round-3 tree: still red. |
|
||
| **A3** | `freeze.py:405` `FreezeWatchdog.evaluate()` and `freeze.py:311` `rail_unreachable()` — both symmetric through `rail_ceiling_w()`'s `for bound in (dev_min, dev_max)` | `(dev_min, dev_max)` | **`main.py:3208`** (and 1928, 2755, 3196, 3219) | **0 B** | the status page's `freeze detector: NOT COVERED — the setpoint device reports a range of …` row | **NO.** Measured: swapped at `main.py:3208-3209`, **0 bytes**, `main.py` fingerprint `61aa413f…` → `2305b560…`, **21/21 green**. Disclosed in §7d, not closed. ⚠️ `rail_unreachable()` is the row the scan puts in its **RETURN** bucket: its returned string is a **sentence**, not a key, so it is a family member after all — which is exactly why that bucket says *read by hand*. |
|
||
| **A4** | `static_tariff.py:522` `_raise_gap()` | `(start, end)` | `static_tariff.py:509`, `:513` | **0 B** | the `ProfileInvalid` refusal an operator reads at startup | **YES — pre-existing.** Measured: both call sites swapped, **0 bytes**, `static_tariff.py` fingerprint `caef7fde…` → `50e9a768…`, **red in `test_static_tariff.py` on two named checks** (*"…and names the uncovered range so the operator can fix it"*, *"…and the refusal names the uncovered range"*), because `_hhmm(start)}-{_hhmm(end)` is asserted as the contiguous literal `"22:00-23:00"`. ⚠️ That batch run also reported `FAIL test_peakguard.py` on a `static_tariff.py`-only edit; the failing check is *"one cycle of the intent form is exactly the delivered form plus the command standing on the wire"*, which is [[CI-06]]'s **mechanism 1 by name**, and `test_peakguard.py` passes **four for four** in isolation on that same tree. No claim here rests on it. |
|
||
| A5–A6 | `static_tariff.py:522` `_raise_gap()` | `(start, weekday)`, `(end, weekday)` | — | — | — | **not real — the scan's one over-report.** It erases the sentence, so it cannot see that `DAY_NAMES[start]` with `start = 1320` raises `IndexError`. A mixed-type pair fails loudly, which is the opposite of this family. |
|
||
| A7–A8 | `store.py:177` `unit_key()`, `mqtt.py:222` `unit_state_key()` | `(unit, key)`, `(slug, key)` | many | 0 B | — | **not this family.** The formatted string is the **return value and it is a KEY**: a swap renames a store key and an MQTT topic, which is behaviour every ordinary check can see. MAINT-05 is the card about it. |
|
||
|
||
### 7c. Census B — the caller builds the sentence
|
||
|
||
Census A only sees a callee formatting its **own** parameters, which was gap 2
|
||
of its own honest-gap list. The other half never crosses a function boundary:
|
||
an operator sentence built where it is printed, interpolating two values that
|
||
are **interchangeable by shape**. No predicate is involved at all, so census A's
|
||
symmetry test has nothing to test. The scan pairs interpolated expressions that
|
||
are **one leaf apart** — `inv.dev_min` / `inv.dev_max`, `cfg.min_w` /
|
||
`cfg.hold_s` — which is exactly what a transposition swaps, and it keeps only
|
||
sentences whose sink is a **message**: an f-string that becomes a URL or a store
|
||
key is data, and a swap there is an ordinary behavioural change. **16 candidates** in round 3, every one of them a **zero-byte** edit — and **22** after round 4 widened the sink classifier (B17–B22 below):
|
||
|
||
| # | site | pair | reaches an operator as | pinned? |
|
||
|---|---|---|---|---|
|
||
| **B1** | `main.py:3231` | `inv.dev_min` / `inv.dev_max` | status page, `… accepts only −120…130 W` | **NO — MEASURED.** Transposed in place, **0 bytes**, `main.py` fingerprint `61aa413f…` → `ac36a97b…`, purged, `:ro`, **21/21 green**. The row then tells the operator the device accepts `130…−120 W`. |
|
||
| **B2** | `main.py:3362` | `inv.dev_min` / `inv.dev_max` | status page, `setpoint … (range −120…130 W)` | **NO** — the literal `setpoint … (range` is in no `test_*.py`. |
|
||
| **B3** | `main.py:3435` | `sign_wd.cfg.hold_s` / `.cfg.min_w` | status page, `sign watchdog: armed (trips after 180 s opposed, both over 300 W)` | **NO** — `s opposed` is in no `test_*.py`. |
|
||
| **B4** | `main.py:3460` | `site_sign_wd.cfg.hold_s` / `.min_w` | status page, the site sign row | **NO** — `armed against the sum` is in no `test_*.py`. |
|
||
| **B5** | `main.py:3491` | `circ_wd.cfg.hold_s` / `.min_w` | status page, the circulation row | **NO** — `circulation watchdog: armed` is in no `test_*.py`. |
|
||
| **B6** | `circulation.py:317` | `cfg.min_w` / `cfg.hold_s` | **`circ_reason` — persisted, and re-read after a restart** | **NO** — `test_watchdog.py` asserts only `"CIRCULATING_POWER" in reason`. **This is SAFETY-31's twin of the exact defect this card just closed for SAFETY-02/30**, on a string with the same persistence. |
|
||
| **B7** | `main.py:2454` | `self.site_max_w` / `self.target` | the clamp action-log line | **NO** — `was clamped to` is in no `test_*.py`. |
|
||
| **B8** | `main.py:3386` | `clamp_wd.engagements` / `.episodes` | status page, `engaged N time(s) in M episode(s)` | **NO** — `time(s) in` is in no `test_*.py`. |
|
||
| **B9–B11** | `main.py:3169` (three pairs) | `p1.net_w` / `.samples` / `.parse_errors` | status page, the P1 row | **NO** — `P1 meter (` is in no `test_*.py`. |
|
||
| **B12** | `arbiter.py:264` | `c.source` / `c.actuator` | `scope_errors`, the arbiter's mis-routing refusal | **ROUND 4: MEASURED, THEN CLOSED.** Round 3 left it at source level; the transposition is **0 bytes**, `arbiter.py` `fdb4ed0520eeb721` → `9111f627ef639ecc`, and it was **21/21 green**. Now red in `test_arbiter.py` on 2 checks. Round 3's reasoning was right: `test_arbiter.py:452` says it *"names the source and the actuator it does not belong on"* and then asserts `"peak" in r_mis.scope_errors[0]`. Under the swap the sentence becomes `"inverter-b: a SITE claim filed on unit 'peak'"` — and `"peak" in …` is **still true**. A claim wider than its assertion, which is habit 14's own shape. |
|
||
| **B13** | `validate.py:723` | `meter_phases` / `meter_max_age_s` | the startup refusal | **NO** for the ordered pair. |
|
||
| **B14** | `validate.py:1211` | `arbitrage_soc_floor` / `arbitrage_soc_ceiling` | the startup refusal | **NO** for the ordered pair. |
|
||
| **B15** | `watchdog.py:667` | `cfg.min_w` / `cfg.hold_s` | the `SIGN_DISAGREEMENT` sentence's `(limits: both over 300 W, held over 180 s)` clause | **YES**, and it is worth naming because it shows the habit works. `test_watchdog.py`'s SAFETY-30 section asserts the site instance's **whole sentence with `==` against a literal**, the `(limits: …)` clause included. Full-string equality is habit 14 taken to its limit. |
|
||
| B16 | `main.py:237` | `i.name` / `i.max_w` | the roster log line | **not real** — a `str` beside a `:.0f` format. The same over-report as A5–A6: a mixed-type transposition raises rather than lying. |
|
||
| **B17** | `arbitrage.py:377` | `v.spread_eur_kwh` / `v.required_spread_eur_kwh` | the DISCHARGE decision's `reason` | **ROUND 4, NEW, and MEASURED: 0 bytes, 21/21 green.** Not visible to round 3 at all — the sentence is handed to a constructor as `reason=`, and the sink walker scored only *assignments*. The row then reads *"0.0400 above the cheapest and clear of the 0.1200 this cycle had to cover"* with the two swapped: a trade justified by a spread it did not clear. |
|
||
| **B18** | `arbitrage.py:393` | same pair, CHARGE branch | the CHARGE decision's `reason` | **ROUND 4, NEW, MEASURED: 0 bytes, 21/21 green.** |
|
||
| **B19** | `main.py:2652` | `g.reason` / `g.soc_floor_reason` | the `goodwe_peak_soc_floor` notification body | **ROUND 4, NEW, MEASURED: 0 bytes, 21/21 green.** The alarm then opens with the *reason the floor bound* and closes with the *guard's own conclusion*, i.e. both halves of a two-part explanation in the wrong order, in the one message SAFETY-24/25 exist to send. |
|
||
| B20–B22 | `main.py:2558`, `main.py:2652` (two more pairs) | `g.reason` / `g.ceiling_w`, `g.ceiling_w` / `g.soc_floor_reason` | the same two notifications | **new in round 4, NOT measured** — mixed types (`str` beside `:.0f`), the same over-report class as B16. |
|
||
|
||
**⚠️ Six of these twenty-two were invisible to round 3, and the reason is one
|
||
line of the sink classifier.** It scored a sentence a MESSAGE when it was
|
||
appended, logged, raised, or assigned to a `reason`-shaped **name**. This
|
||
codebase mostly builds operator sentences as `Decision(…, reason=f"…")` and
|
||
`out.append({"text": f"…"})` — a **keyword argument** and a **dict key**, which
|
||
that walker stepped straight past. Both are message sinks now.
|
||
|
||
### 7d. What this enumeration does NOT close, with sizes
|
||
|
||
* **A3 is a measured, open survivor.** `dev_min`/`dev_max` transposed in
|
||
`rail_unreachable()`'s NOT COVERED row: **0 bytes, 21/21 green.** It is
|
||
disclosed here rather than fixed, because closing it wants a `test_freeze.py`
|
||
or status-page fixture with a device whose two bounds are **asymmetric** —
|
||
every fixture in the repository uses `±d` — and that is a different file's
|
||
card. It is a **status-page row**, not a latching alarm.
|
||
* **B1 is a second measured, open survivor.** `inv.dev_min` and `inv.dev_max`
|
||
transposed in `main.py:3231`'s status row: **0 bytes**, fingerprint
|
||
`61aa413f…` → `ac36a97b…`, **21/21 green**. The page then tells the operator
|
||
the device accepts `130…−120 W`. Same disposition as A3: **A3 is the row the
|
||
freeze detector prints when the rail is UNREACHABLE, B1 the clause the ARMED
|
||
row prints when the device is what set the rail** — the two rows in which that
|
||
detector ever names a device range, and neither one's operand order is
|
||
asserted anywhere.
|
||
* **Thirteen further census-B sites are unpinned and UNMEASURED.** B2–B14 above
|
||
are determined at the **source level** — the distinguishing literal appears in
|
||
no `test_*.py`, or the check that exists asserts a substring wider than its
|
||
own claim — and were **not** mutated. That is a weaker statement than A1–A4
|
||
and B1, and is written down as one.
|
||
* **B12 is a check whose CLAIM is wider than its ASSERTION**, which is habit
|
||
14's own shape one module over. `test_arbiter.py:452` says
|
||
`scope_violations` *"names the source and the actuator it does not belong
|
||
on"* and then asserts `"peak" in r_mis.scope_errors[0]`. Swap `c.source` and
|
||
`c.actuator` in `arbiter.py:264` and the sentence becomes
|
||
*"inverter-b: a SITE claim filed on unit 'peak'"* — and the substring is still
|
||
found. Source-level, not mutated, and not fixed here.
|
||
* **B6 is the closest thing to a repeat.** SAFETY-31's `circ_reason` is
|
||
persisted and re-read after a restart, exactly like `sign_reason`, and its
|
||
two limits are graded by a bare `"CIRCULATING_POWER" in reason`. This card
|
||
does not fix it; §6a already declines to touch `circ_wd`, and this is the
|
||
precise shape of what that declining costs.
|
||
* **The instrument's own blind spots** are printed by the instrument, every
|
||
run: semantic symmetry it cannot spell (it under-reports), census B pairs
|
||
that are the same *kind* but not the same *shape*, `%s`-style logging and
|
||
non-f-string payloads, anything outside `app/`, and the one way it
|
||
**over-reports** — it erases the sentence, so a swap that would make the
|
||
formatting itself raise is scored as changing no decision (A5–A6 above).
|
||
|
||
### 7e. Census C — the sentence an object returns about itself (round 4)
|
||
|
||
**Why there is a third census.** A and B between them covered **one syntactic
|
||
sink**. Everything either of them reported was a sentence that is appended,
|
||
assigned, or logged; **none was returned**. Census A's RETURN bucket only ever
|
||
held a callee formatting its own **parameters**, so a method formatting its own
|
||
**attributes** and handing the string back fell between them — and that is the
|
||
whole `describe()` / `explain()` / reason-builder family, which is where the
|
||
operator sentences in this codebase actually live.
|
||
|
||
**The test** is census B's — one f-string, two interpolations **one leaf apart**
|
||
— run over the RETURN sink, plus one thing B never needed: **a returned string
|
||
is not yet a message.** `store.unit_key()` and `mqtt.unit_state_key()` return
|
||
f-strings too, and swapping there renames a store key and an MQTT topic, which
|
||
every ordinary check can see (MAINT-05). So the sink of a returned sentence is
|
||
**resolved through its call sites**: it is in this family only when some caller
|
||
in `app/` hands it to an operator. That resolution matches on the **bare method
|
||
name**, which over-collects — at least four unrelated classes have an
|
||
`explain()` — so every verdict is printed **with the sites that produced it**,
|
||
and a MESSAGE verdict is a prompt to read them, not a proof.
|
||
|
||
The scan reports **53 returned pairs, 39 reaching an operator, 16 of those
|
||
sharing a format spec** (two values rendered alike, so a transposition is silent
|
||
rather than visibly broken). It **rediscovered both members this round was sent
|
||
to fix without being told where to look**, which is the only reason to trust the
|
||
rest of the list.
|
||
|
||
| # | site | pair | reaches an operator as | Δ bytes | verdict |
|
||
|---|---|---|---|---|---|
|
||
| **C6** | `clamp.py:253` `ClampResult.describe()` | `requested_w` / `applied_w` | §15 action log (`main.py:1366`) **and** the ClampWatchdog notification reason (`main.py:2452`) | **0** | **was a SURVIVOR at 21/21 — now RED**, `test_clamp.py` part 6d, 4 checks |
|
||
| **C7** | `peakguard.py:493` `PeakDecision.explain()` | `projected_w` / `ceiling_w` | status row `main.py:3313`; action log `main.py:1951` on the release edge | **0** | **was a SURVIVOR at 21/21 — now RED**, `test_peakguard.py` part 13, 3 checks |
|
||
| C1 | `allocator.py:154` `UnitPlan.explain()` | `self.span` / `self.why` | the arbiter/allocator log line | **0** | **SURVIVOR, 21/21 green** |
|
||
| C2 | `allocator.py:160` | `self.span` / `self.why` | the ENVELOPE VIOLATED line | **0** | **SURVIVOR, 21/21 green** |
|
||
| C3 | `allocator.py:162` | `self.span` / `self.why` | the ordinary in-envelope line | **0** | **RED** — `test_alloc.py` already asserts that whole literal. The one member of this census the suite could already see, and it says the habit works. |
|
||
| C4a | `arbiter.py:344` `UnitBounds.explain()` | `self.lo` / `self.hi` | `CONTRADICTION within the unit (device lo=… > peak hi=…)` | **0** | **SURVIVOR, 21/21 green** |
|
||
| C4b | `arbiter.py:344` | `self.lo_src` / `self.hi_src` | the same sentence, the two SOURCE names | **0** | **SURVIVOR, 21/21 green** |
|
||
| C5 | `arbiter.py:392` `Resolution.explain()` | `self.winner` / `self.bound_by` | `main.py:2236`'s `ARBITER:` action-log row and `self.reason` at `main.py:2329` | **0** | **SURVIVOR, 21/21 green** — the winner and the limiter swap names, on the line §10.1 exists to make legible |
|
||
| C8 | `tariff.py:279` `PeakReading.explain()` | `self.month` / `self.detail` | the PEAK UNREADABLE row | **0** | **SURVIVOR, 21/21 green** |
|
||
| C9 | `tariff.py:528` `Projection.explain()` | `self.projected_w` / `self.elapsed_s` | the projection line | **0** | **SURVIVOR, 21/21 green** — watts printed as seconds and seconds as watts, both `:.0f` |
|
||
|
||
**Not in the family, and the scan says why rather than dropping them silently:**
|
||
`inverter.py:338` `__repr__()` and `static_tariff.py:378` `words` are reported
|
||
**UNRESOLVED — no call site inside `app/`** (a `__repr__` is called by the
|
||
interpreter, not by a name the scan can match); `price_forecast.py:421/428` and
|
||
`tariff.py:214` `month_key()` resolve to **OTHER** — `month_key` is a **store
|
||
key**, which is MAINT-05's case and a swap there is behaviour every check sees.
|
||
|
||
### 7f. Round 4's mutation ledger
|
||
|
||
Same protocol as §4: a fresh full-repo copy per mutation, `:ro`, bytecode
|
||
purged **and the purge count printed** before the edit, the edited file
|
||
**re-read from disk**, both sides' length and sha256 printed, and the full
|
||
**21-file** suite via `tools/mutation_run.sh`. Every row is a pure
|
||
transposition, so **every Δ is 0 bytes** — verified, not assumed: `len(old) ==
|
||
len(new)` is asserted before the write and `len(after) == len(before)` after it.
|
||
|
||
**⚠️ And every row was also required to change the ANSWER.** A byte delta says
|
||
the file moved; it does not say the sentence did. Each mutated tree is rendered
|
||
through a scratch probe that evaluates the shipped f-string with **distinct
|
||
operands** and diffs it against the same render of the clean tree. **The first
|
||
cut of that probe numbered the operands by ORDER OF APPEARANCE** — so after a
|
||
transposition the other leaf took the same number and all thirteen diffs came
|
||
back **empty**, which reads exactly like "the edit is inert". Keying the value
|
||
to the **name** fixed it. That is the same defect as everything else in this
|
||
card, in the instrument this time: a measurement comparing the swap against
|
||
itself.
|
||
|
||
| mutation | file, before → after (sha256, 16) | Δ | verdict |
|
||
|---|---|---|---|
|
||
| C1 `allocator.py:154` span↔why | `10c1b26ed0099654` → `ac0c174de1ec28ad` | 0 | **SURVIVED 21/21** |
|
||
| C2 `allocator.py:160` span↔why | `10c1b26ed0099654` → `9b10730f4659befd` | 0 | **SURVIVED 21/21** |
|
||
| C3 `allocator.py:162` span↔why | `10c1b26ed0099654` → `07097bf5da31f0c6` | 0 | **RED** — `test_alloc.py` |
|
||
| C4a `arbiter.py:344` lo↔hi | `fdb4ed0520eeb721` → `009820de7ef0eff0` | 0 | **SURVIVED 21/21** |
|
||
| C4b `arbiter.py:344` lo_src↔hi_src | `fdb4ed0520eeb721` → `04cb8cf69dfc7432` | 0 | **SURVIVED 21/21** |
|
||
| C5 `arbiter.py:392` winner↔bound_by | `fdb4ed0520eeb721` → `9238c3db920da8ad` | 0 | **SURVIVED 21/21** |
|
||
| **C6** `clamp.py:253` requested↔applied | `0153133741497fa5` → `47e4e371043e3201` | 0 | **SURVIVED 21/21 before — RED after**, `test_clamp.py`, 4 checks |
|
||
| **C7** `peakguard.py:493` projected↔ceiling | `d95df61a51716c26` → `2d2ee3d0bcd51251` | 0 | **SURVIVED 21/21 before — RED after**, `test_peakguard.py`, 3 checks |
|
||
| C8 `tariff.py:279` month↔detail | `82790523dd5227c9` → `4b2147ca17a28425` | 0 | **SURVIVED 21/21** |
|
||
| C9 `tariff.py:528` projected↔elapsed | `82790523dd5227c9` → `517e9148d26a243a` | 0 | **SURVIVED 21/21** |
|
||
| B17 `arbitrage.py:377` spread↔required | `67fbf949b2938349` → `cddd8045bdeb5e29` | 0 | **SURVIVED 21/21** |
|
||
| B18 `arbitrage.py:393` spread↔required | `67fbf949b2938349` → `04a4f3ad3d255de1` | 0 | **SURVIVED 21/21** |
|
||
| B19 `main.py:2652` reason↔soc_floor_reason | `61aa413fb890433e` → `bbcbb28f476a98d0` | 0 | **SURVIVED 21/21** |
|
||
| **B12** `arbiter.py:264` source↔actuator | `fdb4ed0520eeb721` → `9111f627ef639ecc` | 0 | **SURVIVED 21/21 before — RED after**, `test_arbiter.py`, 2 checks |
|
||
|
||
**14 measured, 12 survived the full suite before this round's checks, 1 was
|
||
already caught, 3 are caught now.** The tree these were taken on is
|
||
`SAFETY-38` @ `357cb4f` rebased on `release/1.0` @ `9e42cec`; `app/main.py` is
|
||
234 542 bytes.
|
||
|
||
### 7g. What round 4 does NOT close, with sizes
|
||
|
||
* **Eleven measured zero-byte survivors are left open** — C1, C2, C4a, C4b, C5,
|
||
C8, C9, B17, B18, B19 and the three already open from round 3 (A3, B1, and the
|
||
B2–B11/B13–B14 source-level set). Each is a sentence an operator reads and
|
||
none is a control decision. They are disclosed with their measurement rather
|
||
than pinned because pinning each one wants a fixture in the file that owns the
|
||
module — `test_alloc.py`, `test_arbiter.py`, `test_tariff.py`,
|
||
`test_arbitrage.py` — and this card has now spent four rounds inside
|
||
`test_watchdog.py`'s subject. **An enumerated list with honest gaps beats a
|
||
fifth round finding a fifth instance**; what would NOT be honest is a fifth
|
||
round finding one that is **not on this list**.
|
||
* **`main.py:1951`, the action-log surface of the peak-guard idle line,** is
|
||
unpinned. The status row off the same `explain()` is pinned; the second call
|
||
site is not, and §4c is the card about exactly that costing a round.
|
||
* **The largest remaining hole is not in any census: `%s`-style lazy logging.**
|
||
`_LOG.error("… %s … %s", a, b)` puts the two operands in *arguments*, not in
|
||
an f-string, so nothing here looks at them and the transposition is the same
|
||
zero-byte edit. The tool now says so first in its NOT-SCANNED list, **with
|
||
the size measured rather than asserted: 31 such calls across 8 modules in
|
||
`app/`, each one listed**.
|
||
* **The tool's own blind-spot list was the round-4 blocker and is now two
|
||
lists.** "What this scans, and what it does not" enumerates its message sinks
|
||
from the constants themselves and then names, in order, what it never looks at:
|
||
`%s`-style logging, `.format()`/`%`/`+`/`.join()` sentences, a sentence that
|
||
leaves through a dict or dataclass and is rendered elsewhere, `yield`ed
|
||
sentences, attributes outside the `reason`-shaped set, and everything outside
|
||
`app/`. "What it can see but gets wrong" keeps the five gaps round 3 printed,
|
||
with the over-report gap now backed by a **printed format-spec column** so a
|
||
reader can tell a silent same-typed transposition from a noisy mixed-type one.
|