Compare commits

..
4 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
7 changed files with 81 additions and 1072 deletions
-117
View File
@@ -31,54 +31,6 @@ guessing a boundary; saying so is more useful than a guess.
## Unreleased
**`test_peakguard.py` answered two of its own checks with the wall clock
(CI-06).** The file sits on the required `checks / unit (pull_request)` context
and had a measured ~20 % flake rate. It cost a wrong merge diagnosis (parents
green, merge red — read as a semantic merge conflict, it was the flake), a false
red on two reviewers' CONTROL runs, and a false "mutation caught" that reached a
merged card in four places. **A flaky check does not waste a re-run; it
manufactures evidence for whichever hypothesis the reader already holds.**
Both causes are the control law's own gain `900 / remaining` applied to a
wall-clock gap the file never intended to have. Neither fix widens anything.
* **The cross-time projection.** The "one cycle of the intent form is exactly
the delivered form plus the command standing on the wire" check decided the
intent form from a **second** `PeakTracker.project(..., datetime.now())` taken
after the real cycle had returned, and compared it against a floor decided
from the **first** projection — three lines under a comment saying both run
"against the same frozen projection". `excess = 900 × (projected − ceiling) /
remaining` moves at `excess / remaining` W/s, so at a 1500 W floor the 1.5 W
tolerance bought exactly **`remaining / 1000` seconds**: 333 ms with 880 s
left in the quarter, **6 ms with 10 s left**. The gap between the two
statements measured **12.7–39.5 ms** over 26 instrumented runs. The check was
decided by *where in the quarter it ran*. It now keeps the `Projection`
**object** the real cycle graded and decides both forms from it — the clock is
out of the comparison and the tolerance is **exact**, not widened. Measured on
a real cycle: re-deciding the delivered form from that object reproduces the
controller's own floor to **0.000000000000 W**.
* **The fixture's own room.** `wired_over_limit()` laid telegrams with
`range(0, span + 1, 5)`, leaving **0–5 s of the open block uncovered by
construction**, and `excess` multiplies an uncovered second back up by
`900 / remaining`. Measured on the shipped file: at **12 s of remaining a
fixture that asked for a 1500 W floor delivered 613 W (−59 %)**, and at 2 s
the guard **did not engage at all** — which fails "the real cycle files the
peak claim", "…and the number written to the inverter is the guard's floor"
and "the STATUS PAGE row for a floor-limited guard" **together**. The
four-attempt retry could not help: four attempts take ~40 ms and all land in
the same dying quarter. Fixed with one telegram stamped at `now` (what a real
meter does, and what the docstring already claimed) plus `FIXTURE_ROOM_S`,
**derived** from the 1 % accuracy the helper promises rather than picked:
`net_w × lag / (remaining × want) ≤ 0.01 − lag/900` → **23.6 s → 24 s**, at
the measured 0.1 s worst-case lag.
**Nothing was skipped, disabled or marked xfail** — the file still carries all
of SAFETY-07 and SAFETY-25's forced-discharge SoC floor, and it gains a check:
the delivered form re-decided from the cycle's own projection must reproduce the
real controller's number to the bit, which pins the test's `decide()` kwargs as
main.py's actual call rather than an approximation of it. **One tolerance got
stricter** (1.5 W → exact), because removing the clock made exactness possible.
**`site_standing` — the one quantity BOTH site guards are asked about — is now
pinned by a fixture whose per-unit shares cannot answer for it (SAFETY-38).**
`app/main.py`'s `site_standing = float(sum(standing.values()))` is the first
@@ -222,75 +174,6 @@ shipped `sum` is correct and must not be "fixed".**
it is recorded rather than quietly dropped — unlike the one that *was*
believed, two bullets up. Ledger, and an **enumerated list of what was not
mutated**, in `DECISIONS/SAFETY-38-site-standing-pin.md` §4a, §4b and §6a.
* **⭐ ROUND 4: THE SCANNER COVERED ONE SYNTACTIC SINK AND CALLED IT THE
FAMILY, AND TWO LIVE MEMBERS WERE SITTING IN THE HOLE.** Every one of census
B's 16 members was a sentence that is **appended, assigned, or logged**. Not
one was **RETURNED** — and census A's return bucket only ever held a callee
formatting its own *parameters*, so a method formatting its own **attributes**
and handing the string back was invisible to both. That is the whole
`describe()` / `explain()` family, which is where this codebase keeps its
operator sentences. **Worse than the gap: it was in none of the five blind
spots the tool printed about itself.** The tool now scans the return sink
(census C), resolving it through the returning method's **call sites** so that
a returned *key* — `store.unit_key()`, `mqtt.unit_state_key()` — is not
mistaken for a returned *sentence*; it also scores a sentence handed to a
constructor as `reason=` or placed under a `"text"` dict key, which added
**six more caller-built members** it had been stepping past. Re-run, it
**rediscovered both of the members this round was sent to fix without being
told where to look**.
* **⭐ `ClampResult.describe()` — the clamp alarm the owner has been complaining
about all day — had a SECOND independent way to lie, and no `test_*.py`
mentioned `describe()` at all.** Transposing `requested_w` and `applied_w` in
`clamp.py:253` is a **zero-byte** edit that leaves the guard reporting a
setpoint clamped **UPWARD**, in the measured case to +9999 W by a bound whose
rating the same sentence gives as 2000 W. It is the §15 action-log row
(`main.py:1366`) **and** the ClampWatchdog notification reason
(`main.py:2452`). Measured **21/21 green** before; now **red on 4 checks** in
`test_clamp.py` part 6d, pinned on the **ordered pair** on the contiguous
literal at **both** consumers, with operands read off `ctl.target` and
`hass.writes[-1]` — what the loop asked and what the device was sent — rather
than off the `ClampResult` the swap lives in.
* **⭐ `PeakDecision.explain()` — the status-page banner — the same shape.**
`projected_w` ↔ `ceiling_w` at `peakguard.py:493`, **zero bytes**, and the
row then reads *"quarter projects 5013 W under a 2300 W ceiling"*: a quarter
**2713 W over** its ceiling, on a row whose own words say *idle* and whose own
flag says **ok**. `"peak guard idle"` appeared in no `test_*.py`. Measured
**21/21 green** before; now **red on 3 checks** in `test_peakguard.py` part 13,
pinned on the decision and on the shipped status row at `main.py:3313`.
⚠️ **A correction the ticket is owed:** the idle sentence does not reach
`main.py:3299/3302` — those are the *floor-limited* branch, where `explain()`
returns `self.reason` before the idle literal is built. It reaches
**`main.py:3313`** and, on the release edge, `main.py:1951`.
* **`test_arbiter.py`'s scope-violation check made a claim WIDER than its
assertion** — it said `scope_violations` *"names the source and the actuator
it does not belong on"* and asserted `"peak" in scope_errors[0]`, which is
still true after the two swap roles. Measured this round: **0 bytes, 21/21
green**; now red on 2 checks, pinned on the ordered pair.
* **14 more zero-byte mutations measured, and 10 SURVIVORS ARE DISCLOSED RATHER
THAN CLOSED**, each with its file fingerprint and its 21/21 verdict, in
`DECISIONS/SAFETY-38-site-standing-pin.md` §7e–§7g: **two** in
`allocator.py`'s `UnitPlan.explain()`, three across `arbiter.py`'s two
`explain()`s, two in `tariff.py`, two in `arbitrage.py`'s trade reasons and
one in `main.py`'s SoC-floor notification. The other four of the fourteen are
kills: `allocator.py:162` was already caught by `test_alloc.py`, and
`clamp.py:253`, `peakguard.py:493` and `arbiter.py:264` are closed here. **An enumerated list with honest
gaps beats a fifth round finding a fifth instance.**
⚠️ **And the probe that grades those mutations was itself the defect, once.**
Its first cut numbered the operands **by order of appearance**, so after a
transposition the other leaf took the same number and all thirteen renders came
back **byte-identical** — which reads exactly like *the edit is inert*. Keying
the value to the **name** fixed it. A measurement comparing the swap against
itself, in the instrument this time.
* **The tool now states what it does NOT scan**, largest hole first:
`%s`-style lazy logging (`_LOG.error("… %s … %s", a, b)` puts the operands in
*arguments*, so no census here looks at them and the transposition is the same
zero-byte edit — **31 such calls across 8 modules, counted and listed by the
tool rather than asserted**), `.format()`/`%`/`+`/`.join()` sentences, a sentence that
leaves through a dict or dataclass and is rendered elsewhere, `yield`ed
sentences, message attributes outside the `reason`-shaped set, and everything
outside `app/`. Its five over/under-report gaps stay, and every census row now
prints whether the two interpolations **share a format spec** — the difference
between a transposition that is silent and one that merely looks broken.
**Inverter B gets its own MQTT device, and inverter A keeps the one it has
(TEL-07).** MQTT discovery announced **one** device, and every entity hung off
@@ -234,7 +234,7 @@ fingerprint, and verifies no cache appears during the run). The mutated line was
| **M11** | **argument SWAP** at the SITE call site: `evaluate(site_standing, self.grid, …)` → `evaluate(self.grid, site_standing, …)` (main.py:1620) | **0** | **was a SURVIVOR in round 1 — now RED**, see §4a |
| **M12** | **argument SWAP** at the PER-UNIT call site: `evaluate(stood, against, …)` → `evaluate(against, stood, …)` (**main.py:1560**) | **0** | **was a SURVIVOR in round 2 — now RED**, see §4c |
**Every one of the ten `site_standing` entries is red in `test_watchdog.py`.** Re-measured in round 2
**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.
@@ -255,10 +255,7 @@ three times on its own pages: the ledger row calls M7 *"a SURVIVOR — now RED"*
`bbc903b`** — a fresh copy each, bytecode purged (`0` remaining, printed), the
line re-read from disk, byte delta **and** `app/main.py` fingerprint printed,
mounted `:ro`, and the **FULL 21-FILE SUITE** run each time rather than
`test_watchdog.py` alone. (`release/1.0` has since moved on to `9e42cec`, which
merged [[CI-06]]; that commit touches `test_peakguard.py` and `CHANGELOG.md`
only, neither of which grades `site_standing`, so the ten verdicts below stand
on the tree they were taken on and this branch is rebased onto the newer tip.)
`test_watchdog.py` alone:
| # | Δ bytes | fingerprint of `app/main.py` | suite before part 9 |
|---|---|---|---|
@@ -584,83 +581,13 @@ disk, byte delta printed):**
this one.
* **Twelve census-B sites** (§7c, B2–B12) determined unpinned at the **source
level** — the distinguishing literal appears in no `test_*.py` — and **not**
mutated. Weaker evidence than A1–A4 and B1, and written down as such
— §7d says the same thing with the same list.
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.
### 6c. Round 4: what was closed, and what is still open
**The blocker round 3 shipped.** The enumeration read as complete and covered
**one syntactic sink**. Every one of census B's 16 members was a sentence that
is **appended, assigned, or passed as a log argument** (`problems.append`,
`out.append({"text": …})`, `self.reason = …`, `_LOG.info`). **Not one was a
sentence that is RETURNED.** Census A has a return bucket, but only for
symmetric **parameters**, so a method formatting its **own attributes** and
handing the string back was invisible to both — and `describe()` / `explain()` /
the reason-builders are exactly where this codebase keeps its operator
sentences. Worse than the gap: it was in **none of the five blind spots the tool
prints about itself**. A tool that asserts a completeness it does not have is
the recurring defect wearing a new hat.
**Closed in round 4:**
1. **`clamp.py:253` `ClampResult.describe()`**, pair `requested_w` /
`applied_w` — §7e C6. A survivor of the round-3 tree at **21/21 green**,
**0 bytes**. It is the §15 action-log row at `main.py:1366` **and** the
ClampWatchdog notification reason at `main.py:2452`; **no `test_*.py`
mentioned `describe()` at all.** Pinned on the ordered pair, on the
contiguous literal, at **both** consumers, with a precondition that the two
operands render differently — `test_clamp.py` part 6d. **4 checks red** under
the transposition.
2. **`peakguard.py:493` `PeakDecision.explain()`**, pair `projected_w` /
`ceiling_w` — §7e C7. Same measurement: **0 bytes, 21/21 green**; the string
`"peak guard idle"` was in no `test_*.py`. Pinned on the decision and on the
shipped status row at `main.py:3313` — `test_peakguard.py` part 13. **3
checks red.**
3. **`arbiter.py:264` (B12)** — the check whose **claim was wider than its
assertion**, carried forward from §7d. Measured this round rather than left
at source level: **0 bytes, 21/21 green** before, **`test_arbiter.py` red**
after. **2 checks red.**
4. **The tool's sinks are now stated rather than implied.** It prints what it
scans as a MESSAGE, that it now scans the RETURN sink through the returning
method's call sites, and — the part that was missing — **an explicit list of
what it does not scan at all**, largest hole first.
**⚠️ The correction round 4 owes the ticket.** The brief placed
`PeakDecision.explain()`'s idle sentence at `main.py:3299/3302`. Those two lines
are the **floor-limited** branch, where `explain()` returns `self.reason` before
the idle literal is ever built. The idle sentence reaches the page at
**`main.py:3313`** and the action log at **`main.py:1951`** (edge-logged, so it
is the RELEASE edge that writes it). The pin is on 3313; 1951 is disclosed
below, unpinned.
**Open, named, and measured — see §7f for the ledger:**
* **TEN further zero-byte survivors** found by the widened scan, each measured
at 21/21 green on this tree: **two** in `allocator.py`'s `UnitPlan.explain()`
(C1 :154, C2 :160), three in `arbiter.py`'s two `explain()`s (C4a, C4b :344,
C5 :392), two in `tariff.py` (C8, C9), two in `arbitrage.py`'s trade reasons
(B17, B18) and one in `main.py`'s SoC-floor notification (B19).
None is a control decision; every one is a sentence an operator reads.
⚠️ **CORRECTED after review, and the error is worth keeping visible.** This
bullet first said "Nine" while listing eleven, and the CHANGELOG said eleven
while listing eleven — and BOTH counted `allocator.py:162` (C3) among the
survivors when §7f records it as a KILL, red in `test_alloc.py`. The
authoritative tables in §7e and §7f were right throughout; only the summary
sentences a reader actually relies on were wrong. That is this card's own
recurring defect in prose rather than in code: a claim wider than the
measurement beneath it. Arithmetic, from §7f: 14 measured = 10 surviving +
1 already caught (C3) + 3 caught by this round (C6, C7, B12).
* **The action-log surface of the peak-guard idle line** (`main.py:1951`) is
not pinned. The status row (3313) is, and both come off the same `explain()`,
so the *sentence* is closed and the *second call site* is not — which is
precisely the distinction §4c was written about, recorded rather than assumed.
* Everything §6a and §6b list stays open.
## 7. The symmetric-predicate family — every call whose argument order is load-bearing for a MESSAGE
**Why this section exists, and it is the most useful thing in the card.** Round 2
@@ -734,7 +661,8 @@ symmetry test has nothing to test. The scan pairs interpolated expressions that
are **one leaf apart** — `inv.dev_min` / `inv.dev_max`, `cfg.min_w` /
`cfg.hold_s` — which is exactly what a transposition swaps, and it keeps only
sentences whose sink is a **message**: an f-string that becomes a URL or a store
key is data, and a swap there is an ordinary behavioural change. **16 candidates** in round 3, every one of them a **zero-byte** edit — and **22** after round 4 widened the sink classifier (B17–B22 below):
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? |
|---|---|---|---|---|
@@ -747,22 +675,11 @@ key is data, and a swap there is an ordinary behavioural change. **16 candidates
| **B7** | `main.py:2454` | `self.site_max_w` / `self.target` | the clamp action-log line | **NO** — `was clamped to` is in no `test_*.py`. |
| **B8** | `main.py:3386` | `clamp_wd.engagements` / `.episodes` | status page, `engaged N time(s) in M episode(s)` | **NO** — `time(s) in` is in no `test_*.py`. |
| **B9–B11** | `main.py:3169` (three pairs) | `p1.net_w` / `.samples` / `.parse_errors` | status page, the P1 row | **NO** — `P1 meter (` is in no `test_*.py`. |
| **B12** | `arbiter.py:264` | `c.source` / `c.actuator` | `scope_errors`, the arbiter's mis-routing refusal | **ROUND 4: MEASURED, THEN CLOSED.** Round 3 left it at source level; the transposition is **0 bytes**, `arbiter.py` `fdb4ed0520eeb721` → `9111f627ef639ecc`, and it was **21/21 green**. Now red in `test_arbiter.py` on 2 checks. Round 3's reasoning was right: `test_arbiter.py:452` says it *"names the source and the actuator it does not belong on"* and then asserts `"peak" in r_mis.scope_errors[0]`. Under the swap the sentence becomes `"inverter-b: a SITE claim filed on unit 'peak'"` — and `"peak" in …` is **still true**. A claim wider than its assertion, which is habit 14's own shape. |
| **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. |
| **B17** | `arbitrage.py:377` | `v.spread_eur_kwh` / `v.required_spread_eur_kwh` | the DISCHARGE decision's `reason` | **ROUND 4, NEW, and MEASURED: 0 bytes, 21/21 green.** Not visible to round 3 at all — the sentence is handed to a constructor as `reason=`, and the sink walker scored only *assignments*. The row then reads *"0.0400 above the cheapest and clear of the 0.1200 this cycle had to cover"* with the two swapped: a trade justified by a spread it did not clear. |
| **B18** | `arbitrage.py:393` | same pair, CHARGE branch | the CHARGE decision's `reason` | **ROUND 4, NEW, MEASURED: 0 bytes, 21/21 green.** |
| **B19** | `main.py:2652` | `g.reason` / `g.soc_floor_reason` | the `goodwe_peak_soc_floor` notification body | **ROUND 4, NEW, MEASURED: 0 bytes, 21/21 green.** The alarm then opens with the *reason the floor bound* and closes with the *guard's own conclusion*, i.e. both halves of a two-part explanation in the wrong order, in the one message SAFETY-24/25 exist to send. |
| B20–B22 | `main.py:2558`, `main.py:2652` (two more pairs) | `g.reason` / `g.ceiling_w`, `g.ceiling_w` / `g.soc_floor_reason` | the same two notifications | **new in round 4, NOT measured** — mixed types (`str` beside `:.0f`), the same over-report class as B16. |
**⚠️ Six of these twenty-two were invisible to round 3, and the reason is one
line of the sink classifier.** It scored a sentence a MESSAGE when it was
appended, logged, raised, or assigned to a `reason`-shaped **name**. This
codebase mostly builds operator sentences as `Decision(…, reason=f"…")` and
`out.append({"text": f"…"})` — a **keyword argument** and a **dict key**, which
that walker stepped straight past. Both are message sinks now.
### 7d. What this enumeration does NOT close, with sizes
@@ -803,123 +720,3 @@ that walker stepped straight past. Both are message sinks now.
non-f-string payloads, anything outside `app/`, and the one way it
**over-reports** — it erases the sentence, so a swap that would make the
formatting itself raise is scored as changing no decision (A5–A6 above).
### 7e. Census C — the sentence an object returns about itself (round 4)
**Why there is a third census.** A and B between them covered **one syntactic
sink**. Everything either of them reported was a sentence that is appended,
assigned, or logged; **none was returned**. Census A's RETURN bucket only ever
held a callee formatting its own **parameters**, so a method formatting its own
**attributes** and handing the string back fell between them — and that is the
whole `describe()` / `explain()` / reason-builder family, which is where the
operator sentences in this codebase actually live.
**The test** is census B's — one f-string, two interpolations **one leaf apart**
— run over the RETURN sink, plus one thing B never needed: **a returned string
is not yet a message.** `store.unit_key()` and `mqtt.unit_state_key()` return
f-strings too, and swapping there renames a store key and an MQTT topic, which
every ordinary check can see (MAINT-05). So the sink of a returned sentence is
**resolved through its call sites**: it is in this family only when some caller
in `app/` hands it to an operator. That resolution matches on the **bare method
name**, which over-collects — at least four unrelated classes have an
`explain()` — so every verdict is printed **with the sites that produced it**,
and a MESSAGE verdict is a prompt to read them, not a proof.
The scan reports **53 returned pairs, 39 reaching an operator, 16 of those
sharing a format spec** (two values rendered alike, so a transposition is silent
rather than visibly broken). It **rediscovered both members this round was sent
to fix without being told where to look**, which is the only reason to trust the
rest of the list.
| # | site | pair | reaches an operator as | Δ bytes | verdict |
|---|---|---|---|---|---|
| **C6** | `clamp.py:253` `ClampResult.describe()` | `requested_w` / `applied_w` | §15 action log (`main.py:1366`) **and** the ClampWatchdog notification reason (`main.py:2452`) | **0** | **was a SURVIVOR at 21/21 — now RED**, `test_clamp.py` part 6d, 4 checks |
| **C7** | `peakguard.py:493` `PeakDecision.explain()` | `projected_w` / `ceiling_w` | status row `main.py:3313`; action log `main.py:1951` on the release edge | **0** | **was a SURVIVOR at 21/21 — now RED**, `test_peakguard.py` part 13, 3 checks |
| C1 | `allocator.py:154` `UnitPlan.explain()` | `self.span` / `self.why` | the arbiter/allocator log line | **0** | **SURVIVOR, 21/21 green** |
| C2 | `allocator.py:160` | `self.span` / `self.why` | the ENVELOPE VIOLATED line | **0** | **SURVIVOR, 21/21 green** |
| C3 | `allocator.py:162` | `self.span` / `self.why` | the ordinary in-envelope line | **0** | **RED** — `test_alloc.py` already asserts that whole literal. The one member of this census the suite could already see, and it says the habit works. |
| C4a | `arbiter.py:344` `UnitBounds.explain()` | `self.lo` / `self.hi` | `CONTRADICTION within the unit (device lo=… > peak hi=…)` | **0** | **SURVIVOR, 21/21 green** |
| C4b | `arbiter.py:344` | `self.lo_src` / `self.hi_src` | the same sentence, the two SOURCE names | **0** | **SURVIVOR, 21/21 green** |
| C5 | `arbiter.py:392` `Resolution.explain()` | `self.winner` / `self.bound_by` | `main.py:2236`'s `ARBITER:` action-log row and `self.reason` at `main.py:2329` | **0** | **SURVIVOR, 21/21 green** — the winner and the limiter swap names, on the line §10.1 exists to make legible |
| C8 | `tariff.py:279` `PeakReading.explain()` | `self.month` / `self.detail` | the PEAK UNREADABLE row | **0** | **SURVIVOR, 21/21 green** |
| C9 | `tariff.py:528` `Projection.explain()` | `self.projected_w` / `self.elapsed_s` | the projection line | **0** | **SURVIVOR, 21/21 green** — watts printed as seconds and seconds as watts, both `:.0f` |
**Not in the family, and the scan says why rather than dropping them silently:**
`inverter.py:338` `__repr__()` and `static_tariff.py:378` `words` are reported
**UNRESOLVED — no call site inside `app/`** (a `__repr__` is called by the
interpreter, not by a name the scan can match); `price_forecast.py:421/428` and
`tariff.py:214` `month_key()` resolve to **OTHER** — `month_key` is a **store
key**, which is MAINT-05's case and a swap there is behaviour every check sees.
### 7f. Round 4's mutation ledger
Same protocol as §4: a fresh full-repo copy per mutation, `:ro`, bytecode
purged **and the purge count printed** before the edit, the edited file
**re-read from disk**, both sides' length and sha256 printed, and the full
**21-file** suite via `tools/mutation_run.sh`. Every row is a pure
transposition, so **every Δ is 0 bytes** — verified, not assumed: `len(old) ==
len(new)` is asserted before the write and `len(after) == len(before)` after it.
**⚠️ And every row was also required to change the ANSWER.** A byte delta says
the file moved; it does not say the sentence did. Each mutated tree is rendered
through a scratch probe that evaluates the shipped f-string with **distinct
operands** and diffs it against the same render of the clean tree. **The first
cut of that probe numbered the operands by ORDER OF APPEARANCE** — so after a
transposition the other leaf took the same number and all thirteen diffs came
back **empty**, which reads exactly like "the edit is inert". Keying the value
to the **name** fixed it. That is the same defect as everything else in this
card, in the instrument this time: a measurement comparing the swap against
itself.
| mutation | file, before → after (sha256, 16) | Δ | verdict |
|---|---|---|---|
| C1 `allocator.py:154` span↔why | `10c1b26ed0099654` → `ac0c174de1ec28ad` | 0 | **SURVIVED 21/21** |
| C2 `allocator.py:160` span↔why | `10c1b26ed0099654` → `9b10730f4659befd` | 0 | **SURVIVED 21/21** |
| C3 `allocator.py:162` span↔why | `10c1b26ed0099654` → `07097bf5da31f0c6` | 0 | **RED** — `test_alloc.py` |
| C4a `arbiter.py:344` lo↔hi | `fdb4ed0520eeb721` → `009820de7ef0eff0` | 0 | **SURVIVED 21/21** |
| C4b `arbiter.py:344` lo_src↔hi_src | `fdb4ed0520eeb721` → `04cb8cf69dfc7432` | 0 | **SURVIVED 21/21** |
| C5 `arbiter.py:392` winner↔bound_by | `fdb4ed0520eeb721` → `9238c3db920da8ad` | 0 | **SURVIVED 21/21** |
| **C6** `clamp.py:253` requested↔applied | `0153133741497fa5` → `47e4e371043e3201` | 0 | **SURVIVED 21/21 before — RED after**, `test_clamp.py`, 4 checks |
| **C7** `peakguard.py:493` projected↔ceiling | `d95df61a51716c26` → `2d2ee3d0bcd51251` | 0 | **SURVIVED 21/21 before — RED after**, `test_peakguard.py`, 3 checks |
| C8 `tariff.py:279` month↔detail | `82790523dd5227c9` → `4b2147ca17a28425` | 0 | **SURVIVED 21/21** |
| C9 `tariff.py:528` projected↔elapsed | `82790523dd5227c9` → `517e9148d26a243a` | 0 | **SURVIVED 21/21** |
| B17 `arbitrage.py:377` spread↔required | `67fbf949b2938349` → `cddd8045bdeb5e29` | 0 | **SURVIVED 21/21** |
| B18 `arbitrage.py:393` spread↔required | `67fbf949b2938349` → `04a4f3ad3d255de1` | 0 | **SURVIVED 21/21** |
| B19 `main.py:2652` reason↔soc_floor_reason | `61aa413fb890433e` → `bbcbb28f476a98d0` | 0 | **SURVIVED 21/21** |
| **B12** `arbiter.py:264` source↔actuator | `fdb4ed0520eeb721` → `9111f627ef639ecc` | 0 | **SURVIVED 21/21 before — RED after**, `test_arbiter.py`, 2 checks |
**14 measured, 12 survived the full suite before this round's checks, 1 was
already caught, 3 are caught now.** The tree these were taken on is
`SAFETY-38` @ `357cb4f` rebased on `release/1.0` @ `9e42cec`; `app/main.py` is
234 542 bytes.
### 7g. What round 4 does NOT close, with sizes
* **Eleven measured zero-byte survivors are left open** — C1, C2, C4a, C4b, C5,
C8, C9, B17, B18, B19 and the three already open from round 3 (A3, B1, and the
B2–B11/B13–B14 source-level set). Each is a sentence an operator reads and
none is a control decision. They are disclosed with their measurement rather
than pinned because pinning each one wants a fixture in the file that owns the
module — `test_alloc.py`, `test_arbiter.py`, `test_tariff.py`,
`test_arbitrage.py` — and this card has now spent four rounds inside
`test_watchdog.py`'s subject. **An enumerated list with honest gaps beats a
fifth round finding a fifth instance**; what would NOT be honest is a fifth
round finding one that is **not on this list**.
* **`main.py:1951`, the action-log surface of the peak-guard idle line,** is
unpinned. The status row off the same `explain()` is pinned; the second call
site is not, and §4c is the card about exactly that costing a round.
* **The largest remaining hole is not in any census: `%s`-style lazy logging.**
`_LOG.error("… %s … %s", a, b)` puts the two operands in *arguments*, not in
an f-string, so nothing here looks at them and the transposition is the same
zero-byte edit. The tool now says so first in its NOT-SCANNED list, **with
the size measured rather than asserted: 31 such calls across 8 modules in
`app/`, each one listed**.
* **The tool's own blind-spot list was the round-4 blocker and is now two
lists.** "What this scans, and what it does not" enumerates its message sinks
from the constants themselves and then names, in order, what it never looks at:
`%s`-style logging, `.format()`/`%`/`+`/`.join()` sentences, a sentence that
leaves through a dict or dataclass and is rendered elsewhere, `yield`ed
sentences, attributes outside the `reason`-shaped set, and everything outside
`app/`. "What it can see but gets wrong" keeps the five gaps round 3 printed,
with the over-report gap now backed by a **printed format-spec column** so a
reader can tell a silent same-typed transposition from a noisy mixed-type one.
+1 -52
View File
@@ -135,7 +135,7 @@ manufactures them from a **fixture**: the check runs, the assertion is
evaluated, the sentence above it is true — and the numbers it compares are
arranged so that the failure it describes could not have changed the answer.
It is not hypothetical and it is not rare. ELEVEN 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
@@ -145,15 +145,6 @@ that names the rule in its own text - and the TENTH INSIDE THE FIX WRITTEN TO
CLOSE THE NINTH, for the second time in this list. See SAFETY-38 below: the
check that closed it asserted a NUMBER and not the number's POSITION.
⚠️ AND THE ELEVENTH IS IN A MEASURING INSTRUMENT RATHER THAN IN A CHECK, which
is where the seventh was too. SAFETY-38 round 4's render probe - the thing that
decides whether a mutation changed the SENTENCE and not merely the bytes -
numbered each interpolated operand BY ORDER OF APPEARANCE. After a transposition
the other operand takes the same number, so all thirteen mutant renders came back
BYTE-IDENTICAL to the clean tree: the probe compared the swap against itself, and
its output read exactly like "the edit is inert". Keying the value to the
operand's NAME fixed it. Habit 16.
⚠️ **AND IT HAS NOW APPEARED IN SIX CONSECUTIVE CARDS.** The seventh instance,
MAINT-05's, is the one that says why: the defect is not carelessness about
fixtures, it is that **a check and the thing it checks were built from the same
@@ -503,48 +494,6 @@ Cheap habits fall out of it, and all of them are now used in this repository:
are in `DECISIONS/SAFETY-38-site-standing-pin.md` §7 — **with two
measured, still-open survivors named there rather than rounded off**,
both zero bytes and both 21/21 green.
* ⚠️ **AND THE SINK IS HALF THE SCOPE, WHICH IS HOW THE FIRST THREE ROUNDS
OF THIS SCAN MISSED THE PLACE THE CODEBASE ACTUALLY KEEPS ITS SENTENCES.**
The scan classified a sentence as operator-facing when it was **appended,
assigned to a `reason`-shaped name, or logged** — and every one of its 16
reported members was one of those. **None was RETURNED.** A method
formatting its own **attributes** and handing the string back —
`ClampResult.describe()`, `PeakDecision.explain()`, every reason-builder
in the tree — was outside all of it, and so were sentences handed to a
constructor as `reason=` or placed under a `"text"` dict key. Four
zero-byte survivors of a green 21-file suite were sitting there, two of
them on the alarm SAFETY-40 and SAFETY-41 are about. **The general form:
an enumeration is scoped by a SINK as much as by a PREDICATE, and a sink
list is a hand-written list like any other (habit 12).** Ask of any such
scan: *which syntactic forms does this call a message, and what does the
code under it actually use?*
16. ⚠️ **AN INSTRUMENT THAT ASSERTS A COMPLETENESS IT DOES NOT HAVE IS WORSE
THAN NO INSTRUMENT, AND ITS BLIND-SPOT LIST IS PART OF THE CODE UNDER
REVIEW.** `tools/symmetric_message_scan.py` printed five honest gaps and a
census count, and a reader — including its author, twice — took the count
for the family. The gap that mattered, the RETURN sink, was **in none of the
five**. A gap list is not a disclaimer; it is a claim, and it is the claim
most likely to be believed because nothing else in the output argues with
it. Two consequences:
* **State what is NOT scanned, largest hole first, as a list of forms** —
not "this under-reports". The tool now names `%s`-style lazy logging
(`_LOG.error("… %s … %s", a, b)`, where the operands are *arguments*, so
no f-string census can ever see them), `.format()`/`%`/`+`/`.join()`
sentences, sentences that leave through a dict or dataclass, `yield`,
message attributes outside its own constant set, and everything outside
`app/`. Where the list is derived from a constant in the file, it is
**printed from that constant**, so the two cannot drift.
* **AND THE PROBE THAT GRADES THE MUTATIONS IS SUBJECT TO EVERY HABIT
ABOVE.** Round 4's render probe gave each interpolated operand a value by
**order of appearance** — so after a transposition the other operand took
the same number and **all thirteen mutant renders came back
byte-identical**, which reads exactly like *the edit changed nothing*.
Keying the value to the operand's **NAME** fixed it. That is habit 4, in
the measuring instrument: the fixture's own values were arranged so the
failure it was built to see could not change the answer. **Eleventh
instance of habit 4 in this file, and the second one in an instrument
rather than in a check.**
And the mutation-table consequence: **a variant is not covered because a
near-miss of it is.** DEV-02's table listed the *actuator* variant of the
+3 -26
View File
@@ -449,32 +449,9 @@ mis_peak = SITE_SIDE[:1] + [Claim.limit("peak", P_PEAK, lo=1500.0,
r_mis = resolve(mis_peak)
print(f" peak floor misfiled on inverter-b: {r_mis.explain()} "
f"scope_errors={r_mis.scope_errors}")
# ⚠️ THE ORDERED PAIR, AND THE CHECK THAT USED TO BE HERE WAS WIDER THAN ITS
# OWN CLAIM. It said `scope_violations` "names the source and the actuator it
# does not belong on" and then asserted `"peak" in r_mis.scope_errors[0]`.
# `arbiter.py:264` builds that sentence as
# `f"{c.source}: a SITE claim filed on unit {c.actuator!r} - …"`; transpose the
# two - zero bytes, no decision changes, SAFETY-38 census B row B12 - and it
# reads "inverter-b: a SITE claim filed on unit 'peak'", naming the misfiled
# claim as the actuator and the actuator as the claim. `"peak" in …` is STILL
# TRUE under that, so the old line graded neither half of what it claimed.
# The operands are read off the CLAIM this test built, not off the message.
_mis_c = mis_peak[-1]
check("PRECONDITION: the misfiled claim's source and actuator are DIFFERENT "
"STRINGS, so a transposition genuinely produces a different sentence",
_mis_c.source != _mis_c.actuator and _mis_c.source == "peak"
and _mis_c.actuator == "inverter-b")
check("the mis-routing is CAUGHT, and scope_violations names the source FIRST "
"and the actuator it does not belong on SECOND - the ORDERED PAIR on the "
"contiguous literal, because a substring test finds either name under a "
"transposition that swaps their roles",
len(r_mis.scope_errors) == 1
and r_mis.scope_errors[0].startswith(
f"{_mis_c.source}: a SITE claim filed on unit {_mis_c.actuator!r}"))
check("...and the TRANSPOSED sentence is not what it says, so the check above "
"discriminates rather than decorates",
f"{_mis_c.actuator}: a SITE claim filed on unit {_mis_c.source!r}"
not in r_mis.scope_errors[0])
check("the mis-routing is CAUGHT: scope_violations names the source and the "
"actuator it does not belong on", len(r_mis.scope_errors) == 1
and "peak" in r_mis.scope_errors[0])
check("...and the reason it must be caught, measured rather than argued: with "
"the floor on a unit the SITE has no floor, so the loop charges 1500 W "
"into the quarter the guard was trying to shave",
-99
View File
@@ -1000,105 +1000,6 @@ check("...and main.py:856's consumer is the line that goes quietly false",
bool(ctl_w.clamp_last and ctl_w.clamp_last.engaged))
# --------------------------------------------------------------------------- #
# 6d. SAFETY-38 round 4 - THE ORDERED PAIR INSIDE `ClampResult.describe()`
# --------------------------------------------------------------------------- #
# ⚠️ THIS IS THE ALARM THE OWNER HAS BEEN COMPLAINING ABOUT ALL DAY, AND UNTIL
# THIS BLOCK NO test_*.py MENTIONED `describe()` AT ALL. `clamp.py:253` builds
# the whole §15 action-log row and the whole ClampWatchdog notification reason
# out of its own attributes:
#
# f"{REASON_CODE}: the computed setpoint {self.requested_w:+.0f} W was
# clamped to {self.applied_w:+.0f} W by the {self.bound} bound
# (rating {self.limit_w:.0f} W) - something upstream is winding"
#
# Transpose `requested_w` and `applied_w` and the guard reports clamping a
# setpoint UPWARD - here, to +9999 W by a bound whose rating the SAME SENTENCE
# gives as 2000 W. MEASURED on this tree: a zero-byte edit (22 507 bytes before,
# 22 507 after, sha256 0153133741497fa5 -> 47e4e371043e3201), the emitted
# sentence changes, and the full 21-file suite stayed GREEN. Nothing decides on
# the order, so nothing behavioural can ever see it - which is why the check
# below has to be on the ORDERED PAIR and on the CONTIGUOUS LITERAL. A substring
# test for either number passes under the swap; both numbers are still there.
#
# ⚠️ AND THE OPERANDS ARE READ OFF THE PLANT, NOT OFF `clamp_last`. `ctl.target`
# is what the loop asked for and `hass.writes[-1]` is what the device was
# actually sent; neither is a `ClampResult` field. Grading the sentence against
# `result.requested_w` would compare the swap against itself - DEV-02's defect,
# and the same one that made the first cut of this card's own render probe emit
# thirteen empty diffs.
async def _sentence(max_w, dev, wanted):
"""One real write over `max_w`, then the guard, on a real Controller."""
hass = _Hass(0.0)
c = Controller({"setpoint_entity": "number.sp", "meter_source": "off",
"max_w": max_w},
hass, _Store(), _Pub(), stop=asyncio.Event())
c.auto = True
c.dev_min, c.dev_max = -dev, dev
c.target = wanted
arm(c, wanted)
await c.write_setpoint(wanted)
await c.check_output_clamp()
return hass, c
hass_x, ctl_x = _sentence_run = asyncio.run(_sentence(2000.0, 5000.0, 9999.0))
notes_x = clamp_notes(hass_x)
_asked_x = ctl_x.target # what the loop asked for
_sent_x = hass_x.writes[-1] # what the device was actually sent
_rating_x = ctl_x.inverters[0].max_w # the bound the sentence names
_rows_x = [e for e in ctl_x.events if REASON_CODE in e]
print(f" max_w {_rating_x:.0f} on a +-5000 W device, asked for "
f"{_asked_x:+.0f} W: the device saw {_sent_x:+.0f} W")
print(f" action log: {_rows_x[0] if _rows_x else '(none)'}")
check("PRECONDITION: the write really clamped on the MAX_W bound, and the two "
"operands are DIFFERENT NUMBERS that RENDER differently - equal ones "
"would make the transposed sentence identical and every check below "
"vacuous. The asked-for magnitude is also strictly ABOVE the rating "
"while the sent one is not, which is what makes the swapped sentence "
"self-contradictory rather than merely wrong",
ctl_x.clamp_last is not None and ctl_x.clamp_last.engaged
and ctl_x.clamp_last.bound == "max_w"
and _asked_x != _sent_x
and f"{_asked_x:+.0f}" != f"{_sent_x:+.0f}"
and abs(_sent_x) <= _rating_x < abs(_asked_x))
check("⚠️ THE ORDERED PAIR IN THE §15 ACTION-LOG ROW (main.py:1366). The "
"contiguous literal names the setpoint the loop COMPUTED first and the "
"one that REACHED THE WIRE second. Under the transposition this row says "
"the loop asked for +2000 W and the guard clamped it UP to +9999 W - "
"past the 2000 W rating printed four words later - which is the alarm "
"reading backwards on the one line an operator is woken by",
any(f"the computed setpoint {_asked_x:+.0f} W was clamped to "
f"{_sent_x:+.0f} W" in e for e in _rows_x))
check("⚠️ ...AND IN THE ClampWatchdog NOTIFICATION REASON (main.py:2452), "
"which is a SECOND surface off the same sentence and has to be pinned "
"separately - SAFETY-38 round 3's whole lesson was that pinning one call "
"site leaves the other shipping",
bool(notes_x)
and f"the computed setpoint {_asked_x:+.0f} W was clamped to "
f"{_sent_x:+.0f} W" in notes_x[0]["message"])
check("...and the TRANSPOSED sentence appears on NEITHER surface, so the two "
"checks above discriminate rather than decorate",
not any(f"the computed setpoint {_sent_x:+.0f} W was clamped to "
f"{_asked_x:+.0f} W" in e for e in _rows_x)
and f"the computed setpoint {_sent_x:+.0f} W was clamped to "
f"{_asked_x:+.0f} W" not in notes_x[0]["message"])
# The DEVICE bound, off part 6c's controller: same sentence, other stage. The
# unit of coverage is the call site, and `bound` is the only thing that differs
# between the two rows an operator can receive.
_asked_w, _sent_w = ctl_w.target, hass_w.writes[-1]
check("...and the same ordered pair holds on the DEVICE-bound row, where the "
"loop asked for +3000 W and the device took +2500 W. PRECONDITION: those "
"two also render differently",
f"{_asked_w:+.0f}" != f"{_sent_w:+.0f}"
and any(f"the computed setpoint {_asked_w:+.0f} W was clamped to "
f"{_sent_w:+.0f} W" in e for e in ctl_w.events)
and f"the computed setpoint {_asked_w:+.0f} W was clamped to "
f"{_sent_w:+.0f} W" in notes_w[0]["message"])
# ==============================================================================
print()
print("7. SAFETY-28: the write path is gated on the allocation record")
+3 -188
View File
@@ -109,7 +109,6 @@ import re
import shutil
import sys
import tempfile
import time
from datetime import datetime, timedelta, timezone
from math import inf, isinf
from pathlib import Path
@@ -1063,58 +1062,6 @@ check("...and the status page says NOT COVERED rather than 'idle' or a green "
# block with no seconds remaining - so the whole setup is retried on a fresh
# Controller rather than left to flake. It is bounded at three attempts: a fourth
# would mean something is wrong that is not a boundary.
#
# ⚠️⚠️ CI-06. AND THE QUARTER MUST HAVE ROOM LEFT TO SIZE A FIXTURE IN, WHICH IS
# A SECOND AND MUCH WIDER RACE THAN THE BOUNDARY ONE ABOVE. It is where three of
# this file's four known-flaky checks came from, and it is not a boundary
# effect - it is the control law's own gain applied to this helper's arithmetic.
#
# Every second of the open block the telegrams do NOT cover is credited at the
# HELD value instead of the measured one, so the projection loses `net_w x
# uncovered / 900` W - and `excess = 900 x (projected - ceiling) / remaining`
# multiplies that back up by `900 / remaining`. The floor this helper delivers
# is therefore
#
# floor ~ want x (1 - uncovered/900) - net_w x uncovered / remaining
#
# and the second term is the whole story: at 600 s left one uncovered second
# costs 6 W of a 1500 W floor, at 12 s left it costs 292 W, and at 3 s left it
# takes the projection under the ceiling and THE GUARD DOES NOT ENGAGE AT ALL -
# which fails "the real cycle files the peak claim", "...and the number written
# to the inverter is the guard's floor" and "the STATUS PAGE row for a
# floor-limited guard" together, for no reason except the minute the suite ran.
# Measured on this tree as shipped: at 12 s left the fixture asked for 1500 W
# and delivered 613 W, a -59 % fixture that stayed green and graded a different
# site than the one its text names.
#
# TWO uncovered stretches produced it and both are closed here:
#
# 1. THE TAIL. `range(0, span + 1, 5)` stops at the last 5 s mark at or before
# `now`, so 0-5 s of the block were never covered by construction. One
# final telegram stamped at `now` closes it - which is also what a real
# meter does, and what this helper's own docstring already claims it does.
# 2. THE LAG between that last telegram and `cycle()`'s own `datetime.now()`.
# That one is real work and cannot be removed, only bounded: measured at
# 12-96 ms over 23 fixtures on this tree under contention.
#
# So the ROOM the helper needs is derived from what it promises rather than
# picked. Requiring the delivered floor to be within 1 % of the floor asked for,
# at the shipped ceiling and this file's smallest fixture (`net_w` ~ 3500 W,
# `want` 1500 W), and taking the lag at 0.1 s - past every one of the 23
# measurements:
#
# net_w x lag / (remaining x want) <= 0.01 - lag/900
# remaining >= 3500 x 0.1 / (1500 x 0.00989) = 23.6 s -> FIXTURE_ROOM_S
#
# Below that the helper WAITS OUT THE REST OF THE QUARTER rather than grading a
# fixture the arithmetic cannot deliver. The wait is at most FIXTURE_ROOM_S and
# is taken on about 2.6 % of runs; a suite that silently graded a -59 % fixture
# instead was the alternative. ⚠️ This is not a widened tolerance - the checks
# downstream are unchanged and the 1 % is a requirement on the FIXTURE, not on
# any assertion.
FIXTURE_ROOM_S = 24.0
def wired_over_limit(db, want_floor_w, opts=None, store=None, batt_w=0.0,
standing_w=None, soc=55.0, prepare=None):
"""A real Controller driven one real cycle over a real over-limit quarter.
@@ -1134,13 +1081,6 @@ def wired_over_limit(db, want_floor_w, opts=None, store=None, batt_w=0.0,
computed backwards from `remaining` at the moment of the run.
"""
for attempt in range(4):
# ⚠️ ROOM FIRST, AND BEFORE THE CONTROLLER IS BUILT. See FIXTURE_ROOM_S.
# Retrying does not help here - four attempts take ~40 ms each and all
# four would land in the same dying quarter - so the tail of the block
# is waited out once, which puts the next attempt at ~900 s of room.
_room = QUARTER_S - (datetime.now(timezone.utc).timestamp() % QUARTER_S)
if _room < FIXTURE_ROOM_S:
time.sleep(_room + 0.05)
ctl, hs = built(db if attempt == 0 else Path(f"{db}.{attempt}"), opts,
store=_Store(store) if store is not None else None)
ctl.auto, ctl.soc, ctl.batt = True, soc, batt_w
@@ -1159,13 +1099,6 @@ def wired_over_limit(db, want_floor_w, opts=None, store=None, batt_w=0.0,
net_w = CAP_W + want_floor_w * remaining / QUARTER_S
for i in range(0, span + 1, 5):
ctl.p1.submit(sample(blk + timedelta(seconds=i), net_w))
# ⚠️ AND ONE TELEGRAM AT `now` ITSELF. The averager credits a sample's
# value over the interval to the NEXT sample, so without this the block
# is covered only to the last 5 s mark and 0-5 s of it are extrapolated
# from the held value. See the header: at 12 s of remaining that alone
# turned a 1500 W fixture into a 613 W one. A real meter does not stop
# sending at the last multiple of five either.
ctl.p1.submit(sample(now, net_w))
# ⚠️ ASSIGNED, not read back from `p1.net_w`. This helper stamps
# `ingest_mono` with an epoch second so the averager (which keys on
# `ingest_ts`) replays correctly, while `net_w` keys on a MONOTONIC age
@@ -1340,23 +1273,6 @@ _shipped_decide = peakguard.decide
def _recording_decide(projection, **kw):
_seen.update(kw)
# ⚠️ CI-06. THE PROJECTION OBJECT ITSELF IS KEPT, AND THAT IS WHAT MAKES THE
# COMPARISON BELOW A COMPARISON RATHER THAN A RACE. The check under
# "THE CONSEQUENCE, ITERATED" says both forms are run "against the same
# frozen projection", and until this line existed that sentence was FALSE:
# the intent form was decided against a SECOND `PeakTracker.project(
# b3.p1.averager, datetime.now())` taken after the cycle had returned, so
# the two numbers came from two different instants of the wall clock. The
# control law is `excess = 900 x (projected - ceiling) / remaining`, whose
# derivative in time is `excess / remaining`, so at a 1500 W floor the two
# instants separated at 1500 / remaining W per second and the check's 1.5 W
# tolerance bought exactly `remaining / 1000` SECONDS - 900 ms at the top of
# a quarter and 6 ms with ten seconds left of it. Measured on this tree, the
# gap between the two statements is 13-40 ms idle and larger under load, so
# the check was answered by WHERE IN THE QUARTER IT RAN about `gap_ms / 900`
# of the time. Keeping the object removes the clock from the comparison
# altogether: the tolerance below is EXACT, not widened.
_seen["projection"] = projection
return _shipped_decide(projection, **kw)
@@ -1378,36 +1294,15 @@ check("B3: main.py sizes the floor from the MEASURED battery power, not from "
# against the same frozen projection - the inverter never follows, so the meter
# never moves - and the two are allowed to separate over time rather than being
# judged at a single point where they need not differ.
#
# ⚠️ CI-06. "THE SAME FROZEN PROJECTION" IS NOW THE SAME OBJECT. `_proj3` is the
# `Projection` the real cycle actually graded, captured by `_recording_decide`
# above, and not a second projection of the same averager taken at a later
# `datetime.now()`. Every number below therefore comes from ONE instant, and the
# tolerances that used to absorb the wall clock are gone.
_proj3 = _seen["projection"]
_proj3 = PeakTracker.project(b3.p1.averager, datetime.now(timezone.utc))
_kw = dict(cap_w=CAP_W, reading=b3.peak.peak(), achievable_w=LIVE_MAX_W,
max_hold_s=b3.p1.averager.max_hold_s, soc=SOC_OK,
soc_floor=SOC_FLOOR)
_intent = _shipped_decide(_proj3, batt_w=STANDING, **_kw)
# The delivered form, re-decided from the SAME projection. It has to reproduce
# the real controller's own floor EXACTLY or `_kw` is not the call main.py
# makes - which is a second thing this now grades and did not before, and the
# one that keeps the identity below honest rather than self-referential.
_delivered = _shipped_decide(_proj3, batt_w=0.0, **_kw)
check("the delivered form re-decided from the cycle's OWN projection is the "
"number the real Controller wrote, to the bit. The kwargs below are "
"main.py's call and not an approximation of it, so the identity that "
"follows is a statement about `decide()` and not about this fixture",
near(_delivered.floor_w, b3.peak_guard.floor_w))
check("one cycle of the intent form is exactly the delivered form plus the "
"command standing on the wire - which is the whole defect in one line: "
"watts that were asked for and never arrived, counted as if they had. "
"⚠️ EXACT, not within 1.5 W: both forms are decided from one Projection "
"object, so a tolerance here would only be hiding a clock. CI-06 - the "
"1.5 W it used to carry bought `remaining / 1000` seconds and made this "
"line answer by where in the quarter the suite happened to run",
near(_intent.floor_w - _delivered.floor_w, STANDING)
and not _intent.saturated and not _delivered.saturated)
"watts that were asked for and never arrived, counted as if they had",
near(_intent.floor_w - b3.peak_guard.floor_w, STANDING, 1.5))
_i_cmd, _d_batt = STANDING, 0.0
_i_track, _d_track, _i_sat_at = [], [], None
@@ -5774,86 +5669,6 @@ check("...and so is the last one, which is not a defeat at all but the EDGE OF "
not MUT[12][1] and EV["min_soc"] <= BMS_FLOOR + 0.05)
# ==============================================================================
print()
print("13. SAFETY-38 round 4: the ORDERED PAIR inside PeakDecision.explain()")
# ⚠️ THE STRING "peak guard idle" APPEARED IN NO test_*.py UNTIL THIS PART.
# `peakguard.py:493` formats its OWN ATTRIBUTES and RETURNS the sentence:
#
# f"peak guard idle - quarter projects {self.projected_w:.0f} W under a "
# f"{self.ceiling_w:.0f} W ceiling ({self.ceiling_from})"
#
# Transpose the two and this fixture's row reads "projects 5013 W under a
# 2300 W ceiling" - a quarter projected 2713 W OVER the ceiling, printed on a
# row whose own words say the guard is IDLE and whose own flag says ok.
# MEASURED on this tree: zero-byte edit (50 032 bytes
# before, 50 032 after, sha256 d95df61a51716c26 -> 2d2ee3d0bcd51251), the
# emitted sentence changes, and the full 21-file suite stayed GREEN.
#
# The sentence was invisible to SAFETY-38's scanner for two rounds because both
# censuses scored one syntactic sink - APPEND, ASSIGN, LOG ARG - and this one is
# RETURNED. `tools/symmetric_message_scan.py` census C now scans the return sink
# and resolves it through the method's call sites; it rediscovered this pair and
# clamp.py's on its own, which is the only reason to trust the rest of its list.
#
# ⚠️ THE OPERANDS COME OFF THE PROJECTION AND OFF `quarter_ceiling_w()`, NOT OFF
# THE DECISION. `d.projected_w` and `d.ceiling_w` are the very fields the swap
# exchanges, so grading the sentence against them would compare the swap against
# itself and pass under it.
_S38_PROJ = PeakTracker.project(averager_at(CAP_W - 1200.0, 450.0),
B + timedelta(seconds=450))
_S38_CEIL_W, _S38_CEIL_FROM = quarter_ceiling_w(CAP_W, obs_hi)
_S38_IDLE = decide(_S38_PROJ, batt_w=0.0, cap_w=CAP_W, reading=obs_hi,
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S,
soc=SOC_OK, soc_floor=SOC_FLOOR)
_S38_LITERAL = (f"quarter projects {_S38_PROJ.projected_w:.0f} W under a "
f"{_S38_CEIL_W:.0f} W ceiling")
_S38_SWAPPED = (f"quarter projects {_S38_CEIL_W:.0f} W under a "
f"{_S38_PROJ.projected_w:.0f} W ceiling")
print(f" projection {_S38_PROJ.projected_w:.0f} W against a "
f"{_S38_CEIL_W:.0f} W ceiling ({_S38_CEIL_FROM})")
print(f" {_S38_IDLE.explain()!r}")
check("PRECONDITION: the guard is IDLE with a projection to print, the ceiling "
"came from the MONTH PEAK rather than the cap, and the two operands are "
"DIFFERENT NUMBERS that RENDER differently. Equal operands would make the "
"transposed sentence identical and every check below decoration",
not _S38_IDLE.engaged and _S38_IDLE.projected_w is not None
and _S38_CEIL_FROM == CEILING_MONTH_PEAK
and _S38_PROJ.projected_w != _S38_CEIL_W
and f"{_S38_PROJ.projected_w:.0f}" != f"{_S38_CEIL_W:.0f}"
and _S38_PROJ.projected_w < _S38_CEIL_W)
check("⚠️ THE ORDERED PAIR ON THE CONTIGUOUS LITERAL. The PROJECTION is named "
"first and the CEILING second; a substring test for either number passes "
"under the transposition because both numbers are still in the sentence, "
"so only the ordered pair can tell the two slots apart (MUTATION.md "
"habit 14)", _S38_LITERAL in _S38_IDLE.explain())
check("...and the TRANSPOSED sentence is NOT what it says, so the check above "
"discriminates rather than decorates",
_S38_SWAPPED not in _S38_IDLE.explain())
check("...and the row still names WHERE the ceiling came from, which is the "
"difference between a cap an operator set and a peak the month already "
"banked", f"ceiling ({CEILING_MONTH_PEAK})" in _S38_IDLE.explain())
# THE SHIPPED CONSUMER. `explain()` is not read by anybody until main.py prints
# it, and round 3's lesson is that the pin belongs on the CALL SITE: the status
# row at main.py:3313 is what a browser shows, and the action-log line at
# main.py:1951 is what the release edge writes.
_c38, _h38 = built(TMP / "s38pin.db")
_c38.peak_guard = _S38_IDLE
_rows38 = [r for r in _c38.checks() if "peak guard" in r["text"]]
print(" status row: " + (_rows38[0]["text"] if _rows38 else "(none)"))
check("⚠️ ...AND THE SAME ORDERED PAIR ON THE STATUS PAGE (main.py:3313), read "
"off the shipped `checks()` rather than off `explain()` a second time. "
"This is the surface the owner actually looks at",
len(_rows38) == 1 and _S38_LITERAL in _rows38[0]["text"]
and _S38_SWAPPED not in _rows38[0]["text"])
check("...and the row is green and un-warned, which is what makes the "
"transposed version dangerous rather than merely untidy: an OK row "
"carrying a projection 2713 W OVER its own ceiling is the one an "
"operator scrolls past", _rows38[0]["ok"] and not _rows38[0]["warn"]
and _S38_CEIL_W - _S38_PROJ.projected_w == 2713.0)
# ==============================================================================
print()
shutil.rmtree(TMP, ignore_errors=True)
+68 -381
View File
@@ -228,20 +228,6 @@ def _sink_of(fn, target, follow=True):
up):
return "MESSAGE"
return "OTHER"
# ⚠️ ROUND 4. A SENTENCE HANDED TO A CONSTRUCTOR AS `reason=` IS A
# MESSAGE, and scoring only ASSIGNMENTS missed every one of them. This
# codebase builds most of its operator sentences that way -
# `ArbitrageDecision(action=..., reason=f"...")`,
# `out.append({"text": f"..."})` - so the sentence never touches a name
# this walker was watching. arbitrage.py's two DISCHARGE/CHARGE reasons
# were scored OTHER for exactly this and are genuine members.
if isinstance(up, ast.keyword) and up.arg in _MSG_ATTRS:
return "MESSAGE"
if isinstance(up, ast.Dict):
for k, v in zip(up.keys, up.values):
if (v is cur and isinstance(k, ast.Constant)
and k.value in _MSG_ATTRS):
return "MESSAGE"
if isinstance(up, ast.Call):
name = (up.func.attr if isinstance(up.func, ast.Attribute)
else up.func.id if isinstance(up.func, ast.Name) else "")
@@ -391,7 +377,6 @@ def scan():
# inventing members.
class _Blank(ast.NodeTransformer):
def visit_Attribute(self, node):
self.generic_visit(node)
@@ -408,194 +393,36 @@ def _blanked(expr):
return ast.dump(_Blank().visit(ast.parse(ast.unparse(expr))))
def _called_name(func):
return (func.attr if isinstance(func, ast.Attribute)
else func.id if isinstance(func, ast.Name) else "")
def _innermost(tree):
"""{id(node): INNERMOST enclosing function}, or None at module level.
⚠️ NOT `for fn in ast.walk(tree): for node in ast.walk(fn)`, which is what
the first version of census B did. That visits every sentence once per
ENCLOSING def, so a sentence inside a nested function was classified twice -
once against the inner scope and once against the outer - and the two
classifications are not obliged to agree. The rows were de-duplicated
afterwards, which hid the double visit rather than removing it.
"""
m = {}
def rec(node, fn):
for child in ast.iter_child_nodes(node):
f = (child if isinstance(child, (ast.FunctionDef,
ast.AsyncFunctionDef)) else fn)
m[id(child)] = f
rec(child, f)
rec(tree, None)
return m
def _spec(fv):
"""The FORMAT SPEC of one interpolation, as text; '' when there is none.
⚠️ IT IS PRINTED BESIDE EVERY PAIR BECAUSE IT IS THE ONE CHEAP TYPE SIGNAL
THIS SCAN HAS, and gap 4 of the second list below is exactly about the pairs
that do not share
one. `{self.requested_w:+.0f}` beside `{self.applied_w:+.0f}` is two floats
rendered identically - a transposition there is silent and reads as a fact.
`{self.limit_w:.0f}` beside `{self.bound}` is a float beside a string: a
transposition there prints `rating max_w W`, which is visibly broken rather
than quietly wrong. Same row in this census, very different hazard, and the
reader cannot tell them apart without this column.
"""
if fv.format_spec is None:
return ""
return "".join(str(v.value) if isinstance(v, ast.Constant) else "{...}"
for v in fv.format_spec.values)
def _same_shape_pairs(node):
"""Pairs of interpolations in ONE f-string that are exactly one leaf apart."""
fvs = [v for v in node.values if isinstance(v, ast.FormattedValue)]
for x, y in itertools.combinations(fvs, 2):
ua, ub = ast.unparse(x.value), ast.unparse(y.value)
if ua == ub:
continue
if _blanked(x.value) != _blanked(y.value):
continue
yield ua, ub, _spec(x), _spec(y)
def _sentences():
"""(path, innermost fn, JoinedStr, sink) for every f-string in app/."""
for path in sorted(APP.glob("*.py")):
tree = ast.parse(path.read_text())
inner = _innermost(tree)
for node in ast.walk(tree):
if not isinstance(node, ast.JoinedStr):
continue
fn = inner.get(id(node))
if fn is None:
continue
yield path, fn, node, _sink_of(fn, node)
def scan_caller():
rows = []
for path, fn, node, sink in _sentences():
# ⚠️ MESSAGE SINKS ONLY, AND THE FIRST VERSION OF THIS CENSUS SAID
# "anything but a RETURN". That let in `p1.py`'s
# `self.url = f"http://{self.host}:{self.port}…"` - an f-string that is
# DATA, used to make a request, where a transposition is an ordinary
# behavioural change every check can see. A formatted string is only in
# this family when its only consumer is a person. RETURN is not OTHER
# though, and treating it as if it were is what census C below fixes.
if sink != "MESSAGE":
continue
for ua, ub, sa, sb in _same_shape_pairs(node):
rows.append((path.name, node.lineno, fn.name, ua, ub, sa, sb))
return sorted(set(rows), key=lambda r: (r[0], r[1], r[3]))
# =============================================================================
# CENSUS C - THE SENTENCE THE OBJECT RETURNS ABOUT ITSELF
# =============================================================================
# ⚠️ SAFETY-38 ROUND 4. CENSUS A AND CENSUS B BETWEEN THEM COVERED ONE
# SYNTACTIC SINK AND CALLED IT THE FAMILY. Every member either census reported
# was a sentence APPENDED, ASSIGNED to a `reason`-shaped attribute, or passed as
# a LOG ARGUMENT. Not one was a sentence that is RETURNED. Census A had a RETURN
# bucket, but only for a callee formatting its own PARAMETERS - so a method
# formatting its own ATTRIBUTES and handing the string back was invisible to
# both, and `describe()` / `explain()` / the reason-builders are precisely where
# this codebase keeps its operator sentences. Two live members were sitting in
# that hole:
#
# clamp.py ClampResult.describe() requested_w <-> applied_w
# peakguard.py PeakDecision.explain() projected_w <-> ceiling_w
#
# Worse than the gap: it was in NONE of the five blind spots this file prints
# about itself. A tool that asserts a completeness it does not have is the
# recurring defect in a new hat, and the list at the bottom now names its sinks.
#
# THE TEST is census B's - one f-string, two interpolations one leaf apart - run
# over the RETURN sink, plus one thing census B never needed: A RETURNED STRING
# IS NOT YET A MESSAGE. `store.unit_key()` and `mqtt.unit_state_key()` return
# f-strings too, and swapping there renames a store key and an MQTT topic, which
# every ordinary check can see (MAINT-05 is the card about that). So the sink of
# a returned sentence is RESOLVED THROUGH ITS CALL SITES: the string is in this
# family only when some caller in `app/` feeds it to an operator.
def _return_consumers(func_name, depth=0, seen=frozenset()):
"""[(site, sink, verdict)] - where the STRING RETURNED BY `func_name` goes.
One hop is not enough: `Resolution.explain()` is returned again by wrappers
that are themselves logged, so a call site whose own sink is RETURN is
resolved by recursing on the ENCLOSING function's name, bounded by `depth`
and by `seen` so mutual recursion cannot loop.
⚠️ THE MATCH IS ON THE BARE METHOD NAME, exactly as `call_sites()` above,
and `explain` is the name of at least four unrelated methods in this tree
(`PeakDecision`, `UnitPlan`, `Resolution`, the maintenance migration
report). So this OVER-collects sites, which pushes a verdict towards
MESSAGE. That is the deliberate direction for a census whose measured
failure has twice been under-reporting - but it means the verdict is a
PROMPT TO READ THE SITES PRINTED BESIDE IT, not a proof.
"""
if func_name in seen or depth > 2:
return []
seen = seen | {func_name}
out = []
for path in sorted(APP.glob("*.py")):
tree = ast.parse(path.read_text())
inner = _innermost(tree)
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
for fn in ast.walk(tree):
if not isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
if _called_name(node.func) != func_name:
continue
fn = inner.get(id(node))
if fn is None:
continue
sink = _sink_of(fn, node)
label = f"{path.name}:{node.lineno} `{ast.unparse(node.func)}`"
if sink == "RETURN":
sub = _return_consumers(fn.name, depth + 1, seen)
if any(v == "MESSAGE" for _l, _s, v in sub):
verdict = "MESSAGE"
elif not sub:
verdict = "UNRESOLVED"
else:
verdict = "OTHER"
sink = f"RETURN by {fn.name}() -> {verdict}"
else:
verdict = sink
out.append((label, sink, verdict))
return sorted(set(out))
def scan_returned():
"""Census C rows: a returned sentence, two interpolations one leaf apart."""
rows = []
for path, fn, node, sink in _sentences():
if sink != "RETURN":
continue
pairs = list(_same_shape_pairs(node))
if not pairs:
continue
sites = _return_consumers(fn.name)
if any(v == "MESSAGE" for _l, _s, v in sites):
verdict = "MESSAGE"
elif not sites:
verdict = "UNRESOLVED - no call site inside app/"
else:
verdict = "OTHER"
for ua, ub, sa, sb in pairs:
rows.append(dict(module=path.name, line=node.lineno, func=fn.name,
a=ua, b=ub, spec_a=sa, spec_b=sb,
verdict=verdict, sites=sites))
return sorted(rows, key=lambda r: (r["module"], r["line"], r["a"]))
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
@@ -610,61 +437,22 @@ def call_sites(name):
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
if _called_name(node.func) != name:
continue
# ⚠️ THE RECEIVER IS PRINTED, because matching on the bare
# attribute name conflates methods: `evaluate` is the name of
# SAFETY-02's, SAFETY-18's and SAFETY-31's watchdogs alike, and
# a list of four lines with no receiver tells a reader nothing
# about which detector they are looking at. `inv.sign_wd` and
# `self.freeze_wd` are the disambiguation, and they are the
# caller's own words.
out.append(f"{path.name}:{node.lineno} `{ast.unparse(node.func)}`")
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 lazy_log_sites():
"""Log calls with a %-format template and >=2 substitution ARGUMENTS.
⚠️ THE SIZE OF THE LARGEST HOLE, MEASURED RATHER THAN ASSERTED. The
NOT-SCANNED list below used to end "and app/ uses that form", which is a
claim about the tree with no number on it - the exact shape this card has
spent four rounds arguing against. `_LOG.error("%s ... %s", a, b)` puts both
operands in ARGUMENTS, so no f-string census here can ever reach them, and
transposing the two costs the same zero bytes. This counts them.
"""
out = []
for path in sorted(APP.glob("*.py")):
tree = ast.parse(path.read_text())
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
if _called_name(node.func) not in _LOG_SINKS or len(node.args) < 3:
continue
head = node.args[0]
tmpl = ""
if isinstance(head, ast.Constant) and isinstance(head.value, str):
tmpl = head.value
elif isinstance(head, ast.BinOp) or isinstance(head, ast.JoinedStr):
tmpl = ast.unparse(head)
if tmpl.count("%") >= 2:
out.append((path.name, node.lineno,
f"{path.name}:{node.lineno} "
f"`{ast.unparse(node.func)}` with "
f"{len(node.args) - 1} args"))
return [r[2] for r in sorted(out)]
def _pair_line(sa, sb):
same = sa == sb
return (f" format spec: '{sa}' / '{sb}' -> "
+ ("SAME - two values rendered alike, so a transposition is "
"SILENT and reads as a fact"
if same else
"DIFFERENT - likely mixed types, so a transposition is more "
"likely to look broken than to lie (gap 4 below)"))
def main():
quiet = "--quiet" in sys.argv
rounds = build_fixpoint()
@@ -727,151 +515,50 @@ def main():
"ORDERED PAIR.")
cb = scan_caller()
print(f"candidates: {len(cb)}")
for mod, line, fn, ua, ub, sa, sb in cb:
for mod, line, fn, ua, ub in cb:
print(f" {mod}:{line} in {fn}()")
print(f" {ua} <-> {ub}")
print(_pair_line(sa, sb))
print()
print("=" * 78)
print("CENSUS C - SENTENCES AN OBJECT RETURNS ABOUT ITSELF (SAFETY-38 "
"round 4)")
print("A method formatting its OWN ATTRIBUTES and RETURNING the string was "
"in neither")
print("census above: census A only reads a callee's PARAMETERS, census B "
"only scored a")
print("MESSAGE sink. The verdict below is resolved through the CALL SITES "
"of the")
print("returning method - a returned string is only in this family once "
"some caller")
print("hands it to an operator (contrast store.unit_key(), whose return "
"value is a KEY).")
cc = scan_returned()
fam_c = [r for r in cc if r["verdict"] == "MESSAGE"]
hot = [r for r in fam_c if r["spec_a"] == r["spec_b"]]
print(f"returned sentences with a same-shape pair: {len(cc)} pair(s); "
f"{len(fam_c)} reach an operator;")
print(f"of those, {len(hot)} ALSO share a format spec - two values "
f"rendered alike, which is")
print("the hazard gap 4 below names: a transposition there is silent and "
"reads as a fact.")
print()
print("*** = reaches an operator AND shares a format spec - THE ACTIONABLE "
"SUBSET")
print(" ! = reaches an operator, specs differ (a swap is likelier to look "
"broken)")
print(" = does not reach an operator, or could not be resolved")
# grouped by the sentence, so the CONSUMERS list is printed once per
# method rather than once per pair - the ungrouped form was 20 identical
# lines under each of six pairs and nobody reads that.
for key in sorted({(r["module"], r["line"], r["func"]) for r in cc}):
mod, line, fname = key
group = [r for r in cc if (r["module"], r["line"], r["func"]) == key]
v = group[0]["verdict"]
print(f" {mod}:{line} {fname}() verdict: {v}")
for r in group:
mark = ("***" if r["verdict"] == "MESSAGE"
and r["spec_a"] == r["spec_b"]
else " ! " if r["verdict"] == "MESSAGE" else " ")
print(f" {mark} {r['a']} <-> {r['b']}")
print(f" {_pair_line(r['spec_a'], r['spec_b']).strip()}")
if not quiet:
sites = group[0]["sites"]
print(f" CONSUMERS ({len(sites)}) - bare-name match, so read "
f"the receiver:")
for label, sink, _v in sites or [("(none inside app/)", "", "")]:
print(f" {label} -> {sink}")
print()
print("=" * 78)
print("WHAT THIS SCANS, AND WHAT IT DOES NOT - stated because round 4's "
"blocker was")
print("a completeness this file ASSERTED and did not have. The sink "
"classifier is a")
print("syntactic walk up the parent chain plus ONE step of local data "
"flow, and it")
print("recognises exactly these sinks and no others:")
print(f" SCANNED as a MESSAGE : a call to {sorted(_LOG_SINKS)}")
print(" ...or to `append`; a `raise`; an "
"assignment to an")
print(f" attribute or local named {sorted(_MSG_ATTRS)}")
print(" ...or to a local that is then interpolated "
"into one of")
print(" those (the one data-flow step).")
print(" SCANNED as a RETURN : census C - resolved through the returning "
"method's")
print(" call sites, one hop plus two of recursion.")
print(" NOT SCANNED AT ALL, and each of these can hold a member:")
lazy = lazy_log_sites()
print(" * `%s`-style lazy logging - `_LOG.error('%s ... %s', a, b)`. The "
"arguments")
print(" are not an f-string, so no census here looks at them, and a "
"transposition")
print(" of two `%s` arguments is the same zero-byte edit. THIS IS THE "
"LARGEST")
print(f" REMAINING HOLE, and it is MEASURED, not asserted: "
f"{len(lazy)} such call(s)")
print(f" in app/, across "
f"{len({s.split(':')[0] for s in lazy})} module(s). None of them is "
f"scanned by anything")
print(" above. Listed here so the size of the hole is a number:")
for site in lazy or ["(none)"]:
print(f" {site}")
print(" * `.format()`, `%`-formatting, and sentences built by `+` or "
"`.join()` out of")
print(" non-f-string pieces.")
print(" * A sentence that leaves the function inside a DICT, LIST or "
"dataclass and is")
print(" rendered somewhere else - the one data-flow step is local and "
"follows only")
print(" a bare Name.")
print(" * `yield`ed sentences, and sentences stored on an object by any "
"attribute")
print(f" name outside {sorted(_MSG_ATTRS)}.")
print(" * Anything outside `app/`: `sim/`, `tools/`, `web.py`'s "
"templates, the")
print(" translations JSON, the firmware, the witness.")
print()
print("WHAT IT CAN SEE BUT GETS WRONG - the shape of each gap, named "
"rather than")
print("rounded off:")
print("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 and C, two interpolated values of the same KIND "
"that are not the")
print(" same SHAPE - `a.lo` beside `hi`, or one of them behind a local "
"variable.")
print(" Only one-leaf-apart expressions are paired.")
print(" 3. WHETHER each call site is pinned. The sites are listed above; "
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 every site list here "
"matches on the")
print(" bare METHOD NAME - three watchdogs are all called `evaluate` "
"and at least")
print(" four unrelated classes have an `explain()` - so the receiver is "
"printed")
print(" beside each one and a reader has to pick.")
print(" 4. ⚠️ IT CAN ALSO OVER-REPORT, IN ONE EXACT WAY. The sentence is "
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(" census A's comparison, so a swap that would make the FORMATTING "
"ITSELF fail")
print(" - a `DAY_NAMES[start]` where `start` is 1320 - is scored as "
"'changes no")
print(" decision'. `_raise_gap`'s (start, weekday) pairs are that. The "
"hazard is a")
print(" SAME-TYPED pair, which is why every census B and C row now "
"prints whether")
print(" the two format specs match.")
print(" 5. CENSUS C's verdict is a bare-name resolution (gap 3) and "
"therefore reads")
print(" MESSAGE too readily. Every verdict is printed with the sites "
"that produced")
print(" it; a MESSAGE verdict is a prompt to read them, not a proof.")
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__":