SAFETY-32 round 5: stop closing sites, build an instrument that finds its own scope
checks / unit (pull_request) Successful in 4m59s

Rounds 2, 3 and 4 each closed exactly the survivor they were handed and each time
the reviewer found the same shape one step over: the reduction, the call site, the
sibling argument, and now the sibling MODULE. Round 4's enumeration stopped at the
`Controller` class boundary; round 5's four blockers were reductions in validate.py,
over a list DERIVED from the roster three calls away from it. Closing four more sites
by hand would have produced a fifth rejection, so this round widens the instrument
instead and lets it enumerate.

⚠️ THE BLOCKERS ARE NOT EQUIVALENT MUTANTS - a safety refusal disappears, verified by
calling the shipped validator both ways on the real tree:

  roster max_w 5000 (inverter-a) beside 2000 (inverter-b), sign_disagree_w 3000
    clean                        -> 1 problem, "...max_w (inverter-b) is 2000 W, so the
                                    SAFETY-02 sign-disagreement watchdog can never fire"
    min(caps[:1])          +4 B  -> 0 problems. The site STARTS.
    caps_ix[:1]            +4 B  -> 0 problems.
  the SAME roster with the small cap FIRST (2000, 5000)
    clean                        -> 1 problem, naming inverter-a
    min(caps[-1:])         +5 B  -> 0 problems.

⚠️ AND THE ONE-ROSTER FIXTURE COULD NOT HAVE SEEN THAT. On [5000, 2000] the `[-1:]`
mutant still refuses, because last IS min there. A fixture of N members cannot separate
two reductions that agree on N members - which is why the behavioural half below is a
SET and not a roster.

THE INSTRUMENT (test_peakguard.py part 12j, widened past `Controller`)

Every `.py` under app/ is read off disk and parsed - NO MODULE IS NAMED IN THE CHECK -
and the roster is found by transitive closure from ONE seed, the `inverters` option key:
through returns, through lists ACCUMULATED in a loop over a roster (which is exactly
what `caps`, `roster` and `problems` are, and what round 4's rule could not express),
through attributes and properties, and THROUGH CALL ARGUMENTS, which is how it walks
into peakguard, arbiter, allocator, circulation, maintenance and store without being
told they exist. A slice of a roster stays a roster so a mutation cannot cut the taint;
an index is one unit and does not.

  183 roster reductions across 9 modules, out of 23 files parsed:
  main 96, allocator 24, validate 19, arbiter 14, circulation 10, maintenance 10,
  peakguard 4, inverter 3, store 3.  14 modules reduce over no roster at all.

⚠️ THE HAND-WRITTEN MODULE LIST WOULD HAVE BEEN WRONG IN BOTH DIRECTIONS. It named
watchdog.py, which reduces over no roster at all (SAFETY-30's sign watchdogs are
strictly per-unit), and omitted maintenance.py and store.py, which both do. A vacuous
claim about one module and no claim at all about two others - which is the argument for
computing the scope, made with data.

The pin rediscovers ALL FOUR blockers without being told where to look, and finds a
FIFTH beside them (validate.py:783, rule 5's own message) that no review round named.
Its counterfactual runs at two depths: every enumerated site's expression mutated with
each of the four cuts this card has been defeated by (183 x 4 = 732 spellings, all
reported), and `[:1]` written into app/validate.py at each blocker position with the
WHOLE analysis re-run over the mutated source.

THE SWEEP, AND THE NUMBER THAT MUST NOT BE QUOTED ALONE

All 183 sites mutated to `[:1]`, +4 B each, against the full 19-file suite in
goodwe-addon-ci (:ro, -B, fresh rsync per mutant, 0 .pyc before and after - a read-only
mount cannot write one): 183/183 RED. ⚠️ That is TAUTOLOGICAL on its own, because the
pin makes every slice red. So the 51 whose first red was test_peakguard.py were re-run
with the pin NEUTRALISED:

  145 of 183   a behavioural fixture separates the site from a slice of itself
   38 of 183   ONLY the structural claim. Behaviourally UNGRADED.

All 38 are listed by site with what a `[:1]` there would cost, in the DECISIONS record
and in part 12j itself: 15 are rows and counts on the operator's page, 3 are
`len(targets)` arithmetic beside a write path that IS graded, 6 are allocator internals
ALLOC-01 reaches but not with enough units to separate. NONE of the 38 is closed
behaviourally by this card and none is counted as covered.

THE BEHAVIOURAL HALF, because a shape pin grades an identity and never a meaning

test_validate.py grows a ROSTER SET for the three rules that reduce over
`unit_caps_indexed()`: widths 2, 3, 4, 5 with the one small cap at EVERY position,
14 rosters x 3 rules = 42 runs of the real validator, each asserting the refusal fires
AND names the unit the number came off. Beside it a COMPUTED family table of twelve
candidates - max, first, last, the mean, [:1], [-1:], [:2], [:3], [1:], [:-1], [::2] -
each compared against what the shipped validator actually answered on the same roster.
Only `min` agrees everywhere; a member no roster kills is printed as a NAMED SURVIVOR.

⚠️ A THIRD RULE WAS READING THE FLAT `max_w`, AND IT IS SAFETY-30's

Found by reading the three branches side by side, NOT by the pin - and said that way
because it is the shape of what the pin cannot see. `unit_sign_disagree_w` was graded
against `_number(opts, "max_w", 2000.0)`, three lines under a branch that had already
stopped doing that. Both directions were measured before the fix:

  two 2000 W units + a leftover `max_w: 5000` + unit_sign_disagree_w 3000 -> 0 problems.
  The site started, the status page said `armed`, and the per-inverter watchdog could
  not trip on EITHER unit. Identical sentence to the blocker, one branch over.
  two 5000 W units with NO flat key -> REFUSED against a 2000 W default the operator
  never wrote, for a threshold both units can reach.

It takes the same `weakest` the branch above takes and names the same unit. At a roster
of one `unit_caps()` is `[max_w]`, so the shipped single-inverter refusal is the same
comparison, the same boundary and the same sentence.

MUTATION PROOF (each: bytecode purged and counted 0 before and 0 after, the edit
re-read FROM DISK with the new text present and the old absent, byte delta printed)

  B1  validate min(caps)     -> min(caps[:1])        +4 B  RED test_validate.py
  B2  validate min(caps)     -> min(caps[-1:])       +5 B  RED test_validate.py
  B3  validate caps_ix       -> caps_ix[:1]          +4 B  RED test_validate.py
  B4  validate rule 6 min    -> min(caps_ix[-1:])    +5 B  RED test_validate.py
  B5  validate rule 5 msg    -> min(caps_ix[:1])     +4 B  RED test_validate.py
  B6  validate rule 5b msg   -> min(caps_ix[-1:])    +5 B  RED test_validate.py
  B7  CONTROL: weakest -> max(0.0, weakest)         +10 B  GREEN, as intended
  R3a peakguard max(floors)  -> min(floors)           0 B  RED test_peakguard.py
  R3b peakguard max(floors)  -> max(floors[-2:])     +5 B  RED test_peakguard.py
  R3c main call site packs   -> self.packs[:2]       +4 B  RED test_peakguard.py
  R4a main pack_floor_soc    -> vals[:2]             +4 B  RED test_peakguard.py
  R4b main pack_floor_soc    -> vals[-2:]            +5 B  RED test_peakguard.py
  R4c main pack_floor_soc    -> vals[:3]             +4 B  RED test_peakguard.py
  R4d main cycle() any(...)  -> self.inverters[:1]   +4 B  RED test_watchdog.py

B1-B6 are caught by the BEHAVIOURAL set in test_validate.py, before the pin is reached.
B7 is a deliberate no-op control: `max(0.0, weakest)` is `weakest` for weakest > 0, and
it stays green, so the battery is not merely red for everything.

R3a is a ZERO-byte delta, so the shipped function was called both ways as well:
  packs floors [15.0, 30.0, 20.0], peak_soc_floor 15
  binding_soc_floor(...) = 30.0   the 0-byte mutant would answer 15.0   (+15.0 pp)
  -> the site would discharge to 15 % while pack-b's own BMS cuts out at 30 %.

⚠️ PRODUCTION IS DEGENERATE ON THE FLOOR AXIS. SPIKE-04: pack A and pack B BOTH floor
at 10 %, so a differing floor exists ONLY in the fixtures. That is the argument for
keeping them, not for simplifying them - collapsing them deletes the only data in this
repository that can see the reduction at all. Restated in config.yaml's example, in the
probe and in the fixtures.

⚠️ INVERTER B DOES NOT PHYSICALLY EXIST (~2026-09-12). Every second-unit figure here -
4600 W FROM ITS DATASHEET, 15 % illustrative, the whole roster SET - is SIMULATED and
labelled. Only inverter A's 10 % is read off hardware.

REBASED onto 67e649a. Two conflicts with SPIKE-04a, both in the blocks this card had
rewritten (config.yaml's roster example and the probe's C/D sections). Resolved to carry
BOTH claims, and the probe was RE-RUN rather than hand-merged: `all cases passed` on
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0, transcript updated with the
date. MUTATION.md habit 8 - a committed transcript stitched together out of two runs is
not a measurement.

Suite: 19/19 green in goodwe-addon-ci, mounted :ro, bytecode purged and verified 0
before and after. CI-06: test_peakguard.py run four times in isolation, green each time
(288 ok lines); test_validate.py three times, green each time.

MUTATION.md gains habits 12 and 13: build the instrument once the same shape has been
found twice AND make it discover its own scope, and say what the instrument cannot see
with sizes in the same breath.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
2026-09-05 05:36:16 +02:00
co-authored by Claude Opus 5
parent 8beba0a3e7
commit 19decf433f
7 changed files with 1188 additions and 137 deletions
+85
View File
@@ -366,6 +366,91 @@ thing that stops. Rule 8's own failure mode, reached through rule 8.
line**, where `sorted(...)[:1]` (+4 bytes) is still green — it truncates names
in one event message; the refusal itself is a different expression and is
graded, so the site still stops.
* ⚠️ **FOURTH FIX ROUND — the same family one MODULE over, and the pin widened
until it discovers its own scope.** Round 4 enumerated the family and closed
it inside the `Controller` class; the review found four more survivors
immediately, in **validate.py**, on a 19/19 green suite: `min(caps)` →
`min(caps[:1])` (+4) and `→ min(caps[-1:])` (+5) at `:779`, the caps
comprehension over `caps_ix[:1]` (+4) at `:778`, and
`min(caps_ix[-1:], key=…)` (+5) at `:893`. **None is an equivalent mutant.**
With `max_w: 5000` on inverter-a beside `2000` on inverter-b and
`sign_disagree_w: 3000`, the shipped validator refuses — *"the SAFETY-02
sign-disagreement watchdog can never fire"* — and all four **accept**: the
site starts with a watchdog reporting `armed` that cannot trip on pack B.
Rule 5's own failure mode reached through rule 5; rule 6 loses its refusal the
same way. **No shipped behaviour changed by the pin; what changed is that a
hand-written scope stopped being trusted.**
* **`test_peakguard.py` part 12j now parses every `.py` under `app/`** — no
module is named in the check — and finds the roster by transitive closure
from **one seed**, the `inverters` option key: through returns, through
lists **accumulated in a loop over a roster** (which is what `caps`,
`roster` and `problems` are, and what round 4's rule could not express),
through attributes and properties, and **through call arguments**, which is
how it walks into `peakguard`, `arbiter`, `allocator`, `circulation`,
`maintenance` and `store` without being told they exist. A slice of a roster
stays a roster so a mutation cannot cut the taint; an index is one unit and
does not. **183 reductions across 9 modules** out of 23 files parsed — `main.py` 96,
`allocator.py` 24, `validate.py` 19, `arbiter.py` 14, `circulation.py` 10,
`maintenance.py` 10, `peakguard.py` 4, `inverter.py` 3, `store.py` 3.
* **The hand-written module list would have been wrong in BOTH directions.**
It named `watchdog.py`, which reduces over no roster at all — SAFETY-30's
sign watchdogs are strictly per-unit — and omitted `maintenance.py` and
`store.py`, which both do. A vacuous claim about one module and no claim
about two others.
* **The pin rediscovers all four blockers without being told where to look,**
and finds a fifth beside them (`validate.py:783`, rule 5's message) that no
review round named. Its counterfactual runs at two depths: every enumerated
site's own expression mutated with each of the four cuts this card has been
defeated by (732 spellings, all reported), and `[:1]` written into
`app/validate.py` at each blocker position with the **whole** analysis
re-run over the mutated source — a sliced site required at that exact line,
where the clean package reports none.
* **And the behavioural half, because a shape pin grades an identity and never
a meaning.** `test_validate.py` grows a **roster SET** for the three rules
that reduce over `unit_caps_indexed()` — widths 2, 3, 4, 5 with the one
small cap at every position, 42 runs of the real validator — plus a
**computed family table** of twelve candidate reductions each compared with
what the shipped validator actually said on the same roster. Only `min`
agrees everywhere; a member no roster kills is printed as a named survivor.
* ⚠️ **A THIRD RULE WAS READING THE FLAT `max_w`, AND IT IS SAFETY-30's** —
found by reading, **not** by the pin, which is the shape of what the pin
cannot see. `unit_sign_disagree_w` was graded against
`_number(opts, "max_w", 2000.0)` three lines under a branch that had already
stopped doing that. **Both directions were measured:** two 2000 W units
beside a leftover `max_w: 5000` and `unit_sign_disagree_w: 3000` produced
**zero problems** — the site started with a per-unit watchdog that could not
trip on either unit — and two 5000 W units with no flat key were **refused**
against a 2000 W default the operator never wrote. It takes the weakest
unit's own cap now and names that unit; at a roster of one it is the same
comparison, boundary and sentence it always was.
* ⚠️ **What the pin cannot see, with sizes**, in
`DECISIONS/SAFETY-32-per-pack-bms-floor.md` §4d: semantics (`min` vs `max`
is a zero-byte edit); direct index sites that are not reductions; a
subscript by a *variable* (`weights[u]` is why); a slice through an
intermediate call; anything outside `app/`, including `sim/inject.py`; and
the taint's own seam at `*args` and unresolvable receivers, where everything
downstream goes **unenumerated rather than reported unreachable**.
`cycle()`'s undeliverable-unit log line is still green at +4 bytes and still
filed open, unchanged from round 4.
* **`tools/supervisor_shape_probe.py` re-run again (2026-09-05, 2026.08.0).**
The rebase onto `67e649a` merged SPIKE-04a's `max_w: 4600` into the same
§C/§D blocks this card had rewritten, and a transcript stitched by hand out
of two runs is not a measurement. `all cases passed`, exit 0, with B's
rating reading 4600 — its **datasheet** figure, still not a measurement of
any unit.
* ⚠️ **THE SWEEP, AND THE NUMBER THAT MUST NOT BE QUOTED ALONE.** All 183
sites mutated to `[:1]`, one at a time, against the full 19-file suite in
`goodwe-addon-ci` (`:ro`, `-B`, fresh rsync per mutant, 0 `.pyc` before and
after): **183/183 RED** — which is tautological on its own, because the pin
makes every slice red. The 51 whose first red was `test_peakguard.py` were
re-run with the pin **neutralised**: **145 of 183 have a behavioural fixture
that separates them from a slice of themselves; 38 have only the structural
claim.** All 38 are listed by site, with what a `[:1]` there would cost, in
§4d and in `test_peakguard.py` part 12j — 15 of them are rows and counts on
the operator's page, 3 are `len(targets)` arithmetic beside a write path
that IS graded, 6 are allocator internals ALLOC-01's fixtures reach but not
with enough units to separate. **None of the 38 is closed behaviourally by
this card, and none is counted as covered.**
⚠️ **No measurement of inverter B appears anywhere in this change.** It does not
exist yet. Every second-pack figure — in fixtures, in the probe, in
@@ -470,13 +470,227 @@ quietly or left unmentioned.**
### And the family closed rather than the member
`test_peakguard.py` part 12j enumerates **every** comprehension in `Controller`
that iterates the roster — 26 of them across 23 methods, computed from main.py's
AST, printed, and required to iterate the roster **whole**. A new reduction added
over `self.inverters[:1]` anywhere in the class is red **on the line it is
written**, at every roster length, with no fixture required. That check is what
would have caught rounds 2, 3 and 4's blockers on the round they were written
instead of one review round each; it is also what found rows 18-21.
`test_peakguard.py` part 12j enumerates the family from the AST and requires
every member to take the roster **whole**, so a new reduction over a slice is red
**on the line it is written**, at every roster length, with no fixture required.
That check is what would have caught rounds 2, 3 and 4's blockers on the round
they were written instead of one review round each; it is also what found rows
18-21.
⚠️ **AND IT WAS STILL TOO NARROW — see §4d.** Round 4's version enumerated the
26 roster comprehensions inside the `Controller` class, because that is where
the four known sites were. Round 5's blockers were four reductions in
`validate.py`, one module over, over a list *derived* from the roster three
calls away from it. The pin now parses every `.py` under `app/` and finds the
roster by transitive closure from one option key: **183 reductions across 9
modules.**
## 4d. ⚠️ ROUND 5 — the same family one MODULE over, and an instrument that discovers its own scope
Round 4 enumerated the family and closed it **inside the `Controller` class**,
because that is where the four known sites lived. The review found four more
survivors immediately, in `validate.py`, on a 19/19 green suite:
| site | mutation | delta | suite |
|---|---|---|---|
| `validate.py:779` | `min(caps)` → `min(caps[:1])` | +4 B | **GREEN** |
| `validate.py:779` | `min(caps)` → `min(caps[-1:])` | +5 B | **GREEN** |
| `validate.py:778` | `caps = [cap for _, cap in caps_ix[:1]]` | +4 B | **GREEN** |
| `validate.py:893` | `min(caps_ix[-1:], key=lambda p: p[1])` | +5 B | **GREEN** |
**None of them is an equivalent mutant — a safety refusal disappears.** With
`max_w: 5000` on inverter-a beside `2000` on inverter-b and
`sign_disagree_w: 3000`, the shipped validator answers one problem —
*"sign_disagree_w is 3000 W but max_w (inverter-b) is 2000 W, so the SAFETY-02
sign-disagreement watchdog can never fire"* — and every one of the four answers
**zero**. The site starts, with a watchdog reporting `armed` on the status page
that cannot trip on pack B. That is rule 5's own failure mode reached **through**
rule 5, which is the sentence `test_inverter.py:1657` says the rule exists to
prevent. Rule 6 loses its refusal the same way.
### Why round 4's pin could not see them, and why the fix is not four more checks
`caps` and `caps_ix` are not comprehensions over `self.inverters`; they are lists
**derived** from the roster, three function calls away from it, in a module the
enumeration never opened. Closing them by hand would have produced a fifth
rejection with the same shape one step over — that is the whole record of this
card. **A hand-written scope is the same defect as a hand-written fixture**
(MUTATION.md habit 12): a check whose data cannot reach the failure it names.
### The instrument
`test_peakguard.py` part 12j now parses **every `.py` under `app/`** off disk —
no module is named anywhere in the check — and finds the roster by transitive
closure from **one seed**, the `inverters` option key:
* a function whose returns are roster-carrying is **roster-producing**
(`roster_entries` → `build_roster` → `unit_caps_indexed` → `unit_caps`);
* a name assigned one carries it, **and so does a list accumulated in a loop
over one** — which is exactly what `caps`, `roster` and `problems` are, and
what round 4's rule could not express;
* an attribute or property answering one carries it (`self.inverters`,
`Controller.packs`, `Controller.units`, `live_units()`);
* **an argument at a call site taints that parameter in the callee**, which is
how the pin walks into `peakguard.binding_soc_floor`, `arbiter.resolve`,
`allocator.plan`, `circulation.circulating_w`, `maintenance.arbitrate` and
`store.migrate_maint_keys` without being told they exist;
* and **a slice of a roster is still a roster**, so a mutation cannot cut the
taint and hide every reduction downstream of itself. An **index** is one unit
and is not.
It then requires, at every place a roster-carrying value is reduced or iterated,
that the whole of it arrives: no `[:1]`, `[-1:]`, `[:2]` or `[0]` between the
roster and the reduction.
### What it found — and the hand-written list was wrong in BOTH directions
**183 reductions across 9 modules**, out of 23 `.py` files parsed:
`main.py` 96, `allocator.py` 24, `validate.py` 19, `arbiter.py` 14,
`circulation.py` 10, `maintenance.py` 10, `peakguard.py` 4, `inverter.py` 3,
`store.py` 3 — and 14 modules with no roster reduction at all.
The card's own suggested module list named `watchdog.py`, which reduces over no
roster at all (SAFETY-30's sign watchdogs are strictly per-unit — there is
nothing to slice), and did **not** name `maintenance.py` or `store.py`, which
both do. A pin built from that list would have made a vacuous claim about one
module and no claim at all about two others. That is the argument for computing
the scope, made with data rather than with an assertion.
The pin **independently rediscovers all four blockers** — it is told one option
key and nothing else — and finds a **fifth** beside them at
`validate.py:783`, the `min(caps_ix, key=...)` inside rule 5's message, which no
review round named.
### The counterfactual, at two depths
* **Per site, all of them.** Every enumerated site's own expression, in its own
scope, mutated with each of the four cuts this card has actually been defeated
by. **183 sites × 4 cuts = 732** mutated spellings; every one reported. The same run asserts
the predicate **accepts** the legitimate wrapped forms the package really uses
(`enumerate`, `zip`, `sorted`, `pack_permissions(packs, floor)`) and does not
treat a keyed lookup (`weights[u]` in `allocator._fill`) as a slice — which is
what four false SLICED reports cost before `shortens()` was written.
* **End to end, on the real files.** `[:1]` written into `app/validate.py` at
each blocker position, the **whole** analysis re-run over the mutated source,
and a sliced site required at that exact line — where the clean package
reports none. This is the check that would have taken PR #48 red four rounds
ago, with no fixture reaching it.
### The sweep, and the number that must not be quoted alone
All 183 sites were mutated to `[:1]`, one at a time, +4 bytes each, and each
mutant run against the full 19-file suite in `goodwe-addon-ci` (mounted `:ro`,
`python3 -B`, a fresh rsync per mutant; 0 `.pyc` in the tree before and after —
a read-only mount cannot write one, so TEST-15 is closed by construction here
rather than by discipline). **183 of 183 RED.**
⚠️ **On its own that is tautological**: with the pin in the tree every slice is
red *by the pin*. So all 51 mutants whose first red was `test_peakguard.py` were
re-run with the pin **neutralised** (`_SLICED` blanked), leaving only the
behavioural checks:
| | |
|---|---|
| **145 of 183** | a behavioural fixture in this repository separates the site from a slice of itself — red with the pin turned off |
| **38 of 183** | **nothing but this pin sees it.** Structurally closed by this card; behaviourally **UNGRADED**, and listed rather than counted as covered |
The 38: `main.py` 27, `allocator.py` 6, `validate.py` 2, `arbiter.py` 1,
`circulation.py` 1, `maintenance.py` 1. Grouped by what a `[:1]` there costs:
* **The operator's page — 15, the largest group.** `Controller.checks`
(3310, 3311, 3319, 3328, 3330, 3347, 3368, 3370, 3400, 3458) and
`status_payload` (3556, 3566, 3578). A slice reports one unit's row where two
exist, or "1 of 2 live" reading 1 of 1. Nothing commands differently; what is
lost is the evidence a human uses to notice that it should.
* **The write path's own counts.** `write_setpoint` 1267/1272/1280 — three
`len(targets)` guards. The write itself iterates `targets` at 1287 and *is*
graded (test_inverter.py); these three are its log arithmetic and refusal
count.
* **The allocator's internals.** `_fill`'s zero-weight last-resort pool (365),
`plan`'s `dropped` (526, 653), `free` (584), `frac_order` (591). ALLOC-01
drives two and three units through the *weighted* pass; the last-resort pass
and the largest-remainder tie-break are reached, but not with enough units for
a slice to change the answer.
* **Startup and FSM plumbing.** `__init__` 235/351, `_only` 831,
`_maint_button` 805, `cycle`'s maintenance zip 1505, `send_heartbeats` 3015,
`check_output_clamp` 2445/2452, `check_circulating` 2701,
`maintenance.arbitrate` 547, `arbiter.site_resolve` 517,
`circulation.opposing` 242.
* **Two refusal-TEXT sites.** `validate._roster_problems`'s `len(entries)` (554)
and `refuse()`'s numbering loop (1263) — both shape the words of a refusal
that still happens.
* **`_grade_store_migration` (698)**, already row 21 of §4c, argued there to be
an equivalent mutant at that call site. The argument is unchanged and is still
an argument, not a proof.
⚠️ **None of the 38 is closed behaviourally by this card, and saying so is the
point.** Closing sites one at a time is what cost this card four review rounds.
The pin closes the **family** — a slice at any of them is red on the line it is
written — and this list is what a shape pin can never promise.
### And the behavioural half, because a shape pin cannot grade a meaning
The pin grades an **identity** ("the whole roster arrives"). `min` → `max` is a
zero-byte edit and invisible to it, forever. So `test_validate.py` grows a
**roster SET** for the three rules that reduce over `unit_caps_indexed()`:
rosters of width 2, 3, 4 and 5 with the one small cap at **every position** —
14 rosters × 3 rules = 42 runs of the real validator — plus a **computed family
table** of twelve candidate reductions (`max`, `first`, `last`, the mean, and
`[:1]`, `[-1:]`, `[:2]`, `[:3]`, `[1:]`, `[:-1]`, `[::2]`) each compared against
what the shipped validator actually said on the same roster. Only `min` agrees
everywhere; any member no roster kills is **printed as a named survivor** and
takes the check red.
### ⚠️ A THIRD RULE WAS READING THE FLAT `max_w`, AND IT IS SAFETY-30's
Found by reading the three branches side by side — **not** by the pin, and said
that way because it is the shape of what the pin cannot see. Rule 5's sibling
branch, `unit_sign_disagree_w` (SAFETY-30's per-inverter sign watchdog), was
graded against `_number(opts, "max_w", 2000.0)` — the **flat** option — three
lines under a branch that had already stopped doing that. Both directions were
measured before the fix:
* a roster of two 2000 W units beside a leftover `max_w: 5000` from a
single-inverter install, with `unit_sign_disagree_w: 3000`, produced **zero
problems**. The site started, the status page said `armed`, and the per-unit
watchdog could not trip on **either** unit. Identical sentence to the blocker,
reached through the branch beside it;
* and a roster of two 5000 W units with no flat key was **refused** at 3000 W
against a 2000 W *default the operator never wrote*, for a threshold both
units can reach.
It takes the same `weakest` the branch above takes, from the same list, and
names the same unit. At a roster of one `unit_caps()` is `[max_w]`, so the
shipped single-inverter refusal is the same comparison, the same boundary and
the same sentence. Both directions are asserted in `test_validate.py`.
### ⚠️ WHAT THE PIN CANNOT SEE — open rows, with sizes
1. **Semantics.** `min` vs `max` vs the mean: zero-byte edits, invisible to any
shape pin. Only the behavioural sets above can see them, and they only see
the rules they drive.
2. **Direct index sites that are not reductions.** `self.inverters[0]` in
`_only()` and `actuator_of()`, `self.inverters[self.maint_index]` in
`maint_unit`, `entries[index]` in `_unit_label()`. Single-unit lookups, meant
to be, graded where they live.
3. **A subscript by a NAME.** `weights[u]`, `cap[u]`, `add[u]` in
`allocator._fill` are keyed lookups into a roster-derived mapping and are
accepted deliberately, so `caps[i]` with a variable `i` would pass. Not in
the family this card has been defeated by; buying it costs four false reports.
4. **A slice through an intermediate call.** `sorted(roster)[:1]` subscripts a
call result, not a roster reference. Nothing in `app/` is written that way
today; if something is, it would not be seen.
5. **Anything outside `app/`.** `tools/`, `sim/` and the checks themselves are
not parsed — and `sim/inject.py` reaches `validate_startup_config()`.
6. **The taint's own seam.** It crosses a call boundary only where the callee is
a plain function this package defines and the argument is positional or
keyword. A roster handed through `*args`, stored in a dict and read back, or
passed to a method on an unresolvable object, is lost at that hop — and
everything downstream goes **unenumerated** rather than reported unreachable.
That is the shape of the remaining exposure: not zero, and not measurable
from inside the check.
7. **`cycle()`'s undeliverable-unit log line** (row 22 of §4c) is still green at
+4 bytes, unchanged from round 4 and still filed open.
## 5. ⚠️ THE RESIDUAL — a per-pack EMS floor is NOT this card
@@ -546,7 +760,7 @@ A's measured 10 % while the 35 % cut-out that moves along the roster is invented
---
## 7. The probe transcript, re-run 2026-09-04 on 2026.08.0
## 7. The probe transcript, re-run 2026-09-05 on 2026.08.0
Command (unchanged, in `tools/supervisor_shape_probe.py`'s own docstring):
@@ -561,6 +775,14 @@ It reads `config.yaml` and runs Supervisor's own `AppOptions` validator in a
throwaway container. It touches no live Supervisor, POSTs nothing and installs
nothing.
⚠️ **RE-RUN AGAIN ON 2026-09-05**, because the rebase onto `67e649a` merged
SPIKE-04a's `max_w: 4600` into the same §C/§D blocks this card had rewritten,
and MUTATION.md habit 8 says a committed transcript that a document quotes is a
check. A hand-merged transcript is not a measurement of anything. The block
below is the output of that run, not of the 2026-09-04 one: B's rating reads
**4600** — its datasheet figure, still not a measurement of any unit — and every
case still passes.
```
== C. AC2: a two-inverter blob an operator would POST ==
PASS two inverters
@@ -568,7 +790,7 @@ nothing.
== D. what the schema itself refuses, per entry ==
PASS the per-entry BASELINE is ACCEPTED, so every refusal below has nowhere else to come from
[{"name": "inverter-b", "setpoint_entity": "number.b", "max_w": 3000, "bms_soc_floor": 10}]
[{"name": "inverter-b", "setpoint_entity": "number.b", "max_w": 4600, "bms_soc_floor": 10}]
PASS an entry with no name is refused, and the refusal NAMES it
Missing option 'name' in inverters in GoodWe RS485 Controller (local_goodwe_controller)
PASS an entry with no setpoint_entity is refused, and the refusal NAMES it
+31
View File
@@ -332,6 +332,37 @@ Cheap habits fall out of it, and all of them are now used in this repository:
sibling of the thing I just hardened, and does any fixture separate it from
a slice of itself?*
12. **When the same shape has been found twice, STOP CLOSING SITES AND BUILD
THE INSTRUMENT — and make it DISCOVER its own scope.** SAFETY-32 was
rejected four times, and each round closed exactly the site it was handed:
the reduction, then the roster handed to it, then the *sibling argument* of
the same call, then the same family one *module* over, in validate.py.
Round 4 did enumerate — and enumerated inside the `Controller` class,
because that is where the four known sites lived. **A hand-written scope is
the same defect as a hand-written fixture**: it is a check whose data cannot
reach the failure it names, and it goes on passing while the thing it was
supposed to cover sits in the file nobody added to the list. Round 5's pin
parses every `.py` under `app/`, seeds itself from ONE option key, and
propagates through returns, accumulator loops, attributes and *call
arguments* to find the roster wherever it went — 183 reductions across 9
modules. It rediscovered all four blockers without being told where to look,
and it named two modules the hand-written list would not have contained
(`maintenance.py`, `store.py`) while *omitting* one the list did contain
(`watchdog.py`, which is strictly per-unit and reduces over no roster at
all). **The list was wrong in both directions**, which is exactly what a
computed scope is for.
13. **Say what the instrument CANNOT see, with sizes, in the same breath.** A
structural pin grades an **identity** ("this whole roster arrives"); it can
never grade a **meaning** (`min` vs `max` is a zero-byte edit and invisible
to it). Ship the pin AND the behavioural set, name the gap, and give every
unreached case its byte delta — `cycle()`'s undeliverable-unit log line
(+4 B, still green), a subscript by a *variable* rather than a constant, a
slice taken through an intermediate call, anything outside `app/`. **An
enumerated list with an honest gap is worth more than a claim of
completeness a reviewer falsifies in ten minutes**, and every round of this
card that claimed the latter was rejected for it.
And the mutation-table consequence: **a variant is not covered because a
near-miss of it is.** DEV-02's table listed the *actuator* variant of the
per-unit claim defect (`device`/`supervised` filed without `inv`), which the
+31 -4
View File
@@ -806,21 +806,48 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str]
# second inverter is added - which is the day nobody is watching the startup
# log. This is `meter_max_age_s`'s rule read the same way: the failure it
# prevents is silent.
#
# ⚠️ AND IT READS THE WEAKEST UNIT'S CAP, NOT THE FLAT `max_w` - SAFETY-32
# ROUND 5. This branch used to compare against `max_w = _number(opts,
# "max_w", 2000.0)`, the FLAT option, which on a roster config is either
# ABSENT (so it compared against a 2000 W DEFAULT describing no unit on the
# site) or a leftover from a single-inverter install (so it compared against
# a number no unit has). Both directions were live and both were measured:
#
# a roster of two 2000 W units beside a leftover `max_w: 5000` and
# `unit_sign_disagree_w: 3000` produced ZERO problems - the site STARTED,
# with a per-inverter watchdog reporting `armed` on the status page and
# incapable of tripping on EITHER unit. That is the identical sentence the
# branch above exists to refuse, reached through the branch beside it;
#
# and a roster of two 5000 W units with no flat key was REFUSED at
# `unit_sign_disagree_w: 3000` against a 2000 W default the operator never
# wrote, for a threshold both units can reach.
#
# `unit_caps()`'s own docstring already said why: there is no such thing as
# a site rating on the wire, and this watchdog grades ONE UNIT's standing
# command against THAT unit's `supervised` bound. So it takes the same
# `weakest` the branch above takes, from the same list, and names the same
# unit. At a roster of one `unit_caps()` is `[max_w]` and this is the
# identical comparison it always was - which is what keeps the shipped
# single-inverter refusal byte-for-byte what it was.
unit_sign_w = _number(opts, "unit_sign_disagree_w",
float(SIGN_OPTION_DEFAULTS["unit_sign_disagree_w"]))
unit_where = (_unit_label(opts, min(caps_ix, key=lambda p: p[1])[0])
if caps_ix else "")
if unit_sign_w is None:
problems.append(f"unit_sign_disagree_w "
f"{opts.get('unit_sign_disagree_w')!r} is not a number")
elif max_w is not None and max_w > 0 and unit_sign_w >= max_w:
elif weakest is not None and weakest > 0 and unit_sign_w >= weakest:
problems.append(
f"unit_sign_disagree_w is {unit_sign_w:.0f} W but max_w is "
f"{max_w:.0f} W, so the SAFETY-30 per-inverter sign-disagreement "
f"unit_sign_disagree_w is {unit_sign_w:.0f} W but max_w{unit_where}"
f" is {weakest:.0f} W, so the SAFETY-30 per-inverter sign-disagreement "
"watchdog can never fire: no inverter is ever commanded past max_w, "
f"and the watchdog needs a command over {unit_sign_w:.0f} W. At two "
"inverters that is the guard which catches a unit doing the "
"OPPOSITE of what it was told while its sibling covers for it at "
"the meter - the case the site-level watchdog is structurally "
f"blind to. Lower unit_sign_disagree_w below {max_w:.0f} W "
f"blind to. Lower unit_sign_disagree_w below {weakest:.0f} W "
f"({SIGN_OPTION_DEFAULTS['unit_sign_disagree_w']:.0f} W is the "
"measured default), or raise max_w.")
+559 -124
View File
@@ -4618,127 +4618,563 @@ check("⚠️ AND THE SECOND CONSUMER OF THE SAME REDUCTION. `arbitrage.decide()
"to a per-unit reading, or defaulted it, would take this line red",
len(_arb_calls) == 1 and _arb_soc == "self.pack_floor_soc")
# --- 12j. THE ENUMERATION ITSELF, AS A RUNNING CHECK -------------------------
# ⚠️ THIS CARD HAS NOW BEEN REJECTED THREE TIMES FOR THE SAME SHAPE ONE STEP
# --- 12j. THE ENUMERATION ITSELF, AS A RUNNING CHECK, ACROSS THE PACKAGE ------
# ⚠️ THIS CARD HAS NOW BEEN REJECTED FOUR TIMES FOR THE SAME SHAPE ONE STEP
# OVER: a reduction over roster length whose fixture could not separate it from
# a slice of the roster. Round 2 hardened `binding_soc_floor()`; round 3 found
# the call site's argument; round 4's blocker was `pack_floor_soc`, the SIBLING
# ARGUMENT of the same `decide()` call. Closing them one at a time has cost four
# rounds, so this block stops enumerating them by hand.
# the call site's argument; round 4 found `pack_floor_soc`, the SIBLING ARGUMENT
# of the same `decide()` call, and wrote the first version of this block - which
# enumerated the family inside `Controller` AND STOPPED AT THE CLASS BOUNDARY.
# Round 5's blocker was four survivors in validate.py, one module over, where
# rules 5 and 6 reduce over `unit_caps_indexed()`. Closing them one at a time
# has cost five rounds. This block does not do that any more.
#
# EVERY comprehension in `Controller` that iterates the roster is listed here,
# COMPUTED from main.py's AST, PRINTED, and required to iterate the roster
# WHOLE. That is the family: `min`, `max`, `sum`, `any`, `all`, `sorted` and the
# list builders all take their roster through one of these, so a `[:1]`, a
# `[-1:]` or a `[:2]` anywhere in the class takes this line red on the line it
# was written - at every roster length, in every configuration, whether or not
# any fixture in this repository reaches it.
# THE INSTRUMENT IS NOW PACKAGE-WIDE AND THE MODULE LIST IS COMPUTED, NOT
# WRITTEN. Every `.py` under `app/` is read off disk and parsed; nothing here
# names a module, so a module added tomorrow is graded on the day it is added.
# ⚠️ A HARDCODED MODULE LIST WOULD ITSELF BE "A CHECK WHOSE DATA CANNOT REACH
# THE FAILURE IT NAMES" (MUTATION.md), which is the defect this file exists to
# complain about: the list would go on passing while the reduction it was
# supposed to cover lived in the module nobody added to it.
#
# ⚠️ WHAT IT DOES NOT COVER, said plainly. It grades the ITERABLE and nothing
# else: not which attribute is read off each unit, not which reduction is
# applied, and not the direct index sites (`self.inverters[0]` in `_only()`,
# `actuator_of()` and the store migration, `self.inverters[self.maint_index]` in
# `maint_unit`), which are not comprehensions and are graded where they live.
# The behavioural halves above are what say what these reductions MEAN.
_ctrl_cls = next(n for n in ast.walk(ast.parse(_mainsrc))
if isinstance(n, ast.ClassDef) and n.name == "Controller")
_ROSTER_ATTRS = ("inverters", "packs")
# WHAT COUNTS AS A ROSTER, DERIVED RATHER THAN DECLARED. The seed is ONE
# expression - reading the `inverters` option - and everything else is transitive
# closure over the AST:
# * a function all of whose returns are roster-carrying is roster-producing
# (`roster_entries` -> `build_roster` -> `unit_caps_indexed` -> `unit_caps`);
# * a name assigned a roster-carrying value carries it, and so does a list
# ACCUMULATED inside a loop over one (`caps`, `roster`, `problems`);
# * an attribute or property that answers one carries it (`self.inverters`,
# `Controller.packs`, `Controller.units`, `Controller.live_units()`);
# * an argument at a call site that is handed one taints THAT PARAMETER in the
# callee, which is how the pin crosses into `peakguard.binding_soc_floor`,
# `arbiter.resolve`, `allocator.plan`, `circulation.circulating_w`,
# `maintenance.arbitrate` and `store.migrate_maint_keys` without being told
# they exist;
# * and A SLICE OF A ROSTER IS STILL A ROSTER, so a mutation cannot cut the
# taint and hide the reductions downstream of itself.
# An INDEX of a roster is NOT a roster: `self.inverters[0]` is one unit, and
# treating it as a roster would spread this analysis over every unit attribute
# in the package.
#
# THE CLAIM, in one line: at every place in `app/` where a roster-carrying value
# is REDUCED or ITERATED, the whole of it arrives. A `[:1]`, a `[-1:]`, a `[:2]`
# or a `[0]` between the roster and the reduction is red on the line it is
# written, at every roster length, in every configuration, and with no fixture
# required.
_APP_SRC = {p.stem: p.read_text(encoding="utf-8")
for p in sorted((HERE / "app").glob("*.py"))}
_ROSTER_OPTION = "inverters" # the ONE seed. Everything else is closure.
_WRAPPERS = {"list", "tuple", "sorted", "enumerate", "zip", "reversed",
"filter", "iter", "set", "dict"}
_REDUCERS = {"min", "max", "sum", "any", "all", "len", "sorted", "next", "list",
"tuple", "set", "reversed", "enumerate", "zip", "map", "filter"}
def _is_roster(node):
return (isinstance(node, ast.Attribute) and node.attr in _ROSTER_ATTRS
and isinstance(node.value, ast.Name) and node.value.id == "self")
def _roster_sites(sources):
"""Every roster-carrying expression in reduction/iteration position.
def _mentions_roster(node):
return any(_is_roster(n) for n in ast.walk(node))
_roster_comps = []
for _fn in ast.walk(_ctrl_cls):
if not isinstance(_fn, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
for _n in ast.walk(_fn):
if isinstance(_n, (ast.ListComp, ast.SetComp, ast.DictComp,
ast.GeneratorExp)):
for _g in _n.generators:
if _mentions_roster(_g.iter):
_roster_comps.append((_fn.name, _g.iter.lineno,
ast.unparse(_g.iter)))
_roster_comps.sort(key=lambda r: (r[1], r[2]))
def _slices_roster(node):
"""Is the roster SUBSCRIPTED anywhere inside this expression?
⚠️ THE TEST IS ON THE SUBSCRIPT, NOT ON THE SPELLING. `enumerate(...)`,
`zip(...)` and `pack_permissions(roster, floor)` all hand the WHOLE roster
on and are legitimate; `self.inverters[:1]` is the family this card has been
rejected over three times. Requiring the iterable to unparse to exactly
`self.inverters` would refuse the first three and grade the spelling instead
of the thing.
Returns `(rows, meta)`; each row is
`(module, lineno, col, end_lineno, end_col, qualname, kind, source, sliced)`.
"""
return any(isinstance(n, ast.Subscript) and _is_roster(n.value)
for n in ast.walk(node))
trees = {m: ast.parse(s) for m, s in sources.items()}
funcs, importmap = {}, {}
for m, tree in trees.items():
def walk(node, qual):
for ch in ast.iter_child_nodes(node):
if isinstance(ch, (ast.FunctionDef, ast.AsyncFunctionDef)):
funcs[(m, qual + ch.name)] = ch
walk(ch, qual + ch.name + ".")
elif isinstance(ch, ast.ClassDef):
walk(ch, qual + ch.name + ".")
else:
walk(ch, qual)
walk(tree, "")
for n in ast.walk(tree):
if isinstance(n, ast.ImportFrom):
if n.module:
base = n.module.split(".")[-1]
if base in trees:
for a in n.names:
importmap[(m, a.asname or a.name)] = (base, a.name)
else:
# `from . import peakguard` - a MODULE, not a name inside
# one. This branch is the only reason the pin can see
# peakguard.py, and it was missing from the first draft.
for a in n.names:
if a.name in trees:
importmap[(m, a.asname or a.name)] = (a.name, None)
elif isinstance(n, ast.Import):
for a in n.names:
base = a.name.split(".")[-1]
if base in trees:
importmap[(m, a.asname or base)] = (base, None)
t_funcs, t_params, t_attrs, t_locals = set(), set(), set(), {}
bodies = {}
def body_nodes(fn):
if id(fn) not in bodies:
out = []
def rec(n):
for ch in ast.iter_child_nodes(n):
if isinstance(ch, (ast.FunctionDef, ast.AsyncFunctionDef,
ast.Lambda)):
continue
out.append(ch)
rec(ch)
rec(fn)
bodies[id(fn)] = out
return bodies[id(fn)]
def callee(m, qual, node):
f = node.func
if isinstance(f, ast.Name):
if (m, f.id) in importmap:
om, oname = importmap[(m, f.id)]
if oname and (om, oname) in funcs:
return (om, oname)
if (m, f.id) in funcs:
return (m, f.id)
elif isinstance(f, ast.Attribute):
if isinstance(f.value, ast.Name):
if f.value.id == "self" and "." in qual:
cls = qual.rsplit(".", 1)[0]
if (m, cls + "." + f.attr) in funcs:
return (m, cls + "." + f.attr)
if (m, f.value.id) in importmap:
om, _ = importmap[(m, f.value.id)]
if (om, f.attr) in funcs:
return (om, f.attr)
return None
def derived(node, m, qual):
if node is None:
return False
if isinstance(node, ast.Subscript):
# A SLICE of a roster is still a roster - a defective one - so the
# taint survives the mutation and every reduction downstream of it
# reports too. An INDEX is one unit and is not a roster.
return (isinstance(node.slice, ast.Slice)
and derived(node.value, m, qual))
if isinstance(node, ast.Call):
f = node.func
if (isinstance(f, ast.Attribute) and f.attr in ("get", "pop")
and node.args and isinstance(node.args[0], ast.Constant)
and node.args[0].value == _ROSTER_OPTION):
return True # THE SEED
if isinstance(f, ast.Name) and f.id in _WRAPPERS:
return any(derived(a, m, qual) for a in node.args)
tgt = callee(m, qual, node)
return bool(tgt and tgt in t_funcs)
if isinstance(node, ast.Name):
return (node.id in t_locals.get((m, qual), ())
or (m, qual, node.id) in t_params)
if isinstance(node, ast.Attribute):
if isinstance(node.value, ast.Name) and node.value.id == "self":
if node.attr in t_attrs:
return True
cls = qual.rsplit(".", 1)[0] if "." in qual else None
return bool(cls and (m, cls + "." + node.attr) in t_funcs)
return False
if isinstance(node, (ast.ListComp, ast.SetComp, ast.DictComp,
ast.GeneratorExp)):
return any(derived(g.iter, m, qual) for g in node.generators)
if isinstance(node, ast.IfExp):
return (derived(node.body, m, qual)
or derived(node.orelse, m, qual))
if isinstance(node, ast.BoolOp):
return any(derived(v, m, qual) for v in node.values)
if isinstance(node, (ast.List, ast.Tuple)):
return any(isinstance(e, ast.Starred) and derived(e.value, m, qual)
for e in node.elts)
return False
for _ in range(40): # transitive closure, to fixpoint
before = (len(t_funcs), len(t_params), len(t_attrs),
sum(len(v) for v in t_locals.values()))
for (m, qual), fn in funcs.items():
loc = t_locals.setdefault((m, qual), set())
for n in body_nodes(fn):
if (isinstance(n, (ast.Assign, ast.AnnAssign))
and getattr(n, "value", None) is not None
and derived(n.value, m, qual)):
for t in (n.targets if isinstance(n, ast.Assign)
else [n.target]):
if isinstance(t, ast.Name):
loc.add(t.id)
elif (isinstance(t, ast.Attribute)
and isinstance(t.value, ast.Name)
and t.value.id == "self"):
t_attrs.add(t.attr)
if (isinstance(n, (ast.For, ast.AsyncFor))
and derived(n.iter, m, qual)):
# A list ACCUMULATED one-per-unit is a roster too: `caps`,
# `roster`, `problems` are all built this way, and round 5's
# blocker was two reductions over one of them.
for c in ast.walk(n):
if (isinstance(c, ast.Call)
and isinstance(c.func, ast.Attribute)
and c.func.attr in ("append", "extend")
and isinstance(c.func.value, ast.Name)):
loc.add(c.func.value.id)
if (isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute)
and n.func.attr == "extend"
and isinstance(n.func.value, ast.Name)
and n.args and derived(n.args[0], m, qual)):
loc.add(n.func.value.id)
if isinstance(n, ast.Return) and derived(n.value, m, qual):
t_funcs.add((m, qual))
if isinstance(n, ast.Call):
tgt = callee(m, qual, n)
if tgt:
cfn = funcs[tgt]
pos = [a.arg for a in
cfn.args.posonlyargs + cfn.args.args]
off = 1 if (pos and pos[0] == "self") else 0
for j, a in enumerate(n.args):
if j + off < len(pos) and derived(a, m, qual):
t_params.add((tgt[0], tgt[1], pos[j + off]))
for kw in n.keywords:
if kw.arg and derived(kw.value, m, qual):
t_params.add((tgt[0], tgt[1], kw.arg))
after = (len(t_funcs), len(t_params), len(t_attrs),
sum(len(v) for v in t_locals.values()))
if after == before:
break
def shortens(sub):
"""Does this subscript change HOW MANY members come out?
A SLICE does, and so does a CONSTANT index (N in, one out). A subscript
by a NAME does not: `weights[u]`, `cap[u]` and `add[u]` in
`allocator._fill` are keyed lookups into a roster-derived MAPPING, which
is what iterating it looks like. Flagging those would make this check cry
wolf on every dict the roster builds - it did, on four sites, before this
predicate was written.
"""
sl = sub.slice
if isinstance(sl, ast.Slice):
return True
if isinstance(sl, ast.Constant) and isinstance(sl.value, int):
return True
return (isinstance(sl, ast.UnaryOp) and isinstance(sl.op, ast.USub)
and isinstance(sl.operand, ast.Constant))
def sliced(node, m, qual):
return any(isinstance(n, ast.Subscript) and shortens(n)
and derived(n.value, m, qual) for n in ast.walk(node))
def leaves(node, m, qual):
"""The roster REFERENCES inside a reduced expression.
The place a slice goes is the roster reference, not the whole argument:
`sum(i.max_w for i in self.inverters)` is defeated by slicing
`self.inverters`, and slicing the generator would be a TypeError rather
than a wrong answer - a mutation that crashes proves nothing.
"""
out = []
def rec(n):
if (isinstance(n, (ast.Name, ast.Attribute, ast.Call,
ast.Subscript)) and derived(n, m, qual)):
out.append(n)
if isinstance(n, ast.Call):
for a in n.args:
rec(a)
return
for c in ast.iter_child_nodes(n):
rec(c)
rec(node)
return out
seen = {}
for (m, qual), fn in funcs.items():
def note(kind, expr):
bad = sliced(expr, m, qual)
for lf in leaves(expr, m, qual):
key = (m, lf.lineno, lf.col_offset, lf.end_lineno,
lf.end_col_offset)
seen.setdefault(key, (qual, kind, ast.unparse(lf), bad))
for n in body_nodes(fn):
if isinstance(n, (ast.ListComp, ast.SetComp, ast.DictComp,
ast.GeneratorExp)):
for g in n.generators:
if derived(g.iter, m, qual) or sliced(g.iter, m, qual):
note("for..in", g.iter)
elif isinstance(n, (ast.For, ast.AsyncFor)):
if derived(n.iter, m, qual) or sliced(n.iter, m, qual):
note("for stmt", n.iter)
elif (isinstance(n, ast.Call) and isinstance(n.func, ast.Name)
and n.func.id in _REDUCERS):
for a in n.args:
if derived(a, m, qual) or sliced(a, m, qual):
note(n.func.id + "()", a)
def probe(m, qual, expr_src):
"""Would this expression, IN THAT SCOPE, be reported as sliced?
The per-site counterfactual runs through here: it is the SAME predicate
the enumeration above is graded with, asked about a mutated spelling of
the site's own expression, so a predicate that stopped answering would
take both to the same place.
"""
return sliced(ast.parse(expr_src, mode="eval").body, m, qual)
rows = sorted((k + v) for k, v in seen.items())
return rows, (sorted(t_funcs), sorted(t_params), sorted(t_attrs), probe)
_sliced = [r for r in _roster_comps
if _slices_roster(ast.parse(r[2], mode="eval").body)]
print(f" EVERY comprehension in Controller that iterates the roster "
f"({len(_roster_comps)} of them, in "
f"{len({m for m, _l, _s in _roster_comps})} methods), and what each "
f"iterates:")
for _mname, _ln, _src in _roster_comps:
print(f" main.py:{_ln:<5} {_mname:<26} for ... in {_src}"
+ ("" if _src in ("self.inverters", "self.packs")
else " [whole roster, wrapped]"))
check("⚠️ THE FAMILY, ENUMERATED FROM THE SOURCE RATHER THAN FROM MEMORY. Every "
"comprehension in `Controller` that iterates the roster iterates it "
"WHOLE - `self.inverters` or `self.packs`, bare. This is the check that "
"would have caught rounds 2, 3 and 4's blockers on the round they were "
"written instead of one review each: `min(... for v in vals[:2])` is not "
"here (it reduces a derived list, which 12i pins directly), but every "
"`for inv in self.inverters[:1]` in the class is. A new reduction added "
"over a slice of the roster is red on the line it is written, at every "
"roster length and with no fixture required. ⚠️ THE ITERABLE ONLY - what "
"is read off each unit and what reduction is applied are graded by the "
"runs, and the direct index sites are not comprehensions and not here",
len(_roster_comps) > 0 and not _sliced)
check("...and the enumeration is not vacuously empty and its predicate is not "
"trivially true (habit 7): it found more than twenty roster "
"comprehensions across more than fifteen methods, it REJECTS the same "
"expressions with a slice on them - bare and wrapped in `enumerate()` "
"alike - and it ACCEPTS the three legitimate wrapped forms this class "
"actually uses. A predicate that answered the same thing for both would "
"make the line above a description rather than a check",
len(_roster_comps) >= 20
and len({m for m, _l, _s in _roster_comps}) >= 15
and all(_slices_roster(ast.parse(f"self.{a}{sl}", mode="eval").body)
and _slices_roster(ast.parse(f"enumerate(self.{a}{sl})",
mode="eval").body)
for a in _ROSTER_ATTRS
for sl in ("[:1]", "[-1:]", "[:2]", "[1:]", "[:-1]", "[::2]"))
and not any(_slices_roster(ast.parse(e, mode="eval").body)
for e in ("self.inverters", "self.packs",
"enumerate(self.inverters)",
"peakguard.pack_permissions(self.packs, f)")))
# ⚠️ AND THE TWO THIS ENUMERATION ACTUALLY FOUND, recorded here because the
# lesson is the finding and not the fix. Mutating every roster reduction in
# `Controller` to `[:1]` one at a time turned up TWO that survived a green
# 19-file suite, both `any(i.sign_wd.tripped for i in self.inverters)` and both
# SAFETY-30's rather than this card's:
_SITES, (_TFUNCS, _TPARAMS, _TATTRS, _PROBE) = _roster_sites(_APP_SRC)
_SLICED = [r for r in _SITES if r[8]]
_BY_MOD = {}
for _r in _SITES:
_BY_MOD.setdefault(_r[0], []).append(_r)
def _slice_at(row, cut="[:1]"):
"""The package source with ONE roster reference sliced, for a counterfactual."""
m, _ln, _c, eln, ec = row[:5]
src = dict(_APP_SRC)
lines = src[m].split("\n")
lines[eln - 1] = lines[eln - 1][:ec] + cut + lines[eln - 1][ec:]
src[m] = "\n".join(lines)
return src
print(f" {len(_APP_SRC)} modules parsed from app/ (NOT a written list); "
f"{len(_BY_MOD)} of them reduce over a roster or a roster-derived list, "
f"and every such reduction is enumerated below "
f"({len(_SITES)} of them):")
for _m in sorted(_BY_MOD):
print(f" {_m}.py: {len(_BY_MOD[_m])}")
for _m in sorted(_BY_MOD):
for _row in _BY_MOD[_m]:
print(f" {'SLICED ' if _row[8] else ' '}{_m}.py:{_row[1]:<5}"
f" {_row[5]:<38} {_row[6]:<10} {_row[7][:56]}")
print(f" modules with NO roster reduction at all: "
f"{sorted(set(_APP_SRC) - set(_BY_MOD))}")
print(f" ...and what made each of them a roster, computed from ONE seed "
f"(`opts[{_ROSTER_OPTION!r}]`): {len(_TFUNCS)} roster-PRODUCING functions, "
f"{len(_TPARAMS)} roster-carrying PARAMETERS across module boundaries, "
f"and the attributes {_TATTRS}:")
print(f" returns a roster: "
+ ", ".join(f"{m}.{q}" for m, q in _TFUNCS))
print(f" is HANDED a roster: "
+ ", ".join(f"{m}.{q}({a})" for m, q, a in _TPARAMS))
# ⚠️ THE PR #48 ROUND-5 BLOCKERS, REDISCOVERED BY THE INSTRUMENT RATHER THAN
# COPIED FROM THE REVIEW. If this pin did not independently name all of them,
# the pin would be too narrow and THAT would be the finding.
#
# `cycle()`'s stale-input hold +4 bytes, 19/19 green - a LATCHED unit must
# take the roster past the hold, or its failsafe SET stops being re-filed
# the moment a poll is missed. Graded now in test_watchdog.py part 8, on
# a two-inverter roster with the latch at EACH position in turn.
# `publish()`'s status vocabulary +4 bytes, 19/19 green - a site with one
# inverter parked by its own failsafe claim published `running`. Same
# part, same two positions.
# ⚠️ MATCHED BY FUNCTION AND EXPRESSION, NOT BY LINE NUMBER. The blockers were
# reported as validate.py:778 `caps_ix`, :779 `caps`, :783 and :893 `caps_ix`;
# editing three lines into validate.py moves all four, and a check pinned to
# 778 would then be green because it found NOTHING - a check whose data cannot
# reach the failure it names, in the block that exists to complain about them.
# The lines the pin actually reports are PRINTED so a reviewer can match them
# against the review by eye.
_BLOCK_FN = "validate_startup_config"
_blockers = [r for r in _SITES
if r[0] == "validate" and r[5] == _BLOCK_FN
and r[7] in ("caps", "caps_ix")]
print(f" PR #48's round-5 blockers, as the pin names them "
f"(validate.{_BLOCK_FN}, reductions over the caps list): "
+ ", ".join(f"validate.py:{r[1]} {r[6]} {r[7]}" for r in _blockers))
check("⚠️ THE FAMILY, ENUMERATED FROM THE SOURCE RATHER THAN FROM MEMORY, AND "
"NOW ACROSS THE WHOLE PACKAGE INSTEAD OF ONE CLASS. Every place in "
"`app/` where a roster or a roster-derived list is REDUCED or ITERATED "
"takes the whole of it: no `[:1]`, no `[-1:]`, no `[:2]`, no `[0]` "
"between the roster and the reduction. This is the check that would have "
"caught rounds 2, 3, 4 AND 5's blockers on the round they were written "
"instead of one review each - round 4's version stopped at `Controller` "
"and round 5's four survivors were one module over, in validate.py. A "
"new reduction added over a slice of a roster, in ANY module under "
"`app/`, is red on the line it is written, at every roster length and "
"with no fixture required",
len(_SITES) > 0 and not _SLICED)
check("...and it INDEPENDENTLY REDISCOVERS EVERY ONE OF PR #48's ROUND-5 "
"BLOCKERS - the `for ... in caps_ix` the caps list is built from, the "
"`min(caps)` rule 5 reduces it with, and the `min(caps_ix, key=...)` "
"rule 5's message and rule 6's comparison each take - and it found a "
"FURTHER ONE beside them that no review round named. A pin that had to "
"be told where to look would be a transcription of the review rather "
"than an instrument, so it is told ONE option key and nothing else",
len([r for r in _blockers if r[7] == "caps_ix"]) >= 3
and len([r for r in _blockers if r[7] == "caps"]) >= 1)
check("...and it reaches the sites of EVERY PREVIOUS ROUND'S BLOCKER TOO: "
"round 2's `peakguard.binding_soc_floor`, round 4's "
"`cycle()`/`publish()` `any(... for i in self.inverters)`, and "
"SAFETY-30's `_grade_store_migration`. The whole point of a family is "
"that its members are found by one rule",
any(r[0] == "peakguard" and r[5] == "binding_soc_floor" for r in _SITES)
and any(r[0] == "main" and r[5] == "Controller.cycle"
and r[6] == "any()" for r in _SITES)
and any(r[0] == "main" and r[5] == "Controller.publish"
and r[6] == "any()" for r in _SITES)
and any(r[0] == "main" and r[5] == "Controller._grade_store_migration"
for r in _SITES))
# --- non-vacuity, habit 7: vary ONE thing from an ACCEPTED baseline ----------
# ⚠️ EVERY ONE of the enumerated sites, mutated. Not a sample: the counterfactual
# asks the SAME predicate the enumeration is graded with whether a `[:1]` on
# THAT site's own expression, in THAT site's own scope, would be reported. A
# predicate that had quietly stopped answering would take the line above green
# and this one red.
_probe_fail = []
for _r in _SITES:
for _cut in ("[:1]", "[-1:]", "[:2]", "[0]"):
if not _PROBE(_r[0], _r[5], _r[7] + _cut):
_probe_fail.append((_r[0], _r[1], _r[7] + _cut))
print(f" per-site counterfactual: {len(_SITES)} sites x 4 cuts "
f"([:1], [-1:], [:2], [0]) = {len(_SITES) * 4} mutated spellings, "
f"{len(_probe_fail)} of them NOT reported")
check("...and the predicate is not trivially true: sliced at EVERY ONE of the "
"enumerated sites, in that site's own scope, with each of the four cuts "
"this card has actually been defeated by, it reports the slice. It also "
"ACCEPTS the legitimate wrapped forms the package really uses "
"(`enumerate(...)`, `zip(...)`, `sorted(...)`, "
"`pack_permissions(packs, floor)`) and REJECTS a keyed lookup being "
"treated as a slice (`weights[u]` in allocator._fill, which is what four "
"false SLICED reports cost before `shortens()` existed)",
not _probe_fail
and not _PROBE("main", "Controller.cycle", "self.inverters")
and not _PROBE("main", "Controller.cycle", "enumerate(self.inverters)")
and not _PROBE("main", "Controller.cycle", "zip(self.inverters, self.maints)")
and not _PROBE("main", "Controller.check_peak_floor_limited",
"peakguard.pack_permissions(self.packs, 10.0)")
and not _PROBE("allocator", "_fill", "[u for u in order if weights[u] > 0.0]")
and _PROBE("allocator", "_fill", "[u for u in order[:1] if weights[u] > 0.0]"))
# --- and the END-TO-END counterfactual, on the REAL FILES --------------------
# ⚠️ The probe above grades the predicate. This grades THE WHOLE INSTRUMENT: the
# text of a shipped module is mutated at a real byte offset, the entire analysis
# is re-run from scratch on it, and the site has to come back SLICED at exactly
# the line the mutation was written on. That is the difference between "the
# rule is right" and "the rule is wired to the files".
_e2e = []
for _r in _blockers:
_mut, _ = _roster_sites(_slice_at(_r))
_hit = [x for x in _mut if x[8] and x[0] == _r[0] and x[1] == _r[1]]
_e2e.append((_r[0], _r[1], _r[7], len(_hit), len([x for x in _mut if x[8]])))
for _m, _ln, _s, _hit, _tot in _e2e:
print(f" {_m}.py:{_ln} `{_s}` -> `{_s}[:1]`: reported at that line "
f"{'YES' if _hit else 'NO'} ({_tot} sliced site(s) in the package)")
check("...and the INSTRUMENT is wired to the files, not only to itself: "
"writing `[:1]` into app/validate.py at each of the four round-5 blocker "
"positions - every reduction over the caps list - and re-running the WHOLE "
"analysis over the mutated source "
"reports a sliced site at that exact line every time, where the clean "
"package reports none. ⚠️ THIS IS THE ONE THAT WOULD HAVE CAUGHT PR #48: "
"the four mutations the reviewer ran by hand against a 19/19 green suite "
"are red HERE without any fixture reaching them",
len(_e2e) >= 4 and all(h for _m, _l, _s, h, _t in _e2e))
# ⚠️ WHAT THIS INSTRUMENT CANNOT SEE, SAID PLAINLY, BECAUSE AN ENUMERATED LIST
# WITH AN HONEST GAP IS WORTH MORE THAN A CLAIM OF COMPLETENESS A REVIEWER CAN
# FALSIFY IN TEN MINUTES. Every item below is an OPEN ROW, not a closed one.
#
# Both were invisible for the same reason every blocker on this card was
# invisible: the only fixture that reached them was a roster of ONE, where
# `self.inverters[:1]` IS `self.inverters`.
# IT GRADES AN IDENTITY, NOT A MEANING. The claim it makes is "the whole roster
# arrives". It says nothing about WHICH attribute is read off each unit, WHICH
# reduction is applied, or whether that reduction is the right one:
# `min(caps)` -> `max(caps)` is invisible here, and always will be, because a
# shape pin on a semantic claim would refuse honest refactors (MUTATION.md
# habit 10). The behavioural halves - 12a-12i above, test_validate.py's rule 5
# and rule 6 roster SETS, test_watchdog.py part 8 - are what say what these
# reductions MEAN, and they are the only thing that can.
#
# AND SPECIFICALLY, THE FOUR THINGS IT WILL NOT REPORT:
# 1. DIRECT INDEX SITES THAT ARE NOT REDUCTIONS. `self.inverters[0]` in
# `_only()` and `actuator_of()`, `self.inverters[self.maint_index]` in
# `maint_unit`, `entries[index]` in `_unit_label()`. These are single-unit
# lookups, they are meant to be, and treating an index as a roster would
# spread this analysis over every unit attribute in the package. They are
# graded where they live.
# 2. A SUBSCRIPT BY A NAME. `weights[u]`, `cap[u]`, `add[u]` in
# `allocator._fill` are keyed lookups into a roster-derived MAPPING and are
# accepted deliberately - `shortens()` above. So a mutation that wrote
# `caps[i]` with `i` a variable would pass. It is not in the family this
# card has been defeated by four times (`[:1]`, `[-1:]`, `[:2]`, `[0]`),
# and buying it would cost four false SLICED reports in allocator.py.
# 3. A SLICE TAKEN THROUGH AN INTERMEDIATE REDUCTION. `sorted(roster)[:1]` is
# a subscript on a CALL RESULT rather than on a roster reference, and the
# pin does not flag it. Nothing in `app/` is written that way today; if
# something is, this line is the record that it would not be seen.
# 4. ANYTHING OUTSIDE `app/`. `tools/`, `sim/` and the checks themselves are
# not parsed. `sim/inject.py` reaches `validate_startup_config()` and is
# graded by nothing here.
#
# AND THE DISCOVERY ITSELF HAS A SEAM: the taint crosses a call boundary only
# where the callee is a plain function this package defines and the argument is
# passed positionally or by keyword. A roster handed through `*args`, stored in a
# dict and read back out, or passed to a method on an object the analysis cannot
# resolve, is lost at that hop - and everything downstream of the hop goes
# UNENUMERATED rather than reported as unreachable. That is the shape of the
# remaining exposure: not zero, and not measurable from inside this check.
# ⚠️ AND WHAT THE ENUMERATION ACTUALLY FOUND WHEN EVERY SITE WAS MUTATED - THE
# FINDING, RECORDED, BECAUSE THE LESSON IS THE MEASUREMENT AND NOT THE FIX.
#
# All 183 sites above were mutated to `[:1]` one at a time, +4 bytes each, and
# each mutant was run against the full 19-file suite in `goodwe-addon-ci`
# (mounted `:ro`, `python3 -B`, a fresh rsync per mutant, 0 `.pyc` in the tree
# before and after - a read-only mount cannot write one, which is TEST-15 closed
# by construction rather than by discipline). **183 of 183 RED.**
#
# ⚠️ THAT NUMBER IS TAUTOLOGICAL ON ITS OWN AND MUST NOT BE QUOTED WITHOUT THE
# SECOND RUN. With the check above in the tree, every slice is red BY THIS
# CHECK. So all 51 mutants whose first red was `test_peakguard.py` were re-run
# with this pin NEUTRALISED (`_SLICED` blanked), leaving only the behavioural
# checks. The answer:
#
# 145 of 183 a BEHAVIOURAL fixture in this repository separates the site
# from a slice of itself - it goes red with the pin turned off
# 38 of 183 NOTHING but this pin sees it. Structurally closed as of this
# card; behaviourally UNGRADED, and listed below rather than
# counted as covered
#
# THE 38, BY MODULE: main.py 27, allocator.py 6, validate.py 2, arbiter.py 1,
# circulation.py 1, maintenance.py 1. Grouped by what a `[:1]` there would cost:
#
# THE OPERATOR'S PAGE - 15 of them, and the largest group by far.
# `Controller.checks` (3310, 3311, 3319, 3328, 3330, 3347, 3368, 3370,
# 3400, 3458) and `status_payload` (3556, 3566, 3578) build the rows and
# the counts an operator reads. A slice reports ONE unit's row where two
# exist, or a "1 of 2 live" that says 1 of 1. Nothing commands differently;
# what is lost is the evidence a human uses to notice that it should.
# THE WRITE PATH's OWN COUNTS - `write_setpoint` 1267/1272/1280, three
# `len(targets)` guards around the one place this add-on writes. The
# write itself iterates `targets` at 1287 and IS graded (test_inverter);
# these three are the arithmetic in its log line and its refusal count.
# THE ALLOCATOR'S INTERNALS - `_fill`'s zero-weight last-resort pool (365),
# `plan`'s `dropped` (526, 653), `free` (584) and `frac_order` (591).
# ALLOC-01's fixtures drive two and three units through the WEIGHTED pass;
# the last-resort pass and the largest-remainder tie-break are reached but
# not with enough units for a slice to change the answer.
# STARTUP AND FSM PLUMBING - `__init__` 235/351, `_only` 831, `_maint_button`
# 805, `cycle`'s maintenance zip at 1505, `send_heartbeats` 3015,
# `check_output_clamp` 2445/2452, `check_circulating` 2701,
# `maintenance.arbitrate` 547, `arbiter.site_resolve` 517,
# `circulation.opposing` 242.
# TWO REFUSAL-TEXT SITES - `validate._roster_problems`'s `len(entries)` (554)
# and `refuse()`'s numbering loop (1263). Both shape the WORDS of a
# refusal that still happens.
# AND `_grade_store_migration` (698), which round 4 already recorded as row 21
# of the DECISIONS table and argued to be an equivalent mutant at that call
# site. The argument is unchanged and is still an argument, not a proof.
#
# ⚠️ NONE OF THESE 38 IS CLOSED BEHAVIOURALLY BY THIS CARD, AND SAYING SO IS THE
# POINT. Closing them one at a time is exactly what cost this card four review
# rounds; the pin closes the FAMILY - a slice at any of them is red on the line
# it is written - and the list above is what a shape pin can never promise. The
# honest summary is: 145 sites have a fixture that can see them, 38 have only a
# structural claim, and every one of the 38 is named here with what it would
# cost rather than left out of a count.
@@ -5144,25 +5580,24 @@ MUT.append(("`pack_floor_soc`'s NONE GUARD narrowed to a slice - "
MUT.append(("`pack_floor_soc` building `vals` from a SLICE of the roster - "
"`[inv.soc for inv in self.inverters[:2]]`, +4 bytes",
ast.unparse(_pfs_vals.generators[0].iter) == "self.inverters"
and _s_head_kill[2] and not _sliced,
and _s_head_kill[2] and not _SLICED,
"part 12i's sweep reads the REAL property on real rosters so it "
"sees this too, its pin is on the comprehension's own iterable, "
"and 12j's ENUMERATION covers every other comprehension in the "
"class at once"))
"and 12j's ENUMERATION covers every other roster reduction in the "
"PACKAGE at once"))
# 33. THE ENUMERATION'S OWN FINDING, and the reason this row is worth more than
# the four above: it is not this card's code.
MUT.append(("a reduction over the roster added ANYWHERE ELSE in `Controller` "
"over a slice - which is how rounds 2, 3 and 4 each began",
bool(_roster_comps) and not _sliced,
"part 12j: every roster comprehension in the class, enumerated "
"from main.py's AST and required to iterate the roster whole. ⚠️ "
"Mutating each of them to `[:1]` found FOUR that survived a green "
"19-file suite, none of them this card's: three `sign_wd.tripped` "
"reductions (SAFETY-30's stale-input hold, `publish()`'s status "
"vocabulary and `status_payload()`'s banner - now graded in "
"test_watchdog.py part 8) and the store-migration guard's redundant "
"second disjunct. THIS row is the one that closes the family "
"rather than the member"))
MUT.append(("a reduction over a roster added ANYWHERE ELSE IN THE PACKAGE over "
"a slice - which is how rounds 2, 3, 4 AND 5 each began",
bool(_SITES) and not _SLICED,
"part 12j: every roster reduction in EVERY module under `app/`, "
"enumerated from the AST off one seed and required to take the "
"roster whole. ⚠️ Round 4's version stopped at the `Controller` "
"class and round 5's four blockers were one module over, in "
"validate.py; this one enumerates "
f"{len(_SITES)} sites across {len(_BY_MOD)} modules and "
"rediscovers all four without being told where to look. THIS row "
"is the one that closes the family rather than the member"))
for label, caught, where in MUT:
print(f" {'caught' if caught else 'NOT CAUGHT':>10} {label}")
+242
View File
@@ -924,6 +924,248 @@ for _name, _got in sorted(_prov.items()):
f"against, and the 4324 W the DC reading would make it {_got}",
all(_got.values()))
# --------------------------------------------------------------------------- #
print("rules 5, 5b and 6 on a ROSTER SET - the smallest cap at EVERY position")
# ⚠️ SAFETY-32 ROUND 5, AND THE REASON THIS SECTION EXISTS AT ALL. PR #48 was
# rejected on FOUR MUTATIONS THAT SURVIVED A 19/19 GREEN SUITE, all of them in
# the three lines above:
#
# validate.py:779 min(caps) -> min(caps[:1]) +4 B GREEN
# validate.py:779 min(caps) -> min(caps[-1:]) +5 B GREEN
# validate.py:778 caps = [cap for _, cap in caps_ix[:1]] +4 B GREEN
# validate.py:893 min(caps_ix[-1:], key=lambda p: p[1]) +5 B GREEN
#
# NONE of them is an equivalent mutant: each one deletes a safety refusal.
# `max_w: 5000` on inverter-a beside `2000` on inverter-b with
# `sign_disagree_w: 3000` is refused by the shipped validator with "the
# SAFETY-02 sign-disagreement watchdog can never fire" and ACCEPTED by every one
# of the four - the site starts, with a watchdog that reports `armed` on the
# status page and cannot trip on pack B. That is rule 5's own failure mode
# reached THROUGH rule 5.
#
# WHY THE OLD FIXTURE COULD NOT SEE IT, which is the whole lesson and is now
# MUTATION.md habit 10: the fixture was ONE roster, `[5000, 2000]`. On that
# roster `min` and `last` and `[-1:]` and `[:2]` all answer 2000. A fixture of N
# members cannot separate two reductions that agree on N members, and adding a
# member moves the hole instead of closing it.
#
# SO THE FIXTURE IS A SET, NOT A ROSTER: the smallest cap at EVERY POSITION of
# rosters of width 2, 3, 4 and 5. `[:1]` needs width 2 to be caught, `[:2]`
# needs 3, `[:3]` needs 4 - the sweep is what terminates the regress that a
# single fixture cannot. And the FAMILY TABLE below is COMPUTED AND PRINTED, so
# a member no fixture kills shows up as a NAMED SURVIVOR rather than as an
# absence from a list nobody re-derived.
#
# ⚠️ THE DIFFERING CAPS ARE A FIXTURE PROPERTY AND MUST STAY ONE. In production
# both packs are expected to floor at 10 % and SPIKE-04a puts inverter B at
# 4600 W against A's 5000 W - a 400 W gap, narrower than slew_w. A fixture built
# on the real pair would be degenerate on exactly the axis these checks measure.
# ⚠️ AND INVERTER B DOES NOT PHYSICALLY EXIST (SPIKE-04, ~2026-09-12). Nothing
# in this section is measured off a second unit; the rosters are simulated and
# are labelled so.
_BIG, _SMALL = 5000.0, 2000.0
_RATE = 3000.0 # strictly between them, which is the point
def _roster_n(caps, **over):
"""A roster of len(caps) units, unit-0..unit-N, each with its own cap."""
o = base(**over)
for k in ("setpoint_entity", "soc_entity", "batt_entity", "max_w"):
o.pop(k, None)
o["inverters"] = [
dict(name=f"unit-{i}", setpoint_entity=f"number.sp{i}",
soc_entity=f"sensor.soc{i}", batt_entity=f"sensor.batt{i}",
max_w=c, bms_soc_floor=10)
for i, c in enumerate(caps)]
o.update(over)
return o
# The three rules that reduce over `unit_caps_indexed()`, each as
# (option name, the token its refusal is recognised by).
_CAP_RULES = (
("sign_disagree_w", "SAFETY-02 sign-disagreement"),
("unit_sign_disagree_w", "SAFETY-30 per-inverter sign-disagreement"),
("maintenance_charge_w", "maintenance charge in a cheap window"),
)
_QUIET = {"sign_disagree_w": 300.0, "unit_sign_disagree_w": 300.0,
"maintenance_charge_w": 1000.0, "maintenance_discharge_w": 1000.0}
# THE SET: width x position of the one small cap.
_SET = []
for _n in (2, 3, 4, 5):
for _pos in range(_n):
_caps = [_BIG] * _n
_caps[_pos] = _SMALL
_SET.append((_n, _pos, tuple(_caps)))
# habit 1: the PRECONDITION as its own check. If a later edit makes the caps
# equal, this goes red rather than the claims below going quietly vacuous.
check("PRECONDITION: every fixture in the set has a UNIQUE smallest cap, that "
"cap is at the stated position, and the rate under test sits STRICTLY "
"between it and the others - two equal caps could not tell `min` from "
"`first` from `last` and the whole set would be measuring nothing",
len(_SET) == 14
and all(c[p] == _SMALL and c.count(_SMALL) == 1 and _SMALL < _RATE < _BIG
for _n2, p, c in _SET)
and {p for _n2, p, _c in _SET} == {0, 1, 2, 3, 4})
def _verdict(opts, token):
"""(refused?, the unit the message NAMES, the cap it QUOTES)."""
ps = [p for p in validate_startup_config(opts, -5000, 5000) if token in p]
if not ps:
return (False, None, None)
m = re.search(r"max_w \(([^)]+)\) is (\d+) W", ps[0])
return (True, m.group(1) if m else None, float(m.group(2)) if m else None)
# --- the shipped answer, on every member of the set -------------------------
_rows = []
for _opt, _token in _CAP_RULES:
for _n, _pos, _caps in _SET:
_o = _roster_n(list(_caps), **dict(_QUIET, **{_opt: _RATE}))
_rows.append((_opt, _n, _pos, _caps, _verdict(_o, _token)))
print(f" {len(_CAP_RULES)} rules x {len(_SET)} rosters = {len(_rows)} runs "
f"of the REAL validator (rate {_RATE:.0f} W, caps {_SMALL:.0f}/"
f"{_BIG:.0f} W, simulated rosters - inverter B does not exist):")
for _opt in sorted({r[0] for r in _rows}):
_got = [(r[1], r[2], r[4]) for r in _rows if r[0] == _opt]
print(f" {_opt:<22} " + " ".join(
f"n{n}p{p}:{'REFUSE ' + str(u) if ok else 'starts'}"
for n, p, (ok, u, _c) in _got))
check("⚠️ EVERY ONE OF THE THREE CAP RULES REFUSES ON EVERY MEMBER OF THE SET, "
"and NAMES THE UNIT THE NUMBER CAME OFF - at width 2, 3, 4 and 5, with "
"the small cap at every position in turn. A reduction that answered "
"`first`, `last`, `[:1]`, `[-1:]`, `[:2]` or `[:-1]` instead of `min` "
"misses at least one member of this set, which is what four hand-run "
"mutations against a 19/19 green suite proved the single `[5000, 2000]` "
"fixture could not do",
all(ok and unit == f"unit-{p}" and cap == _SMALL
for _o2, _n2, p, _c2, (ok, unit, cap) in _rows))
check("...and it is a COMPARISON, not a blanket refusal of rosters: the same "
"three rates against a roster whose caps can ALL take them start "
"cleanly, at every width. Without this the line above is satisfied by a "
"validator that refuses everything (habit 2 in the negative direction)",
all(_verdict(_roster_n([_BIG] * n, **dict(_QUIET, **{opt: _RATE})),
tok) == (False, None, None)
for opt, tok in _CAP_RULES for n in (1, 2, 3, 4, 5)))
# --- the POSITIONAL-SLICE FAMILY TABLE, computed and printed ----------------
# ⚠️ COMPUTED, NOT LISTED. Each candidate is applied to the SAME caps the
# validator saw, and its (unit, cap) answer is compared against what the shipped
# validator actually said on that roster. A candidate that agrees everywhere is
# a reduction this fixture set CANNOT distinguish from the shipped one, and it
# is printed as a SURVIVOR rather than left out of a table nobody re-derived.
def _pick(caps, fn):
ix = list(enumerate(caps))
try:
i, c = fn(ix)
except (ValueError, IndexError):
return None
return (f"unit-{i}", c)
_FAMILY = {
"min (SHIPPED)": lambda ix: min(ix, key=lambda p: p[1]),
"max": lambda ix: max(ix, key=lambda p: p[1]),
"first": lambda ix: ix[0],
"last": lambda ix: ix[-1],
"min(caps[:1])": lambda ix: min(ix[:1], key=lambda p: p[1]),
"min(caps[-1:])": lambda ix: min(ix[-1:], key=lambda p: p[1]),
"min(caps[:2])": lambda ix: min(ix[:2], key=lambda p: p[1]),
"min(caps[:3])": lambda ix: min(ix[:3], key=lambda p: p[1]),
"min(caps[1:])": lambda ix: min(ix[1:], key=lambda p: p[1]),
"min(caps[:-1])": lambda ix: min(ix[:-1], key=lambda p: p[1]),
"min(caps[::2])": lambda ix: min(ix[::2], key=lambda p: p[1]),
"mean": lambda ix: (0, sum(c for _i, c in ix) / len(ix)),
}
_survivors = []
print(" the family table - each candidate against what the SHIPPED "
"validator answered on the same roster:")
for _name, _fn in _FAMILY.items():
_misses = []
for _opt, _n, _pos, _caps, (_ok, _unit, _cap) in _rows:
_pred = _pick(list(_caps), _fn)
_pred_refuses = _pred is not None and _pred[1] > 0 and _RATE >= _pred[1]
if _opt == "maintenance_charge_w":
_pred_refuses = (_pred is not None and _pred[1] > 0
and _RATE > _pred[1])
_said = ((True, _pred[0], _pred[1]) if _pred_refuses
else (False, None, None))
if _said != (_ok, _unit, _cap):
_misses.append(f"{_opt[:4]}/n{_n}p{_pos}")
if not _misses and _name != "min (SHIPPED)":
_survivors.append(_name)
print(f" {_name:<16} {'AGREES EVERYWHERE (survivor)' if not _misses else 'differs on ' + str(len(_misses)) + ' run(s), first: ' + _misses[0]}")
check("⚠️ AND THE FAMILY TABLE HAS NO SURVIVOR BUT THE SHIPPED REDUCTION. "
"Eleven candidates - max, first, last, the mean, and every positional "
"slice this card has actually been defeated by - are each computed "
"against what the real validator answered on the same roster, and every "
"one of them differs somewhere in the set. This is the check that would "
"have taken PR #48 red four rounds ago: a member no fixture kills is "
"PRINTED AS A NAMED SURVIVOR here instead of being absent from a list "
f"nobody re-derived. survivors: {_survivors}",
_survivors == [])
# --- the ROSTER OF ONE, where every one of them is the same thing -----------
_at_one = {n: _pick([_SMALL], f) for n, f in _FAMILY.items()}
print(f" at a ROSTER OF ONE the twelve candidates answer: "
f"{sorted({str(v) for v in _at_one.values()})}")
check("...and at a ROSTER OF ONE every candidate that still has a member to "
"reduce answers THE SAME THING - which is exactly why no "
"single-inverter fixture in this repository could ever have caught any "
"of this, and why the shipped single-inverter refusals are what they "
"always were. The only two that do NOT coincide are `caps[1:]` and "
"`caps[:-1]`, which EMPTY the list and would raise: at N=1 they are "
"crashes rather than silent wrong answers, so they are not survivors "
"there either",
{v for v in _at_one.values() if v is not None} == {("unit-0", _SMALL)}
and {n for n, v in _at_one.items() if v is None}
== {"min(caps[1:])", "min(caps[:-1])"}
and _verdict(_roster_n([_SMALL], **dict(_QUIET, sign_disagree_w=_RATE)),
"SAFETY-02 sign-disagreement")[0])
# --- rule 5's SIBLING BRANCH, which was reading the FLAT max_w --------------
# ⚠️ FOUND BY READING THE THREE BRANCHES SIDE BY SIDE, NOT BY THE STRUCTURAL
# PIN - and said that way on purpose, because it is the shape of what the pin
# cannot see. `unit_sign_disagree_w` is SAFETY-30's, not this card's. It was
# graded against `_number(opts, "max_w", 2000.0)`, the FLAT option, three lines
# under a branch that had already stopped doing that. Both directions were
# measured before the fix and both are asserted here.
_leftover = _roster_n([_SMALL, _SMALL],
**dict(_QUIET, unit_sign_disagree_w=_RATE))
_leftover["max_w"] = _BIG # what a single-inverter install leaves behind
_lv = _verdict(_leftover, "SAFETY-30 per-inverter sign-disagreement")
print(f" leftover flat max_w {_BIG:.0f} beside a roster of two "
f"{_SMALL:.0f} W units, unit_sign_disagree_w {_RATE:.0f} -> {_lv}")
check("⚠️ A LEFTOVER FLAT `max_w` NO LONGER DISARMS SAFETY-30's PER-UNIT "
"WATCHDOG SILENTLY. Two 2000 W units beside a `max_w: 5000` left over "
"from a single-inverter install and `unit_sign_disagree_w: 3000` used to "
"produce ZERO problems - the site started, the status page said `armed`, "
"and the watchdog could not trip on EITHER unit. It is refused now, "
"against the weakest unit's own cap, and the message names that unit",
_lv == (True, "unit-0", _SMALL))
check("...and the over-refusal in the OTHER direction is gone too: a roster of "
"two 5000 W units with NO flat `max_w` was refused at 3000 W against a "
"2000 W DEFAULT the operator never wrote, for a threshold both units can "
"reach. It starts",
_verdict(_roster_n([_BIG, _BIG],
**dict(_QUIET, unit_sign_disagree_w=_RATE)),
"SAFETY-30 per-inverter sign-disagreement")
== (False, None, None))
check("...and the flat single-inverter config it always refused is refused "
"IDENTICALLY - `unit_caps()` at a roster of one is `[max_w]`, so this is "
"the same comparison, the same boundary and the same sentence",
why(validate_startup_config(base(max_w=2000, unit_sign_disagree_w=2000),
-5000, 5000), "unit_sign_disagree_w")
and validate_startup_config(base(max_w=2000, unit_sign_disagree_w=1999),
-5000, 5000) == [])
# --------------------------------------------------------------------------- #
print()
if fails:
@@ -49,6 +49,15 @@ DECISIONS/CFG-04-inverter-roster.md:
per-entry key. That change broke §C and, worse, left §D printing PASS on a
refusal about the WRONG KEY - the exact decay MUTATION.md habit 8 describes. §C
now carries the key and §D is an isolated counterfactual asserting the key name.
⚠️ RE-RUN AGAIN 2026-09-05, on the same 2026.08.0 image, because SAFETY-32's
rebase onto SPIKE-04a merged two independent edits into §C and §D - the required
`bms_soc_floor` and inverter B's datasheet `max_w: 4600`. A transcript stitched
together by hand from two runs is not a measurement, so this one was measured:
`all cases passed`, exit 0. B's 4600 W is STILL its DATASHEET figure and not a
reading off any unit, and B's `bms_soc_floor: 15` in §C is illustrative - it
differs from A's 10 so that the per-entry number is exercised at all, while
production is expected to have BOTH packs at 10 %.
"""
import sys, json, copy
sys.path.insert(0, "/usr/src/supervisor")