SAFETY-119 round 3c: the hidden-bound budget follows the cycle length
checks / unit (pull_request) Successful in 43m29s
infra-pin / fresh (pull_request) Successful in 1s

No single rolling budget met both cases in the sweep (8 false-alarmed on
2 s seed 25 and warned in 1 to 3 of 7 quarters at 15 s). The budget is
now 10 cycles up to a 3.5 s cycle, 8 up to 11 s, 6 up to 16 s, 4 beyond,
per rolling 15 minutes; 8 while the cycle length is unknown. A stop or a
gap over two minutes is not a cycle length. The unsourced "owner
accepted N=8" paragraph is removed from DECISIONS. test_safety119 part 4
now runs 2 s seed 25 and shows a fixed budget of 8 warns there; the
never case runs at 2 s and checks every other quarter at 15 s.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-10-21 00:57:43 +02:00
co-authored by Claude
parent e27e62ff72
commit 6fd0fa05f1
3 changed files with 129 additions and 76 deletions
+64 -22
View File
@@ -10,7 +10,8 @@ limit, so the cycle counted as the loop's own and raised
`goodwe_integrator_bound`. The battery gave what it was told. Now the loop's
bound does not count until the plant's settle (SHORT_CYCLES cycles and
SHORT_SPAN_S) has passed since the hand-back. Round 3: across all
hand-backs the bound is hidden on at most ROLLING_BOUND_CYCLES cycles in any
hand-backs the bound is hidden on at most ROLLING_BUDGET[cycle length] cycles
(10 up to a 3.5 s cycle, 8 up to 11 s, 6 up to 16 s, 4 beyond) in any
ROLLING_S seconds, so a bound that stays warns when the guard and the charge
keep taking turns. DECISIONS/SAFETY-119-bound-after-hand-back.md.
@@ -23,10 +24,11 @@ keep taking turns. DECISIONS/SAFETY-119-bound-after-hand-back.md.
hand-back (after the settle).
3. With a usable forecast: the same commands and the same alarms as without
the fix.
4. Repeated hand-backs (N=2, the guard and the charge take turns), 5, 10 and
15 s meters: it warns (every quarter at 5 s and 10 s); the quarter-edge
case stays silent at 10 s and 15 s too; a start or a blind cycle is not a
hand-back; a slow meter delays by cycles, not seconds.
4. Repeated hand-backs (N=2, the guard and the charge take turns), 2, 5, 10
and 15 s meters: it warns (every quarter at 2, 5 and 10 s, every other
quarter at least at 15 s); the quarter-edge case stays silent at 2, 5, 10
and 15 s, including the 2 s seed where a fixed budget of 8 warns; a start
or a blind cycle is not a hand-back; a slow meter delays by cycles.
5. The rule at its boundaries (`Controller._settling`): the settle, and the
rolling budget.
6. test_safety84's and test_safety87's re-pinned digests: the same commands
@@ -250,7 +252,7 @@ _first = notes(_hb[1])[:1]
print(f" hand-back to a loop at its rail at 600 s: first notification "
f"{_first}, without the fix {notes(_hb_old[1])[:1]}; command at the "
f"end {_hb[0][-1][1]}")
MB = 8
MB = 10 # the budget at a 1 s cycle
check(f"⭐ the loop at its rail after a hand-back still warns, once "
f"{MB} of its bound cycles are hidden (1 s meter: {MB} s)",
_first and 600.0 + MB <= _first[0] <= 600.0 + MB + 4.0
@@ -329,8 +331,8 @@ def per_quarter(c):
for q in range(8)]
_nv = {t: never(t) for t in (5.0, 10.0, 15.0)}
_nv_old = {t: never(t, fix=False) for t in (5.0, 10.0, 15.0)}
_nv = {t: never(t) for t in (2.0, 5.0, 10.0, 15.0)}
_nv_old = {t: never(t, fix=False) for t in (2.0, 5.0, 10.0, 15.0)}
_nv_r1 = patched(Controller, "_settling", round1)(lambda: never(5.0))
for _t in _nv:
print(f" N=2, {_t:.0f} s meter, house 5050 W: notifications "
@@ -339,20 +341,23 @@ for _t in _nv:
f"{len(notes(_nv_old[_t][1]))}, highest quarter "
f"{max(quarters(_nv[_t][1], _t)):.0f} W")
print(f" round 1's rule at 5 s: {len(notes(_nv_r1[1]))}")
check("⭐ 5 s meter: it warns in every quarter after the first",
all(per_quarter(_nv[5.0][1])[q] >= 1 for q in range(1, 8)))
check("⭐ 2 s and 5 s meters: it warns in every quarter after the first",
all(per_quarter(_nv[t][1])[q] >= 1 for q in range(1, 8)
for t in (2.0, 5.0)))
check("⭐ 10 s meter: it warns in every quarter after the first (round 2 "
"gave none)",
all(per_quarter(_nv[10.0][1])[q] >= 1 for q in range(1, 8)))
check("⭐ 15 s meter: it warns (round 2 gave none), in at least one quarter "
"of the seven after the first",
sum(1 for n in per_quarter(_nv[15.0][1])[1:] if n) >= 1)
check("⭐ 15 s meter: it warns at least every other quarter after the first "
"(round 2 gave none)",
not any(a == 0 and b == 0 for a, b in zip(
per_quarter(_nv[15.0][1])[1:], per_quarter(_nv[15.0][1])[2:])))
check("non-vacuous: round 1's rule (a window per hand-back) never warned "
"here, and the fix still hides some of what was raised without it",
notes(_nv_r1[1]) == [] and all(
_nv[t][1].clamp_wd.bound < _nv_old[t][1].clamp_wd.bound
for t in _nv))
check("...every command is the same as without the fix, at 5, 10 and 15 s",
check("...every command is the same as without the fix, at 2, 5, 10 and "
"15 s",
all(commands(_nv[t][0]) == commands(_nv_old[t][0]) for t in _nv))
check("...and at 5 s the cap held",
len(quarters(_nv[5.0][1], 5.0)) == 8
@@ -361,9 +366,12 @@ check("...and at 5 s the cap held",
# The quarter-edge case (part 1) at the slower meters: still no warning.
# The meter's own lag limits the cap there, the same with and without the fix.
_qe = {}
# Seeds 1 to 3 are the brief's fixed set; 2 s seed 25 is the one where the
# sweep's fixed budget of 8 warned (DECISIONS), kept here so that stays seen.
for _t in (2.0, 5.0, 10.0, 15.0): # with part 1: 1, 2, 5, 10, 15 s
rig.TICK = _t
for _seed, _dl in ((1, 1), (2, 1), (3, 2)): # the brief's fixed set
for _seed, _dl in ((1, 1), (2, 1), (3, 2)) + (
((25, 2),) if _t == 2.0 else ()):
_qe[_t, _seed] = (run(house(_seed), delay=_dl),
no_fix(lambda: run(house(_seed), delay=_dl)))
rig.TICK = 1.0
@@ -371,8 +379,8 @@ for _t in (2.0, 5.0, 10.0, 15.0): # with part 1: 1, 2, 5, 10, 15 s
f"{[len(notes(r[0][1])) for k, r in _qe.items() if k[0] == _t]} "
f"(without the fix "
f"{[len(notes(r[1][1])) for k, r in _qe.items() if k[0] == _t]})")
check("⭐ the quarter-edge case at 2, 5, 10 and 15 s meters, seeds 1 to 3: "
"no notification",
check("⭐ the quarter-edge case at 2, 5, 10 and 15 s meters, seeds 1 to 3 "
"and (2 s) 25: no notification",
all(notes(r[0][1]) == [] and r[0][1].clamp_wd.bound == 0
for r in _qe.values()))
check("...every command the same as without the fix; without it the false "
@@ -380,9 +388,20 @@ check("...every command the same as without the fix; without it the false "
all(commands(r[0][0]) == commands(r[1][0]) for r in _qe.values())
and all(any(len(notes(_qe[_t, k][1][1])) >= 1 for k in (1, 2, 3))
for _t in (2.0, 5.0, 10.0, 15.0)))
check("the budget is the one the sweep chose (8). Changing it means "
"re-running the sweep in DECISIONS/SAFETY-119-bound-after-hand-back.md",
app_main.ROLLING_BOUND_CYCLES == 8 and app_main.ROLLING_S == 900.0)
rig.TICK = 2.0
_q25_8 = patched(app_main, "ROLLING_BUDGET", ((math.inf, 8),))(
lambda: run(house(25), delay=2))
rig.TICK = 1.0
print(f" 2 s meter, seed 25, a fixed budget of 8: notifications "
f"{notes(_q25_8[1])}")
check("...2 s seed 25 is the sweep's known case: a fixed budget of 8 warns "
"there (why the budget follows the cycle length)",
len(notes(_q25_8[1])) >= 1)
check("the budget is the one the sweep chose. Changing it means re-running "
"the sweep in DECISIONS/SAFETY-119-bound-after-hand-back.md",
app_main.ROLLING_BUDGET == ((3.5, 10), (11.0, 8), (16.0, 6),
(math.inf, 4))
and app_main.ROLLING_BOUND_CYCLES == 8 and app_main.ROLLING_S == 900.0)
# A start, or a blind cycle, is not a hand-back: the loop at its rail after
# 20 s of not running warns at once (R2: a restart counted as a hand-back).
@@ -398,6 +417,7 @@ check("⭐ a start after a blind spell is not a hand-back: it warns at the "
# The budget is cycles, not seconds (R4): at a 5 s meter the loop at its
# rail after a hand-back warns 8 cycles (40 s) later, not 8 s.
MB = 8 # the budget at a 5 s cycle
rig.TICK = 5.0
_sl = run(house(1, step=(600.0, -6000.0)), dur=900.0,
extra={"peak_cap_w": 20000})
@@ -418,6 +438,7 @@ class _C:
def __init__(self):
self._wire_lost, self._handback, self._hidden = False, None, ()
self._settle_t, self._settle_dt = None, None
self.loop_clamped = (False, "", False)
@@ -537,6 +558,25 @@ _c.loop_clamped = (True, "", False)
check("an over-rating does not use up the budget",
_c._settling(True, _t + 5.0) is True and len(_c._hidden) == 1)
# The budget follows the cycle length: bound cycles hidden before the first
# one counts, one hand-back per turn.
_bud = {}
for _tick in (1.0, 2.0, 3.0, 5.0, 10.0, 15.0, 20.0, 30.0, 60.0):
_c = _C()
_o, _ = turns(_c, _tick, 1, min(14, int(880.0 // (2 * _tick))))
_bud[_tick] = sum(1 for _t2, h in _o if h)
print(f" hidden before the first counts, by cycle length: {_bud}")
check("the budget by cycle length: 10 at 1-3 s, 8 at 5-10 s, 6 at 15 s, 4 "
"at 20-60 s",
_bud == {1.0: 10, 2.0: 10, 3.0: 10, 5.0: 8, 10.0: 8, 15.0: 6,
20.0: 4, 30.0: 4, 60.0: 4})
_c = _C()
_o, _t = turns(_c, 2.0, 3, 8)
_o2, _ = turns(_c, 121.0, 1, 3, first=_t + 119.0)
check("a gap over two minutes is not a cycle length: the 2 s budget (10) "
"stays",
[h for _t2, h in _o + _o2] == [True] * 10 + [False])
# No monotonic clock: the budget cannot refill, so a bound that stays is
# counted after the budget however often the wire changes hands.
_c = _C()
@@ -576,8 +616,10 @@ check("...while the integrator bound on the next cycle is not counted",
_ctl._wire_lost, _ctl._handback = True, (1.0, 3)
_ctl._loop_decision = None # stopped or blind
_ctl._loop_alarm_input(_Res())
check("a stop clears it: the next start is not a hand-back",
(_ctl._wire_lost, _ctl._handback) == (False, None))
check("a stop clears it: the next start is not a hand-back, and the stop "
"is not a cycle length",
(_ctl._wire_lost, _ctl._handback, _ctl._settle_t)
== (False, None, None))
# ============================================================================ #
print("\n6. the pinned digests of test_safety84 and test_safety87: only the "