From 97b59438dcf903b554dc2c012f3bf05225a23aa9 Mon Sep 17 00:00:00 2001 From: gschrooyen Date: Sat, 5 Sep 2026 20:33:20 +0200 Subject: [PATCH] SAFETY-38 round 3: pin the banner the SHIPPED install actually prints Round 2 pinned the operand order of `SignWatchdog.evaluate()` at main.py:1620 - the SITE detector. Part 9c asserts that detector is never armed at a roster of one, so on every install this add-on has, the PER-UNIT banner is the only sign-disagreement sentence that can ever be printed, and its call site is a different line. main.py:1560 was left unpinned. 1. THE SAME ZERO-BYTE DEFECT ONE CALL SITE OVER. `evaluate(stood, against, ...)` -> `evaluate(against, stood, ...)`: app/main.py 234 542 bytes before and after, delta 0, fingerprint 61aa413f... -> e57e6026..., bytecode purged, tree mounted :ro, line re-read from disk. On the round-2 tree the FULL 21-FILE SUITE PASSES. Nothing behavioural could ever have caught it. `watchdog.disagrees()` is SYMMETRIC in its first two arguments - `commanded_w * against_w < 0` with an abs() gate on both - so the mutant takes identical actions on every input. Not "no fixture happens to separate them": no fixture CAN. What moves is the sentence. Measured by diffing the suite's own printed banners between the round-3 tree and the same tree carrying only this edit: 9 emitted banner lines invert, in 3 distinct sentences. shipped commanded -2000 W against a meter reading +2000 W swapped commanded +2000 W against a meter reading -2000 W shipped commanded +1000 W against its own measured power -1000 W swapped commanded -1000 W against its own measured power +1000 W shipped commanded -2000 W against a meter reading +4000 W (the N=1 arm) swapped commanded +4000 W against a meter reading -2000 W Persisted to `sign_reason` and re-read after the restart the latch exists to survive; also the action log, `_LOG.error` and `status_payload()`'s headline. CLOSED by the ORDERED PAIR on both per-unit banners, both operands read off the OBSERVED CYCLE - `inv.last_write` and `inv.batt` off the objects the plant moved, never off the arguments the detector was handed, which would compare the swap against itself: - part 3, the N>=2 trip: "commanded {stood:+.0f} W against its own measured power {meas:+.0f} W", plus the persisted namespaced copy; - part 7, the N=1 controller-driven trip: "commanded {stood:+.0f} W against a meter reading {grid:+.0f} W", plus the namespaced key AND the flat rollback mirror; - a PRECONDITION on each that the two operands are different numbers of opposite sign - without it a transposition produces the same string and the check is decoration. Measured after the fix: RED on four named checks in test_watchdog.py, GREEN in the other twenty files. M11 (the site swap) re-measured on this tree: still red. Round 1's and round 2's kills are intact. 2. A FALSE STATEMENT ROUND 2 INTRODUCED, AND IT WAS WORSE THAN REPORTED. Section 4 said "three of these left the whole suite green - M1, M2 and M3 - and the remaining seven were caught by SAFETY-30's own N>=2 checks". Round 1's vaguer wording was harmless; naming three made it specific and wrong. Re-measured mutation by mutation on clean release/1.0 @ bbc903b, each on a fresh copy, bytecode purged, byte delta and fingerprint printed, FULL 21-FILE SUITE each time: M1 +24 B fp 362374cf... 21/21 PASS - SURVIVOR M2 +9 B fp 86573e74... 21/21 PASS - SURVIVOR M3 +4 B fp 59fa8815... 21/21 PASS - SURVIVOR M4 +9 B fp 43d852be... 21/21 PASS - SURVIVOR M5 +16 B fp 58d26de8... RED test_watchdog.py, 3 checks M6 +1 B fp 407ddb5f... RED test_watchdog.py, 3 checks M7 -11 B fp 2a635a4e... 21/21 PASS - SURVIVOR M8 +6 B fp b21eed89... 21/21 PASS - SURVIVOR M9 +6 B fp 0973d3ca... 21/21 PASS - SURVIVOR M10 +7 B fp 0e7b7a4f... 21/21 PASS - SURVIVOR EIGHT survived, not three - and not the four a reviewer proposed either. The corrected sentence is in DECISIONS section 4. AND THE TWO THAT WERE CAUGHT MAKE THE CARD'S CASE. M5 (`sum(abs ...)`) and M6 (`-sum`) are the only two entries that reverse the SIGN. Every other one changes only the MAGNITUDE, and `_sign_site` - the suite's only N>=2 site fixture before this card - runs shares of about -2500 W each against a 320 W gate, so sum / mean / max / min / sorted[-1] / [0] / self.target / half / double land on -2500, -2500, -2500, -2500, -2500, -2500, -2500, -1250 and -5000 W: nine numbers, ALL ON THE SAME SIDE OF THE SAME THRESHOLD. The pre-existing coverage could see a sign and nothing else. 3. THEN THE THING THAT WOULD HAVE CAUGHT BOTH: the SYMMETRIC-PREDICATE FAMILY, enumerated by machine rather than by hand for a third time. `tools/symmetric_message_scan.py` renames each parameter pair, erases every formatted string, canonicalises the commutative operators and iterates to a FIXPOINT - the symmetry is usually one call deep, and a single pass reports an EMPTY family on a tree whose answer is already known. A second census covers sentences the CALLER builds. Both print their own blind spots, including the one way the scan OVER-reports. It found one open survivor this card does not close and now discloses: rail_unreachable()'s dev_min/dev_max at main.py:3208 - 0 bytes, 21/21 green, a status-page NOT COVERED row - and twelve unpinned caller-built sentences, including SAFETY-31's `circ_reason`, which is the same shape and is also persisted and re-read after a restart. DECISIONS section 7 has the list, the pinned status of every member, and what the instrument cannot see. No app/ code changes. MUTATION.md gains habit 15. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU --- goodwe_controller/CHANGELOG.md | 50 ++ .../DECISIONS/SAFETY-38-site-standing-pin.md | 319 +++++++++- goodwe_controller/MUTATION.md | 70 ++- goodwe_controller/test_watchdog.py | 96 ++- .../tools/symmetric_message_scan.py | 565 ++++++++++++++++++ 5 files changed, 1092 insertions(+), 8 deletions(-) create mode 100644 goodwe_controller/tools/symmetric_message_scan.py diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index 792bd35..8ea5bfe 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -111,6 +111,56 @@ shipped `sum` is correct and must not be "fixed".** unit's share and never the site target. A site asking for −500 W while −250 W stands would then take a **latching site-wide false trip** under its own 300 W gate. Closed by part 9a's device-clamped counterfactual. +* **⚠️ AND THE SAME ZERO-BYTE DEFECT ONE CALL SITE OVER — THE ONE A SHIPPED + INSTALL CAN ACTUALLY SEE.** The ordered pair went onto `main.py:1620`, the + **site** detector. That detector is armed only at N≥2, and part 9c asserts it + is **absent at a roster of one** — so on every install this add-on has, the + **per-unit** banner is the only sign-disagreement sentence that can ever be + printed, and its call at **`main.py:1560`** was still unpinned. The identical + swap there is **0 bytes** and left the tree **21/21 green**, and nothing + behavioural could ever have caught it: `disagrees()` is **symmetric** in those + two arguments (`commanded_w * against_w < 0`, `abs()` gate on both), so the + mutant takes **identical actions on every input**. What moves is the sentence + — measured, **9 emitted banner lines invert in 3 distinct sentences**, + including the one persisted to `sign_reason` and read back after a restart: + *"commanded +1000 W against its own measured power −1000 W"* becomes + *"commanded −1000 W against its own measured power +1000 W"*. Closed by the + ordered pair on **both** per-unit banners — the N≥2 `its own measured power` + one and the **N=1** `a meter reading` one — with both operands read off the + **observed cycle** rather than off the arguments the detector was handed, plus + a precondition on each that the two operands really are different and opposite + in sign. **Red on four named checks, green in the other twenty files. No + `app/` change.** +* **⭐ AND THE FAMILY IS NOW ENUMERATED BY MACHINE, because it had been + enumerated by hand twice and was short both times.** + `tools/symmetric_message_scan.py` finds every call whose argument order is + load-bearing for a **message** rather than for a decision: it renames each + parameter pair, erases every formatted string, canonicalises the commutative + operators and iterates to a **fixpoint** (the symmetry is usually one call + deep — `evaluate()` is symmetric only *through* `disagrees()`), then + classifies each sentence by where it goes. A second census covers sentences + the **caller** builds. It found **one open survivor this card does not close + and now discloses** — `rail_unreachable()`'s `dev_min`/`dev_max` at + `main.py:3208`, **0 bytes, 21/21 green**, a status-page row — and **twelve** + unpinned caller-built sentences including SAFETY-31's `circ_reason`, which is + the same shape and is also persisted. The list, the pinned status of every + member and the instrument's own blind spots are in + `DECISIONS/SAFETY-38-site-standing-pin.md` §7. +* **⚠️ AND A LEDGER SENTENCE THIS CARD ITSELF GOT WRONG, CORRECTED WITH THE + MEASUREMENT.** An earlier revision said *"before part 9 existed, **three** of + these left the whole suite green — M1, M2 and M3 — and the remaining seven + were caught by SAFETY-30's own N≥2 checks"*. **Eight** did. Re-run one at a + time on clean `release/1.0` @ `bbc903b`, fresh copy each, bytecode purged, + byte delta and `main.py` fingerprint printed, **full 21-file suite** every + time: M1, M2, M3, M4, M7, M8, M9 and M10 are all **21/21 PASS**; only M5 and + M6 go red. It is the same species of error this round exists to fix — **a + survivor recorded as a kill** — and the file contradicted itself three times + while saying it. **And the two that ARE caught make the card's case:** M5 + (`sum(abs …)`) and M6 (`-sum`) are the only two that reverse the **sign**. + Every other entry changes only the **magnitude**, and the old `_sign_site` + fixture's ~−2500 W shares put nine different magnitudes on the **same side of + the same 320 W gate**. The pre-existing coverage could see a sign and nothing + else. * **13 mutations — 11 of that line and its call site, plus 2 zero-byte edits to the arbiter claim wiring — each on a fresh full-repo copy mounted `:ro`, bytecode purged by `tools/mutation_run.sh`, the edit re-read from disk and the diff --git a/goodwe_controller/DECISIONS/SAFETY-38-site-standing-pin.md b/goodwe_controller/DECISIONS/SAFETY-38-site-standing-pin.md index 16d6672..d7de3de 100644 --- a/goodwe_controller/DECISIONS/SAFETY-38-site-standing-pin.md +++ b/goodwe_controller/DECISIONS/SAFETY-38-site-standing-pin.md @@ -231,15 +231,70 @@ fingerprint, and verifies no cache appears during the run). The mutated line was | M8 | `sum(standing.values()) / 2.0` | +6 | **RED** | | M9 | `sum(standing.values()) * 2.0` | +6 | **RED** | | M10 | `sorted(standing.values())[-1]` — largest signed | +7 | **RED** | -| **M11** | **argument SWAP** at the call site: `evaluate(site_standing, self.grid, …)` → `evaluate(self.grid, site_standing, …)` (main.py:1620) | **0** | **was a SURVIVOR in round 1 — now RED**, see §4a | +| **M11** | **argument SWAP** at the SITE call site: `evaluate(site_standing, self.grid, …)` → `evaluate(self.grid, site_standing, …)` (main.py:1620) | **0** | **was a SURVIVOR in round 1 — now RED**, see §4a | +| **M12** | **argument SWAP** at the PER-UNIT call site: `evaluate(stood, against, …)` → `evaluate(against, stood, …)` (**main.py:1560**) | **0** | **was a SURVIVOR in round 2 — now RED**, see §4c | **Every one of the ten is red in `test_watchdog.py`.** Re-measured in round 2 on `release/1.0` @ `86eaef2` (where `app/main.py` is 234 542 bytes, 2 022 more than the `f72c48d` the round-1 figures were taken on): all ten still red, at the byte deltas in the table, each on a fresh copy with the edit re-read from disk. -Before part 9 existed, **three** of these left the whole suite green — M1, M2 -**and M3** — and the remaining seven were caught by SAFETY-30's own N≥2 checks, -none of which grade the *quantity*. +Adding assertions cannot turn a red green, so those verdicts carry forward to +the round-3 tree unchanged. + +### ⚠️ ROUND 2 SAID THREE OF THESE SURVIVED. **EIGHT DID.** + +Round 2 wrote: *"Before part 9 existed, three of these left the whole suite +green — M1, M2 and M3 — and the remaining seven were caught by SAFETY-30's own +N≥2 checks."* Round 1's wording was vaguer and harmless; naming three made it +**specific and wrong**, and it is the same species of error the round was +written to fix — **a survivor recorded as a kill**. This file contradicted it +three times on its own pages: the ledger row calls M7 *"a SURVIVOR — now RED"*, +§5 says M7 survived the whole suite, and `CHANGELOG.md` says so too. + +**Re-measured in round 3, one mutation at a time, on clean `release/1.0` @ +`bbc903b`** — a fresh copy each, bytecode purged (`0` remaining, printed), the +line re-read from disk, byte delta **and** `app/main.py` fingerprint printed, +mounted `:ro`, and the **FULL 21-FILE SUITE** run each time rather than +`test_watchdog.py` alone: + +| # | Δ bytes | fingerprint of `app/main.py` | suite before part 9 | +|---|---|---|---| +| M1 | +24 | `362374cfb6486378` | **21/21 PASS — SURVIVOR** | +| M2 | +9 | `86573e7494f1c933` | **21/21 PASS — SURVIVOR** | +| M3 | +4 | `59fa8815e6fda4cf` | **21/21 PASS — SURVIVOR** | +| M4 | +9 | `43d852be3037f861` | **21/21 PASS — SURVIVOR** | +| M5 | +16 | `58d26de82af29a1a` | **RED** — `test_watchdog.py`, 3 checks | +| M6 | +1 | `407ddb5f375cc217` | **RED** — `test_watchdog.py`, 3 checks | +| M7 | **−11** | `2a635a4eca0a790d` | **21/21 PASS — SURVIVOR** | +| M8 | +6 | `b21eed89ff4c9240` | **21/21 PASS — SURVIVOR** | +| M9 | +6 | `0973d3caf680db65` | **21/21 PASS — SURVIVOR** | +| M10 | +7 | `0e7b7a4fed7787d0` | **21/21 PASS — SURVIVOR** | + +**So: EIGHT of the ten left the whole suite green, and TWO were caught** — not +three and seven, and not the four and six a reviewer proposed either. The +correct sentence is: *before part 9 existed, eight of these ten left the whole +21-file suite green, and the only two SAFETY-30's own N≥2 checks caught were M5 +and M6.* + +**⚠️ AND THE TWO THAT WERE CAUGHT ARE CAUGHT FOR A REASON THAT MAKES THE CARD'S +CASE RATHER THAN WEAKENING IT.** M5 (`sum(abs(v) …)`) and M6 (`-sum(…)`) are +the only two entries in the table that **reverse the SIGN** of `site_standing`. +Every other entry changes only its **magnitude** — and `_sign_site`, the suite's +only N≥2 site fixture before this card, runs shares of about **−2500 W each** +against a **320 W** gate. `sum`, the mean, `max(key=abs)`, `min(key=abs)`, +`sorted(...)[-1]`, `[0]`, `self.target`, half the sum and twice the sum all land +on **−2500, −2500, −2500, −2500, −2500, −2500, −2500, −1250 and −5000 W**: nine +different numbers, all on the **same side of the same threshold**. The three +checks that fired on M5 and M6 grade *"the site trips and no unit does"*, the +claim it trips through, and where the latch is written — none of them grades the +**quantity**, and the sign flip reaches them only because it stops the trip +happening at all. + +That is the whole thesis of this card measured rather than argued: **the +pre-existing coverage could see a sign, and nothing else.** Part 9's fixture — +per-unit shares each **below** the gate whose **sum** clears it — is the only +arrangement in which the magnitude is load-bearing, and it is what takes the +other eight red. ⚠️ **ONE FALSE RED, AND IT IS [[CI-06]]'s.** In the batch run M10 also reported `FAIL test_peakguard.py`. Re-run in isolation **three times** and once more @@ -250,6 +305,17 @@ and that **no claim in this card rests on it**. `test_peakguard.py` is the file CI-06 records as flaky, and this is the behaviour it records. Believe no red from it without three isolated re-runs. +⚠️ **ROUND 3 CAN NAME ONE, AND IT IS THE SAME FILE.** Round 3's own batch — the +ten full-suite runs above, plus §7's sibling swaps — produced **no** red from +`test_peakguard.py` on M10 or on any other ledger entry. It produced exactly one +elsewhere, on a **`static_tariff.py`-only** edit (§7b, A4), and this time the +failing check *was* captured: *"one cycle of the intent form is exactly the +delivered form plus the command standing on the wire"* — which is CI-06's +**mechanism 1 by name**, the cross-time projection. `test_peakguard.py` then +passed **four for four** in isolation on that same tree. That is the first time +this card has been able to put a name to one of these reds, and it is the name +CI-06 predicts. + ⚠️ **M3 IS NOT "FOR COMPLETENESS", AND ROUND 1 SAID IT WAS.** Round 1 recorded M3 as already closed structurally by SAFETY-32 and disclaimed it. §1 above has the correction and the mechanism: `[0]` **survives** a full green 21-file suite @@ -321,6 +387,73 @@ coverage rather than merely inheriting it. **W2 is caught entirely by pre-existing checks** — SAFETY-38 adds nothing to circulation, and does not claim to. +### 4c. ⚠️ The SAME zero-byte defect one call site over — and it is the one the shipped install can see + +Round 2 closed §4a by asserting the ordered pair on the **site** banner at +`main.py:1620`. It did not touch `main.py:1560`, the **per-unit** call, and the +identical swap there — + +```python +if inv.sign_wd.evaluate(stood, against, self.following, now) # shipped +if inv.sign_wd.evaluate(against, stood, self.following, now) # M12 +``` + +— left the round-2 tree **21/21 green**. Measured: `app/main.py` 234 542 bytes +before and after, **Δ 0 bytes**, fingerprint `61aa413f…` → `e57e6026…`, bytecode +purged, tree mounted `:ro`, the line re-read from disk. + +**Nothing behavioural can catch it, and that is structural rather than +unlucky.** `watchdog.disagrees()` is *symmetric* in its first two arguments — +`commanded_w * against_w < 0`, with an `abs()` gate on **both** — so the swap +cannot change a single trip decision on any input. What it changes is the +**sentence**. Measured by diffing the suite's own printed banners between the +round-3 tree and the same tree carrying only M12: **9 emitted banner lines +invert, in 3 distinct sentences.** + +``` +shipped: SIGN_DISAGREEMENT: commanded -2000 W against a meter reading +2000 W … +M12: SIGN_DISAGREEMENT: commanded +2000 W against a meter reading -2000 W … + +shipped: … commanded +1000 W against its own measured power -1000 W … +M12: … commanded -1000 W against its own measured power +1000 W … + +shipped: … commanded -2000 W against a meter reading +4000 W … (the N=1 arm) +M12: … commanded +4000 W against a meter reading -2000 W … +``` + +**⚠️ AND IT IS WORSE THAN THE ONE ROUND 2 CLOSED.** Part 9c asserts the SITE +detector is **not armed at a roster of one**, and inverter B does not physically +exist. So on the shipped install the **per-unit banner is the only +sign-disagreement sentence that can ever be printed** — in the action log, in +`_LOG.error`, in `status_payload()`'s headline banner, and in `sign_reason`, +which is persisted and read back after the restart the latch is designed to +survive. Round 2 pinned the N≥2-only call site and left the shipping one open. + +**Closed by the ORDERED PAIR on both per-unit banners**, with both operands read +off the **observed cycle** — `inv.last_write` and `inv.batt` off the objects the +plant moved, never off the arguments the detector was handed, which would be +comparing the swap against itself: + +* `test_watchdog.py` **part 3**, the N≥2 per-unit trip: + `f"commanded {stood:+.0f} W against its own measured power {meas:+.0f} W"`, + plus the persisted `unit/inverter-b/sign_reason` copy, plus a **precondition** + that the two operands are different numbers of opposite sign — without which + a transposition would produce the same string and the check would be + decoration; +* `test_watchdog.py` **part 7**, the **N=1** controller-driven trip: + `f"commanded {stood:+.0f} W against a meter reading {grid:+.0f} W"`, plus the + namespaced key **and** the flat rollback mirror, plus the same precondition. + +**Measured after the fix: M12 is RED on four named checks in +`test_watchdog.py` and GREEN in the other twenty files** — one file, four +checks. M11 re-measured on the same tree: still red. + +**⚠️ Neither §6a nor MUTATION.md disclosed it.** §6a's "what was NOT closed" +enumerated `circ_wd`, the eight other `CLAIM_SCOPE` entries, `actuator_of()`'s +body and the `n_units > 1` guard — not this. MUTATION.md's own closing rule, in +the file round 2 edited, is *"a variant is not covered because a near-miss of it +is."* §7 is the enumeration that would have caught both instances at once. + ## 5. The survivor this card went looking for, and closed M7 is not one of the card's three. It was found by asking the question the @@ -406,6 +539,184 @@ disk, byte delta printed):** re-verified on `86eaef2` for **M3** and **M11** (both full 21-file runs) and is otherwise carried forward from round 1's measurement on `f72c48d`. It is a weaker statement than round 1 made and is written down as such. + ⚠️ **Round 3 closed the other half of this**, but only in the *before* + direction: all ten were re-run on clean `release/1.0` @ `bbc903b` under the + **full 21-file suite** to establish which of them the pre-existing coverage + could see — see §4's ⚠️ paragraph, and the answer was **eight survivors, not + three**. The *after* direction — "each of the ten is red in + `test_watchdog.py` and green in the other twenty **on the round-3 tree**" — + is still measured end-to-end only for **M3, M11 and M12**. For the other + seven, "red" carries forward from round 2 (which adding assertions cannot + undo) and "green elsewhere" carries forward from round 1. * **`test_peakguard.py`'s CI-06 flake is not fixed by this card** and is not this card's subject. It is *characterised* here (five isolated runs on the M3 tree, all PASS) only far enough to overturn the false kill it produced. + +### 6b. Round 3: what was closed, and what is still open + +**Closed in round 3:** + +1. **M12**, the zero-byte argument swap at the **per-unit** call site + `main.py:1560` — §4c. It was a survivor of the round-2 tree at **21/21 + green**, and it is the **only** sign-disagreement banner a shipped + one-inverter install can print. Red on four named checks, green in the other + twenty files. +2. **The ledger arithmetic in §4**, which round 2 made specific and wrong. See + §4's own ⚠️ paragraph: re-measured mutation by mutation on clean + `release/1.0` @ `bbc903b`, full 21-file suite each time. +3. **The family itself is now enumerated by machine** — + `tools/symmetric_message_scan.py`, §7 — rather than by hand for a third time. + +**Open, named, and NOT claimed:** + +* **A3 and B1** — the only two status-page rows in which the freeze detector + ever names a device range: the **NOT COVERED** row and the **armed** row's + "below max_w because …" clause. `rail_unreachable()`'s `dev_min`/`dev_max` at `main.py:3208` and the + literal `f"only {inv.dev_min}...{inv.dev_max} W"` at `main.py:3231`. Both + measured: **0 bytes each, 21/21 green each.** Real, open members of the + family. They are **status-page rows rather than latching alarms**, and closing + them wants a fixture with a device whose two bounds are **asymmetric** — every + fixture in this repository uses `±d`, so the transposition is currently + invisible by construction — which belongs to `test_freeze.py`'s card, not to + this one. +* **Twelve census-B sites** (§7c, B2–B12) determined unpinned at the **source + level** — the distinguishing literal appears in no `test_*.py` — and **not** + mutated. Weaker evidence than A1–A3, and written down as such. +* **B6**, SAFETY-31's `circ_reason`, is the same shape as the defect this card + closed for SAFETY-02/30 and is *also* persisted and re-read after a restart. + §6a already declines to touch `circ_wd`; §7d says what that costs. +* Everything §6a lists stays open: `circ_wd`'s own thresholds, the eight other + `CLAIM_SCOPE` entries, `actuator_of()`'s body, and the `n_units > 1` guard. + +## 7. The symmetric-predicate family — every call whose argument order is load-bearing for a MESSAGE + +**Why this section exists, and it is the most useful thing in the card.** Round 2 +pinned the operand order of `SignWatchdog.evaluate()` at `main.py:1620`. Round 3 +found the **identical zero-byte defect at `main.py:1560`**, one call site over — +and that one is worse, because `test_watchdog.py` part 9c asserts the site +detector is **never armed at +a roster of one**, so on the shipped install the per-unit banner is the **only** +sign-disagreement sentence that can ever be printed. Twice in one card the +enumeration was written by hand and twice it was short. MUTATION.md habit 12: *a +hand-written scope is the same defect as a hand-written fixture.* So the family +is now **computed**, by `tools/symmetric_message_scan.py`. + +### 7a. What makes this family invisible, and why it is not a fixture problem + +Every other defect in this document is a fixture problem: *no fixture happens to +separate the mutant from the shipped code.* Build the right fixture and it goes +red. This one is not that. + +Where a callee is **symmetric** in two arguments, a swap at a call site cannot +change **any** action on **any** input. `watchdog.disagrees()` is +`commanded_w * against_w < 0` with an `abs()` gate on **both** sides: the +product commutes and the two gates are the same gate. There is nothing for a +behavioural check to observe, ever — **not "no fixture separates them" but no +fixture *can*.** The only surface that moves is the **sentence**, and the only +check that can see it is one asserting the **ordered pair** (MUTATION.md habit +14) at **that** call site. + +Two consequences the earlier rounds got wrong: + +* **The unit of coverage is the CALL SITE, not the function.** `evaluate()`'s + operand order was pinned and the defect still shipped, because the pin was on + the *other* call. +* **A behavioural suite is not weak evidence here, it is NO evidence.** "21/21 + green" is what this mutation is *guaranteed* to produce. Measured, twice: M11 + at `main.py:1620` on the round-1 tree, and M12 at `main.py:1560` on the + round-2 tree. + +### 7b. Census A — the callee formats its own parameters + +`tools/symmetric_message_scan.py` renames each parameter pair, erases every +formatted string, canonicalises the commutative operators (`*`, `+`, `and`, +`or`, `==`, `min`/`max`, iterated tuple literals) and compares the **bodies**; +it iterates to a **fixpoint**, because the symmetry is usually one call deep — +`evaluate()` is symmetric only *through* `disagrees()`, and a single pass +reports an empty family on a tree whose answer is already known. It then +classifies each sentence by **where it goes**. + +Fixpoint on this tree: **3 rounds, 17 interchangeable positional and 5 keyword +pairs across 14 functions**. 45 parameter pairs reach a sentence; **8** are +symmetric — **5** whose sentence goes only to an operator, and **3** whose +sentence is the **return value** and therefore has to be classified by hand, +because a returned string is sometimes a KEY and sometimes a SENTENCE. + +| # | scan row | pair | called at | swap costs | reaches an operator via | pinned? | +|---|---|---|---|---|---|---| +| **A1** | `watchdog.py:613` `SignWatchdog.evaluate()` | `(commanded_w, against_w)` | **`main.py:1560`** — per-unit, **and the N=1 detector** | **0 B** | `sign_reason` (persisted), the action log, `_LOG.error`, `status_payload()`'s headline banner | **YES — round 3.** `test_watchdog.py` part 3 (`its own measured power`) and part 7 (`a meter reading`), on the contiguous literal with both operands read off the observed cycle. **Measured: 0 B, red on 4 named checks, green in the other 20 files.** | +| **A2** | same row, second call site | `(commanded_w, against_w)` | **`main.py:1620`** — site | **0 B** | `site_sign_reason`, the action log, the status page | **YES — round 2**, part 9a. Re-measured on the round-3 tree: still red. | +| **A3** | `freeze.py:405` `FreezeWatchdog.evaluate()` and `freeze.py:311` `rail_unreachable()` — both symmetric through `rail_ceiling_w()`'s `for bound in (dev_min, dev_max)` | `(dev_min, dev_max)` | **`main.py:3208`** (and 1928, 2755, 3196, 3219) | **0 B** | the status page's `freeze detector: NOT COVERED — the setpoint device reports a range of …` row | **NO.** Measured: swapped at `main.py:3208-3209`, **0 bytes**, `main.py` fingerprint `61aa413f…` → `2305b560…`, **21/21 green**. Disclosed in §7d, not closed. ⚠️ `rail_unreachable()` is the row the scan puts in its **RETURN** bucket: its returned string is a **sentence**, not a key, so it is a family member after all — which is exactly why that bucket says *read by hand*. | +| **A4** | `static_tariff.py:522` `_raise_gap()` | `(start, end)` | `static_tariff.py:509`, `:513` | **0 B** | the `ProfileInvalid` refusal an operator reads at startup | **YES — pre-existing.** Measured: both call sites swapped, **0 bytes**, `static_tariff.py` fingerprint `caef7fde…` → `50e9a768…`, **red in `test_static_tariff.py` on two named checks** (*"…and names the uncovered range so the operator can fix it"*, *"…and the refusal names the uncovered range"*), because `_hhmm(start)}-{_hhmm(end)` is asserted as the contiguous literal `"22:00-23:00"`. ⚠️ That batch run also reported `FAIL test_peakguard.py` on a `static_tariff.py`-only edit; the failing check is *"one cycle of the intent form is exactly the delivered form plus the command standing on the wire"*, which is [[CI-06]]'s **mechanism 1 by name**, and `test_peakguard.py` passes **four for four** in isolation on that same tree. No claim here rests on it. | +| A5–A6 | `static_tariff.py:522` `_raise_gap()` | `(start, weekday)`, `(end, weekday)` | — | — | — | **not real — the scan's one over-report.** It erases the sentence, so it cannot see that `DAY_NAMES[start]` with `start = 1320` raises `IndexError`. A mixed-type pair fails loudly, which is the opposite of this family. | +| A7–A8 | `store.py:177` `unit_key()`, `mqtt.py:222` `unit_state_key()` | `(unit, key)`, `(slug, key)` | many | 0 B | — | **not this family.** The formatted string is the **return value and it is a KEY**: a swap renames a store key and an MQTT topic, which is behaviour every ordinary check can see. MAINT-05 is the card about it. | + +### 7c. Census B — the caller builds the sentence + +Census A only sees a callee formatting its **own** parameters, which was gap 2 +of its own honest-gap list. The other half never crosses a function boundary: +an operator sentence built where it is printed, interpolating two values that +are **interchangeable by shape**. No predicate is involved at all, so census A's +symmetry test has nothing to test. The scan pairs interpolated expressions that +are **one leaf apart** — `inv.dev_min` / `inv.dev_max`, `cfg.min_w` / +`cfg.hold_s` — which is exactly what a transposition swaps, and it keeps only +sentences whose sink is a **message**: an f-string that becomes a URL or a store +key is data, and a swap there is an ordinary behavioural change. **16 +candidates**, every one of them a **zero-byte** edit: + +| # | site | pair | reaches an operator as | pinned? | +|---|---|---|---|---| +| **B1** | `main.py:3231` | `inv.dev_min` / `inv.dev_max` | status page, `… accepts only −120…130 W` | **NO — MEASURED.** Transposed in place, **0 bytes**, `main.py` fingerprint `61aa413f…` → `ac36a97b…`, purged, `:ro`, **21/21 green**. The row then tells the operator the device accepts `130…−120 W`. | +| **B2** | `main.py:3362` | `inv.dev_min` / `inv.dev_max` | status page, `setpoint … (range −120…130 W)` | **NO** — the literal `setpoint … (range` is in no `test_*.py`. | +| **B3** | `main.py:3435` | `sign_wd.cfg.hold_s` / `.cfg.min_w` | status page, `sign watchdog: armed (trips after 180 s opposed, both over 300 W)` | **NO** — `s opposed` is in no `test_*.py`. | +| **B4** | `main.py:3460` | `site_sign_wd.cfg.hold_s` / `.min_w` | status page, the site sign row | **NO** — `armed against the sum` is in no `test_*.py`. | +| **B5** | `main.py:3491` | `circ_wd.cfg.hold_s` / `.min_w` | status page, the circulation row | **NO** — `circulation watchdog: armed` is in no `test_*.py`. | +| **B6** | `circulation.py:317` | `cfg.min_w` / `cfg.hold_s` | **`circ_reason` — persisted, and re-read after a restart** | **NO** — `test_watchdog.py` asserts only `"CIRCULATING_POWER" in reason`. **This is SAFETY-31's twin of the exact defect this card just closed for SAFETY-02/30**, on a string with the same persistence. | +| **B7** | `main.py:2454` | `self.site_max_w` / `self.target` | the clamp action-log line | **NO** — `was clamped to` is in no `test_*.py`. | +| **B8** | `main.py:3386` | `clamp_wd.engagements` / `.episodes` | status page, `engaged N time(s) in M episode(s)` | **NO** — `time(s) in` is in no `test_*.py`. | +| **B9–B11** | `main.py:3169` (three pairs) | `p1.net_w` / `.samples` / `.parse_errors` | status page, the P1 row | **NO** — `P1 meter (` is in no `test_*.py`. | +| **B12** | `arbiter.py:264` | `c.source` / `c.actuator` | `scope_errors`, the arbiter's mis-routing refusal | **NO, and this one is instructive.** `test_arbiter.py:452` says it *"names the source and the actuator it does not belong on"* and then asserts `"peak" in r_mis.scope_errors[0]`. Under the swap the sentence becomes `"inverter-b: a SITE claim filed on unit 'peak'"` — and `"peak" in …` is **still true**. A claim wider than its assertion, which is habit 14's own shape. | +| **B13** | `validate.py:723` | `meter_phases` / `meter_max_age_s` | the startup refusal | **NO** for the ordered pair. | +| **B14** | `validate.py:1211` | `arbitrage_soc_floor` / `arbitrage_soc_ceiling` | the startup refusal | **NO** for the ordered pair. | +| **B15** | `watchdog.py:667` | `cfg.min_w` / `cfg.hold_s` | the `SIGN_DISAGREEMENT` sentence's `(limits: both over 300 W, held over 180 s)` clause | **YES**, and it is worth naming because it shows the habit works. `test_watchdog.py`'s SAFETY-30 section asserts the site instance's **whole sentence with `==` against a literal**, the `(limits: …)` clause included. Full-string equality is habit 14 taken to its limit. | +| B16 | `main.py:237` | `i.name` / `i.max_w` | the roster log line | **not real** — a `str` beside a `:.0f` format. The same over-report as A5–A6: a mixed-type transposition raises rather than lying. | + +### 7d. What this enumeration does NOT close, with sizes + +* **A3 is a measured, open survivor.** `dev_min`/`dev_max` transposed in + `rail_unreachable()`'s NOT COVERED row: **0 bytes, 21/21 green.** It is + disclosed here rather than fixed, because closing it wants a `test_freeze.py` + or status-page fixture with a device whose two bounds are **asymmetric** — + every fixture in the repository uses `±d` — and that is a different file's + card. It is a **status-page row**, not a latching alarm. +* **B1 is a second measured, open survivor.** `inv.dev_min` and `inv.dev_max` + transposed in `main.py:3231`'s status row: **0 bytes**, fingerprint + `61aa413f…` → `ac36a97b…`, **21/21 green**. The page then tells the operator + the device accepts `130…−120 W`. Same disposition as A3: **A3 is the row the + freeze detector prints when the rail is UNREACHABLE, B1 the clause the ARMED + row prints when the device is what set the rail** — the two rows in which that + detector ever names a device range, and neither one's operand order is + asserted anywhere. +* **Thirteen further census-B sites are unpinned and UNMEASURED.** B2–B14 above + are determined at the **source level** — the distinguishing literal appears in + no `test_*.py`, or the check that exists asserts a substring wider than its + own claim — and were **not** mutated. That is a weaker statement than A1–A4 + and B1, and is written down as one. +* **B12 is a check whose CLAIM is wider than its ASSERTION**, which is habit + 14's own shape one module over. `test_arbiter.py:452` says + `scope_violations` *"names the source and the actuator it does not belong + on"* and then asserts `"peak" in r_mis.scope_errors[0]`. Swap `c.source` and + `c.actuator` in `arbiter.py:264` and the sentence becomes + *"inverter-b: a SITE claim filed on unit 'peak'"* — and the substring is still + found. Source-level, not mutated, and not fixed here. +* **B6 is the closest thing to a repeat.** SAFETY-31's `circ_reason` is + persisted and re-read after a restart, exactly like `sign_reason`, and its + two limits are graded by a bare `"CIRCULATING_POWER" in reason`. This card + does not fix it; §6a already declines to touch `circ_wd`, and this is the + precise shape of what that declining costs. +* **The instrument's own blind spots** are printed by the instrument, every + run: semantic symmetry it cannot spell (it under-reports), census B pairs + that are the same *kind* but not the same *shape*, `%s`-style logging and + non-f-string payloads, anything outside `app/`, and the one way it + **over-reports** — it erases the sentence, so a swap that would make the + formatting itself raise is scored as changing no decision (A5–A6 above). diff --git a/goodwe_controller/MUTATION.md b/goodwe_controller/MUTATION.md index c276686..af5e47c 100644 --- a/goodwe_controller/MUTATION.md +++ b/goodwe_controller/MUTATION.md @@ -276,7 +276,25 @@ same place twice, the check is decoration. The fix is to assert the **ORDERED PAIR**, `f"commanded {sum:+.0f} W against a meter reading {grid:+.0f} W"`. - The general habit is **14** below. + ⚠️ **AND THE ELEVENTH WAS THE SAME DEFECT ONE CALL SITE OVER — THE ONE A + SHIPPED INSTALL CAN ACTUALLY SEE.** Round 2 pinned the operand order of + `SignWatchdog.evaluate()` at `main.py:1620`. That call site is the **site** + detector, and `test_watchdog.py` part 9c asserts it is **never armed at a + roster of one** — which is every install this add-on has. The **per-unit** + call at `main.py:1560` was left unpinned, and swapping its first two + arguments is the same **ZERO-BYTE** edit: `disagrees()` is symmetric in them + (`commanded_w * against_w < 0` with an `abs()` gate on both), so **no trip + decision can change** and the full 21-file suite stays green — measured. What + moves is five banner lines, including the one persisted to `sign_reason` and + re-read after a restart: *"commanded −1000 W against its own measured power + +1000 W"*, operands inverted, on the only sign-disagreement sentence a + one-inverter install can ever print. Closed in round 3 by the ordered pair on + **both** per-unit banners — the N≥2 `its own measured power` one and the N=1 + `a meter reading` one — red on four named checks, green in the other twenty + files. **Habit 15 is what falls out of finding the same shape twice in the + same card.** + + The general habits are **14** and **15** below. Note what these have in common with a masked rung: the mutation *runs*, and the suite is *honestly* green. Nothing in a purge, a fingerprint or a disassembly @@ -427,6 +445,56 @@ Cheap habits fall out of it, and all of them are now used in this repository: this one was found: **inside the fix written to close the previous instance of the same family**, which is the second time in this list. + ⚠️ **AND THE FIX FOR IT WAS ONE CALL SITE WIDE, WHICH IS THE HALF WORTH + KEEPING.** The ordered pair went onto `main.py:1620`, the **site** detector + — armed only at N≥2, and part 9c asserts it is **absent at a roster of + one**. The identical swap at `main.py:1560`, the **per-unit** detector, was + still **ZERO BYTES**, still changed no decision, still left the 21-file + suite green — and it is the **only** sign-disagreement sentence a shipped + one-inverter install can ever print: *"commanded −1000 W against its own + measured power +1000 W"*, backwards, in the log, on the status page and in + `sign_reason` after the restart the latch is designed to survive. Measured + size: **main.py:1560, 0 bytes, 21/21 green.** So habit 14 is not "pin the + banner", it is **"pin every call site of the banner, and say which one the + shipped roster reaches"** — and habit 15 is how you find them all. + +15. **A SYMMETRIC PREDICATE MAKES ARGUMENT ORDER A PROPERTY OF THE MESSAGE + ALONE — so ENUMERATE THAT FAMILY BY MACHINE, and grade the CALL SITES, not + the function.** Where a callee is symmetric in two arguments, a swap at any + call site is invisible to every behavioural check **by construction**: not + "no fixture happens to separate them" but *no fixture can*. There is + nothing for a mutation battery to find, because the mutant and the shipped + tree take identical actions on every input. The only surface that moves is + the sentence, and the only check that can see it is one asserting the + **ordered pair** (habit 14) at **that** call site. + Three consequences, and all three are why this is its own habit: + * **The unit of coverage is the CALL SITE.** SAFETY-38 pinned + `evaluate()`'s operand order and still shipped the defect, because the + pin was on the call two lines below the one that matters. Ask of every + banner: *how many places call this, and which does the shipped roster + reach?* + * **The scope must be COMPUTED** — habit 12's argument, and SAFETY-38 is + its proof: the family was enumerated by hand twice and was short both + times. `tools/symmetric_message_scan.py` renames each parameter pair, + erases every formatted string, canonicalises the commutative operators + and iterates to a **fixpoint** over `app/` (the symmetry is usually one + call deep — `evaluate()` is symmetric only *through* `disagrees()`), then + classifies each sentence by where it GOES. A returned string may be a + **key** — swapping `store.unit_key(unit, key)` renames a store key, which + is behaviour and is MAINT-05's card — or a **sentence**, which is this + family. + * **Say what the instrument cannot see, with shapes** (habit 13). That scan + under-reports semantic symmetry no canonicaliser on its finite list can + spell; its second census, for sentences the CALLER builds, pairs only + expressions **one leaf apart** and never sees `%s`-style logging; it + covers only `app/`; and it **over-reports in exactly one way** — it + erases the sentence, so a swap that would make the FORMATTING itself + raise is scored as changing no decision. It prints all of that on every + run. The family, every member's call sites, and which of them are pinned + are in `DECISIONS/SAFETY-38-site-standing-pin.md` §7 — **with two + measured, still-open survivors named there rather than rounded off**, + both zero bytes and both 21/21 green. + And the mutation-table consequence: **a variant is not covered because a near-miss of it is.** DEV-02's table listed the *actuator* variant of the per-unit claim defect (`device`/`supervised` filed without `inv`), which the diff --git a/goodwe_controller/test_watchdog.py b/goodwe_controller/test_watchdog.py index 10715e4..80efaea 100644 --- a/goodwe_controller/test_watchdog.py +++ b/goodwe_controller/test_watchdog.py @@ -1508,6 +1508,50 @@ check("⚠️ AC 1. THE REVERSED UNIT TRIPS AND ITS SIBLING DOES NOT. The eviden check("...and the reason names the instrument, so the operator is sent to the " "right one", AGAINST_OWN_POWER in _b30.sign_wd.reason) print(f" {_b30.sign_wd.reason!r}") +# ⚠️ THE LAST CYCLE BEFORE B LATCHED, NAMED RATHER THAN INDEXED - part 5's +# lesson and 9a's. After the trip B's share is 0 W and its measured power +# follows it down, so a fixed index reads the REMEDY instead of the evidence. +# Both operands are read off THIS OBSERVED CYCLE - `last_write` and `batt` off +# the objects - and not off the fixture's constants, so the check cannot be +# satisfied by two numbers the fixture happens to hold anyway (habit 2). +_preb30 = [x for x in _s30 if not x["b"]] +_midb30 = _preb30[-1] +print(f" last cycle before B latched: B stood " + f"{_midb30['stood'][1]:+.0f} W, B measured {_midb30['meas'][1]:+.0f} W") +check("⚠️ ...AND THE ORDERED PAIR, on the ONE SIGN-DISAGREEMENT SENTENCE A " + "SHIPPED INSTALL CAN EVER PRINT. Part 9a pins the SITE banner's operand " + "order at main.py:1620; that call site is armed only at N>=2 and part 9c " + "asserts it is ABSENT at a roster of one, so on the shipped install the " + "PER-UNIT banner is the only one there is - and its call site is a " + "different line. Swapping the first two arguments at main.py:1560 - " + "`evaluate(stood, against, ...)` -> `evaluate(against, stood, ...)` - is " + "a ZERO-BYTE edit that changes NO trip decision, because `disagrees()` " + "is symmetric in those two arguments (`commanded_w * against_w < 0` with " + "an abs() gate on both). Nothing behavioural can see it and the full " + "21-file suite stayed green. What it changes is the SENTENCE: 'commanded " + "-1000 W against its own measured power +1000 W', the exact opposite of " + "which instrument read what, on the line somebody woken by this alarm " + "reads first - and it is persisted to `sign_reason` and re-read after a " + "restart. Graded on the CONTIGUOUS LITERAL with each value in its own " + "slot, per MUTATION.md habit 14: an `in` test on a number grades " + "PRESENCE, never ARRANGEMENT", + f"commanded {_midb30['stood'][1]:+.0f} W against its own measured power " + f"{_midb30['meas'][1]:+.0f} W" in _b30.sign_wd.reason) +check("PRECONDITION for the line above, and without it that check is " + "decoration: the two operands are DIFFERENT NUMBERS and OPPOSITE IN " + "SIGN, so transposing them really does produce a different string. A " + "cycle where B's share and B's measured power were equal, or where " + "either was 0 W, would make the swapped sentence identical to the " + "correct one and the assertion could not reach the failure it names", + _midb30["stood"][1] != _midb30["meas"][1] + and _midb30["stood"][1] * _midb30["meas"][1] < 0 + and f"{_midb30['stood'][1]:+.0f}" != f"{_midb30['meas'][1]:+.0f}") +check("...and the SAME ordered pair is what an operator finds AFTER A RESTART, " + "because `sign_reason` is the persisted copy of this string. The swap " + "survives the container restart the same way the latch does", + f"commanded {_midb30['stood'][1]:+.0f} W against its own measured power " + f"{_midb30['meas'][1]:+.0f} W" + in str(_c30.store.data.get(unit_key("inverter-b", "sign_reason"), ""))) # ⚠️ READ OFF THE ALLOCATION RECORD, NOT OFF `Controller.reason`. At a roster # of two `reason` is the SITE resolution's line - "loop -> 2000 W" - because # B's failsafe is a UNIT claim resolved on B's own actuator and never competes @@ -1764,11 +1808,20 @@ async def _n1_sign(): return _orig_eval(*a, **kw) c.site_sign_wd.evaluate = _spy - await _drive(c, n=12, dt=0.2) - return c, _n1_calls + # ⚠️ THE WATCH READS THE CONTROLLER'S OWN STATE, NOT `_spy`'s ARGUMENTS. + # `_n1_calls` records what was HANDED to a detector, so grading the banner + # against it would compare the swap against itself - the DEV-02 defect. + # `last_write` and `c.grid` are read off the objects the plant moved. + seen = await _drive(c, n=12, dt=0.2, watch=lambda c: dict( + grid=c.grid, + stood=(c.inverters[0].last_write + if c.inverters[0].last_write is not None + else c.inverters[0].target), + tripped=c.inverters[0].sign_wd.tripped)) + return c, _n1_calls, seen -_c1s, _n1_site_calls = asyncio.run(_n1_sign()) +_c1s, _n1_site_calls, _seen1s = asyncio.run(_n1_sign()) _inv1 = _c1s.inverters[0] print(f" roster={_c1s.units} armed against {_inv1.sign_wd.against!r} " f"at {_inv1.sign_wd.cfg.min_w:.0f} W / {_inv1.sign_wd.cfg.hold_s:.0f} s") @@ -1786,6 +1839,43 @@ check("...the reason sentence still says 'a meter reading', word for word - it " "is what an operator reads, what the action log carries and what is " "persisted, so it is graded on the literal", "against a meter reading" in _inv1.sign_wd.reason) +# The last cycle before the latch, named rather than indexed: afterwards the +# failsafe stands 0 W and the plant's meter relaxes, so a fixed index reads the +# remedy. Both operands come off the controller, not off the fixture's options. +_pre1s = [x for x in _seen1s if not x["tripped"]] +_mid1s = _pre1s[-1] +print(f" last cycle before the latch: stood {_mid1s['stood']:+.0f} W, " + f"meter {_mid1s['grid']:+.0f} W") +print(f" {_inv1.sign_wd.reason!r}") +check("⚠️ ...AND THE ORDERED PAIR ON THE SHIPPING CALL SITE. This is the SAME " + "zero-byte defect part 9a closed at main.py:1620, one call site over at " + "main.py:1560 - and this is the one a one-inverter install actually " + "reaches, since the site detector is never armed here (asserted below). " + "`evaluate(stood, against, ...)` -> `evaluate(against, stood, ...)` " + "changes no trip decision - `disagrees()` is symmetric in those two " + "arguments - and inverts the banner to 'commanded +4000 W against a " + "meter reading -2000 W': the loop is standing at -2000 W of charge INTO " + "a +4000 W import and the latching banner says the opposite. The " + "substring check above passes under the swap, because both numbers are " + "still in the sentence; only the ORDERED PAIR can tell the slots apart " + "(MUTATION.md habit 14)", + f"commanded {_mid1s['stood']:+.0f} W against a meter reading " + f"{_mid1s['grid']:+.0f} W" in _inv1.sign_wd.reason) +check("PRECONDITION: the two operands are DIFFERENT NUMBERS and OPPOSITE IN " + "SIGN on that cycle, so a transposition genuinely produces a different " + "string. Equal or zero operands would make the swapped banner identical " + "and the check above vacuous", + _mid1s["stood"] != _mid1s["grid"] + and _mid1s["stood"] * _mid1s["grid"] < 0 + and f"{_mid1s['stood']:+.0f}" != f"{_mid1s['grid']:+.0f}") +check("...and the persisted copy carries the same ordered pair, under BOTH the " + "namespaced key and the flat rollback mirror - which is what an operator " + "reads after the restart the latch is designed to survive", + f"commanded {_mid1s['stood']:+.0f} W against a meter reading " + f"{_mid1s['grid']:+.0f} W" in str(_c1s.store.data.get(REASON_KEY, "")) + and f"commanded {_mid1s['stood']:+.0f} W against a meter reading " + f"{_mid1s['grid']:+.0f} W" + in str(_c1s.store.data.get("sign_reason", ""))) check("...the latch is under the same namespaced key with the same flat " "rollback mirror, and SAFETY-30's site pair is untouched at its default. " "A one-inverter install's state file is byte-identical to what it was", diff --git a/goodwe_controller/tools/symmetric_message_scan.py b/goodwe_controller/tools/symmetric_message_scan.py new file mode 100644 index 0000000..2a22192 --- /dev/null +++ b/goodwe_controller/tools/symmetric_message_scan.py @@ -0,0 +1,565 @@ +#!/usr/bin/env python3 +"""SAFETY-38 round 3 - the SYMMETRIC-PREDICATE FAMILY, enumerated by machine. + +WHAT IT LOOKS FOR, in one sentence: a call whose argument ORDER is load-bearing +for a MESSAGE and for nothing else - so swapping the arguments cannot change a +single decision, is invisible to every behavioural check BY CONSTRUCTION, and +still inverts what an operator reads on the line they are woken by. + +⚠️ WHY IT IS AN INSTRUMENT AND NOT A LIST. MUTATION.md habit 12: "a hand-written +scope is the same defect as a hand-written fixture". SAFETY-38 met this family +twice - `SignWatchdog.evaluate()` called at `main.py:1620` (round 2) and at +`main.py:1560` (round 3), ONE CALL SITE OVER - and both times the enumeration +was written by hand and both times it was short. So the scope is computed. + +------------------------------------------------------------------------------ +THE TEST IT APPLIES + +For every function in `app/` and every pair (a, b) of its parameters: + + rename a -> b and b -> a throughout the body, ERASE every string literal + and f-string, CANONICALISE the commutative operators, and compare the two + ASTs. If they are EQUAL, nothing the function DECIDES can depend on which + of the two values arrived first. + +Then, if some f-string in that function mentions BOTH names, the order is +load-bearing for the MESSAGE ALONE, and the pair is reported. + +⚠️ THE SYMMETRY IS USUALLY ONE CALL DEEP, WHICH IS WHY THIS IS A FIXPOINT AND +NOT A SINGLE PASS. `SignWatchdog.evaluate()` does not compare its two arguments +itself; it hands them to `watchdog.disagrees()`, whose body is +`commanded_w * against_w < 0 and abs(commanded_w) > min_w and +abs(against_w) > min_w`. An intraprocedural scan sees `disagrees(a, b, ...)` +become `disagrees(b, a, ...)`, calls that a difference, and reports NOTHING - +which is exactly what the first version of this file did, on a tree where the +answer was already known. So symmetric pairs discovered in one round are used +to canonicalise CALL ARGUMENTS in the next, and the scan iterates until the set +stops growing. + +CANONICALISATION, and each entry is here because something in `app/` needs it: + * `a * b`, `a + b` - operand order sorted (`disagrees`) + * `x and y`, `x or y` - term order sorted (`disagrees`' conjunction) + * `a == b`, `a != b` - operand order sorted + * `min(a, b)`, `max(a, b)` - argument order sorted (`clamp_to_rating`) + * a tuple/list/set LITERAL being iterated - element order sorted + (`rail_ceiling_w`'s `for bound in (dev_min, dev_max)`) + * arguments at a KNOWN SYMMETRIC POSITION PAIR of a called function - sorted + (the fixpoint) + +Equal ASTs after the rename is a SUFFICIENT condition for behavioural +invariance, not a necessary one. This scan therefore UNDER-REPORTS and says so +- see "WHAT THIS CANNOT SEE". That is the conservative direction: everything it +names is genuinely in the family, and the gap is stated with its shape. + +Run: `python3 tools/symmetric_message_scan.py [--quiet]` from goodwe_controller/. +Exit status is 0 always; it is a census, not a gate. +""" +from __future__ import annotations + +import ast +import itertools +import pathlib +import sys + +APP = pathlib.Path(__file__).resolve().parent.parent / "app" + +# {function name: {(i, j) positional index pairs that are interchangeable}} +_SYM: dict[str, set[tuple[int, int]]] = {} +# ⚠️ AND THE SAME THING FOR KEYWORD-ONLY PARAMETERS, WHICH THE FIRST FIXPOINT +# DROPPED. `freeze.rail_ceiling_w(max_w, *, dev_min=None, dev_max=None)` folds +# its two device bounds with `for bound in (dev_min, dev_max): ceiling = min( +# ceiling, abs(bound))` - perfectly symmetric - and `rail_unreachable()` prints +# `"a range of {dev_min}...{dev_max} W"`. A scan that only followed POSITIONAL +# arguments called that pair asymmetric and hid a genuine member of the family +# behind an implementation detail of its own traversal. +_SYM_KW: dict[str, set[tuple[str, str]]] = {} + + +class _Canon(ast.NodeTransformer): + """Rename one pair, erase every string, and sort what commutes.""" + + COMMUTATIVE_BINOP = (ast.Mult, ast.Add, ast.BitAnd, ast.BitOr, ast.BitXor) + COMMUTATIVE_CMP = (ast.Eq, ast.NotEq) + COMMUTATIVE_CALL = ("min", "max") + + def __init__(self, a: str = "", b: str = ""): + self.a, self.b = a, b + + # -- the rename --------------------------------------------------------- + def visit_Name(self, node): + self.generic_visit(node) + if self.a and node.id == self.a: + node.id = self.b + elif self.b and node.id == self.b: + node.id = self.a + return node + + def visit_arg(self, node): + if self.a and node.arg == self.a: + node.arg = self.b + elif self.b and node.arg == self.b: + node.arg = self.a + return node + + # -- the message is exactly what is ALLOWED to differ -------------------- + def visit_JoinedStr(self, node): + return ast.Constant(value="") + + def visit_Constant(self, node): + if isinstance(node.value, str): + return ast.Constant(value="") + return node + + # -- canonicalisation --------------------------------------------------- + def visit_BinOp(self, node): + self.generic_visit(node) + if isinstance(node.op, self.COMMUTATIVE_BINOP): + l, r = ast.dump(node.left), ast.dump(node.right) + if l > r: + node.left, node.right = node.right, node.left + return node + + def visit_BoolOp(self, node): + self.generic_visit(node) + node.values = sorted(node.values, key=ast.dump) + return node + + def visit_Compare(self, node): + self.generic_visit(node) + if (len(node.ops) == 1 + and isinstance(node.ops[0], self.COMMUTATIVE_CMP)): + l, r = ast.dump(node.left), ast.dump(node.comparators[0]) + if l > r: + node.left, node.comparators[0] = node.comparators[0], node.left + return node + + def visit_For(self, node): + self.generic_visit(node) + if isinstance(node.iter, (ast.Tuple, ast.List, ast.Set)): + node.iter.elts = sorted(node.iter.elts, key=ast.dump) + return node + + def visit_Call(self, node): + self.generic_visit(node) + name = (node.func.id if isinstance(node.func, ast.Name) + else node.func.attr if isinstance(node.func, ast.Attribute) + else "") + if name in self.COMMUTATIVE_CALL and len(node.args) == 2 \ + and not node.keywords: + if ast.dump(node.args[0]) > ast.dump(node.args[1]): + node.args = [node.args[1], node.args[0]] + return node + # the fixpoint: sort the arguments at a known symmetric position pair + for (i, j) in sorted(_SYM.get(name, ())): + if j < len(node.args): + if ast.dump(node.args[i]) > ast.dump(node.args[j]): + node.args[i], node.args[j] = node.args[j], node.args[i] + # ...and at a known symmetric KEYWORD pair + for (ka, kb) in sorted(_SYM_KW.get(name, ())): + ia = next((k for k, w in enumerate(node.keywords) + if w.arg == ka), None) + ib = next((k for k, w in enumerate(node.keywords) + if w.arg == kb), None) + if ia is not None and ib is not None: + va, vb = node.keywords[ia].value, node.keywords[ib].value + if ast.dump(va) > ast.dump(vb): + node.keywords[ia].value, node.keywords[ib].value = vb, va + return node + + +def _canon(src: str, a: str = "", b: str = "") -> str: + return ast.dump(_Canon(a, b).visit(ast.parse(src))) + + +# ⚠️ THE SINK IS HALF THE CLAIM, AND THE FIRST VERSION OF THIS SCAN OMITTED IT. +# Erasing every f-string makes a function whose ONLY job is to FORMAT one look +# perfectly symmetric - `store.unit_key(unit, key)` and +# `mqtt.unit_state_key(slug, key)` both came back as "the swap changes nothing" +# when in fact the string IS the return value: swapping there renames a STORE +# KEY and an MQTT TOPIC, which is a behaviour change every ordinary check can +# see, and MAINT-05 is the card about exactly that. The family this file is +# looking for is narrower - the string goes to an OPERATOR and to nobody else - +# so each sentence is classified by where it GOES. +_LOG_SINKS = {"debug", "info", "warning", "warn", "error", "exception", + "critical", "log", "log_event", "notify", "persistent_notify"} +_MSG_ATTRS = {"reason", "message", "msg", "banner", "detail", "note", + "explanation", "why", "text"} + + +def _sink_of(fn, target, follow=True): + """RETURN | MESSAGE | OTHER - where this f-string node ends up. + + `follow` is the ONE step of data flow below, and it is off on the recursive + call so that two locals that interpolate each other cannot loop forever. + """ + for stmt in ast.walk(fn): + for child in ast.iter_child_nodes(stmt): + pass + # a parent map is cheaper than repeated walks + parent = {} + for node in ast.walk(fn): + for child in ast.iter_child_nodes(node): + parent[id(child)] = node + cur = target + while id(cur) in parent: + up = parent[id(cur)] + if isinstance(up, ast.Return): + return "RETURN" + if isinstance(up, ast.Raise): + return "MESSAGE" + if isinstance(up, (ast.Assign, ast.AnnAssign, ast.AugAssign)): + tgts = (up.targets if isinstance(up, ast.Assign) else [up.target]) + for t in tgts: + if isinstance(t, ast.Attribute) and t.attr in _MSG_ATTRS: + return "MESSAGE" + if isinstance(t, ast.Name) and t.id in _MSG_ATTRS: + return "MESSAGE" + # ⚠️ ONE STEP OF DATA FLOW, AND IT IS NOT OPTIONAL. `main.py`'s + # status page builds the device-range clause into a LOCAL - `_cap = + # "; ".join(f"... accepts only {inv.dev_min}...{inv.dev_max} W" ...)` + # - and only then concatenates it into the row's `text`. Stopping at + # the assignment scores that OTHER and drops a member this card + # MEASURED as a live survivor (0 bytes, 21/21 green). So: if the + # name assigned here is interpolated into some other sentence in the + # same function that IS a message, this one is a message too. + if follow: + for t in tgts: + if isinstance(t, ast.Name) and _feeds_a_message(fn, t.id, + up): + return "MESSAGE" + return "OTHER" + if isinstance(up, ast.Call): + name = (up.func.attr if isinstance(up.func, ast.Attribute) + else up.func.id if isinstance(up.func, ast.Name) else "") + if name in _LOG_SINKS: + return "MESSAGE" + if name == "append": + return "MESSAGE" + cur = up + return "OTHER" + + +def _feeds_a_message(fn, name, origin): + """Is `name` interpolated into some OTHER sentence that IS a message?""" + for node in ast.walk(fn): + if not isinstance(node, ast.JoinedStr) or node is origin: + continue + used = any(isinstance(x, ast.Name) and x.id == name + for v in node.values if isinstance(v, ast.FormattedValue) + for x in ast.walk(v.value)) + if used and _sink_of(fn, node, follow=False) == "MESSAGE": + return True + return False + + +def _fstring_pairs(fn, params): + """{(a, b): [(lineno, sink), ...]} for every f-string mentioning both.""" + out: dict[tuple[str, str], list[tuple[int, str]]] = {} + for node in ast.walk(fn): + if not isinstance(node, ast.JoinedStr): + continue + used = set() + for v in node.values: + if isinstance(v, ast.FormattedValue): + for x in ast.walk(v.value): + if isinstance(x, ast.Name) and x.id in params: + used.add(x.id) + if len(used) < 2: + continue + sink = _sink_of(fn, node) + for a, b in itertools.combinations(sorted(used), 2): + out.setdefault((a, b), []).append((node.lineno, sink)) + return out + + +def _functions(): + for path in sorted(APP.glob("*.py")): + tree = ast.parse(path.read_text()) + for fn in ast.walk(tree): + if isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)): + yield path, fn + + +def _positional(fn): + return [a.arg for a in fn.args.posonlyargs + fn.args.args + if a.arg != "self"] + + +def _all_params(fn): + return _positional(fn) + [a.arg for a in fn.args.kwonlyargs] + + +def _body_src(fn) -> str: + """The function's BODY alone. + + ⚠️ NOT `ast.unparse(fn)`. The signature lists the parameters IN ORDER, so + renaming a pair inside it reorders `arguments.args` and every function in + the repository compares unequal to itself - which is what the first version + of this scan did, silently, and it reported an empty family on a tree whose + answer was already known. The claim being tested is about what the body + DECIDES, and the body is what is compared. + """ + return ast.unparse(ast.Module(body=list(fn.body), type_ignores=[])) + + +def _symmetric(fn, a, b) -> bool: + src = _body_src(fn) + return _canon(src) == _canon(src, a, b) + + +def build_fixpoint(max_rounds=6): + """Fill `_SYM` until it stops growing. Returns the number of rounds.""" + for round_no in range(1, max_rounds + 1): + grew = False + for _path, fn in _functions(): + pos = _positional(fn) + kws = [a.arg for a in fn.args.kwonlyargs] + if len(pos) + len(kws) < 2: + continue + found = set() + for i, j in itertools.combinations(range(len(pos)), 2): + if _symmetric(fn, pos[i], pos[j]): + found.add((i, j)) + found_kw = set() + for ka, kb in itertools.combinations(sorted(kws), 2): + if _symmetric(fn, ka, kb): + found_kw.add((ka, kb)) + if found - _SYM.get(fn.name, set()): + _SYM.setdefault(fn.name, set()).update(found) + grew = True + if found_kw - _SYM_KW.get(fn.name, set()): + _SYM_KW.setdefault(fn.name, set()).update(found_kw) + grew = True + if not grew: + return round_no + return max_rounds + + +def scan(): + rows = [] + for path, fn in _functions(): + params = _all_params(fn) + if len(params) < 2: + continue + kwonly = {a.arg for a in fn.args.kwonlyargs} + for (a, b), hits in sorted(_fstring_pairs(fn, params).items()): + rows.append(dict( + module=path.name, func=fn.name, line=fn.lineno, a=a, b=b, + msg_lines=sorted(set(hits)), + sinks={s for _l, s in hits}, + symmetric=_symmetric(fn, a, b), + positional=not (a in kwonly or b in kwonly))) + return rows + + +# ============================================================================= +# CENSUS B - THE SENTENCE THE CALLER BUILDS +# ============================================================================= +# ⚠️ CENSUS A ABOVE ONLY SEES A CALLEE FORMATTING ITS OWN PARAMETERS, AND THAT +# WAS GAP 2 OF ITS OWN "WHAT THIS CANNOT SEE" LIST. The other half of the family +# never passes through a function boundary at all: an operator sentence built +# where it is printed, interpolating two values that are INTERCHANGEABLE BY +# SHAPE. `main.py`'s status page says +# +# f"{inv.setpoint_entity or inv.name} accepts only {inv.dev_min}...{inv.dev_max} W" +# +# and transposing those two costs ZERO BYTES, changes nothing any check can +# observe, and tells the operator a range backwards. No predicate is involved, +# so census A's symmetry test has nothing to test. +# +# THE TEST: inside one MESSAGE-sink f-string, take every pair of interpolated +# expressions, blank every attribute NAME and every string CONSTANT, and compare. +# If the blanked forms are EQUAL while the originals DIFFER, the two expressions +# are the same shape with one leaf changed - `inv.dev_min` / `inv.dev_max`, +# `site_dev_bound("dev_min")` / `site_dev_bound("dev_max")` - which is exactly +# the pair a transposition swaps. Same direction as census A: it under-reports +# (two values of the same KIND spelled differently are missed) rather than +# inventing members. + + +class _Blank(ast.NodeTransformer): + def visit_Attribute(self, node): + self.generic_visit(node) + node.attr = "" + return node + + def visit_Constant(self, node): + if isinstance(node.value, str): + return ast.Constant(value="") + return node + + +def _blanked(expr): + return ast.dump(_Blank().visit(ast.parse(ast.unparse(expr)))) + + +def scan_caller(): + rows = [] + for path in sorted(APP.glob("*.py")): + tree = ast.parse(path.read_text()) + for fn in ast.walk(tree): + if not isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + for node in ast.walk(fn): + if not isinstance(node, ast.JoinedStr): + continue + # ⚠️ MESSAGE SINKS ONLY, AND THE FIRST VERSION OF THIS + # CENSUS SAID "anything but a RETURN". That let in + # `p1.py`'s `self.url = f"http://{self.host}:{self.port}…"` - + # an f-string that is DATA, used to make a request, where a + # transposition is an ordinary behavioural change every check + # can see. Same distinction as census A's RETURN bucket: a + # formatted string is only in this family when its only + # consumer is a person. + if _sink_of(fn, node) != "MESSAGE": + continue + exprs = [v.value for v in node.values + if isinstance(v, ast.FormattedValue)] + for a, b in itertools.combinations(exprs, 2): + ua, ub = ast.unparse(a), ast.unparse(b) + if ua == ub: + continue + if _blanked(a) == _blanked(b): + rows.append((path.name, node.lineno, fn.name, ua, ub)) + # de-duplicate: the same sentence reached through several enclosing defs + return sorted(set(rows), key=lambda r: (r[0], r[1], r[3])) + + +# ⚠️ THE UNIT OF COVERAGE IS THE CALL SITE, NOT THE FUNCTION - which is the +# whole lesson of SAFETY-38 round 3. `SignWatchdog.evaluate()` had its operand +# order pinned and the defect still shipped, because the pin was on the call two +# lines below the one a one-inverter roster actually reaches. So a family member +# is reported WITH EVERY PLACE IT IS CALLED. +def call_sites(name): + out = [] + for path in sorted(APP.glob("*.py")): + tree = ast.parse(path.read_text()) + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + called = (node.func.attr if isinstance(node.func, ast.Attribute) + else node.func.id if isinstance(node.func, ast.Name) + else "") + if called == name: + # ⚠️ THE RECEIVER IS PRINTED, because matching on the bare + # attribute name conflates methods: `evaluate` is the name of + # SAFETY-02's, SAFETY-18's and SAFETY-31's watchdogs alike, and + # a list of four lines with no receiver tells a reader nothing + # about which detector they are looking at. `inv.sign_wd` and + # `self.freeze_wd` are the disambiguation, and they are the + # caller's own words. + out.append(f"{path.name}:{node.lineno} " + f"`{ast.unparse(node.func)}`") + return out + + +def main(): + quiet = "--quiet" in sys.argv + rounds = build_fixpoint() + rows = scan() + fam = [r for r in rows if r["symmetric"] and "MESSAGE" in r["sinks"] + and "RETURN" not in r["sinks"]] + valued = [r for r in rows if r["symmetric"] and "RETURN" in r["sinks"]] + print("SYMMETRIC-PREDICATE FAMILY - argument order load-bearing for a " + "MESSAGE only") + print("=" * 78) + print(f"fixpoint reached after {rounds} round(s); " + f"{sum(len(v) for v in _SYM.values())} interchangeable positional " + f"and {sum(len(v) for v in _SYM_KW.values())} keyword pair(s) " + f"across {len(set(_SYM) | set(_SYM_KW))} function(s) in app/") + print(f"functions formatting >=2 of their own parameters into one " + f"sentence: {len(rows)} pair(s)") + print(f"of those, pairs whose SWAP LEAVES EVERY DECISION IDENTICAL: " + f"{len(fam) + len(valued)}") + print(f" ...of which the sentence is RETURNED (so the swap is a VALUE " + f"change an ordinary check can see): {len(valued)}") + print(f" ...and the sentence goes ONLY TO AN OPERATOR - THE FAMILY: " + f"{len(fam)}") + print() + print("THE FAMILY - a swap here is invisible to every behavioural check " + "BY CONSTRUCTION:") + for r in sorted(fam, key=lambda r: (r["module"], r["line"])): + kind = ("POSITIONAL - a transposition at a call site costs ZERO BYTES" + if r["positional"] else + "KEYWORD-ONLY at the definition - a transposition has to be " + "written out by name, which is not free and not silent") + print(f" {r['module']}:{r['line']} {r['func']}() pair " + f"({r['a']}, {r['b']})") + print(f" sentence at line(s): " + f"{', '.join(f'{l} -> {s}' for l, s in r['msg_lines'])}") + print(f" {kind}") + sites = call_sites(r["func"]) + print(f" CALL SITES ({len(sites)}) - each one is a separate pin, " + f"and the receiver says which detector it is:") + for site in sites or ["(none inside app/)"]: + print(f" {site}") + print() + print("SYMMETRIC, BUT THE SENTENCE IS THE RETURN VALUE - READ THIS BUCKET " + "BY HAND.") + print("A returned string is sometimes a KEY (a swap renames a store key or " + "an MQTT topic,") + print("which is behaviour every ordinary check can see - MAINT-05 is the " + "card about it)") + print("and sometimes a SENTENCE (a swap is message-only after all, and it " + "IS this family):") + for r in sorted(valued, key=lambda r: (r["module"], r["line"])): + print(f" {r['module']}:{r['line']} {r['func']}() pair " + f"({r['a']}, {r['b']})") + print() + print("=" * 78) + print("CENSUS B - SENTENCES THE CALLER BUILDS, interpolating two values of " + "the SAME SHAPE") + print("A transposition inside one of these is ZERO BYTES and no predicate " + "is involved at") + print("all, so nothing behavioural can ever see it. Same rule: assert the " + "ORDERED PAIR.") + cb = scan_caller() + print(f"candidates: {len(cb)}") + for mod, line, fn, ua, ub in cb: + print(f" {mod}:{line} in {fn}()") + print(f" {ua} <-> {ub}") + print() + print("WHAT THIS CANNOT SEE - the shape of each gap, named rather than " + "rounded off:") + print(" 1. SEMANTIC symmetry no canonicaliser above spells. The list of " + "commutative") + print(" forms is finite and hand-written; a function symmetric only " + "through a form") + print(" not on it is REJECTED and never appears. This under-reports.") + print(" 2. In CENSUS B, two interpolated values of the same KIND that " + "are not the same") + print(" SHAPE - `a.lo` beside `hi`, or one of them behind a local " + "variable. Only") + print(" one-leaf-apart expressions are paired. `%s`-style logging " + "(`_LOG.error('%s") + print(" ... %s', a, b)`) and non-f-string payloads are not scanned at " + "all.") + print(" 3. Anything outside `app/` - `sim/`, `tools/`, the firmware, the " + "witness.") + print(" 4. WHETHER each call site is pinned. The sites are listed above; " + "whether a") + print(" check asserts the ORDERED PAIR at each one is a fact about " + "`test_*.py` and is") + print(" recorded in DECISIONS/SAFETY-38-site-standing-pin.md section " + "7. Call sites") + print(" outside `app/` are not listed either, and the site list matches " + "on the bare") + print(" METHOD NAME - three different watchdogs are all called " + "`evaluate`, so the") + print(" receiver is printed beside each one and a reader has to pick.") + print(" 5. ⚠️ IT CAN ALSO OVER-REPORT, IN ONE EXACT WAY. The sentence is " + "ERASED before") + print(" the comparison, so a swap that would make the FORMATTING " + "ITSELF fail - a") + print(" `DAY_NAMES[start]` where `start` is 1320 - is scored as " + "'changes no decision'.") + print(" `_raise_gap`'s (start, weekday) and (end, weekday) pairs are " + "that: mixed") + print(" types, and a real transposition there raises IndexError rather " + "than lying") + print(" quietly. The hazard is a SAME-TYPED pair; read the list with " + "that in hand.") + + +if __name__ == "__main__": + main()