SAFETY-94: a healthy battery that lags or reverses is not flagged as frozen or refusing
checks / unit (pull_request) Successful in 28m10s
infra-pin / fresh (pull_request) Failing after 2s

The stale-reading gate counted its 35 s from the register's last change, so
after a rest the first read after a new command tripped it (SAFETY-58; live
2026-10-03, four trips 1 s after the command). It now counts only the time
the command asked the register to move.

At N=1 the refusal engaged after 12 frozen cycles if the reading was under
100 W at that moment, which a battery reversing through its lag passes at a
1 s meter. The refusal now needs the N>1 evidence at every roster size: under
100 W, one way, for 12 cycles spanning 22 s, restarting when the reading
moves 50 W towards the ask.

DECISIONS/SAFETY-94-lagging-battery.md; test_safety94.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 00:15:42 +02:00
co-authored by Claude Opus 5.5
parent 02ed1200dc
commit 6759d21ea5
11 changed files with 922 additions and 93 deletions
+85 -54
View File
@@ -900,34 +900,51 @@ def _loop_series(day, sig):
return out
class _Before94(Freshness):
"""The gate before SAFETY-94: the clock ran from the register's last
change, not from when the command asked it to move. The history below is
about that gate, so it is replayed on it."""
def obliged_s(self):
return self.graded_s(self.last_read)
def _replay16(cls):
"""Every committed day at 1 Hz. Returns (trips, waits for the next move)."""
waits, trips = [], 0
for _day in _loop_days:
_b, _s = _loop_series(_day, "batt"), _loop_series(_day, "setpoint")
if not _b or not _s:
continue
_moves = [r[0] for i, r in enumerate(_b)
if i == 0 or r[1] != _b[i - 1][1]]
_f8b, _prev8b = cls(BOUNDS["batt"]), False
_ib = _is = 0
_t = max(_b[0][0], _s[0][0])
_end = min(_b[-1][0], _s[-1][0])
while _t <= _end:
while _ib + 1 < len(_b) and _b[_ib + 1][0] <= _t:
_ib += 1
while _is + 1 < len(_s) and _s[_is + 1][0] <= _t:
_is += 1
_f8b.submit(_b[_ib][1], _s[_is][1], _t)
if _f8b.stale and not _prev8b:
trips += 1
_nxt = next((m for m in _moves if m > _t), None)
if _nxt is not None:
waits.append(_nxt - _t)
_prev8b = _f8b.stale
_t += 1.0
return trips, sorted(waits)
_t8b = time.monotonic()
_waits, _trips8b = [], 0
for _day in _loop_days:
_b, _s = _loop_series(_day, "batt"), _loop_series(_day, "setpoint")
if not _b or not _s:
continue
_moves = [r[0] for i, r in enumerate(_b) if i == 0 or r[1] != _b[i - 1][1]]
_f8b, _prev8b = Freshness(BOUNDS["batt"]), False
_ib = _is = 0
_t = max(_b[0][0], _s[0][0])
_end = min(_b[-1][0], _s[-1][0])
while _t <= _end:
while _ib + 1 < len(_b) and _b[_ib + 1][0] <= _t:
_ib += 1
while _is + 1 < len(_s) and _s[_is + 1][0] <= _t:
_is += 1
_f8b.submit(_b[_ib][1], _s[_is][1], _t)
if _f8b.stale and not _prev8b:
_trips8b += 1
_nxt = next((m for m in _moves if m > _t), None)
if _nxt is not None:
_waits.append(_nxt - _t)
_prev8b = _f8b.stale
_t += 1.0
_waits.sort()
_trips8b, _waits = _replay16(_Before94)
_trips94, _waits94 = _replay16(Freshness)
_p50 = nth(_waits, len(_waits) // 2)
print(f" {len(_loop_days)} days replayed at 1 Hz in "
f"{time.monotonic() - _t8b:.1f}s: {_trips8b} trips")
f"{time.monotonic() - _t8b:.1f}s (twice): {_trips8b} trips before "
f"SAFETY-94, {_trips94} after")
print(f" wait for the register's next change after a trip: "
f"min {nth(_waits, 0):.1f} s median {_p50:.1f} s max {nth(_waits, -1):.1f} s; "
f"{sum(1 for w in _waits if w >= 710.0)} at or over the live episode's "
@@ -953,6 +970,17 @@ check("⚠️ AND THAT DISTRIBUTION IS A PROPERTY OF THIS BMS, NOT OF THIS CODE
"is `stale_input_s + release_s` = 32 s whatever the register does",
sum(1 for w in _waits if w < 20.0) >= 20
and float(option_default("stale_input_s")) + BOUNDS["batt"].release_s == 32.0)
print(f" SAFETY-94: trips answered within 20 s: {sum(1 for w in _waits if w < 20.0)}"
f" before, {sum(1 for w in _waits94 if w < 20.0)} after; quickest "
f"answer after a trip {nth(_waits94, 0):.1f} s")
check("⚠️ SAFETY-94 (SAFETY-58): MOST OF THOSE SHORT WAITS WERE FALSE TRIPS, "
"NOT KEEP-ALIVE PULSES. The clock now counts only the time the command "
"asked the register to move. The same sixteen days give 59 trips, not "
"93, and only one of the 59 is answered within 20 s (21 before): the "
"gate no longer judges a register on the first read after a rest",
_trips94 == 59 and len(_waits94) == _trips94
and sum(1 for w in _waits94 if w < 20.0) == 1
and sum(1 for w in _waits if w < 20.0) == 21)
# ==============================================================================
@@ -1017,32 +1045,30 @@ check("NOT A SURVIVOR, recorded because it looks like one: a publisher failure "
"each unit's own command travel and each unit's own number",
_s4a.stale and _s4b.stale and _s4a.held != _s4b.held)
# S5. SAFETY-57's own, and it is a FALSE POSITIVE that predates this card.
# The unchanged clock keeps running while the site rests, so the FIRST read
# at which a command travels 250 W after a long quiet already satisfies all
# three terms - the register is given no time at all to answer the new
# command. Present on the shipped tree; the live capture in part 10 contains
# one (10:48:14, cleared one second later).
# S5. SAFETY-57's own FALSE POSITIVE, CLOSED BY SAFETY-94 (it was SAFETY-58).
# The unchanged clock used to keep running while the site rested, so the
# FIRST read at which a command travelled 250 W after a long quiet tripped
# the gate. The clock now counts only time the register was asked to move.
# Live 2026-10-03: four such trips, each 1 s after the command.
_s5 = Freshness(BOUNDS["batt"])
for t in range(0, 300): # five minutes of a resting site
_s5.submit(-3.0, 0.0, float(t))
_s5_quiet = _s5.stale
_s5.submit(-3.0, 1500.0, 300.0) # the loop asks for 1500 W
check("⚠️ SURVIVOR 5, FALSE POSITIVE, AND IT IS OLDER THAN SAFETY-57: after a "
"long rest the unchanged clock is already past 35 s, so the very FIRST "
"read carrying a travelled command trips the gate - before the 17.0 s in "
"which a healthy register could have answered it. The gate accuses on "
"read one and the register answers on read eleven",
not _s5_quiet and _s5.stale and _s5.graded_s(300.0) == 300.0)
check("...and its SIZE is one `stale_input_s` hold, which is why it is recorded "
"here rather than fixed here: `cycle()` returns at `inputs missing "
"{n}s` for the whole of it, so nothing is commanded differently, and the "
"register's next publish clears it. Fixing it means measuring the "
"unchanged clock from when the OBLIGATION appeared rather than from the "
"register's last move, which re-derives the 35.0 s bound and is a "
"different card",
(lambda f: (f.submit(-4.0, 1500.0, 311.0), not f.stale)[-1])(_s5)
and float(option_default("stale_input_s")) >= 5.0)
_s5_first = _s5.stale
for t in range(301, 334):
_s5.submit(-3.0, 1500.0, float(t))
_s5_before = _s5.stale
_s5.submit(-3.0, 1500.0, 334.0)
check("⚠️ SURVIVOR 5 IS CLOSED (SAFETY-94): after five minutes of rest the "
"first read carrying a travelled command is NOT a trip, nor is any read "
"before 35 s of standing command - and a register that still has not "
"moved after 35 s of it IS",
not _s5_quiet and not _s5_first and not _s5_before and _s5.stale
and _s5.graded_s(334.0) == 334.0 and _s5.obliged_s() == 35.0)
check("...and the evidence sentence still gives the register's own unchanged "
"time (334 s), which an operator compares with a history graph",
"unchanged for 334 s" in _s5.reason)
# ==============================================================================
@@ -1353,20 +1379,23 @@ print(f" after : held number reached a consumer on {sum(_exp57)} reads "
check("⚠️ THE EXIT IS NOT FREE AND THE PRICE IS STATED AS A NUMBER. A register "
"that is dead for good is re-graded for ever: over 1201 reads the held "
"number reaches a consumer on 630 of them against 35 before, and 17 "
"number reaches a consumer on 631 of them against 36 before, and 17 "
"releases are taken. This check exists so that cost cannot be quietly "
"forgotten, and so a change that makes it worse has to move a number "
"somebody reads",
sum(_expb) == 35 and sum(_exp57) == 630 and _dead.releases == 17
sum(_expb) == 36 and sum(_exp57) == 631 and _dead.releases == 17
and _deadb.releases == 0)
check("⚠️ AND IT IS PAYABLE ONLY BECAUSE ONE RUN IS SHORTER THAN EITHER "
"CONSUMER'S HOLD. Every unbroken run of exposure is exactly one 35.0 s "
"grading window, and 35 < 75 (circulation_hold_s) < 85 "
"CONSUMER'S HOLD. Every unbroken run of exposure after a release is "
"exactly one 35.0 s window of standing command (the first is 36 s: the "
"fixture's command starts one read late), and 36 < 75 "
"(circulation_hold_s) < 85 "
"(unit_sign_disagree_s) - so NEITHER detector can reach a verdict inside "
"one, and the re-trip that ends each run hands them a None, which CLEARS "
"their windows rather than accumulating into them",
set(_runs57) == {BOUNDS["batt"].unchanged_s}
and BOUNDS["batt"].unchanged_s < _circ_hold < _unit_hold)
set(_runs57[1:]) == {BOUNDS["batt"].unchanged_s}
and nth(_runs57, 0) == BOUNDS["batt"].unchanged_s + 1.0
and max(_runs57) < _circ_hold < _unit_hold)
check("...and nothing SAFETY-37 detects is given back by that, which is the "
"half that makes the trade a trade. A permanent None clears both windows "
"for ever too, so on the shipped-before module the same two detectors are "
@@ -1643,8 +1672,10 @@ check("...and a re-trip's reason names the TWO durations separately, each "
[f.submit(A_FROZEN_W, 0.0, float(t)) for t in range(40, 80)],
[f.submit(A_FROZEN_W, 1300.0, float(t)) for t in range(80, 130)],
f.stale and f.releases == 1
and "unchanged for 92 s" in f.reason
and "checked 2 times over a 35 s window since the last stop was "
# SAFETY-94: the re-trip comes 35 s after the command returns at
# 80 s (114 s), not 35 s after the release at 57 s.
and "unchanged for 114 s" in f.reason
and "checked 2 times over a 57 s window since the last stop was "
"withdrawn" in f.reason)[-1])(
Freshness(BOUNDS["batt"])))