dd5eb6fdc8 open
SAFETY-94. Also fixes SAFETY-58. SAFETY-74 stays open (see below). Record: goodwe_controller/DECISIONS/SAFETY-94-lagging-battery.md. Tests: test_safety94.py, with the rig in tools/safety94_rig.py.
Root cause
1. The stale-reading gate (SAFETY-37) started its 35 s clock at the register's last change. After a rest, that clock was already past 35 s. So the first read after a new command tripped the gate, before a healthy register could answer. The gate assumed the register would keep changing; in fact it holds one value while nothing asks it to move.
Live on 2026-10-03 (0.14.0, N=2), four trips, each 1 s after the command:
17:39:16 B and 17:39:25 A: the load ramp after a rest.
19:00:04 A: the drain started.
21:21:22 B: drain → charge.
Each register answered within 1.4–11 s.
test_safety94.py part 1 replays the HA history and reproduces all four to the second.
2. The N=1 refusal (SAFETY-52) checked the reading at one moment only.compute() engaged after 12 frozen cycles if the reading was under 100 W at that moment.
The frozen cycles also count a battery reversing through its lag.
At a 1 s meter, cycle 12 lands just as the battery reversing out of a charge passes 0 W (SAFETY-85 O1).
The hold then asked for 0 W, so the battery settled at 0 W and never gave the 100 W that would end the hold. It stayed parked until the 15-minute probe.
N>1 already needed 22 s in the band. That is safe at a 10 s lag but not at 20 s, and not for B, whose reading is 100 W below its power.
Design
The gate counts only the time the command asked the register to move (Freshness._oblige).
A read adds the time since the previous read when, at that read, the command is ≥ 250 W and has moved ≥ 250 W from where it stood when the register last changed.
A register change, or a SAFETY-57 release, resets the count.
A command that flaps in and out of the ask still adds up: a frozen register under a 0/1500 W flap trips at 69 s.
The conjunction, MOVE_W, RELEASE_S and the log/UI text are unchanged. Only a DEBUG line's wording changed.
The refusal evidence is the same at every roster size (Controller._gave_nothing, which is SAFETY-73's count moved into a method).
The battery must give < 100 W, one way, while asked > 200 W, for 12 cycles that span ≥ 22 s.
At N=1, compute(gave_nothing=...) requires this 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), and live rest wander is under 25 W.
A first-order lag crossing the 100 W band gains 50 W within 0.4 τ, so it cannot fill 22 s unless τ > 54 s.
New bounds and worst-case time to catch a real fault
The bound is still 35 s. It now measures what its derivation always assumed: the time from the ask to the answer.
Floor: 6 s plant + 10 s publish + 1 s read = 17 s.
Band: [34, 37.5].
Live answers took 1.4, 3, 6 and 11 s.
fault
before
after
budget
register frozen, then asked to move
0–35 s after the ask (often 1 s)
35 s after the ask
SAFETY-37: 35 s; consumer holds 75/85 s
register frozen at rest (~0 W)
stale 1 s after the ask
0 W (refused or stale) in 23–35 s
the same
refusing battery, 1 s meter
N=1 11–16 s, N=2 22–28 s
22–28 s
SAFETY-73: 22 s span + a 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
After a SAFETY-57 release, the held number is exposed for one 35 s window of asking. That is shorter than either consumer hold (35 < 75 < 85), so the argument still holds (test_freshness.py 10c).
Before → after
Live replay: 4 trips → 0.
Rig (10 s lag, N=1 and N=2, 1/2/5 s meters, two register shapes: 10 s publish with repeats, and 1 s with ±1 W): rest→step, drain start, drain→charge and charge reversal, 48 runs.
Before: 27 stale trips and 1 refusal.
After: 0 and 0.
Reversals (1500/2500 W of charge into a 300–2000 W house, lag 10 and 20 s, 1 s meter):
Before: N=1 had 6 stale trips and 3 refusals; N=2 had 6 and 1.
After: 0, also at 2 s.
16-day loop capture (test_freshness.py part 8): 93 trips → 59. Trips answered within 20 s: 21 → 1.
Two removed trips were not quick ones:
a register held 351 s under a command hovering at the 250 W travel edge (survivor 2's shape);
a refusal the gate had only caught on a passing ramp, which SAFETY-52's refusal path covers.
Still caught: a frozen register (35 s after the ask), a register frozen at rest, and a refusing battery, at N=1 and N=2 (part 3).
Re-pinned, on purpose:
test_freshness.py:
part 8 keeps the pre-94 history, replayed on a _Before94 gate, and adds the new figures;
survivor 5 is now graded as closed;
10c exposure is 631/36 reads, was 630/35;
the 10h re-trip comes at 114 s, was 92 s.
test_modes.py: the SAFETY-87 counterfactual digest. A, cut out, now trips at 1640 s, was 1635 s. The shipped n2, n2_off and n2_maint digests are unchanged.
test_clamp.py: the runaway fixture now runs on simulated 2 s ticks, because the 22 s span cannot elapse on back-to-back real-clock cycles.
All 61 test files pass locally in the CI image.
Mutations
Each mutation ran on a fresh copy with no .pyc (0 left), mounted :ro, under python3 -B. Each was graded by test_safety94, test_freshness, test_refusal, test_refusal_units, test_clamp, test_control and test_modes.
#
mutation
result
M0
none (control)
all green
M1
gate clock from the last change again (graded_s)
red: safety94 8, freshness 6, modes 1
M2
the ask never accumulates
red: safety94 4, freshness 54, modes 1
M3
a register change does not reset the count
red: safety94 3, freshness 7
M4
the window-opening read also resets last_read
survived: equivalent. The opening read can never be an ask, because travel is 0 by construction, so the simpler form ships
SAFETY-58: fixed. Its card asks for the clock to start with the obligation, and for the 35 s bound to be re-derived. Both are done here.
SAFETY-74: not fixed. The test: a battery capped at 300 W with a perfectly flat reading, a 5 s meter and a stepping house.
Trips per hour: before 50 (N=1) and 80 (N=2); after 37 and 60. With ±1 W of dither there are none.
A flat reading under a command that has moved is exactly the case the gate exists for. Telling "limited" from "frozen" needs a second signal, such as the meter or SoC.
Open (pre-existing, not changed): B's standby offset (−95 to −118 W at idle) puts a refusing B outside the 100 W band part of the time, so a refusing B may not latch.
No version or CHANGELOG change.
SAFETY-97 folded in (review F1)
B's power reading shows about −100 W at idle (standby draw), so the |reading| < 100 W band never held a refusing B: never at N=2, and since this PR's shared rule not at N=1 either. Record: goodwe_controller/DECISIONS/SAFETY-97-idle-offset.md. Tests: test_safety97.py.
Design
Each unit learns its idle reading (app/idle.py): only while control runs, the unit is not refused, stale or sign-tripped, and its command has stayed within 50 W for 120 s. First value: a 120 s mean. Then it follows drift slowly (30 min).
Bounds: a sample beyond 250 W is dropped. The band is centred on the learned value clamped to ±75 W (owner, 2026-10-04: the band always keeps 0 W ±25 W; beyond about ±170 W of idle a battery behaves as in 0.14.0). It is saved per unit and restored at start, clamped to 250 W. Until it is learned it is 0 W, which is the old rule.
Centred on it (|reading − idle| < 100 W): the shared evidence, compute()'s N=1 engage and end, the unit refusal ends, the stale-path refusal and SAFETY-42's "parked". SAFETY-37, SAFETY-57 and SAFETY-30 are unchanged; the reasons are in DECISIONS.
No new option. The HA integration's DC battery-power entity was weighed and not taken; the reasons are in DECISIONS.
Refused, seconds after the cut (rig: 400 s rest, then asked, cut at 600 s, ±4 W)
refusing
1 s meter
2 s
5 s
before
A, N=1
28
28
65
same
A, N=2
28
28
110*
same
B, N=1
28
28
65
never
B, N=2
24
24
60
never
* The rig's meter goes flat, so the loop cycles on the 15 s floor. The PR without SAFETY-97 gives the same.
A B that is refusing at start, with its idle reading restored: 24 s. With nothing saved: never (see open points).
A healthy B with its offset, in SAFETY-94's four scenarios (96 runs), plus reversals: 0 stale, 0 refused.
Live replay: 0 trips.
Mutations (fresh copies, 0 .pyc, :ro, python3 -B; graded by safety97, safety94, refusal, refusal_units, control and delivery)
#
mutation
result
S0
control
green
S1
band not centred (_gave_nothing)
red: safety97 5
S2
N=1 engage not centred (offset at N=2 only)
red: safety97 2
S3
offset unbounded
red: safety97 3
S4
learned while refused
red: safety97 1
S5
learned under any command
red: safety97 4
S6
no quiet time
red: safety97 2
S7
unit refusal end not centred
red: safety97 1
S8
not restored after restart
red: safety97 1
S9
compute() end not centred
red: safety97 1
S10
stale-path refusal (N>1) not centred
survived → red after a new check (5 s meter)
S11
fast follow (τ 1 s)
red: safety97 1
S12
idle_w not passed to compute()
red: safety97 1
Open points
Not mutation-tested: the SAFETY-42 "parked" centring, and the N=1 stale-path centring (the same expression as the N>1 one that S10 tests).
A battery that is never asked about 0 W for 2 minutes never learns its idle reading. Such a site keeps the old rule until it rests once.
dd5eb6fdc8840a2f7e35050017702f9d19afb339 open
SAFETY-94. Also fixes SAFETY-58. SAFETY-74 stays open (see below). Record: `goodwe_controller/DECISIONS/SAFETY-94-lagging-battery.md`. Tests: `test_safety94.py`, with the rig in `tools/safety94_rig.py`.
## Root cause
**1. The stale-reading gate (SAFETY-37) started its 35 s clock at the register's last change.** After a rest, that clock was already past 35 s. So the first read after a new command tripped the gate, before a healthy register could answer. The gate assumed the register would keep changing; in fact it holds one value while nothing asks it to move.
- Live on 2026-10-03 (0.14.0, N=2), four trips, each 1 s after the command:
- 17:39:16 B and 17:39:25 A: the load ramp after a rest.
- 19:00:04 A: the drain started.
- 21:21:22 B: drain → charge.
- Each register answered within 1.4–11 s.
- `test_safety94.py` part 1 replays the HA history and reproduces all four to the second.
**2. The N=1 refusal (SAFETY-52) checked the reading at one moment only.** `compute()` engaged after 12 frozen cycles if the reading was under 100 W *at that moment*.
- The frozen cycles also count a battery reversing through its lag.
- At a 1 s meter, cycle 12 lands just as the battery reversing out of a charge passes 0 W (SAFETY-85 O1).
- The hold then asked for 0 W, so the battery settled at 0 W and never gave the 100 W that would end the hold. It stayed parked until the 15-minute probe.
- N>1 already needed 22 s in the band. That is safe at a 10 s lag but not at 20 s, and not for B, whose reading is 100 W below its power.
## Design
- **The gate counts only the time the command asked the register to move** (`Freshness._oblige`).
- A read adds the time since the previous read when, at that read, the command is ≥ 250 W and has moved ≥ 250 W from where it stood when the register last changed.
- A register change, or a SAFETY-57 release, resets the count.
- A command that flaps in and out of the ask still adds up: a frozen register under a 0/1500 W flap trips at 69 s.
- The conjunction, `MOVE_W`, `RELEASE_S` and the log/UI text are unchanged. Only a DEBUG line's wording changed.
- **The refusal evidence is the same at every roster size** (`Controller._gave_nothing`, which is SAFETY-73's count moved into a method).
- The battery must give < 100 W, one way, while asked > 200 W, for 12 cycles that span ≥ 22 s.
- At N=1, `compute(gave_nothing=...)` requires this 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), and live rest wander is under 25 W.
- A first-order lag crossing the 100 W band gains 50 W within 0.4 τ, so it cannot fill 22 s unless τ > 54 s.
## New bounds and worst-case time to catch a real fault
The bound is still **35 s**. It now measures what its derivation always assumed: the time from the ask to the answer.
- Floor: 6 s plant + 10 s publish + 1 s read = 17 s.
- Band: [34, 37.5].
- Live answers took 1.4, 3, 6 and 11 s.
| fault | before | after | budget |
|---|---|---|---|
| register frozen, then asked to move | 0–35 s after the ask (often 1 s) | **35 s** after the ask | SAFETY-37: 35 s; consumer holds 75/85 s |
| register frozen at rest (~0 W) | stale 1 s after the ask | 0 W (refused or stale) in **23–35 s** | the same |
| refusing battery, 1 s meter | N=1 11–16 s, N=2 22–28 s | **22–28 s** | SAFETY-73: 22 s span + a 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 |
After a SAFETY-57 release, the held number is exposed for one 35 s window of asking. That is shorter than either consumer hold (35 < 75 < 85), so the argument still holds (`test_freshness.py` 10c).
## Before → after
- **Live replay:** 4 trips → **0**.
- **Rig** (10 s lag, N=1 and N=2, 1/2/5 s meters, two register shapes: 10 s publish with repeats, and 1 s with ±1 W): rest→step, drain start, drain→charge and charge reversal, 48 runs.
- Before: **27 stale trips and 1 refusal**.
- After: **0 and 0**.
- **Reversals** (1500/2500 W of charge into a 300–2000 W house, lag 10 and 20 s, 1 s meter):
- Before: N=1 had 6 stale trips and 3 refusals; N=2 had 6 and 1.
- After: **0**, also at 2 s.
- **16-day loop capture** (`test_freshness.py` part 8): 93 trips → **59**. Trips answered within 20 s: 21 → 1.
- Two removed trips were not quick ones:
- a register held 351 s under a command hovering at the 250 W travel edge (survivor 2's shape);
- a refusal the gate had only caught on a passing ramp, which SAFETY-52's refusal path covers.
- **Still caught:** a frozen register (35 s after the ask), a register frozen at rest, and a refusing battery, at N=1 and N=2 (part 3).
Re-pinned, on purpose:
- `test_freshness.py`:
- part 8 keeps the pre-94 history, replayed on a `_Before94` gate, and adds the new figures;
- survivor 5 is now graded as closed;
- 10c exposure is 631/36 reads, was 630/35;
- the 10h re-trip comes at 114 s, was 92 s.
- `test_modes.py`: the SAFETY-87 counterfactual digest. A, cut out, now trips at 1640 s, was 1635 s. The shipped n2, n2_off and n2_maint digests are unchanged.
- `test_clamp.py`: the runaway fixture now runs on simulated 2 s ticks, because the 22 s span cannot elapse on back-to-back real-clock cycles.
All 61 test files pass locally in the CI image.
## Mutations
Each mutation ran on a fresh copy with no `.pyc` (0 left), mounted `:ro`, under `python3 -B`. Each was graded by test_safety94, test_freshness, test_refusal, test_refusal_units, test_clamp, test_control and test_modes.
| # | mutation | result |
|---|---|---|
| M0 | none (control) | all green |
| M1 | gate clock from the last change again (`graded_s`) | red: safety94 8, freshness 6, modes 1 |
| M2 | the ask never accumulates | red: safety94 4, freshness 54, modes 1 |
| M3 | a register change does not reset the count | red: safety94 3, freshness 7 |
| M4 | the window-opening read also resets `last_read` | **survived: equivalent**. The opening read can never be an ask, because travel is 0 by construction, so the simpler form ships |
| M5 | the ask drops the travel term | red: safety94 4, freshness 3, modes 1 |
| M6 | `compute()` ignores `gave_nothing` | red: safety94 3 |
| M7 | N=1 passes `gave_nothing=None` | red: safety94 2 |
| M8 | the 50 W progress restart removed | red: safety94 2 |
| M9 | `REFUSED_PROGRESS_W = 0` | red: safety94 3, refusal 10, refusal_units 34, clamp 1, modes 2 |
| M10 | `REFUSED_PROGRESS_W = 400` (+0 B) | red: safety94 3 |
| M11 | the 22 s span ignored | red: safety94 2, refusal_units 1 |
| M12 | `_oblige` after `_grade` (+0 B) | red: safety94 1, freshness 13, modes 1 |
## Other cards
- **SAFETY-58: fixed.** Its card asks for the clock to start with the obligation, and for the 35 s bound to be re-derived. Both are done here.
- **SAFETY-74: not fixed.** The test: a battery capped at 300 W with a perfectly flat reading, a 5 s meter and a stepping house.
- Trips per hour: before 50 (N=1) and 80 (N=2); after 37 and 60. With ±1 W of dither there are none.
- A flat reading under a command that has moved is exactly the case the gate exists for. Telling "limited" from "frozen" needs a second signal, such as the meter or SoC.
- **Open (pre-existing, not changed):** B's standby offset (−95 to −118 W at idle) puts a refusing B outside the 100 W band part of the time, so a refusing B may not latch.
No version or CHANGELOG change.
## SAFETY-97 folded in (review F1)
B's power reading shows about −100 W at idle (standby draw), so the `|reading| < 100 W` band never held a refusing B: never at N=2, and since this PR's shared rule not at N=1 either. Record: `goodwe_controller/DECISIONS/SAFETY-97-idle-offset.md`. Tests: `test_safety97.py`.
**Design**
- Each unit learns its idle reading (`app/idle.py`): only while control runs, the unit is not refused, stale or sign-tripped, and its command has stayed within 50 W for 120 s. First value: a 120 s mean. Then it follows drift slowly (30 min).
- Bounds: a sample beyond 250 W is dropped. The band is centred on the learned value clamped to ±75 W (owner, 2026-10-04: the band always keeps 0 W ±25 W; beyond about ±170 W of idle a battery behaves as in 0.14.0). It is saved per unit and restored at start, clamped to 250 W. Until it is learned it is 0 W, which is the old rule.
- Centred on it (`|reading − idle| < 100 W`): the shared evidence, `compute()`'s N=1 engage and end, the unit refusal ends, the stale-path refusal and SAFETY-42's "parked". SAFETY-37, SAFETY-57 and SAFETY-30 are unchanged; the reasons are in DECISIONS.
- No new option. The HA integration's DC battery-power entity was weighed and not taken; the reasons are in DECISIONS.
**Refused, seconds after the cut** (rig: 400 s rest, then asked, cut at 600 s, ±4 W)
| refusing | 1 s meter | 2 s | 5 s | before |
|---|---|---|---|---|
| A, N=1 | 28 | 28 | 65 | same |
| A, N=2 | 28 | 28 | 110* | same |
| B, N=1 | 28 | 28 | 65 | never |
| B, N=2 | 24 | 24 | 60 | never |
\* The rig's meter goes flat, so the loop cycles on the 15 s floor. The PR without SAFETY-97 gives the same.
- A B that is refusing at start, with its idle reading restored: 24 s. With nothing saved: never (see open points).
- A healthy B with its offset, in SAFETY-94's four scenarios (96 runs), plus reversals: 0 stale, 0 refused.
- Live replay: 0 trips.
**Mutations** (fresh copies, 0 `.pyc`, `:ro`, `python3 -B`; graded by safety97, safety94, refusal, refusal_units, control and delivery)
| # | mutation | result |
|---|---|---|
| S0 | control | green |
| S1 | band not centred (`_gave_nothing`) | red: safety97 5 |
| S2 | N=1 engage not centred (offset at N=2 only) | red: safety97 2 |
| S3 | offset unbounded | red: safety97 3 |
| S4 | learned while refused | red: safety97 1 |
| S5 | learned under any command | red: safety97 4 |
| S6 | no quiet time | red: safety97 2 |
| S7 | unit refusal end not centred | red: safety97 1 |
| S8 | not restored after restart | red: safety97 1 |
| S9 | `compute()` end not centred | red: safety97 1 |
| S10 | stale-path refusal (N>1) not centred | survived → red after a new check (5 s meter) |
| S11 | fast follow (τ 1 s) | red: safety97 1 |
| S12 | `idle_w` not passed to `compute()` | red: safety97 1 |
**Open points**
- Not mutation-tested: the SAFETY-42 "parked" centring, and the N=1 stale-path centring (the same expression as the N>1 one that S10 tests).
- A battery that is never asked about 0 W for 2 minutes never learns its idle reading. Such a site keeps the old rule until it rests once.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
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>
- test_safety94 part 4: time under 250 W is not counted; a command that
swaps direction every cycle is never refusal evidence; a reading moving
away from the ask does not restart the evidence. These kill review
mutants R1, R3 and R6, which survived the PR's tests.
- DECISIONS: at N=1 a battery with B's standby offset no longer latches
a refusal (SAFETY-97 now covers N=1); a part-time ask is caught after
35/d s of wall time.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pushed dd5eb6f: three tests and two lines in DECISIONS. Everything else in the PR checks out. One finding blocks the merge (F1). It needs a decision, not more code.
F1 (blocking): at N=1, a refusing battery with B's −100 W standby offset never latches
Before, compute() checked the reading at one instant. A reading wandering −95…−118 W (B's live idle values) was caught as soon as one cycle landed under 100 W.
Now N=1 uses the shared rule, which needs 12 cycles in the band in a row. That is SAFETY-73's N=2 gap (SAFETY-97), now also at N=1.
Review probe (scratch/SAFETY-94/review/probes/refusal_probe.py, rig battery cut at 300 s, reading drawn from B's live idle values):
N=1, meter
before
after
1 s
latched in 17–78 s
never (1200 s)
2 s
28–78 s
never
5 s
65–105 s
never
At N=2 nothing changes: it never latched before either.
A flat battery (−4 W) is unaffected: 28 s at 1–2 s meters, 45 s at 5 s.
I tried a narrow N=1-only fix: hold the count, rather than reset it, for readings just outside the band. It latches, but in 46–660 s. It is a half-measure because the charge direction stays broken. The real fix is an offset-aware band, which is SAFETY-97's design and also changes N=2.
Recorded under Known gaps in DECISIONS. Options for the manager:
(a) merge, and extend SAFETY-97 to N=1 before any N=1 client with an offset battery;
(b) hold this PR until SAFETY-97 lands;
(c) fold SAFETY-97 in here.
F2 (fixed, docs): a command that asks only part of the time is caught after 35/d s
The gate counts only reads at which the command asks. A command that asks a fraction d of the time is therefore caught after 35/d s of wall time. The PR's own 0/1500 W flap case gives 69 s.
SAFETY-30's per-unit check restarts when its command stops opposing the held reading, so 35 < 85 still holds there.
SAFETY-31's circulation check (75 s) does not look at the command. Its margin holds only while d ≥ 0.47.
Before, a frozen register was caught ≤ 35 s after it froze whatever the command did. So this case is slower, and it is now written down in DECISIONS.
F3 (fixed): three mutants survived the PR's tests
#
mutation
before my tests
after
R1
the count also adds up while the command is under 250 W
red only on the 16-day count pin
red: new check, time under 250 W adds nothing
R3
one-direction check dropped from the evidence restart
survived
red: a command that swaps direction every cycle is never evidence
R6
progress counted either way (abs) instead of towards the ask
survived
red: a reading moving 80 W away does not restart the evidence
All three new checks are in test_safety94.py part 4.
Checks with no finding
Frozen at rest (table row "23–35 s"). At rest nothing is asked, so the 35 s starts only when the command first asks (≥ 250 W and moved ≥ 250 W).
A register frozen near 0 W reads like a refusal. It is refused at 22–28 s (1 s meter), or goes stale at 35 s.
Worst exposure for a frozen register under a big continuous ask: stale at 35 s (+1 read). Then inputs_ok fails, the site holds 15 s (stale_input_s) and commands 0 W at about 51 s.
Before, the worst case was the same 51 s, but a register that froze long before the ask was caught in about 16 s.
During those seconds the loop still regulates on the P1 meter. Only the per-unit consumers see the held number: SAFETY-30 85 s, SAFETY-31 75 s, SAFETY-42 185 s.
The heartbeat and the board watchdog only cover the add-on stopping. They do not bound this case.
Refusal restart on 50 W of progress. It cannot be gamed indefinitely. ref_from moves only up, to the reading that restarted it, and the reading must stay under 100 W. So an uninterrupted stretch allows at most 3 restarts.
Trickle (+60 W for one publish every 60 s): no delay.
Sawtooth: +10 s.
Noise on the register, 2000 seeds each:
±25 W: no delay (22 s / 55 s);
±50 W: p99 42 s at a 1 s meter, 105 s at 5 s;
±75 W: worst 62 s / 135 s.
Every seed latched. Live register noise is ±1–4 W.
Re-pins (test_freshness.py part 8, S5, 10c and 10h; the test_modes.py S87 counterfactual; the test_clamp.py 2 s ticks): each changed only for its stated reason. The shipped n2, n2_off and n2_maint digests are untouched.
Mutants M1, M3, M5, M8, M11 and M12 re-run on purged copies: all red, as reported. M12's test_safety94 red is a TypeError in the test's Before94 helper, but test_freshness has 3 genuine failures. Also red: R2 (no count reset at a SAFETY-57 release), R4 (span 22 → 2 s), R5 (N=2 back on the old inline rule), R7, R8 and R9.
Wording and docs are fine. No version or CHANGELOG change.
## Review, round 1 (head dd5eb6f)
Pushed `dd5eb6f`: three tests and two lines in DECISIONS. Everything else in the PR checks out. **One finding blocks the merge (F1). It needs a decision, not more code.**
### F1 (blocking): at N=1, a refusing battery with B's −100 W standby offset never latches
- Before, `compute()` checked the reading at one instant. A reading wandering −95…−118 W (B's live idle values) was caught as soon as one cycle landed under 100 W.
- Now N=1 uses the shared rule, which needs 12 cycles in the band in a row. That is SAFETY-73's N=2 gap (SAFETY-97), now also at N=1.
- Review probe (`scratch/SAFETY-94/review/probes/refusal_probe.py`, rig battery cut at 300 s, reading drawn from B's live idle values):
| N=1, meter | before | after |
|---|---|---|
| 1 s | latched in 17–78 s | **never (1200 s)** |
| 2 s | 28–78 s | **never** |
| 5 s | 65–105 s | **never** |
- At N=2 nothing changes: it never latched before either.
- A flat battery (−4 W) is unaffected: 28 s at 1–2 s meters, 45 s at 5 s.
- I tried a narrow N=1-only fix: hold the count, rather than reset it, for readings just outside the band. It latches, but in 46–660 s. It is a half-measure because the charge direction stays broken. The real fix is an offset-aware band, which is SAFETY-97's design and also changes N=2.
- Recorded under Known gaps in DECISIONS. Options for the manager:
- (a) merge, and extend SAFETY-97 to N=1 before any N=1 client with an offset battery;
- (b) hold this PR until SAFETY-97 lands;
- (c) fold SAFETY-97 in here.
### F2 (fixed, docs): a command that asks only part of the time is caught after 35/d s
- The gate counts only reads at which the command asks. A command that asks a fraction d of the time is therefore caught after 35/d s of wall time. The PR's own 0/1500 W flap case gives 69 s.
- SAFETY-30's per-unit check restarts when its command stops opposing the held reading, so 35 < 85 still holds there.
- SAFETY-31's circulation check (75 s) does not look at the command. Its margin holds only while d ≥ 0.47.
- Before, a frozen register was caught ≤ 35 s after it froze whatever the command did. So this case is slower, and it is now written down in DECISIONS.
### F3 (fixed): three mutants survived the PR's tests
| # | mutation | before my tests | after |
|---|---|---|---|
| R1 | the count also adds up while the command is under 250 W | red only on the 16-day count pin | red: new check, time under 250 W adds nothing |
| R3 | one-direction check dropped from the evidence restart | **survived** | red: a command that swaps direction every cycle is never evidence |
| R6 | progress counted either way (`abs`) instead of towards the ask | **survived** | red: a reading moving 80 W away does not restart the evidence |
All three new checks are in `test_safety94.py` part 4.
### Checks with no finding
- **Frozen at rest (table row "23–35 s").** At rest nothing is asked, so the 35 s starts only when the command first asks (≥ 250 W and moved ≥ 250 W).
- A register frozen near 0 W reads like a refusal. It is refused at 22–28 s (1 s meter), or goes stale at 35 s.
- Worst exposure for a frozen register under a big continuous ask: stale at 35 s (+1 read). Then `inputs_ok` fails, the site holds 15 s (`stale_input_s`) and commands 0 W at about 51 s.
- Before, the worst case was the same 51 s, but a register that froze long before the ask was caught in about 16 s.
- During those seconds the loop still regulates on the P1 meter. Only the per-unit consumers see the held number: SAFETY-30 85 s, SAFETY-31 75 s, SAFETY-42 185 s.
- The heartbeat and the board watchdog only cover the add-on stopping. They do not bound this case.
- **Refusal restart on 50 W of progress.** It cannot be gamed indefinitely. `ref_from` moves only up, to the reading that restarted it, and the reading must stay under 100 W. So an uninterrupted stretch allows at most 3 restarts.
- Trickle (+60 W for one publish every 60 s): no delay.
- Sawtooth: +10 s.
- Noise on the register, 2000 seeds each:
- ±25 W: no delay (22 s / 55 s);
- ±50 W: p99 42 s at a 1 s meter, 105 s at 5 s;
- ±75 W: worst 62 s / 135 s.
- Every seed latched. Live register noise is ±1–4 W.
- **Re-pins** (`test_freshness.py` part 8, S5, 10c and 10h; the `test_modes.py` S87 counterfactual; the `test_clamp.py` 2 s ticks): each changed only for its stated reason. The shipped n2, n2_off and n2_maint digests are untouched.
- **Mutants M1, M3, M5, M8, M11 and M12** re-run on purged copies: all red, as reported. M12's `test_safety94` red is a TypeError in the test's `Before94` helper, but `test_freshness` has 3 genuine failures. Also red: R2 (no count reset at a SAFETY-57 release), R4 (span 22 → 2 s), R5 (N=2 back on the old inline rule), R7, R8 and R9.
- Wording and docs are fine. No version or CHANGELOG change.
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
At a 5 s meter a register frozen at rest on B trips the stale gate before
the 12-cycle evidence; its refusal must be centred on B's idle reading,
or the site stops and re-trips instead of refusing B.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
The N=1 stale-path refusal and SAFETY-42's parked test are centred on the
idle reading; learning stops while control is stopped or the sign watchdog
has latched. Each had no check (review mutants X7, X8, X13, X14).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review round 1 (SAFETY-94 + SAFETY-97), head 88ebf3a
Not mergeable yet: R1 needs an owner decision. Everything else is fixed or refuted below.
R1: a wrong idle reading hides every refusal on that battery. Blocking, owner decision.
A refusing battery is caught only while |reading − idle| < 100 W.
So an idle value that is off by about 100 W or more, whether learned or restored, means a refusal on that battery is never caught.
The stale gate does not catch it either, because the refusing reading jitters.
This holds at N=1 and at N=2.
With the ±250 W clamp, a battery reading anything from 150 to 350 W can count as "gives nothing", and a battery reading 0 W does not.
Rig: battery cut at 600 s, ±4 W noise, 1 s and 5 s meters. The idle reading was taught wrong by E during the first 400 s rest, or E was restored wrong.
unit
E caught (PR head)
E missed (never, in 300 s)
A (true 0 W), N=1 and N=2
−90 … +90
≤ −110 and +110 … +250
B (true −100 W), N=1 and N=2
−90 … +90
≤ −110 and ≥ +110
How it can go wrong: learning only needs the command within 50 W for 120 s. That happens when:
the command is held at ~0 W while the reading shows something else, for example a unit at its floor that the pack limit holds at 0 W while the inverter tops it up from the grid at under 250 W;
a register freezes at 100–250 W while the site rests;
a different inverter is put behind the same name, so the restored value belongs to the old one.
Recovery happens only through quiet time, with τ = 30 min. An error of 250 W is back under 100 W after about 30 min of quiet. An error of 350 W takes about 40 min.
At N=1, 0.14.0 always caught an A-type battery, so this is a new regression path.
Options
(a) Keep the ±250 W clamp. Covers batteries whose idle reading is up to about ±350 W. Any mis-learn of 100 W or more hides refusals.
(b) Clamp the value used to ±75 W, and keep accepting samples up to 250 W. Recommended. The band then always keeps 0 W ± 25 W.
Rig, same sweep: A is caught at every E, both learned and restored. B is caught for E from −150 to +90.
B learned 110 W or more too high is still missed. That is 0.14.0's N=2 state.
It also means a wrong idle can never falsely refuse a 0 W-offset battery: an ask over 200 W minus 75 W is still more than 100 W.
Cost: a battery whose idle reading is beyond about ±170 W is never caught, as in 0.14.0.
On B (−95…−118 W) the band stays 60 W or more inside.
test_safety94 passes, and so do refusal, refusal_units, delivery and control. In test_safety97, only the checks that pin −100 W or 250 W change.
(c) "Old band OR centred band". Rejected. A correct B would then need to give 200 W to end its refusal, so a 200 W probe reading 82–105 W would not answer.
X14: learning while the sign watchdog has latched.
Each one is now red in test_safety97.py with a non-vacuous check.
R3: equivalent or wording-only survivors. Refuted, no test.
X3: the learned-value clamp raised to 1000 W. Samples over 250 W are already dropped, so a mean or a drift cannot pass 250 W. Only the restore clamp matters, and X5 (restore unclamped) is red.
X10: learning while stale. A stale reading is None, and inv.batt is not None already blocks it.
X9: the N=1 end in _refusal_update uncentred. It changes only which end reason is logged; compute() decides the end, and S9 is red.
10-03 live replay with full HA history, 1 Hz: base 4 trips, PR 0.
I found no HA history for 2026-10-04 (08:45, 08:48, 09:00, 09:45), so those four are not verified here. They should be checked on the rig or the live log after deploy.
The refusal-evidence and freshness mutants M1, M3, M8, R3, R6, R7 and S1, S2, S4, S6, S7, S9, S10, S12 are red. So are my X1, X2, X4, X4c, X5, X6, X11, X12, X15 and X16.
Store: corrupt, NaN, inf, string and huge saved values give 0 W or are clamped. A renamed unit starts at 0 W, the old rule.
Swapping A for B behind one name: B's −100 W on a 0 W unit is caught only at the band's edge (96 W). A's −3 W on B is missed for about 5 min of quiet.
Unchanged checks (SAFETY-37, -57, -30, and SAFETY-42 shortfall): the reasons match the code.
Docs are plain. No version or CHANGELOG change, and no new user-facing strings.
F2 (35/d s) stands as recorded.
## Review round 1 (SAFETY-94 + SAFETY-97), head 88ebf3a
**Not mergeable yet: R1 needs an owner decision.** Everything else is fixed or refuted below.
### R1: a wrong idle reading hides every refusal on that battery. Blocking, owner decision.
- A refusing battery is caught only while `|reading − idle| < 100 W`.
- So an idle value that is off by about 100 W or more, whether learned or restored, means a refusal on that battery is **never** caught.
- The stale gate does not catch it either, because the refusing reading jitters.
- This holds at N=1 and at N=2.
- With the ±250 W clamp, a battery reading anything from 150 to 350 W can count as "gives nothing", and a battery reading 0 W does not.
Rig: battery cut at 600 s, ±4 W noise, 1 s and 5 s meters. The idle reading was taught wrong by E during the first 400 s rest, or E was restored wrong.
| unit | E caught (PR head) | E missed (never, in 300 s) |
|---|---|---|
| A (true 0 W), N=1 and N=2 | −90 … +90 | ≤ −110 and +110 … +250 |
| B (true −100 W), N=1 and N=2 | −90 … +90 | ≤ −110 and ≥ +110 |
- How it can go wrong: learning only needs the command within 50 W for 120 s. That happens when:
- the command is held at ~0 W while the reading shows something else, for example a unit at its floor that the pack limit holds at 0 W while the inverter tops it up from the grid at under 250 W;
- a register freezes at 100–250 W while the site rests;
- a different inverter is put behind the same name, so the restored value belongs to the old one.
- Recovery happens only through quiet time, with τ = 30 min. An error of 250 W is back under 100 W after about 30 min of quiet. An error of 350 W takes about 40 min.
- At N=1, 0.14.0 always caught an A-type battery, so this is a new regression path.
**Options**
- **(a) Keep the ±250 W clamp.** Covers batteries whose idle reading is up to about ±350 W. Any mis-learn of 100 W or more hides refusals.
- **(b) Clamp the value used to ±75 W, and keep accepting samples up to 250 W.** Recommended. The band then always keeps 0 W ± 25 W.
- Rig, same sweep: A is caught at every E, both learned and restored. B is caught for E from −150 to +90.
- B learned 110 W or more too high is still missed. That is 0.14.0's N=2 state.
- It also means a wrong idle can never falsely refuse a 0 W-offset battery: an ask over 200 W minus 75 W is still more than 100 W.
- Cost: a battery whose idle reading is beyond about ±170 W is never caught, as in 0.14.0.
- On B (−95…−118 W) the band stays 60 W or more inside.
- test_safety94 passes, and so do refusal, refusal_units, delivery and control. In test_safety97, only the checks that pin −100 W or 250 W change.
- **(c) "Old band OR centred band".** Rejected. A correct B would then need to *give* 200 W to end its refusal, so a 200 W probe reading 82–105 W would not answer.
### R2: four mutants survived. Fixed in 88ebf3a.
- X7: the N=1 stale-path refusal uncentred.
- X8: SAFETY-42 "parked" uncentred.
- X13: learning while control is stopped.
- X14: learning while the sign watchdog has latched.
- Each one is now red in `test_safety97.py` with a non-vacuous check.
### R3: equivalent or wording-only survivors. Refuted, no test.
- X3: the learned-value clamp raised to 1000 W. Samples over 250 W are already dropped, so a mean or a drift cannot pass 250 W. Only the restore clamp matters, and X5 (restore unclamped) is red.
- X10: learning while stale. A stale reading is `None`, and `inv.batt is not None` already blocks it.
- X9: the N=1 end in `_refusal_update` uncentred. It changes only which end reason is logged; `compute()` decides the end, and S9 is red.
### Checked and fine
- Full suite on 3db2b63: 62/62 pass.
- 10-03 live replay with full HA history, 1 Hz: base 4 trips, PR 0.
- I found no HA history for 2026-10-04 (08:45, 08:48, 09:00, 09:45), so those four are not verified here. They should be checked on the rig or the live log after deploy.
- The refusal-evidence and freshness mutants M1, M3, M8, R3, R6, R7 and S1, S2, S4, S6, S7, S9, S10, S12 are red. So are my X1, X2, X4, X4c, X5, X6, X11, X12, X15 and X16.
- Store: corrupt, NaN, inf, string and huge saved values give 0 W or are clamped. A renamed unit starts at 0 W, the old rule.
- Swapping A for B behind one name: B's −100 W on a 0 W unit is caught only at the band's edge (96 W). A's −3 W on B is missed for about 5 min of quiet.
- Unchanged checks (SAFETY-37, -57, -30, and SAFETY-42 shortfall): the reasons match the code.
- Docs are plain. No version or CHANGELOG change, and no new user-facing strings.
- F2 (35/d s) stands as recorded.
Owner decision 2026-10-04 (review R1). A wrong idle reading more than
about 100 W off hid every refusal on that battery. Clamped under the band,
the band keeps 0 W +-25 W, so a battery reading about 0 W while refusing is
caught whatever was learned or restored. Samples are still taken to 250 W.
Beyond about +-170 W of idle a battery behaves as in 0.14.0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
R1: fixed, per the owner's decision of 2026-10-04 (option b)
The band is now centred on the idle reading clamped to ±75 W (IDLE_MAX_W). Samples are still accepted up to 250 W (IDLE_SAMPLE_W), and the saved value is restored clamped to 250 W. The owner's decision, the trade-off, and the note that a battery idling beyond about ±170 W behaves as in 0.14.0 are recorded in DECISIONS.
New checks in test_safety97.py part 3b, 1 s meter, N=1 and N=2:
A reads about 0 W and refuses, with its idle reading off by −250/−150/+150/+250 W (taught wrong), or restored at −250/+200/+250 W: refused at 28 s in every case. That includes the +200 W wrong idle_w.
B with its idle reading taught −150/−50/+90 W wrong: refused at 28 s (N=1) and 24 s (N=2).
Mutants on the new code:
id
mutation
result
Y1
clamp back to 250 W
red (the ⭐ wrong-idle check, plus the pins)
Y2
clamp applied to the samples instead of the value used
red (B's catch, plus three more)
Y3
value used unclamped
red
Y5
restore ignores the clamp
red
S1, S10, X4, X7, X8, X13, X14
as in round 1
red
M0
no change (control)
green
X5 and X11 no longer apply as written. Y5 replaces X5. Dropping the sample filter is now covered by Y2 and by the over-250 W sample check.
R4: the 2026-10-04 live trips, replayed at 1 Hz
I replayed 08:30–10:00 local from the HA history through the gate.
Base 02ed120: 4 trips, at A 08:45:05, B 08:48:06, B 09:00:06 and A 09:45:05. Each is within 1 s of a live ERROR line.
PR head: 0 trips.
The 10-03 history also still replays at 4 trips on the base and 0 on the head.
No version, CHANGELOG or user-facing string change.
Nothing is left open from my side. I will merge once checks / unit is green on 7d06aa6.
## Review round 2, head 7d06aa6
### R1: fixed, per the owner's decision of 2026-10-04 (option b)
The band is now centred on the idle reading clamped to **±75 W** (`IDLE_MAX_W`). Samples are still accepted up to 250 W (`IDLE_SAMPLE_W`), and the saved value is restored clamped to 250 W. The owner's decision, the trade-off, and the note that a battery idling beyond about ±170 W behaves as in 0.14.0 are recorded in DECISIONS.
New checks in `test_safety97.py` part 3b, 1 s meter, N=1 and N=2:
- A reads about 0 W and refuses, with its idle reading off by −250/−150/+150/+250 W (taught wrong), or restored at −250/+200/+250 W: refused at 28 s in every case. That includes the +200 W wrong `idle_w`.
- B with its idle reading taught −150/−50/+90 W wrong: refused at 28 s (N=1) and 24 s (N=2).
Mutants on the new code:
| id | mutation | result |
|---|---|---|
| Y1 | clamp back to 250 W | red (the ⭐ wrong-idle check, plus the pins) |
| Y2 | clamp applied to the samples instead of the value used | red (B's catch, plus three more) |
| Y3 | value used unclamped | red |
| Y5 | restore ignores the clamp | red |
| S1, S10, X4, X7, X8, X13, X14 | as in round 1 | red |
| M0 | no change (control) | green |
X5 and X11 no longer apply as written. Y5 replaces X5. Dropping the sample filter is now covered by Y2 and by the over-250 W sample check.
### R4: the 2026-10-04 live trips, replayed at 1 Hz
I replayed 08:30–10:00 local from the HA history through the gate.
- Base 02ed120: **4 trips**, at A 08:45:05, B 08:48:06, B 09:00:06 and A 09:45:05. Each is within 1 s of a live ERROR line.
- PR head: **0 trips**.
- The 10-03 history also still replays at 4 trips on the base and 0 on the head.
### Other results
- Full suite on 7d06aa6: 62/62 pass.
- No version, CHANGELOG or user-facing string change.
Nothing is left open from my side. I will merge once `checks / unit` is green on 7d06aa6.
admin
merged commit cf705a9058 into release/1.02026-10-04 17:27:21 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
dd5eb6fdc8openSAFETY-94. Also fixes SAFETY-58. SAFETY-74 stays open (see below). Record:
goodwe_controller/DECISIONS/SAFETY-94-lagging-battery.md. Tests:test_safety94.py, with the rig intools/safety94_rig.py.Root cause
1. The stale-reading gate (SAFETY-37) started its 35 s clock at the register's last change. After a rest, that clock was already past 35 s. So the first read after a new command tripped the gate, before a healthy register could answer. The gate assumed the register would keep changing; in fact it holds one value while nothing asks it to move.
test_safety94.pypart 1 replays the HA history and reproduces all four to the second.2. The N=1 refusal (SAFETY-52) checked the reading at one moment only.
compute()engaged after 12 frozen cycles if the reading was under 100 W at that moment.Design
Freshness._oblige).MOVE_W,RELEASE_Sand the log/UI text are unchanged. Only a DEBUG line's wording changed.Controller._gave_nothing, which is SAFETY-73's count moved into a method).compute(gave_nothing=...)requires this on top of its own freeze test.None(compute alone) keeps the old law.REFUSED_PROGRESS_W).New bounds and worst-case time to catch a real fault
The bound is still 35 s. It now measures what its derivation always assumed: the time from the ask to the answer.
After a SAFETY-57 release, the held number is exposed for one 35 s window of asking. That is shorter than either consumer hold (35 < 75 < 85), so the argument still holds (
test_freshness.py10c).Before → after
test_freshness.pypart 8): 93 trips → 59. Trips answered within 20 s: 21 → 1.Re-pinned, on purpose:
test_freshness.py:_Before94gate, and adds the new figures;test_modes.py: the SAFETY-87 counterfactual digest. A, cut out, now trips at 1640 s, was 1635 s. The shipped n2, n2_off and n2_maint digests are unchanged.test_clamp.py: the runaway fixture now runs on simulated 2 s ticks, because the 22 s span cannot elapse on back-to-back real-clock cycles.All 61 test files pass locally in the CI image.
Mutations
Each mutation ran on a fresh copy with no
.pyc(0 left), mounted:ro, underpython3 -B. Each was graded by test_safety94, test_freshness, test_refusal, test_refusal_units, test_clamp, test_control and test_modes.graded_s)last_readcompute()ignoresgave_nothinggave_nothing=NoneREFUSED_PROGRESS_W = 0REFUSED_PROGRESS_W = 400(+0 B)_obligeafter_grade(+0 B)Other cards
No version or CHANGELOG change.
SAFETY-97 folded in (review F1)
B's power reading shows about −100 W at idle (standby draw), so the
|reading| < 100 Wband never held a refusing B: never at N=2, and since this PR's shared rule not at N=1 either. Record:goodwe_controller/DECISIONS/SAFETY-97-idle-offset.md. Tests:test_safety97.py.Design
app/idle.py): only while control runs, the unit is not refused, stale or sign-tripped, and its command has stayed within 50 W for 120 s. First value: a 120 s mean. Then it follows drift slowly (30 min).|reading − idle| < 100 W): the shared evidence,compute()'s N=1 engage and end, the unit refusal ends, the stale-path refusal and SAFETY-42's "parked". SAFETY-37, SAFETY-57 and SAFETY-30 are unchanged; the reasons are in DECISIONS.Refused, seconds after the cut (rig: 400 s rest, then asked, cut at 600 s, ±4 W)
* The rig's meter goes flat, so the loop cycles on the 15 s floor. The PR without SAFETY-97 gives the same.
Mutations (fresh copies, 0
.pyc,:ro,python3 -B; graded by safety97, safety94, refusal, refusal_units, control and delivery)_gave_nothing)compute()end not centredidle_wnot passed tocompute()Open points
🤖 Generated with Claude Code
https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
Review, round 1 (head
dd5eb6f)Pushed
dd5eb6f: three tests and two lines in DECISIONS. Everything else in the PR checks out. One finding blocks the merge (F1). It needs a decision, not more code.F1 (blocking): at N=1, a refusing battery with B's −100 W standby offset never latches
compute()checked the reading at one instant. A reading wandering −95…−118 W (B's live idle values) was caught as soon as one cycle landed under 100 W.scratch/SAFETY-94/review/probes/refusal_probe.py, rig battery cut at 300 s, reading drawn from B's live idle values):F2 (fixed, docs): a command that asks only part of the time is caught after 35/d s
F3 (fixed): three mutants survived the PR's tests
abs) instead of towards the askAll three new checks are in
test_safety94.pypart 4.Checks with no finding
inputs_okfails, the site holds 15 s (stale_input_s) and commands 0 W at about 51 s.ref_frommoves only up, to the reading that restarted it, and the reading must stay under 100 W. So an uninterrupted stretch allows at most 3 restarts.test_freshness.pypart 8, S5, 10c and 10h; thetest_modes.pyS87 counterfactual; thetest_clamp.py2 s ticks): each changed only for its stated reason. The shipped n2, n2_off and n2_maint digests are untouched.test_safety94red is a TypeError in the test'sBefore94helper, buttest_freshnesshas 3 genuine failures. Also red: R2 (no count reset at a SAFETY-57 release), R4 (span 22 → 2 s), R5 (N=2 back on the old inline rule), R7, R8 and R9.Review round 1 (SAFETY-94 + SAFETY-97), head
88ebf3aNot mergeable yet: R1 needs an owner decision. Everything else is fixed or refuted below.
R1: a wrong idle reading hides every refusal on that battery. Blocking, owner decision.
|reading − idle| < 100 W.Rig: battery cut at 600 s, ±4 W noise, 1 s and 5 s meters. The idle reading was taught wrong by E during the first 400 s rest, or E was restored wrong.
Options
R2: four mutants survived. Fixed in
88ebf3a.test_safety97.pywith a non-vacuous check.R3: equivalent or wording-only survivors. Refuted, no test.
None, andinv.batt is not Nonealready blocks it._refusal_updateuncentred. It changes only which end reason is logged;compute()decides the end, and S9 is red.Checked and fine
3db2b63: 62/62 pass.Review round 2, head
7d06aa6R1: fixed, per the owner's decision of 2026-10-04 (option b)
The band is now centred on the idle reading clamped to ±75 W (
IDLE_MAX_W). Samples are still accepted up to 250 W (IDLE_SAMPLE_W), and the saved value is restored clamped to 250 W. The owner's decision, the trade-off, and the note that a battery idling beyond about ±170 W behaves as in 0.14.0 are recorded in DECISIONS.New checks in
test_safety97.pypart 3b, 1 s meter, N=1 and N=2:idle_w.Mutants on the new code:
X5 and X11 no longer apply as written. Y5 replaces X5. Dropping the sample filter is now covered by Y2 and by the over-250 W sample check.
R4: the 2026-10-04 live trips, replayed at 1 Hz
I replayed 08:30–10:00 local from the HA history through the gate.
02ed120: 4 trips, at A 08:45:05, B 08:48:06, B 09:00:06 and A 09:45:05. Each is within 1 s of a live ERROR line.Other results
7d06aa6: 62/62 pass.Nothing is left open from my side. I will merge once
checks / unitis green on7d06aa6.