SAFETY-119 round 2: one settle window per run of hand-backs
checks / unit (pull_request) Failing after 42m32s
infra-pin / fresh (pull_request) Successful in 2s

A hand-back used to restart the window, so a guard and a charge taking
turns hid the loop's integrator bound for good (0 warnings in 2 h; 28
before the fix). A run of hand-backs (the wire away less than a settle
between them) now hides the bound for at most 11 bound cycles and 22 s in
all, then it counts. The quarter-edge kick stays hidden. No command or text
changes. Tests for the two reviewer mutants that only unit checks caught
(a restart counted as a hand-back; span only), DECISIONS gives the delay at
2 s, 5 s and 60 s. test_safety87.py:543 shortened.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
This commit is contained in:
glenn schrooyen
2026-10-20 00:52:24 +02:00
co-authored by Claude
parent b9ff6acea4
commit e76c70ecb0
4 changed files with 269 additions and 44 deletions
+38 -13
View File
@@ -299,6 +299,10 @@ 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: a run of hand-backs (the wire away less than a settle between
# them) may hide the loop's bound for at most this many cycles and
# SHORT_SPAN_S, in all.
RUN_BOUND_CYCLES = SHORT_CYCLES - 1
def _refused_interval(sign: int) -> tuple:
@@ -1272,10 +1276,13 @@ class Controller:
self._loop_decision = None
self._asked_hist: dict = {}
self._short_run = 0
# SAFETY-119: did another owner set the wire last cycle, and since
# when (monotonic s, owned cycles) has the loop had it back.
# SAFETY-119: did another owner set the wire last cycle; the open run
# (monotonic s and count of the loop's cycles in a row, bound cycles
# hidden, monotonic s of the first); the wire's cycles away in a row
# (first s, count).
self._wire_lost = False
self._handback = None
self._away = None
self.stopping = False
# SAFETY-61. time.monotonic() at the end of the last run_control() pass.
self.loop_pass_mono = None
@@ -5818,6 +5825,7 @@ class Controller:
self._short_run = 0
self._asked_hist = {}
self._wire_lost, self._handback = False, None
self._away = None
return
unmet = self.allocation.unmet_w if self.allocation is not None else 0
cut = (resolution.bound_by or "").split("(")[0]
@@ -5855,25 +5863,42 @@ class Controller:
self.loop_clamp_facts = ()
def _settling(self, owns, now) -> bool:
"""SAFETY-119: is the loop within the plant's settle (SHORT_CYCLES
cycles and SHORT_SPAN_S) of taking the wire back from another owner?
Its integrator is re-seeded from that owner's command and kicked by a
meter that does not show it yet, so its bound there is not counted.
DECISIONS/SAFETY-119-bound-after-hand-back.md."""
"""SAFETY-119: does the loop's bound on this cycle count? Not within
a run of hand-backs from another owner: its integrator is re-seeded
from that owner's command and kicked by a meter that does not show it
yet. A run opens at a hand-back and ends when the loop held the wire,
or the wire was away, a whole settle in a row (SHORT_CYCLES cycles
and SHORT_SPAN_S). Each hand-back restarts the settle, but the run
hides the bound for RUN_BOUND_CYCLES cycles and SHORT_SPAN_S in all,
so a bound that stays is counted after that, however often the wire
changes hands. DECISIONS/SAFETY-119-bound-after-hand-back.md."""
def aged(t0, span):
return now is None or t0 is None or now - t0 >= span
if not owns:
self._wire_lost, self._handback = True, None
self._wire_lost = True
t0, k = self._away or (now, 0)
self._away = (t0, k + 1)
if k + 1 >= SHORT_CYCLES and aged(t0, SHORT_SPAN_S):
self._handback = None
return False
self._away = None
if self._wire_lost:
self._wire_lost, self._handback = False, (now, 0)
self._wire_lost = False
self._handback = (now, 0) + (self._handback or (0,) * 4)[2:]
if self._handback is None:
return False
t0, n = self._handback
tc, n, hid, tb = self._handback
n += 1
self._handback = (t0, n)
if n >= SHORT_CYCLES and (now is None or t0 is None
or now - t0 >= SHORT_SPAN_S):
self._handback = (tc, n, hid, tb)
if aged(tc, SHORT_SPAN_S) and n >= SHORT_CYCLES:
self._handback = None
return False
if self.loop_clamped[0] and not self.loop_clamped[2]:
if hid >= RUN_BOUND_CYCLES and aged(tb, SHORT_SPAN_S):
return False
tb = tb if hid else now
self._handback = (tc, n, hid + 1, tb)
return True
async def check_output_clamp(self) -> None: