SAFETY-38 round 4: the enumeration covered one sink, and it was not the one this codebase uses
checks / unit (pull_request) Successful in 5m12s

The scanner's two censuses between them scored ONE syntactic sink and called it
the family. Every one of census B's 16 members was a sentence that is APPENDED,
ASSIGNED to a `reason`-shaped name, or passed as a LOG ARGUMENT. 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() /
reason-builder family, which is where this repository actually keeps its
operator sentences. Worse than the gap: it was in NONE of the five blind spots
the tool printed about itself.

THE SCANNER, WIDENED - and it rediscovered both members without being told:

  * CENSUS C, the return sink, resolved through the returning method's CALL
    SITES so a returned KEY (store.unit_key, mqtt.unit_state_key - MAINT-05's
    card) is not mistaken for a returned SENTENCE. 53 returned pairs, 39
    reaching an operator, 16 of those sharing a format spec.
  * A sentence handed to a constructor as `reason=` or placed under a "text"
    dict key is a MESSAGE. That one line of the sink walker was hiding SIX more
    caller-built members, census B 16 -> 22.
  * Every census B and C 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.
  * The blind-spot list is now TWO lists: what it scans (printed from the
    constants, so they cannot drift) and WHAT IT DOES NOT SCAN AT ALL, largest
    hole first - `%s`-style lazy logging, MEASURED at 31 calls across 8 modules
    and each one listed, not asserted.

CLOSED, both zero-byte survivors of a green 21-file suite:

  * clamp.py:253 ClampResult.describe(), requested_w <-> applied_w. No
    test_*.py mentioned describe() at all. It is the SS15 action-log row
    (main.py:1366) AND the ClampWatchdog notification reason (main.py:2452) -
    the alarm SAFETY-40 and SAFETY-41 are about. Under the swap the guard
    reports a setpoint clamped UPWARD, to +9999 W by a bound whose rating the
    same sentence gives as 2000 W. Pinned on the ORDERED PAIR on the contiguous
    literal at BOTH consumers, operands read off ctl.target and
    hass.writes[-1] - what the loop asked and what the device was sent - not
    off the ClampResult the swap lives in. 4 checks red.
  * peakguard.py:493 PeakDecision.explain(), projected_w <-> ceiling_w. "peak
    guard idle" was in no test_*.py. The row then reads "projects 5013 W under
    a 2300 W ceiling" - 2713 W OVER - on a row whose own words say idle and
    whose own flag says ok. Pinned on the decision and on the shipped status
    row. 3 checks red.
  * test_arbiter.py's scope-violation check, whose CLAIM was wider than its
    ASSERTION. Measured this round rather than left at source level: 0 bytes,
    21/21 green before, red now. 2 checks.

DISCLOSED, NOT CLOSED - 11 measured zero-byte survivors, each with its file
fingerprint and its 21/21 verdict, in the decision doc SS7e-SS7g. Three 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, one in main.py's
SoC-floor notification. An enumerated list with honest gaps beats a fifth round
finding a fifth instance.

CORRECTIONS THIS ROUND OWES:

  * 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, 1951.
    1951 is disclosed unpinned.
  * SS4's "Every one of the ten is red" sat under a twelve-row table: it is the
    ten site_standing entries.
  * SS6b said "weaker evidence than A1-A3"; A4 was measured, so SS7d's
    "A1-A4 and B1" is the right list and SS6b now says it.
  * AND THE PROBE THAT GRADES THESE MUTATIONS WAS 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 mutant renders came
    back byte-identical - which reads exactly like "the edit is inert". Keying
    the value to the NAME fixed it. Habit 4, in the measuring instrument:
    MUTATION.md habit 16 is the new entry, and the count there is corrected to
    eleven rather than inflated.

14 mutations measured: fresh full-repo copy each, mounted :ro, bytecode purged
and the purge COUNT printed, the edit re-read from disk, length and sha256
printed on both sides, and every one required to change the EMITTED SENTENCE
and not merely the bytes. 12 survived the pre-round suite, 1 was already
caught, 3 are caught now. Full 21-file suite green on the final tree.

NOT ONE BYTE OF app/ MOVES. This card changes coverage only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
Claude Opus 5
2026-09-05 22:38:40 +02:00
parent 357cb4f07f
commit e2efc765af
7 changed files with 901 additions and 78 deletions
+67
View File
@@ -222,6 +222,73 @@ 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 11 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: three 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. **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 is red in `test_watchdog.py`.** Re-measured in round 2
**Every one of the ten `site_standing` entries is red in `test_watchdog.py`.** Re-measured in round 2
on `release/1.0` @ `86eaef2` (where `app/main.py` is 234 542 bytes, 2 022 more
than the `f72c48d` the round-1 figures were taken on): all ten still red, at the
byte deltas in the table, each on a fresh copy with the edit re-read from disk.
@@ -584,13 +584,73 @@ 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–A3, and written down as such.
mutated. Weaker evidence than A1–A4 and B1, and written down as such
— §7d says the same thing with the same list.
* **B6**, SAFETY-31's `circ_reason`, is the same shape as the defect this card
closed for SAFETY-02/30 and is *also* persisted and re-read after a restart.
§6a already declines to touch `circ_wd`; §7d says what that costs.
* Everything §6a lists stays open: `circ_wd`'s own thresholds, the eight other
`CLAIM_SCOPE` entries, `actuator_of()`'s body, and the `n_units > 1` guard.
### 6c. Round 4: what was closed, and what is still open
**The blocker round 3 shipped.** The enumeration read as complete and covered
**one syntactic sink**. Every one of census B's 16 members was a sentence that
is **appended, assigned, or passed as a log argument** (`problems.append`,
`out.append({"text": …})`, `self.reason = …`, `_LOG.info`). **Not one was a
sentence that is RETURNED.** Census A has a return bucket, but only for
symmetric **parameters**, so a method formatting its **own attributes** and
handing the string back was invisible to both — and `describe()` / `explain()` /
the reason-builders are exactly where this codebase keeps its operator
sentences. Worse than the gap: it was in **none of the five blind spots the tool
prints about itself**. A tool that asserts a completeness it does not have is
the recurring defect wearing a new hat.
**Closed in round 4:**
1. **`clamp.py:253` `ClampResult.describe()`**, pair `requested_w` /
`applied_w` — §7e C6. A survivor of the round-3 tree at **21/21 green**,
**0 bytes**. It is the §15 action-log row at `main.py:1366` **and** the
ClampWatchdog notification reason at `main.py:2452`; **no `test_*.py`
mentioned `describe()` at all.** Pinned on the ordered pair, on the
contiguous literal, at **both** consumers, with a precondition that the two
operands render differently — `test_clamp.py` part 6d. **4 checks red** under
the transposition.
2. **`peakguard.py:493` `PeakDecision.explain()`**, pair `projected_w` /
`ceiling_w` — §7e C7. Same measurement: **0 bytes, 21/21 green**; the string
`"peak guard idle"` was in no `test_*.py`. Pinned on the decision and on the
shipped status row at `main.py:3313` — `test_peakguard.py` part 13. **3
checks red.**
3. **`arbiter.py:264` (B12)** — the check whose **claim was wider than its
assertion**, carried forward from §7d. Measured this round rather than left
at source level: **0 bytes, 21/21 green** before, **`test_arbiter.py` red**
after. **2 checks red.**
4. **The tool's sinks are now stated rather than implied.** It prints what it
scans as a MESSAGE, that it now scans the RETURN sink through the returning
method's call sites, and — the part that was missing — **an explicit list of
what it does not scan at all**, largest hole first.
**⚠️ The correction round 4 owes the ticket.** The brief placed
`PeakDecision.explain()`'s idle sentence at `main.py:3299/3302`. Those two lines
are the **floor-limited** branch, where `explain()` returns `self.reason` before
the idle literal is ever built. The idle sentence reaches the page at
**`main.py:3313`** and the action log at **`main.py:1951`** (edge-logged, so it
is the RELEASE edge that writes it). The pin is on 3313; 1951 is disclosed
below, unpinned.
**Open, named, and measured — see §7f for the ledger:**
* **Nine further zero-byte survivors** found by the widened scan, each measured
at 21/21 green on this tree: three in `allocator.py`'s `UnitPlan.explain()`,
three in `arbiter.py`'s two `explain()`s, two in `tariff.py`, and one each in
`arbitrage.py`'s two decision reasons and `main.py`'s SoC-floor notification.
None is a control decision; every one is a sentence an operator reads.
* **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
@@ -664,8 +724,7 @@ 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:
key is data, and a swap there is an ordinary behavioural change. **16 candidates** in round 3, every one of them a **zero-byte** edit — and **22** after round 4 widened the sink classifier (B17–B22 below):
| # | site | pair | reaches an operator as | pinned? |
|---|---|---|---|---|
@@ -678,11 +737,22 @@ candidates**, every one of them a **zero-byte** edit:
| **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. |
| **B12** | `arbiter.py:264` | `c.source` / `c.actuator` | `scope_errors`, the arbiter's mis-routing refusal | **ROUND 4: MEASURED, THEN CLOSED.** Round 3 left it at source level; the transposition is **0 bytes**, `arbiter.py` `fdb4ed0520eeb721` → `9111f627ef639ecc`, and it was **21/21 green**. Now red in `test_arbiter.py` on 2 checks. Round 3's reasoning was right: `test_arbiter.py:452` says it *"names the source and the actuator it does not belong on"* and then asserts `"peak" in r_mis.scope_errors[0]`. Under the swap the sentence becomes `"inverter-b: a SITE claim filed on unit 'peak'"` — and `"peak" in …` is **still true**. A claim wider than its assertion, which is habit 14's own shape. |
| **B13** | `validate.py:723` | `meter_phases` / `meter_max_age_s` | the startup refusal | **NO** for the ordered pair. |
| **B14** | `validate.py:1211` | `arbitrage_soc_floor` / `arbitrage_soc_ceiling` | the startup refusal | **NO** for the ordered pair. |
| **B15** | `watchdog.py:667` | `cfg.min_w` / `cfg.hold_s` | the `SIGN_DISAGREEMENT` sentence's `(limits: both over 300 W, held over 180 s)` clause | **YES**, and it is worth naming because it shows the habit works. `test_watchdog.py`'s SAFETY-30 section asserts the site instance's **whole sentence with `==` against a literal**, the `(limits: …)` clause included. Full-string equality is habit 14 taken to its limit. |
| B16 | `main.py:237` | `i.name` / `i.max_w` | the roster log line | **not real** — a `str` beside a `:.0f` format. The same over-report as A5–A6: a mixed-type transposition raises rather than lying. |
| **B17** | `arbitrage.py:377` | `v.spread_eur_kwh` / `v.required_spread_eur_kwh` | the DISCHARGE decision's `reason` | **ROUND 4, NEW, and MEASURED: 0 bytes, 21/21 green.** Not visible to round 3 at all — the sentence is handed to a constructor as `reason=`, and the sink walker scored only *assignments*. The row then reads *"0.0400 above the cheapest and clear of the 0.1200 this cycle had to cover"* with the two swapped: a trade justified by a spread it did not clear. |
| **B18** | `arbitrage.py:393` | same pair, CHARGE branch | the CHARGE decision's `reason` | **ROUND 4, NEW, MEASURED: 0 bytes, 21/21 green.** |
| **B19** | `main.py:2652` | `g.reason` / `g.soc_floor_reason` | the `goodwe_peak_soc_floor` notification body | **ROUND 4, NEW, MEASURED: 0 bytes, 21/21 green.** The alarm then opens with the *reason the floor bound* and closes with the *guard's own conclusion*, i.e. both halves of a two-part explanation in the wrong order, in the one message SAFETY-24/25 exist to send. |
| B20–B22 | `main.py:2558`, `main.py:2652` (two more pairs) | `g.reason` / `g.ceiling_w`, `g.ceiling_w` / `g.soc_floor_reason` | the same two notifications | **new in round 4, NOT measured** — mixed types (`str` beside `:.0f`), the same over-report class as B16. |
**⚠️ Six of these twenty-two were invisible to round 3, and the reason is one
line of the sink classifier.** It scored a sentence a MESSAGE when it was
appended, logged, raised, or assigned to a `reason`-shaped **name**. This
codebase mostly builds operator sentences as `Decision(…, reason=f"…")` and
`out.append({"text": f"…"})` — a **keyword argument** and a **dict key**, which
that walker stepped straight past. Both are message sinks now.
### 7d. What this enumeration does NOT close, with sizes
@@ -723,3 +793,123 @@ candidates**, every one of them a **zero-byte** edit:
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.
+52 -1
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. TEN instances in two weeks; four
It is not hypothetical and it is not rare. ELEVEN instances in two weeks; four
were found by review, two - CFG-04's fixture pair - by an author's own mutation
battery, which is the cheaper place, one by review again, in the one
place nobody had thought to point the rule at: not a check in the suite, but a
@@ -145,6 +145,15 @@ 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
@@ -494,6 +503,48 @@ 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
+26 -3
View File
@@ -449,9 +449,32 @@ mis_peak = SITE_SIDE[:1] + [Claim.limit("peak", P_PEAK, lo=1500.0,
r_mis = resolve(mis_peak)
print(f" peak floor misfiled on inverter-b: {r_mis.explain()} "
f"scope_errors={r_mis.scope_errors}")
check("the mis-routing is CAUGHT: scope_violations names the source and the "
"actuator it does not belong on", len(r_mis.scope_errors) == 1
and "peak" in r_mis.scope_errors[0])
# ⚠️ THE ORDERED PAIR, AND THE CHECK THAT USED TO BE HERE WAS WIDER THAN ITS
# OWN CLAIM. It said `scope_violations` "names the source and the actuator it
# does not belong on" and then asserted `"peak" in r_mis.scope_errors[0]`.
# `arbiter.py:264` builds that sentence as
# `f"{c.source}: a SITE claim filed on unit {c.actuator!r} - …"`; transpose the
# two - zero bytes, no decision changes, SAFETY-38 census B row B12 - and it
# reads "inverter-b: a SITE claim filed on unit 'peak'", naming the misfiled
# claim as the actuator and the actuator as the claim. `"peak" in …` is STILL
# TRUE under that, so the old line graded neither half of what it claimed.
# The operands are read off the CLAIM this test built, not off the message.
_mis_c = mis_peak[-1]
check("PRECONDITION: the misfiled claim's source and actuator are DIFFERENT "
"STRINGS, so a transposition genuinely produces a different sentence",
_mis_c.source != _mis_c.actuator and _mis_c.source == "peak"
and _mis_c.actuator == "inverter-b")
check("the mis-routing is CAUGHT, and scope_violations names the source FIRST "
"and the actuator it does not belong on SECOND - the ORDERED PAIR on the "
"contiguous literal, because a substring test finds either name under a "
"transposition that swaps their roles",
len(r_mis.scope_errors) == 1
and r_mis.scope_errors[0].startswith(
f"{_mis_c.source}: a SITE claim filed on unit {_mis_c.actuator!r}"))
check("...and the TRANSPOSED sentence is not what it says, so the check above "
"discriminates rather than decorates",
f"{_mis_c.actuator}: a SITE claim filed on unit {_mis_c.source!r}"
not in r_mis.scope_errors[0])
check("...and the reason it must be caught, measured rather than argued: with "
"the floor on a unit the SITE has no floor, so the loop charges 1500 W "
"into the quarter the guard was trying to shave",
+99
View File
@@ -1000,6 +1000,105 @@ check("...and main.py:856's consumer is the line that goes quietly false",
bool(ctl_w.clamp_last and ctl_w.clamp_last.engaged))
# --------------------------------------------------------------------------- #
# 6d. SAFETY-38 round 4 - THE ORDERED PAIR INSIDE `ClampResult.describe()`
# --------------------------------------------------------------------------- #
# ⚠️ THIS IS THE ALARM THE OWNER HAS BEEN COMPLAINING ABOUT ALL DAY, AND UNTIL
# THIS BLOCK NO test_*.py MENTIONED `describe()` AT ALL. `clamp.py:253` builds
# the whole §15 action-log row and the whole ClampWatchdog notification reason
# out of its own attributes:
#
# f"{REASON_CODE}: the computed setpoint {self.requested_w:+.0f} W was
# clamped to {self.applied_w:+.0f} W by the {self.bound} bound
# (rating {self.limit_w:.0f} W) - something upstream is winding"
#
# Transpose `requested_w` and `applied_w` and the guard reports clamping a
# setpoint UPWARD - here, to +9999 W by a bound whose rating the SAME SENTENCE
# gives as 2000 W. MEASURED on this tree: a zero-byte edit (22 507 bytes before,
# 22 507 after, sha256 0153133741497fa5 -> 47e4e371043e3201), the emitted
# sentence changes, and the full 21-file suite stayed GREEN. Nothing decides on
# the order, so nothing behavioural can ever see it - which is why the check
# below has to be on the ORDERED PAIR and on the CONTIGUOUS LITERAL. A substring
# test for either number passes under the swap; both numbers are still there.
#
# ⚠️ AND THE OPERANDS ARE READ OFF THE PLANT, NOT OFF `clamp_last`. `ctl.target`
# is what the loop asked for and `hass.writes[-1]` is what the device was
# actually sent; neither is a `ClampResult` field. Grading the sentence against
# `result.requested_w` would compare the swap against itself - DEV-02's defect,
# and the same one that made the first cut of this card's own render probe emit
# thirteen empty diffs.
async def _sentence(max_w, dev, wanted):
"""One real write over `max_w`, then the guard, on a real Controller."""
hass = _Hass(0.0)
c = Controller({"setpoint_entity": "number.sp", "meter_source": "off",
"max_w": max_w},
hass, _Store(), _Pub(), stop=asyncio.Event())
c.auto = True
c.dev_min, c.dev_max = -dev, dev
c.target = wanted
arm(c, wanted)
await c.write_setpoint(wanted)
await c.check_output_clamp()
return hass, c
hass_x, ctl_x = _sentence_run = asyncio.run(_sentence(2000.0, 5000.0, 9999.0))
notes_x = clamp_notes(hass_x)
_asked_x = ctl_x.target # what the loop asked for
_sent_x = hass_x.writes[-1] # what the device was actually sent
_rating_x = ctl_x.inverters[0].max_w # the bound the sentence names
_rows_x = [e for e in ctl_x.events if REASON_CODE in e]
print(f" max_w {_rating_x:.0f} on a +-5000 W device, asked for "
f"{_asked_x:+.0f} W: the device saw {_sent_x:+.0f} W")
print(f" action log: {_rows_x[0] if _rows_x else '(none)'}")
check("PRECONDITION: the write really clamped on the MAX_W bound, and the two "
"operands are DIFFERENT NUMBERS that RENDER differently - equal ones "
"would make the transposed sentence identical and every check below "
"vacuous. The asked-for magnitude is also strictly ABOVE the rating "
"while the sent one is not, which is what makes the swapped sentence "
"self-contradictory rather than merely wrong",
ctl_x.clamp_last is not None and ctl_x.clamp_last.engaged
and ctl_x.clamp_last.bound == "max_w"
and _asked_x != _sent_x
and f"{_asked_x:+.0f}" != f"{_sent_x:+.0f}"
and abs(_sent_x) <= _rating_x < abs(_asked_x))
check("⚠️ THE ORDERED PAIR IN THE §15 ACTION-LOG ROW (main.py:1366). The "
"contiguous literal names the setpoint the loop COMPUTED first and the "
"one that REACHED THE WIRE second. Under the transposition this row says "
"the loop asked for +2000 W and the guard clamped it UP to +9999 W - "
"past the 2000 W rating printed four words later - which is the alarm "
"reading backwards on the one line an operator is woken by",
any(f"the computed setpoint {_asked_x:+.0f} W was clamped to "
f"{_sent_x:+.0f} W" in e for e in _rows_x))
check("⚠️ ...AND IN THE ClampWatchdog NOTIFICATION REASON (main.py:2452), "
"which is a SECOND surface off the same sentence and has to be pinned "
"separately - SAFETY-38 round 3's whole lesson was that pinning one call "
"site leaves the other shipping",
bool(notes_x)
and f"the computed setpoint {_asked_x:+.0f} W was clamped to "
f"{_sent_x:+.0f} W" in notes_x[0]["message"])
check("...and the TRANSPOSED sentence appears on NEITHER surface, so the two "
"checks above discriminate rather than decorate",
not any(f"the computed setpoint {_sent_x:+.0f} W was clamped to "
f"{_asked_x:+.0f} W" in e for e in _rows_x)
and f"the computed setpoint {_sent_x:+.0f} W was clamped to "
f"{_asked_x:+.0f} W" not in notes_x[0]["message"])
# The DEVICE bound, off part 6c's controller: same sentence, other stage. The
# unit of coverage is the call site, and `bound` is the only thing that differs
# between the two rows an operator can receive.
_asked_w, _sent_w = ctl_w.target, hass_w.writes[-1]
check("...and the same ordered pair holds on the DEVICE-bound row, where the "
"loop asked for +3000 W and the device took +2500 W. PRECONDITION: those "
"two also render differently",
f"{_asked_w:+.0f}" != f"{_sent_w:+.0f}"
and any(f"the computed setpoint {_asked_w:+.0f} W was clamped to "
f"{_sent_w:+.0f} W" in e for e in ctl_w.events)
and f"the computed setpoint {_asked_w:+.0f} W was clamped to "
f"{_sent_w:+.0f} W" in notes_w[0]["message"])
# ==============================================================================
print()
print("7. SAFETY-28: the write path is gated on the allocation record")
+80
View File
@@ -5774,6 +5774,86 @@ check("...and so is the last one, which is not a defeat at all but the EDGE OF "
not MUT[12][1] and EV["min_soc"] <= BMS_FLOOR + 0.05)
# ==============================================================================
print()
print("13. SAFETY-38 round 4: the ORDERED PAIR inside PeakDecision.explain()")
# ⚠️ THE STRING "peak guard idle" APPEARED IN NO test_*.py UNTIL THIS PART.
# `peakguard.py:493` formats its OWN ATTRIBUTES and RETURNS the sentence:
#
# f"peak guard idle - quarter projects {self.projected_w:.0f} W under a "
# f"{self.ceiling_w:.0f} W ceiling ({self.ceiling_from})"
#
# Transpose the two and this fixture's row reads "projects 5013 W under a
# 2300 W ceiling" - a quarter projected 2713 W OVER the ceiling, printed on a
# row whose own words say the guard is IDLE and whose own flag says ok.
# MEASURED on this tree: zero-byte edit (50 032 bytes
# before, 50 032 after, sha256 d95df61a51716c26 -> 2d2ee3d0bcd51251), the
# emitted sentence changes, and the full 21-file suite stayed GREEN.
#
# The sentence was invisible to SAFETY-38's scanner for two rounds because both
# censuses scored one syntactic sink - APPEND, ASSIGN, LOG ARG - and this one is
# RETURNED. `tools/symmetric_message_scan.py` census C now scans the return sink
# and resolves it through the method's call sites; it rediscovered this pair and
# clamp.py's on its own, which is the only reason to trust the rest of its list.
#
# ⚠️ THE OPERANDS COME OFF THE PROJECTION AND OFF `quarter_ceiling_w()`, NOT OFF
# THE DECISION. `d.projected_w` and `d.ceiling_w` are the very fields the swap
# exchanges, so grading the sentence against them would compare the swap against
# itself and pass under it.
_S38_PROJ = PeakTracker.project(averager_at(CAP_W - 1200.0, 450.0),
B + timedelta(seconds=450))
_S38_CEIL_W, _S38_CEIL_FROM = quarter_ceiling_w(CAP_W, obs_hi)
_S38_IDLE = decide(_S38_PROJ, batt_w=0.0, cap_w=CAP_W, reading=obs_hi,
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S,
soc=SOC_OK, soc_floor=SOC_FLOOR)
_S38_LITERAL = (f"quarter projects {_S38_PROJ.projected_w:.0f} W under a "
f"{_S38_CEIL_W:.0f} W ceiling")
_S38_SWAPPED = (f"quarter projects {_S38_CEIL_W:.0f} W under a "
f"{_S38_PROJ.projected_w:.0f} W ceiling")
print(f" projection {_S38_PROJ.projected_w:.0f} W against a "
f"{_S38_CEIL_W:.0f} W ceiling ({_S38_CEIL_FROM})")
print(f" {_S38_IDLE.explain()!r}")
check("PRECONDITION: the guard is IDLE with a projection to print, the ceiling "
"came from the MONTH PEAK rather than the cap, and the two operands are "
"DIFFERENT NUMBERS that RENDER differently. Equal operands would make the "
"transposed sentence identical and every check below decoration",
not _S38_IDLE.engaged and _S38_IDLE.projected_w is not None
and _S38_CEIL_FROM == CEILING_MONTH_PEAK
and _S38_PROJ.projected_w != _S38_CEIL_W
and f"{_S38_PROJ.projected_w:.0f}" != f"{_S38_CEIL_W:.0f}"
and _S38_PROJ.projected_w < _S38_CEIL_W)
check("⚠️ THE ORDERED PAIR ON THE CONTIGUOUS LITERAL. The PROJECTION is named "
"first and the CEILING second; a substring test for either number passes "
"under the transposition because both numbers are still in the sentence, "
"so only the ordered pair can tell the two slots apart (MUTATION.md "
"habit 14)", _S38_LITERAL in _S38_IDLE.explain())
check("...and the TRANSPOSED sentence is NOT what it says, so the check above "
"discriminates rather than decorates",
_S38_SWAPPED not in _S38_IDLE.explain())
check("...and the row still names WHERE the ceiling came from, which is the "
"difference between a cap an operator set and a peak the month already "
"banked", f"ceiling ({CEILING_MONTH_PEAK})" in _S38_IDLE.explain())
# THE SHIPPED CONSUMER. `explain()` is not read by anybody until main.py prints
# it, and round 3's lesson is that the pin belongs on the CALL SITE: the status
# row at main.py:3313 is what a browser shows, and the action-log line at
# main.py:1951 is what the release edge writes.
_c38, _h38 = built(TMP / "s38pin.db")
_c38.peak_guard = _S38_IDLE
_rows38 = [r for r in _c38.checks() if "peak guard" in r["text"]]
print(" status row: " + (_rows38[0]["text"] if _rows38 else "(none)"))
check("⚠️ ...AND THE SAME ORDERED PAIR ON THE STATUS PAGE (main.py:3313), read "
"off the shipped `checks()` rather than off `explain()` a second time. "
"This is the surface the owner actually looks at",
len(_rows38) == 1 and _S38_LITERAL in _rows38[0]["text"]
and _S38_SWAPPED not in _rows38[0]["text"])
check("...and the row is green and un-warned, which is what makes the "
"transposed version dangerous rather than merely untidy: an OK row "
"carrying a projection 2713 W OVER its own ceiling is the one an "
"operator scrolls past", _rows38[0]["ok"] and not _rows38[0]["warn"]
and _S38_CEIL_W - _S38_PROJ.projected_w == 2713.0)
# ==============================================================================
print()
shutil.rmtree(TMP, ignore_errors=True)
+382 -69
View File
@@ -228,6 +228,20 @@ 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 "")
@@ -377,6 +391,7 @@ def scan():
# inventing members.
class _Blank(ast.NodeTransformer):
def visit_Attribute(self, node):
self.generic_visit(node)
@@ -393,38 +408,196 @@ def _blanked(expr):
return ast.dump(_Blank().visit(ast.parse(ast.unparse(expr))))
def scan_caller():
rows = []
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())
for fn in ast.walk(tree):
if not isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)):
inner = _innermost(tree)
for node in ast.walk(tree):
if not isinstance(node, ast.JoinedStr):
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
fn = inner.get(id(node))
if fn is None:
continue
yield path, fn, node, _sink_of(fn, node)
def scan_caller():
rows = []
for path, fn, node, sink in _sentences():
# ⚠️ MESSAGE SINKS ONLY, AND THE FIRST VERSION OF THIS CENSUS SAID
# "anything but a RETURN". That let in `p1.py`'s
# `self.url = f"http://{self.host}:{self.port}…"` - an f-string that is
# DATA, used to make a request, where a transposition is an ordinary
# behavioural change every check can see. A formatted string is only in
# this family when its only consumer is a person. RETURN is not OTHER
# though, and treating it as if it were is what census C below fixes.
if sink != "MESSAGE":
continue
for ua, ub, sa, sb in _same_shape_pairs(node):
rows.append((path.name, node.lineno, fn.name, ua, ub, sa, sb))
return sorted(set(rows), key=lambda r: (r[0], r[1], r[3]))
# =============================================================================
# CENSUS C - THE SENTENCE THE OBJECT RETURNS ABOUT ITSELF
# =============================================================================
# ⚠️ SAFETY-38 ROUND 4. CENSUS A AND CENSUS B BETWEEN THEM COVERED ONE
# SYNTACTIC SINK AND CALLED IT THE FAMILY. Every member either census reported
# was a sentence APPENDED, ASSIGNED to a `reason`-shaped attribute, or passed as
# a LOG ARGUMENT. Not one was a sentence that is RETURNED. Census A had a RETURN
# bucket, but only for a callee formatting its own PARAMETERS - so a method
# formatting its own ATTRIBUTES and handing the string back was invisible to
# both, and `describe()` / `explain()` / the reason-builders are precisely where
# this codebase keeps its operator sentences. Two live members were sitting in
# that hole:
#
# clamp.py ClampResult.describe() requested_w <-> applied_w
# peakguard.py PeakDecision.explain() projected_w <-> ceiling_w
#
# Worse than the gap: it was in NONE of the five blind spots this file prints
# about itself. A tool that asserts a completeness it does not have is the
# recurring defect in a new hat, and the list at the bottom now names its sinks.
#
# THE TEST is census B's - one f-string, two interpolations one leaf apart - run
# over the RETURN sink, plus one thing census B never needed: A RETURNED STRING
# IS NOT YET A MESSAGE. `store.unit_key()` and `mqtt.unit_state_key()` return
# f-strings too, and swapping there renames a store key and an MQTT topic, which
# every ordinary check can see (MAINT-05 is the card about that). So the sink of
# a returned sentence is RESOLVED THROUGH ITS CALL SITES: the string is in this
# family only when some caller in `app/` feeds it to an operator.
def _return_consumers(func_name, depth=0, seen=frozenset()):
"""[(site, sink, verdict)] - where the STRING RETURNED BY `func_name` goes.
One hop is not enough: `Resolution.explain()` is returned again by wrappers
that are themselves logged, so a call site whose own sink is RETURN is
resolved by recursing on the ENCLOSING function's name, bounded by `depth`
and by `seen` so mutual recursion cannot loop.
⚠️ THE MATCH IS ON THE BARE METHOD NAME, exactly as `call_sites()` above,
and `explain` is the name of at least four unrelated methods in this tree
(`PeakDecision`, `UnitPlan`, `Resolution`, the maintenance migration
report). So this OVER-collects sites, which pushes a verdict towards
MESSAGE. That is the deliberate direction for a census whose measured
failure has twice been under-reporting - but it means the verdict is a
PROMPT TO READ THE SITES PRINTED BESIDE IT, not a proof.
"""
if func_name in seen or depth > 2:
return []
seen = seen | {func_name}
out = []
for path in sorted(APP.glob("*.py")):
tree = ast.parse(path.read_text())
inner = _innermost(tree)
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
if _called_name(node.func) != func_name:
continue
fn = inner.get(id(node))
if fn is None:
continue
sink = _sink_of(fn, node)
label = f"{path.name}:{node.lineno} `{ast.unparse(node.func)}`"
if sink == "RETURN":
sub = _return_consumers(fn.name, depth + 1, seen)
if any(v == "MESSAGE" for _l, _s, v in sub):
verdict = "MESSAGE"
elif not sub:
verdict = "UNRESOLVED"
else:
verdict = "OTHER"
sink = f"RETURN by {fn.name}() -> {verdict}"
else:
verdict = sink
out.append((label, sink, verdict))
return sorted(set(out))
def scan_returned():
"""Census C rows: a returned sentence, two interpolations one leaf apart."""
rows = []
for path, fn, node, sink in _sentences():
if sink != "RETURN":
continue
pairs = list(_same_shape_pairs(node))
if not pairs:
continue
sites = _return_consumers(fn.name)
if any(v == "MESSAGE" for _l, _s, v in sites):
verdict = "MESSAGE"
elif not sites:
verdict = "UNRESOLVED - no call site inside app/"
else:
verdict = "OTHER"
for ua, ub, sa, sb in pairs:
rows.append(dict(module=path.name, line=node.lineno, func=fn.name,
a=ua, b=ub, spec_a=sa, spec_b=sb,
verdict=verdict, sites=sites))
return sorted(rows, key=lambda r: (r["module"], r["line"], r["a"]))
# ⚠️ THE UNIT OF COVERAGE IS THE CALL SITE, NOT THE FUNCTION - which is the
# whole lesson of SAFETY-38 round 3. `SignWatchdog.evaluate()` had its operand
# order pinned and the defect still shipped, because the pin was on the call two
@@ -437,22 +610,61 @@ def call_sites(name):
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)}`")
if _called_name(node.func) != name:
continue
# ⚠️ THE RECEIVER IS PRINTED, because matching on the bare
# attribute name conflates methods: `evaluate` is the name of
# SAFETY-02's, SAFETY-18's and SAFETY-31's watchdogs alike, and
# a list of four lines with no receiver tells a reader nothing
# about which detector they are looking at. `inv.sign_wd` and
# `self.freeze_wd` are the disambiguation, and they are the
# caller's own words.
out.append(f"{path.name}:{node.lineno} `{ast.unparse(node.func)}`")
return out
def lazy_log_sites():
"""Log calls with a %-format template and >=2 substitution ARGUMENTS.
⚠️ THE SIZE OF THE LARGEST HOLE, MEASURED RATHER THAN ASSERTED. The
NOT-SCANNED list below used to end "and app/ uses that form", which is a
claim about the tree with no number on it - the exact shape this card has
spent four rounds arguing against. `_LOG.error("%s ... %s", a, b)` puts both
operands in ARGUMENTS, so no f-string census here can ever reach them, and
transposing the two costs the same zero bytes. This counts them.
"""
out = []
for path in sorted(APP.glob("*.py")):
tree = ast.parse(path.read_text())
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
if _called_name(node.func) not in _LOG_SINKS or len(node.args) < 3:
continue
head = node.args[0]
tmpl = ""
if isinstance(head, ast.Constant) and isinstance(head.value, str):
tmpl = head.value
elif isinstance(head, ast.BinOp) or isinstance(head, ast.JoinedStr):
tmpl = ast.unparse(head)
if tmpl.count("%") >= 2:
out.append((path.name, node.lineno,
f"{path.name}:{node.lineno} "
f"`{ast.unparse(node.func)}` with "
f"{len(node.args) - 1} args"))
return [r[2] for r in sorted(out)]
def _pair_line(sa, sb):
same = sa == sb
return (f" format spec: '{sa}' / '{sb}' -> "
+ ("SAME - two values rendered alike, so a transposition is "
"SILENT and reads as a fact"
if same else
"DIFFERENT - likely mixed types, so a transposition is more "
"likely to look broken than to lie (gap 4 below)"))
def main():
quiet = "--quiet" in sys.argv
rounds = build_fixpoint()
@@ -515,50 +727,151 @@ def main():
"ORDERED PAIR.")
cb = scan_caller()
print(f"candidates: {len(cb)}")
for mod, line, fn, ua, ub in cb:
for mod, line, fn, ua, ub, sa, sb in cb:
print(f" {mod}:{line} in {fn}()")
print(f" {ua} <-> {ub}")
print(_pair_line(sa, sb))
print()
print("WHAT THIS CANNOT SEE - the shape of each gap, named rather than "
"rounded off:")
print("=" * 78)
print("CENSUS C - SENTENCES AN OBJECT RETURNS ABOUT ITSELF (SAFETY-38 "
"round 4)")
print("A method formatting its OWN ATTRIBUTES and RETURNING the string was "
"in neither")
print("census above: census A only reads a callee's PARAMETERS, census B "
"only scored a")
print("MESSAGE sink. The verdict below is resolved through the CALL SITES "
"of the")
print("returning method - a returned string is only in this family once "
"some caller")
print("hands it to an operator (contrast store.unit_key(), whose return "
"value is a KEY).")
cc = scan_returned()
fam_c = [r for r in cc if r["verdict"] == "MESSAGE"]
hot = [r for r in fam_c if r["spec_a"] == r["spec_b"]]
print(f"returned sentences with a same-shape pair: {len(cc)} pair(s); "
f"{len(fam_c)} reach an operator;")
print(f"of those, {len(hot)} ALSO share a format spec - two values "
f"rendered alike, which is")
print("the hazard gap 4 below names: a transposition there is silent and "
"reads as a fact.")
print()
print("*** = reaches an operator AND shares a format spec - THE ACTIONABLE "
"SUBSET")
print(" ! = reaches an operator, specs differ (a swap is likelier to look "
"broken)")
print(" = does not reach an operator, or could not be resolved")
# grouped by the sentence, so the CONSUMERS list is printed once per
# method rather than once per pair - the ungrouped form was 20 identical
# lines under each of six pairs and nobody reads that.
for key in sorted({(r["module"], r["line"], r["func"]) for r in cc}):
mod, line, fname = key
group = [r for r in cc if (r["module"], r["line"], r["func"]) == key]
v = group[0]["verdict"]
print(f" {mod}:{line} {fname}() verdict: {v}")
for r in group:
mark = ("***" if r["verdict"] == "MESSAGE"
and r["spec_a"] == r["spec_b"]
else " ! " if r["verdict"] == "MESSAGE" else " ")
print(f" {mark} {r['a']} <-> {r['b']}")
print(f" {_pair_line(r['spec_a'], r['spec_b']).strip()}")
if not quiet:
sites = group[0]["sites"]
print(f" CONSUMERS ({len(sites)}) - bare-name match, so read "
f"the receiver:")
for label, sink, _v in sites or [("(none inside app/)", "", "")]:
print(f" {label} -> {sink}")
print()
print("=" * 78)
print("WHAT THIS SCANS, AND WHAT IT DOES NOT - stated because round 4's "
"blocker was")
print("a completeness this file ASSERTED and did not have. The sink "
"classifier is a")
print("syntactic walk up the parent chain plus ONE step of local data "
"flow, and it")
print("recognises exactly these sinks and no others:")
print(f" SCANNED as a MESSAGE : a call to {sorted(_LOG_SINKS)}")
print(" ...or to `append`; a `raise`; an "
"assignment to an")
print(f" attribute or local named {sorted(_MSG_ATTRS)}")
print(" ...or to a local that is then interpolated "
"into one of")
print(" those (the one data-flow step).")
print(" SCANNED as a RETURN : census C - resolved through the returning "
"method's")
print(" call sites, one hop plus two of recursion.")
print(" NOT SCANNED AT ALL, and each of these can hold a member:")
lazy = lazy_log_sites()
print(" * `%s`-style lazy logging - `_LOG.error('%s ... %s', a, b)`. The "
"arguments")
print(" are not an f-string, so no census here looks at them, and a "
"transposition")
print(" of two `%s` arguments is the same zero-byte edit. THIS IS THE "
"LARGEST")
print(f" REMAINING HOLE, and it is MEASURED, not asserted: "
f"{len(lazy)} such call(s)")
print(f" in app/, across "
f"{len({s.split(':')[0] for s in lazy})} module(s). None of them is "
f"scanned by anything")
print(" above. Listed here so the size of the hole is a number:")
for site in lazy or ["(none)"]:
print(f" {site}")
print(" * `.format()`, `%`-formatting, and sentences built by `+` or "
"`.join()` out of")
print(" non-f-string pieces.")
print(" * A sentence that leaves the function inside a DICT, LIST or "
"dataclass and is")
print(" rendered somewhere else - the one data-flow step is local and "
"follows only")
print(" a bare Name.")
print(" * `yield`ed sentences, and sentences stored on an object by any "
"attribute")
print(f" name outside {sorted(_MSG_ATTRS)}.")
print(" * Anything outside `app/`: `sim/`, `tools/`, `web.py`'s "
"templates, the")
print(" translations JSON, the firmware, the witness.")
print()
print("WHAT IT CAN SEE BUT GETS WRONG - the shape of each gap, named "
"rather than")
print("rounded off:")
print(" 1. SEMANTIC symmetry no canonicaliser above spells. The list of "
"commutative")
print(" forms is finite and hand-written; a function symmetric only "
"through a form")
print(" not on it is REJECTED and never appears. This under-reports.")
print(" 2. In CENSUS B, 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; "
print(" 2. In CENSUS B and C, two interpolated values of the same KIND "
"that are not the")
print(" same SHAPE - `a.lo` beside `hi`, or one of them behind a local "
"variable.")
print(" Only one-leaf-apart expressions are paired.")
print(" 3. WHETHER each call site is pinned. The sites are listed above; "
"whether a")
print(" check asserts the ORDERED PAIR at each one is a fact about "
"`test_*.py` and is")
print(" recorded in DECISIONS/SAFETY-38-site-standing-pin.md section "
"7. Call sites")
print(" outside `app/` are not listed either, and 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 "
print(" outside `app/` are not listed either, and every site list here "
"matches on the")
print(" bare METHOD NAME - three watchdogs are all called `evaluate` "
"and at least")
print(" four unrelated classes have an `explain()` - so the receiver is "
"printed")
print(" beside each one and a reader has to pick.")
print(" 4. ⚠️ IT CAN ALSO OVER-REPORT, IN ONE EXACT WAY. The sentence is "
"ERASED before")
print(" 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.")
print(" census A's comparison, so a swap that would make the FORMATTING "
"ITSELF fail")
print(" - a `DAY_NAMES[start]` where `start` is 1320 - is scored as "
"'changes no")
print(" decision'. `_raise_gap`'s (start, weekday) pairs are that. The "
"hazard is a")
print(" SAME-TYPED pair, which is why every census B and C row now "
"prints whether")
print(" the two format specs match.")
print(" 5. CENSUS C's verdict is a bare-name resolution (gap 3) and "
"therefore reads")
print(" MESSAGE too readily. Every verdict is printed with the sites "
"that produced")
print(" it; a MESSAGE verdict is a prompt to read them, not a proof.")
if __name__ == "__main__":