5.6 KiB
SAFETY-107 - a refusal held under another writer is learned once
Status: for review. Tests: test_safety107.py. One inverter only; nothing
changes at N>1 (SAFETY-73's unit refusal has no "another writer" end).
The defect (MAINT-13 F5, MAINT-14 "seen again"). A full pack under a maintenance charge in a cheap window takes no charge. At N=1 the action log then showed "BATTERY REFUSES asked -2500 W ..." and "BATTERY REFUSES ENDED: another writer" every cycle: 548 + 548 lines in 15 min on the MAINT-13 rig head, 652 + 651 in 12 min on its base, 33 + 32 in 60 s in MAINT-14's c2b. The 40-line log flooded and the hint under the buttons changed every second, so the MAINT-13 notice was gone at once.
Cause (main.py, the SAFETY-52 block before compute()). The end tested
self.target != self.loop_w: the wire differs from the loop's ask. A held
refusal makes the loop ask 0 W, so any claim that binds (the maintenance
charge's hi, the peak guard's lo, a trade's SET) differs from it on every
cycle. The refusal was ended against the same writer it had been learned
against. sat_count is not reset by that end, so compute() learned the same
refusal again in the same cycle. Nothing about the battery or the wire had
changed; only the log and the hint did.
Other writers. The same flap comes from any writer that holds the wire
while the loop is refused, as long as the evidence lasts: a trade (a SET at
P_ARBITRAGE) into a full pack, and the peak guard over an empty pack: 335
refusal lines in 15 min each at a 5 s meter (test_safety107.py part 2, run on
release/1.0).
A maintenance drain or hold does not flap: its SET resets sat_count each cycle.
The rule (_refusal_update(), rejudged). When another writer held the
wire, the held refusal is judged afresh: compute() gets refused=0 and no
probe, exactly as before the change. Then:
- it finds the same refusal against that writer's ask: the same episode continues, with no line and no hint. The probe clock restarts, as it did on the old end and start: a probe is the loop's ask, and it is not on the wire while another writer is;
- it does not: the refusal ends, logged "another writer", or "the battery gives power" when the reading shows it (it was "another writer" before);
- it finds the other direction: end, then a new start, as before. In
practice 2 comes first: SAFETY-94's evidence restarts when the ask changes
sign, so a writer that turns round ends the refusal at once and the other
way is learned ~12 cycles later (
test_safety107.pypart 3e).
Why this rule. The end now means what SAFETY-52 meant by it: the refusal no
longer describes what is on the wire, judged by the same test that learns it.
compute() sees the same inputs as before and the state after each cycle is
the same (batt_refused, batt_refused_at), so every command is unchanged.
Keeping the held refusal into compute() instead (mutation M5) would also
stop the lines, but it holds the loop at 0 W where the old law let it ask past
a writer's limit, which changes commands; it also removes SAFETY-52's
owner-decided "another writer" exit.
Evidence.
- Differential trace (
goodwe/scratch/SAFETY-107/probe/trace.py, not in this repo; the real Controller in the CI image, release/1.0bf3ac79against this tree): 300 seeds, N=1 (153) and N=2 (147), 1/2/5 s meter, full/empty/dead/healthy battery windows, a stepping house, maintenance charge with the cheap window opening and closing, P1 and the peak guard, trade windows, stop/start; 427 680 cycles, 294 seeds with a refusal held. Every command, site target, integrator,sat_count, refusal state and reason is identical. Refusal lines: 100 748 before, 2 188 after. - The card's case: 1 line in 15 min at N=1 and N=2, 1/2/5 s meter, house importing or exporting (release/1.0: 335-1 753 at N=1; N=2 was already 1).
Mutations (tools/mutation_run.sh on purged copies; reds in
test_safety107 / test_refusal / test_refusal_units; trace = seeds of 300 whose
commands differ from release/1.0):
| mutation | reds | trace | |
|---|---|---|---|
| M1 | now == rejudged -> False (every such cycle ends: the old flap) |
26 / 0 / 0 | 0 |
| M2 | rejudged always 0 (another writer never ends it) |
5 / 1 / 2 | 29 |
| M3 | the probe clock is not restarted on a continued episode | 1 / 0 / 0 | 2 |
| M4 | a probe may open while rejudged | 0 / 0 / 0 | 0 |
| M5 | the held refusal goes into compute() while rejudged |
2 / 1 / 2 | 27 |
| M6 | an end while rejudged always says "another writer" | 1 / 0 / 0 | 0 |
M4 is equivalent: a probe matters only if compute() engages in that call,
which needs SETTLE_CYCLES saturated against the writer, and each of those
cycles restarted the clock. The line stays because it states the rule.
Cost. None in power. A takeover by a different writer while the battery
still refuses is logged as an end and, ~12 cycles later, a new start against
that writer (3 lines for the episode, test_safety107.py part 3a).
Not covered.
- A perfectly flat reading under a writer. The stale-reading gate trips, stops
the site and seeds the refusal (SAFETY-52 decision 3). After the stop the
writer's ask returns fresh,
compute()has no saturation history against it, so the refusal ends at the first cycle and the next trip seeds it again: 2 lines per gate cycle (29 in 15 min, house exporting), the same as release/1.0. The gate cycle itself (0 W, then the writer's ask) is the cause; stopping it changes commands. None of the rig runs showed it: real readings move. - At N=1 a refusal shapes only the loop. A writer above it still asks the battery; at N>1 SAFETY-73's P_SAFETY claim binds every writer.