TEST-11: pin the clamp guard to the write it reads
checks / unit (pull_request) Successful in 3m40s

check_output_clamp() sits one line below `await self.write_setpoint(self.target)`
in cycle(), and moving it above the write left test_clamp.py, test_freeze.py and
test_loop.py ALL GREEN. Measured on the move itself before this commit; measured
again after, where test_clamp.py part 6 goes red and the other two are still
green - the gap was real and it was only ever in this file.

WHAT IS ACTUALLY LOST. The guard has two evidence sources. `self.loop_clamped`
is set from the Decision well above the write and survives the move, and on the
10.3 runaway replay it is the source of every engagement the alarm reports (0
write-path engagements against 2 loop-side ones) - which is exactly what makes
the move nearly invisible. `self.clamp_last` is assigned by write_setpoint() and
by nothing else, is None until it has run, and is what `engaged_write` reads at
main.py:856. Moved above the write, that consumer goes quietly false and a
DEVICE-bound clamp - max_w above the inverter's own rating, SAFETY-20's install -
is reported by nothing. A guard whose second evidence source can be silently
disconnected is one refactor from being half a guard.

WHAT THE CHECK PINS, AND WHAT IT DELIBERATELY DOES NOT. Not the position: the
comment at that line warns against tidying SAFETY-03's ordering and SAFETY-18's
into one rule, and a check a rename breaks is worse than none. It pins the
CONSEQUENCE - what `self.clamp_last` held each time the guard consulted the
watchdog - using a spy subclassed from the shipped ClampWatchdog over a
slew-limited ramp where every cycle's command is a different number, so "this
cycle's evidence" and "last cycle's" are distinguishable at all. The spy hooks
`clamp_wd.note()`, the consumer, rather than the guard's name: that is what
keeps a rename legitimate. The same assertions are re-run through a controller
whose guard is renamed and whose write lives in a helper, and were verified
against main.py itself - renaming the method throughout AND moving both calls
into a new `_emit_and_guard()` helper keeps part 6 green; swapping the two calls
turns it red on a line that names clamp_last.

WHY AC 3 NEEDED A NEW STIMULUS, AND WHY IT IS NOT DRIVEN THROUGH cycle().
Through the real cycle() the write-path half cannot engage: the arbiter files
the device range and max_w as P_SAFETY LIMIT claims, so resolution.target_w is
already inside both bounds before write_setpoint() re-applies them. That
duplication is deliberate defence in depth; part 6 now MEASURES the zero
engagements rather than leaving it as a claim in a comment, and then drives the
write path directly at max_w 5000 against a +-2500 W device - the wire carries
2500 W, every loop-side flag stays clean, and the alarm fires on clamp_last
alone. With the two calls swapped the same stimulus clamps the same value and
the alarm is silent, which is the ticket in one pair of runs.

main.py gains one comment sentence naming the check, mirroring the one
SAFETY-18's block already carries for test_freeze.py part 5. No behaviour
changes.

Full suite: 17/17 green in goodwe-addon-ci.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude
2026-09-01 07:31:26 +02:00
parent e8fb596b99
commit b68f288fef
3 changed files with 284 additions and 1 deletions
+35
View File
@@ -2,6 +2,41 @@
## Unreleased
**`check_output_clamp()` could be moved above `write_setpoint()` in `cycle()`
with test_clamp.py, test_freeze.py and test_loop.py all staying GREEN — the
guard's write-path evidence, `self.clamp_last`, had nothing watching it.**
Measured before the fix, on the move itself: all three suites green. The guard
has two evidence sources and only one of them was covered. `self.loop_clamped`
is set from the `Decision` well above the write, so it survives the move — and
on the §10.3 runaway replay it is the source of every engagement the alarm
reports (0 write-path engagements against 2 loop-side ones), which is precisely
what made the move nearly invisible. `self.clamp_last` is assigned by
`write_setpoint()` and by nothing else (`main.py`), is `None` until it has run,
and is what `engaged_write` reads; moved above the write, that consumer goes
quietly false and a DEVICE-bound clamp — `max_w` above the inverter's own
rating, SAFETY-20's install — stops being reported by anything.
test_clamp.py gains part 6, and it grades the CONSEQUENCE rather than the
position: a spy subclassed from the shipped `ClampWatchdog` records what
`self.clamp_last` held each time the guard consulted it, over a slew-limited
ramp where every cycle's command is a different number, and asserts the
evidence is always the write of THAT cycle. Hooking `clamp_wd.note()` rather
than the guard's name is what keeps a rename legitimate; the same assertions
are re-run through a controller whose guard is renamed and whose write lives in
a helper, and were verified against `main.py` itself — renaming the method
throughout and moving both calls into a new `_emit_and_guard()` helper keeps
part 6 green, while swapping the two calls turns it red on a line that names
`clamp_last`. AC 3 needed a stimulus of its own: through the real `cycle()` the
write-path half can never engage, because the arbiter files the device range
and `max_w` as P_SAFETY LIMIT claims and `resolution.target_w` is already
inside both bounds before `write_setpoint()` re-applies them — that duplication
is deliberate defence in depth, and part 6 now measures the 0 engagements
rather than leaving it as a claim in a comment. So the write path is driven
directly at `max_w` 5000 against a ±2500 W device: the wire carries 2500 W,
every loop-side flag stays clean, and the alarm fires on `clamp_last` alone —
with the two calls swapped, the same stimulus clamps the same value and the
alarm is silent. `main.py` gains one comment sentence pointing at the check,
mirroring the one SAFETY-18's block already carries for test_freeze.py part 5.
**`max_w` and the `LIVE_MAX_W` literal beside it were unpinned in test_freeze.py
and test_tariff.py, the same gap TEST-06 closed one key over for
`meter_freeze_s` (TEST-10).** Measured before the fix: `max_w: 2000 -> 4500`