Compare commits

..
Author SHA1 Message Date
adminandClaude Opus 5 97b59438dc 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
2026-09-05 20:33:20 +02:00
glenn schrooyenandClaude Opus 5 695da4791e SAFETY-38 round 2: assert the ordered pair, and correct the M3 attribution
Two blockers, one of them mine to fix and one a mis-measurement to undo. No
`app/` change in either: the shipped `sum` is correct.

1. A ZERO-BYTE SURVIVOR AT THE CALL SITE ONE LINE BELOW THE PINNED VALUE.
   Round 1 pinned `site_standing` and never pinned WHERE IT GOES. Swapping the
   detector's first two arguments at main.py:1620 -
   `evaluate(site_standing, self.grid, ...)` -> `evaluate(self.grid, site_standing, ...)`
   - is a ZERO-BYTE edit and left the round-1 tree fully green. The cost is the
   banner: the site stands at -500 W of charge into a +2500 W import and the
   LATCHING, SITE-STOPPING alarm reads "commanded +2500 W against a meter
   reading -500 W" - which instrument read what, inverted, on the one line
   somebody woken by it reads first (watchdog.py:658-665).

   Round 1's check could not reach it. It asserted `"-500 W"` appears SOMEWHERE
   in the sentence; under the swap it still does, IN THE METER SLOT. A substring
   test grades PRESENCE, never ARRANGEMENT. It now asserts the ORDERED PAIR:
   `f"commanded {sum:+.0f} W against a meter reading {grid:+.0f} W"`. Measured:
   red in test_watchdog.py on that one named check, green in the other twenty.

2. THE M3 ATTRIBUTION WAS FALSE, AND HOW IT BECAME FALSE IS THE USEFUL PART.
   Round 1 recorded `list(standing.values())[0]` as closed by SAFETY-32's
   package-wide pin and told future authors NOT TO RE-COVER IT. Re-measured on
   release/1.0 @ 86eaef2 carrying only that edit: the full 21-file suite is
   GREEN and test_peakguard.py is PASS PASS PASS PASS PASS in isolation. THE
   MUTATION SURVIVES. It was originally measured once, that run hit CI-06's
   ~20% flake, and the red was recorded as a kill.

   The mechanism, read off 12j's own enumeration rather than argued: 12j taints
   names BOUND to roster values and lists ACCUMULATED in a loop. `standing` is a
   dict filled by SUBSCRIPT assignment (main.py:1554/1558), so it never enters
   the taint set and 1599 is not a roster site. On the mutated tree 12j lists
   main.py:1553, 1555 and 1690 - not 1599. Part 9a and 9b close it.

   Corrected in all four places it was committed: DECISIONS section 1 and
   section 4, CHANGELOG, and the part 9 header that carried the instruction.

MEASUREMENT, all on 86eaef2, fresh copy per mutation, mounted :ro, bytecode
purged by tools/mutation_run.sh, edit re-read from disk, byte delta printed:
  * M1-M10 re-run INDEPENDENTLY rather than carried forward - all 10 red, at
    the deltas the table records (M1/M2/M4 re-run in the exact wrapped form).
  * M11, the argument swap: 0 bytes, red on one check in one file.
  * W1 `"sign_site": SCOPE_SITE -> SCOPE_UNIT`: 0 bytes, red; part 9a is one of
    the two killers.
  * W2 `"circulation": SCOPE_SITE -> SCOPE_UNIT`: 0 bytes, red - but ENTIRELY
    on pre-existing checks. This card adds nothing to circulation and does not
    claim it.
Control: 21/21 green before and after. The suite is 21 files, not 20 - TEL-07
added test_mqtt.py in the rebase onto 86eaef2.

DECISIONS section 6a enumerates what was NOT closed: SAFETY-31's circ_wd
detector itself, the other eight CLAIM_SCOPE entries, actuator_of()'s body, the
n_units > 1 guard, and the fact that the "green in the other twenty files" half
of the ledger was re-verified on 86eaef2 only for M3 and M11.

MUTATION.md gains habit 14 - assert the ordered pair, not the substring - and
records this as the tenth instance of the family, found INSIDE THE FIX WRITTEN
TO CLOSE THE NINTH, for the second time in that list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-05 19:42:06 +02:00
adminandClaude Opus 5 2cd094a0fe SAFETY-38: record the CI-06 false red, and where each mutation lands
All ten mutations of `site_standing` are red in test_watchdog.py and in nothing
else - the other 19 files are green under every one of them, which is the
measurement of how unpinned the line was.

One exception, recorded rather than dropped: in the batch run M10
(`sorted(standing.values())[-1]`) also reported FAIL test_peakguard.py. Re-run
in isolation three times and once more through tools/mutation_run.sh - four
clean passes. The batch script had discarded the failure detail so the check
cannot be named; what can be said is that it did not reproduce in four attempts
and that no claim in this card rests on it. That is CI-06's file behaving the
way CI-06 records.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-05 19:42:06 +02:00
adminandClaude Opus 5 0f0e767f42 SAFETY-38: pin site_standing with shares that cannot answer for the site
`app/main.py:1598`'s `site_standing = float(sum(standing.values()))` is the
first argument of SAFETY-30's latching, site-stopping sign detector AND the
`commanded_w` handed to SAFETY-18's `looks_frozen()`. It was pinned by nothing.
Coverage only: not one byte of `app/` moves - the shipped `sum` is correct.

WHY THE SUITE COULD NOT SEE IT. `_sign_site`, the only N>=2 site fixture in the
repository, runs shares of about -2500 W EACH - every one individually over its
320 W gate - so sum, mean and max(key=abs) all agree on it. Its values happen to
be equal, and a check comparing two things that happen to be equal cannot see
the difference it names. MUTATION.md habit 4, ninth instance, inside the site
half of the card that names the rule in its own text. At N=1 it is arithmetic
rather than a fixture defect: sum == mean == max == the one share, identically.

THE FIXTURE (test_watchdog.py part 9). Two SIMULATED units rated 220 W and
280 W, both obeying exactly, inverted gain, house importing 2000 W: the site
commands -500 W against a +2500 W meter and NO SINGLE SHARE clears the shipped
300 W gate. Four candidate reductions, four distinct answers, one over the gate
- sum -500, mean -250, max(key=abs) -280, [0] -220 - asserted as a precondition.
The asymmetry is load-bearing, and the smaller unit is first so [0] stays
distinct from max(key=abs). Gate shipped; only the hold is shortened.

BOTH CONSUMERS. 9b drives the same discrimination at SAFETY-18's threshold: the
site rail is 0.95 x 500 = 475 W, so the sum rails it while 250, 280 and 220 do
not. Shipped: BLIND_FROZEN, floor 0 W. Under both surviving mutations: not blind
at all, no floor filed - the battery may keep importing through a quarter whose
billed average is unobservable. The open quarter is backfilled with the stuck
reading so the mutant cannot reach the same action by the coverage route and
mask the difference. 9c asserts the N=1 identity on a real one-inverter tree.

SAFETY-32 already closes `list(standing.values())[0]` structurally and this card
does not claim it. Its disclosed blind spot - semantics are invisible to a shape
pin, permanently - is why the mean and max(key=abs) needed a behavioural set.

AND THE SEARCH FOR THE NEXT SURVIVOR FOUND ONE. `float(self.target)` - the
arbiter's site target instead of the sum of what stands on the wire - is -11 B
and survives all 20 files on untouched release/1.0 @ f72c48d, because every
fixture that existed was built where the two are the same number. They separate
when a device's reported range is narrower than its max_w: the `device` claim is
filed per actuator and never bounds the site target. Closed here by a
counterfactual that asks for -500 W while -250 W stands.

10 mutations of that one line, each on a fresh full-repo copy mounted :ro,
purged by tools/mutation_run.sh, the edit re-read from disk and the byte delta
printed. Ledger and honest gaps in DECISIONS/SAFETY-38-site-standing-pin.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-05 19:42:06 +02:00
admin bbc903b2b4 Merge pull request 'PKG-06: bump 0.6.0 -> 0.7.0, because the schema changed and nobody bumped' (#55) from PKG-06-version-0.7.0 into release/1.0 2026-09-05 19:23:16 +02:00
5 changed files with 2217 additions and 6 deletions
+144
View File
@@ -31,6 +31,150 @@ guessing a boundary; saying so is more useful than a guess.
## Unreleased
**`site_standing` — the one quantity BOTH site guards are asked about — is now
pinned by a fixture whose per-unit shares cannot answer for it (SAFETY-38).**
`app/main.py`'s `site_standing = float(sum(standing.values()))` is the first
argument of [[SAFETY-30]]'s **latching, site-stopping** sign detector *and* the
`commanded_w` handed to [[SAFETY-18]]'s `looks_frozen()`. It was pinned by
**nothing**, and three mutations of it disarmed both guards with the whole suite
green. **This card changes coverage only: not one byte of `app/` moves — the
shipped `sum` is correct and must not be "fixed".**
* **⭐ THE FIXTURE THE SUITE COULD NOT BUILD: per-unit shares EACH BELOW the
site gate whose SUM clears it.** Two simulated units rated 220 W and 280 W,
both obeying exactly, inverted gain, house importing 2000 W — the site
commands **−500 W against a +2500 W meter** and no single share clears the
300 W gate. That is the case the site detector exists for, and it is the only
arrangement that can separate `sum` from the mean and from the largest share.
`test_watchdog.py` part 9a.
* **WHY THE EXISTING FIXTURE COULD NOT.** `_sign_site`, the suite's only N≥2
site fixture, runs shares of about −2500 W **each** — every one individually
over the gate — so sum, mean and largest-share all agree on it. *Its values
happen to be equal, and a check comparing two things that happen to be equal
cannot see the difference it names.* `MUTATION.md` habit 4, ninth instance,
inside the site half of the very card that names it.
* **FOUR CANDIDATE REDUCTIONS, FOUR DISTINCT ANSWERS, ONE OVER THE GATE** —
`sum` −500, mean −250, `max(key=abs)` −280, `[0]` −220 — and that is asserted
as a precondition, not described. The asymmetric ratings are load-bearing:
equal shares collapse three of the four onto one number, and the **smaller**
unit is first so `[0]` stays distinct from `max(key=abs)`. The **gate is the
shipped 300 W**; only the hold is shortened, because the hold is a wall clock.
* **⭐ THE SECOND CONSUMER IS COVERED, NOT SCOPED OUT.** The same value is
`looks_frozen()`'s `commanded_w` at `main.py:1926`. At the same roster the
site rail is 0.95 × 500 = **475 W**, so `sum` = 500 W rails it while 250, 280
and 220 do not. Shipped: the peak guard goes blind on SAFETY-18's conjunction
and files `floor_w = 0.0`. Under both surviving mutations it is **not blind at
all and files no floor**, so the battery may keep importing through a quarter
whose billed average is unobservable. `test_watchdog.py` part 9b. The open
quarter is deliberately backfilled with the stuck reading so the mutant cannot
reach the same action by the *coverage* route and mask the difference.
* **AT N=1 NOTHING CHANGES, AND IT IS ASSERTED.** On a real one-inverter
controller `standing` has one entry, so `sum`, the mean, `max(key=abs)` and
`[0]` are **the same number** — all three mutations are genuinely *equivalent*
on a shipped install and no N=1 fixture could ever separate them. Printed and
checked, beside the site detector still being absent there. Part 9c.
* **⚠️ A CORRECTION, AND IT IS THE MOST USEFUL LINE IN THIS CARD.** An earlier
revision of this entry said one of the three — `list(standing.values())[0]` —
was **already closed** by [[SAFETY-32]]'s package-wide structural pin, and
told future authors not to re-cover it. **That was false.** It had been
measured **once**, that run hit `test_peakguard.py`'s [[CI-06]] flake (a
measured **~20%** false-red rate), and the red was recorded as a mutation
**kill**. Re-measured on `release/1.0` @ `86eaef2` carrying only that edit:
the **full 21-file suite is green** and `test_peakguard.py` passes **five for
five** in isolation — **the mutation survives.** The reason SAFETY-32 cannot
see it is not the shape: part 12j taints names *bound* to roster values and
lists *accumulated* in a loop, and `standing` is a dict filled by **subscript
assignment**, so it never enters the taint set and `sum(standing.values())` is
not a roster site at all. 12j's own enumeration on the mutated tree lists
`main.py:1553`, `1555`, `1690` — **not `1599`**. **All three mutations needed
this card; all three are now covered by it.** *A single red from
`test_peakguard.py` proves nothing — re-run it five times in isolation and
name the failing check.*
* **⚠️ AND A ZERO-BYTE SURVIVOR ONE LINE BELOW THE ONE THIS CARD PINNED.**
Pinning the *value* `site_standing` said nothing about **where it goes**.
Swapping the detector's first two arguments —
`evaluate(site_standing, self.grid, …)` → `evaluate(self.grid, site_standing, …)`
— is **zero bytes** and left the tree fully green, because the check asserted
only that `"-500 W"` appeared **somewhere** in the banner. It still does:
**in the meter slot.** The swapped banner reads *"commanded +2500 W against a
meter reading −500 W"* — a site charging at −500 W into a +2500 W import, told
to the operator **exactly backwards**, on the one line somebody woken by a
latching site-stopping alarm reads first. The check now asserts the **ordered
pair** rather than a substring, and is red on it. **No `app/` change:** the
hole was in the test.
* **⭐ A SURVIVOR THIS CARD WENT LOOKING FOR AND CLOSED.**
`site_standing = float(self.target)` — the arbiter's site *target* instead of
the sum of what actually **stands on the wire** — is **−11 bytes** and
survived the full suite, because in every fixture that existed the two are the
same number. They are not when a device's reported range is narrower than its
`max_w`: the `device` claim is filed **per actuator**, so it bounds each
unit's share and never the site target. A site asking for −500 W while −250 W
stands would then take a **latching site-wide false trip** under its own
300 W gate. Closed by part 9a's device-clamped counterfactual.
* **⚠️ AND THE SAME ZERO-BYTE DEFECT ONE CALL SITE OVER — THE ONE A SHIPPED
INSTALL CAN ACTUALLY SEE.** The ordered pair went onto `main.py:1620`, the
**site** detector. That detector is armed only at N≥2, and part 9c asserts it
is **absent at a roster of one** — so on every install this add-on has, the
**per-unit** banner is the only sign-disagreement sentence that can ever be
printed, and its call at **`main.py:1560`** was still unpinned. The identical
swap there is **0 bytes** and left the tree **21/21 green**, and nothing
behavioural could ever have caught it: `disagrees()` is **symmetric** in those
two arguments (`commanded_w * against_w < 0`, `abs()` gate on both), so the
mutant takes **identical actions on every input**. What moves is the sentence
— measured, **9 emitted banner lines invert in 3 distinct sentences**,
including the one persisted to `sign_reason` and read back after a restart:
*"commanded +1000 W against its own measured power −1000 W"* becomes
*"commanded −1000 W against its own measured power +1000 W"*. Closed by the
ordered pair on **both** per-unit banners — the N≥2 `its own measured power`
one and the **N=1** `a meter reading` one — with both operands read off the
**observed cycle** rather than off the arguments the detector was handed, plus
a precondition on each that the two operands really are different and opposite
in sign. **Red on four named checks, green in the other twenty files. No
`app/` change.**
* **⭐ AND THE FAMILY IS NOW ENUMERATED BY MACHINE, because it had been
enumerated by hand twice and was short both times.**
`tools/symmetric_message_scan.py` finds every call whose argument order is
load-bearing for a **message** rather than for a decision: it renames each
parameter pair, erases every formatted string, canonicalises the commutative
operators and iterates to a **fixpoint** (the symmetry is usually one call
deep — `evaluate()` is symmetric only *through* `disagrees()`), then
classifies each sentence by where it goes. A second census covers sentences
the **caller** builds. It found **one open survivor this card does not close
and now discloses** — `rail_unreachable()`'s `dev_min`/`dev_max` at
`main.py:3208`, **0 bytes, 21/21 green**, a status-page row — and **twelve**
unpinned caller-built sentences including SAFETY-31's `circ_reason`, which is
the same shape and is also persisted. The list, the pinned status of every
member and the instrument's own blind spots are in
`DECISIONS/SAFETY-38-site-standing-pin.md` §7.
* **⚠️ AND A LEDGER SENTENCE THIS CARD ITSELF GOT WRONG, CORRECTED WITH THE
MEASUREMENT.** An earlier revision said *"before part 9 existed, **three** of
these left the whole suite green — M1, M2 and M3 — and the remaining seven
were caught by SAFETY-30's own N≥2 checks"*. **Eight** did. Re-run one at a
time on clean `release/1.0` @ `bbc903b`, fresh copy each, bytecode purged,
byte delta and `main.py` fingerprint printed, **full 21-file suite** every
time: M1, M2, M3, M4, M7, M8, M9 and M10 are all **21/21 PASS**; only M5 and
M6 go red. It is the same species of error this round exists to fix — **a
survivor recorded as a kill** — and the file contradicted itself three times
while saying it. **And the two that ARE caught make the card's case:** M5
(`sum(abs …)`) and M6 (`-sum`) are the only two that reverse the **sign**.
Every other entry changes only the **magnitude**, and the old `_sign_site`
fixture's ~−2500 W shares put nine different magnitudes on the **same side of
the same 320 W gate**. The pre-existing coverage could see a sign and nothing
else.
* **13 mutations — 11 of that line and its call site, plus 2 zero-byte edits to
the arbiter claim wiring — each on a fresh full-repo copy mounted `:ro`,
bytecode purged by `tools/mutation_run.sh`, the edit re-read from disk and the
byte delta printed. All 13 red.** Every one was **re-run independently** in
this round rather than carried forward from the previous round's report. The
claim-wiring pair (`sign_site` and `circulation` moved from site scope to unit
scope) closes two gaps a reviewer disclosed — though **`circulation`'s kill
belongs entirely to pre-existing checks and is not claimed by this card.**
⚠️ One **false red** from `test_peakguard.py` on M10 did not reproduce in four
subsequent runs; it is CI-06's known flakiness, no claim here rests on it, and
it is recorded rather than quietly dropped — unlike the one that *was*
believed, two bullets up. Ledger, and an **enumerated list of what was not
mutated**, in `DECISIONS/SAFETY-38-site-standing-pin.md` §4a, §4b and §6a.
**Inverter B gets its own MQTT device, and inverter A keeps the one it has
(TEL-07).** MQTT discovery announced **one** device, and every entity hung off
one `unique_id` prefix, so at two inverters B's per-unit telemetry had nowhere
@@ -0,0 +1,722 @@
# SAFETY-38 — pinning `site_standing`, the one quantity both site guards are asked about
**Status: DECIDED 2026-09-05.** `app/main.py`'s
`site_standing = float(sum(standing.values()))` is **correct and is not
changed**. What this card adds is the thing it never had: a **behavioural**
fixture whose per-unit shares **cannot** answer for the site, so that a change
of *meaning* in that reduction goes red instead of green.
Filed with SAFETY-38, card `6a9ac95f2b78646e5a76d2e9`. It is a **coverage**
card: not one byte of `app/` moves.
---
## 0. The line, and its two consumers
```python
site_standing = float(sum(standing.values())) # app/main.py:1598
```
`standing` is `{unit name -> the value THIS controller last put on that unit's
setpoint entity}` — `inv.last_write`, i.e. the number **after**
`clamp_to_rating()` has applied `max_w` and the device's own
`[dev_min, dev_max]`. Two safety inputs are derived from it and nothing else in
`cycle()` is:
| consumer | where | what it does with it |
|---|---|---|
| **[[SAFETY-30]]**'s site sign detector | `self.site_sign_wd.evaluate(site_standing, self.grid, …)` (main.py:1619) | **latches and stops the whole site** when the total command opposes the meter for `site_sign_disagree_s` |
| **[[SAFETY-18]]**'s freeze conjunction | `looks_frozen(unchanged_s, site_standing, max_w, …)` inside `peakguard.decide(frozen=…)` (main.py:1926) | takes the peak guard **blind** and files `floor_w = 0.0` — the battery may not import while the billed quarter is unmeasurable |
Both are P0 paths. **Neither was pinned to the reduction.**
## 1. What survived, and what the rescope measured
The card opened with three demonstrated live mutants, all green across the whole
suite. Re-measured in round 2 against `release/1.0` @ `86eaef2`:
| mutation of `site_standing` | on release/1.0 @ 86eaef2 | closed by |
|---|---|---|
| `list(standing.values())[0]` — one unit's share | **SURVIVES**, full suite 21/21 green | part 9a + 9b (this card) |
| `sum(...) / max(1, len(standing))` — the mean | **SURVIVES**, full suite 21/21 green | part 9a + 9b (this card) |
| `max(standing.values(), key=abs)` — the largest share | **SURVIVES**, full suite 21/21 green | part 9a + 9b (this card) |
### ⚠️ ROUND 1 SAID `[0]` WAS ALREADY CLOSED BY SAFETY-32. THAT WAS FALSE.
Round 1 of this card recorded `[0]` as **CAUGHT by SAFETY-32's package-wide
structural pin**, wrote that claim into four places, and told future authors
*"DO NOT RE-COVER THAT ONE HERE"*. It is wrong, and **how** it went wrong is the
part worth keeping.
**How the error was made.** The mutation was measured **once**. That single run
happened to hit `test_peakguard.py`'s [[CI-06]] flake — a **measured ~20%**
false-red rate — and the red was recorded as a **mutation kill**. Nothing else
was checked, because a red is the answer you are hoping for and it did not look
like it needed a second run.
**The correct measurement.** On `release/1.0` @ `86eaef2` carrying **only** the
`[0]` edit (+4 bytes, re-read from disk, bytecode purged by
`tools/mutation_run.sh`): the **full 21-file suite is green**, and
`test_peakguard.py` run **five times in isolation** on that same mutated tree is
**PASS PASS PASS PASS PASS**. The mutation **survives**. (The reviewer's own
independent re-measurement of the same tree hit the flake once —
PASS PASS PASS FAIL PASS — which is the same verdict plus a live demonstration
of the mechanism that produced the original error.)
**Why SAFETY-32 cannot see it, read off 12j's own enumeration rather than
argued.** Part 12j taints names *bound* to roster-carrying values and lists
*accumulated* in a loop over one. `standing` is neither: it is a dict created
empty at `main.py:1554` and filled by **subscript assignment**
(`standing[inv.name] = stood`) inside the roster loop, and a subscript store is
not a binding 12j follows. So `standing` never enters its taint set and
`sum(standing.values())` is **not a roster site** at all. Run 12j against a tree
carrying the `[0]` mutation and its enumeration lists `main.py:1553`, `1555` and
`1690` — the three `self.inverters` sites bracketing this code — and **not
`1599`**. No shape pin reaches this line; only the behavioural fixture below
does.
**The rule this cost.** `test_peakguard.py` flakes at a measured ~20%. A single
red from it proves nothing. Re-run it in isolation **at least five times** and
**name the failing check** before believing any red from it. This card is the
**third** measurement CI-06 has corrupted.
SAFETY-32's own disclosed blind spot says why the other two were always out of
reach:
> *SEMANTICS. `min` → `max` → the mean are edits invisible to any shape pin,
> permanently. Only the behavioural sets can see them.*
`sum` → mean and `sum` → `max(key=abs)` are **semantic**: identical shape,
different meaning. **No shape pin will ever close them.** That disclosure was
accurate, and is now confirmed by measurement rather than taken on trust.
`[0]` is the one that *looks* structural and therefore *looks* covered — and
that appearance is exactly what produced the false attribution above. It is
out of reach here for a different reason from the other two: not because the
shape is invisible, but because **the value never becomes a roster** in the
analysis's eyes. All three are closed by the same behavioural fixture, and
**all three needed it.**
## 2. Why the suite could not see it — habit 4, inside the site half of the very card that names it
`test_watchdog.py`'s `_sign_site` is the **only** N≥2 site fixture in the
repository. It runs at `site_sign_disagree_w: 320` with shares of roughly
−2500 W **each**. Every one of them is individually far over the gate, so on
those numbers
```
sum = −5000 W over mean = −2500 W over max = −2500 W over
```
— **all three reductions agree**, and a check whose two candidate answers happen
to be equal cannot observe a change that swaps them. That is `MUTATION.md`'s
third failure mode, and this is its ninth instance.
At a roster of **one** it is not a fixture defect but arithmetic:
`sum == mean == max == the one share`, identically, so all three mutations are
genuinely **equivalent** on a shipped install. No N=1 fixture can ever separate
them; §3c asserts that rather than leaving it to the reader.
## 3. The fixture, and the numbers it is built on
Two **simulated** units rated 220 W and 280 W, both obeying exactly, inverted
gain, house importing 2000 W. Both stand at their own rails, so `standing` is
`{-220, -280}` in roster order and the meter reads +2500 W.
| reduction | value | vs the SHIPPED 300 W gate |
|---|---|---|
| `sum` | **−500 W** | **OVER** → trips |
| `sum / len` (the mean) | −250 W | under → silent |
| `max(…, key=abs)` | −280 W | under → silent |
| `list(…)[0]` | −220 W | under → silent |
**Four distinct magnitudes — 500, 250, 280, 220 — of which exactly one clears
the gate.** The asymmetry is load-bearing twice: equal shares would collapse
mean, max and first onto one number, and putting the **smaller** unit first is
what keeps `[0]` distinct from `max(key=abs)`. The gate is left at its shipped
default so the fixture is graded against the magnitude an install runs; only the
**hold** is shortened (325 s → 2 s), because the hold is a wall clock and the
campaign behind 325 s is measured on the rig, not here.
⚠️ **220 W and 280 W are SIMULATED ratings chosen for that arithmetic and for
nothing else.** They are not measurements. In particular they are **not**
inverter B, which does not physically exist; B is a GW5048D-ES, serial
`55048ESU22AW0992`, **4600 W from its datasheet** (`goodwe/NOTES.md` §1.1b).
### 3a. The site sign detector
Shipped tree: the site latches, commands 0 W on every inverter, and the sentence
an operator reads is
`SIGN_DISAGREEMENT: commanded -500 W against a meter reading +2500 W …`. No
per-unit watchdog trips — every unit is faithfully executing its share, which is
the asymmetry the site detector exists for.
Under the mean and under `max(key=abs)`: **nothing trips, ever.** The reviewer's
tree was still commanding −500 W of charge into a +2500 W import at t = 595 s
with `following=True`.
Two counterfactuals sit beside it so the trip is attributable:
* the **same** inverted-gain site whose shares sum to −250 W — under the gate —
does **not** trip, so the trip above is the sum crossing 300 W and not the
mere presence of a second inverter;
* a site whose **device range is narrower than `max_w`** asks for −500 W while
only −250 W stands, and does **not** trip — see §5.
### 3b. The second consumer, covered rather than scoped out
`site_standing` is also `looks_frozen()`'s `commanded_w`. The same
discrimination applies at a different threshold: the site rail is
`RAIL_FRAC * site_max_w` = 0.95 × 500 = **475 W**.
| reduction | value | vs the 475 W rail |
|---|---|---|
| `sum` | **500 W** | **RAILED** → `BLIND_FROZEN`, `floor_w = 0.0` |
| the mean | 250 W | under → silent |
| `max(…, key=abs)` | 280 W | under → silent |
| `list(…)[0]` | 220 W | under → silent |
The sign is flipped from §3a on purpose: the gain is the **shipped positive**
one and the frozen meter reads import, so the site *discharges* +500 W against a
+2500 W import — the two agree in sign, SAFETY-30 has nothing to say, and this
half grades SAFETY-18 alone. Reusing §3a's inverted gain would have the site
sign latch park everything at 0 W, and the freeze conjunction could then never
be true: a check measuring the *other* guard's remedy.
⚠️ **The open quarter is backfilled with the same stuck reading, and that is
not decoration.** Without it the block is mostly uncovered, `blindness()`
answers `BLIND_COVERAGE`, and the mutant reaches *the same action by a different
route* — a check on the action alone could not then see the difference it names.
Seeded, the measurement is honest:
* shipped: `blind = BLIND_FROZEN`, `floor_w = 0.0` — no charging while the
billed quarter is unobservable;
* both mutations: **not blind at all**, `floor_w = None` — the site may go on
importing through a quarter whose billed average cannot be measured. That is
the capacity-tariff hazard SAFETY-18 exists for, silently disarmed.
The counterfactual comes from the **same run**: on the earlier cycles the
command had not yet reached the rail and the guard did not report SAFETY-18,
with the meter equally frozen throughout — so the verdict is produced by the
command magnitude `site_standing` carries and not by `unchanged_s` alone. A low
gain (0.05, the schema floor) is what buys those below-the-rail cycles.
### 3c. At N=1, nothing changes — asserted
A real one-inverter controller is driven through the same frozen-meter
scenario. Its `standing` has a single entry, so `sum`, the mean,
`max(key=abs)` and `[0]` are printed and asserted to be **one number**; the
site sign detector is still absent (part 7 counts the calls on the object); and
SAFETY-18 still reaches `BLIND_FROZEN` on that same value. The claim "the
shipped install is unaffected" now rests on the roster rather than on the
reader.
## 4. Mutation ledger
Every run: a **fresh full-repo copy**, `__pycache__` and `*.pyc` excluded,
mounted `:ro`, graded by `tools/mutation_run.sh` (which purges, prints the tree
fingerprint, and verifies no cache appears during the run). The mutated line was
**re-read from disk** after the edit and the byte delta printed. Shipped
`app/main.py` is 234 542 bytes at `86eaef2`.
| # | mutation of `site_standing` | Δ bytes | verdict |
|---|---|---|---|
| M1 | `sum(...) / max(1, len(standing))` — the mean | +24 | **RED** |
| M2 | `max(standing.values(), key=abs)` — largest share | +9 | **RED** |
| M3 | `list(standing.values())[0]` — one share | +4 | **RED** — and **survives on release/1.0**, see §1 |
| M4 | `min(standing.values(), key=abs)` — smallest share | +9 | **RED** |
| M5 | `sum(abs(v) for v in ...)` — magnitude, sign lost | +16 | **RED** |
| M6 | `-sum(standing.values())` — sign flipped | +1 | **RED** |
| M7 | `float(self.target)` — the arbiter's site target | **−11** | **was a SURVIVOR — now RED**, see §5 |
| M8 | `sum(standing.values()) / 2.0` | +6 | **RED** |
| M9 | `sum(standing.values()) * 2.0` | +6 | **RED** |
| M10 | `sorted(standing.values())[-1]` — largest signed | +7 | **RED** |
| **M11** | **argument SWAP** at the SITE call site: `evaluate(site_standing, self.grid, …)` → `evaluate(self.grid, site_standing, …)` (main.py:1620) | **0** | **was a SURVIVOR in round 1 — now RED**, see §4a |
| **M12** | **argument SWAP** at the PER-UNIT call site: `evaluate(stood, against, …)` → `evaluate(against, stood, …)` (**main.py:1560**) | **0** | **was a SURVIVOR in round 2 — now RED**, see §4c |
**Every one of the ten is red in `test_watchdog.py`.** Re-measured in round 2
on `release/1.0` @ `86eaef2` (where `app/main.py` is 234 542 bytes, 2 022 more
than the `f72c48d` the round-1 figures were taken on): all ten still red, at the
byte deltas in the table, each on a fresh copy with the edit re-read from disk.
Adding assertions cannot turn a red green, so those verdicts carry forward to
the round-3 tree unchanged.
### ⚠️ ROUND 2 SAID THREE OF THESE SURVIVED. **EIGHT DID.**
Round 2 wrote: *"Before part 9 existed, three of these left the whole suite
green — M1, M2 and M3 — and the remaining seven were caught by SAFETY-30's own
N≥2 checks."* Round 1's wording was vaguer and harmless; naming three made it
**specific and wrong**, and it is the same species of error the round was
written to fix — **a survivor recorded as a kill**. This file contradicted it
three times on its own pages: the ledger row calls M7 *"a SURVIVOR — now RED"*,
§5 says M7 survived the whole suite, and `CHANGELOG.md` says so too.
**Re-measured in round 3, one mutation at a time, on clean `release/1.0` @
`bbc903b`** — a fresh copy each, bytecode purged (`0` remaining, printed), the
line re-read from disk, byte delta **and** `app/main.py` fingerprint printed,
mounted `:ro`, and the **FULL 21-FILE SUITE** run each time rather than
`test_watchdog.py` alone:
| # | Δ bytes | fingerprint of `app/main.py` | suite before part 9 |
|---|---|---|---|
| M1 | +24 | `362374cfb6486378` | **21/21 PASS — SURVIVOR** |
| M2 | +9 | `86573e7494f1c933` | **21/21 PASS — SURVIVOR** |
| M3 | +4 | `59fa8815e6fda4cf` | **21/21 PASS — SURVIVOR** |
| M4 | +9 | `43d852be3037f861` | **21/21 PASS — SURVIVOR** |
| M5 | +16 | `58d26de82af29a1a` | **RED** — `test_watchdog.py`, 3 checks |
| M6 | +1 | `407ddb5f375cc217` | **RED** — `test_watchdog.py`, 3 checks |
| M7 | **−11** | `2a635a4eca0a790d` | **21/21 PASS — SURVIVOR** |
| M8 | +6 | `b21eed89ff4c9240` | **21/21 PASS — SURVIVOR** |
| M9 | +6 | `0973d3caf680db65` | **21/21 PASS — SURVIVOR** |
| M10 | +7 | `0e7b7a4fed7787d0` | **21/21 PASS — SURVIVOR** |
**So: EIGHT of the ten left the whole suite green, and TWO were caught** — not
three and seven, and not the four and six a reviewer proposed either. The
correct sentence is: *before part 9 existed, eight of these ten left the whole
21-file suite green, and the only two SAFETY-30's own N≥2 checks caught were M5
and M6.*
**⚠️ AND THE TWO THAT WERE CAUGHT ARE CAUGHT FOR A REASON THAT MAKES THE CARD'S
CASE RATHER THAN WEAKENING IT.** M5 (`sum(abs(v) …)`) and M6 (`-sum(…)`) are
the only two entries in the table that **reverse the SIGN** of `site_standing`.
Every other entry changes only its **magnitude** — and `_sign_site`, the suite's
only N≥2 site fixture before this card, runs shares of about **−2500 W each**
against a **320 W** gate. `sum`, the mean, `max(key=abs)`, `min(key=abs)`,
`sorted(...)[-1]`, `[0]`, `self.target`, half the sum and twice the sum all land
on **−2500, −2500, −2500, −2500, −2500, −2500, −2500, −1250 and −5000 W**: nine
different numbers, all on the **same side of the same threshold**. The three
checks that fired on M5 and M6 grade *"the site trips and no unit does"*, the
claim it trips through, and where the latch is written — none of them grades the
**quantity**, and the sign flip reaches them only because it stops the trip
happening at all.
That is the whole thesis of this card measured rather than argued: **the
pre-existing coverage could see a sign, and nothing else.** Part 9's fixture —
per-unit shares each **below** the gate whose **sum** clears it — is the only
arrangement in which the magnitude is load-bearing, and it is what takes the
other eight red.
⚠️ **ONE FALSE RED, AND IT IS [[CI-06]]'s.** In the batch run M10 also reported
`FAIL test_peakguard.py`. Re-run in isolation **three times** and once more
through `tools/mutation_run.sh` — **four clean passes, zero failures**. The
batch script had discarded the failure detail, so the failing check cannot be
named here; what can be said is that the red did not reproduce in four attempts
and that **no claim in this card rests on it**. `test_peakguard.py` is the file
CI-06 records as flaky, and this is the behaviour it records. Believe no red
from it without three isolated re-runs.
⚠️ **ROUND 3 CAN NAME ONE, AND IT IS THE SAME FILE.** Round 3's own batch — the
ten full-suite runs above, plus §7's sibling swaps — produced **no** red from
`test_peakguard.py` on M10 or on any other ledger entry. It produced exactly one
elsewhere, on a **`static_tariff.py`-only** edit (§7b, A4), and this time the
failing check *was* captured: *"one cycle of the intent form is exactly the
delivered form plus the command standing on the wire"* — which is CI-06's
**mechanism 1 by name**, the cross-time projection. `test_peakguard.py` then
passed **four for four** in isolation on that same tree. That is the first time
this card has been able to put a name to one of these reds, and it is the name
CI-06 predicts.
⚠️ **M3 IS NOT "FOR COMPLETENESS", AND ROUND 1 SAID IT WAS.** Round 1 recorded
M3 as already closed structurally by SAFETY-32 and disclaimed it. §1 above has
the correction and the mechanism: `[0]` **survives** a full green 21-file suite
on `release/1.0` @ `86eaef2`, because `standing` is a dict filled by subscript
assignment and never enters 12j's taint set. **M3 is a real kill by this card's
fixture and by nothing else in the repository.** It is claimed here.
### 4a. ⚠️ The zero-byte survivor one line below the one this card pinned
Round 1 pinned the **value** `site_standing` and never pinned **where it goes**.
Swapping the detector's first two arguments at `main.py:1620` is a **zero-byte**
edit — the same characters, reordered — and it left the round-1 tree **21/21
green**.
What it costs is the banner. `SignWatchdog.evaluate()` formats
`commanded_w` first and `against_w` second, so under the swap part 9a's own
output reads:
```
SIGN_DISAGREEMENT: commanded +2500 W against a meter reading -500 W for 2 s …
```
The site is standing at **−500 W of charge into a +2500 W import** and the
**latching, site-stopping** banner tells the operator **the exact opposite** —
which instrument read what, inverted. `watchdog.py:658-665` names this failure
in its own words: *"a false statement about which instrument produced the
evidence, on the one line somebody woken by this alarm reads first."*
**Why round 1's check could not see it, and this is the general lesson.** It
asserted
```python
f"{sum(_share38):.0f} W" in _c38.site_sign_wd.reason
```
— that `"-500 W"` appears **somewhere** in the sentence. Under the swap it still
does: in the **meter slot**. A substring test cannot reach a failure that is
purely **positional**, and round 1's own comment claimed the opposite ("*would
put a different number in the store, the action log and the banner, and this is
where that shows*"). It shows the **number**, never its **position**. This is
MUTATION.md's *"a check whose data cannot reach the failure it names"*, in the
card that exists to complain about exactly that.
**The fix is one line and touches no `app/` code** — assert the **ordered
pair**:
```python
f"commanded {sum(_share38):+.0f} W against a meter reading "
f"{_mid38['grid']:+.0f} W" in _c38.site_sign_wd.reason
```
Measured: with that line, M11 is **red in `test_watchdog.py` and green in the
other twenty files** — one file, one check.
### 4b. Claim wiring, re-measured rather than assumed
Round 1's reviewer disclosed it had **not** mutated the arbiter claim wiring or
[[SAFETY-31]]/circulation. Both were mutated in round 2. Both are **zero-byte**
edits to `arbiter.CLAIM_SCOPE`:
| # | mutation | Δ bytes | verdict |
|---|---|---|---|
| W1 | `"sign_site": SCOPE_SITE` → `SCOPE_UNIT` | **0** | **RED** — `test_watchdog.py` (parts 5 **and 9a**), `test_arbiter.py`, `test_peakguard.py` |
| W2 | `"circulation": SCOPE_SITE` → `SCOPE_UNIT` | **0** | **RED** — `test_watchdog.py`, `test_arbiter.py`, `test_peakguard.py` |
Part 9a's *"…and it stops the WHOLE site, through the `sign_site` claim by name"*
is one of the two checks that kills W1, so this card contributes to that
coverage rather than merely inheriting it. **W2 is caught entirely by
pre-existing checks** — SAFETY-38 adds nothing to circulation, and does not
claim to.
### 4c. ⚠️ The SAME zero-byte defect one call site over — and it is the one the shipped install can see
Round 2 closed §4a by asserting the ordered pair on the **site** banner at
`main.py:1620`. It did not touch `main.py:1560`, the **per-unit** call, and the
identical swap there —
```python
if inv.sign_wd.evaluate(stood, against, self.following, now) # shipped
if inv.sign_wd.evaluate(against, stood, self.following, now) # M12
```
— left the round-2 tree **21/21 green**. Measured: `app/main.py` 234 542 bytes
before and after, **Δ 0 bytes**, fingerprint `61aa413f…` → `e57e6026…`, bytecode
purged, tree mounted `:ro`, the line re-read from disk.
**Nothing behavioural can catch it, and that is structural rather than
unlucky.** `watchdog.disagrees()` is *symmetric* in its first two arguments —
`commanded_w * against_w < 0`, with an `abs()` gate on **both** — so the swap
cannot change a single trip decision on any input. What it changes is the
**sentence**. Measured by diffing the suite's own printed banners between the
round-3 tree and the same tree carrying only M12: **9 emitted banner lines
invert, in 3 distinct sentences.**
```
shipped: SIGN_DISAGREEMENT: commanded -2000 W against a meter reading +2000 W …
M12: SIGN_DISAGREEMENT: commanded +2000 W against a meter reading -2000 W …
shipped: … commanded +1000 W against its own measured power -1000 W …
M12: … commanded -1000 W against its own measured power +1000 W …
shipped: … commanded -2000 W against a meter reading +4000 W … (the N=1 arm)
M12: … commanded +4000 W against a meter reading -2000 W …
```
**⚠️ AND IT IS WORSE THAN THE ONE ROUND 2 CLOSED.** Part 9c asserts the SITE
detector is **not armed at a roster of one**, and inverter B does not physically
exist. So on the shipped install the **per-unit banner is the only
sign-disagreement sentence that can ever be printed** — in the action log, in
`_LOG.error`, in `status_payload()`'s headline banner, and in `sign_reason`,
which is persisted and read back after the restart the latch is designed to
survive. Round 2 pinned the N≥2-only call site and left the shipping one open.
**Closed by the ORDERED PAIR on both per-unit banners**, with both operands read
off the **observed cycle** — `inv.last_write` and `inv.batt` off the objects the
plant moved, never off the arguments the detector was handed, which would be
comparing the swap against itself:
* `test_watchdog.py` **part 3**, the N≥2 per-unit trip:
`f"commanded {stood:+.0f} W against its own measured power {meas:+.0f} W"`,
plus the persisted `unit/inverter-b/sign_reason` copy, plus a **precondition**
that the two operands are different numbers of opposite sign — without which
a transposition would produce the same string and the check would be
decoration;
* `test_watchdog.py` **part 7**, the **N=1** controller-driven trip:
`f"commanded {stood:+.0f} W against a meter reading {grid:+.0f} W"`, plus the
namespaced key **and** the flat rollback mirror, plus the same precondition.
**Measured after the fix: M12 is RED on four named checks in
`test_watchdog.py` and GREEN in the other twenty files** — one file, four
checks. M11 re-measured on the same tree: still red.
**⚠️ Neither §6a nor MUTATION.md disclosed it.** §6a's "what was NOT closed"
enumerated `circ_wd`, the eight other `CLAIM_SCOPE` entries, `actuator_of()`'s
body and the `n_units > 1` guard — not this. MUTATION.md's own closing rule, in
the file round 2 edited, is *"a variant is not covered because a near-miss of it
is."* §7 is the enumeration that would have caught both instances at once.
## 5. The survivor this card went looking for, and closed
M7 is not one of the card's three. It was found by asking the question the
recurring defect demands — *what mutation would my new checks NOT see?* — and it
is the answer: **−11 bytes**, and it survived every check above **and the whole
20-file suite**, because in every fixture built so far the arbiter's site target
and the sum of what actually stands on the wire are **the same number**.
They are not the same number when a setpoint device's own reported range is
narrower than its `max_w`. The `device` P_SAFETY claim is filed **per actuator**
(`actuator_of("device", inv)`), so it bounds each unit's **share** and never the
site target, and `clamp_to_rating()` clips again on the way out. On two units
rated 220/280 W whose devices report ±120/±130 W, the site asks for **−500 W**
and **−250 W** stands.
Which one the guards mean is already written down, in both of them: SAFETY-02
grades *"the command standing on the wire"*, and `looks_frozen()`'s docstring
says `commanded_w` is *"the EMITTED setpoint — what the arbiter resolved and
`write_setpoint()` put on the wire — not the loop's raw wish"*. [[SAFETY-20]] is
the whole card about the difference. Those sentences are now made to bite: on
the site target the detector fires on a site standing at −250 W against a
+2250 W meter — **under its own 300 W gate** — which is a latching, site-wide
false trip on an install whose inverters simply report a narrower range than the
operator typed.
## 6. Honest gaps
* **A mutation that changes the SIGN CONVENTION of `standing` itself** — i.e. in
`main.py`'s `stood = inv.last_write if inv.last_write is not None else
inv.target`, not on the reduction line — is **not** in this ledger. It is a
different line and a different card; §3a's obedience precondition would see
some of it, but that is not a measured claim and is not made here.
* **The freeze half is graded at ONE roster length (2) and one rail.** The
`RAIL_FRAC` constant, `rail_ceiling_w()`'s device-bound conjunction and
`rail_unreachable()` are `test_freeze.py`'s subject and are untouched here.
* **The site hold is 2 s, not the shipped 325 s.** The window's derivation is a
rig campaign (`DECISIONS/SAFETY-30-per-unit-sign.md` §3B) and this file does
not re-grade it. The **gate** is the shipped 300 W, which is the coordinate
this card is about.
* **Everything about inverter B here is simulated and labelled so.** No number
in this document or in the fixture is a measurement of a device that does not
yet exist.
### 6a. Round 2: what was closed, and what was NOT
Round 1's reviewer disclosed that it had not mutated SAFETY-31/circulation, the
arbiter claim wiring, or ledger entries M4/M5/M6/M8/M9/M10. Enumerated, with the
gaps left open stated rather than rounded off:
**Closed in round 2 (measured, on `86eaef2`, bytecode purged, edit re-read from
disk, byte delta printed):**
1. **M11**, the zero-byte argument swap — §4a. Red on one named check.
2. **M3**, the `[0]` reduction — re-measured, **survives on release/1.0**, closed
by part 9a and 9b. The false attribution corrected in all four places it was
committed: this file §1 and §4, `CHANGELOG.md`, and `test_watchdog.py`'s part
9 header (which previously *instructed future authors not to re-cover it*).
3. **M1, M2, M4, M5, M6, M7, M8, M9, M10** — every ledger entry re-run
independently in round 2 rather than carried forward from round 1's report.
All ten red; M1/M2/M4 re-run in the exact wrapped form the table records so
the byte deltas are the table's own (+24/+9/+9).
4. **W1**, `sign_site` claim scope — §4b. Zero bytes, red, part 9a among the
killers.
5. **W2**, `circulation` claim scope — §4b. Zero bytes, red, but **entirely on
pre-existing checks**.
**NOT closed, and not claimed:**
* **SAFETY-31's `circ_wd` detector itself** — its threshold, its hold, its
`circulating_w()` reduction over the roster. Only its **claim scope** was
mutated (W2), and that kill belongs to checks this card did not write.
Circulation's own behavioural coverage is `test_watchdog.py` part 4's and is
untouched here.
* **The other eight `CLAIM_SCOPE` entries** — `loop`, `peak`, `arbitrage`,
`safety` (site) and `device`, `supervised`, `failsafe`, `maintenance` (unit).
`CLAIM_SCOPE` has ten entries; only the two named above were mutated. `test_peakguard.py` part 10f grades the
classification statically; that is a different instrument and this card did
not re-measure it.
* **`actuator_of()`'s own body** and the `n_units > 1` guard on the `sign_site`
append at `main.py:1764` were **not** mutated.
* **The ten ledger mutations were re-graded against `test_watchdog.py` only.**
The "green in the other twenty files" half of the round-1 claim was
re-verified on `86eaef2` for **M3** and **M11** (both full 21-file runs) and is
otherwise carried forward from round 1's measurement on `f72c48d`. It is a
weaker statement than round 1 made and is written down as such.
⚠️ **Round 3 closed the other half of this**, but only in the *before*
direction: all ten were re-run on clean `release/1.0` @ `bbc903b` under the
**full 21-file suite** to establish which of them the pre-existing coverage
could see — see §4's ⚠️ paragraph, and the answer was **eight survivors, not
three**. The *after* direction — "each of the ten is red in
`test_watchdog.py` and green in the other twenty **on the round-3 tree**" —
is still measured end-to-end only for **M3, M11 and M12**. For the other
seven, "red" carries forward from round 2 (which adding assertions cannot
undo) and "green elsewhere" carries forward from round 1.
* **`test_peakguard.py`'s CI-06 flake is not fixed by this card** and is not
this card's subject. It is *characterised* here (five isolated runs on the M3
tree, all PASS) only far enough to overturn the false kill it produced.
### 6b. Round 3: what was closed, and what is still open
**Closed in round 3:**
1. **M12**, the zero-byte argument swap at the **per-unit** call site
`main.py:1560` — §4c. It was a survivor of the round-2 tree at **21/21
green**, and it is the **only** sign-disagreement banner a shipped
one-inverter install can print. Red on four named checks, green in the other
twenty files.
2. **The ledger arithmetic in §4**, which round 2 made specific and wrong. See
§4's own ⚠️ paragraph: re-measured mutation by mutation on clean
`release/1.0` @ `bbc903b`, full 21-file suite each time.
3. **The family itself is now enumerated by machine** —
`tools/symmetric_message_scan.py`, §7 — rather than by hand for a third time.
**Open, named, and NOT claimed:**
* **A3 and B1** — the only two status-page rows in which the freeze detector
ever names a device range: the **NOT COVERED** row and the **armed** row's
"below max_w because …" clause. `rail_unreachable()`'s `dev_min`/`dev_max` at `main.py:3208` and the
literal `f"only {inv.dev_min}...{inv.dev_max} W"` at `main.py:3231`. Both
measured: **0 bytes each, 21/21 green each.** Real, open members of the
family. They are **status-page rows rather than latching alarms**, and closing
them wants a fixture with a device whose two bounds are **asymmetric** — every
fixture in this repository uses `±d`, so the transposition is currently
invisible by construction — which belongs to `test_freeze.py`'s card, not to
this one.
* **Twelve census-B sites** (§7c, B2–B12) determined unpinned at the **source
level** — the distinguishing literal appears in no `test_*.py` — and **not**
mutated. Weaker evidence than A1–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).
+135 -3
View File
@@ -135,12 +135,15 @@ manufactures them from a **fixture**: the check runs, the assertion is
evaluated, the sentence above it is true — and the numbers it compares are
arranged so that the failure it describes could not have changed the answer.
It is not hypothetical and it is not rare. EIGHT instances in two weeks; four
It is not hypothetical and it is not rare. TEN instances in two weeks; four
were found by review, two - CFG-04's fixture pair - by an author's own mutation
battery, which is the cheaper place, one by review again, in the one
place nobody had thought to point the rule at: not a check in the suite, but a
COMMITTED EVIDENCE ARTIFACT four documents cite - and the eighth INSIDE THE FIX
WRITTEN TO CLOSE THE SEVENTH.
COMMITTED EVIDENCE ARTIFACT four documents cite - the eighth INSIDE THE FIX
WRITTEN TO CLOSE THE SEVENTH, the ninth inside the SITE HALF of the card
that names the rule in its own text - and the TENTH INSIDE THE FIX WRITTEN TO
CLOSE THE NINTH, for the second time in this list. See SAFETY-38 below: the
check that closed it asserted a NUMBER and not the number's POSITION.
⚠️ **AND IT HAS NOW APPEARED IN SIX CONSECUTIVE CARDS.** The seventh instance,
MAINT-05's, is the one that says why: the defect is not carelessness about
@@ -234,6 +237,65 @@ same place twice, the check is decoration.
assertion cannot fail is exactly the thing this section is about. Two habits
follow, and both are habit 5 and 6 below.
* **SAFETY-38** — `main.py`'s
`site_standing = float(sum(standing.values()))`, the first argument of
SAFETY-30's latching site sign detector *and* SAFETY-18's `looks_frozen()`
input. `test_watchdog.py`'s `_sign_site` is the suite's **only** N≥2 site
fixture, and it runs shares of about **−2500 W each** — every one
individually over the 320 W gate it is configured with. On numbers like those
`sum`, the mean and `max(key=abs)` **all agree**, so the reduction could be
swapped for either of the other two and the site guard went **blind with the
whole suite green** — still commanding −500 W of charge into a +2500 W import at
t = 595 s with `following=True`. At a roster of **one** it is worse and it is
not a fixture defect at all: `sum == mean == max == the one share`
*identically*, so every such mutation is genuinely **equivalent** there and no
N=1 fixture could ever separate them. The fixture that separates them is the
one the detector exists for — **per-unit shares EACH BELOW the gate whose SUM
clears it** — and it is now `test_watchdog.py` part 9, at asymmetric ratings
so that all four candidate reductions give four **distinct** answers.
⚠️ **AND THE CARD'S OWN SEARCH FOR ITS NEXT SURVIVOR FOUND ONE, WHICH IS THE
POINT OF ASKING.** Having built the fixture, the question *what mutation would
this NOT see?* produced `float(self.target)` — the arbiter's site target
instead of the sum of what stands on the wire — at **−11 B**, green across the
whole suite, because every fixture in the repository was built where the two
are the same number. They separate only when a device's reported range is
narrower than its `max_w`, since the `device` claim is filed **per actuator**
and never bounds the site target. Closed in the same commit, by a
counterfactual that asks for −500 W while −250 W stands.
⚠️ **AND THEN THE FIX ITSELF WAS THE TENTH INSTANCE — A SUBSTRING WHERE THE
FAILURE WAS POSITIONAL.** The check written to pin the banner asserted
`f"{sum(...):.0f} W" in reason`: that **"−500 W" appears somewhere** in the
sentence. Swapping the detector's first two arguments
(`evaluate(site_standing, self.grid, …)` → `evaluate(self.grid, site_standing, …)`)
is a **ZERO-BYTE** edit that leaves both numbers in the string and moves each
into the other's slot — so the substring is still found, **in the meter
slot**, and the whole suite stayed green while the latching site-stopping
banner told the operator which instrument read what **exactly backwards**.
The fix is to assert the **ORDERED PAIR**,
`f"commanded {sum:+.0f} W against a meter reading {grid:+.0f} W"`.
⚠️ **AND THE ELEVENTH WAS THE SAME DEFECT ONE CALL SITE OVER — THE ONE A
SHIPPED INSTALL CAN ACTUALLY SEE.** Round 2 pinned the operand order of
`SignWatchdog.evaluate()` at `main.py:1620`. That call site is the **site**
detector, and `test_watchdog.py` part 9c asserts it is **never armed at a
roster of one** — which is every install this add-on has. The **per-unit**
call at `main.py:1560` was left unpinned, and swapping its first two
arguments is the same **ZERO-BYTE** edit: `disagrees()` is symmetric in them
(`commanded_w * against_w < 0` with an `abs()` gate on both), so **no trip
decision can change** and the full 21-file suite stays green — measured. What
moves is five banner lines, including the one persisted to `sign_reason` and
re-read after a restart: *"commanded −1000 W against its own measured power
+1000 W"*, operands inverted, on the only sign-disagreement sentence a
one-inverter install can ever print. Closed in round 3 by the ordered pair on
**both** per-unit banners — the N≥2 `its own measured power` one and the N=1
`a meter reading` one — red on four named checks, green in the other twenty
files. **Habit 15 is what falls out of finding the same shape twice in the
same card.**
The general habits are **14** and **15** below.
Note what these have in common with a masked rung: the mutation *runs*, and the
suite is *honestly* green. Nothing in a purge, a fingerprint or a disassembly
can see it, because the code under test really did behave identically on the
@@ -363,6 +425,76 @@ Cheap habits fall out of it, and all of them are now used in this repository:
completeness a reviewer falsifies in ten minutes**, and every round of this
card that claimed the latter was rejected for it.
14. **An `in` test against a formatted sentence grades PRESENCE, never
ARRANGEMENT — so assert the ORDERED PAIR.** SAFETY-38 pinned the banner of a
latching, site-stopping alarm with
`f"{sum(_share38):.0f} W" in ...reason` — that `"-500 W"` appears
*somewhere*. Swapping the detector's first two arguments at the call site
(`evaluate(site_standing, self.grid, …)` →
`evaluate(self.grid, site_standing, …)`) is a **ZERO-BYTE** edit: both
numbers are still in the string, each in the other's slot. The substring was
still found — **in the meter slot** — the whole suite stayed green, and the
banner told the operator which instrument read what **exactly backwards**.
Whenever the property you mean is "**this** value went in **this** slot" — a
reason string, a log line, a notification, an MQTT payload — assert the
surrounding **literal text with the value embedded in its own position**
(`f"commanded {x:+.0f} W against a meter reading {y:+.0f} W"`), or assert
the fields **before** they are formatted. Two values that both appear in one
sentence can always be transposed by an edit that costs **nothing**, and
that no byte delta, no fingerprint and no shape pin can see. ⚠️ Note where
this one was found: **inside the fix written to close the previous instance
of the same family**, which is the second time in this list.
⚠️ **AND THE FIX FOR IT WAS ONE CALL SITE WIDE, WHICH IS THE HALF WORTH
KEEPING.** The ordered pair went onto `main.py:1620`, the **site** detector
— armed only at N≥2, and part 9c asserts it is **absent at a roster of
one**. The identical swap at `main.py:1560`, the **per-unit** detector, was
still **ZERO BYTES**, still changed no decision, still left the 21-file
suite green — and it is the **only** sign-disagreement sentence a shipped
one-inverter install can ever print: *"commanded −1000 W against its own
measured power +1000 W"*, backwards, in the log, on the status page and in
`sign_reason` after the restart the latch is designed to survive. Measured
size: **main.py:1560, 0 bytes, 21/21 green.** So habit 14 is not "pin the
banner", it is **"pin every call site of the banner, and say which one the
shipped roster reaches"** — and habit 15 is how you find them all.
15. **A SYMMETRIC PREDICATE MAKES ARGUMENT ORDER A PROPERTY OF THE MESSAGE
ALONE — so ENUMERATE THAT FAMILY BY MACHINE, and grade the CALL SITES, not
the function.** Where a callee is symmetric in two arguments, a swap at any
call site is invisible to every behavioural check **by construction**: not
"no fixture happens to separate them" but *no fixture can*. There is
nothing for a mutation battery to find, because the mutant and the shipped
tree take identical actions on every input. The only surface that moves is
the sentence, and the only check that can see it is one asserting the
**ordered pair** (habit 14) at **that** call site.
Three consequences, and all three are why this is its own habit:
* **The unit of coverage is the CALL SITE.** SAFETY-38 pinned
`evaluate()`'s operand order and still shipped the defect, because the
pin was on the call two lines below the one that matters. Ask of every
banner: *how many places call this, and which does the shipped roster
reach?*
* **The scope must be COMPUTED** — habit 12's argument, and SAFETY-38 is
its proof: the family was enumerated by hand twice and was short both
times. `tools/symmetric_message_scan.py` renames each parameter pair,
erases every formatted string, canonicalises the commutative operators
and iterates to a **fixpoint** over `app/` (the symmetry is usually one
call deep — `evaluate()` is symmetric only *through* `disagrees()`), then
classifies each sentence by where it GOES. A returned string may be a
**key** — swapping `store.unit_key(unit, key)` renames a store key, which
is behaviour and is MAINT-05's card — or a **sentence**, which is this
family.
* **Say what the instrument cannot see, with shapes** (habit 13). That scan
under-reports semantic symmetry no canonicaliser on its finite list can
spell; its second census, for sentences the CALLER builds, pairs only
expressions **one leaf apart** and never sees `%s`-style logging; it
covers only `app/`; and it **over-reports in exactly one way** — it
erases the sentence, so a swap that would make the FORMATTING itself
raise is scored as changing no decision. It prints all of that on every
run. The family, every member's call sites, and which of them are pinned
are in `DECISIONS/SAFETY-38-site-standing-pin.md` §7 — **with two
measured, still-open survivors named there rather than rounded off**,
both zero bytes and both 21/21 green.
And the 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
+651 -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",
@@ -1993,6 +2083,564 @@ check("...and the counterfactual again: the same site with nothing latched "
not _bn_n["banner"].startswith("FAILSAFE")
and _bn_n["banner"] != _bn_b["banner"])
print()
print("9. SAFETY-38: `site_standing` - the ONE quantity BOTH site guards are")
print(" asked about - pinned by shares that CANNOT answer for it")
# ⚠️ WHAT THIS SECTION PINS, AND WHY NOTHING ABOVE IT COULD.
# `main.py`'s `site_standing = float(sum(standing.values()))` is the first
# argument of SAFETY-30's site sign detector AND the `commanded_w` handed to
# SAFETY-18's `looks_frozen()`. It was pinned by NOTHING. THREE mutations of it
# were demonstrated live against a full green suite, and ALL THREE ARE COVERED
# HERE:
#
# sum(...) / max(1, len(standing)) the MEAN
# max(standing.values(), key=abs) the LARGEST SHARE
# list(standing.values())[0] the FIRST SHARE
#
# ⚠️ ROUND 1 OF THIS CARD SAID THE THIRD ONE WAS ALREADY CLOSED BY SAFETY-32's
# PACKAGE-WIDE STRUCTURAL PIN AND TOLD FUTURE AUTHORS "DO NOT RE-COVER THAT ONE
# HERE". THAT WAS FALSE, AND HOW IT BECAME FALSE IS THE USEFUL PART. It was
# measured ONCE; that single run happened to hit test_peakguard.py's ~20%
# CI-06 flake, the red was recorded as a mutation KILL, and the claim was then
# written into four places. RE-MEASURED IN ROUND 2, on release/1.0 @ 86eaef2
# carrying ONLY the `[0]` edit (+4 bytes, re-read from disk): the full suite is
# 21/21 GREEN, and test_peakguard.py run FIVE times in isolation on that same
# mutated tree is PASS PASS PASS PASS PASS. The mutation SURVIVES. (The
# reviewer's own independent re-measurement of the same tree caught the flake
# once - PASS PASS PASS FAIL PASS - which is the same verdict: a survivor, plus
# a demonstration of the flake that produced the original error.) A SINGLE RED
# FROM test_peakguard.py PROVES NOTHING - re-run it at least five times and NAME
# the failing check before believing it. That rule is CI-06's, and this card is
# the third measurement it has corrupted.
#
# THE MECHANISM, confirmed by reading 12j's own enumeration rather than by
# argument. SAFETY-32's part 12j taints names BOUND to roster-carrying values
# and lists ACCUMULATED in a loop over one. `standing` is neither: it is a dict
# created empty at main.py:1554 and filled by SUBSCRIPT assignment
# (`standing[inv.name] = stood`) inside the roster loop, and a subscript store
# is not a binding 12j follows. So `standing` never enters its taint set at all,
# and `sum(standing.values())` is not a roster site. Run 12j on a tree carrying
# the `[0]` mutation and it enumerates main.py:1553, 1555 and 1690 - the three
# `self.inverters` sites around this code - and NOT 1599. A structural pin
# cannot see this line. Only the behavioural fixture below can.
#
# Identical shape, different meaning - which is precisely the blind spot
# SAFETY-32 disclosed in its own words ("SEMANTICS. `min` -> `max` -> the mean
# are edits invisible to any shape pin, permanently"). No shape pin will ever
# close them. Only a behavioural fixture can, and this is it.
#
# ⚠️ WHY PART 5 ABOVE CANNOT SEE THEM, AND IT IS THIS REPOSITORY'S RECURRING
# DEFECT INSIDE THE SITE HALF OF THE VERY CARD THAT NAMES IT. `_sign_site` is
# the only N>=2 site fixture in the suite and it runs at
# `site_sign_disagree_w: 320` with shares of about -2500 W EACH - every one of
# them individually far OVER the gate. On values like those the sum, the mean
# and the largest share all clear it, so all three reductions agree, and a
# check whose two candidate answers happen to be EQUAL cannot observe a change
# that swaps them. MUTATION.md habit 4. At a roster of ONE it is worse: there
# `sum == mean == max == the one share` identically, so every mutation is
# EQUIVALENT there - see 9c, where that identity is asserted rather than
# assumed.
#
# ⚠️ SO THE NUMBERS BELOW ARE CHOSEN TO DISCRIMINATE, AND HERE IS THE
# ARITHMETIC THEY ARE CHOSEN FOR. Two simulated units rated 220 W and 280 W
# stand at their own rails, so `standing` is {-220, -280} in roster order:
#
# sum -500 W |500| > 300 OVER the gate -> TRIPS
# mean -250 W |250| < 300 under it -> silent
# max(..., key=abs) -280 W |280| < 300 under it -> silent
# list(...)[0] -220 W |220| < 300 under it -> silent
#
# FOUR DISTINCT MAGNITUDES - 500, 250, 280, 220 - of which exactly ONE clears
# the SHIPPED 300 W gate. The asymmetry is load-bearing twice over: equal
# shares would collapse mean, max and first onto one number and the fixture
# could not tell them apart, and putting the SMALLER unit first is what keeps
# `[0]` distinct from `max(key=abs)`.
#
# ⚠️ THE RATINGS ARE SIMULATED AND ARE NOT ANY REAL INVERTER'S. 220 W and 280 W
# are chosen for the arithmetic in that table and for nothing else. In
# particular they are NOT inverter B, which does not physically exist; B's
# rating is 4600 W FROM ITS DATASHEET (goodwe/NOTES.md 1.1b) and no number in
# this section is a measurement of anything.
#
# ⚠️ THE GATE IS THE SHIPPED ONE AND THE HOLD IS NOT. `site_sign_disagree_w` is
# left at its default so this fixture is graded against the magnitude an
# install actually runs; `site_sign_disagree_s` is shortened from 325 s to 2 s
# because the hold is a wall clock and the campaign behind 325 s is measured on
# the rig, not here. The gate is the coordinate this section is about.
from app import peakguard as _pg38 # noqa: E402
from app.freeze import rail_w as _rail_w38 # noqa: E402
from app.p1 import P1Sample as _P1Sample38 # noqa: E402
import time as _time38 # noqa: E402
_S38_A_W, _S38_B_W = 220.0, 280.0 # SIMULATED. See the note above.
_S38_LOAD_W = 2000.0
_S38_FROZEN_METER_W = 2500.0
def _s38_roster(*ratings):
"""A CFG-04 `inverters:` roster, which is the shape an N>=2 install runs.
⚠️ NOT `_sign_site`'s "build one from the flat keys and append a second".
`Controller.__init__` builds `Tuning` FROM the roster - `max_w=site_max_w` -
so a unit appended afterwards leaves the site loop bounded at ONE unit's
rating. That is harmless where part 3 uses it and fatal here: the whole
fixture is a site command standing at the ROSTER's rail.
"""
return [{"name": name, "setpoint_entity": f"number.sp_{tag}",
"soc_entity": f"sensor.soc_{tag}", "batt_entity": f"sensor.batt_{tag}",
"max_w": w, "bms_soc_floor": 10}
for name, tag, w in zip(("inverter-a", "inverter-b", "inverter-c"),
("a", "b", "c"), ratings)]
class _S38Plant:
"""Units that obey INSTANTLY and a meter that is `load - sum(measured)`.
The same modelling choice part 3 makes and for the same reason: an instant
plant removes the one mechanism that could make an obedient unit look
disobedient, so what remains to be graded is the ATTRIBUTION.
"""
def __init__(self, load_w=_S38_LOAD_W):
self.load_w = float(load_w)
self.writes = []
self.calls = []
self.ctrl = None
async def call(self, domain, service, data):
self.calls.append((domain, service, data))
async def limits(self, entity):
return None
def measured(self):
if self.ctrl is None:
return []
return [float((i.last_write if i.last_write is not None else i.target) or 0.0)
for i in self.ctrl.inverters]
async def number(self, entity, invert=False):
m = self.measured()
if entity == "sensor.grid":
return self.load_w - sum(m)
for i, tag in enumerate(("a", "b", "c")):
if entity == f"sensor.soc_{tag}":
return 50.0
if entity == f"sensor.batt_{tag}":
return m[i] if i < len(m) else None
return None
async def set_number(self, entity, value):
self.writes.append((entity, float(value)))
return True
def _s38_ctrl(hass, ratings, store=None, **over):
opts = {"meter_entity": "sensor.grid", "meter_source": "off",
"inverters": _s38_roster(*ratings),
"gain": -0.6,
# The per-unit pair, distinct from the site pair in BOTH
# coordinates, for part 3's reason: an equality below must be able
# to fail on the magnitude alone or on the hold alone.
"unit_sign_disagree_w": 250, "unit_sign_disagree_s": 1,
# ⚠️ `site_sign_disagree_w` DELIBERATELY ABSENT - the shipped 300 W
# default is the gate this whole section is graded against.
"site_sign_disagree_s": 2,
# SAFETY-31 is given a long window so the SITE is not stopped by
# circulation while this section grades the sign attribution. It is
# isolation, not a claim that SAFETY-31 is silent here.
"circulation_hold_s": 3600, "auto_start": True}
opts.update(over)
c = Controller(opts, hass, store if store is not None else _MemStore(),
_P(), stop=asyncio.Event())
for inv in c.inverters:
inv.dev_min, inv.dev_max = -inv.max_w, inv.max_w
c.auto = True
hass.ctrl = c
return c
def _s38_stood(c):
return [inv.last_write if inv.last_write is not None else inv.target
for inv in c.inverters]
print()
print("9a. the SITE sign detector: per-unit shares EACH BELOW the gate whose")
print(" SUM clears it - the case the site detector exists for")
async def _s38_site():
hass = _S38Plant()
c = _s38_ctrl(hass, (_S38_A_W, _S38_B_W))
seen = []
for _ in range(20):
await c.read_inputs()
await c.cycle()
seen.append(dict(grid=c.grid, stood=_s38_stood(c),
meas=[i.batt for i in c.inverters],
site=c.site_sign_wd.tripped,
units=[i.sign_wd.tripped for i in c.inverters],
following=c.following))
await asyncio.sleep(0.2)
return c, seen
_c38, _s38seen = asyncio.run(_s38_site())
# The last sample BEFORE the trip, named rather than indexed - part 5's lesson.
# After the trip every command is 0 W, so a fixed index reads the REMEDY.
_pre38 = [x for x in _s38seen if not x["site"]]
_mid38 = _pre38[-1]
_GATE38 = _c38.site_sign_wd.cfg.min_w
_share38 = list(_mid38["stood"])
_red38 = {"sum": sum(_share38),
"mean": sum(_share38) / max(1, len(_share38)),
"max(key=abs)": max(_share38, key=abs),
"[0]": _share38[0]}
print(f" last cycle before the trip: stood "
f"{[round(x) for x in _share38]} measured "
f"{[round(x) for x in _mid38['meas']]} meter {_mid38['grid']:+.0f} W")
print(f" site gate {_GATE38:.0f} W (SHIPPED default), hold "
f"{_c38.site_sign_wd.cfg.hold_s:.0f} s")
for _k, _v in _red38.items():
print(f" {_k:>13} = {_v:+7.1f} W "
f"{'OVER' if abs(_v) > _GATE38 else 'under'} the gate")
check("PRECONDITION: the gate really is the SHIPPED 300 W, so this fixture is "
"graded against the magnitude an install runs and not against a number "
"chosen to make it pass",
_GATE38 == float(SIGN_OPTION_DEFAULTS["site_sign_disagree_w"]) == 300.0)
check("⚠️ PRECONDITION, AND IT IS THE WHOLE OF SAFETY-38. EVERY per-unit share "
"is BELOW the site gate and their SUM is OVER it. `_sign_site` above "
"cannot make this statement - its shares are each individually over the "
"gate, so sum, mean and largest-share all agree there and its values "
"cannot observe a change that swaps them",
len(_share38) >= 2
and all(abs(s) < _GATE38 for s in _share38)
and abs(sum(_share38)) > _GATE38)
check("...and the FOUR candidate reductions give FOUR DISTINCT answers on it, "
"of which exactly ONE clears the gate. That is what makes this fixture "
"able to tell `sum` from the mean, from the largest share and from the "
"first share - and it is the property the existing site fixture lacks",
len({round(v, 6) for v in _red38.values()}) == 4
and [k for k, v in _red38.items() if abs(v) > _GATE38] == ["sum"])
check("PRECONDITION: EVERY inverter is obeying - each unit's measured power "
"agrees in sign with its own share - and the site is charging into an "
"importing house. Without this the trip below could be a per-unit fault "
"in disguise rather than the site fault this grades",
all(s * m > 0 for s, m in zip(_mid38["stood"], _mid38["meas"]))
and sum(_mid38["stood"]) * _mid38["grid"] < 0
and _mid38["following"] is True)
check("⚠️ THE SITE WATCHDOG TRIPS ON THE SUM, and no per-unit watchdog does. "
"Under ALL THREE semantic mutations - `sum -> mean`, "
"`sum -> max(key=abs)` AND `sum -> list(...)[0]` - NOTHING trips, ever: "
"the reviewer's tree was still commanding -500 W of charge into a "
"+2500 W import at t = 595 s with following=True. This is the check all "
"three go red on. `[0]` is here BECAUSE IT IS NOT COVERED ELSEWHERE - "
"see the section header for the mis-measurement that said it was",
_c38.site_sign_wd.tripped
and not any(i.sign_wd.tripped for i in _c38.inverters))
print(f" {_c38.site_sign_wd.reason!r}")
check("⚠️ ...and the sentence an operator reads carries THE SUM IN THE "
"COMMANDED SLOT AND THE METER IN THE METER SLOT - the ORDERED PAIR, not "
"two numbers loose in a sentence. ROUND 1 ASSERTED `f'{sum(...):.0f} W' "
"in reason`, i.e. that '-500 W' appears SOMEWHERE, AND THAT WAS A HOLE "
"ONE LINE WIDE. Swapping the detector's first two arguments - "
"`evaluate(site_standing, self.grid, ...)` -> "
"`evaluate(self.grid, site_standing, ...)`, a ZERO-BYTE edit at "
"main.py:1620 - leaves BOTH numbers in the string and moves each into "
"the other's slot, so the substring test still passed and the whole "
"suite stayed green. The swapped banner reads 'commanded +2500 W "
"against a meter reading -500 W': the site is standing at -500 W of "
"charge INTO a +2500 W import and the latching, site-stopping banner "
"tells the operator the exact opposite, on the one line somebody woken "
"by this alarm reads first. watchdog.py:658-665 calls naming the wrong "
"instrument there 'a false statement about which instrument produced "
"the evidence'. A check whose data cannot reach the failure it names is "
"MUTATION.md's own defect; this is the line that closes it",
f"commanded {sum(_share38):+.0f} W against a meter reading "
f"{_mid38['grid']:+.0f} W" in _c38.site_sign_wd.reason)
check("...and it stops the WHOLE site, through the `sign_site` claim by name "
"rather than by some other guard happening to stop it",
_c38.target == 0.0 and "sign_site" in _c38.reason
and all(i.target == 0.0 for i in _c38.inverters)
and _c38.store.data.get("site_sign_tripped") is True)
# ⚠️ THE COUNTERFACTUAL, and it is what says the check above is produced by the
# SUM crossing the gate rather than by two inverters being present. Same plant,
# same roster shape, same everything - except the units are rated so that the
# sum lands UNDER the gate. Nothing may trip.
_S38_SMALL = (120.0, 130.0) # sum 250 W: under the 300 W gate
async def _s38_under():
hass = _S38Plant()
c = _s38_ctrl(hass, _S38_SMALL)
for _ in range(20):
await c.read_inputs()
await c.cycle()
await asyncio.sleep(0.2)
return c
_c38u = asyncio.run(_s38_under())
print(f" counterfactual: stood {[round(x) for x in _s38_stood(_c38u)]} "
f"sum {sum(_s38_stood(_c38u)):+.0f} W meter {_c38u.grid:+.0f} W")
check("...and the counterfactual: the SAME inverted-gain site whose shares sum "
"to 250 W - under the gate - does NOT trip, over the same 20 cycles. So "
"the trip above is the sum crossing 300 W and not the mere presence of a "
"second inverter",
abs(sum(_s38_stood(_c38u))) < _GATE38
and not _c38u.site_sign_wd.tripped
and not any(i.sign_wd.tripped for i in _c38u.inverters))
# ⚠️ AND A SECOND COUNTERFACTUAL, WHICH EXISTS BECAUSE OF A MUTATION THE CHECKS
# ABOVE COULD NOT SEE AND THIS SEARCH WENT LOOKING FOR.
# `site_standing = float(self.target)` - the arbiter's SITE TARGET instead of
# the sum of what actually stands on the wire - is -11 bytes, and it survived
# every check above AND the whole 20-file suite, because in every fixture so
# far the two are the same number. THEY ARE NOT THE SAME NUMBER when a setpoint
# device's own reported range is narrower than its `max_w`: the `device`
# P_SAFETY claim is filed PER ACTUATOR (main.py, `actuator_of("device", inv)`),
# so it bounds each unit's SHARE and never the site target, and
# `clamp_to_rating()` clips again on the way out. Here the site asks for -500 W
# and -250 W stands.
#
# WHICH ONE THE GUARDS MEAN IS ALREADY WRITTEN DOWN, IN BOTH OF THEM: SAFETY-02
# grades "the command standing on the wire", and `looks_frozen()`'s docstring
# says `commanded_w` is "the EMITTED setpoint - what the arbiter resolved and
# `write_setpoint()` put on the wire - not the loop's raw wish". SAFETY-20 is
# the whole card about the difference. This check is those sentences made to
# bite: on the site target the detector fires on a site that is standing at
# -250 W against a +2250 W meter - under its own 300 W gate - which is a
# LATCHING, site-stopping false trip on an install whose inverters simply
# report a narrower range than the operator typed.
_S38_DEV = (120.0, 130.0) # narrower than the 220/280 W ratings
async def _s38_devclamped():
hass = _S38Plant()
c = _s38_ctrl(hass, (_S38_A_W, _S38_B_W))
for inv, d in zip(c.inverters, _S38_DEV):
inv.dev_min, inv.dev_max = -d, d
for _ in range(20):
await c.read_inputs()
await c.cycle()
await asyncio.sleep(0.2)
return c
_c38d = asyncio.run(_s38_devclamped())
_stood38d = _s38_stood(_c38d)
print(f" device-clamped: site target {_c38d.target:+.0f} W but only "
f"{[round(x) for x in _stood38d]} = {sum(_stood38d):+.0f} W stands, "
f"meter {_c38d.grid:+.0f} W")
check("PRECONDITION: the site TARGET and the EMITTED total are DIFFERENT "
"numbers here, on opposite sides of the gate - -500 W asked, -250 W "
"standing. Without that separation this check and the one above compare "
"two values that happen to be equal, which is the defect this card is "
"the sixth instance of",
abs(_c38d.target) > _GATE38 and abs(sum(_stood38d)) < _GATE38)
check("⚠️ ...and NOTHING TRIPS, because the quantity graded is what STANDS ON "
"THE WIRE and not what the arbiter asked for. `float(self.target)` is "
"-11 bytes, survives the other 20 files, and turns this ordinary "
"narrow-rating install into a latching site-wide false trip",
not _c38d.site_sign_wd.tripped
and not any(i.sign_wd.tripped for i in _c38d.inverters)
and _c38d.target != 0.0)
print()
print("9b. THE SECOND CONSUMER: the same value is SAFETY-18's `commanded_w`")
print(" inside `looks_frozen()`, and both mutations blind it too")
# ⚠️ THIS IS THE AC THE CARD REFUSES TO LET BE SCOPED OUT SILENTLY.
# `site_standing` is handed to `looks_frozen()` at main.py's `peakguard.decide(
# ..., frozen=...)` call, where it is the EMITTED command graded against the
# site rail. Both surviving mutations shrink it there too, and nothing went red.
#
# THE RAIL ARITHMETIC, and it is the same discrimination as 9a at a different
# threshold: the site rail is `RAIL_FRAC * site_max_w` = 0.95 * 500 = 475 W.
#
# sum 500 W >= 475 RAILED -> BLIND_FROZEN, floor 0 W
# mean 250 W < 475 silent
# max(..., key=abs) 280 W < 475 silent
# list(...)[0] 220 W < 475 silent
#
# ⚠️ THE SIGN IS FLIPPED FROM 9a ON PURPOSE. Here the gain is the SHIPPED
# POSITIVE one and the frozen meter reads IMPORT, so the site discharges +500 W
# against a +2500 W import - the two agree in sign, SAFETY-30 has nothing to
# say, and this section grades SAFETY-18 alone. Reusing 9a's inverted gain
# would have the site sign latch park everything at 0 W and the freeze
# conjunction could then never be true: a check measuring the other guard's
# remedy.
#
# WHAT THE MUTATIONS COST HERE, measured rather than asserted: on the shipped
# tree the guard goes blind and files `floor_w = 0.0` - "the battery may not
# import while the billed quarter cannot be measured". Under both mutations it
# is NOT BLIND AT ALL and files `floor_w = None`, so the site may go on
# charging through a quarter whose billed average is unobservable. That is the
# capacity-tariff hazard SAFETY-18 exists for, silently disarmed.
class _S38Src:
"""The transport's own `unchanged_s`, which only homewizard_local has."""
def __init__(self, unchanged_s):
self.unchanged_s = float(unchanged_s)
def _s38_feed(c, w, ts=None, mono=None):
now = ts if ts is not None else datetime.now(timezone.utc)
c.p1.submit(_P1Sample38(
ingest_ts=now,
ingest_mono=mono if mono is not None else _time38.monotonic(),
telegram_ts=now, source="homewizard_local",
import_w=max(w, 0.0), export_w=max(-w, 0.0), net_w=w,
per_phase_w=(w,), per_phase_import_w=(max(w, 0.0),)))
def _s38_seed_quarter(c, w):
"""Backfill the OPEN quarter with the same stuck value, at 10 s spacing.
⚠️ THIS IS NOT DECORATION. Without it the open block is mostly uncovered and
`blindness()` answers BLIND_COVERAGE - which is still blind, still floor
0 W, and would therefore MASK the mutations: the mutant would reach the
same action by a different route and a check on the action alone could not
see the difference it names. Seeded, the mutant is not blind at ALL, which
is the honest measurement of what the mutation costs. A meter that has been
stuck at the same reading all quarter is also what SAFETY-18 is about.
"""
wall = _time38.time()
q0 = (int(wall) // 900) * 900
mono0 = _time38.monotonic() - (wall - q0)
t = float(q0)
while t < wall:
_s38_feed(c, w, datetime.fromtimestamp(t, timezone.utc), mono0 + (t - q0))
t += 10.0
def _s38_frozen_ctrl(hass, ratings, unchanged_s=600.0, gain=0.05):
# ⚠️ `gain` 0.05 IS THE SCHEMA FLOOR AND IT IS HERE TO BUY A RAMP. At the
# shipped 0.6 the loop is at the rail on cycle 0 and the fixture has no
# BELOW-the-rail cycles to use as its counterfactual. At 0.05 the site
# climbs 125 W a cycle and crosses 475 W part way through the run, so the
# SAME run carries both answers.
c = _s38_ctrl(hass, ratings, meter_source="homewizard_local",
p1_host="meter.local", gain=gain)
c.p1_source = _S38Src(unchanged_s)
return c
async def _s38_frozen(ratings, meter_w=_S38_FROZEN_METER_W, n=8):
hass = _S38Plant()
c = _s38_frozen_ctrl(hass, ratings)
_s38_seed_quarter(c, meter_w)
seen = []
for _ in range(n):
_s38_feed(c, meter_w)
await c.read_inputs()
await c.cycle()
pg = c.peak_guard
seen.append(dict(stood=_s38_stood(c),
blind=None if pg is None else pg.blind,
floor=None if pg is None else pg.floor_w))
await asyncio.sleep(0.05)
return c, seen
_c38f, _s38f = asyncio.run(_s38_frozen((_S38_A_W, _S38_B_W)))
_RAIL38 = _rail_w38(_c38f.site_max_w,
dev_min=_c38f.site_dev_bound("dev_min"),
dev_max=_c38f.site_dev_bound("dev_max"))
_last38 = _s38f[-1]
_below38 = [x for x in _s38f if abs(sum(x["stood"])) < _RAIL38]
print(f" site rail {_RAIL38:.0f} W of site_max_w {_c38f.site_max_w:.0f} W; "
f"meter stuck at {_S38_FROZEN_METER_W:+.0f} W for "
f"{_c38f.p1_source.unchanged_s:.0f} s (freeze hold "
f"{_c38f.freeze_wd.cfg.hold_s:.0f} s)")
for _x in _s38f:
print(" stood %-16s sum %+7.1f W floor=%s blind=%r"
% ([round(v) for v in _x["stood"]], sum(_x["stood"]),
_x["floor"], _x["blind"]))
check("PRECONDITION: at the rail EVERY per-unit share is BELOW the site rail "
"and their SUM is at or over it - the same discrimination as 9a, at "
"SAFETY-18's threshold instead of SAFETY-30's. `[0]`, the mean and the "
"largest share are 220, 250 and 280 W against a 475 W rail",
len(_last38["stood"]) >= 2
and all(abs(s) < _RAIL38 for s in _last38["stood"])
and abs(sum(_last38["stood"])) >= _RAIL38
and len({round(v, 6) for v in
(sum(_last38["stood"]),
sum(_last38["stood"]) / len(_last38["stood"]),
max(_last38["stood"], key=abs),
_last38["stood"][0])}) == 4)
check("⚠️ THE PEAK GUARD GOES BLIND ON SAFETY-18's CONJUNCTION and forbids "
"charging - floor 0 W - because the SITE's total command is at the rail "
"while the meter has stopped moving. Under `sum -> mean` and "
"`sum -> max(key=abs)` it is not blind at all and files no floor, so the "
"battery may keep importing through an unmeasurable billed quarter. This "
"is the second consumer, and it is covered rather than scoped out",
_last38["blind"] == _pg38.BLIND_FROZEN and _last38["floor"] == 0.0)
check("...and the counterfactual, from the SAME run: on the earlier cycles the "
"site command had not yet reached the rail, and the guard did NOT report "
"SAFETY-18 - with the meter equally frozen the whole time. So the "
"verdict is produced by the COMMAND MAGNITUDE `site_standing` carries, "
"not by `unchanged_s` alone",
len(_below38) >= 2
and all(x["blind"] != _pg38.BLIND_FROZEN for x in _below38))
print()
print("9c. AT ONE INVERTER NOTHING CHANGES, and 'nothing' is asserted")
# ⚠️ THE REASON THIS SUBSECTION EXISTS. At a roster of one `standing` has a
# single entry, so `sum`, the mean, `max(key=abs)` and `[0]` are THE SAME
# NUMBER: all three mutations are genuinely EQUIVALENT there and no fixture at
# N=1 can ever separate them. That is a fact about the arithmetic, and leaving
# it implicit is how "the shipped install is covered" gets believed. It is
# asserted here, on a real one-inverter controller's own standing command, so
# the claim rests on the roster rather than on the reader.
_S38_ONE = (500.0,)
async def _s38_one():
hass = _S38Plant()
c = _s38_frozen_ctrl(hass, _S38_ONE)
_s38_seed_quarter(c, _S38_FROZEN_METER_W)
for _ in range(8):
_s38_feed(c, _S38_FROZEN_METER_W)
await c.read_inputs()
await c.cycle()
await asyncio.sleep(0.05)
return c
_c38one = asyncio.run(_s38_one())
_one38 = _s38_stood(_c38one)
_onered38 = (sum(_one38), sum(_one38) / max(1, len(_one38)),
max(_one38, key=abs), _one38[0])
print(f" roster of {len(_c38one.inverters)}: stood "
f"{[round(v) for v in _one38]} -> sum/mean/max/first = "
f"{tuple(round(v, 1) for v in _onered38)}")
check("at a roster of ONE the four reductions are ONE NUMBER - so all three "
"mutations of `site_standing` are EQUIVALENT on a shipped install and no "
"N=1 fixture could ever tell them apart. That is why this card needed a "
"roster of two, stated as an assertion rather than left to the reader",
len(_c38one.inverters) == 1
and len({round(v, 6) for v in _onered38}) == 1)
check("...and the one-inverter site still reaches SAFETY-18's verdict on that "
"same number, unchanged: the command is at the rail, the meter has "
"stopped moving, the guard is blind and forbids charging",
_c38one.peak_guard is not None
and _c38one.peak_guard.blind == _pg38.BLIND_FROZEN
and _c38one.peak_guard.floor_w == 0.0
and abs(sum(_one38)) >= _rail_w38(
_c38one.site_max_w,
dev_min=_c38one.site_dev_bound("dev_min"),
dev_max=_c38one.site_dev_bound("dev_max")))
check("...and the SITE sign detector is still absent at a roster of one - part "
"7 counts the calls; this only records that a fixture built the other "
"way round, from an `inverters:` roster, reaches the same state",
not _c38one.site_sign_wd.tripped
and _c38one.site_sign_wd.disagree_since is None)
print()
if fails:
print(f"{len(fails)} FAILED: {', '.join(fails)}")
@@ -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()