SAFETY-25: pin the claim-source set, so the tripwire is one
checks / unit (pull_request) Successful in 3m53s

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>
This commit is contained in:
glenn schrooyen
2026-09-01 05:04:22 +02:00
co-authored by Claude Opus 5
parent 3cb9d3b7d9
commit e498dd4c1c
5 changed files with 329 additions and 15 deletions
+37 -6
View File
@@ -62,12 +62,33 @@ shape — a `hi` limit on the loop — crosses the peak guard's `lo` and resolve
**CONTRADICTION at 0 W**, the battery parked in the quarter that sets the bill,
unless `bms_soc_floor < loop_soc_floor <= peak_soc_floor` is enforced at startup.
**`test_peakguard.py` part 10 is a tripwire, not a guarantee.** It records the
defect and goes **red** the day a loop-side floor is built, so the exposure cannot
change size under a green suite. Eight mutations were run against it, each
against a freshly copied tree with no `__pycache__` present at all, mounted
`:ro` — the same structural immunity `MUTATION.md` records for CI. All eight go
red on the named check.
**`test_peakguard.py` part 10 is a tripwire, not a guarantee, and part 10f is
what makes it one.** The replay half records the defect; on its own it could not
see the defect being FIXED, because `replay_loop()` hand-builds main.py's claim
list and is therefore blind to any claim main.py adds — which is exactly where a
loop-side floor would be built. PR #31's reviewer proved that by building the
memo's own Option B in `main.py` and watching the whole suite stay green. **10f
pins the claim-source set:** every `claims.append(...)` in `Controller.cycle()`
is read off its AST and must be one of eight named sources, and real
`Controller.cycle()`s — spied at main.py's own `resolve`, swept over the whole
0–100 % state-of-charge range in both the idle and the floor-limited state — must
hand the arbiter exactly `{device, supervised, loop}` and
`{device, supervised, loop, peak}`. A ninth source, at any state of charge, in
any configuration, takes part 10 red on the line it is written. A floor built
inside `control.compute()` or the guard instead moves 10a–10c's replayed numbers,
which the replay drives directly. So the exposure cannot change size under a
green suite.
**Nine mutations, re-run against the fixed tree**, each against a freshly copied
tree with no `__pycache__` present at all, mounted `:ro` — the same structural
immunity `MUTATION.md` records for CI. Six are the AC 3 and behaviour mutations
already on this ticket's record (the status row's scope sentence, the
notification's, `config.yaml`'s, the memo deleted, the loop's gain zeroed, the
guard's claim given a `hi`); two are the reviewer's, and are the ones that
motivated 10f — the memo's own Option B at its recommended default, **now three
checks red where it was fully green**, and an absurd always-binding 95 % reserve,
**six red**; one is new, against the banner short form below. All nine are red,
no survivors, and no byte delta is `+0 B`.
**The gate has never once graded the content a pull request would land, and a
run that never started reported as a test failure (CI-02).** Both halves were
@@ -134,6 +155,16 @@ carries `pending / Waiting to run` within seconds, and a concurrency-held one
API. `/actions/tasks`, which is what was polled originally, lists only tasks
that started, and cannot answer the question at all.
**The NOT READY banner no longer repeats the row underneath it.**
`status_payload()` renders the first hard-failing check as the page headline, so
the scope sentence above turned the loudest element on the page into a
550-character paragraph that the checks list then repeated verbatim. A row may
now carry an optional short `banner` form; the floor-limited row does, and its
headline is the guard's own line plus a 68-character scope tag. **The row itself
is unchanged** — the checks list is where an operator reads what the floor does
and does not bound, and shortening that would have been weakening AC 3. Rows
without the key are unaffected, and the page still renders with `textContent`.
**A setpoint device smaller than `max_w` silently disarmed the frozen-meter
detector, and the status page reported it as armed (SAFETY-20).** SAFETY-18's
conjunction needs `abs(commanded_w) >= 0.95 * max_w`, but the emitted command is