SAFETY-121: a recurring overload warns every quarter
checks / unit (pull_request) Successful in 44m51s
infra-pin / fresh (pull_request) Successful in 1s

TEST-23 F-1: on a 5 s meter a steady overload warned once in 70 min. The
loop was at its bound 4-5 cycles a quarter, all inside SAFETY-119's settle
of 12 cycles (60 s) and under its budget of 8 per 900 s. The simulation
fed the meter a cycle behind the battery reading, so the guard and the
charge chased each other and gave ~20 bound cycles a quarter.

The settle is now 4 cycles and 22 s (HANDBACK_CYCLES, SHORT_SPAN_S), and
the rolling budget one number, 11 per 900 s, at every cycle length. The
cycle length is no longer tracked. Commands unchanged; the re-pinned
digests of test_safety84/87 differ from bee7431 in OUTPUT_CLAMP rows only.
test_safety119 runs the rig's shape (meter in the same cycle) at 1-15 s
and checks a stop keeps the budget (review F2, X3).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-10-21 10:09:14 +02:00
co-authored by Claude
parent bee7431793
commit 8bf25696cb
6 changed files with 291 additions and 135 deletions
+17 -27
View File
@@ -299,14 +299,14 @@ REFUSED_PROGRESS_W = _ctl.PROBE_ANSWER_W / 2
# was told over the same span as above (the plant's measured settle time), so
# a healthy battery still settling on a new command is not short.
SHORT_CYCLES, SHORT_SPAN_S = _ctl.SETTLE_CYCLES, REFUSED_SPAN_S
# SAFETY-119: across all hand-backs, the loop's bound is hidden on at most
# ROLLING_BUDGET cycles in any ROLLING_S seconds, fewer on a slower cycle:
# (cycle seconds up to, cycles), measured in
# DECISIONS/SAFETY-119-bound-after-hand-back.md. Without a monotonic clock
# the budget never refills.
# SAFETY-119/121: after a hand-back the loop's bound is hidden until
# HANDBACK_CYCLES cycles and SHORT_SPAN_S have passed; across all hand-backs
# on at most ROLLING_BOUND_CYCLES cycles in any ROLLING_S seconds. Measured in
# DECISIONS/SAFETY-121-bound-every-quarter.md. Without a monotonic clock the
# budget never refills.
HANDBACK_CYCLES = 4
ROLLING_S = 900.0
ROLLING_BUDGET = ((3.5, 10), (11.0, 8), (16.0, 6), (float("inf"), 4))
ROLLING_BOUND_CYCLES = 8 # the budget while the cycle length is unknown
ROLLING_BOUND_CYCLES = 11
def _refused_interval(sign: int) -> tuple:
@@ -1282,12 +1282,11 @@ class Controller:
self._short_run = 0
# SAFETY-119: did another owner set the wire last cycle; the window
# after a hand-back (monotonic s, the loop's cycles in a row); the
# monotonic s of the bound cycles hidden in the last ROLLING_S; the
# last cycle's monotonic s and the cycle length.
# monotonic s of the bound cycles hidden in the last ROLLING_S (a
# stop does not clear them).
self._wire_lost = False
self._handback = None
self._hidden = ()
self._settle_t, self._settle_dt = None, None
self.stopping = False
# SAFETY-61. time.monotonic() at the end of the last run_control() pass.
self.loop_pass_mono = None
@@ -5830,7 +5829,6 @@ class Controller:
self._short_run = 0
self._asked_hist = {}
self._wire_lost, self._handback = False, None
self._settle_t = None # a stop is not a cycle length
return
unmet = self.allocation.unmet_w if self.allocation is not None else 0
cut = (resolution.bound_by or "").split("(")[0]
@@ -5871,20 +5869,15 @@ class Controller:
"""SAFETY-119: does the loop's bound on this cycle count? Not within
a settle after another owner handed the wire back: its integrator is
re-seeded from that owner's command and kicked by a meter that does
not show it yet. The settle is SHORT_CYCLES cycles and SHORT_SPAN_S
after the hand-back; losing the wire restarts it. Across all
hand-backs the bound is hidden on at most ROLLING_BUDGET[cycle
length] cycles in any ROLLING_S, so a bound that stays is counted
however often the wire changes hands.
DECISIONS/SAFETY-119-bound-after-hand-back.md."""
not show it yet. SAFETY-121: the settle is HANDBACK_CYCLES cycles and
SHORT_SPAN_S after the hand-back, so a bound that outlasts it counts
at any cycle length; losing the wire restarts it. Across all
hand-backs the bound is hidden on at most ROLLING_BOUND_CYCLES cycles
in any ROLLING_S, so a bound that never outlasts a settle is counted
too. DECISIONS/SAFETY-121-bound-every-quarter.md."""
def aged(t0, span):
return now is None or t0 is None or now - t0 >= span
# The cycle length (a gap over two minutes is not one).
if now is not None and self._settle_t is not None \
and 0.0 < now - self._settle_t <= 120.0:
self._settle_dt = now - self._settle_t
self._settle_t = now
if not owns:
self._wire_lost, self._handback = True, None
return False
@@ -5895,16 +5888,13 @@ class Controller:
tc, n = self._handback
n += 1
self._handback = (tc, n)
if aged(tc, SHORT_SPAN_S) and n >= SHORT_CYCLES:
if aged(tc, SHORT_SPAN_S) and n >= HANDBACK_CYCLES:
self._handback = None
return False
if self.loop_clamped[0] and not self.loop_clamped[2]:
kept = tuple(t for t in self._hidden if now is None or (
t is not None and now - t < ROLLING_S))
dt = self._settle_dt
budget = ROLLING_BOUND_CYCLES if dt is None else next(
m for up, m in ROLLING_BUDGET if dt <= up)
if len(kept) >= budget:
if len(kept) >= ROLLING_BOUND_CYCLES:
self._hidden = kept
return False
self._hidden = kept + (now,)