# SAFETY-43 — delinquency measured against the ask, not against a flat 500 W **Status: closed.** `delivery.delinquent()` names a unit whose `short_by()` gap exceeds `min_w` **or** exceeds a fraction of that unit's own share. The fraction is `delivery.DELIVERY_SHARE_FRACTION = 0.85`, derived on the campaign in §3. ⚠️ **INVERTER B DOES NOT PHYSICALLY EXIST** (expected ~2026-09-12; NOTES.md §1.1b). `plantsim.INVERTER_B` is inverter A **copied**, `measured=False`, and `test_packshare.py`'s plant is modelled in that file. **Every two-inverter figure in this document is two copies of A.** Nothing here is a measurement of inverter B. ## 0. The defect, and why it is the quiet regime that matters [[OPT-09]] passes SoC- and capacity-aware weights to the allocator. A weight is a function of a pack's **energy headroom**, not of what the pack delivers, so a **wedged** unit — one that accepts its share and produces nothing — is weighted down exactly like a legitimately low-headroom one. `delinquent()` read an **absolute** gap, `min_w = Tuning.saturation_w = 500 W`. Shrinking the share shrinks the gap. Measured end to end on the real loop (`test_packshare.py` part 8b: real `compute()`, real `plan()`, real `packshare.weights()`, real `delivery.account()`, real `delinquent()`; B wedged at a healthy 15 % against its own 10 % cut-out, so no envelope closes and `account()` explains nothing — a SAFETY-42 **true positive** by that card's own definition): load 1500 W equal weights shares {a:1500, b:1500} named on 199/200 load 1500 W OPT-09 weights shares {a:1505, b: 125} named on 0/200 A unit delivering **0 of 125 W** is 100 % delinquent and 125 W short. Only the absolute threshold disagreed. ⚠️ **THE SUPPRESSED REGIME IS THE DANGEROUS ONE.** At a load the healthy sibling covers, the meter is satisfied (grid within 5 W of zero on that fixture), `unmet_w` is 0 because both units *accepted* their shares, no envelope closes and no action-log line fires. The delinquency alarm is the **only** instrument that would have told the operator the site was quietly running one inverter. ## 1. What was NOT done, and it is the larger half of this card ⚠️ **`min_w` WAS NOT LOWERED.** It is `Tuning.saturation_w` — the gap at which the control law itself concludes the inverter is not following — and it exists to stop **normal saturation** being reported as delinquency. Lowering it trades a missed alarm for a false one on every unit at once. [[SAFETY-41]] spent **four attempts** on notification cadence in this system and the owner's words were *"i'm getting really annoyed by the notifications in my HA"*. So the change is an **`or`**, never a looser first arm: named if short_by() > min_w OR short_by() > frac * share Everything `min_w` named before is still named. Nothing `min_w` refused becomes noisy **unless the unit is delivering almost none of its ask**, which is not saturation-shaped at all. `test_delivery.py` part 1 runs all seven of its existing `delinquent()` fixtures under **both** rules — `share_frac=None` is SAFETY-42's rule exactly — and requires identical answers on every one. The absolute arm's suppression window is therefore **unchanged**: still `site target < 2·min_w` = 1000 W at equal weights, still `site target < min_w·(w_A+w_B)/w_B` = 6500 W under OPT-09's weights on part 8b's fixture. Part 8b still asserts both numbers, now as evidence that the first arm did not move. ## 2. The evidence is a diff between two rules on ONE run `delinquent()` takes `share_frac`, and **`None` means the absolute arm alone**. That parameter exists for one reason: so the red and the green come out of one run over one tree instead of out of two commits that a reader has to trust. `test_packshare.py` part 8b grades every cycle twice: load 1500 W OPT-09 weights absolute arm alone named on 0/200 cycles load 1500 W OPT-09 weights the shipped rule named on 200/200 cycles load 1500 W equal weights absolute arm alone named on 199/200 cycles load 1500 W equal weights the shipped rule named on 200/200 cycles The equal-weights arm gains **exactly one cycle** — the first, where the slew limit holds the split at 500/500, a 500 W gap is not *over* a 500 W threshold (the absolute bound is strict) and the relative arm names a box delivering none of its 500 W. That single cycle is the whole of what this card costs an arm that was already firing. ⚠️ **THE PIN THAT INVERTED.** Part 8b was written by OPT-09 to pin the **suppressed** behaviour, and its comment said: *"if it ever goes red because the alarm fired, read the card that made it fire before 'fixing' this line."* This is that card. The pin now asserts the opposite answer and keeps the old one beside it, measured rather than remembered. ## 3. ⭐ Deriving the fraction ⚠️ **DERIVED ON THE SAME CAMPAIGN AS `delivery_hold_s`'s FLOOR**, not chosen. `admin/goodwe:sim/delivery_window.py` — the harness [[TEST-21]] committed — gained a second grading axis: the same **2640 no-fault runs at N=2** (three families × 6 gains × 5 slews × write staggers; every unit executes the share the allocator gave it) and the same **2640 N=1 control runs**, graded at the shipped `min_w = 500 W` for each candidate fraction, on every cycle of every run — so a pass grades every fraction it is given, and the absolute arm beside them, over one tree and one set of runs. Every row in a pass is comparable to every other row in it, run for run. cd goodwe/sim && GOODWE_ADDON_REPO=/path/to/goodwe-addon \ python3 -B delivery_window.py --selftest --campaign --fractions ⚠️ **THE TABLE BELOW WAS PRODUCED BY TWO PASSES; `FRACTIONS` NOW HOLDS ALL TWENTY SO IT TAKES ONE.** The derivation ran the shipped ten `(0.25 … 0.99)` and then the ten that resolve the low end `(0.05 … 0.65)` with only that tuple edited between them. **The absolute-arm control row came out identical in both** (30.0 / 0.0 / 35.0 / 0.0, same argmax), which is what says the two passes are one campaign. A table a reviewer cannot reproduce without knowing to edit a line nobody wrote down is PROC-01's shape in miniature, so the committed `FRACTIONS` was widened to all twenty in review: the command above now reproduces every row below. ### 3.1 The curve Worst **continuous** legitimate episode (the measure `DeliveryWatchdog` actually sees — its window is cleared by any cycle that does not name), runs naming in brackets: | `share_frac` | reversals (of 1050) | flapping (of 840) | weighted (of 750) | **N=1 control (2640)** | |---:|---:|---:|---:|---:| | absolute only | 30.0 s (460) | 0.0 s (0) | 35.0 s (403) | **0.0 s (0)** | | 0.05 | 85.0 s (826) | 0.0 s (0) | 90.0 s (616) | **0.0 s (0)** | | 0.10 | 75.0 s (824) | 0.0 s (0) | 90.0 s (607) | **0.0 s (0)** | | 0.15 | 70.0 s (836) | 0.0 s (0) | 70.0 s (609) | **0.0 s (0)** | | 0.20 | 70.0 s (839) | 0.0 s (0) | 60.0 s (629) | **0.0 s (0)** | | 0.25 | 55.0 s (832) | 0.0 s (0) | 60.0 s (611) | **0.0 s (0)** | | 0.30 | 55.0 s (840) | 0.0 s (0) | 50.0 s (614) | **0.0 s (0)** | | 0.35 | 55.0 s (840) | 0.0 s (0) | 55.0 s (618) | **0.0 s (0)** | | 0.40 | 50.0 s (840) | 0.0 s (0) | 60.0 s (658) | **0.0 s (0)** | | 0.45 | 45.0 s (840) | 0.0 s (0) | 45.0 s (627) | **0.0 s (0)** | | 0.50 | 45.0 s (840) | 0.0 s (0) | 45.0 s (636) | **0.0 s (0)** | | 0.55 | 40.0 s (840) | 0.0 s (0) | 40.0 s (636) | **0.0 s (0)** | | 0.60 | 40.0 s (840) | 0.0 s (0) | 40.0 s (645) | **0.0 s (0)** | | 0.65 | 35.0 s (840) | 0.0 s (0) | 45.0 s (635) | **0.0 s (0)** | | 0.70 | 35.0 s (840) | 0.0 s (0) | 45.0 s (635) | **0.0 s (0)** | | 0.75 | 35.0 s (840) | 0.0 s (0) | 45.0 s (635) | **0.0 s (0)** | | 0.80 | 35.0 s (840) | 0.0 s (0) | 45.0 s (635) | **0.0 s (0)** | | **0.85** | **35.0 s (840)** | **0.0 s (0)** | **40.0 s (635)** | **0.0 s (0)** | | 0.90 | 35.0 s (840) | 0.0 s (0) | 40.0 s (635) | **0.0 s (0)** | | 0.95 | 35.0 s (840) | 0.0 s (0) | 40.0 s (635) | **0.0 s (0)** | | 0.99 | 35.0 s (840) | 0.0 s (0) | 40.0 s (635) | **0.0 s (0)** | Worst of the three families, which is what `delivery_hold_s` has to cover: 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 ⚠️ **THE FIRST ROW IS THE CONTROL AND IT REPRODUCED SAFETY-42 §3.3 EXACTLY** — 30.0 s / 0.0 s / 35.0 s, and the same argmax (`gain=0.2 slew_w=1000 stagger=4 split=0.10/0.90`, ±2200 W reversal). It is measured **in the same run** as every row below it, which is what makes those rows a comparison rather than a claim. ### 3.2 What the curve says * ⚠️ **THERE IS NO CLIFF ANYWHERE IN THE CURVE, AND THAT IS THE FINDING.** From **0.45 to 0.99** every figure is 40.0 or 45.0 s — within **one control cycle (5.0 s)** of every other — and below 0.45 it rises **gradually**: 60.0 s at 0.25, 70.0 s at 0.15, 90.0 s at 0.05. A reviewer asking "why 0.85 and not 0.90?" is asking about a difference this campaign cannot resolve, and the honest answer is that it cannot. * ⚠️ **IT IS NOT MONOTONE** — 0.40 reports 60.0 s where 0.35 reports 55.0, and 0.65–0.80 report 45.0 s where 0.55/0.60 and 0.85–0.99 report 40.0 — and that is SAFETY-42 §3.3's own mechanism (c) rather than noise in the plant: **a more sensitive rule puts MORE units into the short set, which can turn a proper subset into the WHOLE roster and END the episode.** The reduction is not monotone in its own threshold. Do not read this column as a sensitivity curve in the ordinary sense. * **The shoulder is at about 0.45**, and it is a shoulder rather than an edge: the reversal family's naming count saturates at 840 of 1050 runs from 0.30 upward, so below the shoulder what grows is the LENGTH of an episode, not the number of runs that have one. * **The N=1 column is 0.0 s at every fraction, in all 2640 one-inverter runs.** The compatibility promise is measured for the new arm, not argued. * **The flapping family is 0.0 s at every fraction, in all 840 runs.** A common stimulus makes both units short at once and the subset stops being proper — the property that defeated SAFETY-30's site window still holds. ### 3.3 ⚠️ "Zero false namings" is not available at ANY fraction The card asked for *the largest fraction that yields zero false namings across the whole no-fault campaign*. **There is none.** Every fraction adds naming cycles: the reversal family goes from 460 runs naming to 840, the weighted family from 403 to 635. A relative arm that never fired on a legitimate transient would be a relative arm that never fired at all — the write stagger lands B's number up to four cycles late, and a unit whose new command has not arrived is, for those cycles, delivering a small fraction of what is standing on its wire. What **is** zero at every fraction is **false alarms**: > the longest continuous legitimate episode anywhere in the 20-point sweep is > **90.0 s** — at 0.05, the most sensitive fraction measured — against a > `delivery_hold_s` of **185 s**. Nothing in 2640 no-fault runs comes within > **95 s** of notifying an operator, **at any fraction measured**. At the > shipped 0.85 it is 40.0 s, i.e. 145 s of slack. That is the quantity SAFETY-41 cares about and it is the one this card had to protect. Stated as margin, at the shipped fraction: **185 s is 4.62×** the worst legitimate episode, where the absolute arm alone gave 5.29×. ### 3.4 The bounds 0.85 answers to * **LOWER, from the campaign** — the shoulder at about **0.45**, below which the added episode starts to grow (60.0 s at 0.25, 90.0 s at 0.05). 0.85 is 0.40 above it. * **UPPER, from the card's own case** — strictly **below 1.0**. At 1.0 the arm can only fire on a unit that is *importing* while asked to discharge, which is SAFETY-30's fault and not this one, so a unit delivering **none** of its share (gap/share exactly 1.0) would be missed. The 0.15 below 1.0 is what covers a wedged box reporting a small standby figure instead of a clean zero. * **UPPER, from the hold** — every fraction in the flat range leaves `delivery_hold_s` between **4.11×** (45.0 s) and **4.62×** (40.0 s) the worst legitimate episode, well inside the four bounds SAFETY-42 §3.3 requires of it. **`delivery_hold_s` IS NOT CHANGED BY THIS CARD.** * ⚠️ **NOT DERIVED, AND SAID SO: the exact value inside [0.45, 1.0).** The campaign cannot separate them. 0.85 is the midpoint of the argument above — 0.40 of margin above the shoulder, 0.15 below the ceiling — and a re-derivation that moved it to 0.90 would be moving a number this campaign never resolved. It is not moved for that reason. ## 4. The positive control — what the relative arm must still NOT say A card that adds a way to raise an alarm has to show what it did **not** add. `test_packshare.py` part 8b runs the same fixture, the same weights and the same 125 W share with the one fact that makes the shortfall legitimate changed: | fixture | B's share | named | why it is silent | |---|---|---|---| | B wedged at a healthy 15 % (the fault) | 77–154 W | **200 / 200** | — it is the fault | | B at its own 10 % cut-out | **0 W, all 200 cycles** | **0 / 200** | it is never **asked** | | B delivering **half** its share | 120 W | **0 / 200** | short by 50 %, inside both arms | ### ⚠️ The cut-out row does NOT grade `account()`, and this record said it did **Corrected in review.** The first draft of this section claimed the wedged and cut-out arms had *"identical power traces — 0 W against a positive share, 100 % of the ask missing"* and that *"what separates them is `account()`"*. **Every part of that was false**, and the check that carried it was **vacuous**: * at its own cut-out the pack's usable energy **above that cut-out is zero**, so `packshare.weights()` answers **0.0** for it, `_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**; * the traces are not identical — the cut-out arm's share is **0 W on every cycle** (asserted in part 8b and printed on its line) while the wedged arm's is **125 W** at the last cycle (asserted in the GREEN check) and takes four distinct values 77/125/133/154 W across the 200, measured by re-running the same loop with per-cycle recording during review — and "100 % of the ask" is 0/0. Measured, by deleting the gate the claim rested on: cut-out arm, account() live named 0/200, distinct B shares: [0] cut-out arm, account() DELETED named 0/200, distinct B shares: [0] wedged arm, account() live named 200/200, shares [77,125,133,154] ⚠️ **The fact that replaces it is better, not weaker.** At its own cut-out **OPT-09 stops asking the pack at all**, so SAFETY-43's relative arm is never reached — a **second, independent protection sitting upstream of `account()`**: the allocator declines to ask before the detector has to decline to alarm. Part 8b now asserts the 0 W share and prints it, and runs the arm a second time with `account()` deleted to show which mechanism is doing the work. ⚠️ **It was invisible for one reason worth keeping**: that line was the only one in part 8b that did not print its shares. `inverter-b+0` would have been on screen from the first run. Every line in that part prints them now, and the derated control's vacuity guard 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. **The account gate is graded where it is real**, on a positive 125 W share, in `test_delivery.py` part 1 (*"THE ACCOUNT GATE IS UNTOUCHED BY IT"* — the same 0 of 125 W with the shortfall explained names nobody). That part also carries that the relative bound is **strict** (a gap of exactly `frac · share` is not over it) and that the proper-subset rule still refuses a site-wide cause (both units delivering none of a 125 W share names nobody). ## 5. N=1 is byte-identical, and it is asserted rather than argued The live install is **N=1**. Two independent statements, each graded against the thing that would break it (`test_packshare.py` part 8b): 1. **`allocator.plan()` returns before `weights` is read at all.** Graded by handing it a `weights` mapping that **raises on any read**: at a roster of one it answers identically to the same call with no weights; at a roster of two the same mapping is read and raises, so the tripwire is a real one. 2. **`delinquent()` answers `{}` at a roster of one for every input** — 84 combinations of (share, measured, rule), including **0 of 125 W**, the exact input this card added an arm for. `len(asked) < 2` returns before either threshold is compared, so the fraction is never even multiplied. Nothing in this card is reachable at one inverter. It must land before inverter B is commissioned (~2026-09-12), not before the next deploy. ## 5b. Mutation — the fraction is pinned from BOTH directions ⚠️ **RUN, NOT REASONED ABOUT.** Each mutation was applied to a tree **copied fresh** from this branch and mounted `:ro` inside the checks image, so no `.pyc` could go stale — MUTATION.md's defect, and all three of these are **reds**, the direction that defect cannot fabricate. Recorded in `test_packshare.py` part 11 as M12–M14. | mutation | named checks | where | |---|---:|---| | **M12** drop the relative arm, keep `gap > min_w` — *the card itself reverted* | **7** | `test_delivery.py` part 1 (3: the 0-of-125 W naming, the proportion in the sentence, the strict relative bound) + `test_packshare.py` 8b (4: GREEN back to 0/200, the equal arm's 200 back to 199, the 6000 W arm back to 195/199, the derated control's guard). `test_alloc.py` / `test_control.py` stay green — nothing outside this check moves. | | **M13** `DELIVERY_SHARE_FRACTION` → **1.0** — *too loose* | **7** | part 1 (2) + 8b (5), including the PRECONDITION that the fraction is strictly below 1.0. At 1.0 the card is a no-op: gap/share of exactly 1.0 is not *over* 1.0. | | **M14** `DELIVERY_SHARE_FRACTION` → **0.05** — *too sensitive* | **6** | part 1 (2: the strict bound, and *"IT TOOK NOTHING AWAY"* stops being true) + part **4b** (3: the `saturation_w` pin goes **vacuous**, the relative arm naming B at every threshold) + 8b's derated control, where a unit delivering half its share starts being named. | M13 and M14 are the pair that matters: the constant cannot be moved in **either** direction without a named check moving with it. ### ⚠️ Two of these counts were wrong, and the reason is worth keeping **Corrected in review.** * **M12 and M13 read 1 and 1 in `test_delivery.py`, not 3 and 2.** Part 1 printed the alarm sentence with a bare subscript, `delinquent(_tiny, 500)["b"]`. On any tree where the relative arm is broken or absent that raises `KeyError` and **aborts the file at part 1**, leaving parts 2–8 ungraded — so the recorded coverage of a mutation run read *smaller* than the truth. The direction is safe (an abort is a red, and MUTATION.md's defect can only fabricate a green), but a figure that does not reproduce is a figure that does not reproduce. It is a `.get` now, with the reason written beside it, and the counts above are from re-runs with the guard in place. * **M14 was recorded as 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. That one under-claimed, so it never inflated the evidence; it is still a recorded number that did not reproduce. ## 6. ⚠️ [[CFG-07]] is adjacent and is NOT absorbed here CFG-07 is that `delivery_hold_s` is declared `int(30,900)` in `config.yaml`'s schema while the measured worst legitimate episode at the shipped threshold is **35.0 s** — so the schema offers an operator a value (30) that is **below a measured legitimate episode** and would report on ordinary saturation. **This card makes it worse, and by exactly one control cycle.** The relative arm takes the worst legitimate episode from **35.0 s to 40.0 s** at the shipped fraction, so the band of schema-legal but false-tripping values widens from `[30, 35]` to `[30, 40]`. The floor the schema should have is now **40 s**, not 35 s. It is not fixed here: raising a schema floor is an option-surface change with its own compatibility argument (an existing config carrying `35` would start being refused), and folding it into a safety fix would hide it. **CFG-07 should be re-derived against 40.0 s, not 35.0 s.** ⚠️ **WHEN IT HAS TO CLOSE: BEFORE INVERTER B IS COMMISSIONED (~2026-09-12).** Not before the next deploy. The band is **unreachable at N=1** — `delinquent()` returns `{}` for every input at a roster of one, so no value of `delivery_hold_s` can false-trip on the install that is running today — and the live install is N=1 until B exists. Commissioning B is the moment the band becomes reachable, and that is the deadline this card hands it. ⚠️ **AND CFG-07 HAS NO DECISION DOCUMENT IN THIS TREE.** It exists as a `[[CFG-07]]` mention in `CHANGELOG.md` and in this section, and nowhere else — so there is no file for the 40.0 s to be recorded in, and a reader who goes looking will find a card reference with no record behind it. Whoever takes CFG-07 writes `DECISIONS/CFG-07-*.md` first; the number it has to answer to is **40.0 s**, measured here, and the harness that produces it is `admin/goodwe:sim/delivery_window.py --campaign --fractions`. ## 7. Provenance of every number in this document | number | where it came from | |---|---| | 199/200, 0/200, 200/200, shares 1505/125 | `test_packshare.py` part 8b, run | | 35.0 s absolute-arm floor, and its argmax | `delivery_window.py --campaign`, re-run here, reproduces SAFETY-42 §3.3 | | every row of §3.1 | `delivery_window.py --campaign --fractions`, two passes over the same campaign with `FRACTIONS` changed between them | | 1000 W / 6500 W suppression windows | part 8b's arithmetic, asserted there | | 185 s, 5.29× | SAFETY-42 §3.3 / TEST-21, unchanged by this card | | 4.62× | 185 / 40.0, printed by the campaign | | 0/200 on both positive controls, and B's 0 W share at the cut-out | part 8b, run and asserted | | 0 W cut-out share / 125 W wedged share | part 8b, asserted and printed | | 77/125/133/154 W (the wedged arm's four distinct shares) | the same loop re-run in review with per-cycle recording — NOT part 8b's printed line, which shows the last cycle only | | M12/M13/M14 named-check counts (7 / 7 / 6) | re-run in review on fresh `:ro` trees, after the `KeyError` abort in part 1 was guarded | | 84 single-unit rosters | part 8b, run | ⚠️ Every two-inverter figure above is **two copies of inverter A**.