SAFETY-32 round 4: the same family at the SIBLING ARGUMENT, and the family enumerated

Rounds 2 and 3 hardened the FLOOR axis - the max inside binding_soc_floor(),
the roster handed to it, Controller.packs, pack_floor_pct()'s conjunction.
peakguard.decide() is handed a SECOND reduction over the same roster beside it,
soc=self.pack_floor_soc, and peakguard.py's own docstring names the two as a
pair. Three rewrites of Controller.pack_floor_soc were green across all 19 files
on 52380c0 and again after the rebase onto 3934ad7:

    min(float(v) for v in vals)  ->  ... vals[:2]   +4 bytes
                                 ->  ... vals[-2:]  +5 bytes
                                 ->  ... vals[:3]   +4 bytes

while [:1], [-1:], [1:], [:-1], max() and the mean were all red. The suite's
separating power on the SoC axis stopped at exactly N=2 - the state the floor
axis was in before round 3.

It changes a real answer: three packs at 50/60/11 %, emptiest THIRD, answers
11.0 shipped and 50.0 mutated. The site is then graded for discharge PERMISSION
against a pack 39 points fuller than the emptiest and spends a pack its own BMS
stops, through sim/inject.py which runs no validator rule.

Round 3's machinery transfers directly. New test_peakguard.py part 12i:
- six REAL Controller.cycle()s, emptiest pack at every position of a three-pack
  roster and both positions of a two-pack one, observable read off
  PeakDecision.soc; cut-outs held flat so the floor axis cannot move the answers
- computed slice and non-slice tables, printed, red naming any live survivor
- a LENGTH SWEEP over the real property, 78 rosters of 1..12 real inverters,
  closing head/tail widths 1-11 and asserting width 12 is unreached
- a NONE-GUARD sweep over the same 78
- THREE exact structural pins: the vals comprehension iterates the bare
  self.inverters, and both any() and min() reduce the whole vals - closing every
  slice width at every roster length, which no bounded fixture can

SPIKE-04: pack B is 4 x Dyness DL5.0C, DoD 90 -> floor 10 % [FROM DATASHEET AND
SERIAL - inverter B does not exist and nothing about it is measured]. Pack A is
DoD 90 -> 10 % and that one IS measured. So production is DEGENERATE on the
floor axis and a differing floor exists ONLY in the fixtures - which is the
argument for keeping them, not for simplifying them. Recorded in CHANGELOG and
DECISIONS so nobody later collapses them to one number. The SoC axis has no such
degeneracy.

AND THE FAMILY, not the member. Every reduction over self.inverters/self.packs
in Controller was found from the AST and mutated to a slice against the full
suite. Eighteen were already graded. FOUR were not, and three of them are
SAFETY-30's, all any(i.sign_wd.tripped for i in self.inverters), each +4 bytes
and green across 19 files - because the only fixture that reached them was a
roster of ONE, where self.inverters[:1] IS self.inverters:

- cycle()'s stale-input hold: a LATCHED unit must take the roster past it or its
  failsafe SET stops being re-filed the moment a poll is missed
- publish()'s status vocabulary: a site holding an inverter at 0 W published
  "running"
- status_payload()'s banner: the one headline on the page

New test_watchdog.py part 8 grades all three with the latch at EACH position
plus a no-latch counterfactual. New part 12j pins all 26 roster comprehensions
in Controller to iterating the roster WHOLE, so a reduction added over a slice
anywhere in the class is red on the line it is written, with no fixture needed.

Two rows reported OPEN rather than closed: _grade_store_migration()'s second
disjunct (now red on 12j, argued equivalent because a unit tripped at startup
implies its namespaced key is set) and cycle()'s undeliverable-unit LOG LINE,
where sorted(...)[:1] is still green - it truncates names in one event message;
the refusal itself is a different expression and is graded.

MUTATION.md gains habits 10 and 11: a fixture of N members cannot separate two
reductions that agree on N, and mutate the SIBLINGS of anything you harden.

No shipped behaviour changed - tests and documentation only. 19/19 green;
all three named survivors and all round-3 mutations verified RED.

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 03:01:57 +02:00
committed by glenn schrooyen
co-authored by Claude Opus 5
parent a4c95f83ce
commit 8beba0a3e7
5 changed files with 1106 additions and 1 deletions
+32
View File
@@ -300,6 +300,38 @@ Cheap habits fall out of it, and all of them are now used in this repository:
assert the **shape** of that second report, not only its verdict, so the
check cannot silently revert to grading the first path.
10. **A fixture of N members cannot separate two reductions that agree on N
members — so grade the FAMILY, and grade it from the SOURCE.** This is the
only lesson SAFETY-32 cost four review rounds to learn, and it generalises
past packs to every roster, list and window in this repository. Two packs
cannot tell `max` from `min` from first from last. Three cannot tell `max`
from `max(x[-2:])`. **The regress does not terminate in a fixture**: `[:1]`
needs two members, `[:2]` three, `[:3]` four, and adding one more member
moves the hole rather than closing it. Three things follow, and all three
are now in `test_peakguard.py` parts 12c2, 12g, 12i and 12j:
* the fixture is a **SET** — the extreme at every position of the roster,
not one roster with the extreme somewhere;
* the family table is **COMPUTED and PRINTED**, so a member no fixture kills
shows up as a named survivor and takes the check red, instead of being
absent from a list nobody re-derived;
* where the claim is an **IDENTITY** ("this roster", "this list"), pin it
**exactly on the AST** — `ast.unparse(arg) == "self.packs"` is false of
every slice, where `"self.packs" in src` is true of all of them. Where the
claim is **SEMANTIC** ("the maximum"), a shape pin would refuse honest
refactors, so report the unreached width **with its size** instead.
11. **When a check of this shape is found, mutate its SIBLINGS before shipping
the fix.** SAFETY-32 was rejected three times for the same defect one step
over: the reduction, then the roster handed to it, then the reduction handed
to the *other argument of the same call*. Enumerating the family from the
AST and mutating each member took one afternoon and found **four more
survivors in code the card never touched** — three of SAFETY-30's
`any(i.sign_wd.tripped for i in self.inverters)` reductions, invisible
because the only fixture that reached them was a roster of ONE, where
`self.inverters[:1]` **is** `self.inverters`. Ask of every fix: *what is the
sibling of the thing I just hardened, and does any fixture separate it from
a slice of itself?*
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