From a8bd2b933024043db2533f884b67e21c0959c770 Mon Sep 17 00:00:00 2001 From: admin Date: Sat, 5 Sep 2026 14:10:30 +0200 Subject: [PATCH 1/6] 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 Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU --- goodwe_controller/CHANGELOG.md | 63 +++ .../DECISIONS/SAFETY-38-site-standing-pin.md | 237 ++++++++ goodwe_controller/MUTATION.md | 34 +- goodwe_controller/test_watchdog.py | 518 ++++++++++++++++++ 4 files changed, 849 insertions(+), 3 deletions(-) create mode 100644 goodwe_controller/DECISIONS/SAFETY-38-site-standing-pin.md diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index 51f0ca6..2037d46 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -79,6 +79,69 @@ real controller's number to the bit, which pins the test's `decide()` kwargs as main.py's actual call rather than an approximation of it. **One tolerance got stricter** (1.5 W → exact), because removing the clock made exactness possible. +**`site_standing` — the one quantity BOTH site guards are asked about — is now +pinned by a fixture whose per-unit shares cannot answer for it (SAFETY-38).** +`app/main.py`'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**, and three mutations of it disarmed both guards with the whole suite +green. **This card changes coverage only: not one byte of `app/` moves — the +shipped `sum` is correct and must not be "fixed".** + +* **⭐ THE FIXTURE THE SUITE COULD NOT BUILD: per-unit shares EACH BELOW the + site gate whose SUM clears it.** 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 + 300 W gate. That is the case the site detector exists for, and it is the only + arrangement that can separate `sum` from the mean and from the largest share. + `test_watchdog.py` part 9a. +* **WHY THE EXISTING FIXTURE COULD NOT.** `_sign_site`, the suite's only N≥2 + site fixture, runs shares of about −2500 W **each** — every one individually + over the gate — so sum, mean and largest-share 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 very card that names it. +* **FOUR CANDIDATE REDUCTIONS, FOUR DISTINCT ANSWERS, ONE OVER THE GATE** — + `sum` −500, mean −250, `max(key=abs)` −280, `[0]` −220 — and that is asserted + as a precondition, not described. The asymmetric ratings are load-bearing: + equal shares collapse three of the four onto one number, and the **smaller** + unit is first so `[0]` stays distinct from `max(key=abs)`. The **gate is the + shipped 300 W**; only the hold is shortened, because the hold is a wall clock. +* **⭐ THE SECOND CONSUMER IS COVERED, NOT SCOPED OUT.** The same value is + `looks_frozen()`'s `commanded_w` at `main.py:1926`. At the same roster the + site rail is 0.95 × 500 = **475 W**, so `sum` = 500 W rails it while 250, 280 + and 220 do not. Shipped: the peak guard goes blind on SAFETY-18's conjunction + and files `floor_w = 0.0`. Under both surviving mutations it is **not blind at + all and files no floor**, so the battery may keep importing through a quarter + whose billed average is unobservable. `test_watchdog.py` part 9b. The open + quarter is deliberately backfilled with the stuck reading so the mutant cannot + reach the same action by the *coverage* route and mask the difference. +* **AT N=1 NOTHING CHANGES, AND IT IS ASSERTED.** On a real one-inverter + controller `standing` has one entry, so `sum`, the mean, `max(key=abs)` and + `[0]` are **the same number** — all three mutations are genuinely *equivalent* + on a shipped install and no N=1 fixture could ever separate them. Printed and + checked, beside the site detector still being absent there. Part 9c. +* **ONE OF THE THREE WAS ALREADY CLOSED, AND THIS CARD DOES NOT RE-COVER IT.** + [[SAFETY-32]]'s package-wide structural pin catches + `list(standing.values())[0]` on the line it is written, at every roster + length, with no fixture. Its own disclosed blind spot — *"SEMANTICS. `min` → + `max` → the mean are edits invisible to any shape pin, permanently"* — is why + the other two needed this card, and it is now confirmed by measurement rather + than taken on trust. +* **⭐ A SURVIVOR THIS CARD WENT LOOKING FOR AND CLOSED.** + `site_standing = float(self.target)` — the arbiter's site *target* instead of + the sum of what actually **stands on the wire** — is **−11 bytes** and + survived the full suite, because in every fixture that existed the two are the + same number. They are not when a device's reported range is narrower than its + `max_w`: the `device` claim is filed **per actuator**, so it bounds each + unit's share and never the site target. A site asking for −500 W while −250 W + stands would then take a **latching site-wide false trip** under its own + 300 W gate. Closed by part 9a's device-clamped counterfactual. +* **10 mutations of that one line, each on a fresh full-repo copy mounted `:ro`, + bytecode purged by `tools/mutation_run.sh`, the edit re-read from disk and the + byte delta printed. All 10 red.** Ledger and the honest gaps in + `DECISIONS/SAFETY-38-site-standing-pin.md`. + **Inverter B gets its own MQTT device, and inverter A keeps the one it has (TEL-07).** MQTT discovery announced **one** device, and every entity hung off one `unique_id` prefix, so at two inverters B's per-unit telemetry had nowhere diff --git a/goodwe_controller/DECISIONS/SAFETY-38-site-standing-pin.md b/goodwe_controller/DECISIONS/SAFETY-38-site-standing-pin.md new file mode 100644 index 0000000..443277a --- /dev/null +++ b/goodwe_controller/DECISIONS/SAFETY-38-site-standing-pin.md @@ -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. diff --git a/goodwe_controller/MUTATION.md b/goodwe_controller/MUTATION.md index c95bbed..36e863d 100644 --- a/goodwe_controller/MUTATION.md +++ b/goodwe_controller/MUTATION.md @@ -135,12 +135,13 @@ manufactures them from a **fixture**: the check runs, the assertion is evaluated, the sentence above it is true — and the numbers it compares are arranged so that the failure it describes could not have changed the answer. -It is not hypothetical and it is not rare. EIGHT instances in two weeks; four +It is not hypothetical and it is not rare. NINE instances in two weeks; four were found by review, two - CFG-04's fixture pair - by an author's own mutation battery, which is the cheaper place, one by review again, in the one place nobody had thought to point the rule at: not a check in the suite, but a -COMMITTED EVIDENCE ARTIFACT four documents cite - and the eighth INSIDE THE FIX -WRITTEN TO CLOSE THE SEVENTH. +COMMITTED EVIDENCE ARTIFACT four documents cite - the eighth INSIDE THE FIX +WRITTEN TO CLOSE THE SEVENTH, and the ninth inside the SITE HALF of the card +that names the rule in its own text. ⚠️ **AND IT HAS NOW APPEARED IN SIX CONSECUTIVE CARDS.** The seventh instance, MAINT-05's, is the one that says why: the defect is not carelessness about @@ -234,6 +235,33 @@ same place twice, the check is decoration. assertion cannot fail is exactly the thing this section is about. Two habits follow, and both are habit 5 and 6 below. +* **SAFETY-38** — `main.py`'s + `site_standing = float(sum(standing.values()))`, the first argument of + SAFETY-30's latching site sign detector *and* SAFETY-18's `looks_frozen()` + input. `test_watchdog.py`'s `_sign_site` is the suite's **only** N≥2 site + fixture, and it runs shares of about **−2500 W each** — every one + individually over the 320 W gate it is configured with. On numbers like those + `sum`, the mean and `max(key=abs)` **all agree**, so the reduction could be + swapped for either of the other two and the site guard went **blind with all + 19 files green** — still commanding −500 W of charge into a +2500 W import at + t = 595 s with `following=True`. At a roster of **one** it is worse and it is + not a fixture defect at all: `sum == mean == max == the one share` + *identically*, so every such mutation is genuinely **equivalent** there and no + N=1 fixture could ever separate them. The fixture that separates them is the + one the detector exists for — **per-unit shares EACH BELOW the gate whose SUM + clears it** — and it is now `test_watchdog.py` part 9, at asymmetric ratings + so that all four candidate reductions give four **distinct** answers. + + ⚠️ **AND THE CARD'S OWN SEARCH FOR ITS NEXT SURVIVOR FOUND ONE, WHICH IS THE + POINT OF ASKING.** Having built the fixture, the question *what mutation would + this NOT see?* produced `float(self.target)` — the arbiter's site target + instead of the sum of what stands on the wire — at **−11 B**, green across the + whole suite, because every fixture in the repository was built where the two + are the same number. They separate only when a device's reported range is + narrower than its `max_w`, since the `device` claim is filed **per actuator** + and never bounds the site target. Closed in the same commit, by a + counterfactual that asks for −500 W while −250 W stands. + Note what these have in common with a masked rung: the mutation *runs*, and the suite is *honestly* green. Nothing in a purge, a fingerprint or a disassembly can see it, because the code under test really did behave identically on the diff --git a/goodwe_controller/test_watchdog.py b/goodwe_controller/test_watchdog.py index af55acc..930564a 100644 --- a/goodwe_controller/test_watchdog.py +++ b/goodwe_controller/test_watchdog.py @@ -1993,6 +1993,524 @@ check("...and the counterfactual again: the same site with nothing latched " not _bn_n["banner"].startswith("FAILSAFE") and _bn_n["banner"] != _bn_b["banner"]) + +print() +print("9. SAFETY-38: `site_standing` - the ONE quantity BOTH site guards are") +print(" asked about - pinned by shares that CANNOT answer for it") +# ⚠️ WHAT THIS SECTION PINS, AND WHY NOTHING ABOVE IT COULD. +# `main.py`'s `site_standing = float(sum(standing.values()))` is the first +# argument of SAFETY-30's site sign detector AND the `commanded_w` handed to +# SAFETY-18's `looks_frozen()`. It was pinned by NOTHING. Three mutations of it +# were demonstrated live against a full green suite; SAFETY-32's package-wide +# structural pin has since closed `list(standing.values())[0]` - a slice +# standing between a roster-derived value and its reduction, which it sees on +# the line it is written, at every roster length, with no fixture. DO NOT +# RE-COVER THAT ONE HERE. The two that remain are SEMANTIC: +# +# sum(...) / max(1, len(standing)) the MEAN +# max(standing.values(), key=abs) the LARGEST SHARE +# +# Identical shape, different meaning - which is precisely the blind spot +# SAFETY-32 disclosed in its own words ("SEMANTICS. `min` -> `max` -> the mean +# are edits invisible to any shape pin, permanently"). No shape pin will ever +# close them. Only a behavioural fixture can, and this is it. +# +# ⚠️ WHY PART 5 ABOVE CANNOT SEE THEM, AND IT IS THIS REPOSITORY'S RECURRING +# DEFECT INSIDE THE SITE HALF OF THE VERY CARD THAT NAMES IT. `_sign_site` is +# the only N>=2 site fixture in the suite and it runs at +# `site_sign_disagree_w: 320` with shares of about -2500 W EACH - every one of +# them individually far OVER the gate. On values like those the sum, the mean +# and the largest share all clear it, so all three reductions agree, and a +# check whose two candidate answers happen to be EQUAL cannot observe a change +# that swaps them. MUTATION.md habit 4. At a roster of ONE it is worse: there +# `sum == mean == max == the one share` identically, so every mutation is +# EQUIVALENT there - see 9c, where that identity is asserted rather than +# assumed. +# +# ⚠️ SO THE NUMBERS BELOW ARE CHOSEN TO DISCRIMINATE, AND HERE IS THE +# ARITHMETIC THEY ARE CHOSEN FOR. Two simulated units rated 220 W and 280 W +# stand at their own rails, so `standing` is {-220, -280} in roster order: +# +# sum -500 W |500| > 300 OVER the gate -> TRIPS +# mean -250 W |250| < 300 under it -> silent +# max(..., key=abs) -280 W |280| < 300 under it -> silent +# list(...)[0] -220 W |220| < 300 under it -> silent +# +# FOUR DISTINCT MAGNITUDES - 500, 250, 280, 220 - of which exactly ONE clears +# the SHIPPED 300 W gate. The asymmetry is load-bearing twice over: equal +# shares would collapse mean, max and first onto one number and the fixture +# could not tell them apart, and putting the SMALLER unit first is what keeps +# `[0]` distinct from `max(key=abs)`. +# +# ⚠️ THE RATINGS ARE SIMULATED AND ARE NOT ANY REAL INVERTER'S. 220 W and 280 W +# are chosen for the arithmetic in that table and for nothing else. In +# particular they are NOT inverter B, which does not physically exist; B's +# rating is 4600 W FROM ITS DATASHEET (goodwe/NOTES.md 1.1b) and no number in +# this section is a measurement of anything. +# +# ⚠️ THE GATE IS THE SHIPPED ONE AND THE HOLD IS NOT. `site_sign_disagree_w` is +# left at its default so this fixture is graded against the magnitude an +# install actually runs; `site_sign_disagree_s` is shortened from 325 s to 2 s +# because the hold is a wall clock and the campaign behind 325 s is measured on +# the rig, not here. The gate is the coordinate this section is about. +from app import peakguard as _pg38 # noqa: E402 +from app.freeze import rail_w as _rail_w38 # noqa: E402 +from app.p1 import P1Sample as _P1Sample38 # noqa: E402 +import time as _time38 # noqa: E402 + +_S38_A_W, _S38_B_W = 220.0, 280.0 # SIMULATED. See the note above. +_S38_LOAD_W = 2000.0 +_S38_FROZEN_METER_W = 2500.0 + + +def _s38_roster(*ratings): + """A CFG-04 `inverters:` roster, which is the shape an N>=2 install runs. + + ⚠️ NOT `_sign_site`'s "build one from the flat keys and append a second". + `Controller.__init__` builds `Tuning` FROM the roster - `max_w=site_max_w` - + so a unit appended afterwards leaves the site loop bounded at ONE unit's + rating. That is harmless where part 3 uses it and fatal here: the whole + fixture is a site command standing at the ROSTER's rail. + """ + return [{"name": name, "setpoint_entity": f"number.sp_{tag}", + "soc_entity": f"sensor.soc_{tag}", "batt_entity": f"sensor.batt_{tag}", + "max_w": w, "bms_soc_floor": 10} + for name, tag, w in zip(("inverter-a", "inverter-b", "inverter-c"), + ("a", "b", "c"), ratings)] + + +class _S38Plant: + """Units that obey INSTANTLY and a meter that is `load - sum(measured)`. + + The same modelling choice part 3 makes and for the same reason: an instant + plant removes the one mechanism that could make an obedient unit look + disobedient, so what remains to be graded is the ATTRIBUTION. + """ + + def __init__(self, load_w=_S38_LOAD_W): + self.load_w = float(load_w) + self.writes = [] + self.calls = [] + self.ctrl = None + + async def call(self, domain, service, data): + self.calls.append((domain, service, data)) + + async def limits(self, entity): + return None + + def measured(self): + if self.ctrl is None: + return [] + return [float((i.last_write if i.last_write is not None else i.target) or 0.0) + for i in self.ctrl.inverters] + + async def number(self, entity, invert=False): + m = self.measured() + if entity == "sensor.grid": + return self.load_w - sum(m) + for i, tag in enumerate(("a", "b", "c")): + if entity == f"sensor.soc_{tag}": + return 50.0 + if entity == f"sensor.batt_{tag}": + return m[i] if i < len(m) else None + return None + + async def set_number(self, entity, value): + self.writes.append((entity, float(value))) + return True + + +def _s38_ctrl(hass, ratings, store=None, **over): + opts = {"meter_entity": "sensor.grid", "meter_source": "off", + "inverters": _s38_roster(*ratings), + "gain": -0.6, + # The per-unit pair, distinct from the site pair in BOTH + # coordinates, for part 3's reason: an equality below must be able + # to fail on the magnitude alone or on the hold alone. + "unit_sign_disagree_w": 250, "unit_sign_disagree_s": 1, + # ⚠️ `site_sign_disagree_w` DELIBERATELY ABSENT - the shipped 300 W + # default is the gate this whole section is graded against. + "site_sign_disagree_s": 2, + # SAFETY-31 is given a long window so the SITE is not stopped by + # circulation while this section grades the sign attribution. It is + # isolation, not a claim that SAFETY-31 is silent here. + "circulation_hold_s": 3600, "auto_start": True} + opts.update(over) + c = Controller(opts, hass, store if store is not None else _MemStore(), + _P(), stop=asyncio.Event()) + for inv in c.inverters: + inv.dev_min, inv.dev_max = -inv.max_w, inv.max_w + c.auto = True + hass.ctrl = c + return c + + +def _s38_stood(c): + return [inv.last_write if inv.last_write is not None else inv.target + for inv in c.inverters] + + +print() +print("9a. the SITE sign detector: per-unit shares EACH BELOW the gate whose") +print(" SUM clears it - the case the site detector exists for") + + +async def _s38_site(): + hass = _S38Plant() + c = _s38_ctrl(hass, (_S38_A_W, _S38_B_W)) + seen = [] + for _ in range(20): + await c.read_inputs() + await c.cycle() + seen.append(dict(grid=c.grid, stood=_s38_stood(c), + meas=[i.batt for i in c.inverters], + site=c.site_sign_wd.tripped, + units=[i.sign_wd.tripped for i in c.inverters], + following=c.following)) + await asyncio.sleep(0.2) + return c, seen + + +_c38, _s38seen = asyncio.run(_s38_site()) +# The last sample BEFORE the trip, named rather than indexed - part 5's lesson. +# After the trip every command is 0 W, so a fixed index reads the REMEDY. +_pre38 = [x for x in _s38seen if not x["site"]] +_mid38 = _pre38[-1] +_GATE38 = _c38.site_sign_wd.cfg.min_w +_share38 = list(_mid38["stood"]) +_red38 = {"sum": sum(_share38), + "mean": sum(_share38) / max(1, len(_share38)), + "max(key=abs)": max(_share38, key=abs), + "[0]": _share38[0]} +print(f" last cycle before the trip: stood " + f"{[round(x) for x in _share38]} measured " + f"{[round(x) for x in _mid38['meas']]} meter {_mid38['grid']:+.0f} W") +print(f" site gate {_GATE38:.0f} W (SHIPPED default), hold " + f"{_c38.site_sign_wd.cfg.hold_s:.0f} s") +for _k, _v in _red38.items(): + print(f" {_k:>13} = {_v:+7.1f} W " + f"{'OVER' if abs(_v) > _GATE38 else 'under'} the gate") +check("PRECONDITION: the gate really is the SHIPPED 300 W, so this fixture is " + "graded against the magnitude an install runs and not against a number " + "chosen to make it pass", + _GATE38 == float(SIGN_OPTION_DEFAULTS["site_sign_disagree_w"]) == 300.0) +check("⚠️ PRECONDITION, AND IT IS THE WHOLE OF SAFETY-38. EVERY per-unit share " + "is BELOW the site gate and their SUM is OVER it. `_sign_site` above " + "cannot make this statement - its shares are each individually over the " + "gate, so sum, mean and largest-share all agree there and its values " + "cannot observe a change that swaps them", + len(_share38) >= 2 + and all(abs(s) < _GATE38 for s in _share38) + and abs(sum(_share38)) > _GATE38) +check("...and the FOUR candidate reductions give FOUR DISTINCT answers on it, " + "of which exactly ONE clears the gate. That is what makes this fixture " + "able to tell `sum` from the mean, from the largest share and from the " + "first share - and it is the property the existing site fixture lacks", + len({round(v, 6) for v in _red38.values()}) == 4 + and [k for k, v in _red38.items() if abs(v) > _GATE38] == ["sum"]) +check("PRECONDITION: EVERY inverter is obeying - each unit's measured power " + "agrees in sign with its own share - and the site is charging into an " + "importing house. Without this the trip below could be a per-unit fault " + "in disguise rather than the site fault this grades", + all(s * m > 0 for s, m in zip(_mid38["stood"], _mid38["meas"])) + and sum(_mid38["stood"]) * _mid38["grid"] < 0 + and _mid38["following"] is True) +check("⚠️ THE SITE WATCHDOG TRIPS ON THE SUM, and no per-unit watchdog does. " + "Under `sum -> mean` and `sum -> 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. This is the check both of " + "those mutations go red on", + _c38.site_sign_wd.tripped + and not any(i.sign_wd.tripped for i in _c38.inverters)) +print(f" {_c38.site_sign_wd.reason!r}") +check("...and the sentence an operator reads quotes THE SUM, digit for digit - " + "-500 W, not -250, not -280, not -220. A mutation that still tripped but " + "on some other reduction would put a different number in the store, the " + "action log and the banner, and this is where that shows", + f"{sum(_share38):.0f} W" in _c38.site_sign_wd.reason + and "against a meter reading" in _c38.site_sign_wd.reason) +check("...and it stops the WHOLE site, through the `sign_site` claim by name " + "rather than by some other guard happening to stop it", + _c38.target == 0.0 and "sign_site" in _c38.reason + and all(i.target == 0.0 for i in _c38.inverters) + and _c38.store.data.get("site_sign_tripped") is True) +# ⚠️ THE COUNTERFACTUAL, and it is what says the check above is produced by the +# SUM crossing the gate rather than by two inverters being present. Same plant, +# same roster shape, same everything - except the units are rated so that the +# sum lands UNDER the gate. Nothing may trip. +_S38_SMALL = (120.0, 130.0) # sum 250 W: under the 300 W gate + + +async def _s38_under(): + hass = _S38Plant() + c = _s38_ctrl(hass, _S38_SMALL) + for _ in range(20): + await c.read_inputs() + await c.cycle() + await asyncio.sleep(0.2) + return c + + +_c38u = asyncio.run(_s38_under()) +print(f" counterfactual: stood {[round(x) for x in _s38_stood(_c38u)]} " + f"sum {sum(_s38_stood(_c38u)):+.0f} W meter {_c38u.grid:+.0f} W") +check("...and the counterfactual: the SAME inverted-gain site whose shares sum " + "to 250 W - under the gate - does NOT trip, over the same 20 cycles. So " + "the trip above is the sum crossing 300 W and not the mere presence of a " + "second inverter", + abs(sum(_s38_stood(_c38u))) < _GATE38 + and not _c38u.site_sign_wd.tripped + and not any(i.sign_wd.tripped for i in _c38u.inverters)) +# ⚠️ AND A SECOND COUNTERFACTUAL, WHICH EXISTS BECAUSE OF A MUTATION THE CHECKS +# ABOVE COULD NOT SEE AND THIS SEARCH WENT LOOKING FOR. +# `site_standing = float(self.target)` - the arbiter's SITE TARGET instead of +# the sum of what actually stands on the wire - is -11 bytes, and it survived +# every check above AND the whole 20-file suite, because in every fixture so +# far the two 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 (main.py, `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. Here 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. This check is those sentences made to +# bite: on the site target the detector fires on a site that is standing at +# -250 W against a +2250 W meter - under its own 300 W gate - which is a +# LATCHING, site-stopping false trip on an install whose inverters simply +# report a narrower range than the operator typed. +_S38_DEV = (120.0, 130.0) # narrower than the 220/280 W ratings + + +async def _s38_devclamped(): + hass = _S38Plant() + c = _s38_ctrl(hass, (_S38_A_W, _S38_B_W)) + for inv, d in zip(c.inverters, _S38_DEV): + inv.dev_min, inv.dev_max = -d, d + for _ in range(20): + await c.read_inputs() + await c.cycle() + await asyncio.sleep(0.2) + return c + + +_c38d = asyncio.run(_s38_devclamped()) +_stood38d = _s38_stood(_c38d) +print(f" device-clamped: site target {_c38d.target:+.0f} W but only " + f"{[round(x) for x in _stood38d]} = {sum(_stood38d):+.0f} W stands, " + f"meter {_c38d.grid:+.0f} W") +check("PRECONDITION: the site TARGET and the EMITTED total are DIFFERENT " + "numbers here, on opposite sides of the gate - -500 W asked, -250 W " + "standing. Without that separation this check and the one above compare " + "two values that happen to be equal, which is the defect this card is " + "the sixth instance of", + abs(_c38d.target) > _GATE38 and abs(sum(_stood38d)) < _GATE38) +check("⚠️ ...and NOTHING TRIPS, because the quantity graded is what STANDS ON " + "THE WIRE and not what the arbiter asked for. `float(self.target)` is " + "-11 bytes, survives the other 20 files, and turns this ordinary " + "narrow-rating install into a latching site-wide false trip", + not _c38d.site_sign_wd.tripped + and not any(i.sign_wd.tripped for i in _c38d.inverters) + and _c38d.target != 0.0) + + +print() +print("9b. THE SECOND CONSUMER: the same value is SAFETY-18's `commanded_w`") +print(" inside `looks_frozen()`, and both mutations blind it too") +# ⚠️ THIS IS THE AC THE CARD REFUSES TO LET BE SCOPED OUT SILENTLY. +# `site_standing` is handed to `looks_frozen()` at main.py's `peakguard.decide( +# ..., frozen=...)` call, where it is the EMITTED command graded against the +# site rail. Both surviving mutations shrink it there too, and nothing went red. +# +# THE RAIL ARITHMETIC, and it is the same discrimination as 9a at a different +# threshold: the site rail is `RAIL_FRAC * site_max_w` = 0.95 * 500 = 475 W. +# +# sum 500 W >= 475 RAILED -> BLIND_FROZEN, floor 0 W +# mean 250 W < 475 silent +# max(..., key=abs) 280 W < 475 silent +# list(...)[0] 220 W < 475 silent +# +# ⚠️ THE SIGN IS FLIPPED FROM 9a ON PURPOSE. Here 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 section grades SAFETY-18 alone. Reusing 9a'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. +# +# WHAT THE MUTATIONS COST HERE, measured rather than asserted: on the shipped +# tree the guard goes blind and files `floor_w = 0.0` - "the battery may not +# import while the billed quarter cannot be measured". Under both mutations it +# is NOT BLIND AT ALL and files `floor_w = None`, so the site may go on +# charging through a quarter whose billed average is unobservable. That is the +# capacity-tariff hazard SAFETY-18 exists for, silently disarmed. + + +class _S38Src: + """The transport's own `unchanged_s`, which only homewizard_local has.""" + + def __init__(self, unchanged_s): + self.unchanged_s = float(unchanged_s) + + +def _s38_feed(c, w, ts=None, mono=None): + now = ts if ts is not None else datetime.now(timezone.utc) + c.p1.submit(_P1Sample38( + ingest_ts=now, + ingest_mono=mono if mono is not None else _time38.monotonic(), + telegram_ts=now, source="homewizard_local", + import_w=max(w, 0.0), export_w=max(-w, 0.0), net_w=w, + per_phase_w=(w,), per_phase_import_w=(max(w, 0.0),))) + + +def _s38_seed_quarter(c, w): + """Backfill the OPEN quarter with the same stuck value, at 10 s spacing. + + ⚠️ THIS IS NOT DECORATION. Without it the open block is mostly uncovered and + `blindness()` answers BLIND_COVERAGE - which is still blind, still floor + 0 W, and would therefore MASK the mutations: the mutant would reach the + same action by a different route and a check on the action alone could not + see the difference it names. Seeded, the mutant is not blind at ALL, which + is the honest measurement of what the mutation costs. A meter that has been + stuck at the same reading all quarter is also what SAFETY-18 is about. + """ + wall = _time38.time() + q0 = (int(wall) // 900) * 900 + mono0 = _time38.monotonic() - (wall - q0) + t = float(q0) + while t < wall: + _s38_feed(c, w, datetime.fromtimestamp(t, timezone.utc), mono0 + (t - q0)) + t += 10.0 + + +def _s38_frozen_ctrl(hass, ratings, unchanged_s=600.0, gain=0.05): + # ⚠️ `gain` 0.05 IS THE SCHEMA FLOOR AND IT IS HERE TO BUY A RAMP. At the + # shipped 0.6 the loop is at the rail on cycle 0 and the fixture has no + # BELOW-the-rail cycles to use as its counterfactual. At 0.05 the site + # climbs 125 W a cycle and crosses 475 W part way through the run, so the + # SAME run carries both answers. + c = _s38_ctrl(hass, ratings, meter_source="homewizard_local", + p1_host="meter.local", gain=gain) + c.p1_source = _S38Src(unchanged_s) + return c + + +async def _s38_frozen(ratings, meter_w=_S38_FROZEN_METER_W, n=8): + hass = _S38Plant() + c = _s38_frozen_ctrl(hass, ratings) + _s38_seed_quarter(c, meter_w) + seen = [] + for _ in range(n): + _s38_feed(c, meter_w) + await c.read_inputs() + await c.cycle() + pg = c.peak_guard + seen.append(dict(stood=_s38_stood(c), + blind=None if pg is None else pg.blind, + floor=None if pg is None else pg.floor_w)) + await asyncio.sleep(0.05) + return c, seen + + +_c38f, _s38f = asyncio.run(_s38_frozen((_S38_A_W, _S38_B_W))) +_RAIL38 = _rail_w38(_c38f.site_max_w, + dev_min=_c38f.site_dev_bound("dev_min"), + dev_max=_c38f.site_dev_bound("dev_max")) +_last38 = _s38f[-1] +_below38 = [x for x in _s38f if abs(sum(x["stood"])) < _RAIL38] +print(f" site rail {_RAIL38:.0f} W of site_max_w {_c38f.site_max_w:.0f} W; " + f"meter stuck at {_S38_FROZEN_METER_W:+.0f} W for " + f"{_c38f.p1_source.unchanged_s:.0f} s (freeze hold " + f"{_c38f.freeze_wd.cfg.hold_s:.0f} s)") +for _x in _s38f: + print(" stood %-16s sum %+7.1f W floor=%s blind=%r" + % ([round(v) for v in _x["stood"]], sum(_x["stood"]), + _x["floor"], _x["blind"])) +check("PRECONDITION: at the rail EVERY per-unit share is BELOW the site rail " + "and their SUM is at or over it - the same discrimination as 9a, at " + "SAFETY-18's threshold instead of SAFETY-30's. `[0]`, the mean and the " + "largest share are 220, 250 and 280 W against a 475 W rail", + len(_last38["stood"]) >= 2 + and all(abs(s) < _RAIL38 for s in _last38["stood"]) + and abs(sum(_last38["stood"])) >= _RAIL38 + and len({round(v, 6) for v in + (sum(_last38["stood"]), + sum(_last38["stood"]) / len(_last38["stood"]), + max(_last38["stood"], key=abs), + _last38["stood"][0])}) == 4) +check("⚠️ THE PEAK GUARD GOES BLIND ON SAFETY-18's CONJUNCTION and forbids " + "charging - floor 0 W - because the SITE's total command is at the rail " + "while the meter has stopped moving. Under `sum -> mean` and " + "`sum -> max(key=abs)` it is not blind at all and files no floor, so the " + "battery may keep importing through an unmeasurable billed quarter. This " + "is the second consumer, and it is covered rather than scoped out", + _last38["blind"] == _pg38.BLIND_FROZEN and _last38["floor"] == 0.0) +check("...and the counterfactual, from the SAME run: on the earlier cycles the " + "site command had not yet reached the rail, and the guard did NOT report " + "SAFETY-18 - with the meter equally frozen the whole time. So the " + "verdict is produced by the COMMAND MAGNITUDE `site_standing` carries, " + "not by `unchanged_s` alone", + len(_below38) >= 2 + and all(x["blind"] != _pg38.BLIND_FROZEN for x in _below38)) + + +print() +print("9c. AT ONE INVERTER NOTHING CHANGES, and 'nothing' is asserted") +# ⚠️ THE REASON THIS SUBSECTION EXISTS. At a roster of one `standing` has a +# single entry, so `sum`, the mean, `max(key=abs)` and `[0]` are THE SAME +# NUMBER: all three mutations are genuinely EQUIVALENT there and no fixture at +# N=1 can ever separate them. That is a fact about the arithmetic, and leaving +# it implicit is how "the shipped install is covered" gets believed. It is +# asserted here, on a real one-inverter controller's own standing command, so +# the claim rests on the roster rather than on the reader. +_S38_ONE = (500.0,) + + +async def _s38_one(): + hass = _S38Plant() + c = _s38_frozen_ctrl(hass, _S38_ONE) + _s38_seed_quarter(c, _S38_FROZEN_METER_W) + for _ in range(8): + _s38_feed(c, _S38_FROZEN_METER_W) + await c.read_inputs() + await c.cycle() + await asyncio.sleep(0.05) + return c + + +_c38one = asyncio.run(_s38_one()) +_one38 = _s38_stood(_c38one) +_onered38 = (sum(_one38), sum(_one38) / max(1, len(_one38)), + max(_one38, key=abs), _one38[0]) +print(f" roster of {len(_c38one.inverters)}: stood " + f"{[round(v) for v in _one38]} -> sum/mean/max/first = " + f"{tuple(round(v, 1) for v in _onered38)}") +check("at a roster of ONE the four reductions are ONE NUMBER - so all three " + "mutations of `site_standing` are EQUIVALENT on a shipped install and no " + "N=1 fixture could ever tell them apart. That is why this card needed a " + "roster of two, stated as an assertion rather than left to the reader", + len(_c38one.inverters) == 1 + and len({round(v, 6) for v in _onered38}) == 1) +check("...and the one-inverter site still reaches SAFETY-18's verdict on that " + "same number, unchanged: the command is at the rail, the meter has " + "stopped moving, the guard is blind and forbids charging", + _c38one.peak_guard is not None + and _c38one.peak_guard.blind == _pg38.BLIND_FROZEN + and _c38one.peak_guard.floor_w == 0.0 + and abs(sum(_one38)) >= _rail_w38( + _c38one.site_max_w, + dev_min=_c38one.site_dev_bound("dev_min"), + dev_max=_c38one.site_dev_bound("dev_max"))) +check("...and the SITE sign detector is still absent at a roster of one - part " + "7 counts the calls; this only records that a fixture built the other " + "way round, from an `inverters:` roster, reaches the same state", + not _c38one.site_sign_wd.tripped + and _c38one.site_sign_wd.disagree_since is None) + print() if fails: print(f"{len(fails)} FAILED: {', '.join(fails)}") -- 2.54.0 From 3033420dd7dee08368fdaac184c8c2a0c1557a3a Mon Sep 17 00:00:00 2001 From: admin Date: Sat, 5 Sep 2026 14:52:30 +0200 Subject: [PATCH 2/6] SAFETY-38: record the CI-06 false red, and where each mutation lands All ten mutations of `site_standing` are red in test_watchdog.py and in nothing else - the other 19 files are green under every one of them, which is the measurement of how unpinned the line was. One exception, recorded rather than dropped: in the batch run M10 (`sorted(standing.values())[-1]`) also reported FAIL test_peakguard.py. Re-run in isolation three times and once more through tools/mutation_run.sh - four clean passes. The batch script had discarded the failure detail so the check cannot be named; what can be said is that it did not reproduce in four attempts and that no claim in this card rests on it. That is CI-06's file behaving the way CI-06 records. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU --- goodwe_controller/CHANGELOG.md | 7 +++++-- .../DECISIONS/SAFETY-38-site-standing-pin.md | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index 2037d46..e661657 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -139,8 +139,11 @@ shipped `sum` is correct and must not be "fixed".** 300 W gate. Closed by part 9a's device-clamped counterfactual. * **10 mutations of that one line, each on a fresh full-repo copy mounted `:ro`, bytecode purged by `tools/mutation_run.sh`, the edit re-read from disk and the - byte delta printed. All 10 red.** Ledger and the honest gaps in - `DECISIONS/SAFETY-38-site-standing-pin.md`. + byte delta printed. All 10 red — every one of them in `test_watchdog.py` and + in nothing else.** ⚠️ One **false red** from `test_peakguard.py` on M10 did + not reproduce in four subsequent runs; it is CI-06's known flakiness, no claim + here rests on it, and it is recorded rather than quietly dropped. Ledger and + the honest gaps in `DECISIONS/SAFETY-38-site-standing-pin.md`. **Inverter B gets its own MQTT device, and inverter A keeps the one it has (TEL-07).** MQTT discovery announced **one** device, and every entity hung off diff --git a/goodwe_controller/DECISIONS/SAFETY-38-site-standing-pin.md b/goodwe_controller/DECISIONS/SAFETY-38-site-standing-pin.md index 443277a..289c789 100644 --- a/goodwe_controller/DECISIONS/SAFETY-38-site-standing-pin.md +++ b/goodwe_controller/DECISIONS/SAFETY-38-site-standing-pin.md @@ -189,6 +189,21 @@ fingerprint, and verifies no cache appears during the run). The mutated line was | M9 | `sum(standing.values()) * 2.0` | +6 | **RED** | | M10 | `sorted(standing.values())[-1]` — largest signed | +7 | **RED** | +**Every one of the ten is red in `test_watchdog.py` and in nothing else** — the +other 19 files are green under all ten, which is the measurement of how +unpinned this line was: before part 9 existed, three of these left the whole +suite green and the remaining seven were caught by SAFETY-32's structural pin or +by SAFETY-30's own N≥2 checks, none of which grade the *quantity*. + +⚠️ **ONE FALSE RED, AND IT IS [[CI-06]]'s.** In the batch run M10 also reported +`FAIL test_peakguard.py`. Re-run in isolation **three times** and once more +through `tools/mutation_run.sh` — **four clean passes, zero failures**. The +batch script had discarded the failure detail, so the failing check cannot be +named here; what can be said is that the red did not reproduce in four attempts +and that **no claim in this card rests on it**. `test_peakguard.py` is the file +CI-06 records as flaky, and this is the behaviour it records. Believe no red +from it without three isolated re-runs. + 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. -- 2.54.0 From 564ba11c11b6e7c7556ef2140309c1c10f2de9cc Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Sat, 5 Sep 2026 17:51:10 +0200 Subject: [PATCH 3/6] SAFETY-38 round 2: assert the ordered pair, and correct the M3 attribution Two blockers, one of them mine to fix and one a mis-measurement to undo. No `app/` change in either: the shipped `sum` is correct. 1. A ZERO-BYTE SURVIVOR AT THE CALL SITE ONE LINE BELOW THE PINNED VALUE. Round 1 pinned `site_standing` and never pinned WHERE IT GOES. Swapping the detector's first two arguments at main.py:1620 - `evaluate(site_standing, self.grid, ...)` -> `evaluate(self.grid, site_standing, ...)` - is a ZERO-BYTE edit and left the round-1 tree fully green. The cost is the banner: the site stands at -500 W of charge into a +2500 W import and the LATCHING, SITE-STOPPING alarm reads "commanded +2500 W against a meter reading -500 W" - which instrument read what, inverted, on the one line somebody woken by it reads first (watchdog.py:658-665). Round 1's check could not reach it. It asserted `"-500 W"` appears SOMEWHERE in the sentence; under the swap it still does, IN THE METER SLOT. A substring test grades PRESENCE, never ARRANGEMENT. It now asserts the ORDERED PAIR: `f"commanded {sum:+.0f} W against a meter reading {grid:+.0f} W"`. Measured: red in test_watchdog.py on that one named check, green in the other twenty. 2. THE M3 ATTRIBUTION WAS FALSE, AND HOW IT BECAME FALSE IS THE USEFUL PART. Round 1 recorded `list(standing.values())[0]` as closed by SAFETY-32's package-wide pin and told future authors NOT TO RE-COVER IT. Re-measured on release/1.0 @ 86eaef2 carrying only that edit: the full 21-file suite is GREEN and test_peakguard.py is PASS PASS PASS PASS PASS in isolation. THE MUTATION SURVIVES. It was originally measured once, that run hit CI-06's ~20% flake, and the red was recorded as a kill. The mechanism, read off 12j's own enumeration rather than argued: 12j taints names BOUND to roster values and lists ACCUMULATED in a loop. `standing` is a dict filled by SUBSCRIPT assignment (main.py:1554/1558), so it never enters the taint set and 1599 is not a roster site. On the mutated tree 12j lists main.py:1553, 1555 and 1690 - not 1599. Part 9a and 9b close it. Corrected in all four places it was committed: DECISIONS section 1 and section 4, CHANGELOG, and the part 9 header that carried the instruction. MEASUREMENT, all on 86eaef2, fresh copy per mutation, mounted :ro, bytecode purged by tools/mutation_run.sh, edit re-read from disk, byte delta printed: * M1-M10 re-run INDEPENDENTLY rather than carried forward - all 10 red, at the deltas the table records (M1/M2/M4 re-run in the exact wrapped form). * M11, the argument swap: 0 bytes, red on one check in one file. * W1 `"sign_site": SCOPE_SITE -> SCOPE_UNIT`: 0 bytes, red; part 9a is one of the two killers. * W2 `"circulation": SCOPE_SITE -> SCOPE_UNIT`: 0 bytes, red - but ENTIRELY on pre-existing checks. This card adds nothing to circulation and does not claim it. Control: 21/21 green before and after. The suite is 21 files, not 20 - TEL-07 added test_mqtt.py in the rebase onto 86eaef2. DECISIONS section 6a enumerates what was NOT closed: SAFETY-31's circ_wd detector itself, the other eight CLAIM_SCOPE entries, actuator_of()'s body, the n_units > 1 guard, and the fact that the "green in the other twenty files" half of the ledger was re-verified on 86eaef2 only for M3 and M11. MUTATION.md gains habit 14 - assert the ordered pair, not the substring - and records this as the tenth instance of the family, found INSIDE THE FIX WRITTEN TO CLOSE THE NINTH, for the second time in that list. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU --- goodwe_controller/CHANGELOG.md | 54 +++-- .../DECISIONS/SAFETY-38-site-standing-pin.md | 199 ++++++++++++++++-- goodwe_controller/MUTATION.md | 46 +++- goodwe_controller/test_watchdog.py | 72 +++++-- 4 files changed, 317 insertions(+), 54 deletions(-) diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index e661657..06c0a9f 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -121,13 +121,35 @@ shipped `sum` is correct and must not be "fixed".** `[0]` are **the same number** — all three mutations are genuinely *equivalent* on a shipped install and no N=1 fixture could ever separate them. Printed and checked, beside the site detector still being absent there. Part 9c. -* **ONE OF THE THREE WAS ALREADY CLOSED, AND THIS CARD DOES NOT RE-COVER IT.** - [[SAFETY-32]]'s package-wide structural pin catches - `list(standing.values())[0]` on the line it is written, at every roster - length, with no fixture. Its own disclosed blind spot — *"SEMANTICS. `min` → - `max` → the mean are edits invisible to any shape pin, permanently"* — is why - the other two needed this card, and it is now confirmed by measurement rather - than taken on trust. +* **⚠️ A CORRECTION, AND IT IS THE MOST USEFUL LINE IN THIS CARD.** An earlier + revision of this entry said one of the three — `list(standing.values())[0]` — + was **already closed** by [[SAFETY-32]]'s package-wide structural pin, and + told future authors not to re-cover it. **That was false.** It had been + measured **once**, that run hit `test_peakguard.py`'s [[CI-06]] flake (a + measured **~20%** false-red rate), and the red was recorded as a mutation + **kill**. Re-measured on `release/1.0` @ `86eaef2` carrying only that edit: + the **full 21-file suite is green** and `test_peakguard.py` passes **five for + five** in isolation — **the mutation survives.** The reason SAFETY-32 cannot + see it is not the shape: part 12j taints names *bound* to roster values and + lists *accumulated* in a loop, and `standing` is a dict filled by **subscript + assignment**, so it never enters the taint set and `sum(standing.values())` is + not a roster site at all. 12j's own enumeration on the mutated tree lists + `main.py:1553`, `1555`, `1690` — **not `1599`**. **All three mutations needed + this card; all three are now covered by it.** *A single red from + `test_peakguard.py` proves nothing — re-run it five times in isolation and + name the failing check.* +* **⚠️ AND A ZERO-BYTE SURVIVOR ONE LINE BELOW THE ONE THIS CARD PINNED.** + Pinning the *value* `site_standing` said nothing about **where it goes**. + Swapping the detector's first two arguments — + `evaluate(site_standing, self.grid, …)` → `evaluate(self.grid, site_standing, …)` + — is **zero bytes** and left the tree fully green, because the check asserted + only that `"-500 W"` appeared **somewhere** in the banner. It still does: + **in the meter slot.** The swapped banner reads *"commanded +2500 W against a + meter reading −500 W"* — a site charging at −500 W into a +2500 W import, told + to the operator **exactly backwards**, on the one line somebody woken by a + latching site-stopping alarm reads first. The check now asserts the **ordered + pair** rather than a substring, and is red on it. **No `app/` change:** the + hole was in the test. * **⭐ A SURVIVOR THIS CARD WENT LOOKING FOR AND CLOSED.** `site_standing = float(self.target)` — the arbiter's site *target* instead of the sum of what actually **stands on the wire** — is **−11 bytes** and @@ -137,13 +159,19 @@ shipped `sum` is correct and must not be "fixed".** unit's share and never the site target. A site asking for −500 W while −250 W stands would then take a **latching site-wide false trip** under its own 300 W gate. Closed by part 9a's device-clamped counterfactual. -* **10 mutations of that one line, each on a fresh full-repo copy mounted `:ro`, +* **13 mutations — 11 of that line and its call site, plus 2 zero-byte edits to + the arbiter claim wiring — each on a fresh full-repo copy mounted `:ro`, bytecode purged by `tools/mutation_run.sh`, the edit re-read from disk and the - byte delta printed. All 10 red — every one of them in `test_watchdog.py` and - in nothing else.** ⚠️ One **false red** from `test_peakguard.py` on M10 did - not reproduce in four subsequent runs; it is CI-06's known flakiness, no claim - here rests on it, and it is recorded rather than quietly dropped. Ledger and - the honest gaps in `DECISIONS/SAFETY-38-site-standing-pin.md`. + byte delta printed. All 13 red.** Every one was **re-run independently** in + this round rather than carried forward from the previous round's report. The + claim-wiring pair (`sign_site` and `circulation` moved from site scope to unit + scope) closes two gaps a reviewer disclosed — though **`circulation`'s kill + belongs entirely to pre-existing checks and is not claimed by this card.** + ⚠️ One **false red** from `test_peakguard.py` on M10 did not reproduce in four + subsequent runs; it is CI-06's known flakiness, no claim here rests on it, and + it is recorded rather than quietly dropped — unlike the one that *was* + believed, two bullets up. Ledger, and an **enumerated list of what was not + mutated**, in `DECISIONS/SAFETY-38-site-standing-pin.md` §4a, §4b and §6a. **Inverter B gets its own MQTT device, and inverter A keeps the one it has (TEL-07).** MQTT discovery announced **one** device, and every entity hung off diff --git a/goodwe_controller/DECISIONS/SAFETY-38-site-standing-pin.md b/goodwe_controller/DECISIONS/SAFETY-38-site-standing-pin.md index 289c789..16d6672 100644 --- a/goodwe_controller/DECISIONS/SAFETY-38-site-standing-pin.md +++ b/goodwe_controller/DECISIONS/SAFETY-38-site-standing-pin.md @@ -33,19 +33,55 @@ 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: +suite. Re-measured in round 2 against `release/1.0` @ `86eaef2`: -| mutation of `site_standing` | before SAFETY-32 | after SAFETY-32 | +| mutation of `site_standing` | on release/1.0 @ 86eaef2 | closed by | |---|---|---| -| `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** | +| `list(standing.values())[0]` — one unit's share | **SURVIVES**, full suite 21/21 green | part 9a + 9b (this card) | +| `sum(...) / max(1, len(standing))` — the mean | **SURVIVES**, full suite 21/21 green | part 9a + 9b (this card) | +| `max(standing.values(), key=abs)` — the largest share | **SURVIVES**, full suite 21/21 green | part 9a + 9b (this card) | -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: +### ⚠️ ROUND 1 SAID `[0]` WAS ALREADY CLOSED BY SAFETY-32. THAT WAS FALSE. + +Round 1 of this card recorded `[0]` as **CAUGHT by SAFETY-32's package-wide +structural pin**, wrote that claim into four places, and told future authors +*"DO NOT RE-COVER THAT ONE HERE"*. It is wrong, and **how** it went wrong is the +part worth keeping. + +**How the error was made.** The mutation was measured **once**. That single run +happened to hit `test_peakguard.py`'s [[CI-06]] flake — a **measured ~20%** +false-red rate — and the red was recorded as a **mutation kill**. Nothing else +was checked, because a red is the answer you are hoping for and it did not look +like it needed a second run. + +**The correct measurement.** On `release/1.0` @ `86eaef2` carrying **only** the +`[0]` edit (+4 bytes, re-read from disk, bytecode purged by +`tools/mutation_run.sh`): the **full 21-file suite is green**, and +`test_peakguard.py` run **five times in isolation** on that same mutated tree is +**PASS PASS PASS PASS PASS**. The mutation **survives**. (The reviewer's own +independent re-measurement of the same tree hit the flake once — +PASS PASS PASS FAIL PASS — which is the same verdict plus a live demonstration +of the mechanism that produced the original error.) + +**Why SAFETY-32 cannot see it, read off 12j's own enumeration rather than +argued.** Part 12j taints names *bound* to roster-carrying values and lists +*accumulated* in a loop over one. `standing` is neither: it is a dict created +empty at `main.py:1554` and filled by **subscript assignment** +(`standing[inv.name] = stood`) inside the roster loop, and a subscript store is +not a binding 12j follows. So `standing` never enters its taint set and +`sum(standing.values())` is **not a roster site** at all. Run 12j against a tree +carrying the `[0]` mutation and its enumeration lists `main.py:1553`, `1555` and +`1690` — the three `self.inverters` sites bracketing this code — and **not +`1599`**. No shape pin reaches this line; only the behavioural fixture below +does. + +**The rule this cost.** `test_peakguard.py` flakes at a measured ~20%. A single +red from it proves nothing. Re-run it in isolation **at least five times** and +**name the failing check** before believing any red from it. This card is the +**third** measurement CI-06 has corrupted. + +SAFETY-32's own disclosed blind spot says why the other two were always out of +reach: > *SEMANTICS. `min` → `max` → the mean are edits invisible to any shape pin, > permanently. Only the behavioural sets can see them.* @@ -54,6 +90,13 @@ the other two are out of reach: different meaning. **No shape pin will ever close them.** That disclosure was accurate, and is now confirmed by measurement rather than taken on trust. +`[0]` is the one that *looks* structural and therefore *looks* covered — and +that appearance is exactly what produced the false attribution above. It is +out of reach here for a different reason from the other two: not because the +shape is invisible, but because **the value never becomes a roster** in the +analysis's eyes. All three are closed by the same behavioural fixture, and +**all three needed it.** + ## 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 @@ -174,13 +217,13 @@ 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. +`app/main.py` is 234 542 bytes at `86eaef2`. | # | 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** | +| M3 | `list(standing.values())[0]` — one share | +4 | **RED** — and **survives on release/1.0**, see §1 | | 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** | @@ -188,12 +231,15 @@ fingerprint, and verifies no cache appears during the run). The mutated line was | 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** | +| **M11** | **argument SWAP** at the call site: `evaluate(site_standing, self.grid, …)` → `evaluate(self.grid, site_standing, …)` (main.py:1620) | **0** | **was a SURVIVOR in round 1 — now RED**, see §4a | -**Every one of the ten is red in `test_watchdog.py` and in nothing else** — the -other 19 files are green under all ten, which is the measurement of how -unpinned this line was: before part 9 existed, three of these left the whole -suite green and the remaining seven were caught by SAFETY-32's structural pin or -by SAFETY-30's own N≥2 checks, none of which grade the *quantity*. +**Every one of the ten is red in `test_watchdog.py`.** Re-measured in round 2 +on `release/1.0` @ `86eaef2` (where `app/main.py` is 234 542 bytes, 2 022 more +than the `f72c48d` the round-1 figures were taken on): all ten still red, at the +byte deltas in the table, each on a fresh copy with the edit re-read from disk. +Before part 9 existed, **three** of these left the whole suite green — M1, M2 +**and M3** — and the remaining seven were caught by SAFETY-30's own N≥2 checks, +none of which grade the *quantity*. ⚠️ **ONE FALSE RED, AND IT IS [[CI-06]]'s.** In the batch run M10 also reported `FAIL test_peakguard.py`. Re-run in isolation **three times** and once more @@ -204,9 +250,76 @@ and that **no claim in this card rests on it**. `test_peakguard.py` is the file CI-06 records as flaky, and this is the behaviour it records. Believe no red from it without three isolated re-runs. -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. +⚠️ **M3 IS NOT "FOR COMPLETENESS", AND ROUND 1 SAID IT WAS.** Round 1 recorded +M3 as already closed structurally by SAFETY-32 and disclaimed it. §1 above has +the correction and the mechanism: `[0]` **survives** a full green 21-file suite +on `release/1.0` @ `86eaef2`, because `standing` is a dict filled by subscript +assignment and never enters 12j's taint set. **M3 is a real kill by this card's +fixture and by nothing else in the repository.** It is claimed here. + +### 4a. ⚠️ The zero-byte survivor one line below the one this card pinned + +Round 1 pinned the **value** `site_standing` and never pinned **where it goes**. +Swapping the detector's first two arguments at `main.py:1620` is a **zero-byte** +edit — the same characters, reordered — and it left the round-1 tree **21/21 +green**. + +What it costs is the banner. `SignWatchdog.evaluate()` formats +`commanded_w` first and `against_w` second, so under the swap part 9a's own +output reads: + +``` +SIGN_DISAGREEMENT: commanded +2500 W against a meter reading -500 W for 2 s … +``` + +The site is standing at **−500 W of charge into a +2500 W import** and the +**latching, site-stopping** banner tells the operator **the exact opposite** — +which instrument read what, inverted. `watchdog.py:658-665` names this failure +in its own words: *"a false statement about which instrument produced the +evidence, on the one line somebody woken by this alarm reads first."* + +**Why round 1's check could not see it, and this is the general lesson.** It +asserted + +```python +f"{sum(_share38):.0f} W" in _c38.site_sign_wd.reason +``` + +— that `"-500 W"` appears **somewhere** in the sentence. Under the swap it still +does: in the **meter slot**. A substring test cannot reach a failure that is +purely **positional**, and round 1's own comment claimed the opposite ("*would +put a different number in the store, the action log and the banner, and this is +where that shows*"). It shows the **number**, never its **position**. This is +MUTATION.md's *"a check whose data cannot reach the failure it names"*, in the +card that exists to complain about exactly that. + +**The fix is one line and touches no `app/` code** — assert the **ordered +pair**: + +```python +f"commanded {sum(_share38):+.0f} W against a meter reading " +f"{_mid38['grid']:+.0f} W" in _c38.site_sign_wd.reason +``` + +Measured: with that line, M11 is **red in `test_watchdog.py` and green in the +other twenty files** — one file, one check. + +### 4b. Claim wiring, re-measured rather than assumed + +Round 1's reviewer disclosed it had **not** mutated the arbiter claim wiring or +[[SAFETY-31]]/circulation. Both were mutated in round 2. Both are **zero-byte** +edits to `arbiter.CLAIM_SCOPE`: + +| # | mutation | Δ bytes | verdict | +|---|---|---|---| +| W1 | `"sign_site": SCOPE_SITE` → `SCOPE_UNIT` | **0** | **RED** — `test_watchdog.py` (parts 5 **and 9a**), `test_arbiter.py`, `test_peakguard.py` | +| W2 | `"circulation": SCOPE_SITE` → `SCOPE_UNIT` | **0** | **RED** — `test_watchdog.py`, `test_arbiter.py`, `test_peakguard.py` | + +Part 9a's *"…and it stops the WHOLE site, through the `sign_site` claim by name"* +is one of the two checks that kills W1, so this card contributes to that +coverage rather than merely inheriting it. **W2 is caught entirely by +pre-existing checks** — SAFETY-38 adds nothing to circulation, and does not +claim to. ## 5. The survivor this card went looking for, and closed @@ -250,3 +363,49 @@ operator typed. * **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. + +### 6a. Round 2: what was closed, and what was NOT + +Round 1's reviewer disclosed that it had not mutated SAFETY-31/circulation, the +arbiter claim wiring, or ledger entries M4/M5/M6/M8/M9/M10. Enumerated, with the +gaps left open stated rather than rounded off: + +**Closed in round 2 (measured, on `86eaef2`, bytecode purged, edit re-read from +disk, byte delta printed):** + +1. **M11**, the zero-byte argument swap — §4a. Red on one named check. +2. **M3**, the `[0]` reduction — re-measured, **survives on release/1.0**, closed + by part 9a and 9b. The false attribution corrected in all four places it was + committed: this file §1 and §4, `CHANGELOG.md`, and `test_watchdog.py`'s part + 9 header (which previously *instructed future authors not to re-cover it*). +3. **M1, M2, M4, M5, M6, M7, M8, M9, M10** — every ledger entry re-run + independently in round 2 rather than carried forward from round 1's report. + All ten red; M1/M2/M4 re-run in the exact wrapped form the table records so + the byte deltas are the table's own (+24/+9/+9). +4. **W1**, `sign_site` claim scope — §4b. Zero bytes, red, part 9a among the + killers. +5. **W2**, `circulation` claim scope — §4b. Zero bytes, red, but **entirely on + pre-existing checks**. + +**NOT closed, and not claimed:** + +* **SAFETY-31's `circ_wd` detector itself** — its threshold, its hold, its + `circulating_w()` reduction over the roster. Only its **claim scope** was + mutated (W2), and that kill belongs to checks this card did not write. + Circulation's own behavioural coverage is `test_watchdog.py` part 4's and is + untouched here. +* **The other eight `CLAIM_SCOPE` entries** — `loop`, `peak`, `arbitrage`, + `safety` (site) and `device`, `supervised`, `failsafe`, `maintenance` (unit). + `CLAIM_SCOPE` has ten entries; only the two named above were mutated. `test_peakguard.py` part 10f grades the + classification statically; that is a different instrument and this card did + not re-measure it. +* **`actuator_of()`'s own body** and the `n_units > 1` guard on the `sign_site` + append at `main.py:1764` were **not** mutated. +* **The ten ledger mutations were re-graded against `test_watchdog.py` only.** + The "green in the other twenty files" half of the round-1 claim was + re-verified on `86eaef2` for **M3** and **M11** (both full 21-file runs) and is + otherwise carried forward from round 1's measurement on `f72c48d`. It is a + weaker statement than round 1 made and is written down as such. +* **`test_peakguard.py`'s CI-06 flake is not fixed by this card** and is not + this card's subject. It is *characterised* here (five isolated runs on the M3 + tree, all PASS) only far enough to overturn the false kill it produced. diff --git a/goodwe_controller/MUTATION.md b/goodwe_controller/MUTATION.md index 36e863d..c276686 100644 --- a/goodwe_controller/MUTATION.md +++ b/goodwe_controller/MUTATION.md @@ -135,13 +135,15 @@ manufactures them from a **fixture**: the check runs, the assertion is evaluated, the sentence above it is true — and the numbers it compares are arranged so that the failure it describes could not have changed the answer. -It is not hypothetical and it is not rare. NINE instances in two weeks; four +It is not hypothetical and it is not rare. TEN instances in two weeks; four were found by review, two - CFG-04's fixture pair - by an author's own mutation battery, which is the cheaper place, one by review again, in the one place nobody had thought to point the rule at: not a check in the suite, but a COMMITTED EVIDENCE ARTIFACT four documents cite - the eighth INSIDE THE FIX -WRITTEN TO CLOSE THE SEVENTH, and the ninth inside the SITE HALF of the card -that names the rule in its own text. +WRITTEN TO CLOSE THE SEVENTH, the ninth inside the SITE HALF of the card +that names the rule in its own text - and the TENTH INSIDE THE FIX WRITTEN TO +CLOSE THE NINTH, for the second time in this list. See SAFETY-38 below: the +check that closed it asserted a NUMBER and not the number's POSITION. ⚠️ **AND IT HAS NOW APPEARED IN SIX CONSECUTIVE CARDS.** The seventh instance, MAINT-05's, is the one that says why: the defect is not carelessness about @@ -242,8 +244,8 @@ same place twice, the check is decoration. fixture, and it runs shares of about **−2500 W each** — every one individually over the 320 W gate it is configured with. On numbers like those `sum`, the mean and `max(key=abs)` **all agree**, so the reduction could be - swapped for either of the other two and the site guard went **blind with all - 19 files green** — still commanding −500 W of charge into a +2500 W import at + swapped for either of the other two and the site guard went **blind with the + whole suite green** — still commanding −500 W of charge into a +2500 W import at t = 595 s with `following=True`. At a roster of **one** it is worse and it is not a fixture defect at all: `sum == mean == max == the one share` *identically*, so every such mutation is genuinely **equivalent** there and no @@ -262,6 +264,20 @@ same place twice, the check is decoration. and never bounds the site target. Closed in the same commit, by a counterfactual that asks for −500 W while −250 W stands. + ⚠️ **AND THEN THE FIX ITSELF WAS THE TENTH INSTANCE — A SUBSTRING WHERE THE + FAILURE WAS POSITIONAL.** The check written to pin the banner asserted + `f"{sum(...):.0f} W" in reason`: that **"−500 W" appears somewhere** in the + sentence. Swapping the detector's first two arguments + (`evaluate(site_standing, self.grid, …)` → `evaluate(self.grid, site_standing, …)`) + is a **ZERO-BYTE** edit that leaves both numbers in the string and moves each + into the other's slot — so the substring is still found, **in the meter + slot**, and the whole suite stayed green while the latching site-stopping + banner told the operator which instrument read what **exactly backwards**. + The fix is to assert the **ORDERED PAIR**, + `f"commanded {sum:+.0f} W against a meter reading {grid:+.0f} W"`. + + The general habit is **14** below. + Note what these have in common with a masked rung: the mutation *runs*, and the suite is *honestly* green. Nothing in a purge, a fingerprint or a disassembly can see it, because the code under test really did behave identically on the @@ -391,6 +407,26 @@ Cheap habits fall out of it, and all of them are now used in this repository: completeness a reviewer falsifies in ten minutes**, and every round of this card that claimed the latter was rejected for it. +14. **An `in` test against a formatted sentence grades PRESENCE, never + ARRANGEMENT — so assert the ORDERED PAIR.** SAFETY-38 pinned the banner of a + latching, site-stopping alarm with + `f"{sum(_share38):.0f} W" in ...reason` — that `"-500 W"` appears + *somewhere*. Swapping the detector's first two arguments at the call site + (`evaluate(site_standing, self.grid, …)` → + `evaluate(self.grid, site_standing, …)`) is a **ZERO-BYTE** edit: both + numbers are still in the string, each in the other's slot. The substring was + still found — **in the meter slot** — the whole suite stayed green, and the + banner told the operator which instrument read what **exactly backwards**. + Whenever the property you mean is "**this** value went in **this** slot" — a + reason string, a log line, a notification, an MQTT payload — assert the + surrounding **literal text with the value embedded in its own position** + (`f"commanded {x:+.0f} W against a meter reading {y:+.0f} W"`), or assert + the fields **before** they are formatted. Two values that both appear in one + sentence can always be transposed by an edit that costs **nothing**, and + that no byte delta, no fingerprint and no shape pin can see. ⚠️ Note where + this one was found: **inside the fix written to close the previous instance + of the same family**, which is the second time in this list. + And the mutation-table consequence: **a variant is not covered because a near-miss of it is.** DEV-02's table listed the *actuator* variant of the per-unit claim defect (`device`/`supervised` filed without `inv`), which the diff --git a/goodwe_controller/test_watchdog.py b/goodwe_controller/test_watchdog.py index 930564a..10715e4 100644 --- a/goodwe_controller/test_watchdog.py +++ b/goodwe_controller/test_watchdog.py @@ -2000,15 +2000,40 @@ print(" asked about - pinned by shares that CANNOT answer for it") # ⚠️ WHAT THIS SECTION PINS, AND WHY NOTHING ABOVE IT COULD. # `main.py`'s `site_standing = float(sum(standing.values()))` is the first # argument of SAFETY-30's site sign detector AND the `commanded_w` handed to -# SAFETY-18's `looks_frozen()`. It was pinned by NOTHING. Three mutations of it -# were demonstrated live against a full green suite; SAFETY-32's package-wide -# structural pin has since closed `list(standing.values())[0]` - a slice -# standing between a roster-derived value and its reduction, which it sees on -# the line it is written, at every roster length, with no fixture. DO NOT -# RE-COVER THAT ONE HERE. The two that remain are SEMANTIC: +# SAFETY-18's `looks_frozen()`. It was pinned by NOTHING. THREE mutations of it +# were demonstrated live against a full green suite, and ALL THREE ARE COVERED +# HERE: # # sum(...) / max(1, len(standing)) the MEAN # max(standing.values(), key=abs) the LARGEST SHARE +# list(standing.values())[0] the FIRST SHARE +# +# ⚠️ ROUND 1 OF THIS CARD SAID THE THIRD ONE WAS ALREADY CLOSED BY SAFETY-32's +# PACKAGE-WIDE STRUCTURAL PIN AND TOLD FUTURE AUTHORS "DO NOT RE-COVER THAT ONE +# HERE". THAT WAS FALSE, AND HOW IT BECAME FALSE IS THE USEFUL PART. It was +# measured ONCE; that single run happened to hit test_peakguard.py's ~20% +# CI-06 flake, the red was recorded as a mutation KILL, and the claim was then +# written into four places. RE-MEASURED IN ROUND 2, on release/1.0 @ 86eaef2 +# carrying ONLY the `[0]` edit (+4 bytes, re-read from disk): the full suite is +# 21/21 GREEN, and test_peakguard.py run FIVE times in isolation on that same +# mutated tree is PASS PASS PASS PASS PASS. The mutation SURVIVES. (The +# reviewer's own independent re-measurement of the same tree caught the flake +# once - PASS PASS PASS FAIL PASS - which is the same verdict: a survivor, plus +# a demonstration of the flake that produced the original error.) A SINGLE RED +# FROM test_peakguard.py PROVES NOTHING - re-run it at least five times and NAME +# the failing check before believing it. That rule is CI-06's, and this card is +# the third measurement it has corrupted. +# +# THE MECHANISM, confirmed by reading 12j's own enumeration rather than by +# argument. SAFETY-32's part 12j taints names BOUND to roster-carrying values +# and lists ACCUMULATED in a loop over one. `standing` is neither: it is a dict +# created empty at main.py:1554 and filled by SUBSCRIPT assignment +# (`standing[inv.name] = stood`) inside the roster loop, and a subscript store +# is not a binding 12j follows. So `standing` never enters its taint set at all, +# and `sum(standing.values())` is not a roster site. Run 12j on a tree carrying +# the `[0]` mutation and it enumerates main.py:1553, 1555 and 1690 - the three +# `self.inverters` sites around this code - and NOT 1599. A structural pin +# cannot see this line. Only the behavioural fixture below can. # # Identical shape, different meaning - which is precisely the blind spot # SAFETY-32 disclosed in its own words ("SEMANTICS. `min` -> `max` -> the mean @@ -2217,19 +2242,34 @@ check("PRECONDITION: EVERY inverter is obeying - each unit's measured power " and sum(_mid38["stood"]) * _mid38["grid"] < 0 and _mid38["following"] is True) check("⚠️ THE SITE WATCHDOG TRIPS ON THE SUM, and no per-unit watchdog does. " - "Under `sum -> mean` and `sum -> 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. This is the check both of " - "those mutations go red on", + "Under ALL THREE semantic mutations - `sum -> mean`, " + "`sum -> max(key=abs)` AND `sum -> list(...)[0]` - 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. This is the check all " + "three go red on. `[0]` is here BECAUSE IT IS NOT COVERED ELSEWHERE - " + "see the section header for the mis-measurement that said it was", _c38.site_sign_wd.tripped and not any(i.sign_wd.tripped for i in _c38.inverters)) print(f" {_c38.site_sign_wd.reason!r}") -check("...and the sentence an operator reads quotes THE SUM, digit for digit - " - "-500 W, not -250, not -280, not -220. A mutation that still tripped but " - "on some other reduction would put a different number in the store, the " - "action log and the banner, and this is where that shows", - f"{sum(_share38):.0f} W" in _c38.site_sign_wd.reason - and "against a meter reading" in _c38.site_sign_wd.reason) +check("⚠️ ...and the sentence an operator reads carries THE SUM IN THE " + "COMMANDED SLOT AND THE METER IN THE METER SLOT - the ORDERED PAIR, not " + "two numbers loose in a sentence. ROUND 1 ASSERTED `f'{sum(...):.0f} W' " + "in reason`, i.e. that '-500 W' appears SOMEWHERE, AND THAT WAS A HOLE " + "ONE LINE WIDE. Swapping the detector's first two arguments - " + "`evaluate(site_standing, self.grid, ...)` -> " + "`evaluate(self.grid, site_standing, ...)`, a ZERO-BYTE edit at " + "main.py:1620 - leaves BOTH numbers in the string and moves each into " + "the other's slot, so the substring test still passed and the whole " + "suite stayed green. The swapped banner reads 'commanded +2500 W " + "against a meter reading -500 W': the site is standing at -500 W of " + "charge INTO a +2500 W import and the latching, site-stopping banner " + "tells the operator the exact opposite, on the one line somebody woken " + "by this alarm reads first. watchdog.py:658-665 calls naming the wrong " + "instrument there 'a false statement about which instrument produced " + "the evidence'. A check whose data cannot reach the failure it names is " + "MUTATION.md's own defect; this is the line that closes it", + f"commanded {sum(_share38):+.0f} W against a meter reading " + f"{_mid38['grid']:+.0f} W" in _c38.site_sign_wd.reason) check("...and it stops the WHOLE site, through the `sign_site` claim by name " "rather than by some other guard happening to stop it", _c38.target == 0.0 and "sign_site" in _c38.reason -- 2.54.0 From 357cb4f07f27cdb6817266de5188a01765f7b220 Mon Sep 17 00:00:00 2001 From: gschrooyen Date: Sat, 5 Sep 2026 20:33:20 +0200 Subject: [PATCH 4/6] SAFETY-38 round 3: pin the banner the SHIPPED install actually prints Round 2 pinned the operand order of `SignWatchdog.evaluate()` at main.py:1620 - the SITE detector. Part 9c asserts that detector is never armed at a roster of one, so on every install this add-on has, the PER-UNIT banner is the only sign-disagreement sentence that can ever be printed, and its call site is a different line. main.py:1560 was left unpinned. 1. THE SAME ZERO-BYTE DEFECT ONE CALL SITE OVER. `evaluate(stood, against, ...)` -> `evaluate(against, stood, ...)`: app/main.py 234 542 bytes before and after, delta 0, fingerprint 61aa413f... -> e57e6026..., bytecode purged, tree mounted :ro, line re-read from disk. On the round-2 tree the FULL 21-FILE SUITE PASSES. Nothing behavioural could ever have caught it. `watchdog.disagrees()` is SYMMETRIC in its first two arguments - `commanded_w * against_w < 0` with an abs() gate on both - so the mutant takes identical actions on every input. Not "no fixture happens to separate them": no fixture CAN. What moves is the sentence. Measured by diffing the suite's own printed banners between the round-3 tree and the same tree carrying only this edit: 9 emitted banner lines invert, in 3 distinct sentences. shipped commanded -2000 W against a meter reading +2000 W swapped commanded +2000 W against a meter reading -2000 W shipped commanded +1000 W against its own measured power -1000 W swapped commanded -1000 W against its own measured power +1000 W shipped commanded -2000 W against a meter reading +4000 W (the N=1 arm) swapped commanded +4000 W against a meter reading -2000 W Persisted to `sign_reason` and re-read after the restart the latch exists to survive; also the action log, `_LOG.error` and `status_payload()`'s headline. CLOSED by the ORDERED PAIR on both per-unit banners, both operands read off the OBSERVED CYCLE - `inv.last_write` and `inv.batt` off the objects the plant moved, never off the arguments the detector was handed, which would compare the swap against itself: - part 3, the N>=2 trip: "commanded {stood:+.0f} W against its own measured power {meas:+.0f} W", plus the persisted namespaced copy; - part 7, the N=1 controller-driven trip: "commanded {stood:+.0f} W against a meter reading {grid:+.0f} W", plus the namespaced key AND the flat rollback mirror; - a PRECONDITION on each that the two operands are different numbers of opposite sign - without it a transposition produces the same string and the check is decoration. Measured after the fix: RED on four named checks in test_watchdog.py, GREEN in the other twenty files. M11 (the site swap) re-measured on this tree: still red. Round 1's and round 2's kills are intact. 2. A FALSE STATEMENT ROUND 2 INTRODUCED, AND IT WAS WORSE THAN REPORTED. Section 4 said "three of these left the whole suite green - M1, M2 and M3 - and the remaining seven were caught by SAFETY-30's own N>=2 checks". Round 1's vaguer wording was harmless; naming three made it specific and wrong. Re-measured mutation by mutation on clean release/1.0 @ bbc903b, each on a fresh copy, bytecode purged, byte delta and fingerprint printed, FULL 21-FILE SUITE each time: M1 +24 B fp 362374cf... 21/21 PASS - SURVIVOR M2 +9 B fp 86573e74... 21/21 PASS - SURVIVOR M3 +4 B fp 59fa8815... 21/21 PASS - SURVIVOR M4 +9 B fp 43d852be... 21/21 PASS - SURVIVOR M5 +16 B fp 58d26de8... RED test_watchdog.py, 3 checks M6 +1 B fp 407ddb5f... RED test_watchdog.py, 3 checks M7 -11 B fp 2a635a4e... 21/21 PASS - SURVIVOR M8 +6 B fp b21eed89... 21/21 PASS - SURVIVOR M9 +6 B fp 0973d3ca... 21/21 PASS - SURVIVOR M10 +7 B fp 0e7b7a4f... 21/21 PASS - SURVIVOR EIGHT survived, not three - and not the four a reviewer proposed either. The corrected sentence is in DECISIONS section 4. AND THE TWO THAT WERE CAUGHT MAKE THE CARD'S CASE. M5 (`sum(abs ...)`) and M6 (`-sum`) are the only two entries that reverse the SIGN. Every other one changes only the MAGNITUDE, and `_sign_site` - the suite's only N>=2 site fixture before this card - runs shares of about -2500 W each against a 320 W gate, so sum / mean / max / min / sorted[-1] / [0] / self.target / half / double land on -2500, -2500, -2500, -2500, -2500, -2500, -2500, -1250 and -5000 W: nine numbers, ALL ON THE SAME SIDE OF THE SAME THRESHOLD. The pre-existing coverage could see a sign and nothing else. 3. THEN THE THING THAT WOULD HAVE CAUGHT BOTH: the SYMMETRIC-PREDICATE FAMILY, enumerated by machine rather than by hand for a third time. `tools/symmetric_message_scan.py` renames each parameter pair, erases every formatted string, canonicalises the commutative operators and iterates to a FIXPOINT - the symmetry is usually one call deep, and a single pass reports an EMPTY family on a tree whose answer is already known. A second census covers sentences the CALLER builds. Both print their own blind spots, including the one way the scan OVER-reports. It found one open survivor this card does not close and now discloses: rail_unreachable()'s dev_min/dev_max at main.py:3208 - 0 bytes, 21/21 green, a status-page NOT COVERED row - and twelve unpinned caller-built sentences, including SAFETY-31's `circ_reason`, which is the same shape and is also persisted and re-read after a restart. DECISIONS section 7 has the list, the pinned status of every member, and what the instrument cannot see. No app/ code changes. MUTATION.md gains habit 15. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU --- goodwe_controller/CHANGELOG.md | 50 ++ .../DECISIONS/SAFETY-38-site-standing-pin.md | 322 +++++++++- goodwe_controller/MUTATION.md | 70 ++- goodwe_controller/test_watchdog.py | 96 ++- .../tools/symmetric_message_scan.py | 565 ++++++++++++++++++ 5 files changed, 1095 insertions(+), 8 deletions(-) create mode 100644 goodwe_controller/tools/symmetric_message_scan.py diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index 06c0a9f..86b6014 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -159,6 +159,56 @@ shipped `sum` is correct and must not be "fixed".** unit's share and never the site target. A site asking for −500 W while −250 W stands would then take a **latching site-wide false trip** under its own 300 W gate. Closed by part 9a's device-clamped counterfactual. +* **⚠️ AND THE SAME ZERO-BYTE DEFECT ONE CALL SITE OVER — THE ONE A SHIPPED + INSTALL CAN ACTUALLY SEE.** The ordered pair went onto `main.py:1620`, the + **site** detector. That detector is armed only at N≥2, and part 9c asserts it + is **absent at a roster of one** — so on every install this add-on has, the + **per-unit** banner is the only sign-disagreement sentence that can ever be + printed, and its call at **`main.py:1560`** was still unpinned. The identical + swap there is **0 bytes** and left the tree **21/21 green**, and nothing + behavioural could ever have caught it: `disagrees()` is **symmetric** in those + two arguments (`commanded_w * against_w < 0`, `abs()` gate on both), so the + mutant takes **identical actions on every input**. What moves is the sentence + — measured, **9 emitted banner lines invert in 3 distinct sentences**, + including the one persisted to `sign_reason` and read back after a restart: + *"commanded +1000 W against its own measured power −1000 W"* becomes + *"commanded −1000 W against its own measured power +1000 W"*. Closed by the + ordered pair on **both** per-unit banners — the N≥2 `its own measured power` + one and the **N=1** `a meter reading` one — with both operands read off the + **observed cycle** rather than off the arguments the detector was handed, plus + a precondition on each that the two operands really are different and opposite + in sign. **Red on four named checks, green in the other twenty files. No + `app/` change.** +* **⭐ AND THE FAMILY IS NOW ENUMERATED BY MACHINE, because it had been + enumerated by hand twice and was short both times.** + `tools/symmetric_message_scan.py` finds every call whose argument order is + load-bearing for a **message** rather than for a decision: it renames each + parameter pair, erases every formatted string, canonicalises the commutative + operators and iterates to a **fixpoint** (the symmetry is usually one call + deep — `evaluate()` is symmetric only *through* `disagrees()`), then + classifies each sentence by where it goes. A second census covers sentences + the **caller** builds. It found **one open survivor this card does not close + and now discloses** — `rail_unreachable()`'s `dev_min`/`dev_max` at + `main.py:3208`, **0 bytes, 21/21 green**, a status-page row — and **twelve** + unpinned caller-built sentences including SAFETY-31's `circ_reason`, which is + the same shape and is also persisted. The list, the pinned status of every + member and the instrument's own blind spots are in + `DECISIONS/SAFETY-38-site-standing-pin.md` §7. +* **⚠️ AND A LEDGER SENTENCE THIS CARD ITSELF GOT WRONG, CORRECTED WITH THE + MEASUREMENT.** An earlier revision said *"before part 9 existed, **three** of + these left the whole suite green — M1, M2 and M3 — and the remaining seven + were caught by SAFETY-30's own N≥2 checks"*. **Eight** did. Re-run one at a + time on clean `release/1.0` @ `bbc903b`, fresh copy each, bytecode purged, + byte delta and `main.py` fingerprint printed, **full 21-file suite** every + time: M1, M2, M3, M4, M7, M8, M9 and M10 are all **21/21 PASS**; only M5 and + M6 go red. It is the same species of error this round exists to fix — **a + survivor recorded as a kill** — and the file contradicted itself three times + while saying it. **And the two that ARE caught make the card's case:** M5 + (`sum(abs …)`) and M6 (`-sum`) are the only two that reverse the **sign**. + Every other entry changes only the **magnitude**, and the old `_sign_site` + fixture's ~−2500 W shares put nine different magnitudes on the **same side of + the same 320 W gate**. The pre-existing coverage could see a sign and nothing + else. * **13 mutations — 11 of that line and its call site, plus 2 zero-byte edits to the arbiter claim wiring — each on a fresh full-repo copy mounted `:ro`, bytecode purged by `tools/mutation_run.sh`, the edit re-read from disk and the diff --git a/goodwe_controller/DECISIONS/SAFETY-38-site-standing-pin.md b/goodwe_controller/DECISIONS/SAFETY-38-site-standing-pin.md index 16d6672..c8f1dbc 100644 --- a/goodwe_controller/DECISIONS/SAFETY-38-site-standing-pin.md +++ b/goodwe_controller/DECISIONS/SAFETY-38-site-standing-pin.md @@ -231,15 +231,73 @@ fingerprint, and verifies no cache appears during the run). The mutated line was | 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** | -| **M11** | **argument SWAP** at the call site: `evaluate(site_standing, self.grid, …)` → `evaluate(self.grid, site_standing, …)` (main.py:1620) | **0** | **was a SURVIVOR in round 1 — now RED**, see §4a | +| **M11** | **argument SWAP** at the SITE call site: `evaluate(site_standing, self.grid, …)` → `evaluate(self.grid, site_standing, …)` (main.py:1620) | **0** | **was a SURVIVOR in round 1 — now RED**, see §4a | +| **M12** | **argument SWAP** at the PER-UNIT call site: `evaluate(stood, against, …)` → `evaluate(against, stood, …)` (**main.py:1560**) | **0** | **was a SURVIVOR in round 2 — now RED**, see §4c | **Every one of the ten is red in `test_watchdog.py`.** Re-measured in round 2 on `release/1.0` @ `86eaef2` (where `app/main.py` is 234 542 bytes, 2 022 more than the `f72c48d` the round-1 figures were taken on): all ten still red, at the byte deltas in the table, each on a fresh copy with the edit re-read from disk. -Before part 9 existed, **three** of these left the whole suite green — M1, M2 -**and M3** — and the remaining seven were caught by SAFETY-30's own N≥2 checks, -none of which grade the *quantity*. +Adding assertions cannot turn a red green, so those verdicts carry forward to +the round-3 tree unchanged. + +### ⚠️ ROUND 2 SAID THREE OF THESE SURVIVED. **EIGHT DID.** + +Round 2 wrote: *"Before part 9 existed, three of these left the whole suite +green — M1, M2 and M3 — and the remaining seven were caught by SAFETY-30's own +N≥2 checks."* Round 1's wording was vaguer and harmless; naming three made it +**specific and wrong**, and it is the same species of error the round was +written to fix — **a survivor recorded as a kill**. This file contradicted it +three times on its own pages: the ledger row calls M7 *"a SURVIVOR — now RED"*, +§5 says M7 survived the whole suite, and `CHANGELOG.md` says so too. + +**Re-measured in round 3, one mutation at a time, on clean `release/1.0` @ +`bbc903b`** — a fresh copy each, bytecode purged (`0` remaining, printed), the +line re-read from disk, byte delta **and** `app/main.py` fingerprint printed, +mounted `:ro`, and the **FULL 21-FILE SUITE** run each time rather than +`test_watchdog.py` alone. (`release/1.0` has since moved on to `9e42cec`, which +merged [[CI-06]]; that commit touches `test_peakguard.py` and `CHANGELOG.md` +only, neither of which grades `site_standing`, so the ten verdicts below stand +on the tree they were taken on and this branch is rebased onto the newer tip.) + +| # | Δ bytes | fingerprint of `app/main.py` | suite before part 9 | +|---|---|---|---| +| M1 | +24 | `362374cfb6486378` | **21/21 PASS — SURVIVOR** | +| M2 | +9 | `86573e7494f1c933` | **21/21 PASS — SURVIVOR** | +| M3 | +4 | `59fa8815e6fda4cf` | **21/21 PASS — SURVIVOR** | +| M4 | +9 | `43d852be3037f861` | **21/21 PASS — SURVIVOR** | +| M5 | +16 | `58d26de82af29a1a` | **RED** — `test_watchdog.py`, 3 checks | +| M6 | +1 | `407ddb5f375cc217` | **RED** — `test_watchdog.py`, 3 checks | +| M7 | **−11** | `2a635a4eca0a790d` | **21/21 PASS — SURVIVOR** | +| M8 | +6 | `b21eed89ff4c9240` | **21/21 PASS — SURVIVOR** | +| M9 | +6 | `0973d3caf680db65` | **21/21 PASS — SURVIVOR** | +| M10 | +7 | `0e7b7a4fed7787d0` | **21/21 PASS — SURVIVOR** | + +**So: EIGHT of the ten left the whole suite green, and TWO were caught** — not +three and seven, and not the four and six a reviewer proposed either. The +correct sentence is: *before part 9 existed, eight of these ten left the whole +21-file suite green, and the only two SAFETY-30's own N≥2 checks caught were M5 +and M6.* + +**⚠️ AND THE TWO THAT WERE CAUGHT ARE CAUGHT FOR A REASON THAT MAKES THE CARD'S +CASE RATHER THAN WEAKENING IT.** M5 (`sum(abs(v) …)`) and M6 (`-sum(…)`) are +the only two entries in the table that **reverse the SIGN** of `site_standing`. +Every other entry changes only its **magnitude** — and `_sign_site`, the suite's +only N≥2 site fixture before this card, runs shares of about **−2500 W each** +against a **320 W** gate. `sum`, the mean, `max(key=abs)`, `min(key=abs)`, +`sorted(...)[-1]`, `[0]`, `self.target`, half the sum and twice the sum all land +on **−2500, −2500, −2500, −2500, −2500, −2500, −2500, −1250 and −5000 W**: nine +different numbers, all on the **same side of the same threshold**. The three +checks that fired on M5 and M6 grade *"the site trips and no unit does"*, the +claim it trips through, and where the latch is written — none of them grades the +**quantity**, and the sign flip reaches them only because it stops the trip +happening at all. + +That is the whole thesis of this card measured rather than argued: **the +pre-existing coverage could see a sign, and nothing else.** Part 9's fixture — +per-unit shares each **below** the gate whose **sum** clears it — is the only +arrangement in which the magnitude is load-bearing, and it is what takes the +other eight red. ⚠️ **ONE FALSE RED, AND IT IS [[CI-06]]'s.** In the batch run M10 also reported `FAIL test_peakguard.py`. Re-run in isolation **three times** and once more @@ -250,6 +308,17 @@ and that **no claim in this card rests on it**. `test_peakguard.py` is the file CI-06 records as flaky, and this is the behaviour it records. Believe no red from it without three isolated re-runs. +⚠️ **ROUND 3 CAN NAME ONE, AND IT IS THE SAME FILE.** Round 3's own batch — the +ten full-suite runs above, plus §7's sibling swaps — produced **no** red from +`test_peakguard.py` on M10 or on any other ledger entry. It produced exactly one +elsewhere, on a **`static_tariff.py`-only** edit (§7b, A4), and this time the +failing check *was* captured: *"one cycle of the intent form is exactly the +delivered form plus the command standing on the wire"* — which is CI-06's +**mechanism 1 by name**, the cross-time projection. `test_peakguard.py` then +passed **four for four** in isolation on that same tree. That is the first time +this card has been able to put a name to one of these reds, and it is the name +CI-06 predicts. + ⚠️ **M3 IS NOT "FOR COMPLETENESS", AND ROUND 1 SAID IT WAS.** Round 1 recorded M3 as already closed structurally by SAFETY-32 and disclaimed it. §1 above has the correction and the mechanism: `[0]` **survives** a full green 21-file suite @@ -321,6 +390,73 @@ coverage rather than merely inheriting it. **W2 is caught entirely by pre-existing checks** — SAFETY-38 adds nothing to circulation, and does not claim to. +### 4c. ⚠️ The SAME zero-byte defect one call site over — and it is the one the shipped install can see + +Round 2 closed §4a by asserting the ordered pair on the **site** banner at +`main.py:1620`. It did not touch `main.py:1560`, the **per-unit** call, and the +identical swap there — + +```python +if inv.sign_wd.evaluate(stood, against, self.following, now) # shipped +if inv.sign_wd.evaluate(against, stood, self.following, now) # M12 +``` + +— left the round-2 tree **21/21 green**. Measured: `app/main.py` 234 542 bytes +before and after, **Δ 0 bytes**, fingerprint `61aa413f…` → `e57e6026…`, bytecode +purged, tree mounted `:ro`, the line re-read from disk. + +**Nothing behavioural can catch it, and that is structural rather than +unlucky.** `watchdog.disagrees()` is *symmetric* in its first two arguments — +`commanded_w * against_w < 0`, with an `abs()` gate on **both** — so the swap +cannot change a single trip decision on any input. What it changes is the +**sentence**. Measured by diffing the suite's own printed banners between the +round-3 tree and the same tree carrying only M12: **9 emitted banner lines +invert, in 3 distinct sentences.** + +``` +shipped: SIGN_DISAGREEMENT: commanded -2000 W against a meter reading +2000 W … +M12: SIGN_DISAGREEMENT: commanded +2000 W against a meter reading -2000 W … + +shipped: … commanded +1000 W against its own measured power -1000 W … +M12: … commanded -1000 W against its own measured power +1000 W … + +shipped: … commanded -2000 W against a meter reading +4000 W … (the N=1 arm) +M12: … commanded +4000 W against a meter reading -2000 W … +``` + +**⚠️ AND IT IS WORSE THAN THE ONE ROUND 2 CLOSED.** Part 9c asserts the SITE +detector is **not armed at a roster of one**, and inverter B does not physically +exist. So on the shipped install the **per-unit banner is the only +sign-disagreement sentence that can ever be printed** — in the action log, in +`_LOG.error`, in `status_payload()`'s headline banner, and in `sign_reason`, +which is persisted and read back after the restart the latch is designed to +survive. Round 2 pinned the N≥2-only call site and left the shipping one open. + +**Closed by the ORDERED PAIR on both per-unit banners**, with both operands read +off the **observed cycle** — `inv.last_write` and `inv.batt` off the objects the +plant moved, never off the arguments the detector was handed, which would be +comparing the swap against itself: + +* `test_watchdog.py` **part 3**, the N≥2 per-unit trip: + `f"commanded {stood:+.0f} W against its own measured power {meas:+.0f} W"`, + plus the persisted `unit/inverter-b/sign_reason` copy, plus a **precondition** + that the two operands are different numbers of opposite sign — without which + a transposition would produce the same string and the check would be + decoration; +* `test_watchdog.py` **part 7**, the **N=1** controller-driven trip: + `f"commanded {stood:+.0f} W against a meter reading {grid:+.0f} W"`, plus the + namespaced key **and** the flat rollback mirror, plus the same precondition. + +**Measured after the fix: M12 is RED on four named checks in +`test_watchdog.py` and GREEN in the other twenty files** — one file, four +checks. M11 re-measured on the same tree: still red. + +**⚠️ Neither §6a nor MUTATION.md disclosed it.** §6a's "what was NOT closed" +enumerated `circ_wd`, the eight other `CLAIM_SCOPE` entries, `actuator_of()`'s +body and the `n_units > 1` guard — not this. MUTATION.md's own closing rule, in +the file round 2 edited, is *"a variant is not covered because a near-miss of it +is."* §7 is the enumeration that would have caught both instances at once. + ## 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 @@ -406,6 +542,184 @@ disk, byte delta printed):** re-verified on `86eaef2` for **M3** and **M11** (both full 21-file runs) and is otherwise carried forward from round 1's measurement on `f72c48d`. It is a weaker statement than round 1 made and is written down as such. + ⚠️ **Round 3 closed the other half of this**, but only in the *before* + direction: all ten were re-run on clean `release/1.0` @ `bbc903b` under the + **full 21-file suite** to establish which of them the pre-existing coverage + could see — see §4's ⚠️ paragraph, and the answer was **eight survivors, not + three**. The *after* direction — "each of the ten is red in + `test_watchdog.py` and green in the other twenty **on the round-3 tree**" — + is still measured end-to-end only for **M3, M11 and M12**. For the other + seven, "red" carries forward from round 2 (which adding assertions cannot + undo) and "green elsewhere" carries forward from round 1. * **`test_peakguard.py`'s CI-06 flake is not fixed by this card** and is not this card's subject. It is *characterised* here (five isolated runs on the M3 tree, all PASS) only far enough to overturn the false kill it produced. + +### 6b. Round 3: what was closed, and what is still open + +**Closed in round 3:** + +1. **M12**, the zero-byte argument swap at the **per-unit** call site + `main.py:1560` — §4c. It was a survivor of the round-2 tree at **21/21 + green**, and it is the **only** sign-disagreement banner a shipped + one-inverter install can print. Red on four named checks, green in the other + twenty files. +2. **The ledger arithmetic in §4**, which round 2 made specific and wrong. See + §4's own ⚠️ paragraph: re-measured mutation by mutation on clean + `release/1.0` @ `bbc903b`, full 21-file suite each time. +3. **The family itself is now enumerated by machine** — + `tools/symmetric_message_scan.py`, §7 — rather than by hand for a third time. + +**Open, named, and NOT claimed:** + +* **A3 and B1** — the only two status-page rows in which the freeze detector + ever names a device range: the **NOT COVERED** row and the **armed** row's + "below max_w because …" clause. `rail_unreachable()`'s `dev_min`/`dev_max` at `main.py:3208` and the + literal `f"only {inv.dev_min}...{inv.dev_max} W"` at `main.py:3231`. Both + measured: **0 bytes each, 21/21 green each.** Real, open members of the + family. They are **status-page rows rather than latching alarms**, and closing + them wants a fixture with a device whose two bounds are **asymmetric** — every + fixture in this repository uses `±d`, so the transposition is currently + invisible by construction — which belongs to `test_freeze.py`'s card, not to + this one. +* **Twelve census-B sites** (§7c, B2–B12) determined unpinned at the **source + level** — the distinguishing literal appears in no `test_*.py` — and **not** + mutated. Weaker evidence than A1–A3, and written down as such. +* **B6**, SAFETY-31's `circ_reason`, is the same shape as the defect this card + closed for SAFETY-02/30 and is *also* persisted and re-read after a restart. + §6a already declines to touch `circ_wd`; §7d says what that costs. +* Everything §6a lists stays open: `circ_wd`'s own thresholds, the eight other + `CLAIM_SCOPE` entries, `actuator_of()`'s body, and the `n_units > 1` guard. + +## 7. The symmetric-predicate family — every call whose argument order is load-bearing for a MESSAGE + +**Why this section exists, and it is the most useful thing in the card.** Round 2 +pinned the operand order of `SignWatchdog.evaluate()` at `main.py:1620`. Round 3 +found the **identical zero-byte defect at `main.py:1560`**, one call site over — +and that one is worse, because `test_watchdog.py` part 9c asserts the site +detector is **never armed at +a roster of one**, so on the shipped install the per-unit banner is the **only** +sign-disagreement sentence that can ever be printed. Twice in one card the +enumeration was written by hand and twice it was short. MUTATION.md habit 12: *a +hand-written scope is the same defect as a hand-written fixture.* So the family +is now **computed**, by `tools/symmetric_message_scan.py`. + +### 7a. What makes this family invisible, and why it is not a fixture problem + +Every other defect in this document is a fixture problem: *no fixture happens to +separate the mutant from the shipped code.* Build the right fixture and it goes +red. This one is not that. + +Where a callee is **symmetric** in two arguments, a swap at a call site cannot +change **any** action on **any** input. `watchdog.disagrees()` is +`commanded_w * against_w < 0` with an `abs()` gate on **both** sides: the +product commutes and the two gates are the same gate. There is nothing for a +behavioural check to observe, ever — **not "no fixture separates them" but no +fixture *can*.** The only surface that moves is the **sentence**, and the only +check that can see it is one asserting the **ordered pair** (MUTATION.md habit +14) at **that** call site. + +Two consequences the earlier rounds got wrong: + +* **The unit of coverage is the CALL SITE, not the function.** `evaluate()`'s + operand order was pinned and the defect still shipped, because the pin was on + the *other* call. +* **A behavioural suite is not weak evidence here, it is NO evidence.** "21/21 + green" is what this mutation is *guaranteed* to produce. Measured, twice: M11 + at `main.py:1620` on the round-1 tree, and M12 at `main.py:1560` on the + round-2 tree. + +### 7b. Census A — the callee formats its own parameters + +`tools/symmetric_message_scan.py` renames each parameter pair, erases every +formatted string, canonicalises the commutative operators (`*`, `+`, `and`, +`or`, `==`, `min`/`max`, iterated tuple literals) and compares the **bodies**; +it iterates to a **fixpoint**, because the symmetry is usually one call deep — +`evaluate()` is symmetric only *through* `disagrees()`, and a single pass +reports an empty family on a tree whose answer is already known. It then +classifies each sentence by **where it goes**. + +Fixpoint on this tree: **3 rounds, 17 interchangeable positional and 5 keyword +pairs across 14 functions**. 45 parameter pairs reach a sentence; **8** are +symmetric — **5** whose sentence goes only to an operator, and **3** whose +sentence is the **return value** and therefore has to be classified by hand, +because a returned string is sometimes a KEY and sometimes a SENTENCE. + +| # | scan row | pair | called at | swap costs | reaches an operator via | pinned? | +|---|---|---|---|---|---|---| +| **A1** | `watchdog.py:613` `SignWatchdog.evaluate()` | `(commanded_w, against_w)` | **`main.py:1560`** — per-unit, **and the N=1 detector** | **0 B** | `sign_reason` (persisted), the action log, `_LOG.error`, `status_payload()`'s headline banner | **YES — round 3.** `test_watchdog.py` part 3 (`its own measured power`) and part 7 (`a meter reading`), on the contiguous literal with both operands read off the observed cycle. **Measured: 0 B, red on 4 named checks, green in the other 20 files.** | +| **A2** | same row, second call site | `(commanded_w, against_w)` | **`main.py:1620`** — site | **0 B** | `site_sign_reason`, the action log, the status page | **YES — round 2**, part 9a. Re-measured on the round-3 tree: still red. | +| **A3** | `freeze.py:405` `FreezeWatchdog.evaluate()` and `freeze.py:311` `rail_unreachable()` — both symmetric through `rail_ceiling_w()`'s `for bound in (dev_min, dev_max)` | `(dev_min, dev_max)` | **`main.py:3208`** (and 1928, 2755, 3196, 3219) | **0 B** | the status page's `freeze detector: NOT COVERED — the setpoint device reports a range of …` row | **NO.** Measured: swapped at `main.py:3208-3209`, **0 bytes**, `main.py` fingerprint `61aa413f…` → `2305b560…`, **21/21 green**. Disclosed in §7d, not closed. ⚠️ `rail_unreachable()` is the row the scan puts in its **RETURN** bucket: its returned string is a **sentence**, not a key, so it is a family member after all — which is exactly why that bucket says *read by hand*. | +| **A4** | `static_tariff.py:522` `_raise_gap()` | `(start, end)` | `static_tariff.py:509`, `:513` | **0 B** | the `ProfileInvalid` refusal an operator reads at startup | **YES — pre-existing.** Measured: both call sites swapped, **0 bytes**, `static_tariff.py` fingerprint `caef7fde…` → `50e9a768…`, **red in `test_static_tariff.py` on two named checks** (*"…and names the uncovered range so the operator can fix it"*, *"…and the refusal names the uncovered range"*), because `_hhmm(start)}-{_hhmm(end)` is asserted as the contiguous literal `"22:00-23:00"`. ⚠️ That batch run also reported `FAIL test_peakguard.py` on a `static_tariff.py`-only edit; the failing check is *"one cycle of the intent form is exactly the delivered form plus the command standing on the wire"*, which is [[CI-06]]'s **mechanism 1 by name**, and `test_peakguard.py` passes **four for four** in isolation on that same tree. No claim here rests on it. | +| A5–A6 | `static_tariff.py:522` `_raise_gap()` | `(start, weekday)`, `(end, weekday)` | — | — | — | **not real — the scan's one over-report.** It erases the sentence, so it cannot see that `DAY_NAMES[start]` with `start = 1320` raises `IndexError`. A mixed-type pair fails loudly, which is the opposite of this family. | +| A7–A8 | `store.py:177` `unit_key()`, `mqtt.py:222` `unit_state_key()` | `(unit, key)`, `(slug, key)` | many | 0 B | — | **not this family.** The formatted string is the **return value and it is a KEY**: a swap renames a store key and an MQTT topic, which is behaviour every ordinary check can see. MAINT-05 is the card about it. | + +### 7c. Census B — the caller builds the sentence + +Census A only sees a callee formatting its **own** parameters, which was gap 2 +of its own honest-gap list. The other half never crosses a function boundary: +an operator sentence built where it is printed, interpolating two values that +are **interchangeable by shape**. No predicate is involved at all, so census A's +symmetry test has nothing to test. The scan pairs interpolated expressions that +are **one leaf apart** — `inv.dev_min` / `inv.dev_max`, `cfg.min_w` / +`cfg.hold_s` — which is exactly what a transposition swaps, and it keeps only +sentences whose sink is a **message**: an f-string that becomes a URL or a store +key is data, and a swap there is an ordinary behavioural change. **16 +candidates**, every one of them a **zero-byte** edit: + +| # | site | pair | reaches an operator as | pinned? | +|---|---|---|---|---| +| **B1** | `main.py:3231` | `inv.dev_min` / `inv.dev_max` | status page, `… accepts only −120…130 W` | **NO — MEASURED.** Transposed in place, **0 bytes**, `main.py` fingerprint `61aa413f…` → `ac36a97b…`, purged, `:ro`, **21/21 green**. The row then tells the operator the device accepts `130…−120 W`. | +| **B2** | `main.py:3362` | `inv.dev_min` / `inv.dev_max` | status page, `setpoint … (range −120…130 W)` | **NO** — the literal `setpoint … (range` is in no `test_*.py`. | +| **B3** | `main.py:3435` | `sign_wd.cfg.hold_s` / `.cfg.min_w` | status page, `sign watchdog: armed (trips after 180 s opposed, both over 300 W)` | **NO** — `s opposed` is in no `test_*.py`. | +| **B4** | `main.py:3460` | `site_sign_wd.cfg.hold_s` / `.min_w` | status page, the site sign row | **NO** — `armed against the sum` is in no `test_*.py`. | +| **B5** | `main.py:3491` | `circ_wd.cfg.hold_s` / `.min_w` | status page, the circulation row | **NO** — `circulation watchdog: armed` is in no `test_*.py`. | +| **B6** | `circulation.py:317` | `cfg.min_w` / `cfg.hold_s` | **`circ_reason` — persisted, and re-read after a restart** | **NO** — `test_watchdog.py` asserts only `"CIRCULATING_POWER" in reason`. **This is SAFETY-31's twin of the exact defect this card just closed for SAFETY-02/30**, on a string with the same persistence. | +| **B7** | `main.py:2454` | `self.site_max_w` / `self.target` | the clamp action-log line | **NO** — `was clamped to` is in no `test_*.py`. | +| **B8** | `main.py:3386` | `clamp_wd.engagements` / `.episodes` | status page, `engaged N time(s) in M episode(s)` | **NO** — `time(s) in` is in no `test_*.py`. | +| **B9–B11** | `main.py:3169` (three pairs) | `p1.net_w` / `.samples` / `.parse_errors` | status page, the P1 row | **NO** — `P1 meter (` is in no `test_*.py`. | +| **B12** | `arbiter.py:264` | `c.source` / `c.actuator` | `scope_errors`, the arbiter's mis-routing refusal | **NO, and this one is instructive.** `test_arbiter.py:452` says it *"names the source and the actuator it does not belong on"* and then asserts `"peak" in r_mis.scope_errors[0]`. Under the swap the sentence becomes `"inverter-b: a SITE claim filed on unit 'peak'"` — and `"peak" in …` is **still true**. A claim wider than its assertion, which is habit 14's own shape. | +| **B13** | `validate.py:723` | `meter_phases` / `meter_max_age_s` | the startup refusal | **NO** for the ordered pair. | +| **B14** | `validate.py:1211` | `arbitrage_soc_floor` / `arbitrage_soc_ceiling` | the startup refusal | **NO** for the ordered pair. | +| **B15** | `watchdog.py:667` | `cfg.min_w` / `cfg.hold_s` | the `SIGN_DISAGREEMENT` sentence's `(limits: both over 300 W, held over 180 s)` clause | **YES**, and it is worth naming because it shows the habit works. `test_watchdog.py`'s SAFETY-30 section asserts the site instance's **whole sentence with `==` against a literal**, the `(limits: …)` clause included. Full-string equality is habit 14 taken to its limit. | +| B16 | `main.py:237` | `i.name` / `i.max_w` | the roster log line | **not real** — a `str` beside a `:.0f` format. The same over-report as A5–A6: a mixed-type transposition raises rather than lying. | + +### 7d. What this enumeration does NOT close, with sizes + +* **A3 is a measured, open survivor.** `dev_min`/`dev_max` transposed in + `rail_unreachable()`'s NOT COVERED row: **0 bytes, 21/21 green.** It is + disclosed here rather than fixed, because closing it wants a `test_freeze.py` + or status-page fixture with a device whose two bounds are **asymmetric** — + every fixture in the repository uses `±d` — and that is a different file's + card. It is a **status-page row**, not a latching alarm. +* **B1 is a second measured, open survivor.** `inv.dev_min` and `inv.dev_max` + transposed in `main.py:3231`'s status row: **0 bytes**, fingerprint + `61aa413f…` → `ac36a97b…`, **21/21 green**. The page then tells the operator + the device accepts `130…−120 W`. Same disposition as A3: **A3 is the row the + freeze detector prints when the rail is UNREACHABLE, B1 the clause the ARMED + row prints when the device is what set the rail** — the two rows in which that + detector ever names a device range, and neither one's operand order is + asserted anywhere. +* **Thirteen further census-B sites are unpinned and UNMEASURED.** B2–B14 above + are determined at the **source level** — the distinguishing literal appears in + no `test_*.py`, or the check that exists asserts a substring wider than its + own claim — and were **not** mutated. That is a weaker statement than A1–A4 + and B1, and is written down as one. +* **B12 is a check whose CLAIM is wider than its ASSERTION**, which is habit + 14's own shape one module over. `test_arbiter.py:452` says + `scope_violations` *"names the source and the actuator it does not belong + on"* and then asserts `"peak" in r_mis.scope_errors[0]`. Swap `c.source` and + `c.actuator` in `arbiter.py:264` and the sentence becomes + *"inverter-b: a SITE claim filed on unit 'peak'"* — and the substring is still + found. Source-level, not mutated, and not fixed here. +* **B6 is the closest thing to a repeat.** SAFETY-31's `circ_reason` is + persisted and re-read after a restart, exactly like `sign_reason`, and its + two limits are graded by a bare `"CIRCULATING_POWER" in reason`. This card + does not fix it; §6a already declines to touch `circ_wd`, and this is the + precise shape of what that declining costs. +* **The instrument's own blind spots** are printed by the instrument, every + run: semantic symmetry it cannot spell (it under-reports), census B pairs + that are the same *kind* but not the same *shape*, `%s`-style logging and + non-f-string payloads, anything outside `app/`, and the one way it + **over-reports** — it erases the sentence, so a swap that would make the + formatting itself raise is scored as changing no decision (A5–A6 above). diff --git a/goodwe_controller/MUTATION.md b/goodwe_controller/MUTATION.md index c276686..af5e47c 100644 --- a/goodwe_controller/MUTATION.md +++ b/goodwe_controller/MUTATION.md @@ -276,7 +276,25 @@ same place twice, the check is decoration. The fix is to assert the **ORDERED PAIR**, `f"commanded {sum:+.0f} W against a meter reading {grid:+.0f} W"`. - The general habit is **14** below. + ⚠️ **AND THE ELEVENTH WAS THE SAME DEFECT ONE CALL SITE OVER — THE ONE A + SHIPPED INSTALL CAN ACTUALLY SEE.** Round 2 pinned the operand order of + `SignWatchdog.evaluate()` at `main.py:1620`. That call site is the **site** + detector, and `test_watchdog.py` part 9c asserts it is **never armed at a + roster of one** — which is every install this add-on has. The **per-unit** + call at `main.py:1560` was left unpinned, and swapping its first two + arguments is the same **ZERO-BYTE** edit: `disagrees()` is symmetric in them + (`commanded_w * against_w < 0` with an `abs()` gate on both), so **no trip + decision can change** and the full 21-file suite stays green — measured. What + moves is five banner lines, including the one persisted to `sign_reason` and + re-read after a restart: *"commanded −1000 W against its own measured power + +1000 W"*, operands inverted, on the only sign-disagreement sentence a + one-inverter install can ever print. Closed in round 3 by the ordered pair on + **both** per-unit banners — the N≥2 `its own measured power` one and the N=1 + `a meter reading` one — red on four named checks, green in the other twenty + files. **Habit 15 is what falls out of finding the same shape twice in the + same card.** + + The general habits are **14** and **15** below. Note what these have in common with a masked rung: the mutation *runs*, and the suite is *honestly* green. Nothing in a purge, a fingerprint or a disassembly @@ -427,6 +445,56 @@ Cheap habits fall out of it, and all of them are now used in this repository: this one was found: **inside the fix written to close the previous instance of the same family**, which is the second time in this list. + ⚠️ **AND THE FIX FOR IT WAS ONE CALL SITE WIDE, WHICH IS THE HALF WORTH + KEEPING.** The ordered pair went onto `main.py:1620`, the **site** detector + — armed only at N≥2, and part 9c asserts it is **absent at a roster of + one**. The identical swap at `main.py:1560`, the **per-unit** detector, was + still **ZERO BYTES**, still changed no decision, still left the 21-file + suite green — and it is the **only** sign-disagreement sentence a shipped + one-inverter install can ever print: *"commanded −1000 W against its own + measured power +1000 W"*, backwards, in the log, on the status page and in + `sign_reason` after the restart the latch is designed to survive. Measured + size: **main.py:1560, 0 bytes, 21/21 green.** So habit 14 is not "pin the + banner", it is **"pin every call site of the banner, and say which one the + shipped roster reaches"** — and habit 15 is how you find them all. + +15. **A SYMMETRIC PREDICATE MAKES ARGUMENT ORDER A PROPERTY OF THE MESSAGE + ALONE — so ENUMERATE THAT FAMILY BY MACHINE, and grade the CALL SITES, not + the function.** Where a callee is symmetric in two arguments, a swap at any + call site is invisible to every behavioural check **by construction**: not + "no fixture happens to separate them" but *no fixture can*. There is + nothing for a mutation battery to find, because the mutant and the shipped + tree take identical actions on every input. The only surface that moves is + the sentence, and the only check that can see it is one asserting the + **ordered pair** (habit 14) at **that** call site. + Three consequences, and all three are why this is its own habit: + * **The unit of coverage is the CALL SITE.** SAFETY-38 pinned + `evaluate()`'s operand order and still shipped the defect, because the + pin was on the call two lines below the one that matters. Ask of every + banner: *how many places call this, and which does the shipped roster + reach?* + * **The scope must be COMPUTED** — habit 12's argument, and SAFETY-38 is + its proof: the family was enumerated by hand twice and was short both + times. `tools/symmetric_message_scan.py` renames each parameter pair, + erases every formatted string, canonicalises the commutative operators + and iterates to a **fixpoint** over `app/` (the symmetry is usually one + call deep — `evaluate()` is symmetric only *through* `disagrees()`), then + classifies each sentence by where it GOES. A returned string may be a + **key** — swapping `store.unit_key(unit, key)` renames a store key, which + is behaviour and is MAINT-05's card — or a **sentence**, which is this + family. + * **Say what the instrument cannot see, with shapes** (habit 13). That scan + under-reports semantic symmetry no canonicaliser on its finite list can + spell; its second census, for sentences the CALLER builds, pairs only + expressions **one leaf apart** and never sees `%s`-style logging; it + covers only `app/`; and it **over-reports in exactly one way** — it + erases the sentence, so a swap that would make the FORMATTING itself + raise is scored as changing no decision. It prints all of that on every + run. The family, every member's call sites, and which of them are pinned + are in `DECISIONS/SAFETY-38-site-standing-pin.md` §7 — **with two + measured, still-open survivors named there rather than rounded off**, + both zero bytes and both 21/21 green. + And the mutation-table consequence: **a variant is not covered because a near-miss of it is.** DEV-02's table listed the *actuator* variant of the per-unit claim defect (`device`/`supervised` filed without `inv`), which the diff --git a/goodwe_controller/test_watchdog.py b/goodwe_controller/test_watchdog.py index 10715e4..80efaea 100644 --- a/goodwe_controller/test_watchdog.py +++ b/goodwe_controller/test_watchdog.py @@ -1508,6 +1508,50 @@ check("⚠️ AC 1. THE REVERSED UNIT TRIPS AND ITS SIBLING DOES NOT. The eviden check("...and the reason names the instrument, so the operator is sent to the " "right one", AGAINST_OWN_POWER in _b30.sign_wd.reason) print(f" {_b30.sign_wd.reason!r}") +# ⚠️ THE LAST CYCLE BEFORE B LATCHED, NAMED RATHER THAN INDEXED - part 5's +# lesson and 9a's. After the trip B's share is 0 W and its measured power +# follows it down, so a fixed index reads the REMEDY instead of the evidence. +# Both operands are read off THIS OBSERVED CYCLE - `last_write` and `batt` off +# the objects - and not off the fixture's constants, so the check cannot be +# satisfied by two numbers the fixture happens to hold anyway (habit 2). +_preb30 = [x for x in _s30 if not x["b"]] +_midb30 = _preb30[-1] +print(f" last cycle before B latched: B stood " + f"{_midb30['stood'][1]:+.0f} W, B measured {_midb30['meas'][1]:+.0f} W") +check("⚠️ ...AND THE ORDERED PAIR, on the ONE SIGN-DISAGREEMENT SENTENCE A " + "SHIPPED INSTALL CAN EVER PRINT. Part 9a pins the SITE banner's operand " + "order at main.py:1620; that call site is armed only at N>=2 and part 9c " + "asserts it is ABSENT at a roster of one, so on the shipped install the " + "PER-UNIT banner is the only one there is - and its call site is a " + "different line. Swapping the first two arguments at main.py:1560 - " + "`evaluate(stood, against, ...)` -> `evaluate(against, stood, ...)` - is " + "a ZERO-BYTE edit that changes NO trip decision, because `disagrees()` " + "is symmetric in those two arguments (`commanded_w * against_w < 0` with " + "an abs() gate on both). Nothing behavioural can see it and the full " + "21-file suite stayed green. What it changes is the SENTENCE: 'commanded " + "-1000 W against its own measured power +1000 W', the exact opposite of " + "which instrument read what, on the line somebody woken by this alarm " + "reads first - and it is persisted to `sign_reason` and re-read after a " + "restart. Graded on the CONTIGUOUS LITERAL with each value in its own " + "slot, per MUTATION.md habit 14: an `in` test on a number grades " + "PRESENCE, never ARRANGEMENT", + f"commanded {_midb30['stood'][1]:+.0f} W against its own measured power " + f"{_midb30['meas'][1]:+.0f} W" in _b30.sign_wd.reason) +check("PRECONDITION for the line above, and without it that check is " + "decoration: the two operands are DIFFERENT NUMBERS and OPPOSITE IN " + "SIGN, so transposing them really does produce a different string. A " + "cycle where B's share and B's measured power were equal, or where " + "either was 0 W, would make the swapped sentence identical to the " + "correct one and the assertion could not reach the failure it names", + _midb30["stood"][1] != _midb30["meas"][1] + and _midb30["stood"][1] * _midb30["meas"][1] < 0 + and f"{_midb30['stood'][1]:+.0f}" != f"{_midb30['meas'][1]:+.0f}") +check("...and the SAME ordered pair is what an operator finds AFTER A RESTART, " + "because `sign_reason` is the persisted copy of this string. The swap " + "survives the container restart the same way the latch does", + f"commanded {_midb30['stood'][1]:+.0f} W against its own measured power " + f"{_midb30['meas'][1]:+.0f} W" + in str(_c30.store.data.get(unit_key("inverter-b", "sign_reason"), ""))) # ⚠️ READ OFF THE ALLOCATION RECORD, NOT OFF `Controller.reason`. At a roster # of two `reason` is the SITE resolution's line - "loop -> 2000 W" - because # B's failsafe is a UNIT claim resolved on B's own actuator and never competes @@ -1764,11 +1808,20 @@ async def _n1_sign(): return _orig_eval(*a, **kw) c.site_sign_wd.evaluate = _spy - await _drive(c, n=12, dt=0.2) - return c, _n1_calls + # ⚠️ THE WATCH READS THE CONTROLLER'S OWN STATE, NOT `_spy`'s ARGUMENTS. + # `_n1_calls` records what was HANDED to a detector, so grading the banner + # against it would compare the swap against itself - the DEV-02 defect. + # `last_write` and `c.grid` are read off the objects the plant moved. + seen = await _drive(c, n=12, dt=0.2, watch=lambda c: dict( + grid=c.grid, + stood=(c.inverters[0].last_write + if c.inverters[0].last_write is not None + else c.inverters[0].target), + tripped=c.inverters[0].sign_wd.tripped)) + return c, _n1_calls, seen -_c1s, _n1_site_calls = asyncio.run(_n1_sign()) +_c1s, _n1_site_calls, _seen1s = asyncio.run(_n1_sign()) _inv1 = _c1s.inverters[0] print(f" roster={_c1s.units} armed against {_inv1.sign_wd.against!r} " f"at {_inv1.sign_wd.cfg.min_w:.0f} W / {_inv1.sign_wd.cfg.hold_s:.0f} s") @@ -1786,6 +1839,43 @@ check("...the reason sentence still says 'a meter reading', word for word - it " "is what an operator reads, what the action log carries and what is " "persisted, so it is graded on the literal", "against a meter reading" in _inv1.sign_wd.reason) +# The last cycle before the latch, named rather than indexed: afterwards the +# failsafe stands 0 W and the plant's meter relaxes, so a fixed index reads the +# remedy. Both operands come off the controller, not off the fixture's options. +_pre1s = [x for x in _seen1s if not x["tripped"]] +_mid1s = _pre1s[-1] +print(f" last cycle before the latch: stood {_mid1s['stood']:+.0f} W, " + f"meter {_mid1s['grid']:+.0f} W") +print(f" {_inv1.sign_wd.reason!r}") +check("⚠️ ...AND THE ORDERED PAIR ON THE SHIPPING CALL SITE. This is the SAME " + "zero-byte defect part 9a closed at main.py:1620, one call site over at " + "main.py:1560 - and this is the one a one-inverter install actually " + "reaches, since the site detector is never armed here (asserted below). " + "`evaluate(stood, against, ...)` -> `evaluate(against, stood, ...)` " + "changes no trip decision - `disagrees()` is symmetric in those two " + "arguments - and inverts the banner to 'commanded +4000 W against a " + "meter reading -2000 W': the loop is standing at -2000 W of charge INTO " + "a +4000 W import and the latching banner says the opposite. The " + "substring check above passes under the swap, because both numbers are " + "still in the sentence; only the ORDERED PAIR can tell the slots apart " + "(MUTATION.md habit 14)", + f"commanded {_mid1s['stood']:+.0f} W against a meter reading " + f"{_mid1s['grid']:+.0f} W" in _inv1.sign_wd.reason) +check("PRECONDITION: the two operands are DIFFERENT NUMBERS and OPPOSITE IN " + "SIGN on that cycle, so a transposition genuinely produces a different " + "string. Equal or zero operands would make the swapped banner identical " + "and the check above vacuous", + _mid1s["stood"] != _mid1s["grid"] + and _mid1s["stood"] * _mid1s["grid"] < 0 + and f"{_mid1s['stood']:+.0f}" != f"{_mid1s['grid']:+.0f}") +check("...and the persisted copy carries the same ordered pair, under BOTH the " + "namespaced key and the flat rollback mirror - which is what an operator " + "reads after the restart the latch is designed to survive", + f"commanded {_mid1s['stood']:+.0f} W against a meter reading " + f"{_mid1s['grid']:+.0f} W" in str(_c1s.store.data.get(REASON_KEY, "")) + and f"commanded {_mid1s['stood']:+.0f} W against a meter reading " + f"{_mid1s['grid']:+.0f} W" + in str(_c1s.store.data.get("sign_reason", ""))) check("...the latch is under the same namespaced key with the same flat " "rollback mirror, and SAFETY-30's site pair is untouched at its default. " "A one-inverter install's state file is byte-identical to what it was", diff --git a/goodwe_controller/tools/symmetric_message_scan.py b/goodwe_controller/tools/symmetric_message_scan.py new file mode 100644 index 0000000..2a22192 --- /dev/null +++ b/goodwe_controller/tools/symmetric_message_scan.py @@ -0,0 +1,565 @@ +#!/usr/bin/env python3 +"""SAFETY-38 round 3 - the SYMMETRIC-PREDICATE FAMILY, enumerated by machine. + +WHAT IT LOOKS FOR, in one sentence: a call whose argument ORDER is load-bearing +for a MESSAGE and for nothing else - so swapping the arguments cannot change a +single decision, is invisible to every behavioural check BY CONSTRUCTION, and +still inverts what an operator reads on the line they are woken by. + +⚠️ WHY IT IS AN INSTRUMENT AND NOT A LIST. MUTATION.md habit 12: "a hand-written +scope is the same defect as a hand-written fixture". SAFETY-38 met this family +twice - `SignWatchdog.evaluate()` called at `main.py:1620` (round 2) and at +`main.py:1560` (round 3), ONE CALL SITE OVER - and both times the enumeration +was written by hand and both times it was short. So the scope is computed. + +------------------------------------------------------------------------------ +THE TEST IT APPLIES + +For every function in `app/` and every pair (a, b) of its parameters: + + rename a -> b and b -> a throughout the body, ERASE every string literal + and f-string, CANONICALISE the commutative operators, and compare the two + ASTs. If they are EQUAL, nothing the function DECIDES can depend on which + of the two values arrived first. + +Then, if some f-string in that function mentions BOTH names, the order is +load-bearing for the MESSAGE ALONE, and the pair is reported. + +⚠️ THE SYMMETRY IS USUALLY ONE CALL DEEP, WHICH IS WHY THIS IS A FIXPOINT AND +NOT A SINGLE PASS. `SignWatchdog.evaluate()` does not compare its two arguments +itself; it hands them to `watchdog.disagrees()`, whose body is +`commanded_w * against_w < 0 and abs(commanded_w) > min_w and +abs(against_w) > min_w`. An intraprocedural scan sees `disagrees(a, b, ...)` +become `disagrees(b, a, ...)`, calls that a difference, and reports NOTHING - +which is exactly what the first version of this file did, on a tree where the +answer was already known. So symmetric pairs discovered in one round are used +to canonicalise CALL ARGUMENTS in the next, and the scan iterates until the set +stops growing. + +CANONICALISATION, and each entry is here because something in `app/` needs it: + * `a * b`, `a + b` - operand order sorted (`disagrees`) + * `x and y`, `x or y` - term order sorted (`disagrees`' conjunction) + * `a == b`, `a != b` - operand order sorted + * `min(a, b)`, `max(a, b)` - argument order sorted (`clamp_to_rating`) + * a tuple/list/set LITERAL being iterated - element order sorted + (`rail_ceiling_w`'s `for bound in (dev_min, dev_max)`) + * arguments at a KNOWN SYMMETRIC POSITION PAIR of a called function - sorted + (the fixpoint) + +Equal ASTs after the rename is a SUFFICIENT condition for behavioural +invariance, not a necessary one. This scan therefore UNDER-REPORTS and says so +- see "WHAT THIS CANNOT SEE". That is the conservative direction: everything it +names is genuinely in the family, and the gap is stated with its shape. + +Run: `python3 tools/symmetric_message_scan.py [--quiet]` from goodwe_controller/. +Exit status is 0 always; it is a census, not a gate. +""" +from __future__ import annotations + +import ast +import itertools +import pathlib +import sys + +APP = pathlib.Path(__file__).resolve().parent.parent / "app" + +# {function name: {(i, j) positional index pairs that are interchangeable}} +_SYM: dict[str, set[tuple[int, int]]] = {} +# ⚠️ AND THE SAME THING FOR KEYWORD-ONLY PARAMETERS, WHICH THE FIRST FIXPOINT +# DROPPED. `freeze.rail_ceiling_w(max_w, *, dev_min=None, dev_max=None)` folds +# its two device bounds with `for bound in (dev_min, dev_max): ceiling = min( +# ceiling, abs(bound))` - perfectly symmetric - and `rail_unreachable()` prints +# `"a range of {dev_min}...{dev_max} W"`. A scan that only followed POSITIONAL +# arguments called that pair asymmetric and hid a genuine member of the family +# behind an implementation detail of its own traversal. +_SYM_KW: dict[str, set[tuple[str, str]]] = {} + + +class _Canon(ast.NodeTransformer): + """Rename one pair, erase every string, and sort what commutes.""" + + COMMUTATIVE_BINOP = (ast.Mult, ast.Add, ast.BitAnd, ast.BitOr, ast.BitXor) + COMMUTATIVE_CMP = (ast.Eq, ast.NotEq) + COMMUTATIVE_CALL = ("min", "max") + + def __init__(self, a: str = "", b: str = ""): + self.a, self.b = a, b + + # -- the rename --------------------------------------------------------- + def visit_Name(self, node): + self.generic_visit(node) + if self.a and node.id == self.a: + node.id = self.b + elif self.b and node.id == self.b: + node.id = self.a + return node + + def visit_arg(self, node): + if self.a and node.arg == self.a: + node.arg = self.b + elif self.b and node.arg == self.b: + node.arg = self.a + return node + + # -- the message is exactly what is ALLOWED to differ -------------------- + def visit_JoinedStr(self, node): + return ast.Constant(value="") + + def visit_Constant(self, node): + if isinstance(node.value, str): + return ast.Constant(value="") + return node + + # -- canonicalisation --------------------------------------------------- + def visit_BinOp(self, node): + self.generic_visit(node) + if isinstance(node.op, self.COMMUTATIVE_BINOP): + l, r = ast.dump(node.left), ast.dump(node.right) + if l > r: + node.left, node.right = node.right, node.left + return node + + def visit_BoolOp(self, node): + self.generic_visit(node) + node.values = sorted(node.values, key=ast.dump) + return node + + def visit_Compare(self, node): + self.generic_visit(node) + if (len(node.ops) == 1 + and isinstance(node.ops[0], self.COMMUTATIVE_CMP)): + l, r = ast.dump(node.left), ast.dump(node.comparators[0]) + if l > r: + node.left, node.comparators[0] = node.comparators[0], node.left + return node + + def visit_For(self, node): + self.generic_visit(node) + if isinstance(node.iter, (ast.Tuple, ast.List, ast.Set)): + node.iter.elts = sorted(node.iter.elts, key=ast.dump) + return node + + def visit_Call(self, node): + self.generic_visit(node) + name = (node.func.id if isinstance(node.func, ast.Name) + else node.func.attr if isinstance(node.func, ast.Attribute) + else "") + if name in self.COMMUTATIVE_CALL and len(node.args) == 2 \ + and not node.keywords: + if ast.dump(node.args[0]) > ast.dump(node.args[1]): + node.args = [node.args[1], node.args[0]] + return node + # the fixpoint: sort the arguments at a known symmetric position pair + for (i, j) in sorted(_SYM.get(name, ())): + if j < len(node.args): + if ast.dump(node.args[i]) > ast.dump(node.args[j]): + node.args[i], node.args[j] = node.args[j], node.args[i] + # ...and at a known symmetric KEYWORD pair + for (ka, kb) in sorted(_SYM_KW.get(name, ())): + ia = next((k for k, w in enumerate(node.keywords) + if w.arg == ka), None) + ib = next((k for k, w in enumerate(node.keywords) + if w.arg == kb), None) + if ia is not None and ib is not None: + va, vb = node.keywords[ia].value, node.keywords[ib].value + if ast.dump(va) > ast.dump(vb): + node.keywords[ia].value, node.keywords[ib].value = vb, va + return node + + +def _canon(src: str, a: str = "", b: str = "") -> str: + return ast.dump(_Canon(a, b).visit(ast.parse(src))) + + +# ⚠️ THE SINK IS HALF THE CLAIM, AND THE FIRST VERSION OF THIS SCAN OMITTED IT. +# Erasing every f-string makes a function whose ONLY job is to FORMAT one look +# perfectly symmetric - `store.unit_key(unit, key)` and +# `mqtt.unit_state_key(slug, key)` both came back as "the swap changes nothing" +# when in fact the string IS the return value: swapping there renames a STORE +# KEY and an MQTT TOPIC, which is a behaviour change every ordinary check can +# see, and MAINT-05 is the card about exactly that. The family this file is +# looking for is narrower - the string goes to an OPERATOR and to nobody else - +# so each sentence is classified by where it GOES. +_LOG_SINKS = {"debug", "info", "warning", "warn", "error", "exception", + "critical", "log", "log_event", "notify", "persistent_notify"} +_MSG_ATTRS = {"reason", "message", "msg", "banner", "detail", "note", + "explanation", "why", "text"} + + +def _sink_of(fn, target, follow=True): + """RETURN | MESSAGE | OTHER - where this f-string node ends up. + + `follow` is the ONE step of data flow below, and it is off on the recursive + call so that two locals that interpolate each other cannot loop forever. + """ + for stmt in ast.walk(fn): + for child in ast.iter_child_nodes(stmt): + pass + # a parent map is cheaper than repeated walks + parent = {} + for node in ast.walk(fn): + for child in ast.iter_child_nodes(node): + parent[id(child)] = node + cur = target + while id(cur) in parent: + up = parent[id(cur)] + if isinstance(up, ast.Return): + return "RETURN" + if isinstance(up, ast.Raise): + return "MESSAGE" + if isinstance(up, (ast.Assign, ast.AnnAssign, ast.AugAssign)): + tgts = (up.targets if isinstance(up, ast.Assign) else [up.target]) + for t in tgts: + if isinstance(t, ast.Attribute) and t.attr in _MSG_ATTRS: + return "MESSAGE" + if isinstance(t, ast.Name) and t.id in _MSG_ATTRS: + return "MESSAGE" + # ⚠️ ONE STEP OF DATA FLOW, AND IT IS NOT OPTIONAL. `main.py`'s + # status page builds the device-range clause into a LOCAL - `_cap = + # "; ".join(f"... accepts only {inv.dev_min}...{inv.dev_max} W" ...)` + # - and only then concatenates it into the row's `text`. Stopping at + # the assignment scores that OTHER and drops a member this card + # MEASURED as a live survivor (0 bytes, 21/21 green). So: if the + # name assigned here is interpolated into some other sentence in the + # same function that IS a message, this one is a message too. + if follow: + for t in tgts: + if isinstance(t, ast.Name) and _feeds_a_message(fn, t.id, + up): + return "MESSAGE" + return "OTHER" + if isinstance(up, ast.Call): + name = (up.func.attr if isinstance(up.func, ast.Attribute) + else up.func.id if isinstance(up.func, ast.Name) else "") + if name in _LOG_SINKS: + return "MESSAGE" + if name == "append": + return "MESSAGE" + cur = up + return "OTHER" + + +def _feeds_a_message(fn, name, origin): + """Is `name` interpolated into some OTHER sentence that IS a message?""" + for node in ast.walk(fn): + if not isinstance(node, ast.JoinedStr) or node is origin: + continue + used = any(isinstance(x, ast.Name) and x.id == name + for v in node.values if isinstance(v, ast.FormattedValue) + for x in ast.walk(v.value)) + if used and _sink_of(fn, node, follow=False) == "MESSAGE": + return True + return False + + +def _fstring_pairs(fn, params): + """{(a, b): [(lineno, sink), ...]} for every f-string mentioning both.""" + out: dict[tuple[str, str], list[tuple[int, str]]] = {} + for node in ast.walk(fn): + if not isinstance(node, ast.JoinedStr): + continue + used = set() + for v in node.values: + if isinstance(v, ast.FormattedValue): + for x in ast.walk(v.value): + if isinstance(x, ast.Name) and x.id in params: + used.add(x.id) + if len(used) < 2: + continue + sink = _sink_of(fn, node) + for a, b in itertools.combinations(sorted(used), 2): + out.setdefault((a, b), []).append((node.lineno, sink)) + return out + + +def _functions(): + for path in sorted(APP.glob("*.py")): + tree = ast.parse(path.read_text()) + for fn in ast.walk(tree): + if isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)): + yield path, fn + + +def _positional(fn): + return [a.arg for a in fn.args.posonlyargs + fn.args.args + if a.arg != "self"] + + +def _all_params(fn): + return _positional(fn) + [a.arg for a in fn.args.kwonlyargs] + + +def _body_src(fn) -> str: + """The function's BODY alone. + + ⚠️ NOT `ast.unparse(fn)`. The signature lists the parameters IN ORDER, so + renaming a pair inside it reorders `arguments.args` and every function in + the repository compares unequal to itself - which is what the first version + of this scan did, silently, and it reported an empty family on a tree whose + answer was already known. The claim being tested is about what the body + DECIDES, and the body is what is compared. + """ + return ast.unparse(ast.Module(body=list(fn.body), type_ignores=[])) + + +def _symmetric(fn, a, b) -> bool: + src = _body_src(fn) + return _canon(src) == _canon(src, a, b) + + +def build_fixpoint(max_rounds=6): + """Fill `_SYM` until it stops growing. Returns the number of rounds.""" + for round_no in range(1, max_rounds + 1): + grew = False + for _path, fn in _functions(): + pos = _positional(fn) + kws = [a.arg for a in fn.args.kwonlyargs] + if len(pos) + len(kws) < 2: + continue + found = set() + for i, j in itertools.combinations(range(len(pos)), 2): + if _symmetric(fn, pos[i], pos[j]): + found.add((i, j)) + found_kw = set() + for ka, kb in itertools.combinations(sorted(kws), 2): + if _symmetric(fn, ka, kb): + found_kw.add((ka, kb)) + if found - _SYM.get(fn.name, set()): + _SYM.setdefault(fn.name, set()).update(found) + grew = True + if found_kw - _SYM_KW.get(fn.name, set()): + _SYM_KW.setdefault(fn.name, set()).update(found_kw) + grew = True + if not grew: + return round_no + return max_rounds + + +def scan(): + rows = [] + for path, fn in _functions(): + params = _all_params(fn) + if len(params) < 2: + continue + kwonly = {a.arg for a in fn.args.kwonlyargs} + for (a, b), hits in sorted(_fstring_pairs(fn, params).items()): + rows.append(dict( + module=path.name, func=fn.name, line=fn.lineno, a=a, b=b, + msg_lines=sorted(set(hits)), + sinks={s for _l, s in hits}, + symmetric=_symmetric(fn, a, b), + positional=not (a in kwonly or b in kwonly))) + return rows + + +# ============================================================================= +# CENSUS B - THE SENTENCE THE CALLER BUILDS +# ============================================================================= +# ⚠️ CENSUS A ABOVE ONLY SEES A CALLEE FORMATTING ITS OWN PARAMETERS, AND THAT +# WAS GAP 2 OF ITS OWN "WHAT THIS CANNOT SEE" LIST. The other half of the family +# never passes through a function boundary at all: an operator sentence built +# where it is printed, interpolating two values that are INTERCHANGEABLE BY +# SHAPE. `main.py`'s status page says +# +# f"{inv.setpoint_entity or inv.name} accepts only {inv.dev_min}...{inv.dev_max} W" +# +# and transposing those two costs ZERO BYTES, changes nothing any check can +# observe, and tells the operator a range backwards. No predicate is involved, +# so census A's symmetry test has nothing to test. +# +# THE TEST: inside one MESSAGE-sink f-string, take every pair of interpolated +# expressions, blank every attribute NAME and every string CONSTANT, and compare. +# If the blanked forms are EQUAL while the originals DIFFER, the two expressions +# are the same shape with one leaf changed - `inv.dev_min` / `inv.dev_max`, +# `site_dev_bound("dev_min")` / `site_dev_bound("dev_max")` - which is exactly +# the pair a transposition swaps. Same direction as census A: it under-reports +# (two values of the same KIND spelled differently are missed) rather than +# inventing members. + + +class _Blank(ast.NodeTransformer): + def visit_Attribute(self, node): + self.generic_visit(node) + node.attr = "" + return node + + def visit_Constant(self, node): + if isinstance(node.value, str): + return ast.Constant(value="") + return node + + +def _blanked(expr): + return ast.dump(_Blank().visit(ast.parse(ast.unparse(expr)))) + + +def scan_caller(): + rows = [] + for path in sorted(APP.glob("*.py")): + tree = ast.parse(path.read_text()) + for fn in ast.walk(tree): + if not isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + for node in ast.walk(fn): + if not isinstance(node, ast.JoinedStr): + continue + # ⚠️ MESSAGE SINKS ONLY, AND THE FIRST VERSION OF THIS + # CENSUS SAID "anything but a RETURN". That let in + # `p1.py`'s `self.url = f"http://{self.host}:{self.port}…"` - + # an f-string that is DATA, used to make a request, where a + # transposition is an ordinary behavioural change every check + # can see. Same distinction as census A's RETURN bucket: a + # formatted string is only in this family when its only + # consumer is a person. + if _sink_of(fn, node) != "MESSAGE": + continue + exprs = [v.value for v in node.values + if isinstance(v, ast.FormattedValue)] + for a, b in itertools.combinations(exprs, 2): + ua, ub = ast.unparse(a), ast.unparse(b) + if ua == ub: + continue + if _blanked(a) == _blanked(b): + rows.append((path.name, node.lineno, fn.name, ua, ub)) + # de-duplicate: the same sentence reached through several enclosing defs + return sorted(set(rows), key=lambda r: (r[0], r[1], r[3])) + + +# ⚠️ THE UNIT OF COVERAGE IS THE CALL SITE, NOT THE FUNCTION - which is the +# whole lesson of SAFETY-38 round 3. `SignWatchdog.evaluate()` had its operand +# order pinned and the defect still shipped, because the pin was on the call two +# lines below the one a one-inverter roster actually reaches. So a family member +# is reported WITH EVERY PLACE IT IS CALLED. +def call_sites(name): + out = [] + for path in sorted(APP.glob("*.py")): + tree = ast.parse(path.read_text()) + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + called = (node.func.attr if isinstance(node.func, ast.Attribute) + else node.func.id if isinstance(node.func, ast.Name) + else "") + if called == name: + # ⚠️ THE RECEIVER IS PRINTED, because matching on the bare + # attribute name conflates methods: `evaluate` is the name of + # SAFETY-02's, SAFETY-18's and SAFETY-31's watchdogs alike, and + # a list of four lines with no receiver tells a reader nothing + # about which detector they are looking at. `inv.sign_wd` and + # `self.freeze_wd` are the disambiguation, and they are the + # caller's own words. + out.append(f"{path.name}:{node.lineno} " + f"`{ast.unparse(node.func)}`") + return out + + +def main(): + quiet = "--quiet" in sys.argv + rounds = build_fixpoint() + rows = scan() + fam = [r for r in rows if r["symmetric"] and "MESSAGE" in r["sinks"] + and "RETURN" not in r["sinks"]] + valued = [r for r in rows if r["symmetric"] and "RETURN" in r["sinks"]] + print("SYMMETRIC-PREDICATE FAMILY - argument order load-bearing for a " + "MESSAGE only") + print("=" * 78) + print(f"fixpoint reached after {rounds} round(s); " + f"{sum(len(v) for v in _SYM.values())} interchangeable positional " + f"and {sum(len(v) for v in _SYM_KW.values())} keyword pair(s) " + f"across {len(set(_SYM) | set(_SYM_KW))} function(s) in app/") + print(f"functions formatting >=2 of their own parameters into one " + f"sentence: {len(rows)} pair(s)") + print(f"of those, pairs whose SWAP LEAVES EVERY DECISION IDENTICAL: " + f"{len(fam) + len(valued)}") + print(f" ...of which the sentence is RETURNED (so the swap is a VALUE " + f"change an ordinary check can see): {len(valued)}") + print(f" ...and the sentence goes ONLY TO AN OPERATOR - THE FAMILY: " + f"{len(fam)}") + print() + print("THE FAMILY - a swap here is invisible to every behavioural check " + "BY CONSTRUCTION:") + for r in sorted(fam, key=lambda r: (r["module"], r["line"])): + kind = ("POSITIONAL - a transposition at a call site costs ZERO BYTES" + if r["positional"] else + "KEYWORD-ONLY at the definition - a transposition has to be " + "written out by name, which is not free and not silent") + print(f" {r['module']}:{r['line']} {r['func']}() pair " + f"({r['a']}, {r['b']})") + print(f" sentence at line(s): " + f"{', '.join(f'{l} -> {s}' for l, s in r['msg_lines'])}") + print(f" {kind}") + sites = call_sites(r["func"]) + print(f" CALL SITES ({len(sites)}) - each one is a separate pin, " + f"and the receiver says which detector it is:") + for site in sites or ["(none inside app/)"]: + print(f" {site}") + print() + print("SYMMETRIC, BUT THE SENTENCE IS THE RETURN VALUE - READ THIS BUCKET " + "BY HAND.") + print("A returned string is sometimes a KEY (a swap renames a store key or " + "an MQTT topic,") + print("which is behaviour every ordinary check can see - MAINT-05 is the " + "card about it)") + print("and sometimes a SENTENCE (a swap is message-only after all, and it " + "IS this family):") + for r in sorted(valued, key=lambda r: (r["module"], r["line"])): + print(f" {r['module']}:{r['line']} {r['func']}() pair " + f"({r['a']}, {r['b']})") + print() + print("=" * 78) + print("CENSUS B - SENTENCES THE CALLER BUILDS, interpolating two values of " + "the SAME SHAPE") + print("A transposition inside one of these is ZERO BYTES and no predicate " + "is involved at") + print("all, so nothing behavioural can ever see it. Same rule: assert the " + "ORDERED PAIR.") + cb = scan_caller() + print(f"candidates: {len(cb)}") + for mod, line, fn, ua, ub in cb: + print(f" {mod}:{line} in {fn}()") + print(f" {ua} <-> {ub}") + print() + print("WHAT THIS CANNOT SEE - the shape of each gap, named rather than " + "rounded off:") + print(" 1. SEMANTIC symmetry no canonicaliser above spells. The list of " + "commutative") + print(" forms is finite and hand-written; a function symmetric only " + "through a form") + print(" not on it is REJECTED and never appears. This under-reports.") + print(" 2. In CENSUS B, two interpolated values of the same KIND that " + "are not the same") + print(" SHAPE - `a.lo` beside `hi`, or one of them behind a local " + "variable. Only") + print(" one-leaf-apart expressions are paired. `%s`-style logging " + "(`_LOG.error('%s") + print(" ... %s', a, b)`) and non-f-string payloads are not scanned at " + "all.") + print(" 3. Anything outside `app/` - `sim/`, `tools/`, the firmware, the " + "witness.") + print(" 4. WHETHER each call site is pinned. The sites are listed above; " + "whether a") + print(" check asserts the ORDERED PAIR at each one is a fact about " + "`test_*.py` and is") + print(" recorded in DECISIONS/SAFETY-38-site-standing-pin.md section " + "7. Call sites") + print(" outside `app/` are not listed either, and the site list matches " + "on the bare") + print(" METHOD NAME - three different watchdogs are all called " + "`evaluate`, so the") + print(" receiver is printed beside each one and a reader has to pick.") + print(" 5. ⚠️ IT CAN ALSO OVER-REPORT, IN ONE EXACT WAY. The sentence is " + "ERASED before") + print(" the comparison, so a swap that would make the FORMATTING " + "ITSELF fail - a") + print(" `DAY_NAMES[start]` where `start` is 1320 - is scored as " + "'changes no decision'.") + print(" `_raise_gap`'s (start, weekday) and (end, weekday) pairs are " + "that: mixed") + print(" types, and a real transposition there raises IndexError rather " + "than lying") + print(" quietly. The hazard is a SAME-TYPED pair; read the list with " + "that in hand.") + + +if __name__ == "__main__": + main() -- 2.54.0 From e2efc765afbe81ce8fd0e9197af93e676dfa01d5 Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Sat, 5 Sep 2026 22:38:40 +0200 Subject: [PATCH 5/6] SAFETY-38 round 4: the enumeration covered one sink, and it was not the one this codebase uses The scanner's two censuses between them scored ONE syntactic sink and called it the family. Every one of census B's 16 members was a sentence that is APPENDED, ASSIGNED to a `reason`-shaped name, or passed as a LOG ARGUMENT. Not one was RETURNED - and census A's return bucket only ever held a callee formatting its own PARAMETERS, so a method formatting its own ATTRIBUTES and handing the string back was invisible to both. That is the whole describe() / explain() / reason-builder family, which is where this repository actually keeps its operator sentences. Worse than the gap: it was in NONE of the five blind spots the tool printed about itself. THE SCANNER, WIDENED - and it rediscovered both members without being told: * CENSUS C, the return sink, resolved through the returning method's CALL SITES so a returned KEY (store.unit_key, mqtt.unit_state_key - MAINT-05's card) is not mistaken for a returned SENTENCE. 53 returned pairs, 39 reaching an operator, 16 of those sharing a format spec. * A sentence handed to a constructor as `reason=` or placed under a "text" dict key is a MESSAGE. That one line of the sink walker was hiding SIX more caller-built members, census B 16 -> 22. * Every census B and C row now prints whether the two interpolations share a FORMAT SPEC - the difference between a transposition that is silent and one that merely looks broken. * The blind-spot list is now TWO lists: what it scans (printed from the constants, so they cannot drift) and WHAT IT DOES NOT SCAN AT ALL, largest hole first - `%s`-style lazy logging, MEASURED at 31 calls across 8 modules and each one listed, not asserted. CLOSED, both zero-byte survivors of a green 21-file suite: * clamp.py:253 ClampResult.describe(), requested_w <-> applied_w. No test_*.py mentioned describe() at all. It is the SS15 action-log row (main.py:1366) AND the ClampWatchdog notification reason (main.py:2452) - the alarm SAFETY-40 and SAFETY-41 are about. Under the swap the guard reports a setpoint clamped UPWARD, to +9999 W by a bound whose rating the same sentence gives as 2000 W. Pinned on the ORDERED PAIR on the contiguous literal at BOTH consumers, operands read off ctl.target and hass.writes[-1] - what the loop asked and what the device was sent - not off the ClampResult the swap lives in. 4 checks red. * peakguard.py:493 PeakDecision.explain(), projected_w <-> ceiling_w. "peak guard idle" was in no test_*.py. The row then reads "projects 5013 W under a 2300 W ceiling" - 2713 W OVER - on a row whose own words say idle and whose own flag says ok. Pinned on the decision and on the shipped status row. 3 checks red. * test_arbiter.py's scope-violation check, whose CLAIM was wider than its ASSERTION. Measured this round rather than left at source level: 0 bytes, 21/21 green before, red now. 2 checks. DISCLOSED, NOT CLOSED - 11 measured zero-byte survivors, each with its file fingerprint and its 21/21 verdict, in the decision doc SS7e-SS7g. Three in allocator.py's UnitPlan.explain(), three across arbiter.py's two explain()s, two in tariff.py, two in arbitrage.py's trade reasons, one in main.py's SoC-floor notification. An enumerated list with honest gaps beats a fifth round finding a fifth instance. CORRECTIONS THIS ROUND OWES: * The idle sentence does NOT reach main.py:3299/3302 - those are the floor-limited branch, where explain() returns self.reason before the idle literal is built. It reaches main.py:3313 and, on the release edge, 1951. 1951 is disclosed unpinned. * SS4's "Every one of the ten is red" sat under a twelve-row table: it is the ten site_standing entries. * SS6b said "weaker evidence than A1-A3"; A4 was measured, so SS7d's "A1-A4 and B1" is the right list and SS6b now says it. * AND THE PROBE THAT GRADES THESE MUTATIONS WAS THE DEFECT, ONCE. Its first cut numbered the operands BY ORDER OF APPEARANCE, so after a transposition the other leaf took the same number and all thirteen mutant renders came back byte-identical - which reads exactly like "the edit is inert". Keying the value to the NAME fixed it. Habit 4, in the measuring instrument: MUTATION.md habit 16 is the new entry, and the count there is corrected to eleven rather than inflated. 14 mutations measured: fresh full-repo copy each, mounted :ro, bytecode purged and the purge COUNT printed, the edit re-read from disk, length and sha256 printed on both sides, and every one required to change the EMITTED SENTENCE and not merely the bytes. 12 survived the pre-round suite, 1 was already caught, 3 are caught now. Full 21-file suite green on the final tree. NOT ONE BYTE OF app/ MOVES. This card changes coverage only. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU --- goodwe_controller/CHANGELOG.md | 67 +++ .../DECISIONS/SAFETY-38-site-standing-pin.md | 200 +++++++- goodwe_controller/MUTATION.md | 53 +- goodwe_controller/test_arbiter.py | 29 +- goodwe_controller/test_clamp.py | 99 ++++ goodwe_controller/test_peakguard.py | 80 ++++ .../tools/symmetric_message_scan.py | 451 +++++++++++++++--- 7 files changed, 901 insertions(+), 78 deletions(-) diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index 86b6014..1ec6f33 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -222,6 +222,73 @@ shipped `sum` is correct and must not be "fixed".** it is recorded rather than quietly dropped — unlike the one that *was* believed, two bullets up. Ledger, and an **enumerated list of what was not mutated**, in `DECISIONS/SAFETY-38-site-standing-pin.md` §4a, §4b and §6a. +* **⭐ ROUND 4: THE SCANNER COVERED ONE SYNTACTIC SINK AND CALLED IT THE + FAMILY, AND TWO LIVE MEMBERS WERE SITTING IN THE HOLE.** Every one of census + B's 16 members was a sentence that is **appended, assigned, or logged**. Not + one was **RETURNED** — and census A's return bucket only ever held a callee + formatting its own *parameters*, so a method formatting its own **attributes** + and handing the string back was invisible to both. That is the whole + `describe()` / `explain()` family, which is where this codebase keeps its + operator sentences. **Worse than the gap: it was in none of the five blind + spots the tool printed about itself.** The tool now scans the return sink + (census C), resolving it through the returning method's **call sites** so that + a returned *key* — `store.unit_key()`, `mqtt.unit_state_key()` — is not + mistaken for a returned *sentence*; it also scores a sentence handed to a + constructor as `reason=` or placed under a `"text"` dict key, which added + **six more caller-built members** it had been stepping past. Re-run, it + **rediscovered both of the members this round was sent to fix without being + told where to look**. +* **⭐ `ClampResult.describe()` — the clamp alarm the owner has been complaining + about all day — had a SECOND independent way to lie, and no `test_*.py` + mentioned `describe()` at all.** Transposing `requested_w` and `applied_w` in + `clamp.py:253` is a **zero-byte** edit that leaves the guard reporting a + setpoint clamped **UPWARD**, in the measured case to +9999 W by a bound whose + rating the same sentence gives as 2000 W. It is the §15 action-log row + (`main.py:1366`) **and** the ClampWatchdog notification reason + (`main.py:2452`). Measured **21/21 green** before; now **red on 4 checks** in + `test_clamp.py` part 6d, pinned on the **ordered pair** on the contiguous + literal at **both** consumers, with operands read off `ctl.target` and + `hass.writes[-1]` — what the loop asked and what the device was sent — rather + than off the `ClampResult` the swap lives in. +* **⭐ `PeakDecision.explain()` — the status-page banner — the same shape.** + `projected_w` ↔ `ceiling_w` at `peakguard.py:493`, **zero bytes**, and the + row then reads *"quarter projects 5013 W under a 2300 W ceiling"*: a quarter + **2713 W over** its ceiling, on a row whose own words say *idle* and whose own + flag says **ok**. `"peak guard idle"` appeared in no `test_*.py`. Measured + **21/21 green** before; now **red on 3 checks** in `test_peakguard.py` part 13, + pinned on the decision and on the shipped status row at `main.py:3313`. + ⚠️ **A correction the ticket is owed:** the idle sentence does not reach + `main.py:3299/3302` — those are the *floor-limited* branch, where `explain()` + returns `self.reason` before the idle literal is built. It reaches + **`main.py:3313`** and, on the release edge, `main.py:1951`. +* **`test_arbiter.py`'s scope-violation check made a claim WIDER than its + assertion** — it said `scope_violations` *"names the source and the actuator + it does not belong on"* and asserted `"peak" in scope_errors[0]`, which is + still true after the two swap roles. Measured this round: **0 bytes, 21/21 + green**; now red on 2 checks, pinned on the ordered pair. +* **14 more zero-byte mutations measured, and 11 SURVIVORS ARE DISCLOSED RATHER + THAN CLOSED**, each with its file fingerprint and its 21/21 verdict, in + `DECISIONS/SAFETY-38-site-standing-pin.md` §7e–§7g: three in + `allocator.py`'s `UnitPlan.explain()`, three across `arbiter.py`'s two + `explain()`s, two in `tariff.py`, two in `arbitrage.py`'s trade reasons and + one in `main.py`'s SoC-floor notification. **An enumerated list with honest + gaps beats a fifth round finding a fifth instance.** + ⚠️ **And the probe that grades those mutations was itself the defect, once.** + Its first cut numbered the operands **by order of appearance**, so after a + transposition the other leaf took the same number and all thirteen renders came + back **byte-identical** — which reads exactly like *the edit is inert*. Keying + the value to the **name** fixed it. A measurement comparing the swap against + itself, in the instrument this time. +* **The tool now states what it does NOT scan**, largest hole first: + `%s`-style lazy logging (`_LOG.error("… %s … %s", a, b)` puts the operands in + *arguments*, so no census here looks at them and the transposition is the same + zero-byte edit — **31 such calls across 8 modules, counted and listed by the + tool rather than asserted**), `.format()`/`%`/`+`/`.join()` sentences, a sentence that + leaves through a dict or dataclass and is rendered elsewhere, `yield`ed + sentences, message attributes outside the `reason`-shaped set, and everything + outside `app/`. Its five over/under-report gaps stay, and every census row now + prints whether the two interpolations **share a format spec** — the difference + between a transposition that is silent and one that merely looks broken. **Inverter B gets its own MQTT device, and inverter A keeps the one it has (TEL-07).** MQTT discovery announced **one** device, and every entity hung off diff --git a/goodwe_controller/DECISIONS/SAFETY-38-site-standing-pin.md b/goodwe_controller/DECISIONS/SAFETY-38-site-standing-pin.md index c8f1dbc..c86a774 100644 --- a/goodwe_controller/DECISIONS/SAFETY-38-site-standing-pin.md +++ b/goodwe_controller/DECISIONS/SAFETY-38-site-standing-pin.md @@ -234,7 +234,7 @@ fingerprint, and verifies no cache appears during the run). The mutated line was | **M11** | **argument SWAP** at the SITE call site: `evaluate(site_standing, self.grid, …)` → `evaluate(self.grid, site_standing, …)` (main.py:1620) | **0** | **was a SURVIVOR in round 1 — now RED**, see §4a | | **M12** | **argument SWAP** at the PER-UNIT call site: `evaluate(stood, against, …)` → `evaluate(against, stood, …)` (**main.py:1560**) | **0** | **was a SURVIVOR in round 2 — now RED**, see §4c | -**Every one of the ten is red in `test_watchdog.py`.** Re-measured in round 2 +**Every one of the ten `site_standing` entries is red in `test_watchdog.py`.** Re-measured in round 2 on `release/1.0` @ `86eaef2` (where `app/main.py` is 234 542 bytes, 2 022 more than the `f72c48d` the round-1 figures were taken on): all ten still red, at the byte deltas in the table, each on a fresh copy with the edit re-read from disk. @@ -584,13 +584,73 @@ disk, byte delta printed):** this one. * **Twelve census-B sites** (§7c, B2–B12) determined unpinned at the **source level** — the distinguishing literal appears in no `test_*.py` — and **not** - mutated. Weaker evidence than A1–A3, and written down as such. + mutated. Weaker evidence than A1–A4 and B1, and written down as such + — §7d says the same thing with the same list. * **B6**, SAFETY-31's `circ_reason`, is the same shape as the defect this card closed for SAFETY-02/30 and is *also* persisted and re-read after a restart. §6a already declines to touch `circ_wd`; §7d says what that costs. * Everything §6a lists stays open: `circ_wd`'s own thresholds, the eight other `CLAIM_SCOPE` entries, `actuator_of()`'s body, and the `n_units > 1` guard. +### 6c. Round 4: what was closed, and what is still open + +**The blocker round 3 shipped.** The enumeration read as complete and covered +**one syntactic sink**. Every one of census B's 16 members was a sentence that +is **appended, assigned, or passed as a log argument** (`problems.append`, +`out.append({"text": …})`, `self.reason = …`, `_LOG.info`). **Not one was a +sentence that is RETURNED.** Census A has a return bucket, but only for +symmetric **parameters**, so a method formatting its **own attributes** and +handing the string back was invisible to both — and `describe()` / `explain()` / +the reason-builders are exactly where this codebase keeps its operator +sentences. Worse than the gap: it was in **none of the five blind spots the tool +prints about itself**. A tool that asserts a completeness it does not have is +the recurring defect wearing a new hat. + +**Closed in round 4:** + +1. **`clamp.py:253` `ClampResult.describe()`**, pair `requested_w` / + `applied_w` — §7e C6. A survivor of the round-3 tree at **21/21 green**, + **0 bytes**. It is the §15 action-log row at `main.py:1366` **and** the + ClampWatchdog notification reason at `main.py:2452`; **no `test_*.py` + mentioned `describe()` at all.** Pinned on the ordered pair, on the + contiguous literal, at **both** consumers, with a precondition that the two + operands render differently — `test_clamp.py` part 6d. **4 checks red** under + the transposition. +2. **`peakguard.py:493` `PeakDecision.explain()`**, pair `projected_w` / + `ceiling_w` — §7e C7. Same measurement: **0 bytes, 21/21 green**; the string + `"peak guard idle"` was in no `test_*.py`. Pinned on the decision and on the + shipped status row at `main.py:3313` — `test_peakguard.py` part 13. **3 + checks red.** +3. **`arbiter.py:264` (B12)** — the check whose **claim was wider than its + assertion**, carried forward from §7d. Measured this round rather than left + at source level: **0 bytes, 21/21 green** before, **`test_arbiter.py` red** + after. **2 checks red.** +4. **The tool's sinks are now stated rather than implied.** It prints what it + scans as a MESSAGE, that it now scans the RETURN sink through the returning + method's call sites, and — the part that was missing — **an explicit list of + what it does not scan at all**, largest hole first. + +**⚠️ The correction round 4 owes the ticket.** The brief placed +`PeakDecision.explain()`'s idle sentence at `main.py:3299/3302`. Those two lines +are the **floor-limited** branch, where `explain()` returns `self.reason` before +the idle literal is ever built. The idle sentence reaches the page at +**`main.py:3313`** and the action log at **`main.py:1951`** (edge-logged, so it +is the RELEASE edge that writes it). The pin is on 3313; 1951 is disclosed +below, unpinned. + +**Open, named, and measured — see §7f for the ledger:** + +* **Nine further zero-byte survivors** found by the widened scan, each measured + at 21/21 green on this tree: three in `allocator.py`'s `UnitPlan.explain()`, + three in `arbiter.py`'s two `explain()`s, two in `tariff.py`, and one each in + `arbitrage.py`'s two decision reasons and `main.py`'s SoC-floor notification. + None is a control decision; every one is a sentence an operator reads. +* **The action-log surface of the peak-guard idle line** (`main.py:1951`) is + not pinned. The status row (3313) is, and both come off the same `explain()`, + so the *sentence* is closed and the *second call site* is not — which is + precisely the distinction §4c was written about, recorded rather than assumed. +* Everything §6a and §6b list stays open. + ## 7. The symmetric-predicate family — every call whose argument order is load-bearing for a MESSAGE **Why this section exists, and it is the most useful thing in the card.** Round 2 @@ -664,8 +724,7 @@ symmetry test has nothing to test. The scan pairs interpolated expressions that are **one leaf apart** — `inv.dev_min` / `inv.dev_max`, `cfg.min_w` / `cfg.hold_s` — which is exactly what a transposition swaps, and it keeps only sentences whose sink is a **message**: an f-string that becomes a URL or a store -key is data, and a swap there is an ordinary behavioural change. **16 -candidates**, every one of them a **zero-byte** edit: +key is data, and a swap there is an ordinary behavioural change. **16 candidates** in round 3, every one of them a **zero-byte** edit — and **22** after round 4 widened the sink classifier (B17–B22 below): | # | site | pair | reaches an operator as | pinned? | |---|---|---|---|---| @@ -678,11 +737,22 @@ candidates**, every one of them a **zero-byte** edit: | **B7** | `main.py:2454` | `self.site_max_w` / `self.target` | the clamp action-log line | **NO** — `was clamped to` is in no `test_*.py`. | | **B8** | `main.py:3386` | `clamp_wd.engagements` / `.episodes` | status page, `engaged N time(s) in M episode(s)` | **NO** — `time(s) in` is in no `test_*.py`. | | **B9–B11** | `main.py:3169` (three pairs) | `p1.net_w` / `.samples` / `.parse_errors` | status page, the P1 row | **NO** — `P1 meter (` is in no `test_*.py`. | -| **B12** | `arbiter.py:264` | `c.source` / `c.actuator` | `scope_errors`, the arbiter's mis-routing refusal | **NO, and this one is instructive.** `test_arbiter.py:452` says it *"names the source and the actuator it does not belong on"* and then asserts `"peak" in r_mis.scope_errors[0]`. Under the swap the sentence becomes `"inverter-b: a SITE claim filed on unit 'peak'"` — and `"peak" in …` is **still true**. A claim wider than its assertion, which is habit 14's own shape. | +| **B12** | `arbiter.py:264` | `c.source` / `c.actuator` | `scope_errors`, the arbiter's mis-routing refusal | **ROUND 4: MEASURED, THEN CLOSED.** Round 3 left it at source level; the transposition is **0 bytes**, `arbiter.py` `fdb4ed0520eeb721` → `9111f627ef639ecc`, and it was **21/21 green**. Now red in `test_arbiter.py` on 2 checks. Round 3's reasoning was right: `test_arbiter.py:452` says it *"names the source and the actuator it does not belong on"* and then asserts `"peak" in r_mis.scope_errors[0]`. Under the swap the sentence becomes `"inverter-b: a SITE claim filed on unit 'peak'"` — and `"peak" in …` is **still true**. A claim wider than its assertion, which is habit 14's own shape. | | **B13** | `validate.py:723` | `meter_phases` / `meter_max_age_s` | the startup refusal | **NO** for the ordered pair. | | **B14** | `validate.py:1211` | `arbitrage_soc_floor` / `arbitrage_soc_ceiling` | the startup refusal | **NO** for the ordered pair. | | **B15** | `watchdog.py:667` | `cfg.min_w` / `cfg.hold_s` | the `SIGN_DISAGREEMENT` sentence's `(limits: both over 300 W, held over 180 s)` clause | **YES**, and it is worth naming because it shows the habit works. `test_watchdog.py`'s SAFETY-30 section asserts the site instance's **whole sentence with `==` against a literal**, the `(limits: …)` clause included. Full-string equality is habit 14 taken to its limit. | | B16 | `main.py:237` | `i.name` / `i.max_w` | the roster log line | **not real** — a `str` beside a `:.0f` format. The same over-report as A5–A6: a mixed-type transposition raises rather than lying. | +| **B17** | `arbitrage.py:377` | `v.spread_eur_kwh` / `v.required_spread_eur_kwh` | the DISCHARGE decision's `reason` | **ROUND 4, NEW, and MEASURED: 0 bytes, 21/21 green.** Not visible to round 3 at all — the sentence is handed to a constructor as `reason=`, and the sink walker scored only *assignments*. The row then reads *"0.0400 above the cheapest and clear of the 0.1200 this cycle had to cover"* with the two swapped: a trade justified by a spread it did not clear. | +| **B18** | `arbitrage.py:393` | same pair, CHARGE branch | the CHARGE decision's `reason` | **ROUND 4, NEW, MEASURED: 0 bytes, 21/21 green.** | +| **B19** | `main.py:2652` | `g.reason` / `g.soc_floor_reason` | the `goodwe_peak_soc_floor` notification body | **ROUND 4, NEW, MEASURED: 0 bytes, 21/21 green.** The alarm then opens with the *reason the floor bound* and closes with the *guard's own conclusion*, i.e. both halves of a two-part explanation in the wrong order, in the one message SAFETY-24/25 exist to send. | +| B20–B22 | `main.py:2558`, `main.py:2652` (two more pairs) | `g.reason` / `g.ceiling_w`, `g.ceiling_w` / `g.soc_floor_reason` | the same two notifications | **new in round 4, NOT measured** — mixed types (`str` beside `:.0f`), the same over-report class as B16. | + +**⚠️ Six of these twenty-two were invisible to round 3, and the reason is one +line of the sink classifier.** It scored a sentence a MESSAGE when it was +appended, logged, raised, or assigned to a `reason`-shaped **name**. This +codebase mostly builds operator sentences as `Decision(…, reason=f"…")` and +`out.append({"text": f"…"})` — a **keyword argument** and a **dict key**, which +that walker stepped straight past. Both are message sinks now. ### 7d. What this enumeration does NOT close, with sizes @@ -723,3 +793,123 @@ candidates**, every one of them a **zero-byte** edit: non-f-string payloads, anything outside `app/`, and the one way it **over-reports** — it erases the sentence, so a swap that would make the formatting itself raise is scored as changing no decision (A5–A6 above). + +### 7e. Census C — the sentence an object returns about itself (round 4) + +**Why there is a third census.** A and B between them covered **one syntactic +sink**. Everything either of them reported was a sentence that is appended, +assigned, or logged; **none was returned**. Census A's RETURN bucket only ever +held a callee formatting its own **parameters**, so a method formatting its own +**attributes** and handing the string back fell between them — and that is the +whole `describe()` / `explain()` / reason-builder family, which is where the +operator sentences in this codebase actually live. + +**The test** is census B's — one f-string, two interpolations **one leaf apart** +— run over the RETURN sink, plus one thing B never needed: **a returned string +is not yet a message.** `store.unit_key()` and `mqtt.unit_state_key()` return +f-strings too, and swapping there renames a store key and an MQTT topic, which +every ordinary check can see (MAINT-05). So the sink of a returned sentence is +**resolved through its call sites**: it is in this family only when some caller +in `app/` hands it to an operator. That resolution matches on the **bare method +name**, which over-collects — at least four unrelated classes have an +`explain()` — so every verdict is printed **with the sites that produced it**, +and a MESSAGE verdict is a prompt to read them, not a proof. + +The scan reports **53 returned pairs, 39 reaching an operator, 16 of those +sharing a format spec** (two values rendered alike, so a transposition is silent +rather than visibly broken). It **rediscovered both members this round was sent +to fix without being told where to look**, which is the only reason to trust the +rest of the list. + +| # | site | pair | reaches an operator as | Δ bytes | verdict | +|---|---|---|---|---|---| +| **C6** | `clamp.py:253` `ClampResult.describe()` | `requested_w` / `applied_w` | §15 action log (`main.py:1366`) **and** the ClampWatchdog notification reason (`main.py:2452`) | **0** | **was a SURVIVOR at 21/21 — now RED**, `test_clamp.py` part 6d, 4 checks | +| **C7** | `peakguard.py:493` `PeakDecision.explain()` | `projected_w` / `ceiling_w` | status row `main.py:3313`; action log `main.py:1951` on the release edge | **0** | **was a SURVIVOR at 21/21 — now RED**, `test_peakguard.py` part 13, 3 checks | +| C1 | `allocator.py:154` `UnitPlan.explain()` | `self.span` / `self.why` | the arbiter/allocator log line | **0** | **SURVIVOR, 21/21 green** | +| C2 | `allocator.py:160` | `self.span` / `self.why` | the ENVELOPE VIOLATED line | **0** | **SURVIVOR, 21/21 green** | +| C3 | `allocator.py:162` | `self.span` / `self.why` | the ordinary in-envelope line | **0** | **RED** — `test_alloc.py` already asserts that whole literal. The one member of this census the suite could already see, and it says the habit works. | +| C4a | `arbiter.py:344` `UnitBounds.explain()` | `self.lo` / `self.hi` | `CONTRADICTION within the unit (device lo=… > peak hi=…)` | **0** | **SURVIVOR, 21/21 green** | +| C4b | `arbiter.py:344` | `self.lo_src` / `self.hi_src` | the same sentence, the two SOURCE names | **0** | **SURVIVOR, 21/21 green** | +| C5 | `arbiter.py:392` `Resolution.explain()` | `self.winner` / `self.bound_by` | `main.py:2236`'s `ARBITER:` action-log row and `self.reason` at `main.py:2329` | **0** | **SURVIVOR, 21/21 green** — the winner and the limiter swap names, on the line §10.1 exists to make legible | +| C8 | `tariff.py:279` `PeakReading.explain()` | `self.month` / `self.detail` | the PEAK UNREADABLE row | **0** | **SURVIVOR, 21/21 green** | +| C9 | `tariff.py:528` `Projection.explain()` | `self.projected_w` / `self.elapsed_s` | the projection line | **0** | **SURVIVOR, 21/21 green** — watts printed as seconds and seconds as watts, both `:.0f` | + +**Not in the family, and the scan says why rather than dropping them silently:** +`inverter.py:338` `__repr__()` and `static_tariff.py:378` `words` are reported +**UNRESOLVED — no call site inside `app/`** (a `__repr__` is called by the +interpreter, not by a name the scan can match); `price_forecast.py:421/428` and +`tariff.py:214` `month_key()` resolve to **OTHER** — `month_key` is a **store +key**, which is MAINT-05's case and a swap there is behaviour every check sees. + +### 7f. Round 4's mutation ledger + +Same protocol as §4: a fresh full-repo copy per mutation, `:ro`, bytecode +purged **and the purge count printed** before the edit, the edited file +**re-read from disk**, both sides' length and sha256 printed, and the full +**21-file** suite via `tools/mutation_run.sh`. Every row is a pure +transposition, so **every Δ is 0 bytes** — verified, not assumed: `len(old) == +len(new)` is asserted before the write and `len(after) == len(before)` after it. + +**⚠️ And every row was also required to change the ANSWER.** A byte delta says +the file moved; it does not say the sentence did. Each mutated tree is rendered +through a scratch probe that evaluates the shipped f-string with **distinct +operands** and diffs it against the same render of the clean tree. **The first +cut of that probe numbered the operands by ORDER OF APPEARANCE** — so after a +transposition the other leaf took the same number and all thirteen diffs came +back **empty**, which reads exactly like "the edit is inert". Keying the value +to the **name** fixed it. That is the same defect as everything else in this +card, in the instrument this time: a measurement comparing the swap against +itself. + +| mutation | file, before → after (sha256, 16) | Δ | verdict | +|---|---|---|---| +| C1 `allocator.py:154` span↔why | `10c1b26ed0099654` → `ac0c174de1ec28ad` | 0 | **SURVIVED 21/21** | +| C2 `allocator.py:160` span↔why | `10c1b26ed0099654` → `9b10730f4659befd` | 0 | **SURVIVED 21/21** | +| C3 `allocator.py:162` span↔why | `10c1b26ed0099654` → `07097bf5da31f0c6` | 0 | **RED** — `test_alloc.py` | +| C4a `arbiter.py:344` lo↔hi | `fdb4ed0520eeb721` → `009820de7ef0eff0` | 0 | **SURVIVED 21/21** | +| C4b `arbiter.py:344` lo_src↔hi_src | `fdb4ed0520eeb721` → `04cb8cf69dfc7432` | 0 | **SURVIVED 21/21** | +| C5 `arbiter.py:392` winner↔bound_by | `fdb4ed0520eeb721` → `9238c3db920da8ad` | 0 | **SURVIVED 21/21** | +| **C6** `clamp.py:253` requested↔applied | `0153133741497fa5` → `47e4e371043e3201` | 0 | **SURVIVED 21/21 before — RED after**, `test_clamp.py`, 4 checks | +| **C7** `peakguard.py:493` projected↔ceiling | `d95df61a51716c26` → `2d2ee3d0bcd51251` | 0 | **SURVIVED 21/21 before — RED after**, `test_peakguard.py`, 3 checks | +| C8 `tariff.py:279` month↔detail | `82790523dd5227c9` → `4b2147ca17a28425` | 0 | **SURVIVED 21/21** | +| C9 `tariff.py:528` projected↔elapsed | `82790523dd5227c9` → `517e9148d26a243a` | 0 | **SURVIVED 21/21** | +| B17 `arbitrage.py:377` spread↔required | `67fbf949b2938349` → `cddd8045bdeb5e29` | 0 | **SURVIVED 21/21** | +| B18 `arbitrage.py:393` spread↔required | `67fbf949b2938349` → `04a4f3ad3d255de1` | 0 | **SURVIVED 21/21** | +| B19 `main.py:2652` reason↔soc_floor_reason | `61aa413fb890433e` → `bbcbb28f476a98d0` | 0 | **SURVIVED 21/21** | +| **B12** `arbiter.py:264` source↔actuator | `fdb4ed0520eeb721` → `9111f627ef639ecc` | 0 | **SURVIVED 21/21 before — RED after**, `test_arbiter.py`, 2 checks | + +**14 measured, 12 survived the full suite before this round's checks, 1 was +already caught, 3 are caught now.** The tree these were taken on is +`SAFETY-38` @ `357cb4f` rebased on `release/1.0` @ `9e42cec`; `app/main.py` is +234 542 bytes. + +### 7g. What round 4 does NOT close, with sizes + +* **Eleven measured zero-byte survivors are left open** — C1, C2, C4a, C4b, C5, + C8, C9, B17, B18, B19 and the three already open from round 3 (A3, B1, and the + B2–B11/B13–B14 source-level set). Each is a sentence an operator reads and + none is a control decision. They are disclosed with their measurement rather + than pinned because pinning each one wants a fixture in the file that owns the + module — `test_alloc.py`, `test_arbiter.py`, `test_tariff.py`, + `test_arbitrage.py` — and this card has now spent four rounds inside + `test_watchdog.py`'s subject. **An enumerated list with honest gaps beats a + fifth round finding a fifth instance**; what would NOT be honest is a fifth + round finding one that is **not on this list**. +* **`main.py:1951`, the action-log surface of the peak-guard idle line,** is + unpinned. The status row off the same `explain()` is pinned; the second call + site is not, and §4c is the card about exactly that costing a round. +* **The largest remaining hole is not in any census: `%s`-style lazy logging.** + `_LOG.error("… %s … %s", a, b)` puts the two operands in *arguments*, not in + an f-string, so nothing here looks at them and the transposition is the same + zero-byte edit. The tool now says so first in its NOT-SCANNED list, **with + the size measured rather than asserted: 31 such calls across 8 modules in + `app/`, each one listed**. +* **The tool's own blind-spot list was the round-4 blocker and is now two + lists.** "What this scans, and what it does not" enumerates its message sinks + from the constants themselves and then names, in order, what it never looks at: + `%s`-style logging, `.format()`/`%`/`+`/`.join()` sentences, a sentence that + leaves through a dict or dataclass and is rendered elsewhere, `yield`ed + sentences, attributes outside the `reason`-shaped set, and everything outside + `app/`. "What it can see but gets wrong" keeps the five gaps round 3 printed, + with the over-report gap now backed by a **printed format-spec column** so a + reader can tell a silent same-typed transposition from a noisy mixed-type one. diff --git a/goodwe_controller/MUTATION.md b/goodwe_controller/MUTATION.md index af5e47c..226a792 100644 --- a/goodwe_controller/MUTATION.md +++ b/goodwe_controller/MUTATION.md @@ -135,7 +135,7 @@ manufactures them from a **fixture**: the check runs, the assertion is evaluated, the sentence above it is true — and the numbers it compares are arranged so that the failure it describes could not have changed the answer. -It is not hypothetical and it is not rare. TEN instances in two weeks; four +It is not hypothetical and it is not rare. ELEVEN instances in two weeks; four were found by review, two - CFG-04's fixture pair - by an author's own mutation battery, which is the cheaper place, one by review again, in the one place nobody had thought to point the rule at: not a check in the suite, but a @@ -145,6 +145,15 @@ that names the rule in its own text - and the TENTH INSIDE THE FIX WRITTEN TO CLOSE THE NINTH, for the second time in this list. See SAFETY-38 below: the check that closed it asserted a NUMBER and not the number's POSITION. +⚠️ AND THE ELEVENTH IS IN A MEASURING INSTRUMENT RATHER THAN IN A CHECK, which +is where the seventh was too. SAFETY-38 round 4's render probe - the thing that +decides whether a mutation changed the SENTENCE and not merely the bytes - +numbered each interpolated operand BY ORDER OF APPEARANCE. After a transposition +the other operand takes the same number, so all thirteen mutant renders came back +BYTE-IDENTICAL to the clean tree: the probe compared the swap against itself, and +its output read exactly like "the edit is inert". Keying the value to the +operand's NAME fixed it. Habit 16. + ⚠️ **AND IT HAS NOW APPEARED IN SIX CONSECUTIVE CARDS.** The seventh instance, MAINT-05's, is the one that says why: the defect is not carelessness about fixtures, it is that **a check and the thing it checks were built from the same @@ -494,6 +503,48 @@ Cheap habits fall out of it, and all of them are now used in this repository: are in `DECISIONS/SAFETY-38-site-standing-pin.md` §7 — **with two measured, still-open survivors named there rather than rounded off**, both zero bytes and both 21/21 green. + * ⚠️ **AND THE SINK IS HALF THE SCOPE, WHICH IS HOW THE FIRST THREE ROUNDS + OF THIS SCAN MISSED THE PLACE THE CODEBASE ACTUALLY KEEPS ITS SENTENCES.** + The scan classified a sentence as operator-facing when it was **appended, + assigned to a `reason`-shaped name, or logged** — and every one of its 16 + reported members was one of those. **None was RETURNED.** A method + formatting its own **attributes** and handing the string back — + `ClampResult.describe()`, `PeakDecision.explain()`, every reason-builder + in the tree — was outside all of it, and so were sentences handed to a + constructor as `reason=` or placed under a `"text"` dict key. Four + zero-byte survivors of a green 21-file suite were sitting there, two of + them on the alarm SAFETY-40 and SAFETY-41 are about. **The general form: + an enumeration is scoped by a SINK as much as by a PREDICATE, and a sink + list is a hand-written list like any other (habit 12).** Ask of any such + scan: *which syntactic forms does this call a message, and what does the + code under it actually use?* + +16. ⚠️ **AN INSTRUMENT THAT ASSERTS A COMPLETENESS IT DOES NOT HAVE IS WORSE + THAN NO INSTRUMENT, AND ITS BLIND-SPOT LIST IS PART OF THE CODE UNDER + REVIEW.** `tools/symmetric_message_scan.py` printed five honest gaps and a + census count, and a reader — including its author, twice — took the count + for the family. The gap that mattered, the RETURN sink, was **in none of the + five**. A gap list is not a disclaimer; it is a claim, and it is the claim + most likely to be believed because nothing else in the output argues with + it. Two consequences: + * **State what is NOT scanned, largest hole first, as a list of forms** — + not "this under-reports". The tool now names `%s`-style lazy logging + (`_LOG.error("… %s … %s", a, b)`, where the operands are *arguments*, so + no f-string census can ever see them), `.format()`/`%`/`+`/`.join()` + sentences, sentences that leave through a dict or dataclass, `yield`, + message attributes outside its own constant set, and everything outside + `app/`. Where the list is derived from a constant in the file, it is + **printed from that constant**, so the two cannot drift. + * **AND THE PROBE THAT GRADES THE MUTATIONS IS SUBJECT TO EVERY HABIT + ABOVE.** Round 4's render probe gave each interpolated operand a value by + **order of appearance** — so after a transposition the other operand took + the same number and **all thirteen mutant renders came back + byte-identical**, which reads exactly like *the edit changed nothing*. + Keying the value to the operand's **NAME** fixed it. That is habit 4, in + the measuring instrument: the fixture's own values were arranged so the + failure it was built to see could not change the answer. **Eleventh + instance of habit 4 in this file, and the second one in an instrument + rather than in a check.** And the mutation-table consequence: **a variant is not covered because a near-miss of it is.** DEV-02's table listed the *actuator* variant of the diff --git a/goodwe_controller/test_arbiter.py b/goodwe_controller/test_arbiter.py index d8826f3..eff6ccb 100644 --- a/goodwe_controller/test_arbiter.py +++ b/goodwe_controller/test_arbiter.py @@ -449,9 +449,32 @@ mis_peak = SITE_SIDE[:1] + [Claim.limit("peak", P_PEAK, lo=1500.0, r_mis = resolve(mis_peak) print(f" peak floor misfiled on inverter-b: {r_mis.explain()} " f"scope_errors={r_mis.scope_errors}") -check("the mis-routing is CAUGHT: scope_violations names the source and the " - "actuator it does not belong on", len(r_mis.scope_errors) == 1 - and "peak" in r_mis.scope_errors[0]) +# ⚠️ THE ORDERED PAIR, AND THE CHECK THAT USED TO BE HERE WAS WIDER THAN ITS +# OWN CLAIM. It said `scope_violations` "names the source and the actuator it +# does not belong on" and then asserted `"peak" in r_mis.scope_errors[0]`. +# `arbiter.py:264` builds that sentence as +# `f"{c.source}: a SITE claim filed on unit {c.actuator!r} - …"`; transpose the +# two - zero bytes, no decision changes, SAFETY-38 census B row B12 - and it +# reads "inverter-b: a SITE claim filed on unit 'peak'", naming the misfiled +# claim as the actuator and the actuator as the claim. `"peak" in …` is STILL +# TRUE under that, so the old line graded neither half of what it claimed. +# The operands are read off the CLAIM this test built, not off the message. +_mis_c = mis_peak[-1] +check("PRECONDITION: the misfiled claim's source and actuator are DIFFERENT " + "STRINGS, so a transposition genuinely produces a different sentence", + _mis_c.source != _mis_c.actuator and _mis_c.source == "peak" + and _mis_c.actuator == "inverter-b") +check("the mis-routing is CAUGHT, and scope_violations names the source FIRST " + "and the actuator it does not belong on SECOND - the ORDERED PAIR on the " + "contiguous literal, because a substring test finds either name under a " + "transposition that swaps their roles", + len(r_mis.scope_errors) == 1 + and r_mis.scope_errors[0].startswith( + f"{_mis_c.source}: a SITE claim filed on unit {_mis_c.actuator!r}")) +check("...and the TRANSPOSED sentence is not what it says, so the check above " + "discriminates rather than decorates", + f"{_mis_c.actuator}: a SITE claim filed on unit {_mis_c.source!r}" + not in r_mis.scope_errors[0]) check("...and the reason it must be caught, measured rather than argued: with " "the floor on a unit the SITE has no floor, so the loop charges 1500 W " "into the quarter the guard was trying to shave", diff --git a/goodwe_controller/test_clamp.py b/goodwe_controller/test_clamp.py index 1968880..38d5ca1 100644 --- a/goodwe_controller/test_clamp.py +++ b/goodwe_controller/test_clamp.py @@ -1000,6 +1000,105 @@ check("...and main.py:856's consumer is the line that goes quietly false", bool(ctl_w.clamp_last and ctl_w.clamp_last.engaged)) +# --------------------------------------------------------------------------- # +# 6d. SAFETY-38 round 4 - THE ORDERED PAIR INSIDE `ClampResult.describe()` +# --------------------------------------------------------------------------- # +# ⚠️ THIS IS THE ALARM THE OWNER HAS BEEN COMPLAINING ABOUT ALL DAY, AND UNTIL +# THIS BLOCK NO test_*.py MENTIONED `describe()` AT ALL. `clamp.py:253` builds +# the whole §15 action-log row and the whole ClampWatchdog notification reason +# out of its own attributes: +# +# f"{REASON_CODE}: the computed setpoint {self.requested_w:+.0f} W was +# clamped to {self.applied_w:+.0f} W by the {self.bound} bound +# (rating {self.limit_w:.0f} W) - something upstream is winding" +# +# Transpose `requested_w` and `applied_w` and the guard reports clamping a +# setpoint UPWARD - here, to +9999 W by a bound whose rating the SAME SENTENCE +# gives as 2000 W. MEASURED on this tree: a zero-byte edit (22 507 bytes before, +# 22 507 after, sha256 0153133741497fa5 -> 47e4e371043e3201), the emitted +# sentence changes, and the full 21-file suite stayed GREEN. Nothing decides on +# the order, so nothing behavioural can ever see it - which is why the check +# below has to be on the ORDERED PAIR and on the CONTIGUOUS LITERAL. A substring +# test for either number passes under the swap; both numbers are still there. +# +# ⚠️ AND THE OPERANDS ARE READ OFF THE PLANT, NOT OFF `clamp_last`. `ctl.target` +# is what the loop asked for and `hass.writes[-1]` is what the device was +# actually sent; neither is a `ClampResult` field. Grading the sentence against +# `result.requested_w` would compare the swap against itself - DEV-02's defect, +# and the same one that made the first cut of this card's own render probe emit +# thirteen empty diffs. + + +async def _sentence(max_w, dev, wanted): + """One real write over `max_w`, then the guard, on a real Controller.""" + hass = _Hass(0.0) + c = Controller({"setpoint_entity": "number.sp", "meter_source": "off", + "max_w": max_w}, + hass, _Store(), _Pub(), stop=asyncio.Event()) + c.auto = True + c.dev_min, c.dev_max = -dev, dev + c.target = wanted + arm(c, wanted) + await c.write_setpoint(wanted) + await c.check_output_clamp() + return hass, c + + +hass_x, ctl_x = _sentence_run = asyncio.run(_sentence(2000.0, 5000.0, 9999.0)) +notes_x = clamp_notes(hass_x) +_asked_x = ctl_x.target # what the loop asked for +_sent_x = hass_x.writes[-1] # what the device was actually sent +_rating_x = ctl_x.inverters[0].max_w # the bound the sentence names +_rows_x = [e for e in ctl_x.events if REASON_CODE in e] +print(f" max_w {_rating_x:.0f} on a +-5000 W device, asked for " + f"{_asked_x:+.0f} W: the device saw {_sent_x:+.0f} W") +print(f" action log: {_rows_x[0] if _rows_x else '(none)'}") +check("PRECONDITION: the write really clamped on the MAX_W bound, and the two " + "operands are DIFFERENT NUMBERS that RENDER differently - equal ones " + "would make the transposed sentence identical and every check below " + "vacuous. The asked-for magnitude is also strictly ABOVE the rating " + "while the sent one is not, which is what makes the swapped sentence " + "self-contradictory rather than merely wrong", + ctl_x.clamp_last is not None and ctl_x.clamp_last.engaged + and ctl_x.clamp_last.bound == "max_w" + and _asked_x != _sent_x + and f"{_asked_x:+.0f}" != f"{_sent_x:+.0f}" + and abs(_sent_x) <= _rating_x < abs(_asked_x)) +check("⚠️ THE ORDERED PAIR IN THE §15 ACTION-LOG ROW (main.py:1366). The " + "contiguous literal names the setpoint the loop COMPUTED first and the " + "one that REACHED THE WIRE second. Under the transposition this row says " + "the loop asked for +2000 W and the guard clamped it UP to +9999 W - " + "past the 2000 W rating printed four words later - which is the alarm " + "reading backwards on the one line an operator is woken by", + any(f"the computed setpoint {_asked_x:+.0f} W was clamped to " + f"{_sent_x:+.0f} W" in e for e in _rows_x)) +check("⚠️ ...AND IN THE ClampWatchdog NOTIFICATION REASON (main.py:2452), " + "which is a SECOND surface off the same sentence and has to be pinned " + "separately - SAFETY-38 round 3's whole lesson was that pinning one call " + "site leaves the other shipping", + bool(notes_x) + and f"the computed setpoint {_asked_x:+.0f} W was clamped to " + f"{_sent_x:+.0f} W" in notes_x[0]["message"]) +check("...and the TRANSPOSED sentence appears on NEITHER surface, so the two " + "checks above discriminate rather than decorate", + not any(f"the computed setpoint {_sent_x:+.0f} W was clamped to " + f"{_asked_x:+.0f} W" in e for e in _rows_x) + and f"the computed setpoint {_sent_x:+.0f} W was clamped to " + f"{_asked_x:+.0f} W" not in notes_x[0]["message"]) +# The DEVICE bound, off part 6c's controller: same sentence, other stage. The +# unit of coverage is the call site, and `bound` is the only thing that differs +# between the two rows an operator can receive. +_asked_w, _sent_w = ctl_w.target, hass_w.writes[-1] +check("...and the same ordered pair holds on the DEVICE-bound row, where the " + "loop asked for +3000 W and the device took +2500 W. PRECONDITION: those " + "two also render differently", + f"{_asked_w:+.0f}" != f"{_sent_w:+.0f}" + and any(f"the computed setpoint {_asked_w:+.0f} W was clamped to " + f"{_sent_w:+.0f} W" in e for e in ctl_w.events) + and f"the computed setpoint {_asked_w:+.0f} W was clamped to " + f"{_sent_w:+.0f} W" in notes_w[0]["message"]) + + # ============================================================================== print() print("7. SAFETY-28: the write path is gated on the allocation record") diff --git a/goodwe_controller/test_peakguard.py b/goodwe_controller/test_peakguard.py index 51cb72a..380a4c0 100644 --- a/goodwe_controller/test_peakguard.py +++ b/goodwe_controller/test_peakguard.py @@ -5774,6 +5774,86 @@ check("...and so is the last one, which is not a defeat at all but the EDGE OF " not MUT[12][1] and EV["min_soc"] <= BMS_FLOOR + 0.05) +# ============================================================================== +print() +print("13. SAFETY-38 round 4: the ORDERED PAIR inside PeakDecision.explain()") + +# ⚠️ THE STRING "peak guard idle" APPEARED IN NO test_*.py UNTIL THIS PART. +# `peakguard.py:493` formats its OWN ATTRIBUTES and RETURNS the sentence: +# +# f"peak guard idle - quarter projects {self.projected_w:.0f} W under a " +# f"{self.ceiling_w:.0f} W ceiling ({self.ceiling_from})" +# +# Transpose the two and this fixture's row reads "projects 5013 W under a +# 2300 W ceiling" - a quarter projected 2713 W OVER the ceiling, printed on a +# row whose own words say the guard is IDLE and whose own flag says ok. +# MEASURED on this tree: zero-byte edit (50 032 bytes +# before, 50 032 after, sha256 d95df61a51716c26 -> 2d2ee3d0bcd51251), the +# emitted sentence changes, and the full 21-file suite stayed GREEN. +# +# The sentence was invisible to SAFETY-38's scanner for two rounds because both +# censuses scored one syntactic sink - APPEND, ASSIGN, LOG ARG - and this one is +# RETURNED. `tools/symmetric_message_scan.py` census C now scans the return sink +# and resolves it through the method's call sites; it rediscovered this pair and +# clamp.py's on its own, which is the only reason to trust the rest of its list. +# +# ⚠️ THE OPERANDS COME OFF THE PROJECTION AND OFF `quarter_ceiling_w()`, NOT OFF +# THE DECISION. `d.projected_w` and `d.ceiling_w` are the very fields the swap +# exchanges, so grading the sentence against them would compare the swap against +# itself and pass under it. +_S38_PROJ = PeakTracker.project(averager_at(CAP_W - 1200.0, 450.0), + B + timedelta(seconds=450)) +_S38_CEIL_W, _S38_CEIL_FROM = quarter_ceiling_w(CAP_W, obs_hi) +_S38_IDLE = decide(_S38_PROJ, batt_w=0.0, cap_w=CAP_W, reading=obs_hi, + achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S, + soc=SOC_OK, soc_floor=SOC_FLOOR) +_S38_LITERAL = (f"quarter projects {_S38_PROJ.projected_w:.0f} W under a " + f"{_S38_CEIL_W:.0f} W ceiling") +_S38_SWAPPED = (f"quarter projects {_S38_CEIL_W:.0f} W under a " + f"{_S38_PROJ.projected_w:.0f} W ceiling") +print(f" projection {_S38_PROJ.projected_w:.0f} W against a " + f"{_S38_CEIL_W:.0f} W ceiling ({_S38_CEIL_FROM})") +print(f" {_S38_IDLE.explain()!r}") +check("PRECONDITION: the guard is IDLE with a projection to print, the ceiling " + "came from the MONTH PEAK rather than the cap, and the two operands are " + "DIFFERENT NUMBERS that RENDER differently. Equal operands would make the " + "transposed sentence identical and every check below decoration", + not _S38_IDLE.engaged and _S38_IDLE.projected_w is not None + and _S38_CEIL_FROM == CEILING_MONTH_PEAK + and _S38_PROJ.projected_w != _S38_CEIL_W + and f"{_S38_PROJ.projected_w:.0f}" != f"{_S38_CEIL_W:.0f}" + and _S38_PROJ.projected_w < _S38_CEIL_W) +check("⚠️ THE ORDERED PAIR ON THE CONTIGUOUS LITERAL. The PROJECTION is named " + "first and the CEILING second; a substring test for either number passes " + "under the transposition because both numbers are still in the sentence, " + "so only the ordered pair can tell the two slots apart (MUTATION.md " + "habit 14)", _S38_LITERAL in _S38_IDLE.explain()) +check("...and the TRANSPOSED sentence is NOT what it says, so the check above " + "discriminates rather than decorates", + _S38_SWAPPED not in _S38_IDLE.explain()) +check("...and the row still names WHERE the ceiling came from, which is the " + "difference between a cap an operator set and a peak the month already " + "banked", f"ceiling ({CEILING_MONTH_PEAK})" in _S38_IDLE.explain()) + +# THE SHIPPED CONSUMER. `explain()` is not read by anybody until main.py prints +# it, and round 3's lesson is that the pin belongs on the CALL SITE: the status +# row at main.py:3313 is what a browser shows, and the action-log line at +# main.py:1951 is what the release edge writes. +_c38, _h38 = built(TMP / "s38pin.db") +_c38.peak_guard = _S38_IDLE +_rows38 = [r for r in _c38.checks() if "peak guard" in r["text"]] +print(" status row: " + (_rows38[0]["text"] if _rows38 else "(none)")) +check("⚠️ ...AND THE SAME ORDERED PAIR ON THE STATUS PAGE (main.py:3313), read " + "off the shipped `checks()` rather than off `explain()` a second time. " + "This is the surface the owner actually looks at", + len(_rows38) == 1 and _S38_LITERAL in _rows38[0]["text"] + and _S38_SWAPPED not in _rows38[0]["text"]) +check("...and the row is green and un-warned, which is what makes the " + "transposed version dangerous rather than merely untidy: an OK row " + "carrying a projection 2713 W OVER its own ceiling is the one an " + "operator scrolls past", _rows38[0]["ok"] and not _rows38[0]["warn"] + and _S38_CEIL_W - _S38_PROJ.projected_w == 2713.0) + # ============================================================================== print() shutil.rmtree(TMP, ignore_errors=True) diff --git a/goodwe_controller/tools/symmetric_message_scan.py b/goodwe_controller/tools/symmetric_message_scan.py index 2a22192..c6f5080 100644 --- a/goodwe_controller/tools/symmetric_message_scan.py +++ b/goodwe_controller/tools/symmetric_message_scan.py @@ -228,6 +228,20 @@ def _sink_of(fn, target, follow=True): up): return "MESSAGE" return "OTHER" + # ⚠️ ROUND 4. A SENTENCE HANDED TO A CONSTRUCTOR AS `reason=` IS A + # MESSAGE, and scoring only ASSIGNMENTS missed every one of them. This + # codebase builds most of its operator sentences that way - + # `ArbitrageDecision(action=..., reason=f"...")`, + # `out.append({"text": f"..."})` - so the sentence never touches a name + # this walker was watching. arbitrage.py's two DISCHARGE/CHARGE reasons + # were scored OTHER for exactly this and are genuine members. + if isinstance(up, ast.keyword) and up.arg in _MSG_ATTRS: + return "MESSAGE" + if isinstance(up, ast.Dict): + for k, v in zip(up.keys, up.values): + if (v is cur and isinstance(k, ast.Constant) + and k.value in _MSG_ATTRS): + return "MESSAGE" if isinstance(up, ast.Call): name = (up.func.attr if isinstance(up.func, ast.Attribute) else up.func.id if isinstance(up.func, ast.Name) else "") @@ -377,6 +391,7 @@ def scan(): # inventing members. + class _Blank(ast.NodeTransformer): def visit_Attribute(self, node): self.generic_visit(node) @@ -393,38 +408,196 @@ def _blanked(expr): return ast.dump(_Blank().visit(ast.parse(ast.unparse(expr)))) -def scan_caller(): - rows = [] +def _called_name(func): + return (func.attr if isinstance(func, ast.Attribute) + else func.id if isinstance(func, ast.Name) else "") + + +def _innermost(tree): + """{id(node): INNERMOST enclosing function}, or None at module level. + + ⚠️ NOT `for fn in ast.walk(tree): for node in ast.walk(fn)`, which is what + the first version of census B did. That visits every sentence once per + ENCLOSING def, so a sentence inside a nested function was classified twice - + once against the inner scope and once against the outer - and the two + classifications are not obliged to agree. The rows were de-duplicated + afterwards, which hid the double visit rather than removing it. + """ + m = {} + + def rec(node, fn): + for child in ast.iter_child_nodes(node): + f = (child if isinstance(child, (ast.FunctionDef, + ast.AsyncFunctionDef)) else fn) + m[id(child)] = f + rec(child, f) + + rec(tree, None) + return m + + +def _spec(fv): + """The FORMAT SPEC of one interpolation, as text; '' when there is none. + + ⚠️ IT IS PRINTED BESIDE EVERY PAIR BECAUSE IT IS THE ONE CHEAP TYPE SIGNAL + THIS SCAN HAS, and gap 4 of the second list below is exactly about the pairs + that do not share + one. `{self.requested_w:+.0f}` beside `{self.applied_w:+.0f}` is two floats + rendered identically - a transposition there is silent and reads as a fact. + `{self.limit_w:.0f}` beside `{self.bound}` is a float beside a string: a + transposition there prints `rating max_w W`, which is visibly broken rather + than quietly wrong. Same row in this census, very different hazard, and the + reader cannot tell them apart without this column. + """ + if fv.format_spec is None: + return "" + return "".join(str(v.value) if isinstance(v, ast.Constant) else "{...}" + for v in fv.format_spec.values) + + +def _same_shape_pairs(node): + """Pairs of interpolations in ONE f-string that are exactly one leaf apart.""" + fvs = [v for v in node.values if isinstance(v, ast.FormattedValue)] + for x, y in itertools.combinations(fvs, 2): + ua, ub = ast.unparse(x.value), ast.unparse(y.value) + if ua == ub: + continue + if _blanked(x.value) != _blanked(y.value): + continue + yield ua, ub, _spec(x), _spec(y) + + +def _sentences(): + """(path, innermost fn, JoinedStr, sink) for every f-string in app/.""" for path in sorted(APP.glob("*.py")): tree = ast.parse(path.read_text()) - for fn in ast.walk(tree): - if not isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)): + inner = _innermost(tree) + for node in ast.walk(tree): + if not isinstance(node, ast.JoinedStr): continue - for node in ast.walk(fn): - if not isinstance(node, ast.JoinedStr): - continue - # ⚠️ MESSAGE SINKS ONLY, AND THE FIRST VERSION OF THIS - # CENSUS SAID "anything but a RETURN". That let in - # `p1.py`'s `self.url = f"http://{self.host}:{self.port}…"` - - # an f-string that is DATA, used to make a request, where a - # transposition is an ordinary behavioural change every check - # can see. Same distinction as census A's RETURN bucket: a - # formatted string is only in this family when its only - # consumer is a person. - if _sink_of(fn, node) != "MESSAGE": - continue - exprs = [v.value for v in node.values - if isinstance(v, ast.FormattedValue)] - for a, b in itertools.combinations(exprs, 2): - ua, ub = ast.unparse(a), ast.unparse(b) - if ua == ub: - continue - if _blanked(a) == _blanked(b): - rows.append((path.name, node.lineno, fn.name, ua, ub)) - # de-duplicate: the same sentence reached through several enclosing defs + fn = inner.get(id(node)) + if fn is None: + continue + yield path, fn, node, _sink_of(fn, node) + + +def scan_caller(): + rows = [] + for path, fn, node, sink in _sentences(): + # ⚠️ MESSAGE SINKS ONLY, AND THE FIRST VERSION OF THIS CENSUS SAID + # "anything but a RETURN". That let in `p1.py`'s + # `self.url = f"http://{self.host}:{self.port}…"` - an f-string that is + # DATA, used to make a request, where a transposition is an ordinary + # behavioural change every check can see. A formatted string is only in + # this family when its only consumer is a person. RETURN is not OTHER + # though, and treating it as if it were is what census C below fixes. + if sink != "MESSAGE": + continue + for ua, ub, sa, sb in _same_shape_pairs(node): + rows.append((path.name, node.lineno, fn.name, ua, ub, sa, sb)) return sorted(set(rows), key=lambda r: (r[0], r[1], r[3])) +# ============================================================================= +# CENSUS C - THE SENTENCE THE OBJECT RETURNS ABOUT ITSELF +# ============================================================================= +# ⚠️ SAFETY-38 ROUND 4. CENSUS A AND CENSUS B BETWEEN THEM COVERED ONE +# SYNTACTIC SINK AND CALLED IT THE FAMILY. Every member either census reported +# was a sentence APPENDED, ASSIGNED to a `reason`-shaped attribute, or passed as +# a LOG ARGUMENT. Not one was a sentence that is RETURNED. Census A had a RETURN +# bucket, but only for a callee formatting its own PARAMETERS - so a method +# formatting its own ATTRIBUTES and handing the string back was invisible to +# both, and `describe()` / `explain()` / the reason-builders are precisely where +# this codebase keeps its operator sentences. Two live members were sitting in +# that hole: +# +# clamp.py ClampResult.describe() requested_w <-> applied_w +# peakguard.py PeakDecision.explain() projected_w <-> ceiling_w +# +# Worse than the gap: it was in NONE of the five blind spots this file prints +# about itself. A tool that asserts a completeness it does not have is the +# recurring defect in a new hat, and the list at the bottom now names its sinks. +# +# THE TEST is census B's - one f-string, two interpolations one leaf apart - run +# over the RETURN sink, plus one thing census B never needed: A RETURNED STRING +# IS NOT YET A MESSAGE. `store.unit_key()` and `mqtt.unit_state_key()` return +# f-strings too, and swapping there renames a store key and an MQTT topic, which +# every ordinary check can see (MAINT-05 is the card about that). So the sink of +# a returned sentence is RESOLVED THROUGH ITS CALL SITES: the string is in this +# family only when some caller in `app/` feeds it to an operator. + + +def _return_consumers(func_name, depth=0, seen=frozenset()): + """[(site, sink, verdict)] - where the STRING RETURNED BY `func_name` goes. + + One hop is not enough: `Resolution.explain()` is returned again by wrappers + that are themselves logged, so a call site whose own sink is RETURN is + resolved by recursing on the ENCLOSING function's name, bounded by `depth` + and by `seen` so mutual recursion cannot loop. + + ⚠️ THE MATCH IS ON THE BARE METHOD NAME, exactly as `call_sites()` above, + and `explain` is the name of at least four unrelated methods in this tree + (`PeakDecision`, `UnitPlan`, `Resolution`, the maintenance migration + report). So this OVER-collects sites, which pushes a verdict towards + MESSAGE. That is the deliberate direction for a census whose measured + failure has twice been under-reporting - but it means the verdict is a + PROMPT TO READ THE SITES PRINTED BESIDE IT, not a proof. + """ + if func_name in seen or depth > 2: + return [] + seen = seen | {func_name} + out = [] + for path in sorted(APP.glob("*.py")): + tree = ast.parse(path.read_text()) + inner = _innermost(tree) + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + if _called_name(node.func) != func_name: + continue + fn = inner.get(id(node)) + if fn is None: + continue + sink = _sink_of(fn, node) + label = f"{path.name}:{node.lineno} `{ast.unparse(node.func)}`" + if sink == "RETURN": + sub = _return_consumers(fn.name, depth + 1, seen) + if any(v == "MESSAGE" for _l, _s, v in sub): + verdict = "MESSAGE" + elif not sub: + verdict = "UNRESOLVED" + else: + verdict = "OTHER" + sink = f"RETURN by {fn.name}() -> {verdict}" + else: + verdict = sink + out.append((label, sink, verdict)) + return sorted(set(out)) + + +def scan_returned(): + """Census C rows: a returned sentence, two interpolations one leaf apart.""" + rows = [] + for path, fn, node, sink in _sentences(): + if sink != "RETURN": + continue + pairs = list(_same_shape_pairs(node)) + if not pairs: + continue + sites = _return_consumers(fn.name) + if any(v == "MESSAGE" for _l, _s, v in sites): + verdict = "MESSAGE" + elif not sites: + verdict = "UNRESOLVED - no call site inside app/" + else: + verdict = "OTHER" + for ua, ub, sa, sb in pairs: + rows.append(dict(module=path.name, line=node.lineno, func=fn.name, + a=ua, b=ub, spec_a=sa, spec_b=sb, + verdict=verdict, sites=sites)) + return sorted(rows, key=lambda r: (r["module"], r["line"], r["a"])) + + # ⚠️ THE UNIT OF COVERAGE IS THE CALL SITE, NOT THE FUNCTION - which is the # whole lesson of SAFETY-38 round 3. `SignWatchdog.evaluate()` had its operand # order pinned and the defect still shipped, because the pin was on the call two @@ -437,22 +610,61 @@ def call_sites(name): for node in ast.walk(tree): if not isinstance(node, ast.Call): continue - called = (node.func.attr if isinstance(node.func, ast.Attribute) - else node.func.id if isinstance(node.func, ast.Name) - else "") - if called == name: - # ⚠️ THE RECEIVER IS PRINTED, because matching on the bare - # attribute name conflates methods: `evaluate` is the name of - # SAFETY-02's, SAFETY-18's and SAFETY-31's watchdogs alike, and - # a list of four lines with no receiver tells a reader nothing - # about which detector they are looking at. `inv.sign_wd` and - # `self.freeze_wd` are the disambiguation, and they are the - # caller's own words. - out.append(f"{path.name}:{node.lineno} " - f"`{ast.unparse(node.func)}`") + if _called_name(node.func) != name: + continue + # ⚠️ THE RECEIVER IS PRINTED, because matching on the bare + # attribute name conflates methods: `evaluate` is the name of + # SAFETY-02's, SAFETY-18's and SAFETY-31's watchdogs alike, and + # a list of four lines with no receiver tells a reader nothing + # about which detector they are looking at. `inv.sign_wd` and + # `self.freeze_wd` are the disambiguation, and they are the + # caller's own words. + out.append(f"{path.name}:{node.lineno} `{ast.unparse(node.func)}`") return out +def lazy_log_sites(): + """Log calls with a %-format template and >=2 substitution ARGUMENTS. + + ⚠️ THE SIZE OF THE LARGEST HOLE, MEASURED RATHER THAN ASSERTED. The + NOT-SCANNED list below used to end "and app/ uses that form", which is a + claim about the tree with no number on it - the exact shape this card has + spent four rounds arguing against. `_LOG.error("%s ... %s", a, b)` puts both + operands in ARGUMENTS, so no f-string census here can ever reach them, and + transposing the two costs the same zero bytes. This counts them. + """ + out = [] + for path in sorted(APP.glob("*.py")): + tree = ast.parse(path.read_text()) + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + if _called_name(node.func) not in _LOG_SINKS or len(node.args) < 3: + continue + head = node.args[0] + tmpl = "" + if isinstance(head, ast.Constant) and isinstance(head.value, str): + tmpl = head.value + elif isinstance(head, ast.BinOp) or isinstance(head, ast.JoinedStr): + tmpl = ast.unparse(head) + if tmpl.count("%") >= 2: + out.append((path.name, node.lineno, + f"{path.name}:{node.lineno} " + f"`{ast.unparse(node.func)}` with " + f"{len(node.args) - 1} args")) + return [r[2] for r in sorted(out)] + + +def _pair_line(sa, sb): + same = sa == sb + return (f" format spec: '{sa}' / '{sb}' -> " + + ("SAME - two values rendered alike, so a transposition is " + "SILENT and reads as a fact" + if same else + "DIFFERENT - likely mixed types, so a transposition is more " + "likely to look broken than to lie (gap 4 below)")) + + def main(): quiet = "--quiet" in sys.argv rounds = build_fixpoint() @@ -515,50 +727,151 @@ def main(): "ORDERED PAIR.") cb = scan_caller() print(f"candidates: {len(cb)}") - for mod, line, fn, ua, ub in cb: + for mod, line, fn, ua, ub, sa, sb in cb: print(f" {mod}:{line} in {fn}()") print(f" {ua} <-> {ub}") + print(_pair_line(sa, sb)) print() - print("WHAT THIS CANNOT SEE - the shape of each gap, named rather than " - "rounded off:") + print("=" * 78) + print("CENSUS C - SENTENCES AN OBJECT RETURNS ABOUT ITSELF (SAFETY-38 " + "round 4)") + print("A method formatting its OWN ATTRIBUTES and RETURNING the string was " + "in neither") + print("census above: census A only reads a callee's PARAMETERS, census B " + "only scored a") + print("MESSAGE sink. The verdict below is resolved through the CALL SITES " + "of the") + print("returning method - a returned string is only in this family once " + "some caller") + print("hands it to an operator (contrast store.unit_key(), whose return " + "value is a KEY).") + cc = scan_returned() + fam_c = [r for r in cc if r["verdict"] == "MESSAGE"] + hot = [r for r in fam_c if r["spec_a"] == r["spec_b"]] + print(f"returned sentences with a same-shape pair: {len(cc)} pair(s); " + f"{len(fam_c)} reach an operator;") + print(f"of those, {len(hot)} ALSO share a format spec - two values " + f"rendered alike, which is") + print("the hazard gap 4 below names: a transposition there is silent and " + "reads as a fact.") + print() + print("*** = reaches an operator AND shares a format spec - THE ACTIONABLE " + "SUBSET") + print(" ! = reaches an operator, specs differ (a swap is likelier to look " + "broken)") + print(" = does not reach an operator, or could not be resolved") + # grouped by the sentence, so the CONSUMERS list is printed once per + # method rather than once per pair - the ungrouped form was 20 identical + # lines under each of six pairs and nobody reads that. + for key in sorted({(r["module"], r["line"], r["func"]) for r in cc}): + mod, line, fname = key + group = [r for r in cc if (r["module"], r["line"], r["func"]) == key] + v = group[0]["verdict"] + print(f" {mod}:{line} {fname}() verdict: {v}") + for r in group: + mark = ("***" if r["verdict"] == "MESSAGE" + and r["spec_a"] == r["spec_b"] + else " ! " if r["verdict"] == "MESSAGE" else " ") + print(f" {mark} {r['a']} <-> {r['b']}") + print(f" {_pair_line(r['spec_a'], r['spec_b']).strip()}") + if not quiet: + sites = group[0]["sites"] + print(f" CONSUMERS ({len(sites)}) - bare-name match, so read " + f"the receiver:") + for label, sink, _v in sites or [("(none inside app/)", "", "")]: + print(f" {label} -> {sink}") + print() + print("=" * 78) + print("WHAT THIS SCANS, AND WHAT IT DOES NOT - stated because round 4's " + "blocker was") + print("a completeness this file ASSERTED and did not have. The sink " + "classifier is a") + print("syntactic walk up the parent chain plus ONE step of local data " + "flow, and it") + print("recognises exactly these sinks and no others:") + print(f" SCANNED as a MESSAGE : a call to {sorted(_LOG_SINKS)}") + print(" ...or to `append`; a `raise`; an " + "assignment to an") + print(f" attribute or local named {sorted(_MSG_ATTRS)}") + print(" ...or to a local that is then interpolated " + "into one of") + print(" those (the one data-flow step).") + print(" SCANNED as a RETURN : census C - resolved through the returning " + "method's") + print(" call sites, one hop plus two of recursion.") + print(" NOT SCANNED AT ALL, and each of these can hold a member:") + lazy = lazy_log_sites() + print(" * `%s`-style lazy logging - `_LOG.error('%s ... %s', a, b)`. The " + "arguments") + print(" are not an f-string, so no census here looks at them, and a " + "transposition") + print(" of two `%s` arguments is the same zero-byte edit. THIS IS THE " + "LARGEST") + print(f" REMAINING HOLE, and it is MEASURED, not asserted: " + f"{len(lazy)} such call(s)") + print(f" in app/, across " + f"{len({s.split(':')[0] for s in lazy})} module(s). None of them is " + f"scanned by anything") + print(" above. Listed here so the size of the hole is a number:") + for site in lazy or ["(none)"]: + print(f" {site}") + print(" * `.format()`, `%`-formatting, and sentences built by `+` or " + "`.join()` out of") + print(" non-f-string pieces.") + print(" * A sentence that leaves the function inside a DICT, LIST or " + "dataclass and is") + print(" rendered somewhere else - the one data-flow step is local and " + "follows only") + print(" a bare Name.") + print(" * `yield`ed sentences, and sentences stored on an object by any " + "attribute") + print(f" name outside {sorted(_MSG_ATTRS)}.") + print(" * Anything outside `app/`: `sim/`, `tools/`, `web.py`'s " + "templates, the") + print(" translations JSON, the firmware, the witness.") + print() + print("WHAT IT CAN SEE BUT GETS WRONG - the shape of each gap, named " + "rather than") + print("rounded off:") print(" 1. SEMANTIC symmetry no canonicaliser above spells. The list of " "commutative") print(" forms is finite and hand-written; a function symmetric only " "through a form") print(" not on it is REJECTED and never appears. This under-reports.") - print(" 2. In CENSUS B, two interpolated values of the same KIND that " - "are not the same") - print(" SHAPE - `a.lo` beside `hi`, or one of them behind a local " - "variable. Only") - print(" one-leaf-apart expressions are paired. `%s`-style logging " - "(`_LOG.error('%s") - print(" ... %s', a, b)`) and non-f-string payloads are not scanned at " - "all.") - print(" 3. Anything outside `app/` - `sim/`, `tools/`, the firmware, the " - "witness.") - print(" 4. WHETHER each call site is pinned. The sites are listed above; " + print(" 2. In CENSUS B and C, two interpolated values of the same KIND " + "that are not the") + print(" same SHAPE - `a.lo` beside `hi`, or one of them behind a local " + "variable.") + print(" Only one-leaf-apart expressions are paired.") + print(" 3. WHETHER each call site is pinned. The sites are listed above; " "whether a") print(" check asserts the ORDERED PAIR at each one is a fact about " "`test_*.py` and is") print(" recorded in DECISIONS/SAFETY-38-site-standing-pin.md section " "7. Call sites") - print(" outside `app/` are not listed either, and the site list matches " - "on the bare") - print(" METHOD NAME - three different watchdogs are all called " - "`evaluate`, so the") - print(" receiver is printed beside each one and a reader has to pick.") - print(" 5. ⚠️ IT CAN ALSO OVER-REPORT, IN ONE EXACT WAY. The sentence is " + print(" outside `app/` are not listed either, and every site list here " + "matches on the") + print(" bare METHOD NAME - three watchdogs are all called `evaluate` " + "and at least") + print(" four unrelated classes have an `explain()` - so the receiver is " + "printed") + print(" beside each one and a reader has to pick.") + print(" 4. ⚠️ IT CAN ALSO OVER-REPORT, IN ONE EXACT WAY. The sentence is " "ERASED before") - print(" the comparison, so a swap that would make the FORMATTING " - "ITSELF fail - a") - print(" `DAY_NAMES[start]` where `start` is 1320 - is scored as " - "'changes no decision'.") - print(" `_raise_gap`'s (start, weekday) and (end, weekday) pairs are " - "that: mixed") - print(" types, and a real transposition there raises IndexError rather " - "than lying") - print(" quietly. The hazard is a SAME-TYPED pair; read the list with " - "that in hand.") + print(" census A's comparison, so a swap that would make the FORMATTING " + "ITSELF fail") + print(" - a `DAY_NAMES[start]` where `start` is 1320 - is scored as " + "'changes no") + print(" decision'. `_raise_gap`'s (start, weekday) pairs are that. The " + "hazard is a") + print(" SAME-TYPED pair, which is why every census B and C row now " + "prints whether") + print(" the two format specs match.") + print(" 5. CENSUS C's verdict is a bare-name resolution (gap 3) and " + "therefore reads") + print(" MESSAGE too readily. Every verdict is printed with the sites " + "that produced") + print(" it; a MESSAGE verdict is a prompt to read them, not a proof.") if __name__ == "__main__": -- 2.54.0 From d5b9317d5792f0fd651a1f0838aa69b47095be60 Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Sat, 5 Sep 2026 23:10:30 +0200 Subject: [PATCH 6/6] SAFETY-38: correct the survivor count, which was stated four ways MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found the summary lines miscounted, and one of the miscounts filed a KILL among the survivors. The authoritative tables in §7e and §7f were right throughout; only the sentences a reader actually relies on were wrong. Arithmetic, from §7f's own ledger: 14 measured = 10 surviving + 1 already caught (C3 allocator.py:162, red in test_alloc.py) + 3 caught by round 4 (C6 clamp.py:253, C7 peakguard.py:493, B12 arbiter.py:264) Survivors by file: TWO in allocator.py (C1 :154, C2 :160), three in arbiter.py (C4a/C4b :344, C5 :392), two in tariff.py (C8, C9), two in arbitrage.py (B17, B18), one in main.py (B19). Two, not three, in allocator.py - C3 is the kill. Before: DECISIONS §6c said "Nine" while listing eleven; the CHANGELOG said "11 SURVIVORS" while listing eleven. Both counted C3 as a survivor. ⚠️ KEPT VISIBLE RATHER THAN QUIETLY FIXED. This is this card's own recurring defect in prose rather than in code - a claim wider than the measurement beneath it - and it is the second false statement this ticket has put into its own decision record. The correction says so at the line, because a document whose whole value is that it can be trusted should record when it could not be. No app/ change; no test change. Full suite: 21 passed, 0 failed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU --- goodwe_controller/CHANGELOG.md | 8 +++++--- .../DECISIONS/SAFETY-38-site-standing-pin.md | 18 ++++++++++++++---- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index 1ec6f33..8412c33 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -266,12 +266,14 @@ shipped `sum` is correct and must not be "fixed".** it does not belong on"* and asserted `"peak" in scope_errors[0]`, which is still true after the two swap roles. Measured this round: **0 bytes, 21/21 green**; now red on 2 checks, pinned on the ordered pair. -* **14 more zero-byte mutations measured, and 11 SURVIVORS ARE DISCLOSED RATHER +* **14 more zero-byte mutations measured, and 10 SURVIVORS ARE DISCLOSED RATHER THAN CLOSED**, each with its file fingerprint and its 21/21 verdict, in - `DECISIONS/SAFETY-38-site-standing-pin.md` §7e–§7g: three in + `DECISIONS/SAFETY-38-site-standing-pin.md` §7e–§7g: **two** in `allocator.py`'s `UnitPlan.explain()`, three across `arbiter.py`'s two `explain()`s, two in `tariff.py`, two in `arbitrage.py`'s trade reasons and - one in `main.py`'s SoC-floor notification. **An enumerated list with honest + one in `main.py`'s SoC-floor notification. The other four of the fourteen are + kills: `allocator.py:162` was already caught by `test_alloc.py`, and + `clamp.py:253`, `peakguard.py:493` and `arbiter.py:264` are closed here. **An enumerated list with honest gaps beats a fifth round finding a fifth instance.** ⚠️ **And the probe that grades those mutations was itself the defect, once.** Its first cut numbered the operands **by order of appearance**, so after a diff --git a/goodwe_controller/DECISIONS/SAFETY-38-site-standing-pin.md b/goodwe_controller/DECISIONS/SAFETY-38-site-standing-pin.md index c86a774..2010492 100644 --- a/goodwe_controller/DECISIONS/SAFETY-38-site-standing-pin.md +++ b/goodwe_controller/DECISIONS/SAFETY-38-site-standing-pin.md @@ -640,11 +640,21 @@ below, unpinned. **Open, named, and measured — see §7f for the ledger:** -* **Nine further zero-byte survivors** found by the widened scan, each measured - at 21/21 green on this tree: three in `allocator.py`'s `UnitPlan.explain()`, - three in `arbiter.py`'s two `explain()`s, two in `tariff.py`, and one each in - `arbitrage.py`'s two decision reasons and `main.py`'s SoC-floor notification. +* **TEN further zero-byte survivors** found by the widened scan, each measured + at 21/21 green on this tree: **two** in `allocator.py`'s `UnitPlan.explain()` + (C1 :154, C2 :160), three in `arbiter.py`'s two `explain()`s (C4a, C4b :344, + C5 :392), two in `tariff.py` (C8, C9), two in `arbitrage.py`'s trade reasons + (B17, B18) and one in `main.py`'s SoC-floor notification (B19). None is a control decision; every one is a sentence an operator reads. + ⚠️ **CORRECTED after review, and the error is worth keeping visible.** This + bullet first said "Nine" while listing eleven, and the CHANGELOG said eleven + while listing eleven — and BOTH counted `allocator.py:162` (C3) among the + survivors when §7f records it as a KILL, red in `test_alloc.py`. The + authoritative tables in §7e and §7f were right throughout; only the summary + sentences a reader actually relies on were wrong. That is this card's own + recurring defect in prose rather than in code: a claim wider than the + measurement beneath it. Arithmetic, from §7f: 14 measured = 10 surviving + + 1 already caught (C3) + 3 caught by this round (C6, C7, B12). * **The action-log surface of the peak-guard idle line** (`main.py:1951`) is not pinned. The status row (3313) is, and both come off the same `explain()`, so the *sentence* is closed and the *second call site* is not — which is -- 2.54.0