diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index 51f0ca6..8412c33 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -79,6 +79,219 @@ 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. +* **⚠️ 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 + 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. +* **⚠️ 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 + 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. +* **⭐ 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 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: **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. 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 + 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 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..2010492 --- /dev/null +++ b/goodwe_controller/DECISIONS/SAFETY-38-site-standing-pin.md @@ -0,0 +1,925 @@ +# 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 in round 2 against `release/1.0` @ `86eaef2`: + +| mutation of `site_standing` | on release/1.0 @ 86eaef2 | closed by | +|---|---|---| +| `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) | + +### ⚠️ 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.* + +`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. + +`[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 +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 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** — 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** | +| 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** | +| **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 `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. +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 +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. + +⚠️ **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 +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. + +### 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 +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. + +### 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. + ⚠️ **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–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:** + +* **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 + 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 +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** 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? | +|---|---|---|---|---| +| **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 | **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 + +* **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). + +### 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 c95bbed..226a792 100644 --- a/goodwe_controller/MUTATION.md +++ b/goodwe_controller/MUTATION.md @@ -135,12 +135,24 @@ 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. 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 -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, 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 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 @@ -234,6 +246,65 @@ 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 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 + 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. + + ⚠️ **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"`. + + ⚠️ **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 can see it, because the code under test really did behave identically on the @@ -363,6 +434,118 @@ 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 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 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 per-unit claim defect (`device`/`supervised` filed without `inv`), which 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/test_watchdog.py b/goodwe_controller/test_watchdog.py index af55acc..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", @@ -1993,6 +2083,564 @@ 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, 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 +# 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 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 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 + 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)}") diff --git a/goodwe_controller/tools/symmetric_message_scan.py b/goodwe_controller/tools/symmetric_message_scan.py new file mode 100644 index 0000000..c6f5080 --- /dev/null +++ b/goodwe_controller/tools/symmetric_message_scan.py @@ -0,0 +1,878 @@ +#!/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" + # ⚠️ 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 "") + 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 _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()) + inner = _innermost(tree) + for node in ast.walk(tree): + if not isinstance(node, ast.JoinedStr): + continue + 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 +# 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 + 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() + 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, sa, sb in cb: + print(f" {mod}:{line} in {fn}()") + print(f" {ua} <-> {ub}") + print(_pair_line(sa, sb)) + print() + 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 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 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(" 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__": + main()