TEST-11: pin the clamp guard to the write it reads #36

Merged
admin merged 1 commits from TEST-11 into release/1.0 2026-09-01 07:45:16 +02:00
1 Commits
Author SHA1 Message Date
Claude b68f288fef 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>
2026-09-01 07:31:26 +02:00