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
817 lines
50 KiB
Markdown
817 lines
50 KiB
Markdown
# SAFETY-32 — `bms_soc_floor` per pack, and the two judgement calls it left open
|
||
|
||
**Status:** decided and implemented on branch `SAFETY-32`.
|
||
**Card:** SAFETY-32 — *"One `bms_soc_floor` for two packs grades the shallower
|
||
pack against the deeper one's cut-out"*, scoped by the card's own PM PROOFREAD
|
||
to its **third** bullet only. Bullets 1 (aggregate anti-windup) and 2 (per-instance
|
||
`achievable_w`) were verified as already shipped by DEV-02 and CFG-04 and were
|
||
**not** touched — `main.py`'s `actual_w=self.site_batt_w` comment is the record
|
||
of why the first of those must stay site-level, and it still reads as written.
|
||
|
||
---
|
||
|
||
## 1. The defect, stated in the terms the code uses
|
||
|
||
`bms_soc_floor` **describes a battery**: it is the percent that pack's own BMS
|
||
cuts out at, register `0x0560` on the reference GW5000S-BP, read as 10. It was a
|
||
flat option because there was one pack.
|
||
|
||
`peak_soc_floor` **describes the site's policy**: the depth a money-saving
|
||
discharge may never reach. Rule 8 refuses `peak_soc_floor <= bms_soc_floor`,
|
||
because such a floor can never bind — the BMS wins and the status page reports a
|
||
backstop that does not exist.
|
||
|
||
At two packs of different models the two cut-outs differ, and one number for both
|
||
is wrong **in the direction that empties a pack**: with cut-outs of 10 % and 30 %
|
||
and `peak_soc_floor: 20`, the add-on **started**, and the guard would command a
|
||
forced discharge that pack B's *BMS* — not the operator's floor — is the thing
|
||
that stops. That is rule 8's own failure mode, reached through rule 8.
|
||
|
||
---
|
||
|
||
## 2. DECISION 1 — required per entry, not defaulted from `PEAK_OPTION_DEFAULTS`
|
||
|
||
The card left this open and asked for the reason to be recorded.
|
||
|
||
**Decided: REQUIRED of every roster entry**, in `config.yaml`'s schema
|
||
(`bms_soc_floor: int(0,50)`, no trailing `?`) and in `validate.py` rule 8, which
|
||
refuses a missing one **by name**.
|
||
|
||
The reason is that a default here is not a convenience, it is **this card's own
|
||
defect wearing a different hat**. A default is one number applied to every pack,
|
||
which is exactly what the flat key was. An add-on that answers "10 %" for a pack
|
||
nobody has told it about has invented a property of somebody's battery, and the
|
||
failure mode of the invention is the one this card exists to remove — the
|
||
shallower pack protected by a floor that belongs to the deeper one. CFG-03's
|
||
rule ("defaults live in exactly one place") points the same way: the one mirror
|
||
`PEAK_OPTION_DEFAULTS["bms_soc_floor"]` is the default for the **flat** key, i.e.
|
||
for the single-inverter shape, and reusing it per entry would make one dict the
|
||
default in two shapes with two meanings.
|
||
|
||
It is also the treatment `max_w` already gets, for the same reason and by the
|
||
same argument (`inverter.py`'s `build_roster()` docstring): the per-unit numbers
|
||
that describe hardware are required, and the per-unit *sensors* are optional
|
||
because an absent sensor is a real state.
|
||
|
||
**The cost is stated rather than hidden.** This is a schema change: a roster
|
||
config written before this branch is refused by the Supervisor until each entry
|
||
gains the key, and every roster fixture in the suite needed it. Nothing in
|
||
production carries a roster today — `inverters:` ships `[]` and the second
|
||
inverter is ~8 days out — so the migration is paid by fixtures and by whoever
|
||
writes the first real roster, at a startup refusal that names the entry.
|
||
|
||
**What is NOT inherited.** An entry that omits the key does not borrow the flat
|
||
one. `Inverter.bms_soc_floor` is then `None`, `peakguard.pack_floor_pct()`
|
||
answers `None`, and `discharge_permitted()` already treats an unknown floor as
|
||
**no permission** — so even in the window before rule 8 runs (e.g. `sim/inject.py`,
|
||
which does not run the startup gate) the absence costs a shave and never a pack.
|
||
`0.0` was rejected explicitly: it is the most permissive value the key has.
|
||
|
||
## 3. DECISION 2 — the site verdict is the MAXIMUM of the packs' own floors
|
||
|
||
`Controller.pack_floor_soc` — the **minimum** measured SoC, the emptiest pack —
|
||
**is unchanged**, exactly as the card instructed. What changed is the floor it is
|
||
graded against. `peakguard.binding_soc_floor()` reduces the roster's per-pack
|
||
floors, each of them `max(peak_soc_floor, that pack's own cut-out)`, with a
|
||
`max()`. The two reductions pair: a minimum SoC against a maximum floor is the
|
||
conservative combination, and both move the verdict towards refusing a shave and
|
||
away from spending a pack. `min(soc) <= max(floor)` is implied by "some pack is
|
||
at or below its own floor", so the site verdict is never more permissive than
|
||
grading each pack separately would be. The converse is not true and is not
|
||
claimed — a site can be refused while every pack individually has headroom, which
|
||
costs a shave.
|
||
|
||
### 3a. ⚠️ FIX ROUND — the reduction was written and argued, and NOT GRADED
|
||
|
||
PR #48 was rejected because the argument in §4 below justified *writing* the
|
||
reduction and was then not carried into a check. Neither of the two mutations
|
||
below appears anywhere in §6's twelve-row battery: it graded *"the reduction
|
||
dropped"* and *"the bare option handed over"*, and never asked what happens when
|
||
the reduction is the **wrong one**, or when the roster handed to it is
|
||
**sliced**. Both were applied to `app/` by the reviewer and **all 19 files
|
||
stayed green**:
|
||
|
||
| mutation | Δ | why it survived |
|
||
|---|---|---|
|
||
| `binding_soc_floor()`: `max(floors)` → `min(floors)` | **0 bytes** | every roster in the suite was rule-8-valid or one pack long, so `floors` held **identical numbers** and `max`/`min`/first/last were four spellings of one answer |
|
||
| the `decide()` call site: `self.packs` → `self.packs[:1]` | **+4 bytes** | part 12g asserted `"self.packs" in <the unparsed expression>` — and `self.packs[:1]` **contains that substring** |
|
||
|
||
The zero-byte one was proved to have actually run rather than assumed:
|
||
`dis.get_instructions` showed the `LOAD_GLOBAL`s as `[float, pack_floor_pct,
|
||
any, min]`, and `binding_soc_floor([Pack("a",50,10), Pack("b",50,35)], 20.0)`
|
||
returned `20.0` where the shipped code returns `35.0`.
|
||
|
||
**The common cause is this repository's recurring defect** — a check whose DATA
|
||
cannot reach the failure it names. The one check that used a cut-out **above**
|
||
the EMS floor (12c's `_n1_deep`) ran on a roster of **ONE**, where every possible
|
||
reduction agrees. So no check in the suite ever evaluated
|
||
`binding_soc_floor()` on two packs whose floors **differ**.
|
||
|
||
**And the rule-8 degeneracy is not a defence, by §4's own words.** If
|
||
"unreachable is a claim about today's rule ordering, not a property of this
|
||
function, and `validate_startup_config()` is also `sim/inject.py`'s entry point"
|
||
is enough to *write* the reduction, it is enough to *grade* it. Through
|
||
`sim/inject.py`, which does not run rule 8 at all, `min(floors)` lets the guard
|
||
command a forced discharge to 20 % on a pack whose BMS cuts out at 35 %.
|
||
|
||
**What was added — no shipped behaviour changed, only what observes it:**
|
||
|
||
* **`test_peakguard.py` part 12c2** evaluates `binding_soc_floor()` on a roster
|
||
whose per-pack floors **all differ**. Four **simulated** cut-outs —
|
||
`30, 25, 40, 35` %, every one strictly above `peak_soc_floor: 20`, so
|
||
`pack_floor_pct()` selects the cut-out for each and `floors` *is* that tuple in
|
||
that order. The four candidate reductions are then four different numbers:
|
||
|
||
max = 40 <- the shipped answer
|
||
min = 25
|
||
first = 30 <- "grade the site against pack A", inside the function
|
||
last = 35
|
||
mean = 32.5
|
||
|
||
The extremes sit at **interior** roster positions (index 2 is the max, index 1
|
||
the min). A two-pack roster structurally cannot separate these — there `max`
|
||
and `last` coincide and so do `min` and `first` — and neither can three. Four
|
||
is the smallest roster that separates all four. The precondition is its own
|
||
check (habit 1), one pack is then raised past the old maximum (habit 7: the
|
||
**last** pack, which was neither extreme, so the line can only move if the
|
||
reduction read it), and `peak_soc_floor` is lifted above every cut-out to grade
|
||
the other side of the conjunction.
|
||
|
||
* **Part 12g is behaviour now, not source text** (MUTATION.md habit 4). Real
|
||
`Controller.cycle()`s on a two-pack roster, both packs held at the same SoC and
|
||
everything else identical, with the packs' own cut-outs as the only thing
|
||
varied:
|
||
|
||
cut-outs A 10 / B 18 % -> decide() got soc_floor=20 %, floor_limited=False
|
||
cut-outs A 10 / B 35 % -> decide() got soc_floor=35 %, floor_limited=True
|
||
|
||
The observable is `PeakDecision.soc_floor` — the argument, echoed back by the
|
||
shipped `decide()` — so the whole path from `Controller.inverters` to the
|
||
guard's argument is under test, and the site's **verdict flips** as well as the
|
||
number. `self.packs[:1]` answers 20 % both times. **Which** option key the floor
|
||
comes from stays pinned on the source in part 4, because that claim genuinely
|
||
is about the code's shape; that the option still governs is now graded
|
||
behaviourally too.
|
||
|
||
* ⚠️ **A THIRD SURVIVOR, FOUND WHILE FIXING THE FIRST TWO, AND RECORDED RATHER
|
||
THAN QUIETLY REPAIRED.** The first cut of part 12g varied only **pack B's**
|
||
cut-out — so `self.packs[-1:]`, grade the site against the **last** pack,
|
||
agreed with the full roster on both runs and stayed green across all 19 files
|
||
(+5 bytes). A two-run fixture that only ever moves the second pack pins `[:1]`
|
||
and not `[-1:]`; it is the same defect one step along, and it was in the fix
|
||
for this defect. Part 12g now runs the **same pair of cut-outs in both
|
||
orders**:
|
||
|
||
cut-outs A 10 / B 18 % -> soc_floor=20 %, floor_limited=False
|
||
cut-outs A 10 / B 35 % -> soc_floor=35 %, floor_limited=True
|
||
...the SAME PAIR SWAPPED, A 35 / B 18 % -> soc_floor=35 %, floor_limited=True
|
||
|
||
`[:1]` answers 20 % on the second run, `[-1:]` answers 20 % on the third, and
|
||
the roster answers 35 % on both. No single-position slice passes all three.
|
||
This is the mirror `12h` already ran on the SoC axis, which is where the shape
|
||
was borrowed from.
|
||
|
||
* ⚠️ **AND A FOURTH, from the same battery: the EMPTY-roster branch.** Rewriting
|
||
`binding_soc_floor([])`'s `float(soc_floor)` to `return None` (−43 bytes) was
|
||
green across all 19 files. It is defensive code — `Controller.inverters` is
|
||
never empty on a started controller, and `inputs_ok` refuses the cycle before
|
||
the guard is reached if it were — so nothing in the suite went near it. It is
|
||
**pinned rather than argued**, in both directions (the option when there is
|
||
one, `None` when there is not), because a branch with no check is a branch
|
||
that can be rewritten under a green suite. The check also records why it
|
||
cannot permit anything either way: `pack_floor_soc` is `None` at an empty
|
||
roster, and `discharge_permitted()` refuses on a `None` SoC whatever the floor
|
||
is.
|
||
|
||
* **`wired_over_limit()` gained a `prepare(ctl)` hook**, inside its retry loop so
|
||
every attempt grades the same site, which is the only window in which a second
|
||
inverter can join the roster before the telegrams are fed.
|
||
|
||
* **In-process mutation rows 22, 22b and 23** record all four survivors, so a
|
||
later edit that re-degenerates any of these fixtures goes red on a row rather
|
||
than on nothing.
|
||
|
||
### 3b. ⚠️ SECOND FIX ROUND — the fix carried the defect it was written to close
|
||
|
||
Round 2 closed both named survivors, and both of round 3's blockers are the
|
||
**same defect sitting inside that fix**: a check whose TEXT claims more than its
|
||
FIXTURE can observe. Applied to `app/` on `8501e05`, **all 19 files stayed
|
||
green** for both:
|
||
|
||
| mutation | Δ | the sentence that was too broad | why the fixture could not see it |
|
||
|---|---|---|---|
|
||
| `binding_soc_floor()`: `max(floors)` → `max(floors[-2:])` | **+5 bytes** | *"So the reduction reads **every entry**"* | 12c2's only counterfactual raised the **last** pack (`_deeper[-1]`), and **every window that ends at the roster's end follows the last pack**. On `(30,25,40,35)` both answer 40; on `_deeper` both answer 45. |
|
||
| the `decide()` call site: `self.packs` → `self.packs[:2]` | **+4 bytes** | *"what is reduced is the **ROSTER** and not a POSITION in it"* | all three `_floor_reaching_decide` runs drove a **two-pack** roster, where `self.packs[:2]` **is** `self.packs` — by exactly the argument 12c2 itself makes about two packs being unable to separate max/min/first/last. |
|
||
|
||
Round 2's *report* stated the narrow, true claim ("no **single-position** slice
|
||
passes all three runs"). The *check in the file* stated the broad one. That gap
|
||
is the whole of round 3.
|
||
|
||
**THE GENERAL LAW, stated once so the next card can use it:**
|
||
|
||
> **A fixture of N packs cannot separate two reductions that agree on N packs.**
|
||
> Two cannot separate max from min from first from last. Three cannot separate
|
||
> max from max-of-the-last-two. A single counterfactual that varies one position
|
||
> cannot separate the reduction from any window containing that position.
|
||
|
||
Its corollary is the reason this section is long: **the regress does not
|
||
terminate in a fixture.** Closing `[:1]` took two packs; `[:2]` took three;
|
||
`[:3]` takes four — and a four-pack run hands `[:4]` the same free pass. Round 3
|
||
therefore does two different things rather than adding one more pack.
|
||
|
||
**What was added — no shipped behaviour changed, only what observes it:**
|
||
|
||
* **Part 12c2 gained a ROSTER SET and a computed FAMILY TABLE.** The same four
|
||
simulated cut-outs with the **deepest moved to each of the four positions**,
|
||
and fourteen enumerated rewrites of `max(floors)` each checked against every
|
||
roster. The table is **computed and printed**, and a member with an empty kill
|
||
list takes the check **red naming it** — so a family member can no longer be
|
||
added without a fixture that reaches it. `max(floors[-2:])` is killed only by
|
||
the rosters whose deepest sits at index 0 or 1, which is precisely the data the
|
||
single last-pack counterfactual could not supply.
|
||
|
||
* **Part 12c2 gained a LENGTH SWEEP.** `binding_soc_floor()` is a pure function
|
||
over a list, which is the one place a sweep is cheap: **78 simulated rosters,
|
||
lengths 1 through 12, the deepest cut-out at every position of each.** That
|
||
closes *every* head window `max(floors[:w])` and *every* tail window
|
||
`max(floors[-w:])` of width **1 to 11** in one property rather than one
|
||
mutation at a time — and the check asserts that width **12 is NOT reached**, so
|
||
the limit is a red line rather than a silence.
|
||
|
||
* **Part 12g drives THREE-pack rosters**, with the deep cut-out at index 0, 1
|
||
**and** 2. Every positional slice of a three-pack roster is a proper subset of
|
||
`{0,1,2}` and so drops a position; the run whose deep pack sits there answers
|
||
the option, 20 %, where the roster answers 35 %. `self.packs[:2]` goes red on
|
||
the index-2 run. `_floor_reaching_decide()` now takes `*floors`, and the extra
|
||
units join through the same `second_inverter()` hook the two-pack runs use.
|
||
|
||
* **Part 12g gained a POSITIONAL-SLICE TABLE**, computed against the **real**
|
||
`Controller.packs` each of the six cycles actually ran and the **real**
|
||
`PeakDecision.soc_floor` it produced — so it measures the shipped call site's
|
||
own answer, not a re-derivation, and a survivor takes the check red.
|
||
|
||
* ⚠️ **AND A FIFTH AND SIXTH SURVIVOR, FOUND BY ROUND 3'S OWN BATTERY AND
|
||
CLOSED STRUCTURALLY RATHER THAN BY ONE MORE PACK.** `self.packs[:3]` (+4) and
|
||
`self.packs[-3:]` (+5) survive **all six** behavioural runs, because the
|
||
longest roster those runs drive is three packs long. Part 12g therefore also
|
||
pins the roster **argument's identity** on the AST: it must unparse to exactly
|
||
`self.packs`, which is false of every slice at every length. ⚠️ This is **not**
|
||
the substring test round 2 removed — `"self.packs" in expr` is *true* of every
|
||
slice; `ast.unparse(arg) == "self.packs"` is *false* of all of them. The two
|
||
halves are separated and each says what it reaches: the **runs** grade the
|
||
VALUE and reach every slice up to N=3; the **pin** grades the ARGUMENT'S
|
||
IDENTITY at any N and is blind to two things the runs are not — a rewrite of
|
||
what `Controller.packs` *returns* (the runs' `len(ctl.packs) == 3` catches
|
||
that) and the reduction *inside* `binding_soc_floor()` (12c2's).
|
||
|
||
* ⚠️ **AND ONE ROW IS RECORDED AS NOT CAUGHT.** A reduction over a window
|
||
**twelve packs wide** is indistinguishable from `max(floors)` on every roster
|
||
in the file, because the sweep stops at twelve. Unlike the call site there is
|
||
no identity claim to pin — the claim is semantic, "the maximum", and a shape
|
||
pin would refuse honest refactors of the expression. It is reported **with its
|
||
size** (it needs a 13-pack roster, on a site whose roster is ONE and whose
|
||
second unit is ~8 days out) rather than hidden.
|
||
|
||
* **Two rewrites are EQUIVALENT, not uncaught.** `max(set(floors))` and
|
||
`list(reversed(self.packs))` cannot change any answer — `max` is order- and
|
||
duplicate-invariant. The second is nonetheless red on the structural pin.
|
||
|
||
* **In-process mutation rows 24 – 27** record all four of round 3's findings,
|
||
including the not-caught one, so a later edit that re-degenerates any of these
|
||
fixtures goes red on a row rather than on nothing.
|
||
|
||
## 4. ⚠️ THE HONEST LIMITATION, and it is the reason this record exists
|
||
|
||
**Under any configuration rule 8 accepts, `binding_soc_floor()` answers
|
||
`peak_soc_floor` for every pack.** Rule 8 refuses `peak_soc_floor <= bms_soc_floor`
|
||
on *every* unit, so `max(peak_soc_floor, bms_i) == peak_soc_floor` everywhere the
|
||
add-on will actually start. The per-pack **floor** is therefore non-degenerate
|
||
only against a config rule 8 refuses.
|
||
|
||
This is not a defect and it is not hidden. It is the same argument
|
||
`validate.unit_caps_indexed()` makes about itself, in that function's own words:
|
||
|
||
> *"Rule 9(d) already refuses such a config so the two lists cannot desynchronise
|
||
> in shipped use — but 'unreachable' is a claim about today's rule ordering, not
|
||
> a property of this function, and `validate_startup_config()` is also
|
||
> `sim/inject.py`'s entry point."*
|
||
|
||
CFG-04's index desynchronisation is what happened the last time that reasoning
|
||
was left implicit. So the reduction is written, and it is graded on both sides:
|
||
`test_peakguard.py` part 12c sweeps **1775** rule-8-valid `(peak_soc_floor,
|
||
bms_soc_floor)` pairs and asserts the answer is the option in every one of them
|
||
(that is AC 5, measured over the whole option space rather than at the shipped
|
||
point), and then asserts that on the pair rule 8 *refuses* the answer is the
|
||
**cut-out** — because without that second line the sweep would be satisfied by
|
||
`soc_floor` typed straight through, and the mutation that deletes the `max()`
|
||
could never bite. It does bite: M2 on the PR.
|
||
|
||
**What IS live at a rule-8-valid roster** is the rest of the card: rule 8 now
|
||
refuses the two-model config that used to start; the permission is answered per
|
||
pack, so the alarm names the pack that stopped the site rather than "the
|
||
battery"; and an entry with no stated cut-out is withheld instead of defaulted.
|
||
|
||
## 4b. ⚠️ ROUND 4 — the sibling argument, and SPIKE-04 making this the live axis
|
||
|
||
Rounds 2 and 3 hardened the **floor** axis: the `max` inside
|
||
`binding_soc_floor()`, the roster handed to it, `Controller.packs`, and
|
||
`pack_floor_pct()`'s conjunction. Round 4's blocker is the same family at the
|
||
**sibling argument of the same call**:
|
||
|
||
```python
|
||
self.peak_guard = peakguard.decide(
|
||
...,
|
||
soc=self.pack_floor_soc, # MINIMUM <- round 4
|
||
soc_floor=peakguard.binding_soc_floor(self.packs, ...), # MAXIMUM <- rounds 2-3
|
||
```
|
||
|
||
`peakguard.py`'s own docstring names them as a pair — *"MAXIMUM here pairs with
|
||
the MINIMUM in `pack_floor_soc`"* — and a conjunction is exactly as strong as
|
||
its weaker half. On `52380c0`, with all 19 files green and verified again after
|
||
the rebase onto `3934ad7`:
|
||
|
||
| mutation in `Controller.pack_floor_soc` | bytes | before round 4 | now |
|
||
|---|---|---|---|
|
||
| `min(float(v) for v in vals[:2])` | +4 | **GREEN** | RED |
|
||
| `min(float(v) for v in vals[-2:])` | +5 | **GREEN** | RED |
|
||
| `min(float(v) for v in vals[:3])` | +4 | **GREEN** | RED |
|
||
| `vals[:1]`, `vals[-1:]`, `vals[1:]`, `vals[:-1]`, `max(vals)`, the mean | | RED | RED |
|
||
|
||
The suite's separating power on the SoC axis stopped at exactly **N = 2** —
|
||
precisely the state the floor axis was in before round 3, and which round 3
|
||
itself named as its blocker 2. A fixture of N packs cannot separate reductions
|
||
that agree on N packs.
|
||
|
||
**It changes a real answer.** A three-pack roster at 50 / 60 / 11 %, emptiest
|
||
pack **third**: the shipped property answers 11 %, `vals[:2]` answers 50 %. 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 at all. The verdict flips with it:
|
||
11 % is below the 20 % EMS floor, so the shipped site is floor-limited and sizes
|
||
no forced discharge, where the mutant is permitted and sizes a positive one.
|
||
|
||
### ⚠️ SPIKE-04 makes the differing-floor discipline the entire exposure
|
||
|
||
SPIKE-04 reads pack B as **4 × Dyness DL5.0C, DoD 90 → floor 10 %** — **FROM THE
|
||
DATASHEET AND THE SERIAL. Inverter B does not physically exist (~8 days out) and
|
||
nothing about it has been measured.** Pack A is DoD 90 → 10 % as well, and *that
|
||
one is measured*, off register `0x0560` on the reference GW5000S-BP.
|
||
|
||
So **production is degenerate on the floor axis**: both packs floor at the same
|
||
value, `max(10, 10) == 10`, and a differing floor exists **only in the
|
||
fixtures**. That is not an argument for simplifying the fixtures to matching
|
||
values — it is the argument for keeping them. `binding_soc_floor()`'s reduction
|
||
is observable *nowhere else*, and the moment a third pack, a replacement pack or
|
||
a different chemistry arrives it is observable in the field. **Anyone who
|
||
"simplifies" `A_PACK_FLOOR != B_PACK_FLOOR_SIM` to one number deletes the only
|
||
data this repository has that can see the reduction at all**; part 12c2's
|
||
precondition check goes red if they do, and this paragraph is why.
|
||
|
||
The **SoC axis has no such degeneracy**. Two batteries at the same state of
|
||
charge is a coincidence, not a configuration, so `pack_floor_soc` is the half of
|
||
the pair that will be non-degenerate in the field from the day inverter B is
|
||
wired up.
|
||
|
||
### The treatment, transferred from round 3
|
||
|
||
* **the roster SET** — six real `Controller.cycle()` runs, the emptiest pack at
|
||
every position of a three-pack roster and both positions of a two-pack one,
|
||
the observable read off `PeakDecision.soc` (the argument, echoed) rather than
|
||
off a spy or a grep. Cut-outs held flat across all of them, so the floor axis
|
||
cannot be what moved the answers.
|
||
* **the positional-slice table and the non-slice table** — computed against the
|
||
real `Controller.inverters` each cycle carried and the real decision it
|
||
produced, printed, with a member that nothing kills taking the check red.
|
||
* **the length sweep** — the real property on real rosters of 1 to 12 inverters,
|
||
the empty pack at every position. Closes head and tail windows of width 1-11
|
||
at once. Width 12 and wider is **honestly not reached**, and asserted to be
|
||
unreached so a later sweep that lengthens the rosters must rewrite the
|
||
sentence.
|
||
* **the exact structural pin** — three of them, because there are three
|
||
reductions over roster length in this one property: `vals` is built by one
|
||
comprehension over the bare `self.inverters`; `any(... for v in vals)` reduces
|
||
the whole list; `min(... for v in vals)` reduces the whole list. Every slice
|
||
of either is red at **every** roster length, including the `[:3]` and `[:12]`
|
||
the runs and the sweep cannot see.
|
||
|
||
Unlike the floor axis, **there is no uncaught row here**: the claim at
|
||
`pack_floor_soc` is an *identity* ("this list"), so a shape pin is the right
|
||
instrument, where `binding_soc_floor()`'s claim is *semantic* ("the maximum")
|
||
and a shape pin there would refuse honest refactors. That asymmetry is why one
|
||
carries an open row and the other does not.
|
||
|
||
## 4c. ⚠️ THE ENUMERATION — what OTHER reductions over roster length are graded
|
||
|
||
This card has now been rejected three times for the same shape one step over, so
|
||
round 4 stopped closing members and enumerated the **family**. Every reduction
|
||
over `self.inverters` / `self.packs` in `Controller` was found from the AST and
|
||
mutated to a slice, one at a time, against the full 19-file suite.
|
||
|
||
| # | site | reduction | consumer | mutation | result |
|
||
|---|---|---|---|---|---|
|
||
| 1 | `pack_floor_soc` | `min` over SoC | `decide(soc=)`, `arbitrage.decide(soc=)` | `vals[:2]` +4 | **was GREEN → now RED** (12i) |
|
||
| 2 | `pack_floor_soc` | `any(v is None)` | same | `vals[:1]` +4 | RED (raises in 12d; pinned in 12i) |
|
||
| 3 | `pack_floor_soc` | the `vals` comprehension | same | `self.inverters[:2]` +4 | RED (12i sweep + pin) |
|
||
| 4 | `binding_soc_floor` | `max` over floors | `decide(soc_floor=)` | `min`, windows | RED (12c2, round 2-3) |
|
||
| 5 | call site | the roster argument | same | `self.packs[:1..3]` | RED (12g, round 3) |
|
||
| 6 | `Controller.packs` | the comprehension | `binding_soc_floor`, `pack_permissions` | `self.inverters[:2]` +4 | RED (12g three-pack runs) |
|
||
| 7 | `site_batt_w` | `sum` + `any(None)` | `decide(batt_w=)` — sizes the floor | `self.inverters[:1]` +4 | RED (test_inverter.py) |
|
||
| 8 | `site_rating_w` | `sum` of per-unit conjunction | `decide(achievable_w=)`, freeze rail | `[:1]` +4 | RED (test_inverter.py) |
|
||
| 9 | `site_max_w` | `sum` of `max_w` | the site loop's supervised bound | `[:1]` +4 | RED (test_inverter.py) |
|
||
| 10 | `site_dev_bound` | `sum` + `any(None)` | `looks_frozen`, `rail_unreachable` | `[:1]` +4 | RED (test_inverter.py) |
|
||
| 11 | `site_circulating_w` | `circulating_w` over measured power | SAFETY-31 trip | `[:1]` +4 | RED (test_watchdog.py) |
|
||
| 12 | `inputs_ok` | `all` over both readings | gates the whole cycle (§11.1 rule 1) | `[:1]` +4 | RED (test_inverter.py) |
|
||
| 13 | `units` | `tuple` of names | the arbiter's scope table | `[:1]` +4 | RED (5 files) |
|
||
| 14 | `status_payload` | `min(_dues)` over `self.maints` | the operator's "maintenance due" row | `_dues[:1]` +4 | RED (test_maintenance.py) |
|
||
| 15 | `maint_unit` | `self.inverters[self.maint_index]` | which pack the FSM drives | `[0]` −15 | RED (test_maintenance.py) |
|
||
| 16 | store migration | the roster name list | MAINT-05's key migration | `[:1]` +4 | RED (test_maintenance.py) |
|
||
| 17 | `validate_unit_ranges` | the roster argument | startup refusal | `[:1]` +4 | RED (test_inverter.py) |
|
||
| 18 | `cycle()` stale-input hold | `any(sign_wd.tripped)` | **a latched unit's failsafe SET** | `[:1]` +4 | **was GREEN → now RED** (test_watchdog.py part 8) |
|
||
| 19 | `publish()` status | `any(sign_wd.tripped)` | the MQTT status vocabulary | `[:1]` +4 | **was GREEN → now RED** (part 8) |
|
||
| 20 | `status_payload()` banner | `any(sign_wd.tripped)` | the one headline on the page | `[:1]` +4 | **was GREEN → now RED** (part 8) |
|
||
| 21 | `_grade_store_migration` | `any(sign_wd.tripped)` | SAFETY-02's re-arm guard | `[:1]` +4 | was GREEN → now RED (12j), see below |
|
||
| 22 | `cycle()` undeliverable list | `sorted(...)` over shares | the *text* of one event line | `[:1]` +4 | **STILL GREEN — honest gap, below** |
|
||
|
||
**Rows 18-20 are the finding, and they are not this card's code.** All three are
|
||
`any(i.sign_wd.tripped for i in self.inverters)`, all three are SAFETY-30's, and
|
||
all three survived a green 19-file suite for exactly the reason every blocker on
|
||
this card survived: *the only fixture that reached them was a roster of ONE,
|
||
where `self.inverters[:1]` IS `self.inverters`.* Part 3 of test_watchdog.py has
|
||
built a two-inverter roster with only inverter B latched since SAFETY-30
|
||
shipped — the precise data all three needed — and none of them read it.
|
||
|
||
Row 18 is the one with teeth. A latched unit must take the whole roster past the
|
||
stale-input hold, because that unit's `failsafe` SET is the thing parking it;
|
||
main.py's own comment says *"the trip would stop acting the moment a poll was
|
||
missed"*. With `[:1]`, a latched inverter B plus a one-cycle gap in inverter A's
|
||
SoC returns early, and B's last share stands for the heartbeat to re-emit.
|
||
test_watchdog.py part 8 now drives that with the latch at **each** position, plus
|
||
the no-latch counterfactual that proves the hold really fires.
|
||
|
||
**Row 21 — graded structurally, and argued to be redundant.** The `[:1]` there
|
||
is now red on 12j's enumeration, but I could not construct a *reachable* state in
|
||
which it changes an answer: `_grade_store_migration()` runs at startup, before
|
||
any cycle, so a unit is tripped in memory only if its own `unit/<name>/sign_tripped`
|
||
key was truthy — which makes `namespaced_latched` True and short-circuits the
|
||
disjunct. I believe it is an equivalent mutant at that call site rather than a
|
||
live hole, and I am recording the argument instead of asserting the conclusion.
|
||
|
||
**Row 22 — STILL GREEN, and reported with its size.** `", ".join(sorted(u for u
|
||
in resolution.shares if not self._can_write(u)))[:1]` truncates the **names in
|
||
one event-log line**. It is +4 bytes and no check bites. The refusal itself —
|
||
`elif {u for u in resolution.shares if not self._can_write(u)}` — is a different
|
||
expression and *is* graded (test_peakguard.py part 10h-1b), so the site still
|
||
stops; what a `[:1]` costs is an operator being told one name when two units have
|
||
no writer. It is not a roster comprehension (it iterates `resolution.shares`), so
|
||
12j does not cover it and does not pretend to. Closing it needs a three-inverter
|
||
fixture with two writer-less entries, which is ALLOC-01's fixture and not this
|
||
card's. **Filed as open, with its size and its consequence, rather than closed
|
||
quietly or left unmentioned.**
|
||
|
||
### And the family closed rather than the member
|
||
|
||
`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
|
||
|
||
`peak_soc_floor` is still **one number for the site**, so the card's own
|
||
"or the deeper pack is needlessly withheld" half is not closed and this record
|
||
does not pretend it is. With cut-outs of 10 % and 18 %, rule 8 obliges the
|
||
operator to set the EMS floor above 18, and the 10 % pack is then held above its
|
||
own need. Closing that means `peak_soc_floor` becoming a per-entry key too, which
|
||
is a second option migration and a product decision about what the site-wide
|
||
number then means. The card scoped itself to `bms_soc_floor` and explicitly told
|
||
this developer not to widen it, so it is filed here as the open question it is
|
||
rather than smuggled in.
|
||
|
||
**Also still not in this card, and named by the shipped code already:** the
|
||
charge *ceiling* is the mirror image — `pack_floor_soc` is the wrong reduction
|
||
for it, because a ceiling is the **fullest** pack and `arbitrage`'s `soc_ceiling`
|
||
takes one number. `main.py:932-936` says so, and it belongs to CFG-04/SPIKE-04's
|
||
per-pack bound. Nothing here touched it.
|
||
|
||
## 6. What was changed, and one thing that was found on the way
|
||
|
||
* `config.yaml` — `bms_soc_floor: int(0,50)` required per `inverters:` entry;
|
||
the flat key and its default are untouched and are documented as the
|
||
single-inverter shape's own cut-out.
|
||
* `app/inverter.py` — `Inverter.bms_soc_floor`; `_entry_bms_floor()` (`None`, not
|
||
a borrowed number and not `0.0`); `_flat_bms_floor()`.
|
||
* `app/peakguard.py` — `Pack`, `pack_floor_pct()`, `pack_permissions()`,
|
||
`binding_soc_floor()`. `discharge_permitted()` is unchanged and is still the
|
||
one place the question is answered.
|
||
* `app/validate.py` — `unit_bms_floors_indexed()`; rule 8 grades every unit and
|
||
names it via the **roster** index the value came off.
|
||
* `app/main.py` — `Controller.packs`; the `decide(soc_floor=…)` reduction; the
|
||
floor-limited notification names the pack(s) at or below their own floor, at a
|
||
roster of two or more only.
|
||
* `tools/supervisor_shape_probe.py` — **re-run, and repaired.** MUTATION.md
|
||
habit 8: a committed transcript is a check and it decays. Making the key
|
||
required broke §C outright and, worse, left §D printing **PASS** on a refusal
|
||
about the *wrong key* — its "no `max_w`" case was answered `Missing option
|
||
'bms_soc_floor'`. §D is now an isolated counterfactual over a **merged,
|
||
accepted** baseline with exactly one key varied per case, asserting the key's
|
||
**name** in the refusal, and it carries a `bms_soc_floor` case in both
|
||
directions. Re-run against `ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0`:
|
||
`all cases passed`.
|
||
* `app/inverter.py`, found by the mutation battery — `_flat_bms_floor()` was
|
||
written with the default in **two** places (the `.get()` fallback *and* the
|
||
blank test). Deleting either one survived, because each covered for the other:
|
||
MUTATION.md's masking section in three lines of option reading. It has one
|
||
defaulting line now, and M12 takes six checks red.
|
||
|
||
* **Round 3 changed no `app/` file at all.** `test_peakguard.py` only:
|
||
parts 12c2 and 12g. Its battery was **46 mutations across
|
||
`binding_soc_floor()`'s reduction, the `decide()` call site, `Controller.packs`
|
||
and `pack_floor_pct()`'s conjunction** — bytecode purged and counted before and
|
||
after each (23 → 0). **43 red, 3 survivors, all three recorded above and in the
|
||
in-process rows**: `max(floors[:12])` and `max(floors[-12:])`, which need a
|
||
13-pack roster to observe and are reported with that size rather than hidden;
|
||
and `max(set(floors))`, which is **equivalent** — `max` is duplicate-invariant,
|
||
so it cannot change an answer. `list(reversed(self.packs))` is equivalent too
|
||
(`max` is order-invariant) and is nonetheless red on the structural pin.
|
||
|
||
⚠️ **No measurement of inverter B appears anywhere in this change.** It does not
|
||
physically exist. Every second-pack number in a fixture, in the probe and in
|
||
config.yaml's commented example is **simulated and labelled as such**; only the
|
||
10 % on inverter A is read off hardware. The three-pack runs added in round 3 are simulated
|
||
twice over: there is no inverter C at all, and the shallow slots carry inverter
|
||
A's measured 10 % while the 35 % cut-out that moves along the roster is invented.
|
||
|
||
---
|
||
|
||
## 7. The probe transcript, re-run 2026-09-05 on 2026.08.0
|
||
|
||
Command (unchanged, in `tools/supervisor_shape_probe.py`'s own docstring):
|
||
|
||
```
|
||
docker run --rm -v "$PWD:/w:ro" -v "$PWD/tools:/probe:ro" \
|
||
--entrypoint python3 \
|
||
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0 \
|
||
/probe/supervisor_shape_probe.py
|
||
```
|
||
|
||
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
|
||
[{"name": "inverter-a", "setpoint_entity": "number.goodwe_a_setpoint_w", "soc_entity": "sensor.goodwe_a_soc", "batt_entity": "sensor.goodwe_a_battery_power", "max_w": 5000, "bms_soc_floor": 10}, {"name": "inverter-b", "setpoint_entity": "number.goodwe_b_setpoint_w", "soc_entity": "sensor.goodwe_b_so
|
||
|
||
== 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": 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
|
||
Missing option 'setpoint_entity' in inverters in GoodWe RS485 Controller (local_goodwe_controller)
|
||
PASS an entry with no max_w is refused, and the refusal NAMES it
|
||
Missing option 'max_w' in inverters in GoodWe RS485 Controller (local_goodwe_controller)
|
||
PASS an entry with no bms_soc_floor (SAFETY-32) is refused, and the refusal NAMES it
|
||
Missing option 'bms_soc_floor' in inverters in GoodWe RS485 Controller (local_goodwe_controller)
|
||
PASS an entry with max_w over the range is refused, and the refusal NAMES it
|
||
value must be at most 5000.0
|
||
PASS an entry with bms_soc_floor over the range (SAFETY-32) is refused, and the refusal NAMES it
|
||
value must be at most 50.0
|
||
PASS a JSON STRING where the list belongs is refused
|
||
Invalid list for option 'inverters' in GoodWe RS485 Controller (local_goodwe_controller)
|
||
|
||
== E. the ROLLBACK direction, and the operator's UI ==
|
||
PASS a schema WITHOUT `inverters` drops the key with a warning rather than refusing - so a downgrade is not the §B trap in reverse
|
||
['arbitrage_enabled', 'arbitrage_soc_ceiling', 'arbitrage_soc_floor', 'auto_start', 'batt_entity', 'batt_invert']
|
||
PASS Supervisor's UI renderer turns it into a repeatable section rather than choking - an operator can add the second inverter in the Configuration tab, which the JSON-string alternative could not offer
|
||
[{"name": "inverters", "type": "schema", "optional": true, "multiple": true, "schema": [{"name": "name", "required": true, "type": "string"}, {"name": "setpoint_entity", "required": true, "type": "string"}, {"name": "max_w", "lengthMin": 100.0, "lengthMax": 5000.0, "required": true, "type": "integer
|
||
|
||
== all cases passed ==
|
||
```
|