SAFETY-38: pin site_standing with shares that cannot answer for the site

`app/main.py:1598`'s `site_standing = float(sum(standing.values()))` is the
first argument of SAFETY-30's latching, site-stopping sign detector AND the
`commanded_w` handed to SAFETY-18's `looks_frozen()`. It was pinned by nothing.
Coverage only: not one byte of `app/` moves - the shipped `sum` is correct.

WHY THE SUITE COULD NOT SEE IT. `_sign_site`, the only N>=2 site fixture in the
repository, runs shares of about -2500 W EACH - every one individually over its
320 W gate - so sum, mean and max(key=abs) all agree on it. Its values happen to
be equal, and a check comparing two things that happen to be equal cannot see
the difference it names. MUTATION.md habit 4, ninth instance, inside the site
half of the card that names the rule in its own text. At N=1 it is arithmetic
rather than a fixture defect: sum == mean == max == the one share, identically.

THE FIXTURE (test_watchdog.py part 9). Two SIMULATED units rated 220 W and
280 W, both obeying exactly, inverted gain, house importing 2000 W: the site
commands -500 W against a +2500 W meter and NO SINGLE SHARE clears the shipped
300 W gate. Four candidate reductions, four distinct answers, one over the gate
- sum -500, mean -250, max(key=abs) -280, [0] -220 - asserted as a precondition.
The asymmetry is load-bearing, and the smaller unit is first so [0] stays
distinct from max(key=abs). Gate shipped; only the hold is shortened.

BOTH CONSUMERS. 9b drives the same discrimination at SAFETY-18's threshold: the
site rail is 0.95 x 500 = 475 W, so the sum rails it while 250, 280 and 220 do
not. Shipped: BLIND_FROZEN, floor 0 W. Under both surviving mutations: not blind
at all, no floor filed - the battery may keep importing through a quarter whose
billed average is unobservable. The open quarter is backfilled with the stuck
reading so the mutant cannot reach the same action by the coverage route and
mask the difference. 9c asserts the N=1 identity on a real one-inverter tree.

SAFETY-32 already closes `list(standing.values())[0]` structurally and this card
does not claim it. Its disclosed blind spot - semantics are invisible to a shape
pin, permanently - is why the mean and max(key=abs) needed a behavioural set.

AND THE SEARCH FOR THE NEXT SURVIVOR FOUND ONE. `float(self.target)` - the
arbiter's site target instead of the sum of what stands on the wire - is -11 B
and survives all 20 files on untouched release/1.0 @ f72c48d, because every
fixture that existed was built where the two are the same number. They separate
when a device's reported range is narrower than its max_w: the `device` claim is
filed per actuator and never bounds the site target. Closed here by a
counterfactual that asks for -500 W while -250 W stands.

10 mutations of that one line, each on a fresh full-repo copy mounted :ro,
purged by tools/mutation_run.sh, the edit re-read from disk and the byte delta
printed. Ledger and honest gaps in DECISIONS/SAFETY-38-site-standing-pin.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
2026-09-05 16:31:17 +02:00
committed by glenn schrooyen
co-authored by Claude Opus 5
parent 86eaef2517
commit 0803149548
4 changed files with 849 additions and 3 deletions
@@ -0,0 +1,237 @@
# SAFETY-38 — pinning `site_standing`, the one quantity both site guards are asked about
**Status: DECIDED 2026-09-05.** `app/main.py`'s
`site_standing = float(sum(standing.values()))` is **correct and is not
changed**. What this card adds is the thing it never had: a **behavioural**
fixture whose per-unit shares **cannot** answer for the site, so that a change
of *meaning* in that reduction goes red instead of green.
Filed with SAFETY-38, card `6a9ac95f2b78646e5a76d2e9`. It is a **coverage**
card: not one byte of `app/` moves.
---
## 0. The line, and its two consumers
```python
site_standing = float(sum(standing.values())) # app/main.py:1598
```
`standing` is `{unit name -> the value THIS controller last put on that unit's
setpoint entity}` — `inv.last_write`, i.e. the number **after**
`clamp_to_rating()` has applied `max_w` and the device's own
`[dev_min, dev_max]`. Two safety inputs are derived from it and nothing else in
`cycle()` is:
| consumer | where | what it does with it |
|---|---|---|
| **[[SAFETY-30]]**'s site sign detector | `self.site_sign_wd.evaluate(site_standing, self.grid, …)` (main.py:1619) | **latches and stops the whole site** when the total command opposes the meter for `site_sign_disagree_s` |
| **[[SAFETY-18]]**'s freeze conjunction | `looks_frozen(unchanged_s, site_standing, max_w, …)` inside `peakguard.decide(frozen=…)` (main.py:1926) | takes the peak guard **blind** and files `floor_w = 0.0` — the battery may not import while the billed quarter is unmeasurable |
Both are P0 paths. **Neither was pinned to the reduction.**
## 1. What survived, and what the rescope measured
The card opened with three demonstrated live mutants, all green across the whole
suite. Re-measured on 2026-09-05 against `release/1.0` @ `f72c48d`, after
[[SAFETY-32]] merged:
| mutation of `site_standing` | before SAFETY-32 | after SAFETY-32 |
|---|---|---|
| `list(standing.values())[0]` — one unit's share | survives | **CAUGHT** by SAFETY-32's package-wide structural pin |
| `sum(...) / max(1, len(standing))` — the mean | survives | **still survives** |
| `max(standing.values(), key=abs)` — the largest share | survives | **still survives** |
SAFETY-32's pin catches *a slice or index standing between a roster-derived
value and its reduction*, on the line it is written, at every roster length,
with no fixture — which is exactly `[0]`. Its own disclosed blind spot says why
the other two are out of reach:
> *SEMANTICS. `min` → `max` → the mean are edits invisible to any shape pin,
> permanently. Only the behavioural sets can see them.*
`sum` → mean and `sum` → `max(key=abs)` are **semantic**: identical shape,
different meaning. **No shape pin will ever close them.** That disclosure was
accurate, and is now confirmed by measurement rather than taken on trust.
## 2. Why the suite could not see it — habit 4, inside the site half of the very card that names it
`test_watchdog.py`'s `_sign_site` is the **only** N≥2 site fixture in the
repository. It runs at `site_sign_disagree_w: 320` with shares of roughly
−2500 W **each**. Every one of them is individually far over the gate, so on
those numbers
```
sum = −5000 W over mean = −2500 W over max = −2500 W over
```
— **all three reductions agree**, and a check whose two candidate answers happen
to be equal cannot observe a change that swaps them. That is `MUTATION.md`'s
third failure mode, and this is its ninth instance.
At a roster of **one** it is not a fixture defect but arithmetic:
`sum == mean == max == the one share`, identically, so all three mutations are
genuinely **equivalent** on a shipped install. No N=1 fixture can ever separate
them; §3c asserts that rather than leaving it to the reader.
## 3. The fixture, and the numbers it is built on
Two **simulated** units rated 220 W and 280 W, both obeying exactly, inverted
gain, house importing 2000 W. Both stand at their own rails, so `standing` is
`{-220, -280}` in roster order and the meter reads +2500 W.
| reduction | value | vs the SHIPPED 300 W gate |
|---|---|---|
| `sum` | **−500 W** | **OVER** → trips |
| `sum / len` (the mean) | −250 W | under → silent |
| `max(…, key=abs)` | −280 W | under → silent |
| `list(…)[0]` | −220 W | under → silent |
**Four distinct magnitudes — 500, 250, 280, 220 — of which exactly one clears
the gate.** The asymmetry is load-bearing twice: equal shares would collapse
mean, max and first onto one number, and putting the **smaller** unit first is
what keeps `[0]` distinct from `max(key=abs)`. The gate is left at its shipped
default so the fixture is graded against the magnitude an install runs; only the
**hold** is shortened (325 s → 2 s), because the hold is a wall clock and the
campaign behind 325 s is measured on the rig, not here.
⚠️ **220 W and 280 W are SIMULATED ratings chosen for that arithmetic and for
nothing else.** They are not measurements. In particular they are **not**
inverter B, which does not physically exist; B is a GW5048D-ES, serial
`55048ESU22AW0992`, **4600 W from its datasheet** (`goodwe/NOTES.md` §1.1b).
### 3a. The site sign detector
Shipped tree: the site latches, commands 0 W on every inverter, and the sentence
an operator reads is
`SIGN_DISAGREEMENT: commanded -500 W against a meter reading +2500 W …`. No
per-unit watchdog trips — every unit is faithfully executing its share, which is
the asymmetry the site detector exists for.
Under the mean and under `max(key=abs)`: **nothing trips, ever.** The reviewer's
tree was still commanding −500 W of charge into a +2500 W import at t = 595 s
with `following=True`.
Two counterfactuals sit beside it so the trip is attributable:
* the **same** inverted-gain site whose shares sum to −250 W — under the gate —
does **not** trip, so the trip above is the sum crossing 300 W and not the
mere presence of a second inverter;
* a site whose **device range is narrower than `max_w`** asks for −500 W while
only −250 W stands, and does **not** trip — see §5.
### 3b. The second consumer, covered rather than scoped out
`site_standing` is also `looks_frozen()`'s `commanded_w`. The same
discrimination applies at a different threshold: the site rail is
`RAIL_FRAC * site_max_w` = 0.95 × 500 = **475 W**.
| reduction | value | vs the 475 W rail |
|---|---|---|
| `sum` | **500 W** | **RAILED** → `BLIND_FROZEN`, `floor_w = 0.0` |
| the mean | 250 W | under → silent |
| `max(…, key=abs)` | 280 W | under → silent |
| `list(…)[0]` | 220 W | under → silent |
The sign is flipped from §3a on purpose: the gain is the **shipped positive**
one and the frozen meter reads import, so the site *discharges* +500 W against a
+2500 W import — the two agree in sign, SAFETY-30 has nothing to say, and this
half grades SAFETY-18 alone. Reusing §3a's inverted gain would have the site
sign latch park everything at 0 W, and the freeze conjunction could then never
be true: a check measuring the *other* guard's remedy.
⚠️ **The open quarter is backfilled with the same stuck reading, and that is
not decoration.** Without it the block is mostly uncovered, `blindness()`
answers `BLIND_COVERAGE`, and the mutant reaches *the same action by a different
route* — a check on the action alone could not then see the difference it names.
Seeded, the measurement is honest:
* shipped: `blind = BLIND_FROZEN`, `floor_w = 0.0` — no charging while the
billed quarter is unobservable;
* both mutations: **not blind at all**, `floor_w = None` — the site may go on
importing through a quarter whose billed average cannot be measured. That is
the capacity-tariff hazard SAFETY-18 exists for, silently disarmed.
The counterfactual comes from the **same run**: on the earlier cycles the
command had not yet reached the rail and the guard did not report SAFETY-18,
with the meter equally frozen throughout — so the verdict is produced by the
command magnitude `site_standing` carries and not by `unchanged_s` alone. A low
gain (0.05, the schema floor) is what buys those below-the-rail cycles.
### 3c. At N=1, nothing changes — asserted
A real one-inverter controller is driven through the same frozen-meter
scenario. Its `standing` has a single entry, so `sum`, the mean,
`max(key=abs)` and `[0]` are printed and asserted to be **one number**; the
site sign detector is still absent (part 7 counts the calls on the object); and
SAFETY-18 still reaches `BLIND_FROZEN` on that same value. The claim "the
shipped install is unaffected" now rests on the roster rather than on the
reader.
## 4. Mutation ledger
Every run: a **fresh full-repo copy**, `__pycache__` and `*.pyc` excluded,
mounted `:ro`, graded by `tools/mutation_run.sh` (which purges, prints the tree
fingerprint, and verifies no cache appears during the run). The mutated line was
**re-read from disk** after the edit and the byte delta printed. Shipped
`app/main.py` is 232 520 bytes.
| # | mutation of `site_standing` | Δ bytes | verdict |
|---|---|---|---|
| M1 | `sum(...) / max(1, len(standing))` — the mean | +24 | **RED** |
| M2 | `max(standing.values(), key=abs)` — largest share | +9 | **RED** |
| M3 | `list(standing.values())[0]` — one share | +4 | **RED** |
| M4 | `min(standing.values(), key=abs)` — smallest share | +9 | **RED** |
| M5 | `sum(abs(v) for v in ...)` — magnitude, sign lost | +16 | **RED** |
| M6 | `-sum(standing.values())` — sign flipped | +1 | **RED** |
| M7 | `float(self.target)` — the arbiter's site target | **−11** | **was a SURVIVOR — now RED**, see §5 |
| M8 | `sum(standing.values()) / 2.0` | +6 | **RED** |
| M9 | `sum(standing.values()) * 2.0` | +6 | **RED** |
| M10 | `sorted(standing.values())[-1]` — largest signed | +7 | **RED** |
M3 is listed for completeness only. SAFETY-32 already closes it **structurally**
and this card does not claim it; that the behavioural fixture also catches it is
a free consequence of the same discriminating numbers, not a second pin.
## 5. The survivor this card went looking for, and closed
M7 is not one of the card's three. It was found by asking the question the
recurring defect demands — *what mutation would my new checks NOT see?* — and it
is the answer: **−11 bytes**, and it survived every check above **and the whole
20-file suite**, because in every fixture built so far the arbiter's site target
and the sum of what actually stands on the wire are **the same number**.
They are not the same number when a setpoint device's own reported range is
narrower than its `max_w`. The `device` P_SAFETY claim is filed **per actuator**
(`actuator_of("device", inv)`), so it bounds each unit's **share** and never the
site target, and `clamp_to_rating()` clips again on the way out. On two units
rated 220/280 W whose devices report ±120/±130 W, the site asks for **−500 W**
and **−250 W** stands.
Which one the guards mean is already written down, in both of them: SAFETY-02
grades *"the command standing on the wire"*, and `looks_frozen()`'s docstring
says `commanded_w` is *"the EMITTED setpoint — what the arbiter resolved and
`write_setpoint()` put on the wire — not the loop's raw wish"*. [[SAFETY-20]] is
the whole card about the difference. Those sentences are now made to bite: on
the site target the detector fires on a site standing at −250 W against a
+2250 W meter — **under its own 300 W gate** — which is a latching, site-wide
false trip on an install whose inverters simply report a narrower range than the
operator typed.
## 6. Honest gaps
* **A mutation that changes the SIGN CONVENTION of `standing` itself** — i.e. in
`main.py`'s `stood = inv.last_write if inv.last_write is not None else
inv.target`, not on the reduction line — is **not** in this ledger. It is a
different line and a different card; §3a's obedience precondition would see
some of it, but that is not a measured claim and is not made here.
* **The freeze half is graded at ONE roster length (2) and one rail.** The
`RAIL_FRAC` constant, `rail_ceiling_w()`'s device-bound conjunction and
`rail_unreachable()` are `test_freeze.py`'s subject and are untouched here.
* **The site hold is 2 s, not the shipped 325 s.** The window's derivation is a
rig campaign (`DECISIONS/SAFETY-30-per-unit-sign.md` §3B) and this file does
not re-grade it. The **gate** is the shipped 300 W, which is the coordinate
this card is about.
* **Everything about inverter B here is simulated and labelled so.** No number
in this document or in the fixture is a measurement of a device that does not
yet exist.