Files
goodwe-addon/goodwe_controller/DECISIONS/SAFETY-38-site-standing-pin.md
T
adminandClaude Opus 5 0803149548 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
2026-09-05 16:31:17 +02:00

12 KiB
Raw Blame History

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

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.