SAFETY-30: grade each inverter against its own power, and the site's total against the meter
checks / unit (pull_request) Successful in 4m38s

THE DEFECT, in one line. `inv.sign_wd.evaluate(stood, self.grid, ...)` compared
the command THIS controller put on ONE actuator against what the WHOLE SITE is
exchanging with the network. At a roster of one those are two views of the same
thing - specs.md §6.2's identity - and that is what makes the check sound and
what earned it the best record in the safety table (NOTES.md: 93 % agreement
while healthy against 4 % during runaway, four observed episodes, zero false
positives). At two inverters they are not, and it fails in BOTH directions:

  FALSE TRIP  inverter A commanded -1500 W while the meter reads +1500 W is
              evidence of nothing when inverter B is discharging 3000 W exactly
              as told. Nothing is faulty and the highest-value check fires.
  MISSED      the converse, and it is worse. A unit commanded +1500 W and
              MEASURED at -1500 W, beside a sibling at +1500 W, leaves P1
              exactly where a healthy site leaves it. The summation destroys the
              evidence and no threshold recovers it.

DEV-02 had already shipped the per-INSTANCE half - one SignWatchdog per
inverter, namespaced latch, a trip on B no longer latching A. What was left was
the COMPARISON, and it is two questions rather than one:

  PER UNIT  this unit's standing command against ITS OWN measured power
            (`Inverter.batt`, holding register 0x0518, per-instance since
            DEV-02). Conclusion: "the actuator I am driving is not doing what I
            told it" - a fact about one device, so the remedy stays the per-unit
            `failsafe` SET that arbiter.CLAIM_SCOPE has classified SCOPE_UNIT
            from the day it was written. That entry read "the evidence is
            site-wide and the remedy is not"; the first half is now simply true.
  SITE      the SUM of the standing commands against the meter, as a new
            `sign_site` SET at P_SAFETY on the site actuator, latched under flat
            `site_sign_tripped` / `site_sign_reason`. This is the 2026-08-20
            fault day and NO per-unit check can see it, because every inverter
            may be faithfully executing its share of a number that is itself
            wrong. It stops every inverter and the banner names the site.

WHAT IT IS NOT, said here because two guards that overlap without saying so is
how a false-alarm generator gets built. It is not SAFETY-31: circulation.py
compares the units AGAINST EACH OTHER and catches them pushing power THROUGH
each other, which nets to zero at the meter and about which no single command is
evidence. This compares each unit against ITS OWN COMMAND. They overlap in
exactly one scenario - a unit reversing while its sibling pulls the other way -
and test_watchdog.py asserts on that fixture that both fire and what each
concludes. It is also not the saturation freeze, which is a MAGNITUDE test
(|command - measured| > 500 W) that shapes the loop and fires on a merely
saturated unit; this is a DIRECTION test that latches and parks one.

THE FOUR NUMBERS, RE-DERIVED AT TWO INVERTERS - one carried with a justification
and one refused. 1890 closed-loop runs on admin/goodwe's TEST-19 two-master rig
in which NOTHING is faulty, swept over the whole range config.yaml accepts for
`gain` and `slew_w`, across load reversals with write staggers and flapping
loads, and RUN AT BOTH ROSTERS so the N=1 arm is the control:

  unit_sign_disagree_w  250   `saturation_w / 2`. The check gates on both sides,
                              opposed, so the smallest gap it can see is 500 W -
                              exactly where compute()'s own anti-windup test
                              already says the inverter is not following. It
                              cannot fire on a divergence the control law calls
                              ordinary tracking. Worst legitimate opposition at
                              250 W: 10.0 s; the curve is nearly flat, because a
                              unit's own power chases its own command.
  unit_sign_disagree_s   85   8.5x that 10.0 s, 2.8x the firmware's own 30 s
                              link veto (inverter.LINK_STALE_S), under half the
                              180 s site hold.
  site_sign_disagree_w  300   CARRIED. Its argument is the meter's noise floor
                              and the 15 W deadband; the roster touches neither,
                              and the campaign agrees - the gate opens in 994 of
                              1050 runs at two inverters and 955 of 1050 at one.
  site_sign_disagree_s  325   NOT CARRIED, and this is the number the card
                              exists to not assume. The worst legitimate
                              crossing at 300 W is 110 s with one inverter and
                              215 s with TWO, because the command must travel a
                              rail that is the roster sum at a `slew_w` that is
                              not - reachable at gain 0.05, slew_w 50 and a
                              2.2 kW load reversal, all schema-legal. 180 s
                              would have shipped a false trip. 325 s is 1.51x
                              the measured worst, inside the 900 s billing
                              quarter and inside every episode of the fault day.

AT ONE INVERTER NOTHING CHANGES, and "nothing" is asserted rather than assumed.
The two questions are the same sentence there, so only the detector that shipped
is armed: same comparison against the meter, same 300 W / 180 s, same reason
string byte for byte, same namespaced latch and flat rollback mirror, same
`failsafe` claim, same banner, same single status row - and the site detector is
NEVER CALLED, counted on the object over twelve cycles.

VERIFIED BY BREAKING IT. Thirteen mutations on fresh per-mutation trees with no
__pycache__, mounted :ro, with the battery refusing to report at all unless the
baseline is green. Twelve red on the first pass. M4 - the site half armed at N=1
as well - SURVIVED, because the check read `disagree_since is None` and the unit
detector trips first, parks the inverter and leaves the site standing at 0 W:
the assertion ran, the sentence was true, and the data could not reach the
failure it named. Replaced with a call counter on the object; M4 is red now.
Recorded as a survivor in DECISIONS/SAFETY-30-per-unit-sign.md §5, with the
first battery's own RED BASELINE - a shared mutation directory read mid-copy -
discarded rather than reported.

RECORDED, NOT FIXED: the site comparison is defeated by a square-wave load whose
half period is about one control cycle, which holds it opposed for ~1000 s. The
N=1 control arm is what makes that interpretable - it is identical at the roster
this add-on ships, so it is a property of comparing a lagged command against a
meter and has nothing to do with the second inverter. It wants an alternation
test rather than a number, and a card of its own.

SAFETY-24's lo-only asymmetry proof re-run unchanged: 1296 combinations, 0
negative floors, `hi` +inf on every claim, every claim on the SITE actuator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
2026-09-04 09:33:20 +02:00
co-authored by Claude Opus 5
parent b30797e1f9
commit f000547518
14 changed files with 2126 additions and 89 deletions
@@ -0,0 +1,420 @@
# 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:
```python
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 `failsafe` SET, which
> `arbiter.CLAIM_SCOPE` has classified `SCOPE_UNIT` since 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, same
`failsafe` claim, same banner, same single status row. `test_watchdog.py`
part 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.Inverter`s 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 by `slew_w`, which are configuration, not
hardware; B's dynamics move it at the margin.
* **`unit_sign_disagree_w: 250`** — **no.** It is `saturation_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` |
### M4 is recorded as a survivor because it was one
The check it should have gone red on read:
```python
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.
## 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.