SAFETY-32: bms_soc_floor per pack, rule 8 per unit #48

Merged
admin merged 5 commits from SAFETY-32 into release/1.0 2026-09-05 08:10:51 +02:00
Owner

What this is

SAFETY-32, scoped to the card's third bullet only, per its PM PROOFREAD. I
read main.py:1956 and main.py:1723 first and the proofread is correct:

  • Aggregate anti-windup — already done. actual_w=self.site_batt_w, with the
    comment recording why that half of §6.2's identity had to become site-level.
    Untouched, and not turned into a per-unit test.
  • Per-instance achievable_w — already done. achievable_w=self.site_rating_w,
    summed from the per-unit conjunction. Untouched.
  • Per-instance bms_soc_floor + rule 8 — this PR.

The defect

bms_soc_floor describes a battery — the percent that pack's own BMS cuts
out at, register 0x0560, read as 10 on the reference GW5000S-BP. It was flat
because there was one pack. Rule 8 refuses peak_soc_floor <= bms_soc_floor
because such an EMS floor can never bind: the BMS wins and the status page
reports a backstop that does not exist.

With one cut-out for two packs of different models, rule 8 graded the EMS
floor against whichever battery happened to be in the flat key. A site with
cut-outs of 10 % and 30 % under peak_soc_floor: 20 started, and the
guard would then command a forced discharge that pack B's BMS — not the
operator's floor — is the thing that stops. Rule 8's own failure mode, reached
through rule 8.

What changed

file change
config.yaml bms_soc_floor: int(0,50) required per inverters: entry. Flat key unchanged, now documented as the single-inverter shape's own cut-out
app/inverter.py Inverter.bms_soc_floor; _entry_bms_floor() → None when unstated (not a borrowed number, not 0.0); _flat_bms_floor()
app/peakguard.py Pack, pack_floor_pct(), pack_permissions(), binding_soc_floor(). discharge_permitted() unchanged — still the one place the question is answered
app/validate.py unit_bms_floors_indexed(); rule 8 grades every unit and names it
app/main.py Controller.packs; decide(soc_floor=…) is the per-pack reduction of the same option; the floor-limited notification names the pack that stopped the site
tools/supervisor_shape_probe.py re-run and repaired — see below
DECISIONS/SAFETY-32-per-pack-bms-floor.md the two judgement calls, the limitation, the residual

pack_floor_soc is untouched. The card says the min() over measured SoC is
right and is not what this card changes; it still answers the emptiest pack and
still answers None if any unit did not read. What moved is the floor it is
graded against, plus a per-pack answer beside the site's one verdict.

The two judgement calls the card left open

1. REQUIRED per entry, not defaulted. A default is one number applied to
every pack — which is the defect this card removes, wearing a different hat.
An add-on that answers "10 %" for a pack nobody told it about has invented a
property of somebody's battery. CFG-03 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 makes one dict the
default in two shapes with two meanings. It is also max_w's treatment, for
max_w's reason.

⚠️ The cost is real and is stated, not hidden. This is a schema change: a
roster config written before this branch is refused until each entry gains the
key, and every roster fixture in the suite needed it. Nothing in production
carries a roster (inverters: ships []; 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.

2. The site verdict is max() over the packs' own floors. A minimum SoC
against a maximum floor is the conservative pairing: 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. The converse is not claimed.

⚠️ The limitation, said plainly

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 non-degenerate only against
a config rule 8 refuses.

That is not hidden and it is not a reason to leave the reduction out — it is
unit_caps_indexed()'s own argument, in that function's words: "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 it is graded on both sides — 1775 rule-8-valid
(peak_soc_floor, bms_soc_floor) pairs answer the option, and the pair rule 8
refuses answers the cut-out. Without that second line the sweep would be
satisfied by soc_floor typed straight through and M2 below could never bite.

What IS live at a rule-8-valid roster: rule 8 refuses the two-model config
that used to start; the permission is answered per pack, so the alarm names the
pack rather than "the battery"; an unstated cut-out withholds instead of
defaulting.

⚠️ The residual — NOT in 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. With cut-outs of
10 % and 18 %, rule 8 obliges the EMS floor above 18 and the 10 % pack is held
above its own need. Closing that means peak_soc_floor becoming per-entry too —
a second option migration and a product decision. The card told me not to widen,
so it is filed in the decision record as the open question it is. The charge
ceiling (the fullest pack) is likewise untouched and still belongs to
CFG-04/SPIKE-04, as main.py:932-936 already says.

How the fixtures are built to discriminate

The repo's recurring defect is a check whose data cannot reach the failure it
names, and it has bitten SAFETY-30 and CFG-04 in consecutive cards. Every
fixture here is built against it, and the preconditions are their own checks:

  • AC 1's two packs sit at the SAME state of charge (25 %), with cut-outs of
    10 % and 35 %. SoC is held equal so it cannot be what separates the two
    answers; only the floors differ. A check() asserts the floors differ and
    straddle peak_soc_floor before the claim is made (habit 1).
  • The counterfactual varies exactly one thing (habit 7): give B the same
    cut-out as A and both are permitted. Without it, AC 1 would also pass for an
    implementation that refused the second entry always.
  • The other axis is graded too: equal floors, different SoC → the emptier
    pack is withheld. A grader reading packs[0].soc for all passes one and fails
    the other.
  • The refusal is asserted by CONTENT (habit 6): FLOOR_AT_SOC_FLOOR, not
    "something was refused" — not None is equally satisfied by an unreadable SoC
    and by an unconfigured floor, and those are three different operator actions.
  • The unstated-cut-out pack is probed at 100 % SoC — the one value that
    disarms every other rung, so nothing else can be doing the refusing.
  • Rule 8 is graded with the offender SECOND, then the same two numbers in the
    OTHER ORDER, then both offending.
    Either hardwired index passes one and fails
    the other.
  • The inheritance check sets the flat key to 30 % — a number nothing else in
    the file has. CFG-04 shipped this exact claim against a blank flat key and
    watched the mutation survive.
  • Controller.packs is asserted against literals that differ on all three
    axes (habit 2), not against [(inv.name, inv.soc, inv.bms_soc_floor) …].
  • The notification is DRIVEN, not grepped (habit 4): the shipped
    check_peak_floor_limited() runs on a real two-pack Controller, and then again
    with the two readings swapped between packs.

AC 4 and AC 5

  • AC 4 — pack_floor_soc still returns None, never a number, when any unit
    did not answer, and the pack that did not answer reports FLOOR_SOC_UNREADABLE
    rather than being defaulted to full.
  • AC 5 — asserted, not assumed, and swept: over all 1775 rule-8-valid
    (peak_soc_floor, bms_soc_floor) pairs the schema accepts, the floor at a
    roster of one is peak_soc_floor. And the notification at a roster of one
    is compared byte-for-byte against the two-pack body minus its added sentence:
    no per-pack sentence at all, exactly what SAFETY-25 shipped.

Verification

Full 19-file suite: green. Run in the prebuilt goodwe-addon-ci image over a
tree rsynced to a plain path and bind-mounted :ro, so no __pycache__ can be
written into the tree under test.

Supervisor shape probe: re-run against
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0 → all cases passed.

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 an accepted,
merged
baseline with exactly one key varied per case, asserting the key's
name in the refusal, and it carries bms_soc_floor cases in both directions.
Full transcript in the decision record.

Mutation battery — 12 source edits through tools/mutation_run.sh

Each run: tree rsynced fresh, one edit applied, byte delta recorded, the mutated
line confirmed on disk, __pycache__ purged by the harness and the suites run
under python3 -B. Every row below is a red, which is the direction stale
bytecode cannot fabricate — and the harness purged anyway.

# mutation Δ bytes result the check that bites
M1 build_roster() inherits the flat bms_soc_floor for an entry that omits it −2 RED 12f "an entry that omits bms_soc_floor has NO cut-out and does NOT borrow the flat one"
M2 pack_floor_pct() returns soc_floor — the max() dropped −28 RED 12a AC 1, 12a content, 12c "on the pair rule 8 REFUSES the reduction answers the CUT-OUT"
M3 every pack graded against packs[0]'s floor +7 RED 12a AC 1 + the content check
M4 an unstated cut-out defaulted to 0.0 −19 RED 12b "WITHHELD even at a full battery"
M5 rule 8 grades only the FIRST unit ([:1]) +4 RED 12e AC 3, both-offend, missing-key, numbers-named
M6 rule 8's _unit_label(opts, _ix) hardwired to 1 −2 RED 12e "the SAME two numbers in the OTHER ORDER names inverter-a"
M7 main.py hands decide() the bare option again −41 RED 12g the call-site pin
M8 the alarm names self.packs[-1] instead of the pack at its floor −113 RED 12h "the same two readings swapped between the packs"
M9 Controller.packs uses self.inverters[0].bms_soc_floor for all +45 RED 12d "carries EACH unit's OWN name, SoC and cut-out"
M10 {where} dropped from rule 8's "the pack's own cut-out" clause −7 SURVIVOR — argued see below
M11 {where} dropped from both places, so the refusal names no unit −7 (on top of M10) RED 12e AC 3, the other order, both-offend
M12 _flat_bms_floor() returns None instead of the shipped default −39 RED 12f "the one pack takes the SHIPPED default out of the one mirror", plus AC 5's notification identity and four more

⚠️ M10 is an honest survivor, and the argument is structural. The unit label
appears twice in rule 8's message — once in "the pack's own cut-out
(bms_soc_floor (inverter-b))"
and once in "correct bms_soc_floor (inverter-b)
if it does not match this pack"
. Removing one leaves the refusal still naming the
unit, which is the property the checks assert, so there is nothing for them to
observe. M11 is the same mutation carried to the point where the property is
actually gone, and it is red on three checks. The survivor is a redundancy in the
sentence, not a hole in the check.

⚠️ The battery found a real weakness of its own, and it is fixed here.
_flat_bms_floor() was first written with the default in two places — the
.get() fallback and the blank test below it. The first M12 deleted one of
them and survived, because each was covering for the other: MUTATION.md's
masking section, in three lines of option reading. The function has one defaulting
line now, and M12 takes six checks red.

In-process mutation table: eight SAFETY-32 rows added to
test_peakguard.py part 11 (rows 14–21), each re-implementing a defeat and naming
the check that bites it. These edit no source file, so no .pyc is consulted.

⚠️ Inverter B

No measurement of inverter B appears anywhere in this change. It does not
physically exist. Every second-pack number — in fixtures (B_BMS_FLOOR_SIM = 15,
B_PACK_FLOOR_SIM, B_PACK_FLOOR_DEEP_SIM), in the shape probe, in
config.yaml's commented example — is simulated and labelled as simulated at
its definition
. Only inverter A's 10 % is read off hardware (register 0x0560).
No hardware was touched: no rig, no ESP32, no inverter, no Pi, no
/dev/ttyUSB0, no riglock.py, no live Home Assistant, nothing flashed. The
Supervisor probe runs a validator in a throwaway container against config.yaml
and talks to no live Supervisor.

Do not merge — for review.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU

## What this is SAFETY-32, scoped **to the card's third bullet only**, per its PM PROOFREAD. I read `main.py:1956` and `main.py:1723` first and the proofread is correct: * **Aggregate anti-windup — already done.** `actual_w=self.site_batt_w`, with the comment recording why that half of §6.2's identity had to become site-level. Untouched, and not turned into a per-unit test. * **Per-instance `achievable_w` — already done.** `achievable_w=self.site_rating_w`, summed from the per-unit conjunction. Untouched. * **Per-instance `bms_soc_floor` + rule 8 — this PR.** ## The defect `bms_soc_floor` **describes a battery** — the percent that pack's own BMS cuts out at, register `0x0560`, read as 10 on the reference GW5000S-BP. It was flat because there was one pack. Rule 8 refuses `peak_soc_floor <= bms_soc_floor` because such an EMS floor can never bind: the BMS wins and the status page reports a backstop that does not exist. With **one** cut-out for two packs of different models, rule 8 graded the EMS floor against whichever battery happened to be in the flat key. A site with cut-outs of **10 % and 30 %** under `peak_soc_floor: 20` **started**, and the guard would then command a forced discharge that pack B's *BMS* — not the operator's floor — is the thing that stops. Rule 8's own failure mode, reached through rule 8. ## What changed | file | change | |---|---| | `config.yaml` | `bms_soc_floor: int(0,50)` **required** per `inverters:` entry. Flat key unchanged, now documented as the single-inverter shape's own cut-out | | `app/inverter.py` | `Inverter.bms_soc_floor`; `_entry_bms_floor()` → `None` when unstated (not a borrowed number, not `0.0`); `_flat_bms_floor()` | | `app/peakguard.py` | `Pack`, `pack_floor_pct()`, `pack_permissions()`, `binding_soc_floor()`. `discharge_permitted()` **unchanged** — still the one place the question is answered | | `app/validate.py` | `unit_bms_floors_indexed()`; rule 8 grades **every** unit and **names** it | | `app/main.py` | `Controller.packs`; `decide(soc_floor=…)` is the per-pack reduction of the same option; the floor-limited notification names the pack that stopped the site | | `tools/supervisor_shape_probe.py` | re-run and repaired — see below | | `DECISIONS/SAFETY-32-per-pack-bms-floor.md` | the two judgement calls, the limitation, the residual | **`pack_floor_soc` is untouched.** The card says the `min()` over measured SoC is right and is not what this card changes; it still answers the emptiest pack and still answers `None` if any unit did not read. What moved is the **floor** it is graded against, plus a per-pack answer beside the site's one verdict. ## The two judgement calls the card left open **1. REQUIRED per entry, not defaulted.** A default is one number applied to every pack — which *is* the defect this card removes, wearing a different hat. An add-on that answers "10 %" for a pack nobody told it about has invented a property of somebody's battery. CFG-03 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 makes one dict the default in two shapes with two meanings. It is also `max_w`'s treatment, for `max_w`'s reason. ⚠️ **The cost is real and is stated, not hidden.** This is a schema change: a roster config written before this branch is refused until each entry gains the key, and every roster fixture in the suite needed it. Nothing in production carries a roster (`inverters:` ships `[]`; 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. **2. The site verdict is `max()` over the packs' own floors.** A minimum SoC against a maximum floor is the conservative pairing: `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. The converse is not claimed. ## ⚠️ The limitation, said plainly **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 non-degenerate only against a config rule 8 refuses. That is not hidden and it is not a reason to leave the reduction out — it is `unit_caps_indexed()`'s own argument, in that function's words: *"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 it is graded on **both** sides — 1775 rule-8-valid `(peak_soc_floor, bms_soc_floor)` pairs answer the option, and the pair rule 8 refuses answers the **cut-out**. Without that second line the sweep would be satisfied by `soc_floor` typed straight through and M2 below could never bite. **What IS live at a rule-8-valid roster:** rule 8 refuses the two-model config that used to start; the permission is answered per pack, so the alarm names the pack rather than "the battery"; an unstated cut-out withholds instead of defaulting. ## ⚠️ The residual — NOT in 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. With cut-outs of 10 % and 18 %, rule 8 obliges the EMS floor above 18 and the 10 % pack is held above its own need. Closing that means `peak_soc_floor` becoming per-entry too — a second option migration and a product decision. The card told me not to widen, so it is filed in the decision record as the open question it is. The charge **ceiling** (the fullest pack) is likewise untouched and still belongs to CFG-04/SPIKE-04, as `main.py:932-936` already says. ## How the fixtures are built to discriminate The repo's recurring defect is a check whose data cannot reach the failure it names, and it has bitten SAFETY-30 and CFG-04 in consecutive cards. Every fixture here is built against it, and the preconditions are their own checks: * **AC 1's two packs sit at the SAME state of charge** (25 %), with cut-outs of **10 % and 35 %**. SoC is held equal so it *cannot* be what separates the two answers; only the floors differ. A `check()` asserts the floors differ and straddle `peak_soc_floor` **before** the claim is made (habit 1). * **The counterfactual varies exactly one thing** (habit 7): give B the same cut-out as A and both are permitted. Without it, AC 1 would also pass for an implementation that refused the second entry always. * **The other axis is graded too**: equal floors, different SoC → the emptier pack is withheld. A grader reading `packs[0].soc` for all passes one and fails the other. * **The refusal is asserted by CONTENT** (habit 6): `FLOOR_AT_SOC_FLOOR`, not "something was refused" — `not None` is equally satisfied by an unreadable SoC and by an unconfigured floor, and those are three different operator actions. * **The unstated-cut-out pack is probed at 100 % SoC** — the one value that disarms every other rung, so nothing else can be doing the refusing. * **Rule 8 is graded with the offender SECOND, then the same two numbers in the OTHER ORDER, then both offending.** Either hardwired index passes one and fails the other. * **The inheritance check sets the flat key to 30 %** — a number nothing else in the file has. CFG-04 shipped this exact claim against a *blank* flat key and watched the mutation survive. * **`Controller.packs` is asserted against literals** that differ on all three axes (habit 2), not against `[(inv.name, inv.soc, inv.bms_soc_floor) …]`. * **The notification is DRIVEN, not grepped** (habit 4): the shipped `check_peak_floor_limited()` runs on a real two-pack Controller, and then again with the two readings swapped between packs. ## AC 4 and AC 5 * **AC 4** — `pack_floor_soc` still returns `None`, never a number, when any unit did not answer, and the pack that did not answer reports `FLOOR_SOC_UNREADABLE` rather than being defaulted to full. * **AC 5** — asserted, not assumed, and **swept**: over all **1775** rule-8-valid `(peak_soc_floor, bms_soc_floor)` pairs the schema accepts, the floor at a roster of one **is** `peak_soc_floor`. And the notification at a roster of one is compared byte-for-byte against the two-pack body minus its added sentence: no per-pack sentence at all, exactly what SAFETY-25 shipped. ## Verification **Full 19-file suite: green.** Run in the prebuilt `goodwe-addon-ci` image over a tree rsynced to a plain path and bind-mounted `:ro`, so no `__pycache__` can be written into the tree under test. **Supervisor shape probe: re-run against `ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0` → `all cases passed`.** 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 an **accepted, merged** baseline with exactly one key varied per case, asserting the key's **name** in the refusal, and it carries `bms_soc_floor` cases in both directions. Full transcript in the decision record. ### Mutation battery — 12 source edits through `tools/mutation_run.sh` Each run: tree rsynced fresh, one edit applied, byte delta recorded, the mutated line confirmed on disk, `__pycache__` **purged** by the harness and the suites run under `python3 -B`. Every row below is a **red**, which is the direction stale bytecode cannot fabricate — and the harness purged anyway. | # | mutation | Δ bytes | result | the check that bites | |---|---|---|---|---| | M1 | `build_roster()` inherits the flat `bms_soc_floor` for an entry that omits it | −2 | **RED** | 12f *"an entry that omits `bms_soc_floor` has NO cut-out and does NOT borrow the flat one"* | | M2 | `pack_floor_pct()` returns `soc_floor` — the `max()` dropped | −28 | **RED** | 12a AC 1, 12a content, 12c *"on the pair rule 8 REFUSES the reduction answers the CUT-OUT"* | | M3 | every pack graded against `packs[0]`'s floor | +7 | **RED** | 12a AC 1 + the content check | | M4 | an unstated cut-out defaulted to `0.0` | −19 | **RED** | 12b *"WITHHELD even at a full battery"* | | M5 | rule 8 grades only the FIRST unit (`[:1]`) | +4 | **RED** | 12e AC 3, both-offend, missing-key, numbers-named | | M6 | rule 8's `_unit_label(opts, _ix)` hardwired to `1` | −2 | **RED** | 12e *"the SAME two numbers in the OTHER ORDER names inverter-a"* | | M7 | `main.py` hands `decide()` the bare option again | −41 | **RED** | 12g the call-site pin | | M8 | the alarm names `self.packs[-1]` instead of the pack at its floor | −113 | **RED** | 12h *"the same two readings swapped between the packs"* | | M9 | `Controller.packs` uses `self.inverters[0].bms_soc_floor` for all | +45 | **RED** | 12d *"carries EACH unit's OWN name, SoC and cut-out"* | | M10 | `{where}` dropped from rule 8's *"the pack's own cut-out"* clause | −7 | **SURVIVOR — argued** | see below | | M11 | `{where}` dropped from **both** places, so the refusal names no unit | −7 (on top of M10) | **RED** | 12e AC 3, the other order, both-offend | | M12 | `_flat_bms_floor()` returns `None` instead of the shipped default | −39 | **RED** | 12f *"the one pack takes the SHIPPED default out of the one mirror"*, plus AC 5's notification identity and four more | ⚠️ **M10 is an honest survivor, and the argument is structural.** The unit label appears **twice** in rule 8's message — once in *"the pack's own cut-out (`bms_soc_floor (inverter-b)`)"* and once in *"correct `bms_soc_floor (inverter-b)` if it does not match this pack"*. Removing one leaves the refusal still naming the unit, which is the property the checks assert, so there is nothing for them to observe. M11 is the same mutation carried to the point where the property is actually gone, and it is red on three checks. The survivor is a redundancy in the sentence, not a hole in the check. ⚠️ **The battery found a real weakness of its own, and it is fixed here.** `_flat_bms_floor()` was first written with the default in **two** places — the `.get()` fallback *and* the blank test below it. The first M12 deleted one of them and **survived**, because each was covering for the other: MUTATION.md's masking section, in three lines of option reading. The function has one defaulting line now, and M12 takes six checks red. **In-process mutation table**: eight SAFETY-32 rows added to `test_peakguard.py` part 11 (rows 14–21), each re-implementing a defeat and naming the check that bites it. These edit no source file, so no `.pyc` is consulted. ## ⚠️ Inverter B **No measurement of inverter B appears anywhere in this change.** It does not physically exist. Every second-pack number — in fixtures (`B_BMS_FLOOR_SIM = 15`, `B_PACK_FLOOR_SIM`, `B_PACK_FLOOR_DEEP_SIM`), in the shape probe, in `config.yaml`'s commented example — is **simulated and labelled as simulated at its definition**. Only inverter A's 10 % is read off hardware (register `0x0560`). No hardware was touched: no rig, no ESP32, no inverter, no Pi, no `/dev/ttyUSB0`, no `riglock.py`, no live Home Assistant, nothing flashed. The Supervisor probe runs a validator in a throwaway container against `config.yaml` and talks to no live Supervisor. **Do not merge — for review.** 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
Author
Owner

SAFETY-32 FIX ROUND — AC 6 now met for the reduction main.py actually hands decide()

app/ is untouched. No shipped behaviour changed. What changed is what
observes it: two checks whose data could not reach the failure they named,
plus two more the same battery found on the way.

Why both survivors survived, and it is Decision 2's own degeneracy

Under any rule-8-valid config every pack's pack_floor_pct() equals
peak_soc_floor — rule 8 refuses peak_soc_floor <= bms_soc_floor on every
unit, so max() selects the EMS floor everywhere. floors is therefore a list
of identical numbers, and max, min, floors[0] and floors[-1] are four
spellings of one answer. The only check in the suite that used a cut-out above
the EMS floor — 12c's _n1_deep — ran on a roster of ONE, where every
reduction agrees. So no check ever evaluated binding_soc_floor() on two packs
whose floors differ.

And 12g's call-site pin asserted "self.packs" in <the unparsed expression>.
self.packs[:1] contains that substring. MUTATION.md habit 4: a source-text
test is a check on how code is spelled, not on what it does — CFG-04's if entity: in a different costume.

The degeneracy is not a defence, by this PR's own argument. 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"
justifies
writing the reduction, it equally obliges grading 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

test_peakguard.py part 12c2 — NEW. binding_soc_floor() on a roster whose
per-pack floors all differ. Four SIMULATED cut-outs, each strictly above
peak_soc_floor: 20, so pack_floor_pct() selects the cut-out for each and
floors is that tuple in that order:

    a SIMULATED roster of 4 with cut-outs ['30', '25', '40', '35'] % over peak_soc_floor 20 % -> per-pack floors ['30', '25', '40', '35'] %
    candidate reductions: max=40, min=25, first=30, last=35, mean=32.5; binding_soc_floor() -> 40
    ...raise ONLY the last pack 35 % -> 45 %: 40 -> 45

Why these four numbers discriminate, stated in the file: the extremes sit at
interior roster positions (index 2 is the max, index 1 the min). A two-pack
roster structurally cannot separate the four — there max and last coincide
and so do min and first — and neither can three. Four is the smallest
roster that separates max, min, first and last.
The precondition (five
distinct answers, every cut-out above the option, derived floors == the tuple) is
its own check (habit 1). The counterfactual raises one pack — the last, which
was neither extreme — past the old maximum, and the answer follows (habit 7).
peak_soc_floor is then lifted above every cut-out to grade the conjunction's
other side.

Part 12g — REWRITTEN as behaviour. Real Controller.cycle()s on a two-pack
roster, both packs held at the same SoC, everything else identical, reading
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:

    REAL two-pack cycles, both packs at 25 %, peak_soc_floor 20 % (every cut-out but inverter-a's own 10 % is SIMULATED):
      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 SAME PAIR SWAPPED, A 35 / B 18 % -> decide() got soc_floor=35 %, floor_limited=True
      peak_soc_floor moved to 42 % with both cut-outs under it -> decide() got soc_floor=42 %

The verdict flips, not just the number: the shallow site sizes a positive
forced discharge, both deep sites are FLOOR-LIMITED with
soc_floor_reason == FLOOR_AT_SOC_FLOOR. Which option key the floor comes
from stays pinned on the source in part 4, where it already lives, because that
claim genuinely is about the code's shape; that the option still governs is now
graded behaviourally too (last line above).

Re-examining the ledger: two MORE survivors, found and closed

The card asked me to ask of every check whether its data can reach the failure
it names. Nine further source mutations went through the harness on the fixed
tree. Two of them survived, and both are recorded rather than quietly repaired:

# file mutation Δ first result now
N3 main.py call site → self.packs[-1:] +5 SURVIVED (19/19 green) RED
N4 peakguard.py max(floors) → floors[0] −2 RED RED
N5 peakguard.py max(floors) → floors[-1] −1 RED RED
N7 peakguard.py empty roster → return None −43 SURVIVED (19/19 green) RED
N8 main.py soc= → self.inverters[0].soc +2 RED RED
N9 peakguard.py any(f is None …) → all(…) 0 RED RED
N10 peakguard.py pack_floor_pct() conjoins with min 0 RED RED
N11 peakguard.py pack_permissions() grades all against packs[0] +7 RED RED
N12 main.py Controller.packs in REVERSE order +10 RED RED

N3 is the same defect one step along, and it was inside the fix for the
defect.
My first cut of part 12g varied only pack B's cut-out — so
self.packs[-1:] agreed with the full roster on both runs and stayed green
across all 19 files. A two-run fixture that only ever moves the second pack pins
[:1] and not [-1:]. Part 12g now carries the same pair of cut-outs in both
orders
(the mirror 12h already ran on the SoC axis): the deeper cut-out is on
the second pack in one run and on the first in another, the roster answers 35 %
in both, [:1] answers 20 % in one and [-1:] answers 20 % in the other. No
single-position slice passes all three runs.

N7 is a defensive branch nothing in the suite reached. Controller.inverters
is never empty on a started controller, and inputs_ok refuses the cycle before
the guard is reached if it were. It is pinned rather than argued, in both
directions, because a branch with no check is a branch that can be rewritten
under a green suite — and the check 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).

M10 from the original battery stands as the one argued survivor — the unit
label appears twice in rule 8's message, so removing one leaves the refusal still
naming the unit, which is the property the checks assert; M11 carries it to where
the property is actually gone and is red on three checks. That argument is
unchanged and I have not re-litigated it.

MANDATORY PROOF — every run in the goodwe-addon-ci image, tree rsynced to a plain path and bind-mounted :ro, bytecode entry count printed before and after the purge

Head 8501e05. 19/19 green unmutated; each of the four mutations takes
test_peakguard.py RED and the failure names the right thing.

# file mutation Δ bytes result failing checks
1 app/peakguard.py max(floors) → min(floors) 0 RED 6
2 app/main.py self.packs → self.packs[:1] +4 RED 4
3 app/main.py self.packs → self.packs[-1:] +5 RED 3
4 app/peakguard.py binding_soc_floor([]) → return None −43 RED 2
— unmutated — — files=19 red=0 —
################ SURVIVOR 1  app/peakguard.py binding_soc_floor():  max(floors) -> min(floors)
--- fix2/goodwe_controller/app/peakguard.py	2026-09-04 12:43:55.535578403 +0200
+++ g1/goodwe_controller/app/peakguard.py	2026-09-04 17:09:02.772894460 +0200
@@ -696,7 +696,7 @@
     floors = [pack_floor_pct(p, soc_floor) for p in packs]
     if any(f is None for f in floors):
         return None
-    return max(floors)
+    return min(floors)
 def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
Δ bytes = 0
---- ZERO-BYTE DELTA: PROOF THE MUTATION ACTUALLY RAN, not assumed
  LOAD_GLOBALs in binding_soc_floor: ['float', 'pack_floor_pct', 'any', 'min']
  binding_soc_floor(part-12c2 roster, 20.0) = 25.0   <- shipped answers 40.0
  binding_soc_floor([Pack(a,50,10),Pack(b,50,35)], 20.0) = 20.0   <- shipped answers 35.0
== bytecode entries under /home/homelab/mut32/g1 BEFORE purge: 0
== bytecode entries under /home/homelab/mut32/g1 AFTER  purge: 0
   FAIL  test_peakguard.py
    NOT CAUGHT  `binding_soc_floor()` reduced with `min()`, `first` or `last` instead of `max()` - PR #48 SURVIVOR 1, zero byte delta
    NOT CAUGHT  main.py grading the site against ONE roster position - `self.packs[:1]` (PR #48 SURVIVOR 2) or `self.packs[-1:]`
6 FAILED, the first of them: ⚠️ `binding_soc_floor()` answers the MAXIMUM of the packs' own floors - the DEEPEST cut-out on the roster - and on this roster that is not the minimum, not the first pack's ...
== files=19 red=1

################ SURVIVOR 2  app/main.py decide() call site:  self.packs -> self.packs[:1]
--- fix2/goodwe_controller/app/main.py	2026-09-04 12:46:07.177673021 +0200
+++ g2/goodwe_controller/app/main.py	2026-09-04 17:09:02.778636526 +0200
@@ -1766,7 +1766,7 @@
             # asserted over the whole schema range in test_peakguard.py part 12
             # rather than assumed, because "another rule refuses that" is a claim
             # about rule ordering and sim/inject.py does not run rule 8 at all.
-            soc_floor=peakguard.binding_soc_floor(self.packs, float(self.o.get(
+            soc_floor=peakguard.binding_soc_floor(self.packs[:1], float(self.o.get(
             max_hold_s=self.p1.averager.max_hold_s,
Δ bytes = 4
== bytecode entries under /home/homelab/mut32/g2 BEFORE purge: 0
== bytecode entries under /home/homelab/mut32/g2 AFTER  purge: 0
   FAIL  test_peakguard.py
    NOT CAUGHT  main.py grading the site against ONE roster position - `self.packs[:1]` (PR #48 SURVIVOR 2) or `self.packs[-1:]`
4 FAILED, the first of them: ⚠️ THE CALL-SITE PIN, AS BEHAVIOUR. Change the cut-out of a NON-FIRST pack and the floor that reaches `decide()` MOVES with it: 20 % (the option, because B's cut-out is under it) becomes 35 % (B's own cut-out ...
== files=19 red=1

################ SURVIVOR 3 (found by THIS round)  self.packs -> self.packs[-1:]
--- fix2/goodwe_controller/app/main.py	2026-09-04 12:46:07.177673021 +0200
+++ g3/goodwe_controller/app/main.py	2026-09-04 17:09:02.783636489 +0200
@@ -1766,7 +1766,7 @@
             # asserted over the whole schema range in test_peakguard.py part 12
             # rather than assumed, because "another rule refuses that" is a claim
             # about rule ordering and sim/inject.py does not run rule 8 at all.
-            soc_floor=peakguard.binding_soc_floor(self.packs, float(self.o.get(
+            soc_floor=peakguard.binding_soc_floor(self.packs[-1:], float(self.o.get(
             max_hold_s=self.p1.averager.max_hold_s,
Δ bytes = 5
== bytecode entries under /home/homelab/mut32/g3 BEFORE purge: 0
== bytecode entries under /home/homelab/mut32/g3 AFTER  purge: 0
   FAIL  test_peakguard.py
    NOT CAUGHT  main.py grading the site against ONE roster position - `self.packs[:1]` (PR #48 SURVIVOR 2) or `self.packs[-1:]`
3 FAILED, the first of them: ⚠️ ...AND THE SAME PAIR SWAPPED GIVES THE SAME SITE FLOOR, so what is reduced is the ROSTER and not a POSITION in it. `self.packs[-1:]` - the LAST pack alone - agrees with the roster on both runs above and answers 20 % here ...
== files=19 red=1

################ SURVIVOR 4 (found by THIS round)  binding_soc_floor([]) -> None
--- fix2/goodwe_controller/app/peakguard.py	2026-09-04 12:43:55.535578403 +0200
+++ g4/goodwe_controller/app/peakguard.py	2026-09-04 17:23:11.325739129 +0200
@@ -692,7 +692,7 @@
     than assuming it.
     """
     if not packs:
-        return None if soc_floor is None else float(soc_floor)
+        return None
     floors = [pack_floor_pct(p, soc_floor) for p in packs]
     if any(f is None for f in floors):
         return None
Δ bytes = -43
== bytecode entries under /home/homelab/mut32/g4 BEFORE purge: 0
== bytecode entries under /home/homelab/mut32/g4 AFTER  purge: 0
   FAIL  test_peakguard.py
    NOT CAUGHT  `binding_soc_floor([])` answering None instead of the option
2 FAILED, the first of them: an EMPTY roster answers the OPTION - there is no pack to conjoin with ...
== files=19 red=1

################ THE UNMUTATED TREE
== bytecode entries under /home/homelab/mut32/fix2 BEFORE purge: 0
== bytecode entries under /home/homelab/mut32/fix2 AFTER  purge: 0
== files=19 red=0
== bytecode entries under /home/homelab/mut32/fix2 AFTER  run  : 0

Independently: the pushed commit was cloned fresh from Gitea and run —
files=19 red=0. test_peakguard.py was also run eight times in isolation
(five on the working tree, three on the clone) and passed every time. One
full-suite run of the clone did hit card CI-06's known flake, in a part-8
wired_over_limit check that this round does not touch
("...and the number written to the inverter is not a forced discharge"); it was
re-run in isolation per the card and passed, and the following full-suite run was
green. Gitea CI on 8501e05: checks / unit (pull_request) success.

One thing that is worth saying plainly

Three of the nine mutations above have a zero byte delta (any→all,
pack_floor_pct's max→min, and SURVIVOR 1 itself) and every run went through
tools/mutation_run.sh's purge with the bytecode entry count printed before and
after
. For the zero-delta survivor the mutation was proved to have run rather
than assumed, by disassembly and by a differing return value — see the transcript
above. RED is in any case the direction stale bytecode cannot fabricate.

⚠️ Inverter B

Still no measurement of inverter B anywhere. It does not physically exist
(~8 days out) and its 3000 W rating remains a marked hypothesis. Every figure
added in this round — the four-pack roster's 30 / 25 / 40 / 35 %, the swapped
35 % on inverter-a, B_PACK_FLOOR_SIM and B_PACK_FLOOR_DEEP_SIM — is
SIMULATED and labelled as simulated at its definition and in the printed
output
. The only measured cut-out in the file is inverter A's 10 % (register
0x0560). No hardware was touched: no rig, no ESP32, no inverter, no witness Pi,
no /dev/ttyUSB0, no riglock.py, no live Home Assistant, nothing flashed.

Do not merge — for review.

## SAFETY-32 FIX ROUND — AC 6 now met for the reduction `main.py` actually hands `decide()` `app/` is **untouched**. No shipped behaviour changed. What changed is what observes it: two checks whose **data could not reach the failure they named**, plus two more the same battery found on the way. ### Why both survivors survived, and it is Decision 2's own degeneracy Under any rule-8-valid config every pack's `pack_floor_pct()` **equals** `peak_soc_floor` — rule 8 refuses `peak_soc_floor <= bms_soc_floor` on every unit, so `max()` selects the EMS floor everywhere. `floors` is therefore a list of **identical numbers**, and `max`, `min`, `floors[0]` and `floors[-1]` are four spellings of one answer. The only check in the suite that used a cut-out **above** the EMS floor — 12c's `_n1_deep` — ran on a roster of **ONE**, where every reduction agrees. So no check ever evaluated `binding_soc_floor()` on two packs whose floors differ. And 12g's call-site pin asserted `"self.packs" in <the unparsed expression>`. `self.packs[:1]` **contains that substring**. MUTATION.md habit 4: a source-text test is a check on how code is spelled, not on what it does — CFG-04's `if entity:` in a different costume. **The degeneracy is not a defence, by this PR's own argument.** 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"* justifies **writing** the reduction, it equally obliges **grading** 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 **`test_peakguard.py` part 12c2 — NEW.** `binding_soc_floor()` on a roster whose per-pack floors **all differ**. Four **SIMULATED** cut-outs, each strictly above `peak_soc_floor: 20`, so `pack_floor_pct()` selects the cut-out for each and `floors` *is* that tuple in that order: ``` a SIMULATED roster of 4 with cut-outs ['30', '25', '40', '35'] % over peak_soc_floor 20 % -> per-pack floors ['30', '25', '40', '35'] % candidate reductions: max=40, min=25, first=30, last=35, mean=32.5; binding_soc_floor() -> 40 ...raise ONLY the last pack 35 % -> 45 %: 40 -> 45 ``` **Why these four numbers discriminate**, stated in the file: the extremes sit at **interior** roster positions (index 2 is the max, index 1 the min). A two-pack roster structurally cannot separate the four — there `max` and `last` coincide and so do `min` and `first` — and neither can three. **Four is the smallest roster that separates `max`, `min`, first and last.** The precondition (five distinct answers, every cut-out above the option, derived floors == the tuple) is its own check (habit 1). The counterfactual raises **one** pack — the last, which was neither extreme — past the old maximum, and the answer follows (habit 7). `peak_soc_floor` is then lifted above every cut-out to grade the conjunction's other side. **Part 12g — REWRITTEN as behaviour.** Real `Controller.cycle()`s on a two-pack roster, both packs held at the same SoC, everything else identical, reading `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: ``` REAL two-pack cycles, both packs at 25 %, peak_soc_floor 20 % (every cut-out but inverter-a's own 10 % is SIMULATED): 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 SAME PAIR SWAPPED, A 35 / B 18 % -> decide() got soc_floor=35 %, floor_limited=True peak_soc_floor moved to 42 % with both cut-outs under it -> decide() got soc_floor=42 % ``` The **verdict flips**, not just the number: the shallow site sizes a positive forced discharge, both deep sites are FLOOR-LIMITED with `soc_floor_reason == FLOOR_AT_SOC_FLOOR`. Which **option key** the floor comes from stays pinned on the source in part 4, where it already lives, because that claim genuinely is about the code's shape; that the option still *governs* is now graded behaviourally too (last line above). ### Re-examining the ledger: two MORE survivors, found and closed The card asked me to ask of every check whether its **data** can reach the failure it names. Nine further source mutations went through the harness on the fixed tree. **Two of them survived, and both are recorded rather than quietly repaired:** | # | file | mutation | Δ | first result | now | |---|---|---|---|---|---| | N3 | `main.py` | call site → `self.packs[-1:]` | +5 | **SURVIVED** (19/19 green) | **RED** | | N4 | `peakguard.py` | `max(floors)` → `floors[0]` | −2 | RED | RED | | N5 | `peakguard.py` | `max(floors)` → `floors[-1]` | −1 | RED | RED | | N7 | `peakguard.py` | empty roster → `return None` | −43 | **SURVIVED** (19/19 green) | **RED** | | N8 | `main.py` | `soc=` → `self.inverters[0].soc` | +2 | RED | RED | | N9 | `peakguard.py` | `any(f is None …)` → `all(…)` | **0** | RED | RED | | N10 | `peakguard.py` | `pack_floor_pct()` conjoins with `min` | **0** | RED | RED | | N11 | `peakguard.py` | `pack_permissions()` grades all against `packs[0]` | +7 | RED | RED | | N12 | `main.py` | `Controller.packs` in REVERSE order | +10 | RED | RED | **N3 is the same defect one step along, and it was inside the fix for the defect.** My first cut of part 12g varied only **pack B's** cut-out — so `self.packs[-1:]` agreed with the full roster on both runs and stayed green across all 19 files. A two-run fixture that only ever moves the second pack pins `[:1]` and not `[-1:]`. Part 12g now carries the **same pair of cut-outs in both orders** (the mirror 12h already ran on the SoC axis): the deeper cut-out is on the second pack in one run and on the first in another, the roster answers 35 % in both, `[:1]` answers 20 % in one and `[-1:]` answers 20 % in the other. No single-position slice passes all three runs. **N7 is a defensive branch nothing in the suite reached.** `Controller.inverters` is never empty on a started controller, and `inputs_ok` refuses the cycle before the guard is reached if it were. It is **pinned rather than argued**, in both directions, because a branch with no check is a branch that can be rewritten under a green suite — and the check 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). **M10 from the original battery stands as the one argued survivor** — the unit label appears twice in rule 8's message, so removing one leaves the refusal still naming the unit, which is the property the checks assert; M11 carries it to where the property is actually gone and is red on three checks. That argument is unchanged and I have not re-litigated it. ### MANDATORY PROOF — every run in the `goodwe-addon-ci` image, tree rsynced to a plain path and bind-mounted `:ro`, bytecode entry count printed **before and after** the purge Head **8501e05**. `19/19 green` unmutated; each of the four mutations takes `test_peakguard.py` RED and the failure **names the right thing**. | # | file | mutation | Δ bytes | result | failing checks | |---|---|---|---|---|---| | 1 | `app/peakguard.py` | `max(floors)` → `min(floors)` | **0** | **RED** | 6 | | 2 | `app/main.py` | `self.packs` → `self.packs[:1]` | +4 | **RED** | 4 | | 3 | `app/main.py` | `self.packs` → `self.packs[-1:]` | +5 | **RED** | 3 | | 4 | `app/peakguard.py` | `binding_soc_floor([])` → `return None` | −43 | **RED** | 2 | | — | unmutated | — | — | **`files=19 red=0`** | — | ``` ################ SURVIVOR 1 app/peakguard.py binding_soc_floor(): max(floors) -> min(floors) --- fix2/goodwe_controller/app/peakguard.py 2026-09-04 12:43:55.535578403 +0200 +++ g1/goodwe_controller/app/peakguard.py 2026-09-04 17:09:02.772894460 +0200 @@ -696,7 +696,7 @@ floors = [pack_floor_pct(p, soc_floor) for p in packs] if any(f is None for f in floors): return None - return max(floors) + return min(floors) def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor, Δ bytes = 0 ---- ZERO-BYTE DELTA: PROOF THE MUTATION ACTUALLY RAN, not assumed LOAD_GLOBALs in binding_soc_floor: ['float', 'pack_floor_pct', 'any', 'min'] binding_soc_floor(part-12c2 roster, 20.0) = 25.0 <- shipped answers 40.0 binding_soc_floor([Pack(a,50,10),Pack(b,50,35)], 20.0) = 20.0 <- shipped answers 35.0 == bytecode entries under /home/homelab/mut32/g1 BEFORE purge: 0 == bytecode entries under /home/homelab/mut32/g1 AFTER purge: 0 FAIL test_peakguard.py NOT CAUGHT `binding_soc_floor()` reduced with `min()`, `first` or `last` instead of `max()` - PR #48 SURVIVOR 1, zero byte delta NOT CAUGHT main.py grading the site against ONE roster position - `self.packs[:1]` (PR #48 SURVIVOR 2) or `self.packs[-1:]` 6 FAILED, the first of them: ⚠️ `binding_soc_floor()` answers the MAXIMUM of the packs' own floors - the DEEPEST cut-out on the roster - and on this roster that is not the minimum, not the first pack's ... == files=19 red=1 ################ SURVIVOR 2 app/main.py decide() call site: self.packs -> self.packs[:1] --- fix2/goodwe_controller/app/main.py 2026-09-04 12:46:07.177673021 +0200 +++ g2/goodwe_controller/app/main.py 2026-09-04 17:09:02.778636526 +0200 @@ -1766,7 +1766,7 @@ # asserted over the whole schema range in test_peakguard.py part 12 # rather than assumed, because "another rule refuses that" is a claim # about rule ordering and sim/inject.py does not run rule 8 at all. - soc_floor=peakguard.binding_soc_floor(self.packs, float(self.o.get( + soc_floor=peakguard.binding_soc_floor(self.packs[:1], float(self.o.get( max_hold_s=self.p1.averager.max_hold_s, Δ bytes = 4 == bytecode entries under /home/homelab/mut32/g2 BEFORE purge: 0 == bytecode entries under /home/homelab/mut32/g2 AFTER purge: 0 FAIL test_peakguard.py NOT CAUGHT main.py grading the site against ONE roster position - `self.packs[:1]` (PR #48 SURVIVOR 2) or `self.packs[-1:]` 4 FAILED, the first of them: ⚠️ THE CALL-SITE PIN, AS BEHAVIOUR. Change the cut-out of a NON-FIRST pack and the floor that reaches `decide()` MOVES with it: 20 % (the option, because B's cut-out is under it) becomes 35 % (B's own cut-out ... == files=19 red=1 ################ SURVIVOR 3 (found by THIS round) self.packs -> self.packs[-1:] --- fix2/goodwe_controller/app/main.py 2026-09-04 12:46:07.177673021 +0200 +++ g3/goodwe_controller/app/main.py 2026-09-04 17:09:02.783636489 +0200 @@ -1766,7 +1766,7 @@ # asserted over the whole schema range in test_peakguard.py part 12 # rather than assumed, because "another rule refuses that" is a claim # about rule ordering and sim/inject.py does not run rule 8 at all. - soc_floor=peakguard.binding_soc_floor(self.packs, float(self.o.get( + soc_floor=peakguard.binding_soc_floor(self.packs[-1:], float(self.o.get( max_hold_s=self.p1.averager.max_hold_s, Δ bytes = 5 == bytecode entries under /home/homelab/mut32/g3 BEFORE purge: 0 == bytecode entries under /home/homelab/mut32/g3 AFTER purge: 0 FAIL test_peakguard.py NOT CAUGHT main.py grading the site against ONE roster position - `self.packs[:1]` (PR #48 SURVIVOR 2) or `self.packs[-1:]` 3 FAILED, the first of them: ⚠️ ...AND THE SAME PAIR SWAPPED GIVES THE SAME SITE FLOOR, so what is reduced is the ROSTER and not a POSITION in it. `self.packs[-1:]` - the LAST pack alone - agrees with the roster on both runs above and answers 20 % here ... == files=19 red=1 ################ SURVIVOR 4 (found by THIS round) binding_soc_floor([]) -> None --- fix2/goodwe_controller/app/peakguard.py 2026-09-04 12:43:55.535578403 +0200 +++ g4/goodwe_controller/app/peakguard.py 2026-09-04 17:23:11.325739129 +0200 @@ -692,7 +692,7 @@ than assuming it. """ if not packs: - return None if soc_floor is None else float(soc_floor) + return None floors = [pack_floor_pct(p, soc_floor) for p in packs] if any(f is None for f in floors): return None Δ bytes = -43 == bytecode entries under /home/homelab/mut32/g4 BEFORE purge: 0 == bytecode entries under /home/homelab/mut32/g4 AFTER purge: 0 FAIL test_peakguard.py NOT CAUGHT `binding_soc_floor([])` answering None instead of the option 2 FAILED, the first of them: an EMPTY roster answers the OPTION - there is no pack to conjoin with ... == files=19 red=1 ################ THE UNMUTATED TREE == bytecode entries under /home/homelab/mut32/fix2 BEFORE purge: 0 == bytecode entries under /home/homelab/mut32/fix2 AFTER purge: 0 == files=19 red=0 == bytecode entries under /home/homelab/mut32/fix2 AFTER run : 0 ``` Independently: the **pushed** commit was cloned fresh from Gitea and run — `files=19 red=0`. `test_peakguard.py` was also run **eight times in isolation** (five on the working tree, three on the clone) and passed every time. One full-suite run of the clone did hit **card CI-06**'s known flake, in a part-8 `wired_over_limit` check that this round does not touch (*"...and the number written to the inverter is not a forced discharge"*); it was re-run in isolation per the card and passed, and the following full-suite run was green. Gitea CI on 8501e05: **`checks / unit (pull_request)` success**. ### One thing that is worth saying plainly Three of the nine mutations above have a **zero byte delta** (`any`→`all`, `pack_floor_pct`'s `max`→`min`, and SURVIVOR 1 itself) and every run went through `tools/mutation_run.sh`'s purge with the bytecode entry count printed **before and after**. For the zero-delta survivor the mutation was proved to have *run* rather than assumed, by disassembly and by a differing return value — see the transcript above. RED is in any case the direction stale bytecode cannot fabricate. ### ⚠️ Inverter B **Still no measurement of inverter B anywhere.** It does not physically exist (~8 days out) and its 3000 W rating remains a marked hypothesis. Every figure added in this round — the four-pack roster's `30 / 25 / 40 / 35 %`, the swapped `35 %` on inverter-a, `B_PACK_FLOOR_SIM` and `B_PACK_FLOOR_DEEP_SIM` — is **SIMULATED and labelled as simulated at its definition and in the printed output**. The only measured cut-out in the file is inverter A's 10 % (register `0x0560`). No hardware was touched: no rig, no ESP32, no inverter, no witness Pi, no `/dev/ttyUSB0`, no `riglock.py`, no live Home Assistant, nothing flashed. **Do not merge — for review.**
Author
Owner

Round 3 — both blockers closed, and two more found by the battery that closed them

Head 52380c0. app/peakguard.py and app/main.py are byte-identical to
8501e05
— no shipped behaviour changed. test_peakguard.py, CHANGELOG.md
and DECISIONS/SAFETY-32-per-pack-bms-floor.md only.

Both blockers were correct, and both are the same thing: a check whose TEXT
claims more than its FIXTURE can observe
— the recurring defect, sitting inside
the fix written to close it, for the second round running.

blocker Δ the sentence that was too broad why the fixture could not see it
max(floors) → max(floors[-2:]) +5 "So the reduction reads every entry" 12c2's only counterfactual raised the last pack, and every window ending at the roster's end follows the last pack. Both answer 40 on the baseline and 45 on _deeper.
self.packs → self.packs[:2] +4 "what is reduced is the ROSTER and not a POSITION in it" all three _floor_reaching_decide runs drove a two-pack roster, where [:2] is self.packs — by exactly the argument 12c2 itself makes about two packs.

The prose in both places is now cut back to what the old data actually reaches
("the reduction reads the LAST entry", "no SINGLE-POSITION slice can
produce these three answers"
), each with an explicit ⚠️ note naming what it does
not show, and new data added for the rest.


MANDATORY PROOF

1. The clean tree, all 19 files

=== BYTECODE PURGE (clean_fixed) ===
  __pycache__ .pyc entries BEFORE purge: 23
  __pycache__ .pyc entries AFTER  purge: 0
=== test_alloc.py  exit=0  ok
=== test_arbiter.py  exit=0  ok
=== test_arbitrage.py  exit=0  ok
=== test_bytecode.py  exit=0  ok
=== test_ci_gate.py  exit=0  ok
=== test_clamp.py  exit=0  ok
=== test_control.py  exit=0  ok
=== test_freeze.py  exit=0  ok
=== test_inverter.py  exit=0  ok
=== test_loop.py  exit=0  ok
=== test_maintenance.py  exit=0  ok
=== test_p1.py  exit=0  ok
=== test_peakguard.py  exit=0  ok
=== test_price.py  exit=0  ok
=== test_price_forecast.py  exit=0  ok
=== test_static_tariff.py  exit=0  ok
=== test_tariff.py  exit=0  ok
=== test_validate.py  exit=0  ok
=== test_watchdog.py  exit=0  ok
FILES_RUN=19
SUITE_FAIL=0

test_peakguard.py re-run three more times in isolation (CI-06): all checks passed, EXIT=0, three for three. Trees are rsynced to a plain path and mounted
:ro in goodwe-addon-ci:latest; bytecode is purged and counted before and
after every run
, 23 → 0.

2. BLOCKER 1 — app/peakguard.py:699 return max(floors) → return max(floors[-2:]) (+5 bytes)

Full 19-file suite: SUITE_FAIL=1, test_peakguard.py exit=1, every other file
still exit=0. Six checks red:

  FAIL ...and `binding_soc_floor()` answers `max()` on EVERY roster in the set, which is what turns each separation ab
  FAIL ⚠️ THE LENGTH SWEEP. Over rosters of 1 to 12 packs with the deepest cut-out at every position of each, `bin
  FAIL ⚠️ THE POSITIONAL PIN, AND IT NEEDS THREE PACKS TO EXIST. On a roster of THREE the floor that reaches `deci
  FAIL ...and the VERDICT flips with it, on all three: 25 % is at or below the 35 % cut-out wherever it sits, so every
  FAIL ⚠️ EVERY positional slice in the enumerated family answers something OTHER than the floor that really reach
  FAIL ...⚠️ AND SO ARE THE SEVEN DEFEATS THAT SURVIVED A GREEN 19-FILE SUITE, which is why this card is on its th

3. BLOCKER 2 — app/main.py:1769 binding_soc_floor(self.packs, → self.packs[:2], (+4 bytes)

Full 19-file suite: SUITE_FAIL=1, test_peakguard.py exit=1, every other file
still exit=0. Six checks red:

  FAIL ⚠️ THE POSITIONAL PIN, AND IT NEEDS THREE PACKS TO EXIST. On a roster of THREE the floor that reaches `deci
  FAIL ...and the VERDICT flips with it, on all three: 25 % is at or below the 35 % cut-out wherever it sits, so every
  FAIL ⚠️ EVERY positional slice in the enumerated family answers something OTHER than the floor that really reach
  FAIL ...and the baseline the table is measured against is the shipped call site's OWN answer, not a re-derivation: o
  FAIL ⚠️ THE STRUCTURAL HALF, AND IT IS EXACT RATHER THAN A SUBSTRING. The roster handed to `binding_soc_floor()`
  FAIL ...⚠️ AND SO ARE THE SEVEN DEFEATS THAT SURVIVED A GREEN 19-FILE SUITE, which is why this card is on its th

THE ENUMERATED FAMILY, and which fixture kills each member

Both tables are computed and printed by the checks themselves, not written
from memory. A member with an empty kill list is a live survivor and takes the
check red naming it.

12c2 — the reduction inside binding_soc_floor()

The set is the same four simulated cut-outs with the deepest moved to each
position in turn
— the data the single last-pack counterfactual could not
supply:

    THE ROSTER SET (all SIMULATED), and binding_soc_floor() on each:
      baseline                floors ['30', '25', '40', '35'] -> 40 (max() 40)
      deepest at index 0       floors ['50', '25', '40', '35'] -> 50 (max() 50)
      deepest at index 1       floors ['30', '50', '40', '35'] -> 50 (max() 50)
      deepest at index 2       floors ['30', '25', '50', '35'] -> 50 (max() 50)
      deepest at index 3       floors ['30', '25', '40', '50'] -> 50 (max() 50)
    THE REDUCTION FAMILY, and which roster separates each from max():
      min(floors)                        killed by: baseline; deepest at index 0; deepest at index 1; deepest at index 2; deepest at index 3
      floors[0] (== max(floors[:1]))     killed by: baseline; deepest at index 1; deepest at index 2; deepest at index 3
      floors[-1] (== max(floors[-1:]))   killed by: baseline; deepest at index 0; deepest at index 1; deepest at index 2
      mean(floors)                       killed by: baseline; deepest at index 0; deepest at index 1; deepest at index 2; deepest at index 3
      median(floors), upper              killed by: baseline; deepest at index 0; deepest at index 1; deepest at index 2; deepest at index 3
      max(floors[:2])                    killed by: baseline; deepest at index 2; deepest at index 3
      max(floors[-2:])                   killed by: deepest at index 0; deepest at index 1
      max(floors[:3])                    killed by: deepest at index 3
      max(floors[-3:])                   killed by: deepest at index 0
      max(floors[1:])                    killed by: deepest at index 0
      max(floors[:-1])                   killed by: deepest at index 3
      max(floors[1:-1])                  killed by: deepest at index 0; deepest at index 3
      max(floors[::2])                   killed by: deepest at index 1; deepest at index 3
      min(floors[-2:])                   killed by: baseline; deepest at index 0; deepest at index 1; deepest at index 2; deepest at index 3

binding_soc_floor() is a pure function over a list, which is the one place
a sweep is cheap — so the whole positional family below width 12 is closed as one
property rather than one mutation at a time:

    LENGTH SWEEP (all SIMULATED): 78 rosters, lengths 1..12, the 45 % cut-out at EVERY position of each among 25 % siblings -> 78 answer the deepest, 0 do not
    fixed-width windows killed by the sweep: head widths 1,2,3,4,5,6,7,8,9,10,11; tail widths 1,2,3,4,5,6,7,8,9,10,11; NOT reached: head/tail width 12 and wider

12g — the roster handed to the call site

_floor_reaching_decide() now takes *floors; extra units join through the same
second_inverter() hook. Three real Controller.cycle()s on a three-pack
roster, the deep cut-out at each position:

    REAL THREE-pack cycles, all packs at 25 %, peak_soc_floor 20 %, the 35 % cut-out moved along the roster (units b and c are SIMULATED):
      3-pack, deep at index 0: cut-outs ['35', '10', '10'] % -> decide() got soc_floor=35 %, floor_limited=True
      3-pack, deep at index 1: cut-outs ['10', '35', '10'] % -> decide() got soc_floor=35 %, floor_limited=True
      3-pack, deep at index 2: cut-outs ['10', '10', '35'] % -> decide() got soc_floor=35 %, floor_limited=True

The table below is computed against the real Controller.packs each of the
six cycles ran and the real PeakDecision.soc_floor it produced — the
shipped call site's own answer, not a re-derivation:

    THE POSITIONAL-SLICE FAMILY at this call site, and which run separates each from the whole roster:
      self.packs[:1]     killed by: 2-pack, deep at index 1; 3-pack, deep at index 1; 3-pack, deep at index 2
      self.packs[-1:]    killed by: 2-pack, deep at index 0; 3-pack, deep at index 0; 3-pack, deep at index 1
      self.packs[:2]     killed by: 3-pack, deep at index 2
      self.packs[-2:]    killed by: 3-pack, deep at index 0
      self.packs[1:]     killed by: 2-pack, deep at index 0; 3-pack, deep at index 0
      self.packs[:-1]    killed by: 2-pack, deep at index 1; 3-pack, deep at index 2
      self.packs[1:-1]   killed by: 2-pack, deep at index 1; 2-pack, deep at index 0; 3-pack, deep at index 0; 3-pack, deep at index 2
      self.packs[::2]    killed by: 2-pack, deep at index 1; 3-pack, deep at index 1
    the ROSTER argument main.py reduces is `self.packs`, an AST Attribute

Note self.packs[:2] is killed by one run and [-2:] by one, and both
are three-pack runs. That is the blocker, and it is why two packs could not see it.


⚠️ TWO MORE SURVIVORS, found by this round's own battery

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.

THE GENERAL LAW, and the reason one more pack was the wrong answer:

A fixture of N packs cannot separate two reductions that agree on N packs.
[:1] needed two packs. [:2] needed three. [:3] needs four — and a
four-pack run hands [:4] the same free pass. The regress does not terminate
in a fixture.

So 12g carries two halves, each saying what it reaches:

  • the RUNS grade the VALUE — that the floor reaching decide() really is
    the roster's deepest cut-out wherever it sits, and that the site's verdict
    follows. They reach every positional slice up to N=3 and, by construction,
    nothing beyond.
  • the PIN grades the ARGUMENT'S IDENTITY — exact, on the AST, at any N.
    ⚠️ 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. self.packs[:3] unparses to self.packs[:3].

And the pin is stated with its blind spots, because both are real: it cannot
see a rewrite of what Controller.packs returns (the runs' len(ctl.packs) == 3
catches that — mutated and confirmed red, below), and it cannot see the reduction
inside binding_soc_floor() at all (12c2's job).


THE BATTERY — 46 mutations, 43 red, 3 survivors, all three recorded

Bytecode purged and counted before and after each run (23 → 0 every time).
Every kill is test_peakguard.py exit=1; the byte delta is printed per mutant.

mutation Δ verdict
max(floors) → min / floors[0] / floors[-1] / mean / median 0…+21 red
max(floors) → max(floors[:1] [-1:] [:2] [-2:] [:3] [-3:] [1:] [:-1] [1:-1] [::2] [2:] [:-2]) +4…+6 red
max(floors) → max(floors[:4] [-4:] [:8] [-8:] [:11] [-11:]) +4…+6 red (the length sweep)
max(floors) → min(floors[-2:]) +5 red
self.packs → [:1] [-1:] [:2] [-2:] [1:] [:-1] [1:-1] [::2] [2:] [:-2] +4…+6 red
self.packs → [:3] [-3:] [:4] [-4:] [:99] +4…+5 red — structural pin only
self.packs → list(reversed(self.packs)) +16 red — structural pin (and equivalent: max is order-invariant)
Controller.packs: self.inverters → [:2] / [1:] +4 red
pack_floor_pct(): max(soc_floor, bms_floor) → min 0 red
max(floors) → max(floors[:12]) +5 ⚠️ SURVIVED
max(floors) → max(floors[-12:]) +6 ⚠️ SURVIVED
max(floors) → max(set(floors)) +5 SURVIVED — equivalent, max is duplicate-invariant

The one that is not closed, stated plainly. 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 here — the claim is semantic ("the maximum"), and a
shape pin would refuse honest refactors of the expression. It is row 27 in the
in-process ledger, printed NOT CAUGHT
, with its size: it needs a 13-pack
roster
to become observable, on a site whose roster is one and whose second
unit is ~8 days out. Thirteen packs would close it and open thirteen.

That row exists to be argued with. If the reviewer wants width 12 closed at the
cost of pinning the expression's shape, say so and it goes in — but I am not
going to write a check that claims it while the sweep stops where it stops.


What is in the file now

  • 12c2 — the roster set, the computed family table, the length sweep, and the
    two over-broad sentences narrowed with an explicit note on what they miss.
  • 12g — *floors, the three-pack runs, the positional-slice table, the exact
    structural pin plus its own non-vacuity counterfactual, and the two over-broad
    sentences narrowed the same way.
  • In-process mutation rows 24–27 — the two blockers, the two battery-found
    [:3]/[-3:] survivors closed structurally, and the not-caught width-12 row.
    The ledger's summary line now reads SEVEN defeats that survived a green
    19-file suite, and names each with its byte delta and the commit it was green on.
  • DECISIONS §3b and a CHANGELOG bullet record all of the above,
    including the general law and the equivalent mutants.

CI green on 52380c0 — checks / unit (pull_request). Not merging; over to you.

## Round 3 — both blockers closed, and two more found by the battery that closed them Head **`52380c0`**. `app/peakguard.py` and `app/main.py` are **byte-identical to `8501e05`** — no shipped behaviour changed. `test_peakguard.py`, `CHANGELOG.md` and `DECISIONS/SAFETY-32-per-pack-bms-floor.md` only. Both blockers were correct, and both are the same thing: **a check whose TEXT claims more than its FIXTURE can observe** — the recurring defect, sitting inside the fix written to close it, for the second round running. | blocker | Δ | the sentence that was too broad | why the fixture could not see it | |---|---|---|---| | `max(floors)` → `max(floors[-2:])` | **+5** | *"So the reduction reads **every entry**"* | 12c2's only counterfactual raised the **last** pack, and every window ending at the roster's end follows the last pack. Both answer 40 on the baseline and 45 on `_deeper`. | | `self.packs` → `self.packs[:2]` | **+4** | *"what is reduced is the **ROSTER** and not a POSITION in it"* | all three `_floor_reaching_decide` runs drove a **two-pack** roster, where `[:2]` **is** `self.packs` — by exactly the argument 12c2 itself makes about two packs. | The prose in both places is now cut back to what the old data actually reaches (*"the reduction reads the **LAST** entry"*, *"no **SINGLE-POSITION** slice can produce these three answers"*), each with an explicit ⚠️ note naming what it does **not** show, and new data added for the rest. --- ## MANDATORY PROOF ### 1. The clean tree, all 19 files ``` === BYTECODE PURGE (clean_fixed) === __pycache__ .pyc entries BEFORE purge: 23 __pycache__ .pyc entries AFTER purge: 0 === test_alloc.py exit=0 ok === test_arbiter.py exit=0 ok === test_arbitrage.py exit=0 ok === test_bytecode.py exit=0 ok === test_ci_gate.py exit=0 ok === test_clamp.py exit=0 ok === test_control.py exit=0 ok === test_freeze.py exit=0 ok === test_inverter.py exit=0 ok === test_loop.py exit=0 ok === test_maintenance.py exit=0 ok === test_p1.py exit=0 ok === test_peakguard.py exit=0 ok === test_price.py exit=0 ok === test_price_forecast.py exit=0 ok === test_static_tariff.py exit=0 ok === test_tariff.py exit=0 ok === test_validate.py exit=0 ok === test_watchdog.py exit=0 ok FILES_RUN=19 SUITE_FAIL=0 ``` `test_peakguard.py` re-run **three more times in isolation** (CI-06): `all checks passed`, `EXIT=0`, three for three. Trees are rsynced to a plain path and mounted `:ro` in `goodwe-addon-ci:latest`; bytecode is purged and **counted before and after every run**, 23 → 0. ### 2. BLOCKER 1 — `app/peakguard.py:699` `return max(floors)` → `return max(floors[-2:])` (+5 bytes) Full 19-file suite: `SUITE_FAIL=1`, `test_peakguard.py exit=1`, every other file still `exit=0`. Six checks red: ``` FAIL ...and `binding_soc_floor()` answers `max()` on EVERY roster in the set, which is what turns each separation ab FAIL ⚠️ THE LENGTH SWEEP. Over rosters of 1 to 12 packs with the deepest cut-out at every position of each, `bin FAIL ⚠️ THE POSITIONAL PIN, AND IT NEEDS THREE PACKS TO EXIST. On a roster of THREE the floor that reaches `deci FAIL ...and the VERDICT flips with it, on all three: 25 % is at or below the 35 % cut-out wherever it sits, so every FAIL ⚠️ EVERY positional slice in the enumerated family answers something OTHER than the floor that really reach FAIL ...⚠️ AND SO ARE THE SEVEN DEFEATS THAT SURVIVED A GREEN 19-FILE SUITE, which is why this card is on its th ``` ### 3. BLOCKER 2 — `app/main.py:1769` `binding_soc_floor(self.packs,` → `self.packs[:2],` (+4 bytes) Full 19-file suite: `SUITE_FAIL=1`, `test_peakguard.py exit=1`, every other file still `exit=0`. Six checks red: ``` FAIL ⚠️ THE POSITIONAL PIN, AND IT NEEDS THREE PACKS TO EXIST. On a roster of THREE the floor that reaches `deci FAIL ...and the VERDICT flips with it, on all three: 25 % is at or below the 35 % cut-out wherever it sits, so every FAIL ⚠️ EVERY positional slice in the enumerated family answers something OTHER than the floor that really reach FAIL ...and the baseline the table is measured against is the shipped call site's OWN answer, not a re-derivation: o FAIL ⚠️ THE STRUCTURAL HALF, AND IT IS EXACT RATHER THAN A SUBSTRING. The roster handed to `binding_soc_floor()` FAIL ...⚠️ AND SO ARE THE SEVEN DEFEATS THAT SURVIVED A GREEN 19-FILE SUITE, which is why this card is on its th ``` --- ## THE ENUMERATED FAMILY, and which fixture kills each member Both tables are **computed and printed by the checks themselves**, not written from memory. A member with an empty kill list is a live survivor and takes the check **red naming it**. ### 12c2 — the reduction inside `binding_soc_floor()` The set is the same four simulated cut-outs with **the deepest moved to each position in turn** — the data the single last-pack counterfactual could not supply: ``` THE ROSTER SET (all SIMULATED), and binding_soc_floor() on each: baseline floors ['30', '25', '40', '35'] -> 40 (max() 40) deepest at index 0 floors ['50', '25', '40', '35'] -> 50 (max() 50) deepest at index 1 floors ['30', '50', '40', '35'] -> 50 (max() 50) deepest at index 2 floors ['30', '25', '50', '35'] -> 50 (max() 50) deepest at index 3 floors ['30', '25', '40', '50'] -> 50 (max() 50) THE REDUCTION FAMILY, and which roster separates each from max(): min(floors) killed by: baseline; deepest at index 0; deepest at index 1; deepest at index 2; deepest at index 3 floors[0] (== max(floors[:1])) killed by: baseline; deepest at index 1; deepest at index 2; deepest at index 3 floors[-1] (== max(floors[-1:])) killed by: baseline; deepest at index 0; deepest at index 1; deepest at index 2 mean(floors) killed by: baseline; deepest at index 0; deepest at index 1; deepest at index 2; deepest at index 3 median(floors), upper killed by: baseline; deepest at index 0; deepest at index 1; deepest at index 2; deepest at index 3 max(floors[:2]) killed by: baseline; deepest at index 2; deepest at index 3 max(floors[-2:]) killed by: deepest at index 0; deepest at index 1 max(floors[:3]) killed by: deepest at index 3 max(floors[-3:]) killed by: deepest at index 0 max(floors[1:]) killed by: deepest at index 0 max(floors[:-1]) killed by: deepest at index 3 max(floors[1:-1]) killed by: deepest at index 0; deepest at index 3 max(floors[::2]) killed by: deepest at index 1; deepest at index 3 min(floors[-2:]) killed by: baseline; deepest at index 0; deepest at index 1; deepest at index 2; deepest at index 3 ``` `binding_soc_floor()` is a **pure function over a list**, which is the one place a sweep is cheap — so the whole positional family below width 12 is closed as one property rather than one mutation at a time: ``` LENGTH SWEEP (all SIMULATED): 78 rosters, lengths 1..12, the 45 % cut-out at EVERY position of each among 25 % siblings -> 78 answer the deepest, 0 do not fixed-width windows killed by the sweep: head widths 1,2,3,4,5,6,7,8,9,10,11; tail widths 1,2,3,4,5,6,7,8,9,10,11; NOT reached: head/tail width 12 and wider ``` ### 12g — the roster handed to the call site `_floor_reaching_decide()` now takes `*floors`; extra units join through the same `second_inverter()` hook. Three **real** `Controller.cycle()`s on a **three-pack** roster, the deep cut-out at each position: ``` REAL THREE-pack cycles, all packs at 25 %, peak_soc_floor 20 %, the 35 % cut-out moved along the roster (units b and c are SIMULATED): 3-pack, deep at index 0: cut-outs ['35', '10', '10'] % -> decide() got soc_floor=35 %, floor_limited=True 3-pack, deep at index 1: cut-outs ['10', '35', '10'] % -> decide() got soc_floor=35 %, floor_limited=True 3-pack, deep at index 2: cut-outs ['10', '10', '35'] % -> decide() got soc_floor=35 %, floor_limited=True ``` The table below is computed against the **real** `Controller.packs` each of the six cycles ran and the **real** `PeakDecision.soc_floor` it produced — the shipped call site's own answer, not a re-derivation: ``` THE POSITIONAL-SLICE FAMILY at this call site, and which run separates each from the whole roster: self.packs[:1] killed by: 2-pack, deep at index 1; 3-pack, deep at index 1; 3-pack, deep at index 2 self.packs[-1:] killed by: 2-pack, deep at index 0; 3-pack, deep at index 0; 3-pack, deep at index 1 self.packs[:2] killed by: 3-pack, deep at index 2 self.packs[-2:] killed by: 3-pack, deep at index 0 self.packs[1:] killed by: 2-pack, deep at index 0; 3-pack, deep at index 0 self.packs[:-1] killed by: 2-pack, deep at index 1; 3-pack, deep at index 2 self.packs[1:-1] killed by: 2-pack, deep at index 1; 2-pack, deep at index 0; 3-pack, deep at index 0; 3-pack, deep at index 2 self.packs[::2] killed by: 2-pack, deep at index 1; 3-pack, deep at index 1 the ROSTER argument main.py reduces is `self.packs`, an AST Attribute ``` Note `self.packs[:2]` is killed by **one** run and `[-2:]` by **one**, and both are three-pack runs. That is the blocker, and it is why two packs could not see it. --- ## ⚠️ TWO MORE SURVIVORS, found by this round's own battery `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. **THE GENERAL LAW, and the reason one more pack was the wrong answer:** > A fixture of **N** packs cannot separate two reductions that agree on N packs. > `[:1]` needed two packs. `[:2]` needed three. `[:3]` needs four — and a > four-pack run hands `[:4]` the same free pass. **The regress does not terminate > in a fixture.** So 12g carries **two halves, each saying what it reaches**: * **the RUNS** grade the **VALUE** — that the floor reaching `decide()` really is the roster's deepest cut-out wherever it sits, and that the site's verdict follows. They reach every positional slice up to N=3 and, by construction, nothing beyond. * **the PIN** grades the **ARGUMENT'S IDENTITY** — exact, on the AST, at any N. ⚠️ **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. `self.packs[:3]` unparses to `self.packs[:3]`. And the pin is stated with its **blind spots**, because both are real: it cannot see a rewrite of what `Controller.packs` *returns* (the runs' `len(ctl.packs) == 3` catches that — mutated and confirmed red, below), and it cannot see the reduction *inside* `binding_soc_floor()` at all (12c2's job). --- ## THE BATTERY — 46 mutations, 43 red, 3 survivors, all three recorded Bytecode purged and counted **before and after each** run (23 → 0 every time). Every kill is `test_peakguard.py exit=1`; the byte delta is printed per mutant. | mutation | Δ | verdict | |---|---|---| | `max(floors)` → `min` / `floors[0]` / `floors[-1]` / mean / median | 0…+21 | **red** | | `max(floors)` → `max(floors[:1] [-1:] [:2] [-2:] [:3] [-3:] [1:] [:-1] [1:-1] [::2] [2:] [:-2])` | +4…+6 | **red** | | `max(floors)` → `max(floors[:4] [-4:] [:8] [-8:] [:11] [-11:])` | +4…+6 | **red** (the length sweep) | | `max(floors)` → `min(floors[-2:])` | +5 | **red** | | `self.packs` → `[:1] [-1:] [:2] [-2:] [1:] [:-1] [1:-1] [::2] [2:] [:-2]` | +4…+6 | **red** | | `self.packs` → `[:3] [-3:] [:4] [-4:] [:99]` | +4…+5 | **red** — structural pin only | | `self.packs` → `list(reversed(self.packs))` | +16 | **red** — structural pin (and *equivalent*: `max` is order-invariant) | | `Controller.packs`: `self.inverters` → `[:2]` / `[1:]` | +4 | **red** | | `pack_floor_pct()`: `max(soc_floor, bms_floor)` → `min` | **0** | **red** | | `max(floors)` → `max(floors[:12])` | +5 | ⚠️ **SURVIVED** | | `max(floors)` → `max(floors[-12:])` | +6 | ⚠️ **SURVIVED** | | `max(floors)` → `max(set(floors))` | +5 | **SURVIVED — equivalent**, `max` is duplicate-invariant | **The one that is not closed, stated plainly.** 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 here — the claim is **semantic** ("the maximum"), and a shape pin would refuse honest refactors of the expression. It is **row 27 in the in-process ledger, printed `NOT CAUGHT`**, with its size: it needs a **13-pack roster** to become observable, on a site whose roster is **one** and whose second unit is ~8 days out. Thirteen packs would close it and open thirteen. That row exists to be argued with. If the reviewer wants width 12 closed at the cost of pinning the expression's shape, say so and it goes in — but I am not going to write a check that claims it while the sweep stops where it stops. --- ## What is in the file now * **12c2** — the roster set, the computed family table, the length sweep, and the two over-broad sentences narrowed with an explicit note on what they miss. * **12g** — `*floors`, the three-pack runs, the positional-slice table, the exact structural pin plus its own non-vacuity counterfactual, and the two over-broad sentences narrowed the same way. * **In-process mutation rows 24–27** — the two blockers, the two battery-found `[:3]`/`[-3:]` survivors closed structurally, and the not-caught width-12 row. The ledger's summary line now reads **SEVEN** defeats that survived a green 19-file suite, and names each with its byte delta and the commit it was green on. * **`DECISIONS` §3b** and a **CHANGELOG** bullet record all of the above, including the general law and the equivalent mutants. CI green on `52380c0` — `checks / unit (pull_request)`. Not merging; over to you.
admin force-pushed SAFETY-32 from 52380c0952 to a17fcf5b86 2026-09-05 00:05:18 +02:00 Compare
Author
Owner

Round 4 — the same family at the SIBLING ARGUMENT, and the family enumerated

Rebased onto release/1.0 at 3934ad7 (MAINT-05 merged) first; one CHANGELOG
conflict, both entries kept. Head is now a17fcf5. Round 3's work is
untouched — every check it added is still here and still red on its own
mutations (proof below).

No shipped code changed in this round. git diff --stat is
test_peakguard.py, test_watchdog.py, CHANGELOG.md, MUTATION.md and the
DECISIONS record. The reductions were already right; nothing observed them.


1. The blocker: Controller.pack_floor_soc, the other half of a documented pair

main.py:1877 hands peakguard.decide() two reductions over the same roster:

soc=self.pack_floor_soc,                                   # MINIMUM  <- round 4
soc_floor=peakguard.binding_soc_floor(self.packs, ...),    # MAXIMUM  <- rounds 2-3

and peakguard.py's own docstring names them as a pair — "MAXIMUM here pairs
with the MINIMUM in pack_floor_soc"
. Rounds 2 and 3 hardened one half.

Confirmed on the rebased head before any fix — full 19-file suite, bytecode
purged, count printed before and after, edit re-read from disk:

mutation in pack_floor_soc bytes suite
min(float(v) for v in vals[:2]) +4 SUITE_PASS=19 SUITE_FAIL=0
min(float(v) for v in vals[-2:]) +5 SUITE_PASS=19 SUITE_FAIL=0
min(float(v) for v in vals[:3]) +4 SUITE_PASS=19 SUITE_FAIL=0

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:
shipped answers 11 %, vals[:2] answers 50 %. The site is graded for discharge
permission against a pack 39 points fuller than the emptiest, and spends a
pack its own BMS stops — via sim/inject.py, which runs no validator rule. The
verdict flips with it: 11 % is under the 20 % EMS floor, so the shipped site is
FLOOR-LIMITED and sizes nothing where the mutant is permitted and sizes a
positive discharge.

2. The fix — round 3's treatment, one axis over (new part 12i)

  • the roster SET — six real Controller.cycle()s, the emptiest pack at
    every position of a three-pack roster and both positions of a two-pack one.
    Observable is PeakDecision.soc (the argument, echoed), not a spy and not a
    grep. Every cut-out held at inverter A's measured 10 %, so the FLOOR axis
    cannot be what moved any answer — asserted as its own precondition.
  • two computed tables, printed, red naming any member nothing kills: the
    eight positional slices of vals, and the four non-slice rewrites
    (max, mean, median, second-emptiest). max(vals) is the most likely wrong
    edit here precisely because the sibling reduction one argument over is a max.
  • a LENGTH SWEEP on the real property — 78 rosters of 1 to 12 real
    Inverters, the empty pack at every position. Closes head and tail windows of
    width 1–11 at once. Width 12 and wider is asserted to be unreached, so a
    later sweep that lengthens the rosters must rewrite the sentence.
  • a NONE-GUARD sweep over the same 78 — 12d silences the second of two
    packs only, so any(v is None for v in vals[:1]) answers "all present" there.
  • three exact structural pins — there are three reductions over roster
    length in this one property. vals is built by one comprehension over the
    bare self.inverters; any() reduces the whole vals; min() reduces the
    whole vals. These close every slice width at every roster length,
    including the [:3] and [:12] no bounded fixture can reach.

Unlike the floor axis there is no open row here: the claim at
pack_floor_soc is an identity ("this list"), so a shape pin is the right
instrument. binding_soc_floor()'s claim is semantic ("the maximum"), where a
shape pin would refuse honest refactors — which is why that one still carries
its width-12 row and this one does not.

3. ⚠️ SPIKE-04 — production is DEGENERATE, so the fixtures are the exposure

Pack B is 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 % too, and that one is measured,
off register 0x0560.

So in the field both packs floor at the same value, max(10, 10) is 10 whatever
it reduces, and a differing floor exists only in the fixtures. That is the
argument for keeping them, not for simplifying them: collapsing
A_PACK_FLOOR != B_PACK_FLOOR_SIM to one number deletes the only data this
repository has that can see binding_soc_floor()'s reduction at all. Part
12c2's precondition check goes red if anyone does, and CHANGELOG + DECISIONS now
say so in as many words.

The SoC axis has no such degeneracy — two batteries at the same state of
charge is a coincidence, not a configuration — which is what makes
pack_floor_soc the half of the pair that is live from the day B is wired up.

4. ⚠️ THE OBVIOUS NEXT QUESTION, asked — and it found four more

This card has been rejected three times for the same shape one step over, so I
stopped closing members. Every reduction over self.inverters / self.packs in
Controller was enumerated from the AST and mutated to a slice, one at a time,
against the full 19-file suite.

Eighteen were already graded — site_batt_w (→ decide(batt_w=)),
site_rating_w (→ achievable_w, the freeze rail), site_max_w,
site_dev_bound, site_circulating_w (SAFETY-31's trip), inputs_ok (gates
the cycle), units, Controller.packs, maint_unit, the maintenance-due
minimum, the store-migration roster, validate_unit_ranges, and the round-2/3
sites. Each [:1] took a named check red.

Four were not. Three are SAFETY-30's, all
any(i.sign_wd.tripped for i in self.inverters), each +4 bytes and green
across all 19 files
— and all three 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.
test_watchdog.py part 3 has
built a two-inverter roster with only inverter B latched since SAFETY-30
shipped; none of them read it.

site consumer what [:1] costs
cycle()'s stale-input hold a latched unit's failsafe SET the trip stops acting the moment a poll is missed — main.py's own sentence
publish()'s status the MQTT vocabulary every dashboard reads a site holding an inverter at 0 W publishes running
status_payload() banner the one headline on the page the failsafe headline disappears

All three are graded now, in new test_watchdog.py part 8, with the latch at
each position (a single "B is latched" fixture kills [:1] and leaves
[-1:] answering identically) plus a no-latch counterfactual proving the
hold really fires and the banner really changes.

And the family rather than the member: new part 12j enumerates all 26
roster comprehensions in Controller across 23 methods, from main.py's AST,
prints them, and requires each to iterate the roster whole. A 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 would
have caught rounds 2, 3 and 4's blockers on the round they were written.

5. ⚠️ Two rows reported OPEN rather than closed

  • _grade_store_migration()'s second disjunct — now red on 12j, but I could
    not construct a reachable state where it changes an answer. That method 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. Recording the argument, not asserting the conclusion.
  • cycle()'s undeliverable-unit LOG LINE — STILL GREEN, +4 bytes.
    sorted(u for u in resolution.shares if not self._can_write(u))[:1] truncates
    the names in one event message. The refusal itself is a different
    expression (elif {u for u in resolution.shares ...}) and is graded
    (part 10h-1b), so the site still stops; what it 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. Filed with its size and its
    consequence.

MANDATORY PROOF

All runs in the goodwe-addon-ci image, tree rsynced to a plain path and
mounted :ro, bytecode purged with the count printed before and after, and
every edit re-read from disk to confirm the new text is present and the old
absent.

Clean tree, 19/19

PYC BEFORE PURGE: 0
PYC AFTER PURGE:  0
  ok   test_alloc.py            ok   test_loop.py
  ok   test_arbiter.py          ok   test_maintenance.py
  ok   test_arbitrage.py        ok   test_p1.py
  ok   test_bytecode.py         ok   test_peakguard.py
  ok   test_ci_gate.py          ok   test_price.py
  ok   test_clamp.py            ok   test_price_forecast.py
  ok   test_control.py          ok   test_static_tariff.py
  ok   test_freeze.py           ok   test_tariff.py
  ok   test_inverter.py         ok   test_validate.py
                                ok   test_watchdog.py
SUITE_PASS=19 SUITE_FAIL=0 FAILED:

THE THREE SURVIVORS — before, and now

Before (rebased head 59ab735, full 19-file suite each):

MUTATION pre_s2:  'min(float(v) for v in vals)' -> 'min(float(v) for v in vals[:2])'
  byte delta +4; re-read from disk confirms new text present=True, old absent=True
  PYC BEFORE PURGE: 0     PYC AFTER PURGE: 0
SUITE_PASS=19 SUITE_FAIL=0 FAILED:

MUTATION pre_sm2: 'min(float(v) for v in vals)' -> 'min(float(v) for v in vals[-2:])'
  byte delta +5; re-read from disk confirms new text present=True, old absent=True
  PYC BEFORE PURGE: 0     PYC AFTER PURGE: 0
SUITE_PASS=19 SUITE_FAIL=0 FAILED:

MUTATION pre_s3:  'min(float(v) for v in vals)' -> 'min(float(v) for v in vals[:3])'
  byte delta +4; re-read from disk confirms new text present=True, old absent=True
  PYC BEFORE PURGE: 0     PYC AFTER PURGE: 0
SUITE_PASS=19 SUITE_FAIL=0 FAILED:

Now — all three RED, and which check bites:

A1  vals[:2]   +4   test_peakguard.py exit=1
  FAIL ⚠️ THE POSITIONAL PIN ON THE SoC AXIS, AND IT NEEDS THREE PACKS TO EXIST...
  FAIL ⚠️ ...AND THE SITE'S VERDICT FLIPS WITH IT...
  FAIL ⚠️ EVERY positional slice in the enumerated family answers something OTHER...
  FAIL ⚠️ THE LENGTH SWEEP, on real `Controller.pack_floor_soc`...

A2  vals[-2:]  +5   test_peakguard.py exit=1
  FAIL ⚠️ THE POSITIONAL PIN ON THE SoC AXIS, AND IT NEEDS THREE PACKS TO EXIST...
  FAIL ⚠️ ...AND THE SITE'S VERDICT FLIPS WITH IT...
  FAIL ⚠️ EVERY positional slice in the enumerated family answers something OTHER...
  FAIL ⚠️ THE LENGTH SWEEP, on real `Controller.pack_floor_soc`...

A3  vals[:3]   +4   test_peakguard.py exit=1
  FAIL ⚠️ THE LENGTH SWEEP, on real `Controller.pack_floor_soc`...
  FAIL ⚠️ THE STRUCTURAL HALF, EXACT RATHER THAN A SUBSTRING...

[:3] is caught by the sweep and the pin only — the six real cycles cannot
see it, and the row in part 11 says exactly that.

The rest of the SoC axis, all RED:

A4  vals[:1]   +4   RED (positional pin, two-pack mirror, verdict flip, slice table)
A5  vals[-1:]  +5   RED (AC 4's own check, positional pin, mirror, verdict flip)
A6  min -> max  +0  RED (AC 4, positional pin, mirror, verdict flip)
A7  vals[:12]  +5   RED (STRUCTURAL PIN only - the sweep stops at 12, as recorded)
A8  any(... vals[:1])  +4   RED (raises TypeError at 12d's None check; also pinned)
A9  [inv.soc for inv in self.inverters[:2]]  +4  RED (positional pin, sweep, tables)

Round 3's mutations — still RED, no regression

R1  binding_soc_floor(self.packs[:1], ...)   +4   RED  (12g call-site pin, three-pack runs)
R2  binding_soc_floor(self.packs[-1:], ...)  +5   RED  (swapped-pair run, positional pin)
R3  binding_soc_floor(self.packs[:2], ...)   +4   RED  (three-pack runs, slice table)
R4  binding_soc_floor(self.packs[:3], ...)   +4   RED  (structural pin)
R5  max(floors) -> min(floors)                0   RED  (12c2, family table, length sweep)
R6  max(floors) -> max(floors[-2:])          +5   RED  (roster set, length sweep, 12g)
R7  empty-roster branch -> return None       -43  RED  (12c2's last block)
E8  Controller.packs -> self.inverters[:2]   +4   RED  (12g's three-pack runs)

The enumeration battery — 22 sites, 20 red, 2 reported open

site_batt_w [:1]            +4  RED  test_inverter.py
site_rating_w [:1]          +4  RED  test_inverter.py
site_max_w [:1]             +4  RED  test_inverter.py
site_dev_bound [:1]         +4  RED  test_inverter.py
site_circulating_w [:1]     +4  RED  test_watchdog.py
inputs_ok [:1]              +4  RED  test_inverter.py
units [:1]                  +4  RED  5 files
status_payload min(_dues)   +4  RED  test_maintenance.py
maint_unit -> [0]          -15  RED  test_maintenance.py
store-migration roster [:1] +4  RED  test_maintenance.py
validate_unit_ranges [:1]   +4  RED  test_inverter.py

cycle() stale-input hold [:1]   +4  was 19/19 GREEN  ->  RED (test_watchdog.py part 8)
cycle() stale-input hold [-1:]  +5                       RED (part 8, mirror)
publish() status [:1]           +4  was 19/19 GREEN  ->  RED (part 8)
publish() status [-1:]          +5                       RED (part 8, mirror)
status_payload banner [:1]      +4  was 19/19 GREEN  ->  RED (part 8)
_grade_store_migration [:1]     +4  was 19/19 GREEN  ->  RED (12j) - argued equivalent
cycle() undeliverable log [:1]  +4  STILL GREEN - reported open, above

CI-06

test_peakguard.py went red once, under three concurrent docker suites plus a
timing run. Re-run in isolation 9 times: 9/9 green. The failing check was
never captured before the load cleared, so I am recording it as the known flake
under load rather than claiming to have named it. Every kill above was
re-confirmed serially; E7 and E8 were both re-run alone after showing
load-shaped noise in a parallel batch, and E8 proved to be a clean round-3
kill while E7 proved to be a genuine survivor.


Not merged, release/1.0 not touched, no hardware went anywhere near this.

## Round 4 — the same family at the SIBLING ARGUMENT, and the family enumerated Rebased onto `release/1.0` at `3934ad7` (MAINT-05 merged) first; one CHANGELOG conflict, both entries kept. Head is now **`a17fcf5`**. Round 3's work is untouched — every check it added is still here and still red on its own mutations (proof below). **No shipped code changed in this round.** `git diff --stat` is `test_peakguard.py`, `test_watchdog.py`, `CHANGELOG.md`, `MUTATION.md` and the DECISIONS record. The reductions were already right; nothing observed them. --- ### 1. The blocker: `Controller.pack_floor_soc`, the other half of a documented pair `main.py:1877` hands `peakguard.decide()` two reductions over the same roster: ```python soc=self.pack_floor_soc, # MINIMUM <- round 4 soc_floor=peakguard.binding_soc_floor(self.packs, ...), # MAXIMUM <- rounds 2-3 ``` and `peakguard.py`'s own docstring names them as a pair — *"MAXIMUM here pairs with the MINIMUM in `pack_floor_soc`"*. Rounds 2 and 3 hardened one half. Confirmed on the rebased head before any fix — full 19-file suite, bytecode purged, count printed before and after, edit re-read from disk: | mutation in `pack_floor_soc` | bytes | suite | |---|---|---| | `min(float(v) for v in vals[:2])` | +4 | `SUITE_PASS=19 SUITE_FAIL=0` | | `min(float(v) for v in vals[-2:])` | +5 | `SUITE_PASS=19 SUITE_FAIL=0` | | `min(float(v) for v in vals[:3])` | +4 | `SUITE_PASS=19 SUITE_FAIL=0` | 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**: shipped answers 11 %, `vals[:2]` answers 50 %. The site is graded for discharge *permission* against a pack 39 points fuller than the emptiest, and spends a pack its own BMS stops — via `sim/inject.py`, which runs no validator rule. The verdict flips with it: 11 % is under the 20 % EMS floor, so the shipped site is FLOOR-LIMITED and sizes nothing where the mutant is permitted and sizes a positive discharge. ### 2. The fix — round 3's treatment, one axis over (new part 12i) * **the roster SET** — six real `Controller.cycle()`s, the emptiest pack at every position of a three-pack roster and both positions of a two-pack one. Observable is `PeakDecision.soc` (the argument, echoed), not a spy and not a grep. Every cut-out held at inverter A's measured 10 %, so the FLOOR axis cannot be what moved any answer — asserted as its own precondition. * **two computed tables**, printed, red naming any member nothing kills: the eight positional slices of `vals`, and the four non-slice rewrites (`max`, mean, median, second-emptiest). `max(vals)` is the most likely wrong edit here precisely because the sibling reduction one argument over *is* a max. * **a LENGTH SWEEP on the real property** — 78 rosters of 1 to 12 real `Inverter`s, the empty pack at every position. Closes head and tail windows of width 1–11 at once. Width 12 and wider is **asserted to be unreached**, so a later sweep that lengthens the rosters must rewrite the sentence. * **a NONE-GUARD sweep** over the same 78 — 12d silences the *second* of two packs only, so `any(v is None for v in vals[:1])` answers "all present" there. * **three exact structural pins** — there are three reductions over roster length in this one property. `vals` is built by one comprehension over the bare `self.inverters`; `any()` reduces the whole `vals`; `min()` reduces the whole `vals`. These close every slice width at **every** roster length, including the `[:3]` and `[:12]` no bounded fixture can reach. Unlike the floor axis there is **no open row here**: the claim at `pack_floor_soc` is an *identity* ("this list"), so a shape pin is the right instrument. `binding_soc_floor()`'s claim is *semantic* ("the maximum"), where a shape pin would refuse honest refactors — which is why that one still carries its width-12 row and this one does not. ### 3. ⚠️ SPIKE-04 — production is DEGENERATE, so the fixtures are the exposure Pack B is **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 % too, and *that* one is measured, off register `0x0560`. So in the field both packs floor at the same value, `max(10, 10)` is 10 whatever it reduces, and **a differing floor exists only in the fixtures.** That is the argument for keeping them, not for simplifying them: collapsing `A_PACK_FLOOR != B_PACK_FLOOR_SIM` to one number deletes the only data this repository has that can see `binding_soc_floor()`'s reduction at all. Part 12c2's precondition check goes red if anyone does, and CHANGELOG + DECISIONS now say so in as many words. The **SoC axis has no such degeneracy** — two batteries at the same state of charge is a coincidence, not a configuration — which is what makes `pack_floor_soc` the half of the pair that is live from the day B is wired up. ### 4. ⚠️ THE OBVIOUS NEXT QUESTION, asked — and it found four more This card has been rejected three times for the same shape one step over, so I stopped closing members. Every reduction over `self.inverters` / `self.packs` in `Controller` was enumerated from the AST and mutated to a slice, one at a time, against the full 19-file suite. **Eighteen were already graded** — `site_batt_w` (→ `decide(batt_w=)`), `site_rating_w` (→ `achievable_w`, the freeze rail), `site_max_w`, `site_dev_bound`, `site_circulating_w` (SAFETY-31's trip), `inputs_ok` (gates the cycle), `units`, `Controller.packs`, `maint_unit`, the maintenance-due minimum, the store-migration roster, `validate_unit_ranges`, and the round-2/3 sites. Each `[:1]` took a named check red. **Four were not.** Three are SAFETY-30's, all `any(i.sign_wd.tripped for i in self.inverters)`, each **+4 bytes and green across all 19 files** — and all three 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`.* test_watchdog.py part 3 has built a two-inverter roster with only inverter B latched since SAFETY-30 shipped; none of them read it. | site | consumer | what `[:1]` costs | |---|---|---| | `cycle()`'s stale-input hold | a latched unit's `failsafe` SET | the trip stops acting the moment a poll is missed — main.py's own sentence | | `publish()`'s status | the MQTT vocabulary every dashboard reads | a site holding an inverter at 0 W publishes `running` | | `status_payload()` banner | the one headline on the page | the failsafe headline disappears | All three are graded now, in **new test_watchdog.py part 8**, with the latch at **each** position (a single "B is latched" fixture kills `[:1]` and leaves `[-1:]` answering identically) plus a **no-latch counterfactual** proving the hold really fires and the banner really changes. And the family rather than the member: **new part 12j** enumerates all **26** roster comprehensions in `Controller` across 23 methods, from main.py's AST, prints them, and requires each to iterate the roster **whole**. A 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 would have caught rounds 2, 3 and 4's blockers on the round they were written. ### 5. ⚠️ Two rows reported OPEN rather than closed * **`_grade_store_migration()`'s second disjunct** — now red on 12j, but I could not construct a *reachable* state where it changes an answer. That method 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. Recording the argument, not asserting the conclusion. * **`cycle()`'s undeliverable-unit LOG LINE — STILL GREEN, +4 bytes.** `sorted(u for u in resolution.shares if not self._can_write(u))[:1]` truncates the **names in one event message**. The refusal itself is a *different* expression (`elif {u for u in resolution.shares ...}`) and **is** graded (part 10h-1b), so the site still stops; what it 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. **Filed with its size and its consequence.** --- ## MANDATORY PROOF All runs in the `goodwe-addon-ci` image, tree rsynced to a plain path and mounted `:ro`, **bytecode purged with the count printed before and after**, and every edit **re-read from disk** to confirm the new text is present and the old absent. ### Clean tree, 19/19 ``` PYC BEFORE PURGE: 0 PYC AFTER PURGE: 0 ok test_alloc.py ok test_loop.py ok test_arbiter.py ok test_maintenance.py ok test_arbitrage.py ok test_p1.py ok test_bytecode.py ok test_peakguard.py ok test_ci_gate.py ok test_price.py ok test_clamp.py ok test_price_forecast.py ok test_control.py ok test_static_tariff.py ok test_freeze.py ok test_tariff.py ok test_inverter.py ok test_validate.py ok test_watchdog.py SUITE_PASS=19 SUITE_FAIL=0 FAILED: ``` ### THE THREE SURVIVORS — before, and now **Before (rebased head `59ab735`, full 19-file suite each):** ``` MUTATION pre_s2: 'min(float(v) for v in vals)' -> 'min(float(v) for v in vals[:2])' byte delta +4; re-read from disk confirms new text present=True, old absent=True PYC BEFORE PURGE: 0 PYC AFTER PURGE: 0 SUITE_PASS=19 SUITE_FAIL=0 FAILED: MUTATION pre_sm2: 'min(float(v) for v in vals)' -> 'min(float(v) for v in vals[-2:])' byte delta +5; re-read from disk confirms new text present=True, old absent=True PYC BEFORE PURGE: 0 PYC AFTER PURGE: 0 SUITE_PASS=19 SUITE_FAIL=0 FAILED: MUTATION pre_s3: 'min(float(v) for v in vals)' -> 'min(float(v) for v in vals[:3])' byte delta +4; re-read from disk confirms new text present=True, old absent=True PYC BEFORE PURGE: 0 PYC AFTER PURGE: 0 SUITE_PASS=19 SUITE_FAIL=0 FAILED: ``` **Now — all three RED, and which check bites:** ``` A1 vals[:2] +4 test_peakguard.py exit=1 FAIL ⚠️ THE POSITIONAL PIN ON THE SoC AXIS, AND IT NEEDS THREE PACKS TO EXIST... FAIL ⚠️ ...AND THE SITE'S VERDICT FLIPS WITH IT... FAIL ⚠️ EVERY positional slice in the enumerated family answers something OTHER... FAIL ⚠️ THE LENGTH SWEEP, on real `Controller.pack_floor_soc`... A2 vals[-2:] +5 test_peakguard.py exit=1 FAIL ⚠️ THE POSITIONAL PIN ON THE SoC AXIS, AND IT NEEDS THREE PACKS TO EXIST... FAIL ⚠️ ...AND THE SITE'S VERDICT FLIPS WITH IT... FAIL ⚠️ EVERY positional slice in the enumerated family answers something OTHER... FAIL ⚠️ THE LENGTH SWEEP, on real `Controller.pack_floor_soc`... A3 vals[:3] +4 test_peakguard.py exit=1 FAIL ⚠️ THE LENGTH SWEEP, on real `Controller.pack_floor_soc`... FAIL ⚠️ THE STRUCTURAL HALF, EXACT RATHER THAN A SUBSTRING... ``` `[:3]` is caught by the **sweep and the pin only** — the six real cycles cannot see it, and the row in part 11 says exactly that. **The rest of the SoC axis, all RED:** ``` A4 vals[:1] +4 RED (positional pin, two-pack mirror, verdict flip, slice table) A5 vals[-1:] +5 RED (AC 4's own check, positional pin, mirror, verdict flip) A6 min -> max +0 RED (AC 4, positional pin, mirror, verdict flip) A7 vals[:12] +5 RED (STRUCTURAL PIN only - the sweep stops at 12, as recorded) A8 any(... vals[:1]) +4 RED (raises TypeError at 12d's None check; also pinned) A9 [inv.soc for inv in self.inverters[:2]] +4 RED (positional pin, sweep, tables) ``` ### Round 3's mutations — still RED, no regression ``` R1 binding_soc_floor(self.packs[:1], ...) +4 RED (12g call-site pin, three-pack runs) R2 binding_soc_floor(self.packs[-1:], ...) +5 RED (swapped-pair run, positional pin) R3 binding_soc_floor(self.packs[:2], ...) +4 RED (three-pack runs, slice table) R4 binding_soc_floor(self.packs[:3], ...) +4 RED (structural pin) R5 max(floors) -> min(floors) 0 RED (12c2, family table, length sweep) R6 max(floors) -> max(floors[-2:]) +5 RED (roster set, length sweep, 12g) R7 empty-roster branch -> return None -43 RED (12c2's last block) E8 Controller.packs -> self.inverters[:2] +4 RED (12g's three-pack runs) ``` ### The enumeration battery — 22 sites, 20 red, 2 reported open ``` site_batt_w [:1] +4 RED test_inverter.py site_rating_w [:1] +4 RED test_inverter.py site_max_w [:1] +4 RED test_inverter.py site_dev_bound [:1] +4 RED test_inverter.py site_circulating_w [:1] +4 RED test_watchdog.py inputs_ok [:1] +4 RED test_inverter.py units [:1] +4 RED 5 files status_payload min(_dues) +4 RED test_maintenance.py maint_unit -> [0] -15 RED test_maintenance.py store-migration roster [:1] +4 RED test_maintenance.py validate_unit_ranges [:1] +4 RED test_inverter.py cycle() stale-input hold [:1] +4 was 19/19 GREEN -> RED (test_watchdog.py part 8) cycle() stale-input hold [-1:] +5 RED (part 8, mirror) publish() status [:1] +4 was 19/19 GREEN -> RED (part 8) publish() status [-1:] +5 RED (part 8, mirror) status_payload banner [:1] +4 was 19/19 GREEN -> RED (part 8) _grade_store_migration [:1] +4 was 19/19 GREEN -> RED (12j) - argued equivalent cycle() undeliverable log [:1] +4 STILL GREEN - reported open, above ``` ### CI-06 `test_peakguard.py` went red once, under three concurrent docker suites plus a timing run. **Re-run in isolation 9 times: 9/9 green.** The failing check was never captured before the load cleared, so I am recording it as the known flake under load rather than claiming to have named it. Every kill above was re-confirmed serially; `E7` and `E8` were both re-run alone after showing load-shaped noise in a parallel batch, and `E8` proved to be a clean round-3 kill while `E7` proved to be a genuine survivor. --- Not merged, `release/1.0` not touched, no hardware went anywhere near this.
admin added 5 commits 2026-09-05 05:36:26 +02:00
`bms_soc_floor` describes A BATTERY, not a site. At two packs of different
models the cut-outs differ, and one number for both graded the shallower pack
against the deeper one's BMS - so a site with cut-outs of 10 % and 30 % under
`peak_soc_floor: 20` 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.

Scoped to the card's third bullet, per its PM PROOFREAD: the aggregate
anti-windup (DEV-02) and per-instance `achievable_w` (CFG-04) were verified as
already shipped and are untouched.

* config.yaml: `bms_soc_floor: int(0,50)` REQUIRED per `inverters:` entry.
  Nothing is inherited; the flat key stays the single-inverter shape's own
  cut-out. A per-entry DEFAULT was declined - a default is one number for every
  pack, which is this card's defect - and the decision is recorded.
* inverter.py: `Inverter.bms_soc_floor`; an entry that states none gets None,
  not a borrowed number and not 0.0 (the most permissive value the key has).
* 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.
* validate.py: `unit_bms_floors_indexed()` carries the ROSTER index; rule 8
  grades EVERY unit and NAMES the one it refuses. Graded with the offender
  SECOND and with the same two numbers in the other order.
* main.py: `Controller.packs`; `decide(soc_floor=...)` is the per-pack
  reduction of the same option; the floor-limited notification names the pack
  that stopped the site. `pack_floor_soc` - the emptiest pack, and its
  None-on-missing behaviour - is untouched.
* tools/supervisor_shape_probe.py: RE-RUN and repaired. The required key broke
  §C and left §D printing PASS on a refusal about a different key. §D is now an
  isolated counterfactual over an accepted merged baseline, one key varied,
  asserting the key name. `all cases passed` on 2026.08.0.

Stated rather than hidden: under any config rule 8 accepts the per-pack FLOOR
reduces to `peak_soc_floor` for every pack. The reduction is written for
`unit_caps_indexed()`'s reason and graded on both sides - 1775 rule-8-valid
pairs answer the option, the pair rule 8 refuses answers the cut-out.
`peak_soc_floor` is still one number for the site, so the "deeper pack
needlessly withheld" half is filed as an open question, not closed.

Full 19-file suite green. Twelve source mutations through
tools/mutation_run.sh: eleven red, one argued survivor. The battery also found
`_flat_bms_floor()` defaulting in two places, each covering for the other.

No measurement of inverter B appears anywhere: it does not exist yet. Every
second-pack figure is simulated and labelled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
AC 6 was not met for the reduction main.py actually hands decide(). The
reviewer removed the per-pack grading twice and the 19-file suite stayed green
both times. No shipped behaviour changes here - app/ is untouched. What changes
is what observes it.

WHY BOTH SURVIVED, and it is this repository's recurring defect landing on this
card's own Decision 2: under any rule-8-valid config every pack's
pack_floor_pct() equals peak_soc_floor, so `floors` is a list of IDENTICAL
numbers and max/min/first/last are four spellings of one answer. The only check
using a cut-out ABOVE the EMS floor - 12c's `_n1_deep` - ran on a roster of ONE,
where every reduction agrees. So no check ever evaluated binding_soc_floor() on
two packs whose floors differ. And the degeneracy is not a defence: this PR's own
argument ("unreachable is a claim about today's rule ordering, and
validate_startup_config() is also sim/inject.py's entry point") justifies writing
the reduction and equally obliges grading it.

  * test_peakguard.py part 12c2 - NEW. binding_soc_floor() on a roster whose
    per-pack floors ALL DIFFER: four SIMULATED cut-outs 30/25/40/35 %, each
    strictly above peak_soc_floor, so max (40), min (25), first (30), last (35)
    and the mean (32.5) are five different numbers and the extremes sit at
    INTERIOR roster positions. Two packs structurally cannot separate those -
    there max==last and min==first - and neither can three; four is the smallest
    roster that can. Precondition is its own check (habit 1); the counterfactual
    raises ONE pack - the last, neither extreme - past the old maximum and the
    answer follows (habit 7); peak_soc_floor is then lifted above every cut-out
    to grade the other side of the conjunction.
  * test_peakguard.py part 12g - REWRITTEN as behaviour (habit 4). The old
    assertion was `"self.packs" in <the unparsed call site>`, which `self.packs[:1]`
    still satisfies. It now drives REAL Controller.cycle()s on a two-pack roster,
    both packs at the same SoC, and reads PeakDecision.soc_floor - the argument,
    echoed back by the shipped decide(). The cut-outs are the only thing varied,
    and the SAME PAIR is carried in BOTH ORDERS.
  * ⚠️ TWO MORE SURVIVORS, found by this round's own battery and recorded rather
    than quietly repaired. (a) The first cut of 12g varied only pack B's cut-out,
    so `self.packs[-1:]` (+5 bytes) agreed with the roster on both runs and stayed
    green across all 19 files; the swapped pair closes it, and between the two
    directions no single-position slice passes all three runs. (b)
    binding_soc_floor()'s EMPTY-roster branch rewritten to `return None`
    (-43 bytes) was green too - a defensive branch nothing in the suite reached.
    Both directions of it are pinned now.
  * wired_over_limit() gained a prepare(ctl) hook, inside its retry loop so every
    attempt grades the same site.
  * In-process mutation rows 22, 22b and 23 record all four survivors.

Nine further source mutations went through the harness on the fixed tree
(binding_soc_floor -> floors[0] / floors[-1], pack_floor_pct conjoining with
min, pack_permissions grading against packs[0], `any` -> `all` on the unknown
floor, Controller.packs reversed, decide(soc=) taking inverters[0].soc, and the
two survivors above). Seven were red on first run; the two survivors are now red
as well. Three of the nine have a ZERO byte delta and still went red, which is
the direction stale bytecode cannot fabricate - and the harness purged anyway.

MANDATORY PROOF, all in the CI image over a tree rsynced to a plain path and
bind-mounted :ro, bytecode entry count printed before and after the purge:
each of the four mutations now takes test_peakguard.py RED, and the unmutated
tree is 19/19 green (twice, plus test_peakguard.py five times in isolation -
card CI-06's flake did not reappear). The zero-byte one was proved to have RUN rather than
assumed: dis.get_instructions LOAD_GLOBALs were [float, pack_floor_pct, any,
min] and binding_soc_floor() returned 25.0 on the 12c2 roster where the shipped
code returns 40.0.

No measurement of inverter B appears anywhere. It does not physically exist.
Every second-pack and fourth-pack figure added here is SIMULATED and labelled at
its definition; only inverter A's 10 % is read off hardware. No hardware was
touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
Both blockers were the recurring defect sitting inside the fix written to close
it: a check whose TEXT claims more than its FIXTURE can observe. Both survived a
green 19-file suite on 8501e05.

  max(floors) -> max(floors[-2:])   +5 bytes.  12c2's only counterfactual raised
  the LAST pack, and every window ending at the roster's end follows the last
  pack. The check said "the reduction reads every entry"; the data reached "it
  reads the last entry".

  self.packs -> self.packs[:2]      +4 bytes.  All three 12g runs drove a TWO-pack
  roster, where [:2] IS the roster. The check said "what is reduced is the ROSTER
  and not a POSITION in it"; the true claim was the narrower "no SINGLE-POSITION
  slice passes all three runs".

The prose is cut back to what the old data reaches, and new data added for the
rest. No app/ file is touched - app/peakguard.py and app/main.py are byte-identical
to 8501e05.

12c2 - the pure function:
  * a ROSTER SET: the same four simulated cut-outs with the deepest moved to each
    of the four positions.
  * a computed FAMILY TABLE of fourteen rewrites, printed, with the roster that
    kills each. A member with an empty kill list takes the check RED naming it.
  * a LENGTH SWEEP: 78 rosters, lengths 1-12, deepest at every position of each.
    Closes every head window max(floors[:w]) and tail window max(floors[-w:]) of
    width 1-11 in one property.

12g - the call site:
  * _floor_reaching_decide() takes *floors; extra units join through the same
    second_inverter() hook.
  * THREE-pack runs with the deep cut-out at index 0, 1 and 2. Every positional
    slice of a three-pack roster drops a position, and the run whose deep pack
    sits there answers the option where the roster answers the cut-out.
  * a POSITIONAL-SLICE TABLE computed against the REAL Controller.packs each of
    the six cycles ran and the REAL PeakDecision.soc_floor it produced.
  * an EXACT STRUCTURAL PIN on the roster argument's identity. 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 GENERAL LAW, and why the pin is there: a fixture of N packs cannot separate
two reductions that agree on N packs, and the regress does not terminate in a
fixture. [:1] needed two packs, [:2] three, [:3] four. This round's own battery
found self.packs[:3] (+4) and self.packs[-3:] (+5) surviving all six behavioural
runs; the structural pin is what closes them, and one more pack would only have
moved the hole.

Battery: 46 mutations over the reduction, the call site, Controller.packs and
pack_floor_pct()'s conjunction. Bytecode purged and counted 23 -> 0 for each.
43 red, 3 survivors, all three recorded:
  max(floors[:12]) / max(floors[-12:])  NOT CAUGHT - needs a 13-pack roster,
                                        reported with its size, row 27.
  max(set(floors))                      EQUIVALENT - max is duplicate-invariant.
(list(reversed(self.packs)) is equivalent too and is red on the structural pin.)

In-process mutation rows 24-27 record all of it, including the not-caught one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
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
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
admin force-pushed SAFETY-32 from a17fcf5b86 to 19decf433f 2026-09-05 05:36:26 +02:00 Compare
Author
Owner

Round 5 — pushed 19decf4, rebased onto 67e649a (SPIKE-04a). Suite 19/19 green.

The blocker is closed twice over — behaviourally and structurally — and the
instrument that closes it no longer has a scope anyone wrote by hand.

The four survivors are not equivalent mutants, and I re-measured that

Calling the shipped validator both ways on the real tree:

roster sign_disagree_w clean mutant
max_w 5000 (a) + 2000 (b) 3000 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
same 3000 same caps_ix[:1] +4 B → 0 problems
2000 (a) + 5000 (b) 3000 1 problem, naming inverter-a min(caps[-1:]) +5 B → 0 problems

⚠️ And the one-roster fixture in the card could not have shown the third row.
On [5000, 2000] the [-1:] mutant still refuses, because there last is
min. A fixture of N members cannot separate two reductions that agree on N
members — which is why the behavioural half below is a set, not a roster.

1. The instrument, widened past Controller — and it discovers its own scope

test_peakguard.py part 12j now reads every .py under app/ off disk;
no module is named in the check. 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 and hide what is downstream; an index is one
unit and does not.

23 modules parsed from app/ (NOT a written list); 9 of them reduce over a roster
or a roster-derived list, and every such reduction is enumerated (183 of them):
  allocator.py: 24   arbiter.py: 14   circulation.py: 10   inverter.py: 3
  main.py: 96        maintenance.py: 10  peakguard.py: 4   store.py: 3
  validate.py: 19
modules with NO roster reduction at all: ['__init__', 'arbitrage', 'clamp',
  'control', 'freeze', 'hass', 'mqtt', 'p1', 'price', 'price_forecast',
  'static_tariff', 'tariff', 'watchdog', 'web']

⚠️ The hand-written module list would have been wrong in BOTH directions. The
card's 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
it omitted maintenance.py and store.py, which both do. 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 instead of asserted.

2. What it named — including all four blockers, unprompted

PR #48's round-5 blockers, as the pin names them (validate.validate_startup_config,
reductions over the caps list): validate.py:778 for..in caps_ix,
validate.py:779 min() caps, validate.py:783 min() caps_ix,
validate.py:836 min() caps_ix, validate.py:920 min() caps_ix

It is told one option key and nothing else, and it independently names all
four — plus a fifth at :783, the min(caps_ix, key=…) inside rule 5's own
message, which no review round named. (:836 is new this round, see §4;
:920 is the blocker reported as :893 before the file grew.)

Matched by function and expression, not by line number — three lines added to
validate.py move all four, and a check pinned to 778 would then be green
because it found nothing. The lines are printed so they can be matched by eye.

3. The sweep — and the number that must not be quoted alone

All 183 sites mutated to [:1], one at a time, +4 B each, each mutant run
against the full 19-file suite in goodwe-addon-ci (:ro, python3 -B, fresh
rsync per mutant, 0 .pyc before and after — a read-only mount cannot write
one, so TEST-15 is closed by construction here). 183 of 183 RED.

⚠️ On its own that is tautological: with the pin in the tree every slice is
red by the pin. So the 51 whose first red was test_peakguard.py were re-run
with the pin neutralised (_SLICED blanked):

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
DECISIONS/SAFETY-32-per-pack-bms-floor.md §4d and in part 12j itself:
main.py 27, allocator.py 6, validate.py 2, arbiter.py 1,
circulation.py 1, maintenance.py 1. Fifteen of them are rows and counts on
the operator's page (checks, status_payload) — a slice reports one unit's
row where two exist; three are len(targets) arithmetic beside a write path that
is graded; six 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.

4. ⚠️ 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 (SAFETY-30's per-inverter sign watchdog) was graded
against _number(opts, "max_w", 2000.0), three lines under a branch that had
already stopped doing that. Both directions measured before the fix:

  • two 2000 W units beside a leftover max_w: 5000 from a single-inverter
    install, unit_sign_disagree_w: 3000 → 0 problems. The site started, the
    status page said armed, and the per-unit watchdog could not trip on either
    unit. The identical sentence to the blocker, reached through the branch beside
    it;
  • two 5000 W units with no flat key → 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 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, boundary and sentence. Both directions asserted.

5. The behavioural half — because a shape pin grades an identity, never a meaning

test_validate.py grows a roster SET for the three rules that reduce over
unit_caps_indexed(): widths 2, 3, 4 and 5 with the one small cap at every
position
— 14 rosters × 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:

      min (SHIPPED)    AGREES EVERYWHERE (survivor)
      max              differs on 42 run(s), first: sign/n2p0
      first            differs on 30 run(s), first: sign/n2p1
      last             differs on 30 run(s), first: sign/n2p0
      min(caps[:1])    differs on 30 run(s), first: sign/n2p1
      min(caps[-1:])   differs on 30 run(s), first: sign/n2p0
      min(caps[:2])    differs on 18 run(s), first: sign/n3p2
      min(caps[:3])    differs on  9 run(s), first: sign/n4p3
      min(caps[1:])    differs on 12 run(s), first: sign/n2p0
      min(caps[:-1])   differs on 12 run(s), first: sign/n2p1
      min(caps[::2])   differs on 18 run(s), first: sign/n2p1
      mean             differs on 42 run(s), first: sign/n2p0

A member no roster kills is printed as a named survivor and takes the check
red. At a roster of one every candidate that still has a member coincides —
which is why no single-inverter fixture could ever have caught any of this.

6. Mutation proof

Each: bytecode purged and counted 0 before and 0 after, the edit re-read
from disk
(new text present, 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         -> 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
— so the blocker is closed by a fixture and not only by a shape.
B7 is a deliberate no-op control: max(0.0, weakest) is weakest for
weakest > 0, and it stays green — the battery is not merely red for everything
(habit 7, an accepted baseline with one thing varied).

R3a is a ZERO-byte delta, so the shipped function was called both ways too:

packs floors [15.0, 30.0, 20.0], peak_soc_floor 15
  SHIPPED binding_soc_floor(...)          = 30.0
  the 0-byte mutant (max -> min) would be = 15.0     they DIFFER (+15.0 pp)
  -> the site would discharge to 15 % while pack-b's own BMS cuts out at 30 %.

7. The pin's own non-vacuity

per-site counterfactual: 183 sites x 4 cuts ([:1], [-1:], [:2], [0])
  = 732 mutated spellings, 0 of them NOT reported
validate.py:778 `caps_ix` -> `caps_ix[:1]`: reported at that line YES (1 sliced site)
validate.py:779 `caps`    -> `caps[:1]`   : reported at that line YES (1 sliced site)
validate.py:783 `caps_ix` -> `caps_ix[:1]`: reported at that line YES (1 sliced site)
validate.py:836 `caps_ix` -> `caps_ix[:1]`: reported at that line YES (1 sliced site)
validate.py:920 `caps_ix` -> `caps_ix[:1]`: reported at that line YES (1 sliced site)

The first is the predicate; the second re-runs the whole analysis over
mutated source text at a real byte offset, so the instrument is proved wired
to the files and not only to itself. The same check asserts the predicate
accepts enumerate(…), zip(…), sorted(…) and
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.

8. ⚠️ WHAT THE PIN CANNOT SEE — identity vs semantics, stated so the next reader knows the shape of the exposure

  1. Semantics. min → max → the mean are zero-byte edits and are
    invisible to any shape pin, permanently. Only the behavioural sets can see
    them, and only for the rules they drive.
  2. Direct index sites that are not reductions — self.inverters[0] in
    _only()/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] 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 receiver, is lost at that hop — and
    everything downstream goes unenumerated rather than reported unreachable.
    Not zero, and not measurable from inside the check.
  7. cycle()'s undeliverable-unit log line is still green at +4 B, unchanged
    from round 4 and still filed open (row 22 of §4c).
  8. The 38 behaviourally-ungraded sites of §3.

9. ⚠️ Production is DEGENERATE on the floor axis — do not "simplify" the fixtures

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
collapsing them: anyone who makes the fixtures match reality deletes the only
data this repository has that can see the reduction at all, and part 12c2's
precondition check goes red if they do. Restated at the line in config.yaml's
example, in the probe, and in the new test_validate.py section.

⚠️ 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.

10. Rebase and the probe

Rebased onto 67e649a. Two conflicts with SPIKE-04a, both inside blocks this
card had rewritten (config.yaml's roster example and the probe's §C/§D).
Resolved to carry both claims, and the probe was re-run rather than
hand-merged
— MUTATION.md habit 8, a transcript stitched out of two runs is not
a measurement:

== all cases passed ==     ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0
   D baseline: [{"name": "inverter-b", ..., "max_w": 4600, "bms_soc_floor": 10}]

11. 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. No red was observed at any point, so no red had to be believed or
disbelieved.

⚠️ One transparency note on the sweep: it ran against a frozen snapshot of this
tree. The snapshot differs from the pushed commit in exactly one place — the
comment block inside part 12j that records the sweep's own result, which did
not exist while the sweep was running. diff -r over app/ is empty and all 19
test files are byte-identical apart from that comment.

Not merged, as instructed.

## Round 5 — pushed `19decf4`, rebased onto `67e649a` (SPIKE-04a). Suite 19/19 green. **The blocker is closed twice over — behaviourally and structurally — and the instrument that closes it no longer has a scope anyone wrote by hand.** ### The four survivors are not equivalent mutants, and I re-measured that Calling the shipped validator both ways on the real tree: | roster | `sign_disagree_w` | clean | mutant | |---|---|---|---| | `max_w` 5000 (a) + 2000 (b) | 3000 | 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** | | same | 3000 | same | `caps_ix[:1]` +4 B → **0 problems** | | **2000 (a) + 5000 (b)** | 3000 | 1 problem, naming **inverter-a** | `min(caps[-1:])` +5 B → **0 problems** | ⚠️ **And the one-roster fixture in the card could not have shown the third row.** On `[5000, 2000]` the `[-1:]` mutant still refuses, because there *last* **is** *min*. A fixture of N members cannot separate two reductions that agree on N members — which is why the behavioural half below is a **set**, not a roster. ### 1. The instrument, widened past `Controller` — and it discovers its own scope `test_peakguard.py` part 12j now reads **every `.py` under `app/`** off disk; **no module is named in the check**. 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 and hide what is downstream; an **index** is one unit and does not. ``` 23 modules parsed from app/ (NOT a written list); 9 of them reduce over a roster or a roster-derived list, and every such reduction is enumerated (183 of them): allocator.py: 24 arbiter.py: 14 circulation.py: 10 inverter.py: 3 main.py: 96 maintenance.py: 10 peakguard.py: 4 store.py: 3 validate.py: 19 modules with NO roster reduction at all: ['__init__', 'arbitrage', 'clamp', 'control', 'freeze', 'hass', 'mqtt', 'p1', 'price', 'price_forecast', 'static_tariff', 'tariff', 'watchdog', 'web'] ``` ⚠️ **The hand-written module list would have been wrong in BOTH directions.** The card's 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 it omitted `maintenance.py` and `store.py`, which both do. 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 instead of asserted. ### 2. What it named — including all four blockers, unprompted ``` PR #48's round-5 blockers, as the pin names them (validate.validate_startup_config, reductions over the caps list): validate.py:778 for..in caps_ix, validate.py:779 min() caps, validate.py:783 min() caps_ix, validate.py:836 min() caps_ix, validate.py:920 min() caps_ix ``` It is told **one option key** and nothing else, and it independently names all four — plus a **fifth** at `:783`, the `min(caps_ix, key=…)` inside rule 5's own message, which no review round named. (`:836` is new this round, see §4; `:920` is the blocker reported as `:893` before the file grew.) Matched by **function and expression, not by line number** — three lines added to `validate.py` move all four, and a check pinned to `778` would then be green because it found *nothing*. The lines are printed so they can be matched by eye. ### 3. The sweep — and the number that must not be quoted alone All 183 sites mutated to `[:1]`, one at a time, +4 B each, each mutant run against the full 19-file suite in `goodwe-addon-ci` (`:ro`, `python3 -B`, fresh rsync per mutant, **0 `.pyc` before and after** — a read-only mount cannot write one, so TEST-15 is closed by construction here). **183 of 183 RED.** ⚠️ **On its own that is tautological**: with the pin in the tree every slice is red *by the pin*. So the 51 whose first red was `test_peakguard.py` were re-run **with the pin neutralised** (`_SLICED` blanked): | | | |---|---| | **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 `DECISIONS/SAFETY-32-per-pack-bms-floor.md` §4d and in part 12j itself: `main.py` 27, `allocator.py` 6, `validate.py` 2, `arbiter.py` 1, `circulation.py` 1, `maintenance.py` 1. Fifteen of them are rows and counts on the **operator's page** (`checks`, `status_payload`) — a slice reports one unit's row where two exist; three are `len(targets)` arithmetic beside a write path that **is** graded; six 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.** ### 4. ⚠️ 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` (SAFETY-30's per-inverter sign watchdog) was graded against `_number(opts, "max_w", 2000.0)`, three lines under a branch that had already stopped doing that. **Both directions measured before the fix:** * two 2000 W units beside a leftover `max_w: 5000` from a single-inverter install, `unit_sign_disagree_w: 3000` → **0 problems.** The site started, the status page said `armed`, and the per-unit watchdog could not trip on *either* unit. The identical sentence to the blocker, reached through the branch beside it; * two 5000 W units with **no** flat key → **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` 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, boundary and sentence. Both directions asserted. ### 5. The behavioural half — because a shape pin grades an identity, never a meaning `test_validate.py` grows a **roster SET** for the three rules that reduce over `unit_caps_indexed()`: widths 2, 3, 4 and 5 with the one small cap at **every position** — 14 rosters × 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**: ``` min (SHIPPED) AGREES EVERYWHERE (survivor) max differs on 42 run(s), first: sign/n2p0 first differs on 30 run(s), first: sign/n2p1 last differs on 30 run(s), first: sign/n2p0 min(caps[:1]) differs on 30 run(s), first: sign/n2p1 min(caps[-1:]) differs on 30 run(s), first: sign/n2p0 min(caps[:2]) differs on 18 run(s), first: sign/n3p2 min(caps[:3]) differs on 9 run(s), first: sign/n4p3 min(caps[1:]) differs on 12 run(s), first: sign/n2p0 min(caps[:-1]) differs on 12 run(s), first: sign/n2p1 min(caps[::2]) differs on 18 run(s), first: sign/n2p1 mean differs on 42 run(s), first: sign/n2p0 ``` A member no roster kills is **printed as a named survivor** and takes the check red. At a roster of one every candidate that still has a member coincides — which is why no single-inverter fixture could ever have caught any of this. ### 6. Mutation proof Each: bytecode purged and **counted 0 before and 0 after**, the edit **re-read from disk** (new text present, 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 -> 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** — so the blocker is closed by a fixture and not only by a shape. **B7 is a deliberate no-op control**: `max(0.0, weakest)` **is** `weakest` for `weakest > 0`, and it stays green — the battery is not merely red for everything (habit 7, an accepted baseline with one thing varied). **R3a is a ZERO-byte delta**, so the shipped function was called both ways too: ``` packs floors [15.0, 30.0, 20.0], peak_soc_floor 15 SHIPPED binding_soc_floor(...) = 30.0 the 0-byte mutant (max -> min) would be = 15.0 they DIFFER (+15.0 pp) -> the site would discharge to 15 % while pack-b's own BMS cuts out at 30 %. ``` ### 7. The pin's own non-vacuity ``` per-site counterfactual: 183 sites x 4 cuts ([:1], [-1:], [:2], [0]) = 732 mutated spellings, 0 of them NOT reported validate.py:778 `caps_ix` -> `caps_ix[:1]`: reported at that line YES (1 sliced site) validate.py:779 `caps` -> `caps[:1]` : reported at that line YES (1 sliced site) validate.py:783 `caps_ix` -> `caps_ix[:1]`: reported at that line YES (1 sliced site) validate.py:836 `caps_ix` -> `caps_ix[:1]`: reported at that line YES (1 sliced site) validate.py:920 `caps_ix` -> `caps_ix[:1]`: reported at that line YES (1 sliced site) ``` The first is the predicate; the second re-runs the **whole** analysis over mutated **source text** at a real byte offset, so the instrument is proved wired to the files and not only to itself. The same check asserts the predicate **accepts** `enumerate(…)`, `zip(…)`, `sorted(…)` and `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. ### 8. ⚠️ WHAT THE PIN CANNOT SEE — identity vs semantics, stated so the next reader knows the shape of the exposure 1. **Semantics.** `min` → `max` → the mean are **zero-byte** edits and are invisible to any shape pin, permanently. Only the behavioural sets can see them, and only for the rules they drive. 2. **Direct index sites that are not reductions** — `self.inverters[0]` in `_only()`/`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]` 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 receiver, is lost at that hop — and everything downstream goes **unenumerated rather than reported unreachable**. Not zero, and not measurable from inside the check. 7. **`cycle()`'s undeliverable-unit log line** is still green at +4 B, unchanged from round 4 and still filed open (row 22 of §4c). 8. **The 38 behaviourally-ungraded sites of §3.** ### 9. ⚠️ Production is DEGENERATE on the floor axis — do not "simplify" the fixtures 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 collapsing them: anyone who makes the fixtures match reality deletes the only data this repository has that can see the reduction at all, and part 12c2's precondition check goes red if they do. Restated at the line in `config.yaml`'s example, in the probe, and in the new `test_validate.py` section. ⚠️ **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. ### 10. Rebase and the probe Rebased onto `67e649a`. Two conflicts with SPIKE-04a, both inside blocks this card had rewritten (`config.yaml`'s roster example and the probe's §C/§D). Resolved to carry **both** claims, and the probe was **re-run rather than hand-merged** — MUTATION.md habit 8, a transcript stitched out of two runs is not a measurement: ``` == all cases passed == ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0 D baseline: [{"name": "inverter-b", ..., "max_w": 4600, "bms_soc_floor": 10}] ``` ### 11. 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. No red was observed at any point, so no red had to be believed or disbelieved. ⚠️ One transparency note on the sweep: it ran against a frozen snapshot of this tree. The snapshot differs from the pushed commit in exactly one place — the comment block inside part 12j that **records the sweep's own result**, which did not exist while the sweep was running. `diff -r` over `app/` is empty and all 19 test files are byte-identical apart from that comment. Not merged, as instructed.
admin merged commit 3993f9f32b into release/1.0 2026-09-05 08:10:51 +02:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: admin/goodwe-addon#48