SAFETY-41: raise the clamp alarm's re-assert 30 s -> 150 s #52

Merged
admin merged 4 commits from SAFETY-41-reassert into release/1.0 2026-09-05 18:31:31 +02:00
2 changed files with 59 additions and 19 deletions
Showing only changes of commit 1d393ced22 - Show all commits
+44 -19
View File
@@ -149,27 +149,52 @@ REASON_CODE = "OUTPUT_CLAMP"
#
# ⚠️ RAISED 30 s -> 150 s on 2026-09-05, and the reason is a REGIME CHANGE, not a
# change of mind about the alarm. 30 s was the planning cadence (2.3), chosen
# when a loop-side engagement was a rare event: this module's own note records
# "60 cycles of the §10.3 runaway produce 2 loop-side engagements". On the live
# install it produced 48 IN 25 MINUTES of ordinary operation, because `max_w` is
# 5000 W where the loop's tuning was derived at a 1500 W clamp (SAFETY-39) - so
# the integrator reaches its bound constantly and the notification re-asserted
# every 30 s, all day. An alarm that speaks that often about the normal case is
# how an operator learns to ignore the one that matters, which is the exact
# failure this module's docstring is written against.
# when a loop-side engagement was a rare event: `main.py`'s note at the action
# log records "60 cycles of the §10.3 runaway produce 0 write-path engagements
# and 2 loop-side ones". On the live install the counter reached 48 loop-side
# ENGAGEMENTS in ~25 minutes of ordinary operation - roughly one cycle in six,
# not a pinned rail, but far more often than a guard sized for 2-in-60 assumed -
# and at a 30 s re-assert the owner received a persistent notification about it
# all day.
#
# ⚠️ 150 s IS NOT A COMFORT CHOICE, IT IS THE REQUIREMENT'S OWN EDGE, WITH ONE
# STEP OF MARGIN. test_clamp.py part 2 pins the cadence in WALL-CLOCK terms
# rather than against this constant - a loop pinned on the rail must speak at
# least MIN_PER_QUARTER = 5 times inside the 900 s quarter that bills the
# capacity tariff. That caps this constant at 900/5 = 180 s. At 180 s the check
# passes with exactly 5 of a required 5, so any later change to the firing logic
# tips it red; 150 s gives 6. The floor is the thing that must not move.
# ⚠️ THE FIGURE ABOVE IS AN ENGAGEMENT COUNT READ OFF THE LIVE LOG, NOT A
# MEASUREMENT IN THIS REPO, and it is not the alarm count. Do not compare it
# with the 2-in-60 above without noting that one is engagements and the other is
# engagements too, but over a runaway rather than ordinary running.
#
# ⚠️ THIS IS A STOP-GAP AND SHOULD BE REVISITED. It reduces how often the alarm
# repeats; it does not make the alarm correct. SAFETY-40 is the wording and the
# i-clamped/clamped split, and SAFETY-39 is the tuning that would stop the loop
# railing in the first place. Neither is done by this line.
# WHY the loop reaches its integrator bound so much more often than the guard's
# author saw: `max_w` on the live install is 5000 W, while the loop's slew was
# derived against a 1500 W clamp - admin/goodwe `NOTES.md` §"Simulated 2 kW load
# step (gain 0.6, clamp 1500)". ⚠️ THAT DERIVATION IS IN THE OTHER REPO; nothing
# in this one corroborates it, so do not go looking here. SAFETY-39 is the ticket
# that re-derives it, and fixing that is what would stop the railing; this
# constant only changes how often the alarm repeats.
#
# ⚠️ 150 s IS THE REQUIREMENT'S EDGE WITH MARGIN, AND THE CHECK NOW ENFORCES THAT
# EDGE. `test_clamp.py` part 2 grades the cadence in wall-clock terms - a pinned
# loop must speak at least MIN_PER_QUARTER = 5 times inside the 900 s quarter
# that bills the capacity tariff - which caps this constant at 900/5 = 180 s.
# Measured on the worst sliding 900 s window: 180 s earns 5 alarms, 181 s earns
# 4. 150 s earns 6.
#
# ⚠️ THAT CHECK USED TO PERMIT 224 s, AND THE FIX SHIPPED WITH THIS COMMIT. Its
# sweep starts at i=0, where the rising edge is free, so it measured one alarm
# more than a real quarter earns. A later editor raising this to 200 s would have
# had a green suite and a requirement quietly unmet. `test_clamp.py` now grades
# floor(QUARTER_S / REASSERT_S) directly.
#
# ⚠️ MIN_PER_QUARTER = 5 IS A TEST-LOCAL INTEGER, not a spec figure - it exists
# only in `test_clamp.py` and the CHANGELOG entry that introduced it. The 900 s
# quarter IS system-wide (`tariff.py`, `freeze.py`, `DOCS.md`). So the 180 s
# ceiling rests on one number a test author chose; if that number is ever
# revisited, this constant moves with it.
#
# ⚠️ THIS IS A STOP-GAP AND DOES NOT MAKE THE ALARM CORRECT. The message still
# calls the integrator bound a BUG SIGNAL and still pairs a request from one
# moment with a `self.target` read at another - SAFETY-40. Nothing here is
# suppressed: every engagement is still counted, the action-log row is written on
# every engaged cycle regardless, and the rising edge of every episode alarms
# unconditionally, so no alarm is DROPPED - only delayed.
REASSERT_S = 150.0
# Consecutive clean cycles before the alarm de-asserts (10.2 hysteresis).
+15
View File
@@ -239,6 +239,21 @@ print(f" pinned on the rail for {QUARTER_S:.0f} s at 1 s cycles: "
check(f"a loop pinned for a whole billing quarter speaks at least "
f"{MIN_PER_QUARTER} times (it spoke {alarms})",
alarms >= MIN_PER_QUARTER)
# ⚠️ REVIEW-FOUND, SAFETY-41, and it is the SAME DEFECT as the one recorded
# twenty lines above, one notch weaker. The sweep above runs i = 0..899 and the
# rising edge at i=0 is FREE, so it measures `1 + floor(899 / REASSERT_S)` - one
# alarm more than a real quarter earns. Measured on this very check:
# REASSERT_S = 180 -> 5 alarms, green (and 180 is the true edge: on the worst
# REASSERT_S = 224 -> 5 alarms, GREEN sliding 900 s window 180 gives 5 and
# REASSERT_S = 225 -> 4 alarms, red 181 gives 4)
# So the check as written permits 224 s while the REQUIREMENT permits 180 s, and
# anyone raising the constant to 200 s would get a green suite and conclude the
# floor was met when a real quarter would earn 4 alarms. Grade the cadence
# DIRECTLY, so the check enforces the ceiling its own comment claims for it.
check(f"...and the CADENCE itself honours the floor, independent of where the "
f"sweep happens to start: floor({QUARTER_S:.0f}/{REASSERT_S:.0f}) = "
f"{int(QUARTER_S // REASSERT_S)} >= {MIN_PER_QUARTER}",
QUARTER_S // REASSERT_S >= MIN_PER_QUARTER)
check(f"...and on no more than {MAX_FRACTION:.0%} of cycles (it was "
f"{alarms / QUARTER_S:.1%})", alarms <= MAX_FRACTION * QUARTER_S)
check("...while every single cycle is still counted",