B's power reading shows -95 to -118 W at idle (standby draw), so the |reading| < 100 W refusal band never held a refusing B: never at N=2, and since SAFETY-94's shared rule not at N=1 either (review F1). Each unit now learns its idle reading (app/idle.py): only while control runs, the unit is not refused/stale/tripped and its command has stayed within 50 W for 120 s; first value a 120 s mean, then a 30 min follow; samples beyond 250 W dropped, value clamped to 250 W; saved per unit and restored. The shared refusal evidence, compute()'s N=1 engage and end, the unit refusal ends, the stale-path refusal and SAFETY-42's "parked" test are centred on it. Until learned it is 0 W, the rule as it was. Refusing B caught at N=1 28/28/65 s and N=2 24/24/60 s (1/2/5 s meters), before never; refusing A unchanged. Healthy B with its offset, SAFETY-94's scenarios, 96 runs: 0 stale, 0 refused. Live replay: 0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
103 lines
5.7 KiB
Markdown
103 lines
5.7 KiB
Markdown
# SAFETY-94 - a battery that lags or reverses is not frozen or refusing
|
|
|
|
**Status: implemented 2026-10-03, for review.** Tests: `test_safety94.py`
|
|
(rig: `tools/safety94_rig.py`). Also closes SAFETY-58. SAFETY-74 stays open.
|
|
|
|
## The defects
|
|
|
|
1. **Stale-reading gate (SAFETY-37).** Its 35 s ran from the register's last
|
|
change. After a rest that clock was already past 35 s, so the first read
|
|
after a new command tripped it, before the register could answer. Live
|
|
2026-10-03 (0.14.0, N=2): 17:39:16 B and 17:39:25 A (load ramp after a
|
|
rest), 19:00:04 A (drain start), 21:21:22 B (drain -> charge). Each was
|
|
1 s after its command; each register answered within 11 s. This is
|
|
SAFETY-58's shape.
|
|
2. **Refusal at N=1 (SAFETY-52).** `compute()` engaged after `SETTLE_CYCLES`
|
|
frozen cycles if the reading was under 100 W at that moment. The frozen
|
|
cycles include a reversal through the battery's lag. At a 1 s meter the
|
|
12th cycle comes as a battery reversing out of a charge passes 0 W
|
|
(SAFETY-85 rig, O1: "gave -20 W for 13 cycles"). The refusal then held
|
|
0 W, the battery settled at 0 W, never gave 100 W, and waited for the
|
|
15-minute probe. N>1 already required 22 s in the band, so it was safe
|
|
at a 10 s lag but not at 20 s.
|
|
|
|
## What changed
|
|
|
|
- **The gate counts only time the register was asked to move**
|
|
(`freshness.Freshness._oblige`). A read counts the time since the last
|
|
read when, at that read, the command is 250 W or more and has moved 250 W
|
|
from where it stood at the register's last change. A register change
|
|
(or a SAFETY-57 release) restarts the count. A command flapping in and
|
|
out of the ask still adds up (frozen register, 0/1500 W every 5 s: trip
|
|
at 69 s). The conjunction, `MOVE_W` and `RELEASE_S` are unchanged.
|
|
- **The bound is unchanged at 35 s and now means what its derivation
|
|
says.** The floor was always "how long a healthy register takes to answer
|
|
a command": 6 s plant + 10 s publish + 1 s read = 17 s. The band
|
|
`[2 x 17, 0.5 x 75] = [34, 37.5]` holds. Live answers measured: 1.4, 3,
|
|
6 and 11 s after the command.
|
|
- **Refusal evidence is the same at every roster size**
|
|
(`main.Controller._gave_nothing`, SAFETY-73's count moved into a method).
|
|
The reading must stay under 100 W, one way, while asked over 200 W, for 12
|
|
cycles spanning 22 s. At N=1 `compute()` gets this as `gave_nothing` and
|
|
needs it on top of its own freeze test. `None` (compute alone) keeps the
|
|
old law.
|
|
- **The evidence restarts when the reading moves 50 W towards the ask**
|
|
(`REFUSED_PROGRESS_W`). A refusing battery reads flat (-3/-4 W live). Live
|
|
readings wander by under 25 W at rest. A first-order lag crossing the band
|
|
gains 50 W in at most 0.4 tau, so it cannot fill 22 s unless tau > 54 s.
|
|
This also covers B, whose reading is 100 W below its power.
|
|
|
|
## Bounds and worst-case catch
|
|
|
|
| fault | before | after | budget |
|
|
|---|---|---|---|
|
|
| register frozen, then asked to move | 0-35 s after the ask (often 1 s) | 35 s after the ask (+1 read) | SAFETY-37: 35 s, under the 75/85 s consumer holds |
|
|
| register frozen at rest reading ~0 W | stale 1 s after the ask | refused or stale, 0 W within 23-35 s | the same |
|
|
| refusing battery, 1 s meter, N=1 | 11-16 s | 22-28 s | SAFETY-73's 22 s span (+ one 10 s publish) |
|
|
| refusing battery, 2 s meter | 22-28 s | 22-28 s | unchanged |
|
|
| refusing battery, 5 s meter | N=1 35-41 s, N=2 41-55 s | N=1 40-45 s, N=2 50-55 s | 12 cycles = 55-60 s |
|
|
|
|
Rig: `test_safety94.py` part 3. A release after SAFETY-57 still exposes
|
|
the held number for one 35 s window of asking, so 35 < 75 < 85 still holds.
|
|
|
|
## Before -> after
|
|
|
|
- Live replay (part 1): 4 trips -> 0.
|
|
- Refusing B (its -100 W idle reading): see SAFETY-97.
|
|
- Rig, 10 s lag, N=1 and N=2, 1/2/5 s meters, two register shapes, 48 runs
|
|
(part 2): before 27 stale trips and 1 refusal, after 0 and 0.
|
|
- Reversals, lag 10 and 20 s, 1 s meter (part 2): before N=1 6 stale / 3
|
|
refused, N=2 6 / 1; after 0 / 0.
|
|
- 16-day loop capture (`test_freshness.py` part 8): 93 trips -> 59. Trips
|
|
answered within 20 s: 21 -> 1. Two of the removed trips were not quick:
|
|
a register held 351 s under a command hovering at the 250 W travel edge
|
|
(under 35 s of asking before it moved; survivor 2's shape), and a
|
|
refusing battery the gate had caught only on a passing ramp (SAFETY-52's
|
|
refusal covers it).
|
|
|
|
## SAFETY-58 and SAFETY-74
|
|
|
|
- **SAFETY-58 is fixed** by this change: its card asks for the clock to
|
|
start with the obligation and for the bound to be re-derived. Both are
|
|
done above. `test_freshness.py` survivor 5 is now graded as closed.
|
|
- **SAFETY-74 is not fixed.** A battery capped at 300 W with a perfectly
|
|
flat reading, 5 s meter, house stepping: 50 trips/h at N=1 and 80 at N=2
|
|
before, 37 and 60 after. With +-1 W dither: 0. A flat reading under a
|
|
command that moved is exactly what the gate is for; telling "limited"
|
|
from "frozen" needs a second signal (the meter, or SoC).
|
|
|
|
## Known gaps
|
|
|
|
- ~~B's standby offset (-95 to -118 W at idle) puts a refusing B outside the
|
|
100 W band, so a refusing B never latched (review F1: at N=1 new, at N=2
|
|
as before).~~ **Fixed by SAFETY-97 in the same PR:** the band is centred
|
|
on each unit's learned idle reading. `SAFETY-97-idle-offset.md`.
|
|
- A command that asks only part of the time (a fraction d) is caught after
|
|
35 s of asking, which is 35/d s of wall time (0/1500 W every 5 s: 69 s).
|
|
SAFETY-30's per-unit check restarts when its command stops opposing the
|
|
held reading, which a lapsing ask normally does, so 35 < 85 holds there.
|
|
SAFETY-31's circulation check (75 s) ignores the command, so its margin
|
|
holds only while d >= 0.47.
|
|
- A register frozen under a command that stays near the 250 W travel edge
|
|
is caught late or not at all (survivor 2).
|