SAFETY-107: a refusal held under another writer is learned once #183

Merged
admin merged 2 commits from SAFETY-107 into release/1.0 2026-10-09 04:29:48 +02:00
Owner

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.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC

**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
admin added 1 commit 2026-10-09 03:03:20 +02:00
SAFETY-107: a refusal held under another writer is learned once
checks / unit (pull_request) Successful in 35m8s
infra-pin / fresh (pull_request) Successful in 2s
d12228d986
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
admin added 1 commit 2026-10-09 03:52:41 +02:00
SAFETY-107 review: test a writer that turns round on a dead pack; say when rule 3 applies
checks / unit (pull_request) Successful in 35m22s
infra-pin / fresh (pull_request) Successful in 2s
848140c29f
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Owner

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.
## 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.0 2026-10-09 04:29:48 +02:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: admin/goodwe-addon#183