diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index cfc18e2..4470cbe 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -267,6 +267,35 @@ the conclusion is unchanged and is now stated on the predicate's own inputs. hazard from opposite sides: **OPT-09 stops the site ASKING a pack whose SoC says it cannot deliver; SAFETY-42 alarms when a pack that said it could does not.** +⚠️ **AND THIS CARD NARROWS SAFETY-42's DETECTION WINDOW. IT IS A DETECTION +REGRESSION AT TWO INVERTERS AND IT IS DISCLOSED RATHER THAN FIXED.** An earlier +draft of this entry and of `DECISIONS/OPT-09-soc-aware-weights.md` §5 said a +wedged unit's weight does not move, so OPT-09 could not see the case at all. The +ENVELOPE half is true - a wedged box reads a healthy SoC and nothing closes. The +WEIGHT half was **false**: a weight is a function of a pack's ENERGY HEADROOM, +not of what the pack delivers, so a wedged unit's weight moves whenever its +headroom differs from its sibling's. And `delivery.delinquent()` names a unit +only while its shortfall exceeds `saturation_w` - **500 W in absolute watts** - +so shrinking the share shrinks the gap below the threshold and the alarm stops +firing. Measured end to end with the real loop, the real allocator and the real +detector, on a unit wedged at a healthy 15 % against its own 10 % floor (a +SAFETY-42 true positive by that card's own definition): at a 1500 W load the +healthy pack can carry alone, equal weights name it on **199 of 200** cycles and +these weights name it on **none of the 200** - shares 1505/125, gap 125 W, and +125 W is under the threshold. The suppression window widens from a site target +below 1000 W to a site target below `saturation_w * (w_A + w_B) / w_B`, which on +that fixture is **6500 W of a 9600 W site**; above it the healthy pack hits its +own rating, the allocator's last-resort pass hands the wedged unit a big share +again and the alarm returns. So in the quiet regime - where `unmet_w` is 0, the +meter is satisfied and no action-log line fires - **a wedged inverter with less +energy headroom than its sibling can now go unreported at ordinary house +loads.** `test_packshare.py` part 8b grades it and pins the CURRENT behaviour, so +the day it changes a named check moves. Closing it means making `delinquent()` +relative to the ask rather than absolute, which is SAFETY-42's code and a design +change; that is filed as its own card and is deliberately not done here. +⚠️ Every two-unit figure above is **two copies of inverter A** through a modelled +plant - inverter B does not exist yet - and B's ratings are datasheet and serial. + ⚠️ **AN INVERTER THAT STOPS DELIVERING ITS SHARE IS NOW REPORTED BY NAME ([[SAFETY-42]]) - AND THIS ADDS AN OPTION, SO IT IS A SCHEMA CHANGE.** `delivery_hold_s: 185` joins `options:` and `schema:` (`int(30,900)`). The diff --git a/goodwe_controller/DECISIONS/OPT-09-soc-aware-weights.md b/goodwe_controller/DECISIONS/OPT-09-soc-aware-weights.md index e409462..5732102 100644 --- a/goodwe_controller/DECISIONS/OPT-09-soc-aware-weights.md +++ b/goodwe_controller/DECISIONS/OPT-09-soc-aware-weights.md @@ -1,6 +1,8 @@ # OPT-09 - the weights parameter, and what it is honestly for -**Status:** implemented. **Branch:** `OPT-09`. **Base:** `release/1.0` @ `464d398`. +**Status:** implemented. **Branch:** `OPT-09`. **Base:** `release/1.0` @ `3368231` +(`git merge-base HEAD 3368231`; the branch was rebased and this line used to name +the pre-rebase base, `464d398`). ## 0. What was actually wrong @@ -9,7 +11,7 @@ consumes it in two passes, and **nothing had ever passed one**. Confirmed on the base commit: arbiter.py:535 def resolve(claims, *, units=None, weights=None, live=None, ...) - main.py:2272 resolve(claims, units=self.units, live=self.live_units(), ...) + main.py:2422 resolve(claims, units=self.units, live=self.live_units(), ...) So `weights` was None on every tick of every install, `plan()` turned that into 1.0 per unit, and the site target was split **50/50 regardless of what either @@ -147,16 +149,67 @@ deferred option, and the BMS is what stops it. ## 5. SAFETY-42 - the same hazard from the opposite side -They meet and do not overlap, and the seam is **the state of charge**: +The seam is **the state of charge** - and they overlap in one direction, which +is what the rest of this section is about: * **OPT-09 stops the site ASKING** a pack whose own SoC says it cannot deliver. * **SAFETY-42 alarms** when a pack that said it could does not. -A wedged unit reads a healthy SoC, so no envelope closes and no weight moves - -OPT-09 cannot see it at all. A pack at its floor is `account()`'s explained case, -so SAFETY-42 stays silent - and after this card that ask does not arrive in the -first place, which removes the benign arm from the detector's input rather than -teaching it a new discriminator. +A wedged unit reads a healthy SoC, so **no envelope closes** - that half is +absolute, and it is why this card cannot stop the ask on a wedged box. A pack at +its floor is `account()`'s explained case, so SAFETY-42 stays silent - and after +this card that ask does not arrive in the first place, which removes the benign +arm from the detector's input rather than teaching it a new discriminator. + +⚠️ **BUT THE WEIGHT DOES MOVE, AND IT NARROWS SAFETY-42's DETECTION WINDOW. THIS +IS A DETECTION REGRESSION AT N=2 AND IT IS NOT FIXED HERE.** An earlier draft of +this section said "no envelope closes and no weight moves - OPT-09 cannot see it +at all". The second clause was **false**, and it was load-bearing for the claim +that the two cards do not overlap. A weight is a function of the pack's ENERGY +HEADROOM, not of what the pack delivers, so a wedged unit's weight moves whenever +its headroom differs from its sibling's - which is the ordinary case this card +exists to exploit. And `delivery.delinquent()` (`app/delivery.py:484`) +names a unit only while its `short_by()` gap exceeds `min_w = +Tuning.saturation_w` - the comparison is `app/delivery.py:531`, and it is +**500 W in absolute watts**. Shrinking a unit's share shrinks its gap. Shrink the +share far enough and the gap drops below 500 W while the unit is still delivering +nothing, and the alarm stops firing. + +Measured on the closed loop - real `compute()`, real `plan()`, real +`delinquent()`, modelled plant, B wedged at 15 % against its own 10 % floor so +nothing closes and `account()` explains nothing, i.e. a SAFETY-42 **true +positive** by that card's own definition: + + load 1500 W equal weights (shipped) named on 199/200 cycles + shares {a:1500, b:1500} grid +0 W named=['inverter-b'] + load 1500 W OPT-09 weights named on 0/200 cycles + shares {a:1505, b: 125} grid -5 W named=(nobody) + +⚠️ That plant is modelled in `test_packshare.py`, and **inverter B does not +exist** (expected ~2026-09-12): it is two copies of A driven with different +numbers, and B's 4600 W / 20.48 kWh / 10 % floor are from the datasheet and the +serial. The claim is about the arithmetic of the split and the threshold, which +needs no hardware, and not about a GW5000S-BP. + +At a load the healthy pack can cover - the regime where nothing else tells the +operator anything, `unmet_w` is 0, the meter is satisfied and no action-log line +fires - a wedged inverter goes from named on essentially every cycle to **never +named**. The suppression window widens from `site target < 2 * min_w` = 1000 W to +`site target < min_w * (w_A + w_B) / w_B`, which on that fixture is **6500 W** of +a 9600 W site; above it - once the healthy pack is at its own rating and +`_fill()`'s last-resort pass hands the wedged unit a big share again - the alarm +returns. `test_packshare.py` part 8b grades all of it and **pins the current, +unflattering behaviour**, so that the day it changes a named check moves. + +So the seam, stated accurately: **OPT-09 stops the ask AND narrows SAFETY-42's +detection window for a low-headroom unit.** The two cards do not merely meet from +opposite sides; this one takes something away from the other one. Closing the gap +means making `delinquent()` relative to the ask rather than absolute, which is +SAFETY-42's code and a design change - it is filed as its own card, deliberately +not done here, and this paragraph is its input. Until then this is a **disclosed +residual**, in the same voice as SAFETY-13's "2 of 272" and SAFETY-42's own "the +discriminator does not exist": at two inverters, a wedged unit with materially +less energy headroom than its sibling can go unreported at ordinary house loads. ⚠️ **And OPT-09 supplies the mechanism SAFETY-42 said was missing, without connecting it.** That module's rejection of re-allocation rests on *"the only diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index c12fdba..3a631b2 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -234,9 +234,12 @@ single-inverter entity back in that field. **Before downgrading, write down the #### How the site target is divided between the packs (OPT-09) -Two packs do not get half each. Since 0.7.x the split is driven by two separate -things, and it is worth knowing which is which, because only one of them is a -rule: +Two packs do not get half each **from 0.8.0 onwards**. ⚠️ **On 0.7.0 and every +version before it the split is a fixed 50/50 regardless of what either pack can +do** — that is the defect this section describes the fix for, so if the add-on +you are running reports 0.7.0, none of what follows is happening yet. From 0.8.0 +the split is driven by two separate things, and it is worth knowing which is +which, because only one of them is a rule: * **The envelope — a permission.** A pack whose own state of charge is at or under its own `bms_soc_floor` is **not asked to discharge**; a pack at 100 % diff --git a/goodwe_controller/app/packshare.py b/goodwe_controller/app/packshare.py index eed8b32..d222c45 100644 --- a/goodwe_controller/app/packshare.py +++ b/goodwe_controller/app/packshare.py @@ -57,6 +57,37 @@ deliver DOES NOT SHIFT TO ITS SIBLING by any mechanism in this add-on. never rises. That module DETECTS it; this one cannot fix it, and saying so is worth more than a weighting scheme that looks like it might. +⚠️ AND IT IS WORSE THAN "CANNOT FIX IT": THESE WEIGHTS NARROW SAFETY-42's +DETECTION WINDOW, WHICH IS A DETECTION REGRESSION AT N=2 +======================================================================= +This module used to say - and `DECISIONS/OPT-09-soc-aware-weights.md` §5 said +with it - that a wedged unit's weight does not move, so OPT-09 cannot see the +case at all. THE ENVELOPE HALF IS TRUE: a wedged box reads a healthy SoC and +nothing here closes. THE WEIGHT HALF WAS FALSE. `weights()` is a function of a +pack's ENERGY HEADROOM, not of what the pack delivers, so a wedged unit's weight +moves whenever its headroom differs from its sibling's - the ordinary case this +module exists to exploit. + +`delivery.delinquent()` names a unit only while its `short_by()` gap exceeds +`min_w` (`Tuning.saturation_w`, 500 W) in ABSOLUTE WATTS. Shrinking a unit's +share shrinks its gap. Measured end to end (`test_packshare.py` part 8b: real +`compute()`, real `plan()`, real `delinquent()`, B wedged at 15 % against its +own 10 % floor, so `envelope()` does not close and `account()` explains +nothing): at a 1500 W load the healthy pack can carry alone, equal weights name +the wedged unit on 199 of 200 cycles and these weights name it on NONE. The +suppression window widens from `site target < 2 * min_w` = 1000 W to +`site target < min_w * (w_A + w_B) / w_B` = 6500 W on that fixture - and it has +an upper edge: past the healthy pack's own rating, `_fill()`'s last-resort pass +hands the wedged unit a big share again and the alarm returns. + + ⚠️ SO THE SEAM IS NOT "OPT-09 CANNOT SEE IT". IT IS: OPT-09 STOPS THE ASK + **AND** NARROWS SAFETY-42's DETECTION WINDOW FOR A LOW-HEADROOM UNIT. That + is a disclosed residual, not a fixed thing. Closing it means making + `delinquent()` relative to the ask rather than absolute - SAFETY-42's code + and a design change, filed as its own card and deliberately NOT done here. + Do not "fix" part 8b by making it assert the alarm still fires; it does not, + and a green check that says otherwise would be worse than the gap. + WHY ENERGY AND NOT POWER ======================== The two packs at this site are asymmetric IN OPPOSITE DIRECTIONS. From the diff --git a/goodwe_controller/test_packshare.py b/goodwe_controller/test_packshare.py index 6ad4818..97445e3 100644 --- a/goodwe_controller/test_packshare.py +++ b/goodwe_controller/test_packshare.py @@ -41,6 +41,7 @@ from app.arbiter import ( ACTUATOR_SITE, CLAIM_SCOPE, P_LOOP, P_SAFETY, SCOPE_UNIT, Claim, resolve, ) from app.control import Tuning, compute +from app.delivery import UnitDelivery, account, delinquent from app.peakguard import discharge_permitted fails = [] @@ -599,14 +600,168 @@ check("...so: WEIGHTS ARE A COMFORT CHOICE FOR STABILITY AND NOT FOR DELIVERY, " weights={A: 1.0, Bee: 0.0}).shares == {A: 2000, Bee: 1000}) check("...and SAFETY-42 is where the case this card CANNOT reach is handled. A " "WEDGED unit - one that accepts its share and does not produce it - has a " - "healthy state of charge, so no envelope closes and no weight moves; that " - "module detects it, latches and notifies, and deliberately does not act. " - "OPT-09 stops the site ASKING a pack whose own SoC says it cannot " - "deliver; SAFETY-42 alarms when a pack that said it could does not. Same " - "hazard, opposite sides, and the seam is the state of charge", + "healthy state of charge, so NO ENVELOPE CLOSES; that module detects it, " + "latches and notifies, and deliberately does not act. OPT-09 stops the " + "site ASKING a pack whose own SoC says it cannot deliver; SAFETY-42 " + "alarms when a pack that said it could does not. ⚠️ THE WEIGHT, HOWEVER, " + "DOES MOVE - this check used to say it did not - and part 8b grades what " + "that costs the alarm", not packshare.envelope(PE(Bee, 55.0, B_KWH)).binding) +# ============================================================================== +print() +print("8b. ⚠️ AND WHAT THE WEIGHTS DO TO SAFETY-42's DETECTION WINDOW, WHICH IS " + "A REGRESSION AND IS PINNED HERE AS ONE") + +# ⚠️ READ THIS BEFORE READING THE CHECKS: THEY ASSERT THE UNFLATTERING ANSWER ON +# PURPOSE. An earlier draft of §5 of the decision said a wedged unit's weight +# does not move, so OPT-09 "cannot see it at all". The first clause is about the +# ENVELOPE and is true; the second is FALSE. A wedged unit's weight moves +# whenever its energy headroom differs from its sibling's - it is a pack fact, +# not a delivery fact - and `delivery.delinquent()` names a unit only while its +# `short_by()` gap exceeds `min_w` (`Tuning.saturation_w`, 500 W) IN ABSOLUTE +# WATTS. Shrinking a unit's share shrinks its gap. Shrink it below 500 W and the +# alarm stops firing on a unit that is still delivering nothing. +# +# So the seam is not "OPT-09 cannot see it". It is: OPT-09 stops the ask AND +# narrows SAFETY-42's detection window on the same unit. At equal weights the +# window is `site target < 2 * min_w` = 1000 W; at OPT-09's weights it is +# `site target < min_w * (w_A + w_B) / w_B`, which on this fixture is 6500 W. +# +# ⚠️ THIS IS A DETECTION REGRESSION AT N=2 AND IT IS NOT FIXED HERE. Making +# `delinquent()` relative to the ask rather than absolute is SAFETY-42's code and +# a design change; it is filed as its own card. What this part does is GRADE THE +# CURRENT BEHAVIOUR so that the day it changes, a named check moves. +# +# ⚠️ AND THE FIXTURE IS A SAFETY-42 TRUE POSITIVE BY THAT CARD'S OWN DEFINITION, +# checked below before anything is concluded from it: B is WEDGED - it accepts +# its share and produces nothing - at a HEALTHY 15 % against its own 10 % floor, +# so `envelope()` does not close and `account()` explains nothing. This is +# exactly the case §5 says the envelope cannot reach, which is why it is the +# case where the weights' side effect matters. +# +# ⚠️ TWO COPIES OF A. Inverter B does not exist; the plant below is modelled in +# this file the way part 8's is, and B's ratings are datasheet. + +WEDGED_A_SOC, WEDGED_B_SOC = 90.0, 15.0 # both healthy, both above their floor +MIN_W = Tuning().saturation_w # what `delinquent()` is handed + + +def wedged_loop(load_w, weighted, cycles=200): + """The closed loop with unit B WEDGED at a healthy SoC. Real everything. + + Real `compute()`, real `plan()`, real `packshare.weights()`, real + `delivery.account()` and real `delivery.delinquent()`. The only model is the + plant: A obeys within one cycle inside its rating, B accepts whatever it is + told and delivers 0 W - which is the wedged box, and is what makes this a + SAFETY-42 case rather than an OPT-09 one. + + `weighted=False` is equal weights - what shipped. Envelopes are left OPEN in + both arms deliberately: neither pack's SoC closes one here, so filing them + would change nothing and would blur which half of the card is being graded. + + Returns `(cycles on which delinquent() named somebody, last cycle)`. + """ + packs = [PE(A, WEDGED_A_SOC, A_KWH), PE(Bee, WEDGED_B_SOC, B_KWH)] + tuning = Tuning(gain=1.0, slew_w=1000.0, max_w=A_W + B_W) + prev, i_w, sat = 0.0, None, 0 + power = [0.0, 0.0] + grid = load_w + named, last = 0, None + for _c in range(cycles): + d = compute(prev, grid, sum(power), tuning, sat, i_w) + prev, i_w, sat = d.target_w, d.i_w, d.sat_count + w = packshare.weights(packs, d.target_w) if weighted else None + r = plan(d.target_w, {A: _B(-A_W, A_W), Bee: _B(-B_W, B_W)}, weights=w) + assert r.ok, r.refused + # THE MODELLED PLANT: A within one cycle inside its rating; B WEDGED. + power = [max(-A_W, min(A_W, float(r.shares[A]))), 0.0] + grid = load_w - sum(power) + who = delinquent( + [UnitDelivery(p.name, r.shares[p.name], m, + account(live=True, parked=False, soc=p.soc, + bms_floor=p.bms_floor)) + for p, m in zip(packs, power)], MIN_W) + named += bool(who) + last = (d.target_w, dict(r.shares), grid, who) + return named, last + + +_WEDGED_PACK = PE(Bee, WEDGED_B_SOC, B_KWH) +check("PRECONDITION: the wedged unit is a SAFETY-42 TRUE POSITIVE and NOT an " + "OPT-09 case - at 15 % against its own 10 % floor no envelope closes and " + "`account()` explains nothing, so anything the weights do to the alarm " + "here is done to a fault the operator is entitled to be told about", + not packshare.envelope(_WEDGED_PACK).binding + and account(live=True, parked=False, soc=WEDGED_B_SOC, + bms_floor=FLOOR) is None) + +_wedge = {} +for _load in (1500.0, 6000.0): + for _weighted in (False, True): + _n, _t = wedged_loop(_load, _weighted) + _wedge[(_load, _weighted)] = (_n, _t) + print(f" load {_load:.0f} W, B wedged at 15 %, " + f"{'OPT-09 weights' if _weighted else 'equal weights ':<14} " + f"named on {_n:3d}/200 cycles, site target {_t[0]:+.0f} W, shares " + + ", ".join(f"{u}{v:+d}" for u, v in sorted(_t[1].items())) + + f", grid {_t[2]:+.0f} W, named=" + + (str(sorted(_t[3])) if _t[3] else "(nobody)")) + +check("AT EQUAL WEIGHTS - WHAT SHIPPED - the wedged unit is named on every " + "cycle but the first, at a load its sibling can carry on its own. The " + "loop winds the site target to twice the load, the 50/50 split stands " + "+1500 W on a box producing nothing, and 1500 W > 500 W is what " + "`delinquent()` reads", + _wedge[(1500.0, False)][0] == 199 + and sorted(_wedge[(1500.0, False)][1][3]) == [Bee] + and _wedge[(1500.0, False)][1][1] == {A: 1500, Bee: 1500}) + +check("⚠️ AND WITH OPT-09's WEIGHTS IT IS NAMED ON NONE OF THE 200. Same wedged " + "box, same healthy SoC, same load, same detector: the split moves to " + "1505/125, the gap on the wedged unit falls to 125 W, and 125 W < 500 W " + "so `delinquent()` returns {}. THIS CHECK PINS A REGRESSION - it asserts " + "what the code DOES, not what anyone wants, and if it ever goes red " + "because the alarm fired, read the card that made it fire before " + "'fixing' this line", + _wedge[(1500.0, True)][0] == 0 + and _wedge[(1500.0, True)][1][3] == {} + and _wedge[(1500.0, True)][1][1] == {A: 1505, Bee: 125}) + +check("...and this is the regime where NOTHING ELSE TELLS THE OPERATOR " + "ANYTHING: the meter is satisfied (grid within 5 W of zero), `unmet_w` is " + "0 because both units accepted their shares, no envelope closed and no " + "action-log line fires. The site is quietly running one inverter and the " + "one instrument built to say so has gone quiet with it", + abs(_wedge[(1500.0, True)][1][2]) <= 5.0 + and abs(_wedge[(1500.0, False)][1][2]) <= 5.0) + +_w = packshare.weights([PE(A, WEDGED_A_SOC, A_KWH), _WEDGED_PACK], 1000.0) +_window_equal = MIN_W * 2.0 +_window_opt09 = MIN_W * sum(_w.values()) / _w[Bee] +print(f" suppression window on this fixture: equal weights, site target " + f"< {_window_equal:.0f} W; OPT-09 weights, site target " + f"< {_window_opt09:.0f} W of a {A_W + B_W:.0f} W site") +check("...and the arithmetic behind it, stated as a window rather than as one " + "fixture: `delinquent()` reads an ABSOLUTE gap, so a unit weighted to a " + "fraction f of the site target is invisible while `f * target < min_w`. " + "Equal weights suppress below 1000 W; these weights suppress below " + "6500 W of a 9600 W site. That is the regression, and it is a property " + "of the threshold being absolute rather than of any number chosen here", + abs(_window_equal - 1000.0) < 1.0 and abs(_window_opt09 - 6500.0) < 1.0) + +check("⚠️ AND IT IS NOT THAT THE WEIGHTED ARM NEVER NAMES ANYBODY - which is " + "what a check that only ever ran the quiet load would be pinning. Push " + "the load past the healthy pack's OWN RATING and `_fill()`'s last-resort " + "pass hands the wedged unit 3000 W anyway, the gap goes back over 500 W, " + "and BOTH arms name it. The window is real and it has an upper edge; the " + "hole is the quiet regime below that edge", + _wedge[(6000.0, True)][0] == 195 + and sorted(_wedge[(6000.0, True)][1][3]) == [Bee] + and _wedge[(6000.0, False)][0] == 199 + and sorted(_wedge[(6000.0, False)][1][3]) == [Bee]) + # ============================================================================== print() print("9. PER-PACK ARBITRAGE SIZING") @@ -814,8 +969,12 @@ print(" policy; `arbitrage_soc_ceiling` is a trade preference and using") print(" it here would be SAFETY-25's deferred option in the charge") print(" direction. Everything between is left to the weights, which are") print(" a preference and are honest about being one.") -print(" * A WEDGED UNIT IS NOT REACHABLE FROM HERE AT ALL. Its SoC reads") -print(" healthy, so no envelope closes. SAFETY-42 owns it.") +print(" * A WEDGED UNIT IS NOT REACHABLE BY THE ENVELOPE. Its SoC reads") +print(" healthy, so nothing closes, and SAFETY-42 owns the detection.") +print(" ⚠️ BUT IT IS REACHABLE BY THE WEIGHTS, WHICH MOVE ON ENERGY") +print(" HEADROOM AND NOT ON DELIVERY - part 8b grades what that does") +print(" to SAFETY-42's window, and the answer is a REGRESSION that is") +print(" pinned rather than fixed.") # ============================================================================== print() @@ -856,6 +1015,15 @@ MUT = [ "test_packshare 1's PRECONDITION. ⚠️ IT WAS A TRACEBACK BEFORE THAT LINE " "EXISTED - the first draft of this file had no precondition and the " "mutation took it down with a TypeError three lines into part 1"), + ("M11 weights(): keep the shape, force every weight to 1.0 <- THE PROOF " + "THAT PART 8b CAN GO RED", + "test_packshare 8b, three named checks: the wedged unit goes back to being " + "named on 199/200 at 1500 W, the split reverts to 1500/1500 and the " + "window collapses from 6500 W to 1000 W. ⚠️ RUN, NOT REASONED ABOUT - a " + "check that pins an UNFLATTERING behaviour has to be shown capable of " + "failing, or it is indistinguishable from a check that pins nothing. " + "(It also takes parts 1, 3, 7 and 8 down, which is M3's territory; 8b is " + "the part that names the SAFETY-42 interaction)"), ] for _n, _w in MUT: print(f" caught {_n}") @@ -866,7 +1034,7 @@ check("every mutation in the table above was RUN and every one of them is " "invisible to every behavioural fixture in this repository, because they " "all build their own claim list and call `resolve()` themselves. That is " "why the call site is pinned statically as well", - len(MUT) == 10) + len(MUT) == 11) print() if fails: