REVIEW FOUND A MUTATION SURVIVOR THAT UNDOES THE WHOLE PRODUCT OF AC 5, and it
is a coverage defect rather than a behaviour one - the shipped code arms the
detectors correctly today. In `_arm_sign_watchdogs()`, collapsing
inv.sign_wd.arm(self.sign_cfg if one else self.unit_sign_cfg, ...)
to `arm(self.sign_cfg, ...)` throws the re-derived 250 W / 85 s per-unit pair
away and arms every unit with SAFETY-02's meter-facing 300 W / 180 s - and left
ALL 19 FILES GREEN. Reproduced here before fixing anything: fresh per-mutation
tree, no __pycache__, mounted :ro, graded through tools/mutation_run.sh; -31 B;
19 pass, 0 fail.
WHY NOTHING CAUGHT IT. `grep -rn "sign_wd.cfg" test_*.py` found exactly ONE
threshold assertion, part 7's N=1 arm at (300.0, 1.0) - which the mutation
PRESERVES. Nothing at a roster of two read `inv.sign_wd.cfg` at all. The ledger
mutates the VALUES in SIGN_OPTION_DEFAULTS (M10 250->300, M11 325->180, both
correctly red through the config pins) and never the WIRING that delivers them.
Part 3's behavioural checks cannot close the gap either, and the comment now
says why: B is reversed at ~2000 W with short holds, so it trips under EITHER
pair, and a fault big enough and long enough to trip under both thresholds is
evidence about neither.
THE FIX - test_watchdog.py part 3, on the real two-inverter Controller. Every
`inv.sign_wd.cfg` equals the per-unit pair, `site_sign_wd.cfg` equals the site
pair, and each detector's `against` is its own instrument. Graded against
LITERALS, because `== self.unit_sign_cfg` cannot tell "the per-unit pair" from
"whatever this Controller was handed". Both pairs are printed.
⚠️ AND THE FIXTURE HAD TO CHANGE FOR THE PIN TO MEAN ANYTHING - the recursion
worth naming, because it is the same defect class as the one being closed.
`_sign_site` set `sign_disagree_s`, `unit_sign_disagree_s` AND
`site_sign_disagree_s` all to 1 s, with two of the three magnitude gates at
300 W. A bare equality there discriminates on `min_w` alone: a mis-wiring that
delivered the right magnitude with the WRONG HOLD compares EQUAL to the pair it
was supposed to have. A check comparing two things that happen to be equal
cannot see the difference it names (MUTATION.md's third failure mode). The
three pairs are now pairwise distinct IN BOTH COORDINATES - 250 W / 1 s per
unit, 320 W / 2 s site, 300 W / 3 s shipped - a PRECONDITION asserts that
distinctness so a later edit collapsing them goes red rather than quietly
vacuous, and a second PRECONDITION asserts the literals are the pairs this
Controller actually holds.
VERIFIED BY BREAKING IT. Three mutations, each on its own freshly copied tree
with no __pycache__, mounted :ro, through tools/mutation_run.sh:
M14 arm() handed self.sign_cfg at every roster -31 B RED (was the
survivor; the AC 5 WIRING check names the wiring)
M15 right min_w, SHIPPED hold - hold-only mis-wire +78 B RED, same pin
M16 right min_w, SITE hold - hold-only, and it still
trips inside the drive +83 B RED, and the
ONLY red in test_watchdog.py
M16 is the one that says the hold half of the equality bites on its own.
Unmutated tree: 19 pass, 0 fail, purged and fingerprinted.
Ledger rows M14-M16 and the write-up in DECISIONS/SAFETY-30-per-unit-sign.md
section 5; a CHANGELOG bullet.
NOT IN THIS PR, so it gets a card rather than a quiet fix: a stuck per-unit
power sensor would false-trip the new per-unit check at N>=2. Already-merged
SAFETY-31 has the identical exposure on the same fixture. Pre-existing N>=2
class, unreachable at the deployed roster of one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
27 KiB
SAFETY-30 — what the sign watchdog compares once there is more than one inverter, and the numbers it compares with
Status: DECIDED 2026-09-04. The one comparison becomes two, asked of the evidence that can answer each: each unit's standing command against that unit's own measured power (register 0x0518, per-instance since DEV-02), and the sum of the standing commands against the meter. At a roster of one nothing changes at all — the two questions are the same sentence there, and the detector that ships is the only one armed.
This document records the argument for that split, the campaign the four numbers
were read off, a number that was carried and a number that refused to be, and
the mutation ledger. Filed with SAFETY-30, card 6a9a1828e844f22fabf7fe9c.
0. The half that was already built, and the half this card is
DEV-02 shipped the per-instance half and nothing here re-tests it: each
Inverter owns its own SignWatchdog, the latch is namespaced under
unit/<name>/sign_tripped, the flat pre-DEV-02 pair is maintained as a rollback
view, and a trip on B no longer latches A. test_inverter.py part 3 grades that.
What was left is the comparison itself, and it was one line:
inv.sign_wd.evaluate(stood, self.grid, self.following, now) # every unit
stood is the command this controller put on THIS actuator. self.grid is
what the whole site is exchanging with the network. At one inverter those are
two views of one thing — §6.2's identity — which is what makes the check sound
and what earned its record. At two they are not, and it fails in both
directions:
| what happens | why no threshold fixes it | |
|---|---|---|
| false trip | A commanded −1500 W while the meter reads +1500 W, because B is discharging 3000 W exactly as told | the meter is answering for a unit A's command says nothing about |
| missed fault | B commanded +1500 W and measured at −1500 W, beside A at +1500 W — the meter sits where a healthy site leaves it | the summation destroys the evidence |
NOTES.md calls SAFETY-02 the highest-value check in the safety table: 93 % agreement while healthy against 4 % during runaway, four observed episodes, zero false positives. That record was earned at N=1 on the site comparison. The first row above spends it; the second row is the guard being absent when it matters.
1. The two questions, and the two remedies
PER UNIT — this unit's standing command against its own measured power. Conclusion: the actuator I am driving is not doing what I told it. Evidence about one device, so the remedy is that device: the
failsafeSET, whicharbiter.CLAIM_SCOPEhas classifiedSCOPE_UNITsince it was written.SITE — the sum of the standing commands against the meter. Conclusion: the site as a whole is driving the wrong way — an inverted gain, an integrator wound past the rail, a strategy on the wrong side of the meter. That is the 2026-08-20 fault day, it is a property of the connection, and the remedy is a new SITE SET,
sign_site, that stops every inverter.
Why failsafe stays per-unit and sign_site is new
CLAIM_SCOPE's entry for failsafe used to read "the evidence is site-wide and
the remedy is not" — a site measurement attributed to one actuator on the
strength of the N=1 identity. After this card the first half of that sentence is
simply true: the evidence is per-unit. The site-wide half did not disappear;
it moved to a claim that is site-scoped for the reason circulation is, one
entry above it: the offending number is the site target, which no single
inverter owns. Parking one unit on it would leave the rest of the roster
pushing the same way with the guard satisfied.
At N=1, exactly one detector is armed
Controller._arm_sign_watchdogs() is the only place the roster decides anything
here, and it is a pure function of len(self.inverters):
- one inverter — the unit detector is armed against the meter, at the
shipped
sign_disagree_w/sign_disagree_s. The site detector is constructed and never evaluated. Same comparison, same thresholds, same reason string byte for byte, same namespaced keys and flat mirror, samefailsafeclaim, same banner, same single status row.test_watchdog.pypart 7 asserts each of those, including that the site detector's window was never so much as opened. - two or more — each unit against its own 0x0518 power under the per-unit pair, and the site detector against the meter under the site pair.
Arming both at N=1 would be two latches and two banners for one fault, and a false-positive record measured on one comparison spent twice.
2. What this is NOT — the two neighbours, named
Two guards that overlap without saying so is how a false-alarm generator gets built, so both are written down.
It is not SAFETY-31
SAFETY-31 — circulation.py |
SAFETY-30 — the per-unit half | |
|---|---|---|
| compares | the units against each other: Σ|Bᵢ| − |ΣBᵢ| over measured power |
each unit against its own command |
| catches | two inverters pushing through each other; nets to zero at the meter | a unit not doing what it was told; that unit is faulty or wedged |
| attributes | nothing — the quantity is defined over the roster and is identically zero for one unit | the unit, by name, and parks it |
| scope | SITE claim, flat latch | UNIT claim, namespaced latch |
They genuinely overlap in one scenario — a unit reversing while its sibling
pulls the other way — and test_watchdog.py part 4 asserts exactly that, on the
same fixture, with both readings printed: circulating power is over its threshold
and SAFETY-30 names B. Neither is the other with a different threshold. Take
two units faithfully executing an allocation with two signs: circulating power is
the whole loop and every per-unit sign check reads perfect agreement. Take one
unit doing the opposite of its share while its sibling covers: this reads the
whole disagreement and circulating power can be zero.
It is not the saturation freeze
compute()'s anti-windup test is |prev_w − actual_w| > saturation_w (500 W). It
concludes the inverter cannot follow, it fires on a merely saturated unit as
readily as on a reversed one, it stops the integrator winding — an ordinary
control action taken every day — and it neither latches nor parks anything.
This asks for opposition: a sign, on both sides of a threshold, held continuously. A saturated-but-obedient unit cannot produce it. And the two meet at a number rather than overlapping in a band nobody drew — see §3.
3. The numbers, and where they come from
Read off admin/goodwe's sim/ two-master rig (TEST-19), used read-only and
run container-free. Real and imported, never transcribed: the control law
(control.compute, Tuning), the allocator (arbiter.allocate_record →
allocator.plan), the sign predicate (watchdog.disagrees), the wire
(frame_check.setpoint_atomic, the firmware's own encoder), the plant
(plantsim.Plant), and the per-unit reading, taken from holding register
0x0518 — the register the live batt_entity binds.
1890 closed-loop runs in which NOTHING is faulty — every unit executes the
share the allocator gave it — over 6 gains × 5 slews, the whole range
config.yaml accepts for gain float(0.05,1.0) and slew_w int(50,5000),
across both families of stimulus that can produce legitimate opposition:
- 1050 load reversals — 7 sizes (±300 W … ±4000 W) × 5 write staggers, the serialised write path landing unit B's number up to four cycles after A's;
- 840 flapping loads — 7 periods (10 s … 600 s) × 4 amplitudes.
Every run is executed twice: once at a roster of two and once at a roster of one on the same stimulus. The N=1 arm is the control, and it is the reason this document can separate "a property of the check that shipped" from "a property of the second inverter" instead of asserting one and hoping.
Supervised caps are the shipped max_w (2000 W per unit), so the site rail is
2000 W at N=1 and 4000 W at N=2 — Controller.site_max_w, the roster sum.
Reproducing it
The harness is not committed here, for the reason SAFETY-31's was not: it
imports plantsim and frame_check, which live in the other repository and are
not present in CI. What it does is small enough to restate: build two
plantsim.Inverters from INVERTER_A / INVERTER_B, close the loop with the
add-on's own compute() and arbiter.allocate_record(), put each share on its
own wire through Inverter.command(), advance Site.settle() one control cycle,
and record (standing command, inv.reg(0x0518)) per unit and
(Σ standing, site.grid) for the site. Then, for each candidate threshold, the
longest run of consecutive cycles on which the shipped disagrees() is true —
not a count of true samples, because the watchdog's window is cleared by any
disagreeing sample. Point it at a checkout with GOODWE_ADDON_REPO.
⚠️ MUTATION.md's note applies: those harnesses write __pycache__ into both
trees.
A. PER-UNIT — a unit's standing command vs its own 0x0518 power, N=2
| threshold | worst continuous, reversals | worst continuous, flapping | runs crossing (of 1890) |
|---|---|---|---|
| 50 W | 25.0 s | 20.0 s | 962 |
| 100 W | 20.0 s | 15.0 s | 511 |
| 150 W | 15.0 s | 15.0 s | 379 |
| 200 W | 10.0 s | 15.0 s | 258 |
| 250 W | 10.0 s | 10.0 s | 194 |
| 300 W | 10.0 s | 10.0 s | 124 |
| 500 W | 5.0 s | 5.0 s | 37 |
| 700 W | 5.0 s | 5.0 s | 16 |
| 1000 W | 0.0 s | 0.0 s | 0 |
Largest opposed magnitude anywhere in the 1890 runs: 875 W.
The curve is flat, and that is the finding that decided the shape of this pair. Unlike the site comparison — where the meter is driven by the LOAD and a flapping load can hold it opposed for minutes — a unit's own power chases its own command through a bounded ramp and a first-order lag, so the opposition can only last as long as the crossing does. Dropping the threshold from 300 W to 50 W buys 15 s of extra transient and nothing else. So the work is done by the window, and the threshold is put where the control law already draws its line.
unit_sign_disagree_w: 250 — schema int(50,5000)
saturation_w / 2. The check gates on both sides being over min_w, and the
two are opposed, so the smallest command-versus-power gap it can ever see is
2 × min_w. At 250 W that is exactly the 500 W at which compute()'s own
anti-windup test already concludes the inverter is not following. This watchdog
therefore cannot fire on a divergence the shipped control law itself calls
ordinary tracking, and the two guards meet at a number.
Rejected: a threshold above the observed 875 W maximum, which would gate nothing legitimate in — and would also be blind to a unit reversing a 900 W share.
unit_sign_disagree_s: 85 — schema int(30,1800)
Three bounds, and the value clears all three.
- lower, from the rig — 10.0 s. Two control cycles, the worst continuous legitimate opposition above 250 W across all 1890 runs. It is a reversing site whose second write lands a cycle after the first, both units then slewing through zero behind a 10 s lag. Nothing is wrong.
- lower, from the firmware — 30 s (
inverter.LINK_STALE_S). A unit not executing its share for less than the firmware's own veto window is a link fault the add-on already answers by dropping the unit from the split. Firing inside that window would raise a second, louder alarm about a state already being handled — the SAFETY-15/SAFETY-19 failure exactly. - upper, from SAFETY-02 itself — 180 s, the site hold. A unit that is not doing what it was told must not be parked later than the site would be parked for the same watts, and this conclusion needs nothing to net out: it is complete on the first cycle it appears.
85 s is seventeen control cycles: 8.5× the worst legitimate episode, 2.8× the firmware veto, and under half the site hold. Deliberately not 60, 75, 90, 120 or 180.
B / C. SITE — Σ standing commands vs the meter, at BOTH rosters
Load-reversal family (1050 runs each), worst continuous legitimate opposition:
| threshold | N=2 | N=1 (control) | ratio |
|---|---|---|---|
| 100 W | 335.0 s | 175.0 s | 1.91× |
| 200 W | 260.0 s | 120.0 s | 2.17× |
| 300 W | 215.0 s | 110.0 s | 1.95× |
| 500 W | 155.0 s | 90.0 s | 1.72× |
| 700 W | 130.0 s | 70.0 s | 1.86× |
| 1000 W | 85.0 s | 35.0 s | 2.43× |
The second inverter roughly DOUBLES the worst legitimate crossing, and at the shipped 300 W threshold it takes it from 110 s — comfortably inside the 180 s hold — to 215 s, which is outside it.
The mechanism is not mysterious and is worth stating because it predicts the
ratio: the transient the 180 s hold exists to reject is the loop crossing zero
after a load reversal, and the command has to travel the whole site rail at
slew_w per cycle. At N≥2 that rail is Controller.site_max_w, the roster sum,
while slew_w is unchanged. The worst case is the corner of the config space
where that takes longest — gain: 0.05, slew_w: 50, both schema floors —
against a ±2200 W load reversal, which is an oven and a heat pump.
site_sign_disagree_w: 300 — CARRIED, and here is the justification
The argument behind 300 W is about noise: "near zero the sign is noise, and
near zero is where a healthy loop LIVES" — the deadband holds the command still
while the meter wanders either side of target_grid_w. deadband_w is 15 W and
there is one P1 whatever is behind it. Nothing in that argument depends on the
roster, and the campaign agrees: the fraction of samples gated in at 300 W is
within a few percent at both rosters. Carried, with a measurement behind the
carry rather than an assumption.
site_sign_disagree_s: 325 — NOT carried, and that is the point of the card
180 s is inside the 215 s the two-inverter campaign measures, at a configuration the schema accepts. Carrying it would have shipped a false trip on the check with the best record in the safety table — which is the exact failure this card was opened to prevent, arriving from the other direction.
325 s is 1.51× the measured worst. It is well inside the 900 s quarter that bills the capacity tariff, and it is inside every episode the fault day contains: 2026-08-20's four clusters run 16, 30, 77 and 77 minutes, and its opposed runs reach 1750 s. What it costs is stated rather than hidden — about two and a half extra minutes of wrong-direction site power before the stop — and it is bought to remove a false trip a legal configuration produces. Deliberately not 300 or 360.
Rejected alternative: keep 180 s and raise the site threshold to 1000 W, where the worst is 85 s. It gives a better margin and a faster stop, and it is wrong: it would be raising a noise gate to reject a transient, which is what the window is for, and it would leave the site check blind to a 900 W whole-site reversal at a 4 kW site.
A limitation this campaign found, which SAFETY-30 does not introduce and does not fix
The flapping-load family behaves completely differently for the SITE comparison, and the control arm is what makes it interpretable:
| threshold | N=2 | N=1 (control) |
|---|---|---|
| 300 W | 980.0 s | 1000.0 s |
| 700 W | 975.0 s | 1000.0 s |
| 1000 W | 950.0 s | 1000.0 s |
A square wave whose half period is about one control cycle opposes the command on every cycle: the command chases the previous half-cycle's meter while the meter has already reversed. No window inside the billing quarter survives it and no threshold up to 1000 W does either — at EITHER roster. The N=1 column is this document's whole reason for existing: this is a property of comparing a lagged command against a meter, and it has nothing to do with the second inverter.
It is not acted on here, in order of weight: it is identical at the roster this add-on ships, so acting on it would be changing a live safety path on evidence that has nothing to do with this card; a ~6 kW square wave at 0.1 Hz sustained for a quarter of an hour is not a house (the captured healthy day's longest opposed run is 30 s); and the fix is not a number — it wants the detector to notice that the disagreement is alternating, which is a different check. It is a card, and this section is its evidence.
D. The fault arm — because a survey of healthy data is not a derivation
210 runs with inverter B executing the negation of its share throughout, sibling faithful, meter left to net:
| threshold | longest run the per-unit check detects | runs the SITE check also sees |
|---|---|---|
| 250 W | 890.0 s | 210 / 210 |
| 300 W | 890.0 s | 180 / 210 |
| 700 W | 885.0 s | 112 / 210 |
| 1000 W | 885.0 s | 60 / 210 |
| 1500 W | 880.0 s | 45 / 210 |
| 2000 W | 0.0 s | 39 / 210 |
At the shipped 250 W the fault is detected continuously for the whole run in
every one of the 210 — an 85 s hold has ten times the evidence it needs. The
right-hand column is the honest half: on a reversing stimulus the site
comparison often does see something too, because the reversal is large enough to
move the meter. The case it cannot see is the steady one, and that is the
fixture test_watchdog.py part 4 runs: the shipped comparison is false on
0 of 14 cycles while the per-unit check convicts B on every one.
4. Inverter B does not exist
Every two-inverter figure in §3 was produced with inverter A's ramp and lag
copied onto B (plantsim.INVERTER_B, measured=False); its 3000 W rating,
where this repository's fixtures use one, is a hypothesis marked as one.
SPIKE-04 measures the real ones and is the revisit trigger for all four
numbers — not a reason to move any of them now.
Which figures depend on it, stated so nobody has to work it out:
unit_sign_disagree_s: 85— most. Its lower bound is the 10.0 s crossing, which is a direct function of B's ramp and lag. A slower second unit lengthens it; the 8.5× is what buys headroom for that.site_sign_disagree_s: 325— yes, but less directly. Its 215 s is dominated by the rail and byslew_w, which are configuration, not hardware; B's dynamics move it at the margin.unit_sign_disagree_w: 250— no. It issaturation_w / 2, read off the add-on's own control law. The rig only confirms that nothing legitimate sits over it for long.site_sign_disagree_w: 300— no. It is the meter's noise floor and the deadband, neither of which is a property of any inverter.
5. Mutation ledger
Every run on a freshly copied tree of its own, no __pycache__, mounted
:ro into the CI image — the structural immunity MUTATION.md records. ⚠️ The
first battery used ONE shared directory rebuilt between mutations and reported a
RED baseline; a container mounted it mid-copy and read a missing
config.yaml as a failing check. That whole run was discarded: MUTATION.md's
rule is that a run which could have been racing is not a result, and a battery
whose baseline is not green establishes nothing. The harness now copies a
directory per mutation and refuses to report anything if the baseline is not
green.
Byte deltas measured, not estimated; the +0 B ones are marked, since a
size-preserving edit is what TEST-15 turned into a false survivor once already.
| # | file | mutation | Δ | result |
|---|---|---|---|---|
| M1 | main.py |
the per-unit comparison put back to self.grid at every roster |
−30 B | RED — test_watchdog.py. This is the card |
| M2 | main.py |
the per-unit comparison forced to inv.batt at N=1 too |
−31 B | RED — AC 8 |
| M3 | main.py |
the site half never evaluated | −6 B | RED — AC 3 |
| M4 | main.py |
the site half armed at N=1 as well | +0 B | SURVIVOR on the first battery — GAP CLOSED, then RED. See below |
| M5 | main.py |
the sign_site claim never filed |
−6 B | RED — test_watchdog.py. ⚠️ test_peakguard.py stayed GREEN, correctly: 10f's AST scan pins which sources CAN be filed and the append is still written; whether it is REACHED is a dynamic question no fixture there gets to |
| M6 | main.py |
the site latch not persisted to the store | −58 B | RED — the flat-keys and restart checks |
| M7 | main.py |
the operator clear leaves the site latch set | −42 B | RED — the clear check |
| M8 | watchdog.py |
arm() clears disagree_since |
+35 B | RED — "re-armed on every one of 38 cycles still trips" |
| M9 | watchdog.py |
the reason hard-codes "a meter reading" again | +1 B | RED — test_watchdog.py. test_inverter.py stayed GREEN; its part-3 row reads the noun off sign_wd.against rather than out of the reason string, so the two checks bite in different places |
| M10 | watchdog.py |
unit_sign_disagree_w 250 → 300 |
+0 B | RED — the saturation_w / 2 pin and the CFG-03 pin |
| M11 | watchdog.py |
site_sign_disagree_s 325 → 180, i.e. the number carried |
+0 B | RED — the measured-215 s check and the CFG-03 pin |
| M12 | arbiter.py |
sign_site scoped SCOPE_UNIT |
+0 B | RED — test_arbiter.py and test_peakguard.py |
| M13 | validate.py |
the unit_sign_disagree_w rule removed |
−126 B | RED — test_validate.py |
| M14 | main.py |
arm() handed self.sign_cfg at EVERY roster — the WIRING, not the values |
−31 B | SURVIVOR at review — GAP CLOSED, then RED. See below |
| M15 | main.py |
per-unit arm() given the right min_w and the SHIPPED hold — a HOLD-ONLY mis-wire |
+78 B | RED — the same wiring pin |
| M16 | main.py |
per-unit arm() given the right min_w and the SITE hold — hold-only again, and behaviour-preserving |
+83 B | RED — the wiring pin, and the ONLY red in test_watchdog.py |
M4 is recorded as a survivor because it was one
The check it should have gone red on read:
not _c1s.site_sign_wd.tripped and _c1s.site_sign_wd.disagree_since is None
Arming the site detector at a roster of one left that green, and the reason
is worth reading because the check looked airtight. In the N=1 fixture the unit
detector trips first at its 1 s hold and parks the inverter; the site's standing
command is then 0 W, so the site predicate is false, its window is never
opened, and disagree_since is None is true whether or not the detector was
evaluated at all. The assertion ran, the sentence was true, and the data
could not reach the failure it named — MUTATION.md's third failure mode,
inside the check written to prevent it.
Replaced with a call counter installed on the object before the run: twelve cycles, zero calls, printed. A counter can only be zero for one reason. The mutation was re-run against it and is red; the run that found it was discarded along with its tree, so this is a result and not an artefact.
M14: the VALUES were pinned and the WIRING was not
M10 and M11 mutate the numbers in watchdog.SIGN_OPTION_DEFAULTS and both are
red through the config pins. Neither of them touches the line that DELIVERS
those numbers to a detector:
inv.sign_wd.arm(self.sign_cfg if one else self.unit_sign_cfg,
AGAINST_METER if one else AGAINST_OWN_POWER)
Drop the conditional — arm(self.sign_cfg, ...) — and every unit is armed with
SAFETY-02's meter-facing pair instead of the re-derived per-unit one. At review
that left all 19 files green. grep -rn "sign_wd.cfg" test_*.py found
exactly one threshold assertion, and it is §7's N=1 arm — (300.0, 1.0) —
which the mutation PRESERVES. Nothing anywhere read inv.sign_wd.cfg at a
roster of two.
The behavioural checks in part 3 cannot close it and it is worth saying why: B is reversed at ~2000 W and every hold in that fixture is short, so B trips under either pair. A fault big enough and long enough to trip under both thresholds is evidence about neither.
What the survivor costs in the field is the whole product of §3: at
config.yaml's own defaults the per-unit check would run at sign_disagree_s
180 s instead of 85 s — 2.1× slower to park a reversed inverter — and its
gate would move from 250 W to 300 W, so a unit reversing a share between the two
becomes invisible, while SIGN_OPTION_DEFAULTS, config.yaml and §3 all go on
describing a pair the running code does not use. With CI green.
Closed by a wiring pin in test_watchdog.py part 3, on the real
two-inverter Controller: every inv.sign_wd.cfg equals the per-unit pair,
site_sign_wd.cfg equals the site pair, and each detector's against is its
own instrument — all graded against literals, since == self.unit_sign_cfg
cannot tell the per-unit pair from whatever this Controller was handed.
⚠️ And the fixture had to be changed for that pin to mean anything.
_sign_site set unit_sign_disagree_s: 1 and site_sign_disagree_s: 1
and sign_disagree_s: 1, with two of the three magnitude gates at 300 W. A
bare equality there discriminates on min_w alone: a mis-wiring that delivered
the right magnitude with the wrong hold would compare EQUAL to the pair it was
supposed to have. That is this document's own §5 lesson and MUTATION.md's third
failure mode — a check comparing two things that happen to be equal cannot see
the difference it names — reappearing inside the fix for it. The three pairs are
now pairwise distinct in both coordinates (250 W / 1 s, 320 W / 2 s,
300 W / 3 s), a PRECONDITION check asserts that distinctness so a later edit
collapsing them goes red rather than quietly vacuous, and both pairs are
printed. M15 and M16 exist to show the hold half actually bites: right
magnitude, wrong hold, red both times — and M16, whose wrong hold still trips
inside the drive, is red on the wiring pin and on nothing else in that file.
6. What this card does NOT close
- The flapping-load blind spot in the SITE comparison. §3, at length. Present identically at the shipped roster of one; wants an alternation test, not a number; a card of its own.
- A unit misbehaving while it is NOT loop-driven. Gate 1 (
following) is kept for the per-unit comparison, deliberately: the whole no-false-positive argument is built on it, and the saturation freeze already grades that window. So between the start of a maintenance cycle and its end, a unit reversing against its own command is the freeze's to notice. Widening it is a card with a rig campaign behind it, not an edit. - A sign inversion downstream of this add-on — in the firmware, on the wire, or in a future driver. Unchanged: our view stays self-consistent and both comparisons stay silent. That is the witness's problem and it always was.
- A false-alarm interaction with SAFETY-31 that nobody has to think about. They overlap in one scenario and both fire in it. That is correct — they conclude different things — but an operator will see two alarms for one event. Whether the banner should say so in one sentence is a UI card.