SAFETY-25: say what peak_soc_floor bounds, and measure what it does not #31

Merged
admin merged 3 commits from SAFETY-25 into release/1.0 2026-09-01 05:23:38 +02:00
Owner

The defect

peak_soc_floor reads like a pack-wide reserve and is not one. The grid-following (self-consumption) loop has no SoC guard of any kind, so on a sustained-load evening it discharges straight past that number and keeps going until the pack's own BMS cut-out stops it.

The gap has been written down four times without being filed — SAFETY-18's finding (freeze.py:18), SAFETY-24's module note, its PR body, and its honestly-uncaught 13th mutation — and never once measured.

The evidence (AC 4)

Measured on the committed scenarios/p1-net-2026-08-20.jsonl.gz, driving the shipped control.compute(), peakguard.decide(), peakguard.claim() and the real arbiter.resolve() in main.py's order. test_peakguard.py part 10.

A sustained-load evening — 19:00–22:00 UTC, 830 W mean, minimum 701 W, not one watt of export in the window. Reference pack (300 Ah × 51.2 V × DoD 90) started at 21 %, one point above the shipped floor:

pack at the start 21.0 %
pack three hours later 10.0 % — bms_soc_floor
energy the loop delivered 1525 Wh
cycles below peak_soc_floor 549 of 587, 343 still discharging
cycles on which the guard filed any floor 0

It is a behaviour, not a config value. Same evening, peak_soc_floor swept over the whole schema range:

peak_soc_floor  5 %  ->  ends at 9.9715 %, 1524.58 Wh out
peak_soc_floor 11 %  ->  ends at 9.9715 %, 1524.58 Wh out
peak_soc_floor 20 %  ->  ends at 9.9715 %, 1524.58 Wh out
peak_soc_floor 60 %  ->  ends at 9.9715 %, 1524.58 Wh out

Identical to four decimal places. The option an operator would reach for cannot move the number by any amount at all. (The sweep deliberately overruns what validate.py rule 8 would accept, so "a legal value would have worked" is closed off; bms_soc_floor + 1, the first startable floor, is in it too.)

And the two states coexist in the same cycle. On the same day's high-load block (15:00–17:00 UTC) with the pack below the floor, the guard reports PEAK GUARD FLOOR-LIMITED — "commanding no forced discharge" — on 49 cycles, and on all 49 the arbiter simultaneously grants the loop up to 2000 W of discharge.

Stated fairly: this is not a runaway. Self-consumption is demand-driven and self-limiting, and every watt of it serves load that would otherwise have been imported. What it means is that the depth the pack reaches on a heavy evening is set by the house, not by anything in config.yaml, and peak_soc_floor names a guarantee it does not give.

Why SAFETY-24 could not close it

The peak guard files a lo limit — a lower bound. That asymmetry is what makes it trustworthy (a reviewer proved across 19,800 combinations that it can never lower a target, cap a discharge or contradict the arbiter), and it is what makes it structurally incapable of stopping a discharge the loop itself wants. A floor on the guard cannot become a floor on the pack by being raised; the sweep above is that sentence measured.

What I chose, and why

AC 3 — done in full: make the scope honest on the four surfaces an operator reads

Required whichever way the open question goes, and it is the actual misstatement on the live system today.

  • config.yaml, beside the option — it bounds the peak guard only and is not a pack-wide reserve, with the replayed evening and the sweep.
  • DOCS.md — the options-table row, and a new section carrying the figures above.
  • The status-page row for a floor-limited guard. This is the one line a human reads at the moment the guard stops discharging, and read alone it says "the battery is being held at peak_soc_floor" — which the replay shows is false in every cycle that row is rendered.
  • The persistent notification, which outlives the page. It now names what has stopped (the guard buying a lower bill) and what has not (the pack being spent).

peakguard.py and freeze.py carry the measurement beside the findings they already recorded.

AC 1 — the memo, committed, with a recommendation

goodwe_controller/DECISIONS/SAFETY-25-loop-soc-floor.md. Options A–D, what each costs and protects, weighed against MAINT-01 (a pack repeatedly at the BMS floor is the SoC-drift condition the maintenance cycle exists to correct, and an over-tight reserve fights it from the other side) and against the four-floor naming question. It recommends Option B after SAFETY-27's naming decision and after MAINT-01 has run one real cycle — and it says plainly it is a memo, not a decision.

A trap found while writing it, now checked through the real arbiter rather than described (part 10d): the obvious shape of a loop-side floor is a hi limit at P_LOOP. Filed while the peak guard is asking for a positive floor, that is lo > hi, which arbiter.py resolves to a CONTRADICTION at 0 W — the battery parked, in the one quarter of the month that sets the bill. Same crossing-limits hazard shaping_suspended() exists to avoid. The ordering bms_soc_floor < loop_soc_floor <= peak_soc_floor removes it provably: wherever the reserve binds the guard is already floor-limited and files lo = 0, so lo == hi == 0. Both halves are run.

AC 2 — left explicitly OPEN. No new SoC floor is built here.

Whether the loop gets a reserve decides whether this house's battery may discharge to the BMS floor on a heavy evening. That moves real energy and it is the product owner's call, not a developer's.

The test is a tripwire, not a guarantee

test_peakguard.py part 10 records a defect. It goes red the day somebody builds a loop-side floor, so the exposure cannot change size under a green suite and closing it has to be deliberate. Part 11's mutation 13 stays honestly NOT CAUGHT — nothing prevents the loop discharging past this floor — but it now reports a number instead of a sentence.

Verification

16/16 suites green in the goodwe-addon-ci image (test_peakguard.py 44 s → 48 s).

Eight mutations, all red on the named check. Each was run against a tree copied fresh with __pycache__ excluded and mounted :ro, so no .pyc existed to go stale — the structural immunity MUTATION.md records for CI. Byte deltas +61, -8, -465, -430, -1088, -46, (file deleted), -95; none is +0 B.

mutation result
M1 peakguard.claim() also files hi=floor_w 7 red, incl. 10c
M2 control.compute() gain forced to 0 6 red, incl. 10a/10b
M3 scope sentence removed from the status row 10e red
M4 scope sentence removed from the notification 10e red
M5 scope paragraph removed from config.yaml 10e red
M6 scope bullet weakened in DOCS.md 10e red
M7 the decision memo deleted 10e red
M8 arbiter clamps crossing limits instead of contradicting 5 red, incl. 10d

What I am unsure about, and where to look hardest

  1. The pack and the SoC trajectory are MODELLED. The captures carry meter power only — there is no battery or SoC telemetry in any committed fixture — so the starting state of charge is a fixture parameter, not a measurement. It is derived (one point above the shipped peak_soc_floor) rather than picked, and everything downstream of it is the real capture and the real code, but the fixtures cannot express a low pack on their own and I am saying so rather than implying otherwise. Memo §7 states the whole model.
  2. The 15 s replay tick is cycle_floor_s, not the meter's ~5 s rate. The real loop runs a cycle on a changed meter value or on cycle_floor_s, whichever comes first (SAFETY-12), so the modelled loop actuates less often than the real one. That makes the replay conservative — a faster loop empties the pack sooner — but it is a difference from the shipped cycle rate and a reviewer should decide whether it matters.
  3. HOT_R["limited_discharging"] == HOT_R["limited"] is an equality on measured data (49 == 49). It is the sharpest true statement of the finding and it is deterministic, but it is tighter than it strictly needs to be; a future change that shifts one cycle would take it red for a reason that is not a regression.
  4. Part 10 adds ~4 s to test_peakguard.py. Six replays over ~2000 and ~1400 telegrams. I judged that worth it for AC 4; someone who disagrees would cut the sweep to two points.
  5. The memo's Option B ordering rule is analysis, not shipped code. 10d checks the arbiter behaviour it rests on — both the contradiction and its removal — but nobody has built the option, and I have not tried to.
  6. DECISIONS/ is a new directory. I made it a directory rather than a single file so SAFETY-27's recorded decision has somewhere to land beside this one, per the PM note that the two must not be solved twice in different shapes. Say if you would rather it were one file.
  7. I did not touch arbitrage_soc_floor's documentation or the rule-8 twin — SAFETY-27 owns both, and solving them here is exactly the duplication that note warns about.

Closes SAFETY-25 AC 1, AC 3 and AC 4. AC 2 remains open pending the product owner's answer.

## The defect **`peak_soc_floor` reads like a pack-wide reserve and is not one.** The grid-following (self-consumption) loop has **no SoC guard of any kind**, so on a sustained-load evening it discharges straight past that number and keeps going until the pack's own BMS cut-out stops it. The gap has been written down **four** times without being filed — SAFETY-18's finding (`freeze.py:18`), SAFETY-24's module note, its PR body, and its honestly-uncaught 13th mutation — and never once measured. ## The evidence (AC 4) Measured on the committed `scenarios/p1-net-2026-08-20.jsonl.gz`, driving the **shipped** `control.compute()`, `peakguard.decide()`, `peakguard.claim()` and the **real** `arbiter.resolve()` in `main.py`'s order. `test_peakguard.py` part 10. **A sustained-load evening — 19:00–22:00 UTC, 830 W mean, minimum 701 W, not one watt of export in the window.** Reference pack (300 Ah × 51.2 V × DoD 90) started at **21 %**, one point above the shipped floor: | | | |---|---| | pack at the start | **21.0 %** | | pack three hours later | **10.0 %** — `bms_soc_floor` | | energy the **loop** delivered | **1525 Wh** | | cycles below `peak_soc_floor` | **549 of 587**, **343** still discharging | | cycles on which the guard filed *any* floor | **0** | **It is a behaviour, not a config value.** Same evening, `peak_soc_floor` swept over the whole schema range: ``` peak_soc_floor 5 % -> ends at 9.9715 %, 1524.58 Wh out peak_soc_floor 11 % -> ends at 9.9715 %, 1524.58 Wh out peak_soc_floor 20 % -> ends at 9.9715 %, 1524.58 Wh out peak_soc_floor 60 % -> ends at 9.9715 %, 1524.58 Wh out ``` Identical to four decimal places. The option an operator would reach for cannot move the number by any amount at all. (The sweep deliberately overruns what `validate.py` rule 8 would accept, so "a legal value would have worked" is closed off; `bms_soc_floor + 1`, the first startable floor, is in it too.) **And the two states coexist in the same cycle.** On the same day's high-load block (15:00–17:00 UTC) with the pack below the floor, the guard reports `PEAK GUARD FLOOR-LIMITED` — *"commanding no forced discharge"* — on **49 cycles**, and on **all 49** the arbiter simultaneously grants the loop up to **2000 W** of discharge. **Stated fairly:** this is *not* a runaway. Self-consumption is demand-driven and self-limiting, and every watt of it serves load that would otherwise have been imported. What it means is that the depth the pack reaches on a heavy evening is set by the house, not by anything in `config.yaml`, and `peak_soc_floor` names a guarantee it does not give. ## Why SAFETY-24 could not close it The peak guard files a `lo` limit — a **lower** bound. That asymmetry is what makes it trustworthy (a reviewer proved across 19,800 combinations that it can never lower a target, cap a discharge or contradict the arbiter), and it is what makes it **structurally incapable** of stopping a discharge the loop itself wants. A floor on the guard cannot become a floor on the pack by being raised; the sweep above is that sentence measured. ## What I chose, and why ### AC 3 — done in full: make the scope honest on the four surfaces an operator reads Required whichever way the open question goes, and it is the actual misstatement on the live system today. - **`config.yaml`**, beside the option — it *bounds the peak guard only* and is *not a pack-wide reserve*, with the replayed evening and the sweep. - **`DOCS.md`** — the options-table row, and a new section carrying the figures above. - **The status-page row** for a floor-limited guard. This is the one line a human reads *at the moment the guard stops discharging*, and read alone it says "the battery is being held at `peak_soc_floor`" — which the replay shows is false in every cycle that row is rendered. - **The persistent notification**, which outlives the page. It now names what has stopped (the guard buying a lower bill) and what has not (the pack being spent). `peakguard.py` and `freeze.py` carry the measurement beside the findings they already recorded. ### AC 1 — the memo, committed, with a recommendation `goodwe_controller/DECISIONS/SAFETY-25-loop-soc-floor.md`. Options A–D, what each costs and protects, weighed against **MAINT-01** (a pack repeatedly at the BMS floor is the SoC-drift condition the maintenance cycle exists to correct, and an over-tight reserve fights it from the other side) and against the four-floor naming question. It recommends Option B *after* SAFETY-27's naming decision and after MAINT-01 has run one real cycle — and it says plainly it is a memo, not a decision. **A trap found while writing it, now checked through the real arbiter rather than described** (part 10d): the obvious shape of a loop-side floor is a `hi` limit at `P_LOOP`. Filed while the peak guard is asking for a positive floor, that is `lo > hi`, which `arbiter.py` resolves to a **CONTRADICTION at 0 W** — the battery **parked**, in the one quarter of the month that sets the bill. Same crossing-limits hazard `shaping_suspended()` exists to avoid. The ordering `bms_soc_floor < loop_soc_floor <= peak_soc_floor` removes it provably: wherever the reserve binds the guard is already floor-limited and files `lo = 0`, so `lo == hi == 0`. Both halves are run. ### AC 2 — **left explicitly OPEN. No new SoC floor is built here.** Whether the loop gets a reserve decides whether this house's battery may discharge to the BMS floor on a heavy evening. That moves real energy and it is the product owner's call, not a developer's. ## The test is a tripwire, not a guarantee `test_peakguard.py` **part 10** records a **defect**. It goes **red** the day somebody builds a loop-side floor, so the exposure cannot change size under a green suite and closing it has to be deliberate. Part 11's mutation 13 stays honestly **NOT CAUGHT** — nothing prevents the loop discharging past this floor — but it now reports a number instead of a sentence. ## Verification **16/16 suites green** in the `goodwe-addon-ci` image (`test_peakguard.py` 44 s → 48 s). **Eight mutations, all red on the named check.** Each was run against a tree copied fresh with `__pycache__` excluded and mounted `:ro`, so no `.pyc` existed to go stale — the structural immunity `MUTATION.md` records for CI. Byte deltas `+61, -8, -465, -430, -1088, -46, (file deleted), -95`; **none is `+0 B`**. | mutation | result | |---|---| | M1 `peakguard.claim()` also files `hi=floor_w` | 7 red, incl. 10c | | M2 `control.compute()` gain forced to 0 | 6 red, incl. 10a/10b | | M3 scope sentence removed from the status row | 10e red | | M4 scope sentence removed from the notification | 10e red | | M5 scope paragraph removed from `config.yaml` | 10e red | | M6 scope bullet weakened in `DOCS.md` | 10e red | | M7 the decision memo deleted | 10e red | | M8 arbiter clamps crossing limits instead of contradicting | 5 red, incl. 10d | ## What I am unsure about, and where to look hardest 1. **The pack and the SoC trajectory are MODELLED.** The captures carry meter power only — there is no battery or SoC telemetry in any committed fixture — so the starting state of charge is a fixture parameter, not a measurement. It is *derived* (one point above the shipped `peak_soc_floor`) rather than picked, and everything downstream of it is the real capture and the real code, but **the fixtures cannot express a low pack on their own and I am saying so rather than implying otherwise.** Memo §7 states the whole model. 2. **The 15 s replay tick is `cycle_floor_s`, not the meter's ~5 s rate.** The real loop runs a cycle on a *changed* meter value **or** on `cycle_floor_s`, whichever comes first (SAFETY-12), so the modelled loop actuates less often than the real one. That makes the replay **conservative** — a faster loop empties the pack sooner — but it is a difference from the shipped cycle rate and a reviewer should decide whether it matters. 3. **`HOT_R["limited_discharging"] == HOT_R["limited"]` is an equality on measured data** (49 == 49). It is the sharpest true statement of the finding and it is deterministic, but it is tighter than it strictly needs to be; a future change that shifts one cycle would take it red for a reason that is not a regression. 4. **Part 10 adds ~4 s to `test_peakguard.py`.** Six replays over ~2000 and ~1400 telegrams. I judged that worth it for AC 4; someone who disagrees would cut the sweep to two points. 5. **The memo's Option B ordering rule is analysis, not shipped code.** 10d checks the arbiter behaviour it rests on — both the contradiction and its removal — but nobody has built the option, and I have not tried to. 6. **`DECISIONS/` is a new directory.** I made it a directory rather than a single file so SAFETY-27's recorded decision has somewhere to land beside this one, per the PM note that the two must not be solved twice in different shapes. Say if you would rather it were one file. 7. I did **not** touch `arbitrage_soc_floor`'s documentation or the rule-8 twin — **SAFETY-27 owns both**, and solving them here is exactly the duplication that note warns about. Closes SAFETY-25 AC 1, AC 3 and AC 4. **AC 2 remains open pending the product owner's answer.**
admin added 1 commit 2026-09-01 03:47:34 +02:00
peak_soc_floor reads like a pack-wide reserve and is not one. The
grid-following loop has no SoC guard of any kind, so on a sustained-load
evening it discharges straight past that number and keeps going until the
pack's own BMS cut-out stops it.

The gap has now been written down four times without being filed -
SAFETY-18's finding (freeze.py:18), SAFETY-24's module note, its PR body,
and its honestly-uncaught 13th mutation - and never once measured. It is
measured here, on the committed 2026-08-20 capture, driving the SHIPPED
control.compute(), peakguard.decide(), peakguard.claim() and the real
arbiter.resolve() in main.py's order:

    19:00-22:00 UTC, 830 W mean, minimum 701 W, zero export in the window
    reference pack (300 Ah x 51.2 V x DoD 90) started at 21 %, one point
    above peak_soc_floor

      ends at 10.0 %  -  the bms_soc_floor cut-out
      1525 Wh delivered by the LOOP over 587 cycles
      549 cycles below peak_soc_floor, 343 of them still discharging
      0 cycles on which the peak guard filed any floor at all

AND IT IS A BEHAVIOUR, NOT A CONFIG VALUE. The same evening ends at
9.9715 % at peak_soc_floor = 5, 11, 20 and 60 - the shipped value and both
ends of the schema range - identical to four decimal places. The option an
operator would reach for cannot move the number by any amount at all. On
the same day's high-load block the two states coexist: 49 cycles reporting
PEAK GUARD FLOOR-LIMITED and, on all 49 of them, the arbiter simultaneously
granting the loop up to 2000 W of discharge.

WHY SAFETY-24 COULD NOT CLOSE IT. The peak guard files a `lo` limit - a
LOWER bound on the setpoint. That asymmetry is exactly what makes it
trustworthy: a reviewer proved across 19,800 combinations that it can never
lower a target, cap a discharge or contradict the arbiter. The same property
makes it structurally incapable of stopping a discharge the loop itself
wants. A floor on the guard cannot become a floor on the pack by being
raised, and the sweep above is that sentence measured.

WHAT THIS COMMIT CHANGES IS THE HONESTY, on the four surfaces an operator
actually reads. That half is required whichever way the open question is
answered, and it is the real misstatement on the live system today:

  * config.yaml, beside the option itself - it BOUNDS THE PEAK GUARD ONLY
    and is NOT A PACK-WIDE RESERVE, with the replayed evening and the sweep;
  * DOCS.md, in the options table and in a new section carrying the figures;
  * the STATUS-PAGE ROW for a floor-limited guard. This is the one line a
    human reads at the moment the guard stops discharging, and read alone it
    says "the battery is being held at peak_soc_floor" - which the replay
    shows is false in every cycle that row is rendered;
  * the PERSISTENT NOTIFICATION, which outlives the page. It now names what
    has stopped (the guard buying a lower bill) and what has not (the pack
    being spent).

peakguard.py and freeze.py carry the measurement beside the findings they
already recorded, so the next reader does not have to take the caveat on
trust.

THE PRODUCT DECISION IS NOT TAKEN HERE, and must not be. Whether the loop
gets a reserve floor of its own decides whether this house's battery may
discharge to the BMS floor on a heavy evening; that moves real energy and it
is the owner's call. DECISIONS/SAFETY-25-loop-soc-floor.md is the memo AC 1
asks for: the options, what each costs, what each protects, weighed against
MAINT-01 (a pack repeatedly at the BMS floor is the SoC-drift condition the
maintenance cycle exists to correct, and an over-tight reserve fights it from
the other side) and against the fact that this would be the FIFTH SoC key -
OPT-04's arbitrage_soc_floor fired SAFETY-24's "revisit if a third floor
appears" trigger unremarked, so SAFETY-27's naming decision should land
first. It carries a recommendation and says plainly that it is not a
decision.

It also records a trap found while writing it, and now CHECKED through the
real arbiter rather than described: the obvious shape of a loop-side floor
is a `hi` limit at P_LOOP, and filed while the peak guard is asking for a
positive floor that is `lo > hi` - which arbiter.py resolves to a
CONTRADICTION at 0 W, the battery PARKED in the one quarter of the month
that sets the bill. It is the same crossing-limits hazard
shaping_suspended() already exists to avoid. The ordering
`bms_soc_floor < loop_soc_floor <= peak_soc_floor` removes it provably,
because wherever the reserve binds the guard is already floor-limited and
files lo = 0; both halves are run in part 10d.

test_peakguard.py part 10 is a TRIPWIRE, not a guarantee. It records a
defect and goes RED the day somebody builds a loop-side floor, so the
exposure cannot change size under a green suite and closing it has to be
deliberate. Part 11's mutation 13 stays honestly NOT CAUGHT - nothing
prevents the loop discharging past this floor - but it now reports a number
instead of a sentence.

Verified: 16/16 suites green in the CI image. Eight mutations, each against
a tree copied fresh with __pycache__ excluded and mounted :ro, so no .pyc
existed to go stale (MUTATION.md's structural immunity, the same one it
records for CI); byte deltas +61, -8, -465, -430, -1088, -46, file deleted,
and -95; none is +0 B. All eight go red on the named check:

  M1 peakguard.claim() also files hi=floor_w        -> 7 red, incl. 10c
  M2 control.compute() gain forced to 0             -> 6 red, incl. 10a/10b
  M3 scope sentence removed from the status row     -> 10e red
  M4 scope sentence removed from the notification   -> 10e red
  M5 scope paragraph removed from config.yaml       -> 10e red
  M6 scope bullet weakened in DOCS.md               -> 10e red
  M7 the decision memo deleted                      -> 10e red
  M8 arbiter clamps crossing limits instead of
     reporting a contradiction                      -> 5 red, incl. 10d

AC 2 is left explicitly OPEN, pending the owner's answer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
admin added 2 commits 2026-09-01 05:04:30 +02:00
CI-02 landed on release/1.0 after this branch was opened and both tickets
add a block under `## Unreleased`, so CHANGELOG.md collided. Both entries
are kept, newest-first: SAFETY-25's, then CI-02's, then SAFETY-20's.
Nothing else conflicted.
SAFETY-25: pin the claim-source set, so the tripwire is one
checks / unit (pull_request) Successful in 3m53s
e498dd4c1c
The durability claim this PR shipped was FALSE, and the reviewer of #31
disproved it by building the fix. Part 10 said, in four committed places,
that it "goes RED the day a loop-side floor is built, so the exposure
cannot change size under a green suite". The reviewer implemented the
memo's own recommended Option B in main.py - a
`Claim.limit("reserve", P_LOOP, hi=0.0)` filed under a `loop_soc_floor` -
and the ENTIRE SUITE STAYED GREEN. Part 10 green. Mutation 13 went on
printing "NOTHING PREVENTS IT, ON PURPOSE" and the same 21 % -> 10.0 %,
on a tree where something DID prevent it. Pushed to an absurd
always-binding 95 % reserve it was caught only by SAFETY-24's pre-existing
part 7 wiring checks; part 10 was still green.

THE CAUSE IS STRUCTURAL, not a missing assertion. `replay_loop()`
hand-builds main.py's claim list as a literal
`[supervised, loop, peakguard.claim(...)]`, so it is blind to any claim
main.py ADDS - which is exactly where AC 2 would be built. That is this
repository's named failure mode: a check that observed something other
than what it claimed to observe, shipped as a guarantee inside the
document that recommends the change.

THE FIX IS 10f: THE CLAIM-SOURCE SET, PINNED, two ways because the two
fail differently.

  * STATICALLY, over `Controller.cycle()`'s own AST. Every
    `claims.append(...)` in it must be one the scan can read - an
    unreadable append FAILS rather than counting as nothing, which is how
    a scan like this goes quietly vacuous - and the sources it can file
    must be exactly {device, supervised, failsafe, safety, peak,
    arbitrage, maintenance, loop}. A ninth takes part 10 red on the line
    it is written, in every configuration, whether or not any fixture
    here reaches the state that files it.
  * DYNAMICALLY, through REAL `Controller.cycle()`s with main.py's OWN
    `resolve` spied - not a list this file built and hoped matched -
    swept over the WHOLE 0..100 % SoC range in the two states 10a-10c
    replay. Idle: exactly {device, supervised, loop}. Floor-limited:
    exactly {device, supervised, loop, peak}. A claim conditioned on a
    low pack, which is the shape a loop-side floor has, cannot hide from
    a sweep that visits 0 %.

And the fold is checked rather than assumed: main.py's two P_SAFETY
rating limits are both symmetric LIMITs, so replay_loop() modelling them
as one `supervised` claim at the tighter bound is exact.

MEASURED AGAINST THE COUNTEREXAMPLE. Reproduced first on the unfixed
tree (green, as reported). On this tree the reviewer's Option B at its
recommended default takes part 10 RED on 3 checks, and the 95 % variant
on 6. Nine mutations were run in all, each against a freshly copied tree
with no `__pycache__` present, mounted `:ro`; all nine red, no survivors,
no `+0 B` delta. MUTATION.md records why a second round was needed: the
first round's mutations were all aimed at DISARMING the guard, while the
claim part 10 actually made was about the guard being FIXED.

The four false sentences are replaced with what is now true, and each
says WHICH half catches WHICH shape - 10f for an arbiter claim
(Options B and C), 10a-10c for a clamp inside control.compute() or the
guard (Option D), which the replay drives directly.

AC 2 IS STILL OPEN AND NOTHING HERE BUILDS IT. The loop-side floor was a
test mutant, reverted before commit. The memo still says "Status: OPEN.
This is a memo, not a decision."

Also, non-blocking, from the same review: the NOT READY banner had
inherited the new scope sentence and become a ~550-character paragraph
that the row three lines below repeated verbatim. A checks() row may now
carry an optional short `banner` form; the floor-limited row does, and
`status_payload()` prefers it. THE ROW IS UNCHANGED - shortening that
would have been weakening AC 3 - and rows without the key are
unaffected. Graded, and mutated: dropping the short form goes red.

Merged origin/release/1.0 first (CI-02), keeping both tickets' CHANGELOG
entries.

Full suite green: 17/17 in goodwe-addon-ci (test_ci_gate.py needs `git`,
which CI-02 added to .gitea/ci/Dockerfile but the local image lacks; run
with it installed and it passes).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
admin merged commit b7267eb51a into release/1.0 2026-09-01 05:23:38 +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#31