Compare commits
4
Commits
CI-06
..
97b59438dc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97b59438dc | ||
|
|
695da4791e | ||
|
|
2cd094a0fe | ||
|
|
0f0e767f42 |
+142
-46
@@ -31,53 +31,149 @@ 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.**
|
||||
**`site_standing` — the one quantity BOTH site guards are asked about — is now
|
||||
pinned by a fixture whose per-unit shares cannot answer for it (SAFETY-38).**
|
||||
`app/main.py`'s `site_standing = float(sum(standing.values()))` is the first
|
||||
argument of [[SAFETY-30]]'s **latching, site-stopping** sign detector *and* the
|
||||
`commanded_w` handed to [[SAFETY-18]]'s `looks_frozen()`. It was pinned by
|
||||
**nothing**, and three mutations of it disarmed both guards with the whole suite
|
||||
green. **This card changes coverage only: not one byte of `app/` moves — the
|
||||
shipped `sum` is correct and must not be "fixed".**
|
||||
|
||||
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.
|
||||
* **⭐ THE FIXTURE THE SUITE COULD NOT BUILD: per-unit shares EACH BELOW the
|
||||
site gate whose SUM clears it.** Two simulated units rated 220 W and 280 W,
|
||||
both obeying exactly, inverted gain, house importing 2000 W — the site
|
||||
commands **−500 W against a +2500 W meter** and no single share clears the
|
||||
300 W gate. That is the case the site detector exists for, and it is the only
|
||||
arrangement that can separate `sum` from the mean and from the largest share.
|
||||
`test_watchdog.py` part 9a.
|
||||
* **WHY THE EXISTING FIXTURE COULD NOT.** `_sign_site`, the suite's only N≥2
|
||||
site fixture, runs shares of about −2500 W **each** — every one individually
|
||||
over the gate — so sum, mean and largest-share all agree on it. *Its values
|
||||
happen to be equal, and a check comparing two things that happen to be equal
|
||||
cannot see the difference it names.* `MUTATION.md` habit 4, ninth instance,
|
||||
inside the site half of the very card that names it.
|
||||
* **FOUR CANDIDATE REDUCTIONS, FOUR DISTINCT ANSWERS, ONE OVER THE GATE** —
|
||||
`sum` −500, mean −250, `max(key=abs)` −280, `[0]` −220 — and that is asserted
|
||||
as a precondition, not described. The asymmetric ratings are load-bearing:
|
||||
equal shares collapse three of the four onto one number, and the **smaller**
|
||||
unit is first so `[0]` stays distinct from `max(key=abs)`. The **gate is the
|
||||
shipped 300 W**; only the hold is shortened, because the hold is a wall clock.
|
||||
* **⭐ THE SECOND CONSUMER IS COVERED, NOT SCOPED OUT.** The same value is
|
||||
`looks_frozen()`'s `commanded_w` at `main.py:1926`. At the same roster the
|
||||
site rail is 0.95 × 500 = **475 W**, so `sum` = 500 W rails it while 250, 280
|
||||
and 220 do not. Shipped: the peak guard goes blind on SAFETY-18's conjunction
|
||||
and files `floor_w = 0.0`. Under both surviving mutations it is **not blind at
|
||||
all and files no floor**, so the battery may keep importing through a quarter
|
||||
whose billed average is unobservable. `test_watchdog.py` part 9b. The open
|
||||
quarter is deliberately backfilled with the stuck reading so the mutant cannot
|
||||
reach the same action by the *coverage* route and mask the difference.
|
||||
* **AT N=1 NOTHING CHANGES, AND IT IS ASSERTED.** On a real one-inverter
|
||||
controller `standing` has one entry, so `sum`, the mean, `max(key=abs)` and
|
||||
`[0]` are **the same number** — all three mutations are genuinely *equivalent*
|
||||
on a shipped install and no N=1 fixture could ever separate them. Printed and
|
||||
checked, beside the site detector still being absent there. Part 9c.
|
||||
* **⚠️ A CORRECTION, AND IT IS THE MOST USEFUL LINE IN THIS CARD.** An earlier
|
||||
revision of this entry said one of the three — `list(standing.values())[0]` —
|
||||
was **already closed** by [[SAFETY-32]]'s package-wide structural pin, and
|
||||
told future authors not to re-cover it. **That was false.** It had been
|
||||
measured **once**, that run hit `test_peakguard.py`'s [[CI-06]] flake (a
|
||||
measured **~20%** false-red rate), and the red was recorded as a mutation
|
||||
**kill**. Re-measured on `release/1.0` @ `86eaef2` carrying only that edit:
|
||||
the **full 21-file suite is green** and `test_peakguard.py` passes **five for
|
||||
five** in isolation — **the mutation survives.** The reason SAFETY-32 cannot
|
||||
see it is not the shape: part 12j taints names *bound* to roster values and
|
||||
lists *accumulated* in a loop, and `standing` is a dict filled by **subscript
|
||||
assignment**, so it never enters the taint set and `sum(standing.values())` is
|
||||
not a roster site at all. 12j's own enumeration on the mutated tree lists
|
||||
`main.py:1553`, `1555`, `1690` — **not `1599`**. **All three mutations needed
|
||||
this card; all three are now covered by it.** *A single red from
|
||||
`test_peakguard.py` proves nothing — re-run it five times in isolation and
|
||||
name the failing check.*
|
||||
* **⚠️ AND A ZERO-BYTE SURVIVOR ONE LINE BELOW THE ONE THIS CARD PINNED.**
|
||||
Pinning the *value* `site_standing` said nothing about **where it goes**.
|
||||
Swapping the detector's first two arguments —
|
||||
`evaluate(site_standing, self.grid, …)` → `evaluate(self.grid, site_standing, …)`
|
||||
— is **zero bytes** and left the tree fully green, because the check asserted
|
||||
only that `"-500 W"` appeared **somewhere** in the banner. It still does:
|
||||
**in the meter slot.** The swapped banner reads *"commanded +2500 W against a
|
||||
meter reading −500 W"* — a site charging at −500 W into a +2500 W import, told
|
||||
to the operator **exactly backwards**, on the one line somebody woken by a
|
||||
latching site-stopping alarm reads first. The check now asserts the **ordered
|
||||
pair** rather than a substring, and is red on it. **No `app/` change:** the
|
||||
hole was in the test.
|
||||
* **⭐ A SURVIVOR THIS CARD WENT LOOKING FOR AND CLOSED.**
|
||||
`site_standing = float(self.target)` — the arbiter's site *target* instead of
|
||||
the sum of what actually **stands on the wire** — is **−11 bytes** and
|
||||
survived the full suite, because in every fixture that existed the two are the
|
||||
same number. They are not when a device's reported range is narrower than its
|
||||
`max_w`: the `device` claim is filed **per actuator**, so it bounds each
|
||||
unit's share and never the site target. A site asking for −500 W while −250 W
|
||||
stands would then take a **latching site-wide false trip** under its own
|
||||
300 W gate. Closed by part 9a's device-clamped counterfactual.
|
||||
* **⚠️ AND THE SAME ZERO-BYTE DEFECT ONE CALL SITE OVER — THE ONE A SHIPPED
|
||||
INSTALL CAN ACTUALLY SEE.** The ordered pair went onto `main.py:1620`, the
|
||||
**site** detector. That detector is armed only at N≥2, and part 9c asserts it
|
||||
is **absent at a roster of one** — so on every install this add-on has, the
|
||||
**per-unit** banner is the only sign-disagreement sentence that can ever be
|
||||
printed, and its call at **`main.py:1560`** was still unpinned. The identical
|
||||
swap there is **0 bytes** and left the tree **21/21 green**, and nothing
|
||||
behavioural could ever have caught it: `disagrees()` is **symmetric** in those
|
||||
two arguments (`commanded_w * against_w < 0`, `abs()` gate on both), so the
|
||||
mutant takes **identical actions on every input**. What moves is the sentence
|
||||
— measured, **9 emitted banner lines invert in 3 distinct sentences**,
|
||||
including the one persisted to `sign_reason` and read back after a restart:
|
||||
*"commanded +1000 W against its own measured power −1000 W"* becomes
|
||||
*"commanded −1000 W against its own measured power +1000 W"*. Closed by the
|
||||
ordered pair on **both** per-unit banners — the N≥2 `its own measured power`
|
||||
one and the **N=1** `a meter reading` one — with both operands read off the
|
||||
**observed cycle** rather than off the arguments the detector was handed, plus
|
||||
a precondition on each that the two operands really are different and opposite
|
||||
in sign. **Red on four named checks, green in the other twenty files. No
|
||||
`app/` change.**
|
||||
* **⭐ AND THE FAMILY IS NOW ENUMERATED BY MACHINE, because it had been
|
||||
enumerated by hand twice and was short both times.**
|
||||
`tools/symmetric_message_scan.py` finds every call whose argument order is
|
||||
load-bearing for a **message** rather than for a decision: it renames each
|
||||
parameter pair, erases every formatted string, canonicalises the commutative
|
||||
operators and iterates to a **fixpoint** (the symmetry is usually one call
|
||||
deep — `evaluate()` is symmetric only *through* `disagrees()`), then
|
||||
classifies each sentence by where it goes. A second census covers sentences
|
||||
the **caller** builds. It found **one open survivor this card does not close
|
||||
and now discloses** — `rail_unreachable()`'s `dev_min`/`dev_max` at
|
||||
`main.py:3208`, **0 bytes, 21/21 green**, a status-page row — and **twelve**
|
||||
unpinned caller-built sentences including SAFETY-31's `circ_reason`, which is
|
||||
the same shape and is also persisted. The list, the pinned status of every
|
||||
member and the instrument's own blind spots are in
|
||||
`DECISIONS/SAFETY-38-site-standing-pin.md` §7.
|
||||
* **⚠️ AND A LEDGER SENTENCE THIS CARD ITSELF GOT WRONG, CORRECTED WITH THE
|
||||
MEASUREMENT.** An earlier revision said *"before part 9 existed, **three** of
|
||||
these left the whole suite green — M1, M2 and M3 — and the remaining seven
|
||||
were caught by SAFETY-30's own N≥2 checks"*. **Eight** did. Re-run one at a
|
||||
time on clean `release/1.0` @ `bbc903b`, fresh copy each, bytecode purged,
|
||||
byte delta and `main.py` fingerprint printed, **full 21-file suite** every
|
||||
time: M1, M2, M3, M4, M7, M8, M9 and M10 are all **21/21 PASS**; only M5 and
|
||||
M6 go red. It is the same species of error this round exists to fix — **a
|
||||
survivor recorded as a kill** — and the file contradicted itself three times
|
||||
while saying it. **And the two that ARE caught make the card's case:** M5
|
||||
(`sum(abs …)`) and M6 (`-sum`) are the only two that reverse the **sign**.
|
||||
Every other entry changes only the **magnitude**, and the old `_sign_site`
|
||||
fixture's ~−2500 W shares put nine different magnitudes on the **same side of
|
||||
the same 320 W gate**. The pre-existing coverage could see a sign and nothing
|
||||
else.
|
||||
* **13 mutations — 11 of that line and its call site, plus 2 zero-byte edits to
|
||||
the arbiter claim wiring — each on a fresh full-repo copy mounted `:ro`,
|
||||
bytecode purged by `tools/mutation_run.sh`, the edit re-read from disk and the
|
||||
byte delta printed. All 13 red.** Every one was **re-run independently** in
|
||||
this round rather than carried forward from the previous round's report. The
|
||||
claim-wiring pair (`sign_site` and `circulation` moved from site scope to unit
|
||||
scope) closes two gaps a reviewer disclosed — though **`circulation`'s kill
|
||||
belongs entirely to pre-existing checks and is not claimed by this card.**
|
||||
⚠️ One **false red** from `test_peakguard.py` on M10 did not reproduce in four
|
||||
subsequent runs; it is CI-06's known flakiness, no claim here rests on it, and
|
||||
it is recorded rather than quietly dropped — unlike the one that *was*
|
||||
believed, two bullets up. Ledger, and an **enumerated list of what was not
|
||||
mutated**, in `DECISIONS/SAFETY-38-site-standing-pin.md` §4a, §4b and §6a.
|
||||
|
||||
**Inverter B gets its own MQTT device, and inverter A keeps the one it has
|
||||
(TEL-07).** MQTT discovery announced **one** device, and every entity hung off
|
||||
|
||||
@@ -0,0 +1,722 @@
|
||||
# SAFETY-38 — pinning `site_standing`, the one quantity both site guards are asked about
|
||||
|
||||
**Status: DECIDED 2026-09-05.** `app/main.py`'s
|
||||
`site_standing = float(sum(standing.values()))` is **correct and is not
|
||||
changed**. What this card adds is the thing it never had: a **behavioural**
|
||||
fixture whose per-unit shares **cannot** answer for the site, so that a change
|
||||
of *meaning* in that reduction goes red instead of green.
|
||||
|
||||
Filed with SAFETY-38, card `6a9ac95f2b78646e5a76d2e9`. It is a **coverage**
|
||||
card: not one byte of `app/` moves.
|
||||
|
||||
---
|
||||
|
||||
## 0. The line, and its two consumers
|
||||
|
||||
```python
|
||||
site_standing = float(sum(standing.values())) # app/main.py:1598
|
||||
```
|
||||
|
||||
`standing` is `{unit name -> the value THIS controller last put on that unit's
|
||||
setpoint entity}` — `inv.last_write`, i.e. the number **after**
|
||||
`clamp_to_rating()` has applied `max_w` and the device's own
|
||||
`[dev_min, dev_max]`. Two safety inputs are derived from it and nothing else in
|
||||
`cycle()` is:
|
||||
|
||||
| consumer | where | what it does with it |
|
||||
|---|---|---|
|
||||
| **[[SAFETY-30]]**'s site sign detector | `self.site_sign_wd.evaluate(site_standing, self.grid, …)` (main.py:1619) | **latches and stops the whole site** when the total command opposes the meter for `site_sign_disagree_s` |
|
||||
| **[[SAFETY-18]]**'s freeze conjunction | `looks_frozen(unchanged_s, site_standing, max_w, …)` inside `peakguard.decide(frozen=…)` (main.py:1926) | takes the peak guard **blind** and files `floor_w = 0.0` — the battery may not import while the billed quarter is unmeasurable |
|
||||
|
||||
Both are P0 paths. **Neither was pinned to the reduction.**
|
||||
|
||||
## 1. What survived, and what the rescope measured
|
||||
|
||||
The card opened with three demonstrated live mutants, all green across the whole
|
||||
suite. Re-measured in round 2 against `release/1.0` @ `86eaef2`:
|
||||
|
||||
| mutation of `site_standing` | on release/1.0 @ 86eaef2 | closed by |
|
||||
|---|---|---|
|
||||
| `list(standing.values())[0]` — one unit's share | **SURVIVES**, full suite 21/21 green | part 9a + 9b (this card) |
|
||||
| `sum(...) / max(1, len(standing))` — the mean | **SURVIVES**, full suite 21/21 green | part 9a + 9b (this card) |
|
||||
| `max(standing.values(), key=abs)` — the largest share | **SURVIVES**, full suite 21/21 green | part 9a + 9b (this card) |
|
||||
|
||||
### ⚠️ ROUND 1 SAID `[0]` WAS ALREADY CLOSED BY SAFETY-32. THAT WAS FALSE.
|
||||
|
||||
Round 1 of this card recorded `[0]` as **CAUGHT by SAFETY-32's package-wide
|
||||
structural pin**, wrote that claim into four places, and told future authors
|
||||
*"DO NOT RE-COVER THAT ONE HERE"*. It is wrong, and **how** it went wrong is the
|
||||
part worth keeping.
|
||||
|
||||
**How the error was made.** The mutation was measured **once**. That single run
|
||||
happened to hit `test_peakguard.py`'s [[CI-06]] flake — a **measured ~20%**
|
||||
false-red rate — and the red was recorded as a **mutation kill**. Nothing else
|
||||
was checked, because a red is the answer you are hoping for and it did not look
|
||||
like it needed a second run.
|
||||
|
||||
**The correct measurement.** On `release/1.0` @ `86eaef2` carrying **only** the
|
||||
`[0]` edit (+4 bytes, re-read from disk, bytecode purged by
|
||||
`tools/mutation_run.sh`): the **full 21-file suite is green**, and
|
||||
`test_peakguard.py` run **five times in isolation** on that same mutated tree is
|
||||
**PASS PASS PASS PASS PASS**. The mutation **survives**. (The reviewer's own
|
||||
independent re-measurement of the same tree hit the flake once —
|
||||
PASS PASS PASS FAIL PASS — which is the same verdict plus a live demonstration
|
||||
of the mechanism that produced the original error.)
|
||||
|
||||
**Why SAFETY-32 cannot see it, read off 12j's own enumeration rather than
|
||||
argued.** Part 12j taints names *bound* to roster-carrying values and lists
|
||||
*accumulated* in a loop over one. `standing` is neither: it is a dict created
|
||||
empty at `main.py:1554` and filled by **subscript assignment**
|
||||
(`standing[inv.name] = stood`) inside the roster loop, and a subscript store is
|
||||
not a binding 12j follows. So `standing` never enters its taint set and
|
||||
`sum(standing.values())` is **not a roster site** at all. Run 12j against a tree
|
||||
carrying the `[0]` mutation and its enumeration lists `main.py:1553`, `1555` and
|
||||
`1690` — the three `self.inverters` sites bracketing this code — and **not
|
||||
`1599`**. No shape pin reaches this line; only the behavioural fixture below
|
||||
does.
|
||||
|
||||
**The rule this cost.** `test_peakguard.py` flakes at a measured ~20%. A single
|
||||
red from it proves nothing. Re-run it in isolation **at least five times** and
|
||||
**name the failing check** before believing any red from it. This card is the
|
||||
**third** measurement CI-06 has corrupted.
|
||||
|
||||
SAFETY-32's own disclosed blind spot says why the other two were always out of
|
||||
reach:
|
||||
|
||||
> *SEMANTICS. `min` → `max` → the mean are edits invisible to any shape pin,
|
||||
> permanently. Only the behavioural sets can see them.*
|
||||
|
||||
`sum` → mean and `sum` → `max(key=abs)` are **semantic**: identical shape,
|
||||
different meaning. **No shape pin will ever close them.** That disclosure was
|
||||
accurate, and is now confirmed by measurement rather than taken on trust.
|
||||
|
||||
`[0]` is the one that *looks* structural and therefore *looks* covered — and
|
||||
that appearance is exactly what produced the false attribution above. It is
|
||||
out of reach here for a different reason from the other two: not because the
|
||||
shape is invisible, but because **the value never becomes a roster** in the
|
||||
analysis's eyes. All three are closed by the same behavioural fixture, and
|
||||
**all three needed it.**
|
||||
|
||||
## 2. Why the suite could not see it — habit 4, inside the site half of the very card that names it
|
||||
|
||||
`test_watchdog.py`'s `_sign_site` is the **only** N≥2 site fixture in the
|
||||
repository. It runs at `site_sign_disagree_w: 320` with shares of roughly
|
||||
−2500 W **each**. Every one of them is individually far over the gate, so on
|
||||
those numbers
|
||||
|
||||
```
|
||||
sum = −5000 W over mean = −2500 W over max = −2500 W over
|
||||
```
|
||||
|
||||
— **all three reductions agree**, and a check whose two candidate answers happen
|
||||
to be equal cannot observe a change that swaps them. That is `MUTATION.md`'s
|
||||
third failure mode, and this is its ninth instance.
|
||||
|
||||
At a roster of **one** it is not a fixture defect but arithmetic:
|
||||
`sum == mean == max == the one share`, identically, so all three mutations are
|
||||
genuinely **equivalent** on a shipped install. No N=1 fixture can ever separate
|
||||
them; §3c asserts that rather than leaving it to the reader.
|
||||
|
||||
## 3. The fixture, and the numbers it is built on
|
||||
|
||||
Two **simulated** units rated 220 W and 280 W, both obeying exactly, inverted
|
||||
gain, house importing 2000 W. Both stand at their own rails, so `standing` is
|
||||
`{-220, -280}` in roster order and the meter reads +2500 W.
|
||||
|
||||
| reduction | value | vs the SHIPPED 300 W gate |
|
||||
|---|---|---|
|
||||
| `sum` | **−500 W** | **OVER** → trips |
|
||||
| `sum / len` (the mean) | −250 W | under → silent |
|
||||
| `max(…, key=abs)` | −280 W | under → silent |
|
||||
| `list(…)[0]` | −220 W | under → silent |
|
||||
|
||||
**Four distinct magnitudes — 500, 250, 280, 220 — of which exactly one clears
|
||||
the gate.** The asymmetry is load-bearing twice: equal shares would collapse
|
||||
mean, max and first onto one number, and putting the **smaller** unit first is
|
||||
what keeps `[0]` distinct from `max(key=abs)`. The gate is left at its shipped
|
||||
default so the fixture is graded against the magnitude an install runs; only the
|
||||
**hold** is shortened (325 s → 2 s), because the hold is a wall clock and the
|
||||
campaign behind 325 s is measured on the rig, not here.
|
||||
|
||||
⚠️ **220 W and 280 W are SIMULATED ratings chosen for that arithmetic and for
|
||||
nothing else.** They are not measurements. In particular they are **not**
|
||||
inverter B, which does not physically exist; B is a GW5048D-ES, serial
|
||||
`55048ESU22AW0992`, **4600 W from its datasheet** (`goodwe/NOTES.md` §1.1b).
|
||||
|
||||
### 3a. The site sign detector
|
||||
|
||||
Shipped tree: the site latches, commands 0 W on every inverter, and the sentence
|
||||
an operator reads is
|
||||
`SIGN_DISAGREEMENT: commanded -500 W against a meter reading +2500 W …`. No
|
||||
per-unit watchdog trips — every unit is faithfully executing its share, which is
|
||||
the asymmetry the site detector exists for.
|
||||
|
||||
Under the mean and under `max(key=abs)`: **nothing trips, ever.** The reviewer's
|
||||
tree was still commanding −500 W of charge into a +2500 W import at t = 595 s
|
||||
with `following=True`.
|
||||
|
||||
Two counterfactuals sit beside it so the trip is attributable:
|
||||
|
||||
* the **same** inverted-gain site whose shares sum to −250 W — under the gate —
|
||||
does **not** trip, so the trip above is the sum crossing 300 W and not the
|
||||
mere presence of a second inverter;
|
||||
* a site whose **device range is narrower than `max_w`** asks for −500 W while
|
||||
only −250 W stands, and does **not** trip — see §5.
|
||||
|
||||
### 3b. The second consumer, covered rather than scoped out
|
||||
|
||||
`site_standing` is also `looks_frozen()`'s `commanded_w`. The same
|
||||
discrimination applies at a different threshold: the site rail is
|
||||
`RAIL_FRAC * site_max_w` = 0.95 × 500 = **475 W**.
|
||||
|
||||
| reduction | value | vs the 475 W rail |
|
||||
|---|---|---|
|
||||
| `sum` | **500 W** | **RAILED** → `BLIND_FROZEN`, `floor_w = 0.0` |
|
||||
| the mean | 250 W | under → silent |
|
||||
| `max(…, key=abs)` | 280 W | under → silent |
|
||||
| `list(…)[0]` | 220 W | under → silent |
|
||||
|
||||
The sign is flipped from §3a on purpose: the gain is the **shipped positive**
|
||||
one and the frozen meter reads import, so the site *discharges* +500 W against a
|
||||
+2500 W import — the two agree in sign, SAFETY-30 has nothing to say, and this
|
||||
half grades SAFETY-18 alone. Reusing §3a's inverted gain would have the site
|
||||
sign latch park everything at 0 W, and the freeze conjunction could then never
|
||||
be true: a check measuring the *other* guard's remedy.
|
||||
|
||||
⚠️ **The open quarter is backfilled with the same stuck reading, and that is
|
||||
not decoration.** Without it the block is mostly uncovered, `blindness()`
|
||||
answers `BLIND_COVERAGE`, and the mutant reaches *the same action by a different
|
||||
route* — a check on the action alone could not then see the difference it names.
|
||||
Seeded, the measurement is honest:
|
||||
|
||||
* shipped: `blind = BLIND_FROZEN`, `floor_w = 0.0` — no charging while the
|
||||
billed quarter is unobservable;
|
||||
* both mutations: **not blind at all**, `floor_w = None` — the site may go on
|
||||
importing through a quarter whose billed average cannot be measured. That is
|
||||
the capacity-tariff hazard SAFETY-18 exists for, silently disarmed.
|
||||
|
||||
The counterfactual comes from the **same run**: on the earlier cycles the
|
||||
command had not yet reached the rail and the guard did not report SAFETY-18,
|
||||
with the meter equally frozen throughout — so the verdict is produced by the
|
||||
command magnitude `site_standing` carries and not by `unchanged_s` alone. A low
|
||||
gain (0.05, the schema floor) is what buys those below-the-rail cycles.
|
||||
|
||||
### 3c. At N=1, nothing changes — asserted
|
||||
|
||||
A real one-inverter controller is driven through the same frozen-meter
|
||||
scenario. Its `standing` has a single entry, so `sum`, the mean,
|
||||
`max(key=abs)` and `[0]` are printed and asserted to be **one number**; the
|
||||
site sign detector is still absent (part 7 counts the calls on the object); and
|
||||
SAFETY-18 still reaches `BLIND_FROZEN` on that same value. The claim "the
|
||||
shipped install is unaffected" now rests on the roster rather than on the
|
||||
reader.
|
||||
|
||||
## 4. Mutation ledger
|
||||
|
||||
Every run: a **fresh full-repo copy**, `__pycache__` and `*.pyc` excluded,
|
||||
mounted `:ro`, graded by `tools/mutation_run.sh` (which purges, prints the tree
|
||||
fingerprint, and verifies no cache appears during the run). The mutated line was
|
||||
**re-read from disk** after the edit and the byte delta printed. Shipped
|
||||
`app/main.py` is 234 542 bytes at `86eaef2`.
|
||||
|
||||
| # | mutation of `site_standing` | Δ bytes | verdict |
|
||||
|---|---|---|---|
|
||||
| M1 | `sum(...) / max(1, len(standing))` — the mean | +24 | **RED** |
|
||||
| M2 | `max(standing.values(), key=abs)` — largest share | +9 | **RED** |
|
||||
| M3 | `list(standing.values())[0]` — one share | +4 | **RED** — and **survives on release/1.0**, see §1 |
|
||||
| M4 | `min(standing.values(), key=abs)` — smallest share | +9 | **RED** |
|
||||
| M5 | `sum(abs(v) for v in ...)` — magnitude, sign lost | +16 | **RED** |
|
||||
| M6 | `-sum(standing.values())` — sign flipped | +1 | **RED** |
|
||||
| M7 | `float(self.target)` — the arbiter's site target | **−11** | **was a SURVIVOR — now RED**, see §5 |
|
||||
| M8 | `sum(standing.values()) / 2.0` | +6 | **RED** |
|
||||
| M9 | `sum(standing.values()) * 2.0` | +6 | **RED** |
|
||||
| M10 | `sorted(standing.values())[-1]` — largest signed | +7 | **RED** |
|
||||
| **M11** | **argument SWAP** at the SITE call site: `evaluate(site_standing, self.grid, …)` → `evaluate(self.grid, site_standing, …)` (main.py:1620) | **0** | **was a SURVIVOR in round 1 — now RED**, see §4a |
|
||||
| **M12** | **argument SWAP** at the PER-UNIT call site: `evaluate(stood, against, …)` → `evaluate(against, stood, …)` (**main.py:1560**) | **0** | **was a SURVIVOR in round 2 — now RED**, see §4c |
|
||||
|
||||
**Every one of the ten is red in `test_watchdog.py`.** Re-measured in round 2
|
||||
on `release/1.0` @ `86eaef2` (where `app/main.py` is 234 542 bytes, 2 022 more
|
||||
than the `f72c48d` the round-1 figures were taken on): all ten still red, at the
|
||||
byte deltas in the table, each on a fresh copy with the edit re-read from disk.
|
||||
Adding assertions cannot turn a red green, so those verdicts carry forward to
|
||||
the round-3 tree unchanged.
|
||||
|
||||
### ⚠️ ROUND 2 SAID THREE OF THESE SURVIVED. **EIGHT DID.**
|
||||
|
||||
Round 2 wrote: *"Before part 9 existed, three of these left the whole suite
|
||||
green — M1, M2 and M3 — and the remaining seven were caught by SAFETY-30's own
|
||||
N≥2 checks."* Round 1's wording was vaguer and harmless; naming three made it
|
||||
**specific and wrong**, and it is the same species of error the round was
|
||||
written to fix — **a survivor recorded as a kill**. This file contradicted it
|
||||
three times on its own pages: the ledger row calls M7 *"a SURVIVOR — now RED"*,
|
||||
§5 says M7 survived the whole suite, and `CHANGELOG.md` says so too.
|
||||
|
||||
**Re-measured in round 3, one mutation at a time, on clean `release/1.0` @
|
||||
`bbc903b`** — a fresh copy each, bytecode purged (`0` remaining, printed), the
|
||||
line re-read from disk, byte delta **and** `app/main.py` fingerprint printed,
|
||||
mounted `:ro`, and the **FULL 21-FILE SUITE** run each time rather than
|
||||
`test_watchdog.py` alone:
|
||||
|
||||
| # | Δ bytes | fingerprint of `app/main.py` | suite before part 9 |
|
||||
|---|---|---|---|
|
||||
| M1 | +24 | `362374cfb6486378` | **21/21 PASS — SURVIVOR** |
|
||||
| M2 | +9 | `86573e7494f1c933` | **21/21 PASS — SURVIVOR** |
|
||||
| M3 | +4 | `59fa8815e6fda4cf` | **21/21 PASS — SURVIVOR** |
|
||||
| M4 | +9 | `43d852be3037f861` | **21/21 PASS — SURVIVOR** |
|
||||
| M5 | +16 | `58d26de82af29a1a` | **RED** — `test_watchdog.py`, 3 checks |
|
||||
| M6 | +1 | `407ddb5f375cc217` | **RED** — `test_watchdog.py`, 3 checks |
|
||||
| M7 | **−11** | `2a635a4eca0a790d` | **21/21 PASS — SURVIVOR** |
|
||||
| M8 | +6 | `b21eed89ff4c9240` | **21/21 PASS — SURVIVOR** |
|
||||
| M9 | +6 | `0973d3caf680db65` | **21/21 PASS — SURVIVOR** |
|
||||
| M10 | +7 | `0e7b7a4fed7787d0` | **21/21 PASS — SURVIVOR** |
|
||||
|
||||
**So: EIGHT of the ten left the whole suite green, and TWO were caught** — not
|
||||
three and seven, and not the four and six a reviewer proposed either. The
|
||||
correct sentence is: *before part 9 existed, eight of these ten left the whole
|
||||
21-file suite green, and the only two SAFETY-30's own N≥2 checks caught were M5
|
||||
and M6.*
|
||||
|
||||
**⚠️ AND THE TWO THAT WERE CAUGHT ARE CAUGHT FOR A REASON THAT MAKES THE CARD'S
|
||||
CASE RATHER THAN WEAKENING IT.** M5 (`sum(abs(v) …)`) and M6 (`-sum(…)`) are
|
||||
the only two entries in the table that **reverse the SIGN** of `site_standing`.
|
||||
Every other entry changes only its **magnitude** — and `_sign_site`, the suite's
|
||||
only N≥2 site fixture before this card, runs shares of about **−2500 W each**
|
||||
against a **320 W** gate. `sum`, the mean, `max(key=abs)`, `min(key=abs)`,
|
||||
`sorted(...)[-1]`, `[0]`, `self.target`, half the sum and twice the sum all land
|
||||
on **−2500, −2500, −2500, −2500, −2500, −2500, −2500, −1250 and −5000 W**: nine
|
||||
different numbers, all on the **same side of the same threshold**. The three
|
||||
checks that fired on M5 and M6 grade *"the site trips and no unit does"*, the
|
||||
claim it trips through, and where the latch is written — none of them grades the
|
||||
**quantity**, and the sign flip reaches them only because it stops the trip
|
||||
happening at all.
|
||||
|
||||
That is the whole thesis of this card measured rather than argued: **the
|
||||
pre-existing coverage could see a sign, and nothing else.** Part 9's fixture —
|
||||
per-unit shares each **below** the gate whose **sum** clears it — is the only
|
||||
arrangement in which the magnitude is load-bearing, and it is what takes the
|
||||
other eight red.
|
||||
|
||||
⚠️ **ONE FALSE RED, AND IT IS [[CI-06]]'s.** In the batch run M10 also reported
|
||||
`FAIL test_peakguard.py`. Re-run in isolation **three times** and once more
|
||||
through `tools/mutation_run.sh` — **four clean passes, zero failures**. The
|
||||
batch script had discarded the failure detail, so the failing check cannot be
|
||||
named here; what can be said is that the red did not reproduce in four attempts
|
||||
and that **no claim in this card rests on it**. `test_peakguard.py` is the file
|
||||
CI-06 records as flaky, and this is the behaviour it records. Believe no red
|
||||
from it without three isolated re-runs.
|
||||
|
||||
⚠️ **ROUND 3 CAN NAME ONE, AND IT IS THE SAME FILE.** Round 3's own batch — the
|
||||
ten full-suite runs above, plus §7's sibling swaps — produced **no** red from
|
||||
`test_peakguard.py` on M10 or on any other ledger entry. It produced exactly one
|
||||
elsewhere, on a **`static_tariff.py`-only** edit (§7b, A4), and this time the
|
||||
failing check *was* captured: *"one cycle of the intent form is exactly the
|
||||
delivered form plus the command standing on the wire"* — which is CI-06's
|
||||
**mechanism 1 by name**, the cross-time projection. `test_peakguard.py` then
|
||||
passed **four for four** in isolation on that same tree. That is the first time
|
||||
this card has been able to put a name to one of these reds, and it is the name
|
||||
CI-06 predicts.
|
||||
|
||||
⚠️ **M3 IS NOT "FOR COMPLETENESS", AND ROUND 1 SAID IT WAS.** Round 1 recorded
|
||||
M3 as already closed structurally by SAFETY-32 and disclaimed it. §1 above has
|
||||
the correction and the mechanism: `[0]` **survives** a full green 21-file suite
|
||||
on `release/1.0` @ `86eaef2`, because `standing` is a dict filled by subscript
|
||||
assignment and never enters 12j's taint set. **M3 is a real kill by this card's
|
||||
fixture and by nothing else in the repository.** It is claimed here.
|
||||
|
||||
### 4a. ⚠️ The zero-byte survivor one line below the one this card pinned
|
||||
|
||||
Round 1 pinned the **value** `site_standing` and never pinned **where it goes**.
|
||||
Swapping the detector's first two arguments at `main.py:1620` is a **zero-byte**
|
||||
edit — the same characters, reordered — and it left the round-1 tree **21/21
|
||||
green**.
|
||||
|
||||
What it costs is the banner. `SignWatchdog.evaluate()` formats
|
||||
`commanded_w` first and `against_w` second, so under the swap part 9a's own
|
||||
output reads:
|
||||
|
||||
```
|
||||
SIGN_DISAGREEMENT: commanded +2500 W against a meter reading -500 W for 2 s …
|
||||
```
|
||||
|
||||
The site is standing at **−500 W of charge into a +2500 W import** and the
|
||||
**latching, site-stopping** banner tells the operator **the exact opposite** —
|
||||
which instrument read what, inverted. `watchdog.py:658-665` names this failure
|
||||
in its own words: *"a false statement about which instrument produced the
|
||||
evidence, on the one line somebody woken by this alarm reads first."*
|
||||
|
||||
**Why round 1's check could not see it, and this is the general lesson.** It
|
||||
asserted
|
||||
|
||||
```python
|
||||
f"{sum(_share38):.0f} W" in _c38.site_sign_wd.reason
|
||||
```
|
||||
|
||||
— that `"-500 W"` appears **somewhere** in the sentence. Under the swap it still
|
||||
does: in the **meter slot**. A substring test cannot reach a failure that is
|
||||
purely **positional**, and round 1's own comment claimed the opposite ("*would
|
||||
put a different number in the store, the action log and the banner, and this is
|
||||
where that shows*"). It shows the **number**, never its **position**. This is
|
||||
MUTATION.md's *"a check whose data cannot reach the failure it names"*, in the
|
||||
card that exists to complain about exactly that.
|
||||
|
||||
**The fix is one line and touches no `app/` code** — assert the **ordered
|
||||
pair**:
|
||||
|
||||
```python
|
||||
f"commanded {sum(_share38):+.0f} W against a meter reading "
|
||||
f"{_mid38['grid']:+.0f} W" in _c38.site_sign_wd.reason
|
||||
```
|
||||
|
||||
Measured: with that line, M11 is **red in `test_watchdog.py` and green in the
|
||||
other twenty files** — one file, one check.
|
||||
|
||||
### 4b. Claim wiring, re-measured rather than assumed
|
||||
|
||||
Round 1's reviewer disclosed it had **not** mutated the arbiter claim wiring or
|
||||
[[SAFETY-31]]/circulation. Both were mutated in round 2. Both are **zero-byte**
|
||||
edits to `arbiter.CLAIM_SCOPE`:
|
||||
|
||||
| # | mutation | Δ bytes | verdict |
|
||||
|---|---|---|---|
|
||||
| W1 | `"sign_site": SCOPE_SITE` → `SCOPE_UNIT` | **0** | **RED** — `test_watchdog.py` (parts 5 **and 9a**), `test_arbiter.py`, `test_peakguard.py` |
|
||||
| W2 | `"circulation": SCOPE_SITE` → `SCOPE_UNIT` | **0** | **RED** — `test_watchdog.py`, `test_arbiter.py`, `test_peakguard.py` |
|
||||
|
||||
Part 9a's *"…and it stops the WHOLE site, through the `sign_site` claim by name"*
|
||||
is one of the two checks that kills W1, so this card contributes to that
|
||||
coverage rather than merely inheriting it. **W2 is caught entirely by
|
||||
pre-existing checks** — SAFETY-38 adds nothing to circulation, and does not
|
||||
claim to.
|
||||
|
||||
### 4c. ⚠️ The SAME zero-byte defect one call site over — and it is the one the shipped install can see
|
||||
|
||||
Round 2 closed §4a by asserting the ordered pair on the **site** banner at
|
||||
`main.py:1620`. It did not touch `main.py:1560`, the **per-unit** call, and the
|
||||
identical swap there —
|
||||
|
||||
```python
|
||||
if inv.sign_wd.evaluate(stood, against, self.following, now) # shipped
|
||||
if inv.sign_wd.evaluate(against, stood, self.following, now) # M12
|
||||
```
|
||||
|
||||
— left the round-2 tree **21/21 green**. Measured: `app/main.py` 234 542 bytes
|
||||
before and after, **Δ 0 bytes**, fingerprint `61aa413f…` → `e57e6026…`, bytecode
|
||||
purged, tree mounted `:ro`, the line re-read from disk.
|
||||
|
||||
**Nothing behavioural can catch it, and that is structural rather than
|
||||
unlucky.** `watchdog.disagrees()` is *symmetric* in its first two arguments —
|
||||
`commanded_w * against_w < 0`, with an `abs()` gate on **both** — so the swap
|
||||
cannot change a single trip decision on any input. What it changes is the
|
||||
**sentence**. Measured by diffing the suite's own printed banners between the
|
||||
round-3 tree and the same tree carrying only M12: **9 emitted banner lines
|
||||
invert, in 3 distinct sentences.**
|
||||
|
||||
```
|
||||
shipped: SIGN_DISAGREEMENT: commanded -2000 W against a meter reading +2000 W …
|
||||
M12: SIGN_DISAGREEMENT: commanded +2000 W against a meter reading -2000 W …
|
||||
|
||||
shipped: … commanded +1000 W against its own measured power -1000 W …
|
||||
M12: … commanded -1000 W against its own measured power +1000 W …
|
||||
|
||||
shipped: … commanded -2000 W against a meter reading +4000 W … (the N=1 arm)
|
||||
M12: … commanded +4000 W against a meter reading -2000 W …
|
||||
```
|
||||
|
||||
**⚠️ AND IT IS WORSE THAN THE ONE ROUND 2 CLOSED.** Part 9c asserts the SITE
|
||||
detector is **not armed at a roster of one**, and inverter B does not physically
|
||||
exist. So on the shipped install the **per-unit banner is the only
|
||||
sign-disagreement sentence that can ever be printed** — in the action log, in
|
||||
`_LOG.error`, in `status_payload()`'s headline banner, and in `sign_reason`,
|
||||
which is persisted and read back after the restart the latch is designed to
|
||||
survive. Round 2 pinned the N≥2-only call site and left the shipping one open.
|
||||
|
||||
**Closed by the ORDERED PAIR on both per-unit banners**, with both operands read
|
||||
off the **observed cycle** — `inv.last_write` and `inv.batt` off the objects the
|
||||
plant moved, never off the arguments the detector was handed, which would be
|
||||
comparing the swap against itself:
|
||||
|
||||
* `test_watchdog.py` **part 3**, the N≥2 per-unit trip:
|
||||
`f"commanded {stood:+.0f} W against its own measured power {meas:+.0f} W"`,
|
||||
plus the persisted `unit/inverter-b/sign_reason` copy, plus a **precondition**
|
||||
that the two operands are different numbers of opposite sign — without which
|
||||
a transposition would produce the same string and the check would be
|
||||
decoration;
|
||||
* `test_watchdog.py` **part 7**, the **N=1** controller-driven trip:
|
||||
`f"commanded {stood:+.0f} W against a meter reading {grid:+.0f} W"`, plus the
|
||||
namespaced key **and** the flat rollback mirror, plus the same precondition.
|
||||
|
||||
**Measured after the fix: M12 is RED on four named checks in
|
||||
`test_watchdog.py` and GREEN in the other twenty files** — one file, four
|
||||
checks. M11 re-measured on the same tree: still red.
|
||||
|
||||
**⚠️ Neither §6a nor MUTATION.md disclosed it.** §6a's "what was NOT closed"
|
||||
enumerated `circ_wd`, the eight other `CLAIM_SCOPE` entries, `actuator_of()`'s
|
||||
body and the `n_units > 1` guard — not this. MUTATION.md's own closing rule, in
|
||||
the file round 2 edited, is *"a variant is not covered because a near-miss of it
|
||||
is."* §7 is the enumeration that would have caught both instances at once.
|
||||
|
||||
## 5. The survivor this card went looking for, and closed
|
||||
|
||||
M7 is not one of the card's three. It was found by asking the question the
|
||||
recurring defect demands — *what mutation would my new checks NOT see?* — and it
|
||||
is the answer: **−11 bytes**, and it survived every check above **and the whole
|
||||
20-file suite**, because in every fixture built so far the arbiter's site target
|
||||
and the sum of what actually stands on the wire are **the same number**.
|
||||
|
||||
They are not the same number when a setpoint device's own reported range is
|
||||
narrower than its `max_w`. The `device` P_SAFETY claim is filed **per actuator**
|
||||
(`actuator_of("device", inv)`), so it bounds each unit's **share** and never the
|
||||
site target, and `clamp_to_rating()` clips again on the way out. On two units
|
||||
rated 220/280 W whose devices report ±120/±130 W, the site asks for **−500 W**
|
||||
and **−250 W** stands.
|
||||
|
||||
Which one the guards mean is already written down, in both of them: SAFETY-02
|
||||
grades *"the command standing on the wire"*, and `looks_frozen()`'s docstring
|
||||
says `commanded_w` is *"the EMITTED setpoint — what the arbiter resolved and
|
||||
`write_setpoint()` put on the wire — not the loop's raw wish"*. [[SAFETY-20]] is
|
||||
the whole card about the difference. Those sentences are now made to bite: on
|
||||
the site target the detector fires on a site standing at −250 W against a
|
||||
+2250 W meter — **under its own 300 W gate** — which is a latching, site-wide
|
||||
false trip on an install whose inverters simply report a narrower range than the
|
||||
operator typed.
|
||||
|
||||
## 6. Honest gaps
|
||||
|
||||
* **A mutation that changes the SIGN CONVENTION of `standing` itself** — i.e. in
|
||||
`main.py`'s `stood = inv.last_write if inv.last_write is not None else
|
||||
inv.target`, not on the reduction line — is **not** in this ledger. It is a
|
||||
different line and a different card; §3a's obedience precondition would see
|
||||
some of it, but that is not a measured claim and is not made here.
|
||||
* **The freeze half is graded at ONE roster length (2) and one rail.** The
|
||||
`RAIL_FRAC` constant, `rail_ceiling_w()`'s device-bound conjunction and
|
||||
`rail_unreachable()` are `test_freeze.py`'s subject and are untouched here.
|
||||
* **The site hold is 2 s, not the shipped 325 s.** The window's derivation is a
|
||||
rig campaign (`DECISIONS/SAFETY-30-per-unit-sign.md` §3B) and this file does
|
||||
not re-grade it. The **gate** is the shipped 300 W, which is the coordinate
|
||||
this card is about.
|
||||
* **Everything about inverter B here is simulated and labelled so.** No number
|
||||
in this document or in the fixture is a measurement of a device that does not
|
||||
yet exist.
|
||||
|
||||
### 6a. Round 2: what was closed, and what was NOT
|
||||
|
||||
Round 1's reviewer disclosed that it had not mutated SAFETY-31/circulation, the
|
||||
arbiter claim wiring, or ledger entries M4/M5/M6/M8/M9/M10. Enumerated, with the
|
||||
gaps left open stated rather than rounded off:
|
||||
|
||||
**Closed in round 2 (measured, on `86eaef2`, bytecode purged, edit re-read from
|
||||
disk, byte delta printed):**
|
||||
|
||||
1. **M11**, the zero-byte argument swap — §4a. Red on one named check.
|
||||
2. **M3**, the `[0]` reduction — re-measured, **survives on release/1.0**, closed
|
||||
by part 9a and 9b. The false attribution corrected in all four places it was
|
||||
committed: this file §1 and §4, `CHANGELOG.md`, and `test_watchdog.py`'s part
|
||||
9 header (which previously *instructed future authors not to re-cover it*).
|
||||
3. **M1, M2, M4, M5, M6, M7, M8, M9, M10** — every ledger entry re-run
|
||||
independently in round 2 rather than carried forward from round 1's report.
|
||||
All ten red; M1/M2/M4 re-run in the exact wrapped form the table records so
|
||||
the byte deltas are the table's own (+24/+9/+9).
|
||||
4. **W1**, `sign_site` claim scope — §4b. Zero bytes, red, part 9a among the
|
||||
killers.
|
||||
5. **W2**, `circulation` claim scope — §4b. Zero bytes, red, but **entirely on
|
||||
pre-existing checks**.
|
||||
|
||||
**NOT closed, and not claimed:**
|
||||
|
||||
* **SAFETY-31's `circ_wd` detector itself** — its threshold, its hold, its
|
||||
`circulating_w()` reduction over the roster. Only its **claim scope** was
|
||||
mutated (W2), and that kill belongs to checks this card did not write.
|
||||
Circulation's own behavioural coverage is `test_watchdog.py` part 4's and is
|
||||
untouched here.
|
||||
* **The other eight `CLAIM_SCOPE` entries** — `loop`, `peak`, `arbitrage`,
|
||||
`safety` (site) and `device`, `supervised`, `failsafe`, `maintenance` (unit).
|
||||
`CLAIM_SCOPE` has ten entries; only the two named above were mutated. `test_peakguard.py` part 10f grades the
|
||||
classification statically; that is a different instrument and this card did
|
||||
not re-measure it.
|
||||
* **`actuator_of()`'s own body** and the `n_units > 1` guard on the `sign_site`
|
||||
append at `main.py:1764` were **not** mutated.
|
||||
* **The ten ledger mutations were re-graded against `test_watchdog.py` only.**
|
||||
The "green in the other twenty files" half of the round-1 claim was
|
||||
re-verified on `86eaef2` for **M3** and **M11** (both full 21-file runs) and is
|
||||
otherwise carried forward from round 1's measurement on `f72c48d`. It is a
|
||||
weaker statement than round 1 made and is written down as such.
|
||||
⚠️ **Round 3 closed the other half of this**, but only in the *before*
|
||||
direction: all ten were re-run on clean `release/1.0` @ `bbc903b` under the
|
||||
**full 21-file suite** to establish which of them the pre-existing coverage
|
||||
could see — see §4's ⚠️ paragraph, and the answer was **eight survivors, not
|
||||
three**. The *after* direction — "each of the ten is red in
|
||||
`test_watchdog.py` and green in the other twenty **on the round-3 tree**" —
|
||||
is still measured end-to-end only for **M3, M11 and M12**. For the other
|
||||
seven, "red" carries forward from round 2 (which adding assertions cannot
|
||||
undo) and "green elsewhere" carries forward from round 1.
|
||||
* **`test_peakguard.py`'s CI-06 flake is not fixed by this card** and is not
|
||||
this card's subject. It is *characterised* here (five isolated runs on the M3
|
||||
tree, all PASS) only far enough to overturn the false kill it produced.
|
||||
|
||||
### 6b. Round 3: what was closed, and what is still open
|
||||
|
||||
**Closed in round 3:**
|
||||
|
||||
1. **M12**, the zero-byte argument swap at the **per-unit** call site
|
||||
`main.py:1560` — §4c. It was a survivor of the round-2 tree at **21/21
|
||||
green**, and it is the **only** sign-disagreement banner a shipped
|
||||
one-inverter install can print. Red on four named checks, green in the other
|
||||
twenty files.
|
||||
2. **The ledger arithmetic in §4**, which round 2 made specific and wrong. See
|
||||
§4's own ⚠️ paragraph: re-measured mutation by mutation on clean
|
||||
`release/1.0` @ `bbc903b`, full 21-file suite each time.
|
||||
3. **The family itself is now enumerated by machine** —
|
||||
`tools/symmetric_message_scan.py`, §7 — rather than by hand for a third time.
|
||||
|
||||
**Open, named, and NOT claimed:**
|
||||
|
||||
* **A3 and B1** — the only two status-page rows in which the freeze detector
|
||||
ever names a device range: the **NOT COVERED** row and the **armed** row's
|
||||
"below max_w because …" clause. `rail_unreachable()`'s `dev_min`/`dev_max` at `main.py:3208` and the
|
||||
literal `f"only {inv.dev_min}...{inv.dev_max} W"` at `main.py:3231`. Both
|
||||
measured: **0 bytes each, 21/21 green each.** Real, open members of the
|
||||
family. They are **status-page rows rather than latching alarms**, and closing
|
||||
them wants a fixture with a device whose two bounds are **asymmetric** — every
|
||||
fixture in this repository uses `±d`, so the transposition is currently
|
||||
invisible by construction — which belongs to `test_freeze.py`'s card, not to
|
||||
this one.
|
||||
* **Twelve census-B sites** (§7c, B2–B12) determined unpinned at the **source
|
||||
level** — the distinguishing literal appears in no `test_*.py` — and **not**
|
||||
mutated. Weaker evidence than A1–A3, and written down as such.
|
||||
* **B6**, SAFETY-31's `circ_reason`, is the same shape as the defect this card
|
||||
closed for SAFETY-02/30 and is *also* persisted and re-read after a restart.
|
||||
§6a already declines to touch `circ_wd`; §7d says what that costs.
|
||||
* Everything §6a lists stays open: `circ_wd`'s own thresholds, the eight other
|
||||
`CLAIM_SCOPE` entries, `actuator_of()`'s body, and the `n_units > 1` guard.
|
||||
|
||||
## 7. The symmetric-predicate family — every call whose argument order is load-bearing for a MESSAGE
|
||||
|
||||
**Why this section exists, and it is the most useful thing in the card.** Round 2
|
||||
pinned the operand order of `SignWatchdog.evaluate()` at `main.py:1620`. Round 3
|
||||
found the **identical zero-byte defect at `main.py:1560`**, one call site over —
|
||||
and that one is worse, because `test_watchdog.py` part 9c asserts the site
|
||||
detector is **never armed at
|
||||
a roster of one**, so on the shipped install the per-unit banner is the **only**
|
||||
sign-disagreement sentence that can ever be printed. Twice in one card the
|
||||
enumeration was written by hand and twice it was short. MUTATION.md habit 12: *a
|
||||
hand-written scope is the same defect as a hand-written fixture.* So the family
|
||||
is now **computed**, by `tools/symmetric_message_scan.py`.
|
||||
|
||||
### 7a. What makes this family invisible, and why it is not a fixture problem
|
||||
|
||||
Every other defect in this document is a fixture problem: *no fixture happens to
|
||||
separate the mutant from the shipped code.* Build the right fixture and it goes
|
||||
red. This one is not that.
|
||||
|
||||
Where a callee is **symmetric** in two arguments, a swap at a call site cannot
|
||||
change **any** action on **any** input. `watchdog.disagrees()` is
|
||||
`commanded_w * against_w < 0` with an `abs()` gate on **both** sides: the
|
||||
product commutes and the two gates are the same gate. There is nothing for a
|
||||
behavioural check to observe, ever — **not "no fixture separates them" but no
|
||||
fixture *can*.** The only surface that moves is the **sentence**, and the only
|
||||
check that can see it is one asserting the **ordered pair** (MUTATION.md habit
|
||||
14) at **that** call site.
|
||||
|
||||
Two consequences the earlier rounds got wrong:
|
||||
|
||||
* **The unit of coverage is the CALL SITE, not the function.** `evaluate()`'s
|
||||
operand order was pinned and the defect still shipped, because the pin was on
|
||||
the *other* call.
|
||||
* **A behavioural suite is not weak evidence here, it is NO evidence.** "21/21
|
||||
green" is what this mutation is *guaranteed* to produce. Measured, twice: M11
|
||||
at `main.py:1620` on the round-1 tree, and M12 at `main.py:1560` on the
|
||||
round-2 tree.
|
||||
|
||||
### 7b. Census A — the callee formats its own parameters
|
||||
|
||||
`tools/symmetric_message_scan.py` renames each parameter pair, erases every
|
||||
formatted string, canonicalises the commutative operators (`*`, `+`, `and`,
|
||||
`or`, `==`, `min`/`max`, iterated tuple literals) and compares the **bodies**;
|
||||
it iterates to a **fixpoint**, because the symmetry is usually one call deep —
|
||||
`evaluate()` is symmetric only *through* `disagrees()`, and a single pass
|
||||
reports an empty family on a tree whose answer is already known. It then
|
||||
classifies each sentence by **where it goes**.
|
||||
|
||||
Fixpoint on this tree: **3 rounds, 17 interchangeable positional and 5 keyword
|
||||
pairs across 14 functions**. 45 parameter pairs reach a sentence; **8** are
|
||||
symmetric — **5** whose sentence goes only to an operator, and **3** whose
|
||||
sentence is the **return value** and therefore has to be classified by hand,
|
||||
because a returned string is sometimes a KEY and sometimes a SENTENCE.
|
||||
|
||||
| # | scan row | pair | called at | swap costs | reaches an operator via | pinned? |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **A1** | `watchdog.py:613` `SignWatchdog.evaluate()` | `(commanded_w, against_w)` | **`main.py:1560`** — per-unit, **and the N=1 detector** | **0 B** | `sign_reason` (persisted), the action log, `_LOG.error`, `status_payload()`'s headline banner | **YES — round 3.** `test_watchdog.py` part 3 (`its own measured power`) and part 7 (`a meter reading`), on the contiguous literal with both operands read off the observed cycle. **Measured: 0 B, red on 4 named checks, green in the other 20 files.** |
|
||||
| **A2** | same row, second call site | `(commanded_w, against_w)` | **`main.py:1620`** — site | **0 B** | `site_sign_reason`, the action log, the status page | **YES — round 2**, part 9a. Re-measured on the round-3 tree: still red. |
|
||||
| **A3** | `freeze.py:405` `FreezeWatchdog.evaluate()` and `freeze.py:311` `rail_unreachable()` — both symmetric through `rail_ceiling_w()`'s `for bound in (dev_min, dev_max)` | `(dev_min, dev_max)` | **`main.py:3208`** (and 1928, 2755, 3196, 3219) | **0 B** | the status page's `freeze detector: NOT COVERED — the setpoint device reports a range of …` row | **NO.** Measured: swapped at `main.py:3208-3209`, **0 bytes**, `main.py` fingerprint `61aa413f…` → `2305b560…`, **21/21 green**. Disclosed in §7d, not closed. ⚠️ `rail_unreachable()` is the row the scan puts in its **RETURN** bucket: its returned string is a **sentence**, not a key, so it is a family member after all — which is exactly why that bucket says *read by hand*. |
|
||||
| **A4** | `static_tariff.py:522` `_raise_gap()` | `(start, end)` | `static_tariff.py:509`, `:513` | **0 B** | the `ProfileInvalid` refusal an operator reads at startup | **YES — pre-existing.** Measured: both call sites swapped, **0 bytes**, `static_tariff.py` fingerprint `caef7fde…` → `50e9a768…`, **red in `test_static_tariff.py` on two named checks** (*"…and names the uncovered range so the operator can fix it"*, *"…and the refusal names the uncovered range"*), because `_hhmm(start)}-{_hhmm(end)` is asserted as the contiguous literal `"22:00-23:00"`. ⚠️ That batch run also reported `FAIL test_peakguard.py` on a `static_tariff.py`-only edit; the failing check is *"one cycle of the intent form is exactly the delivered form plus the command standing on the wire"*, which is [[CI-06]]'s **mechanism 1 by name**, and `test_peakguard.py` passes **four for four** in isolation on that same tree. No claim here rests on it. |
|
||||
| A5–A6 | `static_tariff.py:522` `_raise_gap()` | `(start, weekday)`, `(end, weekday)` | — | — | — | **not real — the scan's one over-report.** It erases the sentence, so it cannot see that `DAY_NAMES[start]` with `start = 1320` raises `IndexError`. A mixed-type pair fails loudly, which is the opposite of this family. |
|
||||
| A7–A8 | `store.py:177` `unit_key()`, `mqtt.py:222` `unit_state_key()` | `(unit, key)`, `(slug, key)` | many | 0 B | — | **not this family.** The formatted string is the **return value and it is a KEY**: a swap renames a store key and an MQTT topic, which is behaviour every ordinary check can see. MAINT-05 is the card about it. |
|
||||
|
||||
### 7c. Census B — the caller builds the sentence
|
||||
|
||||
Census A only sees a callee formatting its **own** parameters, which was gap 2
|
||||
of its own honest-gap list. The other half never crosses a function boundary:
|
||||
an operator sentence built where it is printed, interpolating two values that
|
||||
are **interchangeable by shape**. No predicate is involved at all, so census A's
|
||||
symmetry test has nothing to test. The scan pairs interpolated expressions that
|
||||
are **one leaf apart** — `inv.dev_min` / `inv.dev_max`, `cfg.min_w` /
|
||||
`cfg.hold_s` — which is exactly what a transposition swaps, and it keeps only
|
||||
sentences whose sink is a **message**: an f-string that becomes a URL or a store
|
||||
key is data, and a swap there is an ordinary behavioural change. **16
|
||||
candidates**, every one of them a **zero-byte** edit:
|
||||
|
||||
| # | site | pair | reaches an operator as | pinned? |
|
||||
|---|---|---|---|---|
|
||||
| **B1** | `main.py:3231` | `inv.dev_min` / `inv.dev_max` | status page, `… accepts only −120…130 W` | **NO — MEASURED.** Transposed in place, **0 bytes**, `main.py` fingerprint `61aa413f…` → `ac36a97b…`, purged, `:ro`, **21/21 green**. The row then tells the operator the device accepts `130…−120 W`. |
|
||||
| **B2** | `main.py:3362` | `inv.dev_min` / `inv.dev_max` | status page, `setpoint … (range −120…130 W)` | **NO** — the literal `setpoint … (range` is in no `test_*.py`. |
|
||||
| **B3** | `main.py:3435` | `sign_wd.cfg.hold_s` / `.cfg.min_w` | status page, `sign watchdog: armed (trips after 180 s opposed, both over 300 W)` | **NO** — `s opposed` is in no `test_*.py`. |
|
||||
| **B4** | `main.py:3460` | `site_sign_wd.cfg.hold_s` / `.min_w` | status page, the site sign row | **NO** — `armed against the sum` is in no `test_*.py`. |
|
||||
| **B5** | `main.py:3491` | `circ_wd.cfg.hold_s` / `.min_w` | status page, the circulation row | **NO** — `circulation watchdog: armed` is in no `test_*.py`. |
|
||||
| **B6** | `circulation.py:317` | `cfg.min_w` / `cfg.hold_s` | **`circ_reason` — persisted, and re-read after a restart** | **NO** — `test_watchdog.py` asserts only `"CIRCULATING_POWER" in reason`. **This is SAFETY-31's twin of the exact defect this card just closed for SAFETY-02/30**, on a string with the same persistence. |
|
||||
| **B7** | `main.py:2454` | `self.site_max_w` / `self.target` | the clamp action-log line | **NO** — `was clamped to` is in no `test_*.py`. |
|
||||
| **B8** | `main.py:3386` | `clamp_wd.engagements` / `.episodes` | status page, `engaged N time(s) in M episode(s)` | **NO** — `time(s) in` is in no `test_*.py`. |
|
||||
| **B9–B11** | `main.py:3169` (three pairs) | `p1.net_w` / `.samples` / `.parse_errors` | status page, the P1 row | **NO** — `P1 meter (` is in no `test_*.py`. |
|
||||
| **B12** | `arbiter.py:264` | `c.source` / `c.actuator` | `scope_errors`, the arbiter's mis-routing refusal | **NO, and this one is instructive.** `test_arbiter.py:452` says it *"names the source and the actuator it does not belong on"* and then asserts `"peak" in r_mis.scope_errors[0]`. Under the swap the sentence becomes `"inverter-b: a SITE claim filed on unit 'peak'"` — and `"peak" in …` is **still true**. A claim wider than its assertion, which is habit 14's own shape. |
|
||||
| **B13** | `validate.py:723` | `meter_phases` / `meter_max_age_s` | the startup refusal | **NO** for the ordered pair. |
|
||||
| **B14** | `validate.py:1211` | `arbitrage_soc_floor` / `arbitrage_soc_ceiling` | the startup refusal | **NO** for the ordered pair. |
|
||||
| **B15** | `watchdog.py:667` | `cfg.min_w` / `cfg.hold_s` | the `SIGN_DISAGREEMENT` sentence's `(limits: both over 300 W, held over 180 s)` clause | **YES**, and it is worth naming because it shows the habit works. `test_watchdog.py`'s SAFETY-30 section asserts the site instance's **whole sentence with `==` against a literal**, the `(limits: …)` clause included. Full-string equality is habit 14 taken to its limit. |
|
||||
| B16 | `main.py:237` | `i.name` / `i.max_w` | the roster log line | **not real** — a `str` beside a `:.0f` format. The same over-report as A5–A6: a mixed-type transposition raises rather than lying. |
|
||||
|
||||
### 7d. What this enumeration does NOT close, with sizes
|
||||
|
||||
* **A3 is a measured, open survivor.** `dev_min`/`dev_max` transposed in
|
||||
`rail_unreachable()`'s NOT COVERED row: **0 bytes, 21/21 green.** It is
|
||||
disclosed here rather than fixed, because closing it wants a `test_freeze.py`
|
||||
or status-page fixture with a device whose two bounds are **asymmetric** —
|
||||
every fixture in the repository uses `±d` — and that is a different file's
|
||||
card. It is a **status-page row**, not a latching alarm.
|
||||
* **B1 is a second measured, open survivor.** `inv.dev_min` and `inv.dev_max`
|
||||
transposed in `main.py:3231`'s status row: **0 bytes**, fingerprint
|
||||
`61aa413f…` → `ac36a97b…`, **21/21 green**. The page then tells the operator
|
||||
the device accepts `130…−120 W`. Same disposition as A3: **A3 is the row the
|
||||
freeze detector prints when the rail is UNREACHABLE, B1 the clause the ARMED
|
||||
row prints when the device is what set the rail** — the two rows in which that
|
||||
detector ever names a device range, and neither one's operand order is
|
||||
asserted anywhere.
|
||||
* **Thirteen further census-B sites are unpinned and UNMEASURED.** B2–B14 above
|
||||
are determined at the **source level** — the distinguishing literal appears in
|
||||
no `test_*.py`, or the check that exists asserts a substring wider than its
|
||||
own claim — and were **not** mutated. That is a weaker statement than A1–A4
|
||||
and B1, and is written down as one.
|
||||
* **B12 is a check whose CLAIM is wider than its ASSERTION**, which is habit
|
||||
14's own shape one module over. `test_arbiter.py:452` says
|
||||
`scope_violations` *"names the source and the actuator it does not belong
|
||||
on"* and then asserts `"peak" in r_mis.scope_errors[0]`. Swap `c.source` and
|
||||
`c.actuator` in `arbiter.py:264` and the sentence becomes
|
||||
*"inverter-b: a SITE claim filed on unit 'peak'"* — and the substring is still
|
||||
found. Source-level, not mutated, and not fixed here.
|
||||
* **B6 is the closest thing to a repeat.** SAFETY-31's `circ_reason` is
|
||||
persisted and re-read after a restart, exactly like `sign_reason`, and its
|
||||
two limits are graded by a bare `"CIRCULATING_POWER" in reason`. This card
|
||||
does not fix it; §6a already declines to touch `circ_wd`, and this is the
|
||||
precise shape of what that declining costs.
|
||||
* **The instrument's own blind spots** are printed by the instrument, every
|
||||
run: semantic symmetry it cannot spell (it under-reports), census B pairs
|
||||
that are the same *kind* but not the same *shape*, `%s`-style logging and
|
||||
non-f-string payloads, anything outside `app/`, and the one way it
|
||||
**over-reports** — it erases the sentence, so a swap that would make the
|
||||
formatting itself raise is scored as changing no decision (A5–A6 above).
|
||||
@@ -135,12 +135,15 @@ manufactures them from a **fixture**: the check runs, the assertion is
|
||||
evaluated, the sentence above it is true — and the numbers it compares are
|
||||
arranged so that the failure it describes could not have changed the answer.
|
||||
|
||||
It is not hypothetical and it is not rare. EIGHT instances in two weeks; four
|
||||
It is not hypothetical and it is not rare. TEN instances in two weeks; four
|
||||
were found by review, two - CFG-04's fixture pair - by an author's own mutation
|
||||
battery, which is the cheaper place, one by review again, in the one
|
||||
place nobody had thought to point the rule at: not a check in the suite, but a
|
||||
COMMITTED EVIDENCE ARTIFACT four documents cite - and the eighth INSIDE THE FIX
|
||||
WRITTEN TO CLOSE THE SEVENTH.
|
||||
COMMITTED EVIDENCE ARTIFACT four documents cite - the eighth INSIDE THE FIX
|
||||
WRITTEN TO CLOSE THE SEVENTH, the ninth inside the SITE HALF of the card
|
||||
that names the rule in its own text - and the TENTH INSIDE THE FIX WRITTEN TO
|
||||
CLOSE THE NINTH, for the second time in this list. See SAFETY-38 below: the
|
||||
check that closed it asserted a NUMBER and not the number's POSITION.
|
||||
|
||||
⚠️ **AND IT HAS NOW APPEARED IN SIX CONSECUTIVE CARDS.** The seventh instance,
|
||||
MAINT-05's, is the one that says why: the defect is not carelessness about
|
||||
@@ -234,6 +237,65 @@ same place twice, the check is decoration.
|
||||
assertion cannot fail is exactly the thing this section is about. Two habits
|
||||
follow, and both are habit 5 and 6 below.
|
||||
|
||||
* **SAFETY-38** — `main.py`'s
|
||||
`site_standing = float(sum(standing.values()))`, the first argument of
|
||||
SAFETY-30's latching site sign detector *and* SAFETY-18's `looks_frozen()`
|
||||
input. `test_watchdog.py`'s `_sign_site` is the suite's **only** N≥2 site
|
||||
fixture, and it runs shares of about **−2500 W each** — every one
|
||||
individually over the 320 W gate it is configured with. On numbers like those
|
||||
`sum`, the mean and `max(key=abs)` **all agree**, so the reduction could be
|
||||
swapped for either of the other two and the site guard went **blind with the
|
||||
whole suite green** — still commanding −500 W of charge into a +2500 W import at
|
||||
t = 595 s with `following=True`. At a roster of **one** it is worse and it is
|
||||
not a fixture defect at all: `sum == mean == max == the one share`
|
||||
*identically*, so every such mutation is genuinely **equivalent** there and no
|
||||
N=1 fixture could ever separate them. The fixture that separates them is the
|
||||
one the detector exists for — **per-unit shares EACH BELOW the gate whose SUM
|
||||
clears it** — and it is now `test_watchdog.py` part 9, at asymmetric ratings
|
||||
so that all four candidate reductions give four **distinct** answers.
|
||||
|
||||
⚠️ **AND THE CARD'S OWN SEARCH FOR ITS NEXT SURVIVOR FOUND ONE, WHICH IS THE
|
||||
POINT OF ASKING.** Having built the fixture, the question *what mutation would
|
||||
this NOT see?* produced `float(self.target)` — the arbiter's site target
|
||||
instead of the sum of what stands on the wire — at **−11 B**, green across the
|
||||
whole suite, because every fixture in the repository was built where the two
|
||||
are the same number. They separate only when a device's reported range is
|
||||
narrower than its `max_w`, since the `device` claim is filed **per actuator**
|
||||
and never bounds the site target. Closed in the same commit, by a
|
||||
counterfactual that asks for −500 W while −250 W stands.
|
||||
|
||||
⚠️ **AND THEN THE FIX ITSELF WAS THE TENTH INSTANCE — A SUBSTRING WHERE THE
|
||||
FAILURE WAS POSITIONAL.** The check written to pin the banner asserted
|
||||
`f"{sum(...):.0f} W" in reason`: that **"−500 W" appears somewhere** in the
|
||||
sentence. Swapping the detector's first two arguments
|
||||
(`evaluate(site_standing, self.grid, …)` → `evaluate(self.grid, site_standing, …)`)
|
||||
is a **ZERO-BYTE** edit that leaves both numbers in the string and moves each
|
||||
into the other's slot — so the substring is still found, **in the meter
|
||||
slot**, and the whole suite stayed green while the latching site-stopping
|
||||
banner told the operator which instrument read what **exactly backwards**.
|
||||
The fix is to assert the **ORDERED PAIR**,
|
||||
`f"commanded {sum:+.0f} W against a meter reading {grid:+.0f} W"`.
|
||||
|
||||
⚠️ **AND THE ELEVENTH WAS THE SAME DEFECT ONE CALL SITE OVER — THE ONE A
|
||||
SHIPPED INSTALL CAN ACTUALLY SEE.** Round 2 pinned the operand order of
|
||||
`SignWatchdog.evaluate()` at `main.py:1620`. That call site is the **site**
|
||||
detector, and `test_watchdog.py` part 9c asserts it is **never armed at a
|
||||
roster of one** — which is every install this add-on has. The **per-unit**
|
||||
call at `main.py:1560` was left unpinned, and swapping its first two
|
||||
arguments is the same **ZERO-BYTE** edit: `disagrees()` is symmetric in them
|
||||
(`commanded_w * against_w < 0` with an `abs()` gate on both), so **no trip
|
||||
decision can change** and the full 21-file suite stays green — measured. What
|
||||
moves is five banner lines, including the one persisted to `sign_reason` and
|
||||
re-read after a restart: *"commanded −1000 W against its own measured power
|
||||
+1000 W"*, operands inverted, on the only sign-disagreement sentence a
|
||||
one-inverter install can ever print. Closed in round 3 by the ordered pair on
|
||||
**both** per-unit banners — the N≥2 `its own measured power` one and the N=1
|
||||
`a meter reading` one — red on four named checks, green in the other twenty
|
||||
files. **Habit 15 is what falls out of finding the same shape twice in the
|
||||
same card.**
|
||||
|
||||
The general habits are **14** and **15** below.
|
||||
|
||||
Note what these have in common with a masked rung: the mutation *runs*, and the
|
||||
suite is *honestly* green. Nothing in a purge, a fingerprint or a disassembly
|
||||
can see it, because the code under test really did behave identically on the
|
||||
@@ -363,6 +425,76 @@ Cheap habits fall out of it, and all of them are now used in this repository:
|
||||
completeness a reviewer falsifies in ten minutes**, and every round of this
|
||||
card that claimed the latter was rejected for it.
|
||||
|
||||
14. **An `in` test against a formatted sentence grades PRESENCE, never
|
||||
ARRANGEMENT — so assert the ORDERED PAIR.** SAFETY-38 pinned the banner of a
|
||||
latching, site-stopping alarm with
|
||||
`f"{sum(_share38):.0f} W" in ...reason` — that `"-500 W"` appears
|
||||
*somewhere*. Swapping the detector's first two arguments at the call site
|
||||
(`evaluate(site_standing, self.grid, …)` →
|
||||
`evaluate(self.grid, site_standing, …)`) is a **ZERO-BYTE** edit: both
|
||||
numbers are still in the string, each in the other's slot. The substring was
|
||||
still found — **in the meter slot** — the whole suite stayed green, and the
|
||||
banner told the operator which instrument read what **exactly backwards**.
|
||||
Whenever the property you mean is "**this** value went in **this** slot" — a
|
||||
reason string, a log line, a notification, an MQTT payload — assert the
|
||||
surrounding **literal text with the value embedded in its own position**
|
||||
(`f"commanded {x:+.0f} W against a meter reading {y:+.0f} W"`), or assert
|
||||
the fields **before** they are formatted. Two values that both appear in one
|
||||
sentence can always be transposed by an edit that costs **nothing**, and
|
||||
that no byte delta, no fingerprint and no shape pin can see. ⚠️ Note where
|
||||
this one was found: **inside the fix written to close the previous instance
|
||||
of the same family**, which is the second time in this list.
|
||||
|
||||
⚠️ **AND THE FIX FOR IT WAS ONE CALL SITE WIDE, WHICH IS THE HALF WORTH
|
||||
KEEPING.** The ordered pair went onto `main.py:1620`, the **site** detector
|
||||
— armed only at N≥2, and part 9c asserts it is **absent at a roster of
|
||||
one**. The identical swap at `main.py:1560`, the **per-unit** detector, was
|
||||
still **ZERO BYTES**, still changed no decision, still left the 21-file
|
||||
suite green — and it is the **only** sign-disagreement sentence a shipped
|
||||
one-inverter install can ever print: *"commanded −1000 W against its own
|
||||
measured power +1000 W"*, backwards, in the log, on the status page and in
|
||||
`sign_reason` after the restart the latch is designed to survive. Measured
|
||||
size: **main.py:1560, 0 bytes, 21/21 green.** So habit 14 is not "pin the
|
||||
banner", it is **"pin every call site of the banner, and say which one the
|
||||
shipped roster reaches"** — and habit 15 is how you find them all.
|
||||
|
||||
15. **A SYMMETRIC PREDICATE MAKES ARGUMENT ORDER A PROPERTY OF THE MESSAGE
|
||||
ALONE — so ENUMERATE THAT FAMILY BY MACHINE, and grade the CALL SITES, not
|
||||
the function.** Where a callee is symmetric in two arguments, a swap at any
|
||||
call site is invisible to every behavioural check **by construction**: not
|
||||
"no fixture happens to separate them" but *no fixture can*. There is
|
||||
nothing for a mutation battery to find, because the mutant and the shipped
|
||||
tree take identical actions on every input. The only surface that moves is
|
||||
the sentence, and the only check that can see it is one asserting the
|
||||
**ordered pair** (habit 14) at **that** call site.
|
||||
Three consequences, and all three are why this is its own habit:
|
||||
* **The unit of coverage is the CALL SITE.** SAFETY-38 pinned
|
||||
`evaluate()`'s operand order and still shipped the defect, because the
|
||||
pin was on the call two lines below the one that matters. Ask of every
|
||||
banner: *how many places call this, and which does the shipped roster
|
||||
reach?*
|
||||
* **The scope must be COMPUTED** — habit 12's argument, and SAFETY-38 is
|
||||
its proof: the family was enumerated by hand twice and was short both
|
||||
times. `tools/symmetric_message_scan.py` renames each parameter pair,
|
||||
erases every formatted string, canonicalises the commutative operators
|
||||
and iterates to a **fixpoint** over `app/` (the symmetry is usually one
|
||||
call deep — `evaluate()` is symmetric only *through* `disagrees()`), then
|
||||
classifies each sentence by where it GOES. A returned string may be a
|
||||
**key** — swapping `store.unit_key(unit, key)` renames a store key, which
|
||||
is behaviour and is MAINT-05's card — or a **sentence**, which is this
|
||||
family.
|
||||
* **Say what the instrument cannot see, with shapes** (habit 13). That scan
|
||||
under-reports semantic symmetry no canonicaliser on its finite list can
|
||||
spell; its second census, for sentences the CALLER builds, pairs only
|
||||
expressions **one leaf apart** and never sees `%s`-style logging; it
|
||||
covers only `app/`; and it **over-reports in exactly one way** — it
|
||||
erases the sentence, so a swap that would make the FORMATTING itself
|
||||
raise is scored as changing no decision. It prints all of that on every
|
||||
run. The family, every member's call sites, and which of them are pinned
|
||||
are in `DECISIONS/SAFETY-38-site-standing-pin.md` §7 — **with two
|
||||
measured, still-open survivors named there rather than rounded off**,
|
||||
both zero bytes and both 21/21 green.
|
||||
|
||||
And the mutation-table consequence: **a variant is not covered because a
|
||||
near-miss of it is.** DEV-02's table listed the *actuator* variant of the
|
||||
per-unit claim defect (`device`/`supervised` filed without `inv`), which the
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1508,6 +1508,50 @@ check("⚠️ AC 1. THE REVERSED UNIT TRIPS AND ITS SIBLING DOES NOT. The eviden
|
||||
check("...and the reason names the instrument, so the operator is sent to the "
|
||||
"right one", AGAINST_OWN_POWER in _b30.sign_wd.reason)
|
||||
print(f" {_b30.sign_wd.reason!r}")
|
||||
# ⚠️ THE LAST CYCLE BEFORE B LATCHED, NAMED RATHER THAN INDEXED - part 5's
|
||||
# lesson and 9a's. After the trip B's share is 0 W and its measured power
|
||||
# follows it down, so a fixed index reads the REMEDY instead of the evidence.
|
||||
# Both operands are read off THIS OBSERVED CYCLE - `last_write` and `batt` off
|
||||
# the objects - and not off the fixture's constants, so the check cannot be
|
||||
# satisfied by two numbers the fixture happens to hold anyway (habit 2).
|
||||
_preb30 = [x for x in _s30 if not x["b"]]
|
||||
_midb30 = _preb30[-1]
|
||||
print(f" last cycle before B latched: B stood "
|
||||
f"{_midb30['stood'][1]:+.0f} W, B measured {_midb30['meas'][1]:+.0f} W")
|
||||
check("⚠️ ...AND THE ORDERED PAIR, on the ONE SIGN-DISAGREEMENT SENTENCE A "
|
||||
"SHIPPED INSTALL CAN EVER PRINT. Part 9a pins the SITE banner's operand "
|
||||
"order at main.py:1620; that call site is armed only at N>=2 and part 9c "
|
||||
"asserts it is ABSENT at a roster of one, so on the shipped install the "
|
||||
"PER-UNIT banner is the only one there is - and its call site is a "
|
||||
"different line. Swapping the first two arguments at main.py:1560 - "
|
||||
"`evaluate(stood, against, ...)` -> `evaluate(against, stood, ...)` - is "
|
||||
"a ZERO-BYTE edit that changes NO trip decision, because `disagrees()` "
|
||||
"is symmetric in those two arguments (`commanded_w * against_w < 0` with "
|
||||
"an abs() gate on both). Nothing behavioural can see it and the full "
|
||||
"21-file suite stayed green. What it changes is the SENTENCE: 'commanded "
|
||||
"-1000 W against its own measured power +1000 W', the exact opposite of "
|
||||
"which instrument read what, on the line somebody woken by this alarm "
|
||||
"reads first - and it is persisted to `sign_reason` and re-read after a "
|
||||
"restart. Graded on the CONTIGUOUS LITERAL with each value in its own "
|
||||
"slot, per MUTATION.md habit 14: an `in` test on a number grades "
|
||||
"PRESENCE, never ARRANGEMENT",
|
||||
f"commanded {_midb30['stood'][1]:+.0f} W against its own measured power "
|
||||
f"{_midb30['meas'][1]:+.0f} W" in _b30.sign_wd.reason)
|
||||
check("PRECONDITION for the line above, and without it that check is "
|
||||
"decoration: the two operands are DIFFERENT NUMBERS and OPPOSITE IN "
|
||||
"SIGN, so transposing them really does produce a different string. A "
|
||||
"cycle where B's share and B's measured power were equal, or where "
|
||||
"either was 0 W, would make the swapped sentence identical to the "
|
||||
"correct one and the assertion could not reach the failure it names",
|
||||
_midb30["stood"][1] != _midb30["meas"][1]
|
||||
and _midb30["stood"][1] * _midb30["meas"][1] < 0
|
||||
and f"{_midb30['stood'][1]:+.0f}" != f"{_midb30['meas'][1]:+.0f}")
|
||||
check("...and the SAME ordered pair is what an operator finds AFTER A RESTART, "
|
||||
"because `sign_reason` is the persisted copy of this string. The swap "
|
||||
"survives the container restart the same way the latch does",
|
||||
f"commanded {_midb30['stood'][1]:+.0f} W against its own measured power "
|
||||
f"{_midb30['meas'][1]:+.0f} W"
|
||||
in str(_c30.store.data.get(unit_key("inverter-b", "sign_reason"), "")))
|
||||
# ⚠️ READ OFF THE ALLOCATION RECORD, NOT OFF `Controller.reason`. At a roster
|
||||
# of two `reason` is the SITE resolution's line - "loop -> 2000 W" - because
|
||||
# B's failsafe is a UNIT claim resolved on B's own actuator and never competes
|
||||
@@ -1764,11 +1808,20 @@ async def _n1_sign():
|
||||
return _orig_eval(*a, **kw)
|
||||
|
||||
c.site_sign_wd.evaluate = _spy
|
||||
await _drive(c, n=12, dt=0.2)
|
||||
return c, _n1_calls
|
||||
# ⚠️ THE WATCH READS THE CONTROLLER'S OWN STATE, NOT `_spy`'s ARGUMENTS.
|
||||
# `_n1_calls` records what was HANDED to a detector, so grading the banner
|
||||
# against it would compare the swap against itself - the DEV-02 defect.
|
||||
# `last_write` and `c.grid` are read off the objects the plant moved.
|
||||
seen = await _drive(c, n=12, dt=0.2, watch=lambda c: dict(
|
||||
grid=c.grid,
|
||||
stood=(c.inverters[0].last_write
|
||||
if c.inverters[0].last_write is not None
|
||||
else c.inverters[0].target),
|
||||
tripped=c.inverters[0].sign_wd.tripped))
|
||||
return c, _n1_calls, seen
|
||||
|
||||
|
||||
_c1s, _n1_site_calls = asyncio.run(_n1_sign())
|
||||
_c1s, _n1_site_calls, _seen1s = asyncio.run(_n1_sign())
|
||||
_inv1 = _c1s.inverters[0]
|
||||
print(f" roster={_c1s.units} armed against {_inv1.sign_wd.against!r} "
|
||||
f"at {_inv1.sign_wd.cfg.min_w:.0f} W / {_inv1.sign_wd.cfg.hold_s:.0f} s")
|
||||
@@ -1786,6 +1839,43 @@ check("...the reason sentence still says 'a meter reading', word for word - it "
|
||||
"is what an operator reads, what the action log carries and what is "
|
||||
"persisted, so it is graded on the literal",
|
||||
"against a meter reading" in _inv1.sign_wd.reason)
|
||||
# The last cycle before the latch, named rather than indexed: afterwards the
|
||||
# failsafe stands 0 W and the plant's meter relaxes, so a fixed index reads the
|
||||
# remedy. Both operands come off the controller, not off the fixture's options.
|
||||
_pre1s = [x for x in _seen1s if not x["tripped"]]
|
||||
_mid1s = _pre1s[-1]
|
||||
print(f" last cycle before the latch: stood {_mid1s['stood']:+.0f} W, "
|
||||
f"meter {_mid1s['grid']:+.0f} W")
|
||||
print(f" {_inv1.sign_wd.reason!r}")
|
||||
check("⚠️ ...AND THE ORDERED PAIR ON THE SHIPPING CALL SITE. This is the SAME "
|
||||
"zero-byte defect part 9a closed at main.py:1620, one call site over at "
|
||||
"main.py:1560 - and this is the one a one-inverter install actually "
|
||||
"reaches, since the site detector is never armed here (asserted below). "
|
||||
"`evaluate(stood, against, ...)` -> `evaluate(against, stood, ...)` "
|
||||
"changes no trip decision - `disagrees()` is symmetric in those two "
|
||||
"arguments - and inverts the banner to 'commanded +4000 W against a "
|
||||
"meter reading -2000 W': the loop is standing at -2000 W of charge INTO "
|
||||
"a +4000 W import and the latching banner says the opposite. The "
|
||||
"substring check above passes under the swap, because both numbers are "
|
||||
"still in the sentence; only the ORDERED PAIR can tell the slots apart "
|
||||
"(MUTATION.md habit 14)",
|
||||
f"commanded {_mid1s['stood']:+.0f} W against a meter reading "
|
||||
f"{_mid1s['grid']:+.0f} W" in _inv1.sign_wd.reason)
|
||||
check("PRECONDITION: the two operands are DIFFERENT NUMBERS and OPPOSITE IN "
|
||||
"SIGN on that cycle, so a transposition genuinely produces a different "
|
||||
"string. Equal or zero operands would make the swapped banner identical "
|
||||
"and the check above vacuous",
|
||||
_mid1s["stood"] != _mid1s["grid"]
|
||||
and _mid1s["stood"] * _mid1s["grid"] < 0
|
||||
and f"{_mid1s['stood']:+.0f}" != f"{_mid1s['grid']:+.0f}")
|
||||
check("...and the persisted copy carries the same ordered pair, under BOTH the "
|
||||
"namespaced key and the flat rollback mirror - which is what an operator "
|
||||
"reads after the restart the latch is designed to survive",
|
||||
f"commanded {_mid1s['stood']:+.0f} W against a meter reading "
|
||||
f"{_mid1s['grid']:+.0f} W" in str(_c1s.store.data.get(REASON_KEY, ""))
|
||||
and f"commanded {_mid1s['stood']:+.0f} W against a meter reading "
|
||||
f"{_mid1s['grid']:+.0f} W"
|
||||
in str(_c1s.store.data.get("sign_reason", "")))
|
||||
check("...the latch is under the same namespaced key with the same flat "
|
||||
"rollback mirror, and SAFETY-30's site pair is untouched at its default. "
|
||||
"A one-inverter install's state file is byte-identical to what it was",
|
||||
@@ -1993,6 +2083,564 @@ check("...and the counterfactual again: the same site with nothing latched "
|
||||
not _bn_n["banner"].startswith("FAILSAFE")
|
||||
and _bn_n["banner"] != _bn_b["banner"])
|
||||
|
||||
|
||||
print()
|
||||
print("9. SAFETY-38: `site_standing` - the ONE quantity BOTH site guards are")
|
||||
print(" asked about - pinned by shares that CANNOT answer for it")
|
||||
# ⚠️ WHAT THIS SECTION PINS, AND WHY NOTHING ABOVE IT COULD.
|
||||
# `main.py`'s `site_standing = float(sum(standing.values()))` is the first
|
||||
# argument of SAFETY-30's site sign detector AND the `commanded_w` handed to
|
||||
# SAFETY-18's `looks_frozen()`. It was pinned by NOTHING. THREE mutations of it
|
||||
# were demonstrated live against a full green suite, and ALL THREE ARE COVERED
|
||||
# HERE:
|
||||
#
|
||||
# sum(...) / max(1, len(standing)) the MEAN
|
||||
# max(standing.values(), key=abs) the LARGEST SHARE
|
||||
# list(standing.values())[0] the FIRST SHARE
|
||||
#
|
||||
# ⚠️ ROUND 1 OF THIS CARD SAID THE THIRD ONE WAS ALREADY CLOSED BY SAFETY-32's
|
||||
# PACKAGE-WIDE STRUCTURAL PIN AND TOLD FUTURE AUTHORS "DO NOT RE-COVER THAT ONE
|
||||
# HERE". THAT WAS FALSE, AND HOW IT BECAME FALSE IS THE USEFUL PART. It was
|
||||
# measured ONCE; that single run happened to hit test_peakguard.py's ~20%
|
||||
# CI-06 flake, the red was recorded as a mutation KILL, and the claim was then
|
||||
# written into four places. RE-MEASURED IN ROUND 2, on release/1.0 @ 86eaef2
|
||||
# carrying ONLY the `[0]` edit (+4 bytes, re-read from disk): the full suite is
|
||||
# 21/21 GREEN, and test_peakguard.py run FIVE times in isolation on that same
|
||||
# mutated tree is PASS PASS PASS PASS PASS. The mutation SURVIVES. (The
|
||||
# reviewer's own independent re-measurement of the same tree caught the flake
|
||||
# once - PASS PASS PASS FAIL PASS - which is the same verdict: a survivor, plus
|
||||
# a demonstration of the flake that produced the original error.) A SINGLE RED
|
||||
# FROM test_peakguard.py PROVES NOTHING - re-run it at least five times and NAME
|
||||
# the failing check before believing it. That rule is CI-06's, and this card is
|
||||
# the third measurement it has corrupted.
|
||||
#
|
||||
# THE MECHANISM, confirmed by reading 12j's own enumeration rather than by
|
||||
# argument. SAFETY-32's part 12j taints names BOUND to roster-carrying values
|
||||
# and lists ACCUMULATED in a loop over one. `standing` is neither: it is a dict
|
||||
# created empty at main.py:1554 and filled by SUBSCRIPT assignment
|
||||
# (`standing[inv.name] = stood`) inside the roster loop, and a subscript store
|
||||
# is not a binding 12j follows. So `standing` never enters its taint set at all,
|
||||
# and `sum(standing.values())` is not a roster site. Run 12j on a tree carrying
|
||||
# the `[0]` mutation and it enumerates main.py:1553, 1555 and 1690 - the three
|
||||
# `self.inverters` sites around this code - and NOT 1599. A structural pin
|
||||
# cannot see this line. Only the behavioural fixture below can.
|
||||
#
|
||||
# Identical shape, different meaning - which is precisely the blind spot
|
||||
# SAFETY-32 disclosed in its own words ("SEMANTICS. `min` -> `max` -> the mean
|
||||
# are edits invisible to any shape pin, permanently"). No shape pin will ever
|
||||
# close them. Only a behavioural fixture can, and this is it.
|
||||
#
|
||||
# ⚠️ WHY PART 5 ABOVE CANNOT SEE THEM, AND IT IS THIS REPOSITORY'S RECURRING
|
||||
# DEFECT INSIDE THE SITE HALF OF THE VERY CARD THAT NAMES IT. `_sign_site` is
|
||||
# the only N>=2 site fixture in the suite and it runs at
|
||||
# `site_sign_disagree_w: 320` with shares of about -2500 W EACH - every one of
|
||||
# them individually far OVER the gate. On values like those the sum, the mean
|
||||
# and the largest share all clear it, so all three reductions agree, and a
|
||||
# check whose two candidate answers happen to be EQUAL cannot observe a change
|
||||
# that swaps them. MUTATION.md habit 4. At a roster of ONE it is worse: there
|
||||
# `sum == mean == max == the one share` identically, so every mutation is
|
||||
# EQUIVALENT there - see 9c, where that identity is asserted rather than
|
||||
# assumed.
|
||||
#
|
||||
# ⚠️ SO THE NUMBERS BELOW ARE CHOSEN TO DISCRIMINATE, AND HERE IS THE
|
||||
# ARITHMETIC THEY ARE CHOSEN FOR. Two simulated units rated 220 W and 280 W
|
||||
# stand at their own rails, so `standing` is {-220, -280} in roster order:
|
||||
#
|
||||
# sum -500 W |500| > 300 OVER the gate -> TRIPS
|
||||
# mean -250 W |250| < 300 under it -> silent
|
||||
# max(..., key=abs) -280 W |280| < 300 under it -> silent
|
||||
# list(...)[0] -220 W |220| < 300 under it -> silent
|
||||
#
|
||||
# FOUR DISTINCT MAGNITUDES - 500, 250, 280, 220 - of which exactly ONE clears
|
||||
# the SHIPPED 300 W gate. The asymmetry is load-bearing twice over: equal
|
||||
# shares would collapse mean, max and first onto one number and the fixture
|
||||
# could not tell them apart, and putting the SMALLER unit first is what keeps
|
||||
# `[0]` distinct from `max(key=abs)`.
|
||||
#
|
||||
# ⚠️ THE RATINGS ARE SIMULATED AND ARE NOT ANY REAL INVERTER'S. 220 W and 280 W
|
||||
# are chosen for the arithmetic in that table and for nothing else. In
|
||||
# particular they are NOT inverter B, which does not physically exist; B's
|
||||
# rating is 4600 W FROM ITS DATASHEET (goodwe/NOTES.md 1.1b) and no number in
|
||||
# this section is a measurement of anything.
|
||||
#
|
||||
# ⚠️ THE GATE IS THE SHIPPED ONE AND THE HOLD IS NOT. `site_sign_disagree_w` is
|
||||
# left at its default so this fixture is graded against the magnitude an
|
||||
# install actually runs; `site_sign_disagree_s` is shortened from 325 s to 2 s
|
||||
# because the hold is a wall clock and the campaign behind 325 s is measured on
|
||||
# the rig, not here. The gate is the coordinate this section is about.
|
||||
from app import peakguard as _pg38 # noqa: E402
|
||||
from app.freeze import rail_w as _rail_w38 # noqa: E402
|
||||
from app.p1 import P1Sample as _P1Sample38 # noqa: E402
|
||||
import time as _time38 # noqa: E402
|
||||
|
||||
_S38_A_W, _S38_B_W = 220.0, 280.0 # SIMULATED. See the note above.
|
||||
_S38_LOAD_W = 2000.0
|
||||
_S38_FROZEN_METER_W = 2500.0
|
||||
|
||||
|
||||
def _s38_roster(*ratings):
|
||||
"""A CFG-04 `inverters:` roster, which is the shape an N>=2 install runs.
|
||||
|
||||
⚠️ NOT `_sign_site`'s "build one from the flat keys and append a second".
|
||||
`Controller.__init__` builds `Tuning` FROM the roster - `max_w=site_max_w` -
|
||||
so a unit appended afterwards leaves the site loop bounded at ONE unit's
|
||||
rating. That is harmless where part 3 uses it and fatal here: the whole
|
||||
fixture is a site command standing at the ROSTER's rail.
|
||||
"""
|
||||
return [{"name": name, "setpoint_entity": f"number.sp_{tag}",
|
||||
"soc_entity": f"sensor.soc_{tag}", "batt_entity": f"sensor.batt_{tag}",
|
||||
"max_w": w, "bms_soc_floor": 10}
|
||||
for name, tag, w in zip(("inverter-a", "inverter-b", "inverter-c"),
|
||||
("a", "b", "c"), ratings)]
|
||||
|
||||
|
||||
class _S38Plant:
|
||||
"""Units that obey INSTANTLY and a meter that is `load - sum(measured)`.
|
||||
|
||||
The same modelling choice part 3 makes and for the same reason: an instant
|
||||
plant removes the one mechanism that could make an obedient unit look
|
||||
disobedient, so what remains to be graded is the ATTRIBUTION.
|
||||
"""
|
||||
|
||||
def __init__(self, load_w=_S38_LOAD_W):
|
||||
self.load_w = float(load_w)
|
||||
self.writes = []
|
||||
self.calls = []
|
||||
self.ctrl = None
|
||||
|
||||
async def call(self, domain, service, data):
|
||||
self.calls.append((domain, service, data))
|
||||
|
||||
async def limits(self, entity):
|
||||
return None
|
||||
|
||||
def measured(self):
|
||||
if self.ctrl is None:
|
||||
return []
|
||||
return [float((i.last_write if i.last_write is not None else i.target) or 0.0)
|
||||
for i in self.ctrl.inverters]
|
||||
|
||||
async def number(self, entity, invert=False):
|
||||
m = self.measured()
|
||||
if entity == "sensor.grid":
|
||||
return self.load_w - sum(m)
|
||||
for i, tag in enumerate(("a", "b", "c")):
|
||||
if entity == f"sensor.soc_{tag}":
|
||||
return 50.0
|
||||
if entity == f"sensor.batt_{tag}":
|
||||
return m[i] if i < len(m) else None
|
||||
return None
|
||||
|
||||
async def set_number(self, entity, value):
|
||||
self.writes.append((entity, float(value)))
|
||||
return True
|
||||
|
||||
|
||||
def _s38_ctrl(hass, ratings, store=None, **over):
|
||||
opts = {"meter_entity": "sensor.grid", "meter_source": "off",
|
||||
"inverters": _s38_roster(*ratings),
|
||||
"gain": -0.6,
|
||||
# The per-unit pair, distinct from the site pair in BOTH
|
||||
# coordinates, for part 3's reason: an equality below must be able
|
||||
# to fail on the magnitude alone or on the hold alone.
|
||||
"unit_sign_disagree_w": 250, "unit_sign_disagree_s": 1,
|
||||
# ⚠️ `site_sign_disagree_w` DELIBERATELY ABSENT - the shipped 300 W
|
||||
# default is the gate this whole section is graded against.
|
||||
"site_sign_disagree_s": 2,
|
||||
# SAFETY-31 is given a long window so the SITE is not stopped by
|
||||
# circulation while this section grades the sign attribution. It is
|
||||
# isolation, not a claim that SAFETY-31 is silent here.
|
||||
"circulation_hold_s": 3600, "auto_start": True}
|
||||
opts.update(over)
|
||||
c = Controller(opts, hass, store if store is not None else _MemStore(),
|
||||
_P(), stop=asyncio.Event())
|
||||
for inv in c.inverters:
|
||||
inv.dev_min, inv.dev_max = -inv.max_w, inv.max_w
|
||||
c.auto = True
|
||||
hass.ctrl = c
|
||||
return c
|
||||
|
||||
|
||||
def _s38_stood(c):
|
||||
return [inv.last_write if inv.last_write is not None else inv.target
|
||||
for inv in c.inverters]
|
||||
|
||||
|
||||
print()
|
||||
print("9a. the SITE sign detector: per-unit shares EACH BELOW the gate whose")
|
||||
print(" SUM clears it - the case the site detector exists for")
|
||||
|
||||
|
||||
async def _s38_site():
|
||||
hass = _S38Plant()
|
||||
c = _s38_ctrl(hass, (_S38_A_W, _S38_B_W))
|
||||
seen = []
|
||||
for _ in range(20):
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
seen.append(dict(grid=c.grid, stood=_s38_stood(c),
|
||||
meas=[i.batt for i in c.inverters],
|
||||
site=c.site_sign_wd.tripped,
|
||||
units=[i.sign_wd.tripped for i in c.inverters],
|
||||
following=c.following))
|
||||
await asyncio.sleep(0.2)
|
||||
return c, seen
|
||||
|
||||
|
||||
_c38, _s38seen = asyncio.run(_s38_site())
|
||||
# The last sample BEFORE the trip, named rather than indexed - part 5's lesson.
|
||||
# After the trip every command is 0 W, so a fixed index reads the REMEDY.
|
||||
_pre38 = [x for x in _s38seen if not x["site"]]
|
||||
_mid38 = _pre38[-1]
|
||||
_GATE38 = _c38.site_sign_wd.cfg.min_w
|
||||
_share38 = list(_mid38["stood"])
|
||||
_red38 = {"sum": sum(_share38),
|
||||
"mean": sum(_share38) / max(1, len(_share38)),
|
||||
"max(key=abs)": max(_share38, key=abs),
|
||||
"[0]": _share38[0]}
|
||||
print(f" last cycle before the trip: stood "
|
||||
f"{[round(x) for x in _share38]} measured "
|
||||
f"{[round(x) for x in _mid38['meas']]} meter {_mid38['grid']:+.0f} W")
|
||||
print(f" site gate {_GATE38:.0f} W (SHIPPED default), hold "
|
||||
f"{_c38.site_sign_wd.cfg.hold_s:.0f} s")
|
||||
for _k, _v in _red38.items():
|
||||
print(f" {_k:>13} = {_v:+7.1f} W "
|
||||
f"{'OVER' if abs(_v) > _GATE38 else 'under'} the gate")
|
||||
check("PRECONDITION: the gate really is the SHIPPED 300 W, so this fixture is "
|
||||
"graded against the magnitude an install runs and not against a number "
|
||||
"chosen to make it pass",
|
||||
_GATE38 == float(SIGN_OPTION_DEFAULTS["site_sign_disagree_w"]) == 300.0)
|
||||
check("⚠️ PRECONDITION, AND IT IS THE WHOLE OF SAFETY-38. EVERY per-unit share "
|
||||
"is BELOW the site gate and their SUM is OVER it. `_sign_site` above "
|
||||
"cannot make this statement - its shares are each individually over the "
|
||||
"gate, so sum, mean and largest-share all agree there and its values "
|
||||
"cannot observe a change that swaps them",
|
||||
len(_share38) >= 2
|
||||
and all(abs(s) < _GATE38 for s in _share38)
|
||||
and abs(sum(_share38)) > _GATE38)
|
||||
check("...and the FOUR candidate reductions give FOUR DISTINCT answers on it, "
|
||||
"of which exactly ONE clears the gate. That is what makes this fixture "
|
||||
"able to tell `sum` from the mean, from the largest share and from the "
|
||||
"first share - and it is the property the existing site fixture lacks",
|
||||
len({round(v, 6) for v in _red38.values()}) == 4
|
||||
and [k for k, v in _red38.items() if abs(v) > _GATE38] == ["sum"])
|
||||
check("PRECONDITION: EVERY inverter is obeying - each unit's measured power "
|
||||
"agrees in sign with its own share - and the site is charging into an "
|
||||
"importing house. Without this the trip below could be a per-unit fault "
|
||||
"in disguise rather than the site fault this grades",
|
||||
all(s * m > 0 for s, m in zip(_mid38["stood"], _mid38["meas"]))
|
||||
and sum(_mid38["stood"]) * _mid38["grid"] < 0
|
||||
and _mid38["following"] is True)
|
||||
check("⚠️ THE SITE WATCHDOG TRIPS ON THE SUM, and no per-unit watchdog does. "
|
||||
"Under ALL THREE semantic mutations - `sum -> mean`, "
|
||||
"`sum -> max(key=abs)` AND `sum -> list(...)[0]` - NOTHING trips, ever: "
|
||||
"the reviewer's tree was still commanding -500 W of charge into a "
|
||||
"+2500 W import at t = 595 s with following=True. This is the check all "
|
||||
"three go red on. `[0]` is here BECAUSE IT IS NOT COVERED ELSEWHERE - "
|
||||
"see the section header for the mis-measurement that said it was",
|
||||
_c38.site_sign_wd.tripped
|
||||
and not any(i.sign_wd.tripped for i in _c38.inverters))
|
||||
print(f" {_c38.site_sign_wd.reason!r}")
|
||||
check("⚠️ ...and the sentence an operator reads carries THE SUM IN THE "
|
||||
"COMMANDED SLOT AND THE METER IN THE METER SLOT - the ORDERED PAIR, not "
|
||||
"two numbers loose in a sentence. ROUND 1 ASSERTED `f'{sum(...):.0f} W' "
|
||||
"in reason`, i.e. that '-500 W' appears SOMEWHERE, AND THAT WAS A HOLE "
|
||||
"ONE LINE WIDE. Swapping the detector's first two arguments - "
|
||||
"`evaluate(site_standing, self.grid, ...)` -> "
|
||||
"`evaluate(self.grid, site_standing, ...)`, a ZERO-BYTE edit at "
|
||||
"main.py:1620 - leaves BOTH numbers in the string and moves each into "
|
||||
"the other's slot, so the substring test still passed and the whole "
|
||||
"suite stayed green. The swapped banner reads 'commanded +2500 W "
|
||||
"against a meter reading -500 W': the site is standing at -500 W of "
|
||||
"charge INTO a +2500 W import and the latching, site-stopping banner "
|
||||
"tells the operator the exact opposite, on the one line somebody woken "
|
||||
"by this alarm reads first. watchdog.py:658-665 calls naming the wrong "
|
||||
"instrument there 'a false statement about which instrument produced "
|
||||
"the evidence'. A check whose data cannot reach the failure it names is "
|
||||
"MUTATION.md's own defect; this is the line that closes it",
|
||||
f"commanded {sum(_share38):+.0f} W against a meter reading "
|
||||
f"{_mid38['grid']:+.0f} W" in _c38.site_sign_wd.reason)
|
||||
check("...and it stops the WHOLE site, through the `sign_site` claim by name "
|
||||
"rather than by some other guard happening to stop it",
|
||||
_c38.target == 0.0 and "sign_site" in _c38.reason
|
||||
and all(i.target == 0.0 for i in _c38.inverters)
|
||||
and _c38.store.data.get("site_sign_tripped") is True)
|
||||
# ⚠️ THE COUNTERFACTUAL, and it is what says the check above is produced by the
|
||||
# SUM crossing the gate rather than by two inverters being present. Same plant,
|
||||
# same roster shape, same everything - except the units are rated so that the
|
||||
# sum lands UNDER the gate. Nothing may trip.
|
||||
_S38_SMALL = (120.0, 130.0) # sum 250 W: under the 300 W gate
|
||||
|
||||
|
||||
async def _s38_under():
|
||||
hass = _S38Plant()
|
||||
c = _s38_ctrl(hass, _S38_SMALL)
|
||||
for _ in range(20):
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
await asyncio.sleep(0.2)
|
||||
return c
|
||||
|
||||
|
||||
_c38u = asyncio.run(_s38_under())
|
||||
print(f" counterfactual: stood {[round(x) for x in _s38_stood(_c38u)]} "
|
||||
f"sum {sum(_s38_stood(_c38u)):+.0f} W meter {_c38u.grid:+.0f} W")
|
||||
check("...and the counterfactual: the SAME inverted-gain site whose shares sum "
|
||||
"to 250 W - under the gate - does NOT trip, over the same 20 cycles. So "
|
||||
"the trip above is the sum crossing 300 W and not the mere presence of a "
|
||||
"second inverter",
|
||||
abs(sum(_s38_stood(_c38u))) < _GATE38
|
||||
and not _c38u.site_sign_wd.tripped
|
||||
and not any(i.sign_wd.tripped for i in _c38u.inverters))
|
||||
# ⚠️ AND A SECOND COUNTERFACTUAL, WHICH EXISTS BECAUSE OF A MUTATION THE CHECKS
|
||||
# ABOVE COULD NOT SEE AND THIS SEARCH WENT LOOKING FOR.
|
||||
# `site_standing = float(self.target)` - the arbiter's SITE TARGET instead of
|
||||
# the sum of what actually stands on the wire - is -11 bytes, and it survived
|
||||
# every check above AND the whole 20-file suite, because in every fixture so
|
||||
# far the two are the same number. THEY ARE NOT THE SAME NUMBER when a setpoint
|
||||
# device's own reported range is narrower than its `max_w`: the `device`
|
||||
# P_SAFETY claim is filed PER ACTUATOR (main.py, `actuator_of("device", inv)`),
|
||||
# so it bounds each unit's SHARE and never the site target, and
|
||||
# `clamp_to_rating()` clips again on the way out. Here the site asks for -500 W
|
||||
# and -250 W stands.
|
||||
#
|
||||
# WHICH ONE THE GUARDS MEAN IS ALREADY WRITTEN DOWN, IN BOTH OF THEM: SAFETY-02
|
||||
# grades "the command standing on the wire", and `looks_frozen()`'s docstring
|
||||
# says `commanded_w` is "the EMITTED setpoint - what the arbiter resolved and
|
||||
# `write_setpoint()` put on the wire - not the loop's raw wish". SAFETY-20 is
|
||||
# the whole card about the difference. This check is those sentences made to
|
||||
# bite: on the site target the detector fires on a site that is standing at
|
||||
# -250 W against a +2250 W meter - under its own 300 W gate - which is a
|
||||
# LATCHING, site-stopping false trip on an install whose inverters simply
|
||||
# report a narrower range than the operator typed.
|
||||
_S38_DEV = (120.0, 130.0) # narrower than the 220/280 W ratings
|
||||
|
||||
|
||||
async def _s38_devclamped():
|
||||
hass = _S38Plant()
|
||||
c = _s38_ctrl(hass, (_S38_A_W, _S38_B_W))
|
||||
for inv, d in zip(c.inverters, _S38_DEV):
|
||||
inv.dev_min, inv.dev_max = -d, d
|
||||
for _ in range(20):
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
await asyncio.sleep(0.2)
|
||||
return c
|
||||
|
||||
|
||||
_c38d = asyncio.run(_s38_devclamped())
|
||||
_stood38d = _s38_stood(_c38d)
|
||||
print(f" device-clamped: site target {_c38d.target:+.0f} W but only "
|
||||
f"{[round(x) for x in _stood38d]} = {sum(_stood38d):+.0f} W stands, "
|
||||
f"meter {_c38d.grid:+.0f} W")
|
||||
check("PRECONDITION: the site TARGET and the EMITTED total are DIFFERENT "
|
||||
"numbers here, on opposite sides of the gate - -500 W asked, -250 W "
|
||||
"standing. Without that separation this check and the one above compare "
|
||||
"two values that happen to be equal, which is the defect this card is "
|
||||
"the sixth instance of",
|
||||
abs(_c38d.target) > _GATE38 and abs(sum(_stood38d)) < _GATE38)
|
||||
check("⚠️ ...and NOTHING TRIPS, because the quantity graded is what STANDS ON "
|
||||
"THE WIRE and not what the arbiter asked for. `float(self.target)` is "
|
||||
"-11 bytes, survives the other 20 files, and turns this ordinary "
|
||||
"narrow-rating install into a latching site-wide false trip",
|
||||
not _c38d.site_sign_wd.tripped
|
||||
and not any(i.sign_wd.tripped for i in _c38d.inverters)
|
||||
and _c38d.target != 0.0)
|
||||
|
||||
|
||||
print()
|
||||
print("9b. THE SECOND CONSUMER: the same value is SAFETY-18's `commanded_w`")
|
||||
print(" inside `looks_frozen()`, and both mutations blind it too")
|
||||
# ⚠️ THIS IS THE AC THE CARD REFUSES TO LET BE SCOPED OUT SILENTLY.
|
||||
# `site_standing` is handed to `looks_frozen()` at main.py's `peakguard.decide(
|
||||
# ..., frozen=...)` call, where it is the EMITTED command graded against the
|
||||
# site rail. Both surviving mutations shrink it there too, and nothing went red.
|
||||
#
|
||||
# THE RAIL ARITHMETIC, and it is the same discrimination as 9a at a different
|
||||
# threshold: the site rail is `RAIL_FRAC * site_max_w` = 0.95 * 500 = 475 W.
|
||||
#
|
||||
# sum 500 W >= 475 RAILED -> BLIND_FROZEN, floor 0 W
|
||||
# mean 250 W < 475 silent
|
||||
# max(..., key=abs) 280 W < 475 silent
|
||||
# list(...)[0] 220 W < 475 silent
|
||||
#
|
||||
# ⚠️ THE SIGN IS FLIPPED FROM 9a ON PURPOSE. Here the gain is the SHIPPED
|
||||
# POSITIVE one and the frozen meter reads IMPORT, so the site discharges +500 W
|
||||
# against a +2500 W import - the two agree in sign, SAFETY-30 has nothing to
|
||||
# say, and this section grades SAFETY-18 alone. Reusing 9a's inverted gain
|
||||
# would have the site sign latch park everything at 0 W and the freeze
|
||||
# conjunction could then never be true: a check measuring the other guard's
|
||||
# remedy.
|
||||
#
|
||||
# WHAT THE MUTATIONS COST HERE, measured rather than asserted: on the shipped
|
||||
# tree the guard goes blind and files `floor_w = 0.0` - "the battery may not
|
||||
# import while the billed quarter cannot be measured". Under both mutations it
|
||||
# is NOT BLIND AT ALL and files `floor_w = None`, so the site may go on
|
||||
# charging through a quarter whose billed average is unobservable. That is the
|
||||
# capacity-tariff hazard SAFETY-18 exists for, silently disarmed.
|
||||
|
||||
|
||||
class _S38Src:
|
||||
"""The transport's own `unchanged_s`, which only homewizard_local has."""
|
||||
|
||||
def __init__(self, unchanged_s):
|
||||
self.unchanged_s = float(unchanged_s)
|
||||
|
||||
|
||||
def _s38_feed(c, w, ts=None, mono=None):
|
||||
now = ts if ts is not None else datetime.now(timezone.utc)
|
||||
c.p1.submit(_P1Sample38(
|
||||
ingest_ts=now,
|
||||
ingest_mono=mono if mono is not None else _time38.monotonic(),
|
||||
telegram_ts=now, source="homewizard_local",
|
||||
import_w=max(w, 0.0), export_w=max(-w, 0.0), net_w=w,
|
||||
per_phase_w=(w,), per_phase_import_w=(max(w, 0.0),)))
|
||||
|
||||
|
||||
def _s38_seed_quarter(c, w):
|
||||
"""Backfill the OPEN quarter with the same stuck value, at 10 s spacing.
|
||||
|
||||
⚠️ THIS IS NOT DECORATION. Without it the open block is mostly uncovered and
|
||||
`blindness()` answers BLIND_COVERAGE - which is still blind, still floor
|
||||
0 W, and would therefore MASK the mutations: the mutant would reach the
|
||||
same action by a different route and a check on the action alone could not
|
||||
see the difference it names. Seeded, the mutant is not blind at ALL, which
|
||||
is the honest measurement of what the mutation costs. A meter that has been
|
||||
stuck at the same reading all quarter is also what SAFETY-18 is about.
|
||||
"""
|
||||
wall = _time38.time()
|
||||
q0 = (int(wall) // 900) * 900
|
||||
mono0 = _time38.monotonic() - (wall - q0)
|
||||
t = float(q0)
|
||||
while t < wall:
|
||||
_s38_feed(c, w, datetime.fromtimestamp(t, timezone.utc), mono0 + (t - q0))
|
||||
t += 10.0
|
||||
|
||||
|
||||
def _s38_frozen_ctrl(hass, ratings, unchanged_s=600.0, gain=0.05):
|
||||
# ⚠️ `gain` 0.05 IS THE SCHEMA FLOOR AND IT IS HERE TO BUY A RAMP. At the
|
||||
# shipped 0.6 the loop is at the rail on cycle 0 and the fixture has no
|
||||
# BELOW-the-rail cycles to use as its counterfactual. At 0.05 the site
|
||||
# climbs 125 W a cycle and crosses 475 W part way through the run, so the
|
||||
# SAME run carries both answers.
|
||||
c = _s38_ctrl(hass, ratings, meter_source="homewizard_local",
|
||||
p1_host="meter.local", gain=gain)
|
||||
c.p1_source = _S38Src(unchanged_s)
|
||||
return c
|
||||
|
||||
|
||||
async def _s38_frozen(ratings, meter_w=_S38_FROZEN_METER_W, n=8):
|
||||
hass = _S38Plant()
|
||||
c = _s38_frozen_ctrl(hass, ratings)
|
||||
_s38_seed_quarter(c, meter_w)
|
||||
seen = []
|
||||
for _ in range(n):
|
||||
_s38_feed(c, meter_w)
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
pg = c.peak_guard
|
||||
seen.append(dict(stood=_s38_stood(c),
|
||||
blind=None if pg is None else pg.blind,
|
||||
floor=None if pg is None else pg.floor_w))
|
||||
await asyncio.sleep(0.05)
|
||||
return c, seen
|
||||
|
||||
|
||||
_c38f, _s38f = asyncio.run(_s38_frozen((_S38_A_W, _S38_B_W)))
|
||||
_RAIL38 = _rail_w38(_c38f.site_max_w,
|
||||
dev_min=_c38f.site_dev_bound("dev_min"),
|
||||
dev_max=_c38f.site_dev_bound("dev_max"))
|
||||
_last38 = _s38f[-1]
|
||||
_below38 = [x for x in _s38f if abs(sum(x["stood"])) < _RAIL38]
|
||||
print(f" site rail {_RAIL38:.0f} W of site_max_w {_c38f.site_max_w:.0f} W; "
|
||||
f"meter stuck at {_S38_FROZEN_METER_W:+.0f} W for "
|
||||
f"{_c38f.p1_source.unchanged_s:.0f} s (freeze hold "
|
||||
f"{_c38f.freeze_wd.cfg.hold_s:.0f} s)")
|
||||
for _x in _s38f:
|
||||
print(" stood %-16s sum %+7.1f W floor=%s blind=%r"
|
||||
% ([round(v) for v in _x["stood"]], sum(_x["stood"]),
|
||||
_x["floor"], _x["blind"]))
|
||||
check("PRECONDITION: at the rail EVERY per-unit share is BELOW the site rail "
|
||||
"and their SUM is at or over it - the same discrimination as 9a, at "
|
||||
"SAFETY-18's threshold instead of SAFETY-30's. `[0]`, the mean and the "
|
||||
"largest share are 220, 250 and 280 W against a 475 W rail",
|
||||
len(_last38["stood"]) >= 2
|
||||
and all(abs(s) < _RAIL38 for s in _last38["stood"])
|
||||
and abs(sum(_last38["stood"])) >= _RAIL38
|
||||
and len({round(v, 6) for v in
|
||||
(sum(_last38["stood"]),
|
||||
sum(_last38["stood"]) / len(_last38["stood"]),
|
||||
max(_last38["stood"], key=abs),
|
||||
_last38["stood"][0])}) == 4)
|
||||
check("⚠️ THE PEAK GUARD GOES BLIND ON SAFETY-18's CONJUNCTION and forbids "
|
||||
"charging - floor 0 W - because the SITE's total command is at the rail "
|
||||
"while the meter has stopped moving. Under `sum -> mean` and "
|
||||
"`sum -> max(key=abs)` it is not blind at all and files no floor, so the "
|
||||
"battery may keep importing through an unmeasurable billed quarter. This "
|
||||
"is the second consumer, and it is covered rather than scoped out",
|
||||
_last38["blind"] == _pg38.BLIND_FROZEN and _last38["floor"] == 0.0)
|
||||
check("...and the counterfactual, from the SAME run: on the earlier cycles the "
|
||||
"site command had not yet reached the rail, and the guard did NOT report "
|
||||
"SAFETY-18 - with the meter equally frozen the whole time. So the "
|
||||
"verdict is produced by the COMMAND MAGNITUDE `site_standing` carries, "
|
||||
"not by `unchanged_s` alone",
|
||||
len(_below38) >= 2
|
||||
and all(x["blind"] != _pg38.BLIND_FROZEN for x in _below38))
|
||||
|
||||
|
||||
print()
|
||||
print("9c. AT ONE INVERTER NOTHING CHANGES, and 'nothing' is asserted")
|
||||
# ⚠️ THE REASON THIS SUBSECTION EXISTS. At a roster of one `standing` has a
|
||||
# single entry, so `sum`, the mean, `max(key=abs)` and `[0]` are THE SAME
|
||||
# NUMBER: all three mutations are genuinely EQUIVALENT there and no fixture at
|
||||
# N=1 can ever separate them. That is a fact about the arithmetic, and leaving
|
||||
# it implicit is how "the shipped install is covered" gets believed. It is
|
||||
# asserted here, on a real one-inverter controller's own standing command, so
|
||||
# the claim rests on the roster rather than on the reader.
|
||||
_S38_ONE = (500.0,)
|
||||
|
||||
|
||||
async def _s38_one():
|
||||
hass = _S38Plant()
|
||||
c = _s38_frozen_ctrl(hass, _S38_ONE)
|
||||
_s38_seed_quarter(c, _S38_FROZEN_METER_W)
|
||||
for _ in range(8):
|
||||
_s38_feed(c, _S38_FROZEN_METER_W)
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
await asyncio.sleep(0.05)
|
||||
return c
|
||||
|
||||
|
||||
_c38one = asyncio.run(_s38_one())
|
||||
_one38 = _s38_stood(_c38one)
|
||||
_onered38 = (sum(_one38), sum(_one38) / max(1, len(_one38)),
|
||||
max(_one38, key=abs), _one38[0])
|
||||
print(f" roster of {len(_c38one.inverters)}: stood "
|
||||
f"{[round(v) for v in _one38]} -> sum/mean/max/first = "
|
||||
f"{tuple(round(v, 1) for v in _onered38)}")
|
||||
check("at a roster of ONE the four reductions are ONE NUMBER - so all three "
|
||||
"mutations of `site_standing` are EQUIVALENT on a shipped install and no "
|
||||
"N=1 fixture could ever tell them apart. That is why this card needed a "
|
||||
"roster of two, stated as an assertion rather than left to the reader",
|
||||
len(_c38one.inverters) == 1
|
||||
and len({round(v, 6) for v in _onered38}) == 1)
|
||||
check("...and the one-inverter site still reaches SAFETY-18's verdict on that "
|
||||
"same number, unchanged: the command is at the rail, the meter has "
|
||||
"stopped moving, the guard is blind and forbids charging",
|
||||
_c38one.peak_guard is not None
|
||||
and _c38one.peak_guard.blind == _pg38.BLIND_FROZEN
|
||||
and _c38one.peak_guard.floor_w == 0.0
|
||||
and abs(sum(_one38)) >= _rail_w38(
|
||||
_c38one.site_max_w,
|
||||
dev_min=_c38one.site_dev_bound("dev_min"),
|
||||
dev_max=_c38one.site_dev_bound("dev_max")))
|
||||
check("...and the SITE sign detector is still absent at a roster of one - part "
|
||||
"7 counts the calls; this only records that a fixture built the other "
|
||||
"way round, from an `inverters:` roster, reaches the same state",
|
||||
not _c38one.site_sign_wd.tripped
|
||||
and _c38one.site_sign_wd.disagree_since is None)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED: {', '.join(fails)}")
|
||||
|
||||
@@ -0,0 +1,565 @@
|
||||
#!/usr/bin/env python3
|
||||
"""SAFETY-38 round 3 - the SYMMETRIC-PREDICATE FAMILY, enumerated by machine.
|
||||
|
||||
WHAT IT LOOKS FOR, in one sentence: a call whose argument ORDER is load-bearing
|
||||
for a MESSAGE and for nothing else - so swapping the arguments cannot change a
|
||||
single decision, is invisible to every behavioural check BY CONSTRUCTION, and
|
||||
still inverts what an operator reads on the line they are woken by.
|
||||
|
||||
⚠️ WHY IT IS AN INSTRUMENT AND NOT A LIST. MUTATION.md habit 12: "a hand-written
|
||||
scope is the same defect as a hand-written fixture". SAFETY-38 met this family
|
||||
twice - `SignWatchdog.evaluate()` called at `main.py:1620` (round 2) and at
|
||||
`main.py:1560` (round 3), ONE CALL SITE OVER - and both times the enumeration
|
||||
was written by hand and both times it was short. So the scope is computed.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
THE TEST IT APPLIES
|
||||
|
||||
For every function in `app/` and every pair (a, b) of its parameters:
|
||||
|
||||
rename a -> b and b -> a throughout the body, ERASE every string literal
|
||||
and f-string, CANONICALISE the commutative operators, and compare the two
|
||||
ASTs. If they are EQUAL, nothing the function DECIDES can depend on which
|
||||
of the two values arrived first.
|
||||
|
||||
Then, if some f-string in that function mentions BOTH names, the order is
|
||||
load-bearing for the MESSAGE ALONE, and the pair is reported.
|
||||
|
||||
⚠️ THE SYMMETRY IS USUALLY ONE CALL DEEP, WHICH IS WHY THIS IS A FIXPOINT AND
|
||||
NOT A SINGLE PASS. `SignWatchdog.evaluate()` does not compare its two arguments
|
||||
itself; it hands them to `watchdog.disagrees()`, whose body is
|
||||
`commanded_w * against_w < 0 and abs(commanded_w) > min_w and
|
||||
abs(against_w) > min_w`. An intraprocedural scan sees `disagrees(a, b, ...)`
|
||||
become `disagrees(b, a, ...)`, calls that a difference, and reports NOTHING -
|
||||
which is exactly what the first version of this file did, on a tree where the
|
||||
answer was already known. So symmetric pairs discovered in one round are used
|
||||
to canonicalise CALL ARGUMENTS in the next, and the scan iterates until the set
|
||||
stops growing.
|
||||
|
||||
CANONICALISATION, and each entry is here because something in `app/` needs it:
|
||||
* `a * b`, `a + b` - operand order sorted (`disagrees`)
|
||||
* `x and y`, `x or y` - term order sorted (`disagrees`' conjunction)
|
||||
* `a == b`, `a != b` - operand order sorted
|
||||
* `min(a, b)`, `max(a, b)` - argument order sorted (`clamp_to_rating`)
|
||||
* a tuple/list/set LITERAL being iterated - element order sorted
|
||||
(`rail_ceiling_w`'s `for bound in (dev_min, dev_max)`)
|
||||
* arguments at a KNOWN SYMMETRIC POSITION PAIR of a called function - sorted
|
||||
(the fixpoint)
|
||||
|
||||
Equal ASTs after the rename is a SUFFICIENT condition for behavioural
|
||||
invariance, not a necessary one. This scan therefore UNDER-REPORTS and says so
|
||||
- see "WHAT THIS CANNOT SEE". That is the conservative direction: everything it
|
||||
names is genuinely in the family, and the gap is stated with its shape.
|
||||
|
||||
Run: `python3 tools/symmetric_message_scan.py [--quiet]` from goodwe_controller/.
|
||||
Exit status is 0 always; it is a census, not a gate.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import itertools
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
APP = pathlib.Path(__file__).resolve().parent.parent / "app"
|
||||
|
||||
# {function name: {(i, j) positional index pairs that are interchangeable}}
|
||||
_SYM: dict[str, set[tuple[int, int]]] = {}
|
||||
# ⚠️ AND THE SAME THING FOR KEYWORD-ONLY PARAMETERS, WHICH THE FIRST FIXPOINT
|
||||
# DROPPED. `freeze.rail_ceiling_w(max_w, *, dev_min=None, dev_max=None)` folds
|
||||
# its two device bounds with `for bound in (dev_min, dev_max): ceiling = min(
|
||||
# ceiling, abs(bound))` - perfectly symmetric - and `rail_unreachable()` prints
|
||||
# `"a range of {dev_min}...{dev_max} W"`. A scan that only followed POSITIONAL
|
||||
# arguments called that pair asymmetric and hid a genuine member of the family
|
||||
# behind an implementation detail of its own traversal.
|
||||
_SYM_KW: dict[str, set[tuple[str, str]]] = {}
|
||||
|
||||
|
||||
class _Canon(ast.NodeTransformer):
|
||||
"""Rename one pair, erase every string, and sort what commutes."""
|
||||
|
||||
COMMUTATIVE_BINOP = (ast.Mult, ast.Add, ast.BitAnd, ast.BitOr, ast.BitXor)
|
||||
COMMUTATIVE_CMP = (ast.Eq, ast.NotEq)
|
||||
COMMUTATIVE_CALL = ("min", "max")
|
||||
|
||||
def __init__(self, a: str = "", b: str = ""):
|
||||
self.a, self.b = a, b
|
||||
|
||||
# -- the rename ---------------------------------------------------------
|
||||
def visit_Name(self, node):
|
||||
self.generic_visit(node)
|
||||
if self.a and node.id == self.a:
|
||||
node.id = self.b
|
||||
elif self.b and node.id == self.b:
|
||||
node.id = self.a
|
||||
return node
|
||||
|
||||
def visit_arg(self, node):
|
||||
if self.a and node.arg == self.a:
|
||||
node.arg = self.b
|
||||
elif self.b and node.arg == self.b:
|
||||
node.arg = self.a
|
||||
return node
|
||||
|
||||
# -- the message is exactly what is ALLOWED to differ --------------------
|
||||
def visit_JoinedStr(self, node):
|
||||
return ast.Constant(value="<msg>")
|
||||
|
||||
def visit_Constant(self, node):
|
||||
if isinstance(node.value, str):
|
||||
return ast.Constant(value="<msg>")
|
||||
return node
|
||||
|
||||
# -- canonicalisation ---------------------------------------------------
|
||||
def visit_BinOp(self, node):
|
||||
self.generic_visit(node)
|
||||
if isinstance(node.op, self.COMMUTATIVE_BINOP):
|
||||
l, r = ast.dump(node.left), ast.dump(node.right)
|
||||
if l > r:
|
||||
node.left, node.right = node.right, node.left
|
||||
return node
|
||||
|
||||
def visit_BoolOp(self, node):
|
||||
self.generic_visit(node)
|
||||
node.values = sorted(node.values, key=ast.dump)
|
||||
return node
|
||||
|
||||
def visit_Compare(self, node):
|
||||
self.generic_visit(node)
|
||||
if (len(node.ops) == 1
|
||||
and isinstance(node.ops[0], self.COMMUTATIVE_CMP)):
|
||||
l, r = ast.dump(node.left), ast.dump(node.comparators[0])
|
||||
if l > r:
|
||||
node.left, node.comparators[0] = node.comparators[0], node.left
|
||||
return node
|
||||
|
||||
def visit_For(self, node):
|
||||
self.generic_visit(node)
|
||||
if isinstance(node.iter, (ast.Tuple, ast.List, ast.Set)):
|
||||
node.iter.elts = sorted(node.iter.elts, key=ast.dump)
|
||||
return node
|
||||
|
||||
def visit_Call(self, node):
|
||||
self.generic_visit(node)
|
||||
name = (node.func.id if isinstance(node.func, ast.Name)
|
||||
else node.func.attr if isinstance(node.func, ast.Attribute)
|
||||
else "")
|
||||
if name in self.COMMUTATIVE_CALL and len(node.args) == 2 \
|
||||
and not node.keywords:
|
||||
if ast.dump(node.args[0]) > ast.dump(node.args[1]):
|
||||
node.args = [node.args[1], node.args[0]]
|
||||
return node
|
||||
# the fixpoint: sort the arguments at a known symmetric position pair
|
||||
for (i, j) in sorted(_SYM.get(name, ())):
|
||||
if j < len(node.args):
|
||||
if ast.dump(node.args[i]) > ast.dump(node.args[j]):
|
||||
node.args[i], node.args[j] = node.args[j], node.args[i]
|
||||
# ...and at a known symmetric KEYWORD pair
|
||||
for (ka, kb) in sorted(_SYM_KW.get(name, ())):
|
||||
ia = next((k for k, w in enumerate(node.keywords)
|
||||
if w.arg == ka), None)
|
||||
ib = next((k for k, w in enumerate(node.keywords)
|
||||
if w.arg == kb), None)
|
||||
if ia is not None and ib is not None:
|
||||
va, vb = node.keywords[ia].value, node.keywords[ib].value
|
||||
if ast.dump(va) > ast.dump(vb):
|
||||
node.keywords[ia].value, node.keywords[ib].value = vb, va
|
||||
return node
|
||||
|
||||
|
||||
def _canon(src: str, a: str = "", b: str = "") -> str:
|
||||
return ast.dump(_Canon(a, b).visit(ast.parse(src)))
|
||||
|
||||
|
||||
# ⚠️ THE SINK IS HALF THE CLAIM, AND THE FIRST VERSION OF THIS SCAN OMITTED IT.
|
||||
# Erasing every f-string makes a function whose ONLY job is to FORMAT one look
|
||||
# perfectly symmetric - `store.unit_key(unit, key)` and
|
||||
# `mqtt.unit_state_key(slug, key)` both came back as "the swap changes nothing"
|
||||
# when in fact the string IS the return value: swapping there renames a STORE
|
||||
# KEY and an MQTT TOPIC, which is a behaviour change every ordinary check can
|
||||
# see, and MAINT-05 is the card about exactly that. The family this file is
|
||||
# looking for is narrower - the string goes to an OPERATOR and to nobody else -
|
||||
# so each sentence is classified by where it GOES.
|
||||
_LOG_SINKS = {"debug", "info", "warning", "warn", "error", "exception",
|
||||
"critical", "log", "log_event", "notify", "persistent_notify"}
|
||||
_MSG_ATTRS = {"reason", "message", "msg", "banner", "detail", "note",
|
||||
"explanation", "why", "text"}
|
||||
|
||||
|
||||
def _sink_of(fn, target, follow=True):
|
||||
"""RETURN | MESSAGE | OTHER - where this f-string node ends up.
|
||||
|
||||
`follow` is the ONE step of data flow below, and it is off on the recursive
|
||||
call so that two locals that interpolate each other cannot loop forever.
|
||||
"""
|
||||
for stmt in ast.walk(fn):
|
||||
for child in ast.iter_child_nodes(stmt):
|
||||
pass
|
||||
# a parent map is cheaper than repeated walks
|
||||
parent = {}
|
||||
for node in ast.walk(fn):
|
||||
for child in ast.iter_child_nodes(node):
|
||||
parent[id(child)] = node
|
||||
cur = target
|
||||
while id(cur) in parent:
|
||||
up = parent[id(cur)]
|
||||
if isinstance(up, ast.Return):
|
||||
return "RETURN"
|
||||
if isinstance(up, ast.Raise):
|
||||
return "MESSAGE"
|
||||
if isinstance(up, (ast.Assign, ast.AnnAssign, ast.AugAssign)):
|
||||
tgts = (up.targets if isinstance(up, ast.Assign) else [up.target])
|
||||
for t in tgts:
|
||||
if isinstance(t, ast.Attribute) and t.attr in _MSG_ATTRS:
|
||||
return "MESSAGE"
|
||||
if isinstance(t, ast.Name) and t.id in _MSG_ATTRS:
|
||||
return "MESSAGE"
|
||||
# ⚠️ ONE STEP OF DATA FLOW, AND IT IS NOT OPTIONAL. `main.py`'s
|
||||
# status page builds the device-range clause into a LOCAL - `_cap =
|
||||
# "; ".join(f"... accepts only {inv.dev_min}...{inv.dev_max} W" ...)`
|
||||
# - and only then concatenates it into the row's `text`. Stopping at
|
||||
# the assignment scores that OTHER and drops a member this card
|
||||
# MEASURED as a live survivor (0 bytes, 21/21 green). So: if the
|
||||
# name assigned here is interpolated into some other sentence in the
|
||||
# same function that IS a message, this one is a message too.
|
||||
if follow:
|
||||
for t in tgts:
|
||||
if isinstance(t, ast.Name) and _feeds_a_message(fn, t.id,
|
||||
up):
|
||||
return "MESSAGE"
|
||||
return "OTHER"
|
||||
if isinstance(up, ast.Call):
|
||||
name = (up.func.attr if isinstance(up.func, ast.Attribute)
|
||||
else up.func.id if isinstance(up.func, ast.Name) else "")
|
||||
if name in _LOG_SINKS:
|
||||
return "MESSAGE"
|
||||
if name == "append":
|
||||
return "MESSAGE"
|
||||
cur = up
|
||||
return "OTHER"
|
||||
|
||||
|
||||
def _feeds_a_message(fn, name, origin):
|
||||
"""Is `name` interpolated into some OTHER sentence that IS a message?"""
|
||||
for node in ast.walk(fn):
|
||||
if not isinstance(node, ast.JoinedStr) or node is origin:
|
||||
continue
|
||||
used = any(isinstance(x, ast.Name) and x.id == name
|
||||
for v in node.values if isinstance(v, ast.FormattedValue)
|
||||
for x in ast.walk(v.value))
|
||||
if used and _sink_of(fn, node, follow=False) == "MESSAGE":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _fstring_pairs(fn, params):
|
||||
"""{(a, b): [(lineno, sink), ...]} for every f-string mentioning both."""
|
||||
out: dict[tuple[str, str], list[tuple[int, str]]] = {}
|
||||
for node in ast.walk(fn):
|
||||
if not isinstance(node, ast.JoinedStr):
|
||||
continue
|
||||
used = set()
|
||||
for v in node.values:
|
||||
if isinstance(v, ast.FormattedValue):
|
||||
for x in ast.walk(v.value):
|
||||
if isinstance(x, ast.Name) and x.id in params:
|
||||
used.add(x.id)
|
||||
if len(used) < 2:
|
||||
continue
|
||||
sink = _sink_of(fn, node)
|
||||
for a, b in itertools.combinations(sorted(used), 2):
|
||||
out.setdefault((a, b), []).append((node.lineno, sink))
|
||||
return out
|
||||
|
||||
|
||||
def _functions():
|
||||
for path in sorted(APP.glob("*.py")):
|
||||
tree = ast.parse(path.read_text())
|
||||
for fn in ast.walk(tree):
|
||||
if isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
yield path, fn
|
||||
|
||||
|
||||
def _positional(fn):
|
||||
return [a.arg for a in fn.args.posonlyargs + fn.args.args
|
||||
if a.arg != "self"]
|
||||
|
||||
|
||||
def _all_params(fn):
|
||||
return _positional(fn) + [a.arg for a in fn.args.kwonlyargs]
|
||||
|
||||
|
||||
def _body_src(fn) -> str:
|
||||
"""The function's BODY alone.
|
||||
|
||||
⚠️ NOT `ast.unparse(fn)`. The signature lists the parameters IN ORDER, so
|
||||
renaming a pair inside it reorders `arguments.args` and every function in
|
||||
the repository compares unequal to itself - which is what the first version
|
||||
of this scan did, silently, and it reported an empty family on a tree whose
|
||||
answer was already known. The claim being tested is about what the body
|
||||
DECIDES, and the body is what is compared.
|
||||
"""
|
||||
return ast.unparse(ast.Module(body=list(fn.body), type_ignores=[]))
|
||||
|
||||
|
||||
def _symmetric(fn, a, b) -> bool:
|
||||
src = _body_src(fn)
|
||||
return _canon(src) == _canon(src, a, b)
|
||||
|
||||
|
||||
def build_fixpoint(max_rounds=6):
|
||||
"""Fill `_SYM` until it stops growing. Returns the number of rounds."""
|
||||
for round_no in range(1, max_rounds + 1):
|
||||
grew = False
|
||||
for _path, fn in _functions():
|
||||
pos = _positional(fn)
|
||||
kws = [a.arg for a in fn.args.kwonlyargs]
|
||||
if len(pos) + len(kws) < 2:
|
||||
continue
|
||||
found = set()
|
||||
for i, j in itertools.combinations(range(len(pos)), 2):
|
||||
if _symmetric(fn, pos[i], pos[j]):
|
||||
found.add((i, j))
|
||||
found_kw = set()
|
||||
for ka, kb in itertools.combinations(sorted(kws), 2):
|
||||
if _symmetric(fn, ka, kb):
|
||||
found_kw.add((ka, kb))
|
||||
if found - _SYM.get(fn.name, set()):
|
||||
_SYM.setdefault(fn.name, set()).update(found)
|
||||
grew = True
|
||||
if found_kw - _SYM_KW.get(fn.name, set()):
|
||||
_SYM_KW.setdefault(fn.name, set()).update(found_kw)
|
||||
grew = True
|
||||
if not grew:
|
||||
return round_no
|
||||
return max_rounds
|
||||
|
||||
|
||||
def scan():
|
||||
rows = []
|
||||
for path, fn in _functions():
|
||||
params = _all_params(fn)
|
||||
if len(params) < 2:
|
||||
continue
|
||||
kwonly = {a.arg for a in fn.args.kwonlyargs}
|
||||
for (a, b), hits in sorted(_fstring_pairs(fn, params).items()):
|
||||
rows.append(dict(
|
||||
module=path.name, func=fn.name, line=fn.lineno, a=a, b=b,
|
||||
msg_lines=sorted(set(hits)),
|
||||
sinks={s for _l, s in hits},
|
||||
symmetric=_symmetric(fn, a, b),
|
||||
positional=not (a in kwonly or b in kwonly)))
|
||||
return rows
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CENSUS B - THE SENTENCE THE CALLER BUILDS
|
||||
# =============================================================================
|
||||
# ⚠️ CENSUS A ABOVE ONLY SEES A CALLEE FORMATTING ITS OWN PARAMETERS, AND THAT
|
||||
# WAS GAP 2 OF ITS OWN "WHAT THIS CANNOT SEE" LIST. The other half of the family
|
||||
# never passes through a function boundary at all: an operator sentence built
|
||||
# where it is printed, interpolating two values that are INTERCHANGEABLE BY
|
||||
# SHAPE. `main.py`'s status page says
|
||||
#
|
||||
# f"{inv.setpoint_entity or inv.name} accepts only {inv.dev_min}...{inv.dev_max} W"
|
||||
#
|
||||
# and transposing those two costs ZERO BYTES, changes nothing any check can
|
||||
# observe, and tells the operator a range backwards. No predicate is involved,
|
||||
# so census A's symmetry test has nothing to test.
|
||||
#
|
||||
# THE TEST: inside one MESSAGE-sink f-string, take every pair of interpolated
|
||||
# expressions, blank every attribute NAME and every string CONSTANT, and compare.
|
||||
# If the blanked forms are EQUAL while the originals DIFFER, the two expressions
|
||||
# are the same shape with one leaf changed - `inv.dev_min` / `inv.dev_max`,
|
||||
# `site_dev_bound("dev_min")` / `site_dev_bound("dev_max")` - which is exactly
|
||||
# the pair a transposition swaps. Same direction as census A: it under-reports
|
||||
# (two values of the same KIND spelled differently are missed) rather than
|
||||
# inventing members.
|
||||
|
||||
|
||||
class _Blank(ast.NodeTransformer):
|
||||
def visit_Attribute(self, node):
|
||||
self.generic_visit(node)
|
||||
node.attr = "<leaf>"
|
||||
return node
|
||||
|
||||
def visit_Constant(self, node):
|
||||
if isinstance(node.value, str):
|
||||
return ast.Constant(value="<leaf>")
|
||||
return node
|
||||
|
||||
|
||||
def _blanked(expr):
|
||||
return ast.dump(_Blank().visit(ast.parse(ast.unparse(expr))))
|
||||
|
||||
|
||||
def scan_caller():
|
||||
rows = []
|
||||
for path in sorted(APP.glob("*.py")):
|
||||
tree = ast.parse(path.read_text())
|
||||
for fn in ast.walk(tree):
|
||||
if not isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
continue
|
||||
for node in ast.walk(fn):
|
||||
if not isinstance(node, ast.JoinedStr):
|
||||
continue
|
||||
# ⚠️ MESSAGE SINKS ONLY, AND THE FIRST VERSION OF THIS
|
||||
# CENSUS SAID "anything but a RETURN". That let in
|
||||
# `p1.py`'s `self.url = f"http://{self.host}:{self.port}…"` -
|
||||
# an f-string that is DATA, used to make a request, where a
|
||||
# transposition is an ordinary behavioural change every check
|
||||
# can see. Same distinction as census A's RETURN bucket: a
|
||||
# formatted string is only in this family when its only
|
||||
# consumer is a person.
|
||||
if _sink_of(fn, node) != "MESSAGE":
|
||||
continue
|
||||
exprs = [v.value for v in node.values
|
||||
if isinstance(v, ast.FormattedValue)]
|
||||
for a, b in itertools.combinations(exprs, 2):
|
||||
ua, ub = ast.unparse(a), ast.unparse(b)
|
||||
if ua == ub:
|
||||
continue
|
||||
if _blanked(a) == _blanked(b):
|
||||
rows.append((path.name, node.lineno, fn.name, ua, ub))
|
||||
# de-duplicate: the same sentence reached through several enclosing defs
|
||||
return sorted(set(rows), key=lambda r: (r[0], r[1], r[3]))
|
||||
|
||||
|
||||
# ⚠️ THE UNIT OF COVERAGE IS THE CALL SITE, NOT THE FUNCTION - which is the
|
||||
# whole lesson of SAFETY-38 round 3. `SignWatchdog.evaluate()` had its operand
|
||||
# order pinned and the defect still shipped, because the pin was on the call two
|
||||
# lines below the one a one-inverter roster actually reaches. So a family member
|
||||
# is reported WITH EVERY PLACE IT IS CALLED.
|
||||
def call_sites(name):
|
||||
out = []
|
||||
for path in sorted(APP.glob("*.py")):
|
||||
tree = ast.parse(path.read_text())
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
called = (node.func.attr if isinstance(node.func, ast.Attribute)
|
||||
else node.func.id if isinstance(node.func, ast.Name)
|
||||
else "")
|
||||
if called == name:
|
||||
# ⚠️ THE RECEIVER IS PRINTED, because matching on the bare
|
||||
# attribute name conflates methods: `evaluate` is the name of
|
||||
# SAFETY-02's, SAFETY-18's and SAFETY-31's watchdogs alike, and
|
||||
# a list of four lines with no receiver tells a reader nothing
|
||||
# about which detector they are looking at. `inv.sign_wd` and
|
||||
# `self.freeze_wd` are the disambiguation, and they are the
|
||||
# caller's own words.
|
||||
out.append(f"{path.name}:{node.lineno} "
|
||||
f"`{ast.unparse(node.func)}`")
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
quiet = "--quiet" in sys.argv
|
||||
rounds = build_fixpoint()
|
||||
rows = scan()
|
||||
fam = [r for r in rows if r["symmetric"] and "MESSAGE" in r["sinks"]
|
||||
and "RETURN" not in r["sinks"]]
|
||||
valued = [r for r in rows if r["symmetric"] and "RETURN" in r["sinks"]]
|
||||
print("SYMMETRIC-PREDICATE FAMILY - argument order load-bearing for a "
|
||||
"MESSAGE only")
|
||||
print("=" * 78)
|
||||
print(f"fixpoint reached after {rounds} round(s); "
|
||||
f"{sum(len(v) for v in _SYM.values())} interchangeable positional "
|
||||
f"and {sum(len(v) for v in _SYM_KW.values())} keyword pair(s) "
|
||||
f"across {len(set(_SYM) | set(_SYM_KW))} function(s) in app/")
|
||||
print(f"functions formatting >=2 of their own parameters into one "
|
||||
f"sentence: {len(rows)} pair(s)")
|
||||
print(f"of those, pairs whose SWAP LEAVES EVERY DECISION IDENTICAL: "
|
||||
f"{len(fam) + len(valued)}")
|
||||
print(f" ...of which the sentence is RETURNED (so the swap is a VALUE "
|
||||
f"change an ordinary check can see): {len(valued)}")
|
||||
print(f" ...and the sentence goes ONLY TO AN OPERATOR - THE FAMILY: "
|
||||
f"{len(fam)}")
|
||||
print()
|
||||
print("THE FAMILY - a swap here is invisible to every behavioural check "
|
||||
"BY CONSTRUCTION:")
|
||||
for r in sorted(fam, key=lambda r: (r["module"], r["line"])):
|
||||
kind = ("POSITIONAL - a transposition at a call site costs ZERO BYTES"
|
||||
if r["positional"] else
|
||||
"KEYWORD-ONLY at the definition - a transposition has to be "
|
||||
"written out by name, which is not free and not silent")
|
||||
print(f" {r['module']}:{r['line']} {r['func']}() pair "
|
||||
f"({r['a']}, {r['b']})")
|
||||
print(f" sentence at line(s): "
|
||||
f"{', '.join(f'{l} -> {s}' for l, s in r['msg_lines'])}")
|
||||
print(f" {kind}")
|
||||
sites = call_sites(r["func"])
|
||||
print(f" CALL SITES ({len(sites)}) - each one is a separate pin, "
|
||||
f"and the receiver says which detector it is:")
|
||||
for site in sites or ["(none inside app/)"]:
|
||||
print(f" {site}")
|
||||
print()
|
||||
print("SYMMETRIC, BUT THE SENTENCE IS THE RETURN VALUE - READ THIS BUCKET "
|
||||
"BY HAND.")
|
||||
print("A returned string is sometimes a KEY (a swap renames a store key or "
|
||||
"an MQTT topic,")
|
||||
print("which is behaviour every ordinary check can see - MAINT-05 is the "
|
||||
"card about it)")
|
||||
print("and sometimes a SENTENCE (a swap is message-only after all, and it "
|
||||
"IS this family):")
|
||||
for r in sorted(valued, key=lambda r: (r["module"], r["line"])):
|
||||
print(f" {r['module']}:{r['line']} {r['func']}() pair "
|
||||
f"({r['a']}, {r['b']})")
|
||||
print()
|
||||
print("=" * 78)
|
||||
print("CENSUS B - SENTENCES THE CALLER BUILDS, interpolating two values of "
|
||||
"the SAME SHAPE")
|
||||
print("A transposition inside one of these is ZERO BYTES and no predicate "
|
||||
"is involved at")
|
||||
print("all, so nothing behavioural can ever see it. Same rule: assert the "
|
||||
"ORDERED PAIR.")
|
||||
cb = scan_caller()
|
||||
print(f"candidates: {len(cb)}")
|
||||
for mod, line, fn, ua, ub in cb:
|
||||
print(f" {mod}:{line} in {fn}()")
|
||||
print(f" {ua} <-> {ub}")
|
||||
print()
|
||||
print("WHAT THIS CANNOT SEE - the shape of each gap, named rather than "
|
||||
"rounded off:")
|
||||
print(" 1. SEMANTIC symmetry no canonicaliser above spells. The list of "
|
||||
"commutative")
|
||||
print(" forms is finite and hand-written; a function symmetric only "
|
||||
"through a form")
|
||||
print(" not on it is REJECTED and never appears. This under-reports.")
|
||||
print(" 2. In CENSUS B, two interpolated values of the same KIND that "
|
||||
"are not the same")
|
||||
print(" SHAPE - `a.lo` beside `hi`, or one of them behind a local "
|
||||
"variable. Only")
|
||||
print(" one-leaf-apart expressions are paired. `%s`-style logging "
|
||||
"(`_LOG.error('%s")
|
||||
print(" ... %s', a, b)`) and non-f-string payloads are not scanned at "
|
||||
"all.")
|
||||
print(" 3. Anything outside `app/` - `sim/`, `tools/`, the firmware, the "
|
||||
"witness.")
|
||||
print(" 4. WHETHER each call site is pinned. The sites are listed above; "
|
||||
"whether a")
|
||||
print(" check asserts the ORDERED PAIR at each one is a fact about "
|
||||
"`test_*.py` and is")
|
||||
print(" recorded in DECISIONS/SAFETY-38-site-standing-pin.md section "
|
||||
"7. Call sites")
|
||||
print(" outside `app/` are not listed either, and the site list matches "
|
||||
"on the bare")
|
||||
print(" METHOD NAME - three different watchdogs are all called "
|
||||
"`evaluate`, so the")
|
||||
print(" receiver is printed beside each one and a reader has to pick.")
|
||||
print(" 5. ⚠️ IT CAN ALSO OVER-REPORT, IN ONE EXACT WAY. The sentence is "
|
||||
"ERASED before")
|
||||
print(" the comparison, so a swap that would make the FORMATTING "
|
||||
"ITSELF fail - a")
|
||||
print(" `DAY_NAMES[start]` where `start` is 1320 - is scored as "
|
||||
"'changes no decision'.")
|
||||
print(" `_raise_gap`'s (start, weekday) and (end, weekday) pairs are "
|
||||
"that: mixed")
|
||||
print(" types, and a real transposition there raises IndexError rather "
|
||||
"than lying")
|
||||
print(" quietly. The hazard is a SAME-TYPED pair; read the list with "
|
||||
"that in hand.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user