SAFETY-38 round 3: pin the banner the SHIPPED install actually prints
checks / unit (pull_request) Failing after 3s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
2026-09-05 20:33:20 +02:00
co-authored by Claude Opus 5
parent 695da4791e
commit 97b59438dc
5 changed files with 1092 additions and 8 deletions
+50
View File
@@ -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
@@ -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).
+69 -1
View File
@@ -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
+93 -3
View File
@@ -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",
@@ -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="<msg>")
def visit_Constant(self, node):
if isinstance(node.value, str):
return ast.Constant(value="<msg>")
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 = "<leaf>"
return node
def visit_Constant(self, node):
if isinstance(node.value, str):
return ast.Constant(value="<leaf>")
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()