SAFETY-43: delinquency measured against the ask, not against a flat 500 W #69

Merged
admin merged 3 commits from SAFETY-43 into release/1.0 2026-09-08 05:56:45 +02:00
3 Commits
Author SHA1 Message Date
glenn schrooyenandClaude Opus 5 1448bd909b SAFETY-43: the no_account switch was graded by nothing
checks / unit (pull_request) Successful in 6m29s
Review's remaining non-blocking finding, and it is B1's shape one level down -
in the fix for B1.

`wedged_loop`'s `no_account=True` was used in exactly ONE place: the cut-out
fixture, where it PROVABLY cannot change the answer, because a pack at its own
cut-out is never asked and the gate is never reached. Review neutered the switch
so it removed nothing, and the file stayed GREEN while printing

    ...named=(nobody); with account() DELETED: 0/200

with `account()` not deleted. A line asserting a mechanism that did not happen.

⭐ THE SWITCH DOES BITE, ON A FIXTURE THIS HELPER COULD ALREADY REACH. At the
same cut-out with EQUAL weights, `plan()` hands B a 50/50 share, so the account
gate is the only thing keeping it silent. Measured:

    cut-out, OPT-09 weights   gate on   0/200   gate DELETED     0/200
    cut-out, EQUAL weights    gate on   0/200   gate DELETED   200/200

Asserted. Driven: neutering the switch now goes RED on exactly that check, and
nothing else.

Two things this buys beyond closing the finding. It proves the switch removes
the gate, so the printed "with account() DELETED" line is about a run in which
something was. And it gives part 8b the end-to-end account-gate control ON A
POSITIVE SHARE that the block previously had to outsource to test_delivery.py
part 1 - the arm is no longer only true, it is locally demonstrated.

Suite: 27 files, all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-08 04:38:18 +02:00
Claude Opus 5 b21e229adf SAFETY-43 round 2: the cut-out "positive control" was vacuous, and the record
checks / unit (pull_request) Successful in 6m29s
stated the wrong mechanism

⚠️ REVIEW FINDING, BLOCKING, AND IT IS THE EVIDENCE THAT WAS WRONG RATHER THAN
THE BEHAVIOUR. Part 8b's cut-out arm claimed an "identical power trace" to the
wedged arm and that "what separates them is `account()`". EVERY PART OF THAT WAS
FALSE, and the check survived deleting the gate it named. Measured:

    cut-out arm, account() live        named 0/200, distinct B shares: [0]
    cut-out arm, account() FORCED None named 0/200, distinct B shares: [0]
    wedged  arm, account() live        named 200/200, shares 77/125/133/154 W

At its own cut-out the pack's usable energy ABOVE that cut-out is zero, so
`weights()` answers 0.0, `_fill()`'s preferred pass gives it nothing, the healthy
sibling covers the load alone so the last-resort pass never runs, and its share
is 0 W ON ALL 200 CYCLES. A unit with a 0 W share is not in `asked`, so
`len(asked) < 2` returns {} BEFORE EITHER ARM IS EVALUATED and `account()` is
never consulted. Nothing was identical, "100 % of the ask" was 0/0, and the arm
never reached the comparison it was written to grade.

WHAT REPLACES IT IS A BETTER FACT, NOT A WEAKER ONE: at its own cut-out OPT-09
STOPS ASKING THE PACK AT ALL, so SAFETY-43's relative arm is never reached. That
is a SECOND, INDEPENDENT protection sitting UPSTREAM of `account()` - the
allocator declines to ask before the detector has to decline to alarm. So the arm
is kept and re-pointed, not deleted:

  * the 0 W share is now ASSERTED (`_sat_last[1][Bee] == 0`), not inferred from
    the silence - a control that cannot say WHY it is quiet cannot fail;
  * the arm runs a SECOND time with `account()` DELETED (`no_account=True`) and
    must still be silent, which is what proves which mechanism does the work;
  * the account gate's claim is re-pointed at test_delivery.py part 1, where it
    is graded on a REAL positive 125 W share.

⚠️ AND IT WAS INVISIBLE FOR ONE REASON WORTH KEEPING: that was the ONLY line in
part 8b that did not print its shares. `inverter-b+0` would have been on screen
from the first run. Every line in the part prints them now. The derated control's
vacuity guard also now asserts ITS OWN unit is asked, rather than re-asserting a
different run's 200/200 - which is exactly why the old guard could not see this.

ALSO, from the same review:

  N1  app/delivery.py said 0.05 was "twelve times more sensitive" than 0.85.
      0.85/0.05 = 17 and no reading of the pair gives 12. An unsupported number
      in shipped source; replaced with what was actually measured.
  N2  M14's recorded count was 4 named checks in test_delivery.py and it is 5 -
      part 1's pre-existing "a shortfall AT the threshold is not one" also goes
      red. It under-claimed, so it never inflated the evidence, but a recorded
      figure that does not reproduce is still one.
  N3  test_delivery.py part 1 printed the alarm sentence with a bare
      `delinquent(...)["b"]`, which raises KeyError under M12/M13 and ABORTED
      THE FILE AT PART 1, leaving parts 2-8 ungraded. The direction is safe (an
      abort is a red; MUTATION.md's defect fabricates only greens) but the
      recorded coverage read smaller than the truth. It is a `.get` now, and
      M12/M13's counts are re-run: 7 and 7 named checks, not 5 and 6.

CFG-07: the assessment is unchanged (worse by one control cycle, 35.0 -> 40.0 s,
deliberately not fixed here) and now says WHEN it has to close - BEFORE INVERTER
B IS COMMISSIONED (~2026-09-12), because the band is unreachable at N=1 and
commissioning B is when it first becomes reachable - and that CFG-07 HAS NO
DECISION DOCUMENT IN THIS TREE, so whoever takes it writes one first.

No behaviour change. `delinquent()`'s two arms, the fraction, `min_w` and the
N=1 path are all untouched by this commit. 27/27 suites green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-08 04:15:07 +02:00
Claude Opus 5 2271e147c6 SAFETY-43: delinquency measured against the ask, not against a flat 500 W
checks / unit (pull_request) Successful in 6m20s
`delivery.delinquent()` named a unit whose `short_by()` gap exceeded an
ABSOLUTE `min_w = Tuning.saturation_w = 500 W`. OPT-09's SoC-aware weights
shrink a low-headroom unit's share, and shrinking the share shrinks the gap, so
a unit standing at +125 W and delivering 0 W - 100 % delinquent - was never
named. Measured end to end on the real loop (test_packshare.py part 8b):

    load 1500 W, B wedged at 15 %, equal weights    named on 199/200 cycles
    load 1500 W, B wedged at 15 %, OPT-09 weights   named on   0/200 cycles

⚠️ The suppressed regime is the quiet one: at a load the healthy sibling covers,
the meter is satisfied, `unmet_w` is 0 and no action-log line fires, so this
alarm is the ONLY instrument that would have told the operator.

WHAT CHANGED, in one line:

    named if  short_by() > min_w  OR  short_by() > frac * share

⚠️ `min_w` WAS NOT LOWERED. It exists to stop normal saturation being reported
as delinquency, and lowering it trades a missed alarm for a false one on every
unit at once - SAFETY-41's four-attempt regime. This is a SECOND way to be
named, not a looser first one: all seven of test_delivery.py part 1's existing
fixtures answer identically under both rules, and the absolute arm's suppression
window is still exactly 1000 W / 6500 W, still asserted in part 8b.

THE FRACTION IS DERIVED, 0.85. admin/goodwe:sim/delivery_window.py gained a
`--fractions` axis and grades the constant over the SAME 2640 no-fault runs that
produced `delivery_hold_s`'s 35.0 s floor. Worst continuous legitimate episode,
worst of three families, twenty fractions:

    frac   0.05  0.10  0.15  0.20  0.25  0.30  0.35  0.40  0.45  0.50
    worst  90.0  90.0  70.0  70.0  60.0  55.0  55.0  60.0  45.0  45.0  s
    frac   0.55  0.60  0.65  0.70  0.75  0.80 *0.85  0.90  0.95  0.99
    worst  40.0  40.0  45.0  45.0  45.0  45.0  40.0  40.0  40.0  40.0  s
    absolute arm alone: 35.0 s (SAFETY-42 §3.3, reproduced in the same runs)

⚠️ NO CLIFF ANYWHERE: 0.45-0.99 is flat within one control cycle, and it is not
monotone - §3.3's own mechanism (c). ⚠️ "Zero false namings" is NOT achievable
at any fraction and the record says so; what is zero is false ALARMS - 90.0 s
worst against a 185 s hold, so nothing comes within 95 s of notifying anybody.
N=1 control 0.0 s at every fraction in all 2640 one-inverter runs; flapping
0.0 s in all 840.

DRIVEN. Part 8b pinned the SUPPRESSED behaviour and said "if it ever goes red
because the alarm fired, read the card that made it fire". It now grades BOTH
rules on one run - `share_frac=None` is SAFETY-42's rule exactly:

    OPT-09 weights, absolute arm alone   named on   0/200
    OPT-09 weights, the shipped rule     named on 200/200

POSITIVE CONTROL. Same fixture, same weights, same 125 W share, one legitimate
fact changed: a pack at its own 10 % cut-out is named on 0/200 (account() still
gates it), and a unit delivering HALF its share is named on 0/200.

N=1 BYTE-IDENTICAL, asserted: `plan()` handed a `weights` mapping that raises on
any read still answers identically at a roster of one, and the same mapping
raises at a roster of two; `delinquent()` answers {} on all 84 single-unit
(share, measured, rule) combinations including 0 of 125 W.

MUTATION, run on fresh :ro trees: M12 (drop the relative arm), M13 (frac -> 1.0)
and M14 (frac -> 0.05) are all red on named checks. The constant cannot move in
either direction unseen.

⚠️ CFG-07 gets ONE CONTROL CYCLE WORSE and is deliberately not fixed here: the
worst legitimate episode goes 35.0 -> 40.0 s, so the schema's `int(30,900)`
false-tripping band widens from [30,35] to [30,40]. CFG-07 should be re-derived
against 40.0 s.

No option value, no schema bound and no default changed. 27/27 suites green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-08 03:14:16 +02:00