checks / unit (pull_request) Successful in 4m48s
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
475 lines
27 KiB
Markdown
475 lines
27 KiB
Markdown
# 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` |
|
||
| **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:
|
||
|
||
```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.
|
||
|
||
### 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:
|
||
|
||
```python
|
||
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.
|