SAFETY-107: the refusal log flaps every second while maintenance charges a full pack.
Cause.main.py, SAFETY-52's block before compute(): the "another writer" end tested self.target != self.loop_w. 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) differed from it every cycle. The refusal was ended against the same writer it was learned on, and since that end does not reset sat_count, compute() learned it again in the same cycle.
Fix. When another writer held the wire, the held refusal is judged afresh: compute() gets refused=0 and no probe, the same inputs as before. If it finds the same refusal, the episode continues with no line and no hint (the probe clock restarts, as before). If not, the refusal ends: "another writer", or "the battery gives power" when the reading shows it.
Other writers. A trade into a full pack and the peak guard over an empty pack flapped the same way (335 lines in 15 min each on release/1.0); both are fixed by the same rule. N>1 had no flap (SAFETY-73 has no another-writer end) and is unchanged.
Power. Unchanged. A 300-seed differential trace against release/1.0 bf3ac79 (real Controller, CI image, N=1 and N=2, 1/2/5 s meter, maintenance charge, peak guard, trades, stop/start; 427 680 cycles) gives identical commands, site target, integrator, sat_count and refusal state on every cycle. Refusal lines: 100 748 before, 2 188 after.
Tests.test_safety107.py: the card's case at N=1 and N=2, 1/2/5 s meter, house importing and exporting (1 line in 15 min; release/1.0: 335-1 753 at N=1). Other writers, real takeovers, delivery, the other direction, stop, the probe clock. Mutations M1-M6 are in DECISIONS/SAFETY-107 (M4 is equivalent, explained there).
Not covered (DECISIONS): a perfectly flat reading under a writer still gives one start/end pair per stale-gate cycle, the same as release/1.0. Stopping it would change commands.
**SAFETY-107: the refusal log flaps every second while maintenance charges a full pack.**
**Cause.** `main.py`, SAFETY-52's block before `compute()`: the "another writer" end tested `self.target != self.loop_w`. 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) differed from it every cycle. The refusal was ended against the same writer it was learned on, and since that end does not reset `sat_count`, `compute()` learned it again in the same cycle.
**Fix.** When another writer held the wire, the held refusal is judged afresh: `compute()` gets `refused=0` and no probe, the same inputs as before. If it finds the same refusal, the episode continues with no line and no hint (the probe clock restarts, as before). If not, the refusal ends: "another writer", or "the battery gives power" when the reading shows it.
**Other writers.** A trade into a full pack and the peak guard over an empty pack flapped the same way (335 lines in 15 min each on release/1.0); both are fixed by the same rule. N>1 had no flap (SAFETY-73 has no another-writer end) and is unchanged.
**Power.** Unchanged. A 300-seed differential trace against release/1.0 bf3ac79 (real Controller, CI image, N=1 and N=2, 1/2/5 s meter, maintenance charge, peak guard, trades, stop/start; 427 680 cycles) gives identical commands, site target, integrator, `sat_count` and refusal state on every cycle. Refusal lines: 100 748 before, 2 188 after.
**Tests.** `test_safety107.py`: the card's case at N=1 and N=2, 1/2/5 s meter, house importing and exporting (1 line in 15 min; release/1.0: 335-1 753 at N=1). Other writers, real takeovers, delivery, the other direction, stop, the probe clock. Mutations M1-M6 are in DECISIONS/SAFETY-107 (M4 is equivalent, explained there).
**Not covered** (DECISIONS): a perfectly flat reading under a writer still gives one start/end pair per stale-gate cycle, the same as release/1.0. Stopping it would change commands.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
At one inverter SAFETY-52's "another writer" end tested target != loop_w.
A held refusal makes the loop ask 0 W, so any binding claim (maintenance
charge, peak guard, trade) ended it every cycle, and compute() learned it
again in the same cycle: "BATTERY REFUSES" / "ENDED: another writer" each
second on the rig. Now the held refusal is judged afresh against the
writer's ask (compute() gets the same inputs as before); it ends only if
compute() no longer finds it. Commands unchanged: 300-seed differential
trace against release/1.0, N=1 and N=2, every command and refusal state
identical. DECISIONS/SAFETY-107.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
Review of SAFETY-107 (head reviewed: d12228d; I pushed 848140c, tests and docs only)
Root cause: confirmed. On bf3ac79 the "another writer" end (main.py:4977) fires every cycle against the writer the refusal was learned against. _refusal_end() leaves sat_count alone, so compute() learns it again in the same cycle. With this PR's test file on bf3ac79: 1753 / 875 / 335 lines in 15 min at a 1 / 2 / 5 s meter, and 26 checks fail. On the head: 1 line. This fixes the cause; it is not a log rate limit.
Power is unchanged. I checked this by reading the code: compute() gets the same inputs as before (refused=0, probe=False). Nothing reads batt_refused between the old end and _refusal_update(), and each branch leaves batt_refused and batt_refused_at as the old end-and-restart did. The trace agrees. I ran trace.py on 40 new seeds (300-339): base and head match on the cmd, state and full hashes, 0 of 40 differ. My mutants R5 and R6 change it, so it can fail.
Drain and HOLD: I ran my own probe over 96 scenarios (N=1 and N=2; drain, hold, charge or none; empty, full or dead; 2 house levels; 2 meter rates). Commands are identical to base everywhere. Drain and HOLD give 0 refusal lines on both, because the SET resets sat_count (main.py:4792). The only line-count difference is the N=1 charge flap: 3553 / 695 lines before, 1 after.
Findings
Low, fixed in 848140c. Rule 3 (the other direction found while another writer holds the wire) had no test. The mutants R2 (no new start) and R3 (sign ignored) survived the three refusal test files and the trace. In practice the path cannot be reached at N=1: _gave_nothing() (main.py:1463) restarts its evidence when the ask changes sign, so rule 2 ends the refusal first. I added part 3e: a writer turns round on a dead pack, giving one end and then one start the other way. I also added one note to DECISIONS. R2 and R3 still survive; I consider them equivalent in practice.
Info. M4: the conclusion holds, but the stated reason is incomplete. A probe window can be open on a rejudged cycle: in my probe it was open 8 times, when a trade arrived just as the loop's own probe opened. In each case sat_count was 0, because the loop holding 0 W resets it, so compute() cannot engage. M4's output is identical to the head's.
Info. The DECISIONS entry is long (≈96 lines) and repeats the mutation table. It is acceptable; I did not change it.
Open questions (my view only, nothing changed):
The flat-reading pair: I confirmed 29 lines per 15 min on both base and head. It deserves its own card next to SAFETY-74, and it is low priority because real readings move.
At N=1 a writer still asks a refusing battery. That is known and by design (SAFETY-52 shapes only the loop). Changing it changes power, so it needs an owner decision and should not ride on this card.
"Refuses to give power" for a charge refusal: yes, that is a small UX card.
The N=2 "dropped from the split" line: it is logged once and is the same as base. Not a defect here; it could go into the UX card as wording.
## Review of SAFETY-107 (head reviewed: d12228d; I pushed 848140c, tests and docs only)
**Root cause: confirmed.** On bf3ac79 the "another writer" end (main.py:4977) fires every cycle against the writer the refusal was learned against. `_refusal_end()` leaves `sat_count` alone, so `compute()` learns it again in the same cycle. With this PR's test file on bf3ac79: 1753 / 875 / 335 lines in 15 min at a 1 / 2 / 5 s meter, and 26 checks fail. On the head: 1 line. This fixes the cause; it is not a log rate limit.
**Power is unchanged.** I checked this by reading the code: `compute()` gets the same inputs as before (`refused=0`, `probe=False`). Nothing reads `batt_refused` between the old end and `_refusal_update()`, and each branch leaves `batt_refused` and `batt_refused_at` as the old end-and-restart did. The trace agrees. I ran `trace.py` on 40 new seeds (300-339): base and head match on the cmd, state and full hashes, 0 of 40 differ. My mutants R5 and R6 change it, so it can fail.
**Drain and HOLD:** I ran my own probe over 96 scenarios (N=1 and N=2; drain, hold, charge or none; empty, full or dead; 2 house levels; 2 meter rates). Commands are identical to base everywhere. Drain and HOLD give 0 refusal lines on both, because the SET resets `sat_count` (main.py:4792). The only line-count difference is the N=1 charge flap: 3553 / 695 lines before, 1 after.
**Findings**
1. *Low, fixed in 848140c.* Rule 3 (the other direction found while another writer holds the wire) had no test. The mutants R2 (no new start) and R3 (sign ignored) survived the three refusal test files and the trace. In practice the path cannot be reached at N=1: `_gave_nothing()` (main.py:1463) restarts its evidence when the ask changes sign, so rule 2 ends the refusal first. I added part 3e: a writer turns round on a dead pack, giving one end and then one start the other way. I also added one note to DECISIONS. R2 and R3 still survive; I consider them equivalent in practice.
2. *Info.* M4: the conclusion holds, but the stated reason is incomplete. A probe window can be open on a rejudged cycle: in my probe it was open 8 times, when a trade arrived just as the loop's own probe opened. In each case `sat_count` was 0, because the loop holding 0 W resets it, so `compute()` cannot engage. M4's output is identical to the head's.
3. *Info.* The DECISIONS entry is long (≈96 lines) and repeats the mutation table. It is acceptable; I did not change it.
**Open questions (my view only, nothing changed):**
1. The flat-reading pair: I confirmed 29 lines per 15 min on both base and head. It deserves its own card next to SAFETY-74, and it is low priority because real readings move.
2. At N=1 a writer still asks a refusing battery. That is known and by design (SAFETY-52 shapes only the loop). Changing it changes power, so it needs an owner decision and should not ride on this card.
3. "Refuses to give power" for a charge refusal: yes, that is a small UX card.
4. The N=2 "dropped from the split" line: it is logged once and is the same as base. Not a defect here; it could go into the UX card as wording.
admin
merged commit db9e2315d1 into release/1.02026-10-09 04:29:48 +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.
SAFETY-107: the refusal log flaps every second while maintenance charges a full pack.
Cause.
main.py, SAFETY-52's block beforecompute(): the "another writer" end testedself.target != self.loop_w. A held refusal makes the loop ask 0 W, so any claim that binds (the maintenance charge'shi, the peak guard'slo, a trade's SET) differed from it every cycle. The refusal was ended against the same writer it was learned on, and since that end does not resetsat_count,compute()learned it again in the same cycle.Fix. When another writer held the wire, the held refusal is judged afresh:
compute()getsrefused=0and no probe, the same inputs as before. If it finds the same refusal, the episode continues with no line and no hint (the probe clock restarts, as before). If not, the refusal ends: "another writer", or "the battery gives power" when the reading shows it.Other writers. A trade into a full pack and the peak guard over an empty pack flapped the same way (335 lines in 15 min each on release/1.0); both are fixed by the same rule. N>1 had no flap (SAFETY-73 has no another-writer end) and is unchanged.
Power. Unchanged. A 300-seed differential trace against release/1.0
bf3ac79(real Controller, CI image, N=1 and N=2, 1/2/5 s meter, maintenance charge, peak guard, trades, stop/start; 427 680 cycles) gives identical commands, site target, integrator,sat_countand refusal state on every cycle. Refusal lines: 100 748 before, 2 188 after.Tests.
test_safety107.py: the card's case at N=1 and N=2, 1/2/5 s meter, house importing and exporting (1 line in 15 min; release/1.0: 335-1 753 at N=1). Other writers, real takeovers, delivery, the other direction, stop, the probe clock. Mutations M1-M6 are in DECISIONS/SAFETY-107 (M4 is equivalent, explained there).Not covered (DECISIONS): a perfectly flat reading under a writer still gives one start/end pair per stale-gate cycle, the same as release/1.0. Stopping it would change commands.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
Review of SAFETY-107 (head reviewed: d12228d; I pushed
848140c, tests and docs only)Root cause: confirmed. On
bf3ac79the "another writer" end (main.py:4977) fires every cycle against the writer the refusal was learned against._refusal_end()leavessat_countalone, socompute()learns it again in the same cycle. With this PR's test file onbf3ac79: 1753 / 875 / 335 lines in 15 min at a 1 / 2 / 5 s meter, and 26 checks fail. On the head: 1 line. This fixes the cause; it is not a log rate limit.Power is unchanged. I checked this by reading the code:
compute()gets the same inputs as before (refused=0,probe=False). Nothing readsbatt_refusedbetween the old end and_refusal_update(), and each branch leavesbatt_refusedandbatt_refused_atas the old end-and-restart did. The trace agrees. I rantrace.pyon 40 new seeds (300-339): base and head match on the cmd, state and full hashes, 0 of 40 differ. My mutants R5 and R6 change it, so it can fail.Drain and HOLD: I ran my own probe over 96 scenarios (N=1 and N=2; drain, hold, charge or none; empty, full or dead; 2 house levels; 2 meter rates). Commands are identical to base everywhere. Drain and HOLD give 0 refusal lines on both, because the SET resets
sat_count(main.py:4792). The only line-count difference is the N=1 charge flap: 3553 / 695 lines before, 1 after.Findings
848140c. Rule 3 (the other direction found while another writer holds the wire) had no test. The mutants R2 (no new start) and R3 (sign ignored) survived the three refusal test files and the trace. In practice the path cannot be reached at N=1:_gave_nothing()(main.py:1463) restarts its evidence when the ask changes sign, so rule 2 ends the refusal first. I added part 3e: a writer turns round on a dead pack, giving one end and then one start the other way. I also added one note to DECISIONS. R2 and R3 still survive; I consider them equivalent in practice.sat_countwas 0, because the loop holding 0 W resets it, socompute()cannot engage. M4's output is identical to the head's.Open questions (my view only, nothing changed):