Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97b59438dc | ||
|
|
695da4791e | ||
|
|
2cd094a0fe | ||
|
|
0f0e767f42 | ||
|
|
bbc903b2b4 | ||
|
|
d8e02ad3f8 | ||
|
|
4482cd8201 | ||
|
|
3527c8a04c | ||
|
|
8790c4d38f | ||
|
|
1d393ced22 | ||
|
|
3516947b5e |
@@ -1,5 +1,34 @@
|
||||
# Changelog
|
||||
|
||||
## 0.7.0
|
||||
|
||||
⚠️ **SCHEMA CHANGE, AND IT IS WHY THIS IS A MINOR BUMP RATHER THAN A PATCH.**
|
||||
[[SAFETY-32]] adds `bms_soc_floor: int(0,50)` as a **required** per-entry key on
|
||||
the `inverters` roster - no trailing `?`. A config that declares a roster and
|
||||
omits it on any entry is REFUSED at startup. An install running `inverters: []`,
|
||||
which is every single-inverter install including the reference one, is
|
||||
unaffected: the flat `bms_soc_floor` still describes the one pack.
|
||||
|
||||
CFG-04 recorded the rule this follows - the Supervisor keys the built image by
|
||||
`version:`, so a schema change without a bump is a schema nobody reads.
|
||||
|
||||
Contents, in merge order since 0.6.0 (`2d025ee`):
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `3934ad7` | MAINT-05 - one recalibration per pack, migration graded against the roster |
|
||||
| `67e649a` | SPIKE-04a - inverter B and pack B recorded, from datasheet and serial |
|
||||
| `3993f9f` | SAFETY-32 - per-pack `bms_soc_floor`, rule 8 per unit, package-wide roster pin |
|
||||
| `f72c48d` | UX-01 - add-on configuration in English and Dutch |
|
||||
| `86eaef2` | TEL-07 - a second MQTT device for inverter B; A's identity byte-identical |
|
||||
| `4482cd8` | SAFETY-41 - the clamp alarm stops narrating normal anti-windup |
|
||||
|
||||
⚠️ **0.6.0 WAS NEVER SECTIONED.** Its content sits below this heading rather than
|
||||
under one of its own, because the bump shipped without cutting the section. That
|
||||
is a defect in this file, not in the release: the deployed 0.6.0 is `2d025ee` and
|
||||
the table above is what is new since it. Splitting it retroactively would mean
|
||||
guessing a boundary; saying so is more useful than a guess.
|
||||
|
||||
## Unreleased
|
||||
|
||||
**`site_standing` — the one quantity BOTH site guards are asked about — is now
|
||||
@@ -82,6 +111,56 @@ shipped `sum` is correct and must not be "fixed".**
|
||||
unit's share and never the site target. A site asking for −500 W while −250 W
|
||||
stands would then take a **latching site-wide false trip** under its own
|
||||
300 W gate. Closed by part 9a's device-clamped counterfactual.
|
||||
* **⚠️ AND THE SAME ZERO-BYTE DEFECT ONE CALL SITE OVER — THE ONE A SHIPPED
|
||||
INSTALL CAN ACTUALLY SEE.** The ordered pair went onto `main.py:1620`, the
|
||||
**site** detector. That detector is armed only at N≥2, and part 9c asserts it
|
||||
is **absent at a roster of one** — so on every install this add-on has, the
|
||||
**per-unit** banner is the only sign-disagreement sentence that can ever be
|
||||
printed, and its call at **`main.py:1560`** was still unpinned. The identical
|
||||
swap there is **0 bytes** and left the tree **21/21 green**, and nothing
|
||||
behavioural could ever have caught it: `disagrees()` is **symmetric** in those
|
||||
two arguments (`commanded_w * against_w < 0`, `abs()` gate on both), so the
|
||||
mutant takes **identical actions on every input**. What moves is the sentence
|
||||
— measured, **9 emitted banner lines invert in 3 distinct sentences**,
|
||||
including the one persisted to `sign_reason` and read back after a restart:
|
||||
*"commanded +1000 W against its own measured power −1000 W"* becomes
|
||||
*"commanded −1000 W against its own measured power +1000 W"*. Closed by the
|
||||
ordered pair on **both** per-unit banners — the N≥2 `its own measured power`
|
||||
one and the **N=1** `a meter reading` one — with both operands read off the
|
||||
**observed cycle** rather than off the arguments the detector was handed, plus
|
||||
a precondition on each that the two operands really are different and opposite
|
||||
in sign. **Red on four named checks, green in the other twenty files. No
|
||||
`app/` change.**
|
||||
* **⭐ AND THE FAMILY IS NOW ENUMERATED BY MACHINE, because it had been
|
||||
enumerated by hand twice and was short both times.**
|
||||
`tools/symmetric_message_scan.py` finds every call whose argument order is
|
||||
load-bearing for a **message** rather than for a decision: it renames each
|
||||
parameter pair, erases every formatted string, canonicalises the commutative
|
||||
operators and iterates to a **fixpoint** (the symmetry is usually one call
|
||||
deep — `evaluate()` is symmetric only *through* `disagrees()`), then
|
||||
classifies each sentence by where it goes. A second census covers sentences
|
||||
the **caller** builds. It found **one open survivor this card does not close
|
||||
and now discloses** — `rail_unreachable()`'s `dev_min`/`dev_max` at
|
||||
`main.py:3208`, **0 bytes, 21/21 green**, a status-page row — and **twelve**
|
||||
unpinned caller-built sentences including SAFETY-31's `circ_reason`, which is
|
||||
the same shape and is also persisted. The list, the pinned status of every
|
||||
member and the instrument's own blind spots are in
|
||||
`DECISIONS/SAFETY-38-site-standing-pin.md` §7.
|
||||
* **⚠️ AND A LEDGER SENTENCE THIS CARD ITSELF GOT WRONG, CORRECTED WITH THE
|
||||
MEASUREMENT.** An earlier revision said *"before part 9 existed, **three** of
|
||||
these left the whole suite green — M1, M2 and M3 — and the remaining seven
|
||||
were caught by SAFETY-30's own N≥2 checks"*. **Eight** did. Re-run one at a
|
||||
time on clean `release/1.0` @ `bbc903b`, fresh copy each, bytecode purged,
|
||||
byte delta and `main.py` fingerprint printed, **full 21-file suite** every
|
||||
time: M1, M2, M3, M4, M7, M8, M9 and M10 are all **21/21 PASS**; only M5 and
|
||||
M6 go red. It is the same species of error this round exists to fix — **a
|
||||
survivor recorded as a kill** — and the file contradicted itself three times
|
||||
while saying it. **And the two that ARE caught make the card's case:** M5
|
||||
(`sum(abs …)`) and M6 (`-sum`) are the only two that reverse the **sign**.
|
||||
Every other entry changes only the **magnitude**, and the old `_sign_site`
|
||||
fixture's ~−2500 W shares put nine different magnitudes on the **same side of
|
||||
the same 320 W gate**. The pre-existing coverage could see a sign and nothing
|
||||
else.
|
||||
* **13 mutations — 11 of that line and its call site, plus 2 zero-byte edits to
|
||||
the arbiter claim wiring — each on a fresh full-repo copy mounted `:ro`,
|
||||
bytecode purged by `tools/mutation_run.sh`, the edit re-read from disk and the
|
||||
|
||||
@@ -231,15 +231,70 @@ fingerprint, and verifies no cache appears during the run). The mutated line was
|
||||
| M8 | `sum(standing.values()) / 2.0` | +6 | **RED** |
|
||||
| M9 | `sum(standing.values()) * 2.0` | +6 | **RED** |
|
||||
| M10 | `sorted(standing.values())[-1]` — largest signed | +7 | **RED** |
|
||||
| **M11** | **argument SWAP** at the call site: `evaluate(site_standing, self.grid, …)` → `evaluate(self.grid, site_standing, …)` (main.py:1620) | **0** | **was a SURVIVOR in round 1 — now RED**, see §4a |
|
||||
| **M11** | **argument SWAP** at the SITE call site: `evaluate(site_standing, self.grid, …)` → `evaluate(self.grid, site_standing, …)` (main.py:1620) | **0** | **was a SURVIVOR in round 1 — now RED**, see §4a |
|
||||
| **M12** | **argument SWAP** at the PER-UNIT call site: `evaluate(stood, against, …)` → `evaluate(against, stood, …)` (**main.py:1560**) | **0** | **was a SURVIVOR in round 2 — now RED**, see §4c |
|
||||
|
||||
**Every one of the ten is red in `test_watchdog.py`.** Re-measured in round 2
|
||||
on `release/1.0` @ `86eaef2` (where `app/main.py` is 234 542 bytes, 2 022 more
|
||||
than the `f72c48d` the round-1 figures were taken on): all ten still red, at the
|
||||
byte deltas in the table, each on a fresh copy with the edit re-read from disk.
|
||||
Before part 9 existed, **three** of these left the whole suite green — M1, M2
|
||||
**and M3** — and the remaining seven were caught by SAFETY-30's own N≥2 checks,
|
||||
none of which grade the *quantity*.
|
||||
Adding assertions cannot turn a red green, so those verdicts carry forward to
|
||||
the round-3 tree unchanged.
|
||||
|
||||
### ⚠️ ROUND 2 SAID THREE OF THESE SURVIVED. **EIGHT DID.**
|
||||
|
||||
Round 2 wrote: *"Before part 9 existed, three of these left the whole suite
|
||||
green — M1, M2 and M3 — and the remaining seven were caught by SAFETY-30's own
|
||||
N≥2 checks."* Round 1's wording was vaguer and harmless; naming three made it
|
||||
**specific and wrong**, and it is the same species of error the round was
|
||||
written to fix — **a survivor recorded as a kill**. This file contradicted it
|
||||
three times on its own pages: the ledger row calls M7 *"a SURVIVOR — now RED"*,
|
||||
§5 says M7 survived the whole suite, and `CHANGELOG.md` says so too.
|
||||
|
||||
**Re-measured in round 3, one mutation at a time, on clean `release/1.0` @
|
||||
`bbc903b`** — a fresh copy each, bytecode purged (`0` remaining, printed), the
|
||||
line re-read from disk, byte delta **and** `app/main.py` fingerprint printed,
|
||||
mounted `:ro`, and the **FULL 21-FILE SUITE** run each time rather than
|
||||
`test_watchdog.py` alone:
|
||||
|
||||
| # | Δ bytes | fingerprint of `app/main.py` | suite before part 9 |
|
||||
|---|---|---|---|
|
||||
| M1 | +24 | `362374cfb6486378` | **21/21 PASS — SURVIVOR** |
|
||||
| M2 | +9 | `86573e7494f1c933` | **21/21 PASS — SURVIVOR** |
|
||||
| M3 | +4 | `59fa8815e6fda4cf` | **21/21 PASS — SURVIVOR** |
|
||||
| M4 | +9 | `43d852be3037f861` | **21/21 PASS — SURVIVOR** |
|
||||
| M5 | +16 | `58d26de82af29a1a` | **RED** — `test_watchdog.py`, 3 checks |
|
||||
| M6 | +1 | `407ddb5f375cc217` | **RED** — `test_watchdog.py`, 3 checks |
|
||||
| M7 | **−11** | `2a635a4eca0a790d` | **21/21 PASS — SURVIVOR** |
|
||||
| M8 | +6 | `b21eed89ff4c9240` | **21/21 PASS — SURVIVOR** |
|
||||
| M9 | +6 | `0973d3caf680db65` | **21/21 PASS — SURVIVOR** |
|
||||
| M10 | +7 | `0e7b7a4fed7787d0` | **21/21 PASS — SURVIVOR** |
|
||||
|
||||
**So: EIGHT of the ten left the whole suite green, and TWO were caught** — not
|
||||
three and seven, and not the four and six a reviewer proposed either. The
|
||||
correct sentence is: *before part 9 existed, eight of these ten left the whole
|
||||
21-file suite green, and the only two SAFETY-30's own N≥2 checks caught were M5
|
||||
and M6.*
|
||||
|
||||
**⚠️ AND THE TWO THAT WERE CAUGHT ARE CAUGHT FOR A REASON THAT MAKES THE CARD'S
|
||||
CASE RATHER THAN WEAKENING IT.** M5 (`sum(abs(v) …)`) and M6 (`-sum(…)`) are
|
||||
the only two entries in the table that **reverse the SIGN** of `site_standing`.
|
||||
Every other entry changes only its **magnitude** — and `_sign_site`, the suite's
|
||||
only N≥2 site fixture before this card, runs shares of about **−2500 W each**
|
||||
against a **320 W** gate. `sum`, the mean, `max(key=abs)`, `min(key=abs)`,
|
||||
`sorted(...)[-1]`, `[0]`, `self.target`, half the sum and twice the sum all land
|
||||
on **−2500, −2500, −2500, −2500, −2500, −2500, −2500, −1250 and −5000 W**: nine
|
||||
different numbers, all on the **same side of the same threshold**. The three
|
||||
checks that fired on M5 and M6 grade *"the site trips and no unit does"*, the
|
||||
claim it trips through, and where the latch is written — none of them grades the
|
||||
**quantity**, and the sign flip reaches them only because it stops the trip
|
||||
happening at all.
|
||||
|
||||
That is the whole thesis of this card measured rather than argued: **the
|
||||
pre-existing coverage could see a sign, and nothing else.** Part 9's fixture —
|
||||
per-unit shares each **below** the gate whose **sum** clears it — is the only
|
||||
arrangement in which the magnitude is load-bearing, and it is what takes the
|
||||
other eight red.
|
||||
|
||||
⚠️ **ONE FALSE RED, AND IT IS [[CI-06]]'s.** In the batch run M10 also reported
|
||||
`FAIL test_peakguard.py`. Re-run in isolation **three times** and once more
|
||||
@@ -250,6 +305,17 @@ and that **no claim in this card rests on it**. `test_peakguard.py` is the file
|
||||
CI-06 records as flaky, and this is the behaviour it records. Believe no red
|
||||
from it without three isolated re-runs.
|
||||
|
||||
⚠️ **ROUND 3 CAN NAME ONE, AND IT IS THE SAME FILE.** Round 3's own batch — the
|
||||
ten full-suite runs above, plus §7's sibling swaps — produced **no** red from
|
||||
`test_peakguard.py` on M10 or on any other ledger entry. It produced exactly one
|
||||
elsewhere, on a **`static_tariff.py`-only** edit (§7b, A4), and this time the
|
||||
failing check *was* captured: *"one cycle of the intent form is exactly the
|
||||
delivered form plus the command standing on the wire"* — which is CI-06's
|
||||
**mechanism 1 by name**, the cross-time projection. `test_peakguard.py` then
|
||||
passed **four for four** in isolation on that same tree. That is the first time
|
||||
this card has been able to put a name to one of these reds, and it is the name
|
||||
CI-06 predicts.
|
||||
|
||||
⚠️ **M3 IS NOT "FOR COMPLETENESS", AND ROUND 1 SAID IT WAS.** Round 1 recorded
|
||||
M3 as already closed structurally by SAFETY-32 and disclaimed it. §1 above has
|
||||
the correction and the mechanism: `[0]` **survives** a full green 21-file suite
|
||||
@@ -321,6 +387,73 @@ coverage rather than merely inheriting it. **W2 is caught entirely by
|
||||
pre-existing checks** — SAFETY-38 adds nothing to circulation, and does not
|
||||
claim to.
|
||||
|
||||
### 4c. ⚠️ The SAME zero-byte defect one call site over — and it is the one the shipped install can see
|
||||
|
||||
Round 2 closed §4a by asserting the ordered pair on the **site** banner at
|
||||
`main.py:1620`. It did not touch `main.py:1560`, the **per-unit** call, and the
|
||||
identical swap there —
|
||||
|
||||
```python
|
||||
if inv.sign_wd.evaluate(stood, against, self.following, now) # shipped
|
||||
if inv.sign_wd.evaluate(against, stood, self.following, now) # M12
|
||||
```
|
||||
|
||||
— left the round-2 tree **21/21 green**. Measured: `app/main.py` 234 542 bytes
|
||||
before and after, **Δ 0 bytes**, fingerprint `61aa413f…` → `e57e6026…`, bytecode
|
||||
purged, tree mounted `:ro`, the line re-read from disk.
|
||||
|
||||
**Nothing behavioural can catch it, and that is structural rather than
|
||||
unlucky.** `watchdog.disagrees()` is *symmetric* in its first two arguments —
|
||||
`commanded_w * against_w < 0`, with an `abs()` gate on **both** — so the swap
|
||||
cannot change a single trip decision on any input. What it changes is the
|
||||
**sentence**. Measured by diffing the suite's own printed banners between the
|
||||
round-3 tree and the same tree carrying only M12: **9 emitted banner lines
|
||||
invert, in 3 distinct sentences.**
|
||||
|
||||
```
|
||||
shipped: SIGN_DISAGREEMENT: commanded -2000 W against a meter reading +2000 W …
|
||||
M12: SIGN_DISAGREEMENT: commanded +2000 W against a meter reading -2000 W …
|
||||
|
||||
shipped: … commanded +1000 W against its own measured power -1000 W …
|
||||
M12: … commanded -1000 W against its own measured power +1000 W …
|
||||
|
||||
shipped: … commanded -2000 W against a meter reading +4000 W … (the N=1 arm)
|
||||
M12: … commanded +4000 W against a meter reading -2000 W …
|
||||
```
|
||||
|
||||
**⚠️ AND IT IS WORSE THAN THE ONE ROUND 2 CLOSED.** Part 9c asserts the SITE
|
||||
detector is **not armed at a roster of one**, and inverter B does not physically
|
||||
exist. So on the shipped install the **per-unit banner is the only
|
||||
sign-disagreement sentence that can ever be printed** — in the action log, in
|
||||
`_LOG.error`, in `status_payload()`'s headline banner, and in `sign_reason`,
|
||||
which is persisted and read back after the restart the latch is designed to
|
||||
survive. Round 2 pinned the N≥2-only call site and left the shipping one open.
|
||||
|
||||
**Closed by the ORDERED PAIR on both per-unit banners**, with both operands read
|
||||
off the **observed cycle** — `inv.last_write` and `inv.batt` off the objects the
|
||||
plant moved, never off the arguments the detector was handed, which would be
|
||||
comparing the swap against itself:
|
||||
|
||||
* `test_watchdog.py` **part 3**, the N≥2 per-unit trip:
|
||||
`f"commanded {stood:+.0f} W against its own measured power {meas:+.0f} W"`,
|
||||
plus the persisted `unit/inverter-b/sign_reason` copy, plus a **precondition**
|
||||
that the two operands are different numbers of opposite sign — without which
|
||||
a transposition would produce the same string and the check would be
|
||||
decoration;
|
||||
* `test_watchdog.py` **part 7**, the **N=1** controller-driven trip:
|
||||
`f"commanded {stood:+.0f} W against a meter reading {grid:+.0f} W"`, plus the
|
||||
namespaced key **and** the flat rollback mirror, plus the same precondition.
|
||||
|
||||
**Measured after the fix: M12 is RED on four named checks in
|
||||
`test_watchdog.py` and GREEN in the other twenty files** — one file, four
|
||||
checks. M11 re-measured on the same tree: still red.
|
||||
|
||||
**⚠️ Neither §6a nor MUTATION.md disclosed it.** §6a's "what was NOT closed"
|
||||
enumerated `circ_wd`, the eight other `CLAIM_SCOPE` entries, `actuator_of()`'s
|
||||
body and the `n_units > 1` guard — not this. MUTATION.md's own closing rule, in
|
||||
the file round 2 edited, is *"a variant is not covered because a near-miss of it
|
||||
is."* §7 is the enumeration that would have caught both instances at once.
|
||||
|
||||
## 5. The survivor this card went looking for, and closed
|
||||
|
||||
M7 is not one of the card's three. It was found by asking the question the
|
||||
@@ -406,6 +539,184 @@ disk, byte delta printed):**
|
||||
re-verified on `86eaef2` for **M3** and **M11** (both full 21-file runs) and is
|
||||
otherwise carried forward from round 1's measurement on `f72c48d`. It is a
|
||||
weaker statement than round 1 made and is written down as such.
|
||||
⚠️ **Round 3 closed the other half of this**, but only in the *before*
|
||||
direction: all ten were re-run on clean `release/1.0` @ `bbc903b` under the
|
||||
**full 21-file suite** to establish which of them the pre-existing coverage
|
||||
could see — see §4's ⚠️ paragraph, and the answer was **eight survivors, not
|
||||
three**. The *after* direction — "each of the ten is red in
|
||||
`test_watchdog.py` and green in the other twenty **on the round-3 tree**" —
|
||||
is still measured end-to-end only for **M3, M11 and M12**. For the other
|
||||
seven, "red" carries forward from round 2 (which adding assertions cannot
|
||||
undo) and "green elsewhere" carries forward from round 1.
|
||||
* **`test_peakguard.py`'s CI-06 flake is not fixed by this card** and is not
|
||||
this card's subject. It is *characterised* here (five isolated runs on the M3
|
||||
tree, all PASS) only far enough to overturn the false kill it produced.
|
||||
|
||||
### 6b. Round 3: what was closed, and what is still open
|
||||
|
||||
**Closed in round 3:**
|
||||
|
||||
1. **M12**, the zero-byte argument swap at the **per-unit** call site
|
||||
`main.py:1560` — §4c. It was a survivor of the round-2 tree at **21/21
|
||||
green**, and it is the **only** sign-disagreement banner a shipped
|
||||
one-inverter install can print. Red on four named checks, green in the other
|
||||
twenty files.
|
||||
2. **The ledger arithmetic in §4**, which round 2 made specific and wrong. See
|
||||
§4's own ⚠️ paragraph: re-measured mutation by mutation on clean
|
||||
`release/1.0` @ `bbc903b`, full 21-file suite each time.
|
||||
3. **The family itself is now enumerated by machine** —
|
||||
`tools/symmetric_message_scan.py`, §7 — rather than by hand for a third time.
|
||||
|
||||
**Open, named, and NOT claimed:**
|
||||
|
||||
* **A3 and B1** — the only two status-page rows in which the freeze detector
|
||||
ever names a device range: the **NOT COVERED** row and the **armed** row's
|
||||
"below max_w because …" clause. `rail_unreachable()`'s `dev_min`/`dev_max` at `main.py:3208` and the
|
||||
literal `f"only {inv.dev_min}...{inv.dev_max} W"` at `main.py:3231`. Both
|
||||
measured: **0 bytes each, 21/21 green each.** Real, open members of the
|
||||
family. They are **status-page rows rather than latching alarms**, and closing
|
||||
them wants a fixture with a device whose two bounds are **asymmetric** — every
|
||||
fixture in this repository uses `±d`, so the transposition is currently
|
||||
invisible by construction — which belongs to `test_freeze.py`'s card, not to
|
||||
this one.
|
||||
* **Twelve census-B sites** (§7c, B2–B12) determined unpinned at the **source
|
||||
level** — the distinguishing literal appears in no `test_*.py` — and **not**
|
||||
mutated. Weaker evidence than A1–A3, and written down as such.
|
||||
* **B6**, SAFETY-31's `circ_reason`, is the same shape as the defect this card
|
||||
closed for SAFETY-02/30 and is *also* persisted and re-read after a restart.
|
||||
§6a already declines to touch `circ_wd`; §7d says what that costs.
|
||||
* Everything §6a lists stays open: `circ_wd`'s own thresholds, the eight other
|
||||
`CLAIM_SCOPE` entries, `actuator_of()`'s body, and the `n_units > 1` guard.
|
||||
|
||||
## 7. The symmetric-predicate family — every call whose argument order is load-bearing for a MESSAGE
|
||||
|
||||
**Why this section exists, and it is the most useful thing in the card.** Round 2
|
||||
pinned the operand order of `SignWatchdog.evaluate()` at `main.py:1620`. Round 3
|
||||
found the **identical zero-byte defect at `main.py:1560`**, one call site over —
|
||||
and that one is worse, because `test_watchdog.py` part 9c asserts the site
|
||||
detector is **never armed at
|
||||
a roster of one**, so on the shipped install the per-unit banner is the **only**
|
||||
sign-disagreement sentence that can ever be printed. Twice in one card the
|
||||
enumeration was written by hand and twice it was short. MUTATION.md habit 12: *a
|
||||
hand-written scope is the same defect as a hand-written fixture.* So the family
|
||||
is now **computed**, by `tools/symmetric_message_scan.py`.
|
||||
|
||||
### 7a. What makes this family invisible, and why it is not a fixture problem
|
||||
|
||||
Every other defect in this document is a fixture problem: *no fixture happens to
|
||||
separate the mutant from the shipped code.* Build the right fixture and it goes
|
||||
red. This one is not that.
|
||||
|
||||
Where a callee is **symmetric** in two arguments, a swap at a call site cannot
|
||||
change **any** action on **any** input. `watchdog.disagrees()` is
|
||||
`commanded_w * against_w < 0` with an `abs()` gate on **both** sides: the
|
||||
product commutes and the two gates are the same gate. There is nothing for a
|
||||
behavioural check to observe, ever — **not "no fixture separates them" but no
|
||||
fixture *can*.** The only surface that moves is the **sentence**, and the only
|
||||
check that can see it is one asserting the **ordered pair** (MUTATION.md habit
|
||||
14) at **that** call site.
|
||||
|
||||
Two consequences the earlier rounds got wrong:
|
||||
|
||||
* **The unit of coverage is the CALL SITE, not the function.** `evaluate()`'s
|
||||
operand order was pinned and the defect still shipped, because the pin was on
|
||||
the *other* call.
|
||||
* **A behavioural suite is not weak evidence here, it is NO evidence.** "21/21
|
||||
green" is what this mutation is *guaranteed* to produce. Measured, twice: M11
|
||||
at `main.py:1620` on the round-1 tree, and M12 at `main.py:1560` on the
|
||||
round-2 tree.
|
||||
|
||||
### 7b. Census A — the callee formats its own parameters
|
||||
|
||||
`tools/symmetric_message_scan.py` renames each parameter pair, erases every
|
||||
formatted string, canonicalises the commutative operators (`*`, `+`, `and`,
|
||||
`or`, `==`, `min`/`max`, iterated tuple literals) and compares the **bodies**;
|
||||
it iterates to a **fixpoint**, because the symmetry is usually one call deep —
|
||||
`evaluate()` is symmetric only *through* `disagrees()`, and a single pass
|
||||
reports an empty family on a tree whose answer is already known. It then
|
||||
classifies each sentence by **where it goes**.
|
||||
|
||||
Fixpoint on this tree: **3 rounds, 17 interchangeable positional and 5 keyword
|
||||
pairs across 14 functions**. 45 parameter pairs reach a sentence; **8** are
|
||||
symmetric — **5** whose sentence goes only to an operator, and **3** whose
|
||||
sentence is the **return value** and therefore has to be classified by hand,
|
||||
because a returned string is sometimes a KEY and sometimes a SENTENCE.
|
||||
|
||||
| # | scan row | pair | called at | swap costs | reaches an operator via | pinned? |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **A1** | `watchdog.py:613` `SignWatchdog.evaluate()` | `(commanded_w, against_w)` | **`main.py:1560`** — per-unit, **and the N=1 detector** | **0 B** | `sign_reason` (persisted), the action log, `_LOG.error`, `status_payload()`'s headline banner | **YES — round 3.** `test_watchdog.py` part 3 (`its own measured power`) and part 7 (`a meter reading`), on the contiguous literal with both operands read off the observed cycle. **Measured: 0 B, red on 4 named checks, green in the other 20 files.** |
|
||||
| **A2** | same row, second call site | `(commanded_w, against_w)` | **`main.py:1620`** — site | **0 B** | `site_sign_reason`, the action log, the status page | **YES — round 2**, part 9a. Re-measured on the round-3 tree: still red. |
|
||||
| **A3** | `freeze.py:405` `FreezeWatchdog.evaluate()` and `freeze.py:311` `rail_unreachable()` — both symmetric through `rail_ceiling_w()`'s `for bound in (dev_min, dev_max)` | `(dev_min, dev_max)` | **`main.py:3208`** (and 1928, 2755, 3196, 3219) | **0 B** | the status page's `freeze detector: NOT COVERED — the setpoint device reports a range of …` row | **NO.** Measured: swapped at `main.py:3208-3209`, **0 bytes**, `main.py` fingerprint `61aa413f…` → `2305b560…`, **21/21 green**. Disclosed in §7d, not closed. ⚠️ `rail_unreachable()` is the row the scan puts in its **RETURN** bucket: its returned string is a **sentence**, not a key, so it is a family member after all — which is exactly why that bucket says *read by hand*. |
|
||||
| **A4** | `static_tariff.py:522` `_raise_gap()` | `(start, end)` | `static_tariff.py:509`, `:513` | **0 B** | the `ProfileInvalid` refusal an operator reads at startup | **YES — pre-existing.** Measured: both call sites swapped, **0 bytes**, `static_tariff.py` fingerprint `caef7fde…` → `50e9a768…`, **red in `test_static_tariff.py` on two named checks** (*"…and names the uncovered range so the operator can fix it"*, *"…and the refusal names the uncovered range"*), because `_hhmm(start)}-{_hhmm(end)` is asserted as the contiguous literal `"22:00-23:00"`. ⚠️ That batch run also reported `FAIL test_peakguard.py` on a `static_tariff.py`-only edit; the failing check is *"one cycle of the intent form is exactly the delivered form plus the command standing on the wire"*, which is [[CI-06]]'s **mechanism 1 by name**, and `test_peakguard.py` passes **four for four** in isolation on that same tree. No claim here rests on it. |
|
||||
| A5–A6 | `static_tariff.py:522` `_raise_gap()` | `(start, weekday)`, `(end, weekday)` | — | — | — | **not real — the scan's one over-report.** It erases the sentence, so it cannot see that `DAY_NAMES[start]` with `start = 1320` raises `IndexError`. A mixed-type pair fails loudly, which is the opposite of this family. |
|
||||
| A7–A8 | `store.py:177` `unit_key()`, `mqtt.py:222` `unit_state_key()` | `(unit, key)`, `(slug, key)` | many | 0 B | — | **not this family.** The formatted string is the **return value and it is a KEY**: a swap renames a store key and an MQTT topic, which is behaviour every ordinary check can see. MAINT-05 is the card about it. |
|
||||
|
||||
### 7c. Census B — the caller builds the sentence
|
||||
|
||||
Census A only sees a callee formatting its **own** parameters, which was gap 2
|
||||
of its own honest-gap list. The other half never crosses a function boundary:
|
||||
an operator sentence built where it is printed, interpolating two values that
|
||||
are **interchangeable by shape**. No predicate is involved at all, so census A's
|
||||
symmetry test has nothing to test. The scan pairs interpolated expressions that
|
||||
are **one leaf apart** — `inv.dev_min` / `inv.dev_max`, `cfg.min_w` /
|
||||
`cfg.hold_s` — which is exactly what a transposition swaps, and it keeps only
|
||||
sentences whose sink is a **message**: an f-string that becomes a URL or a store
|
||||
key is data, and a swap there is an ordinary behavioural change. **16
|
||||
candidates**, every one of them a **zero-byte** edit:
|
||||
|
||||
| # | site | pair | reaches an operator as | pinned? |
|
||||
|---|---|---|---|---|
|
||||
| **B1** | `main.py:3231` | `inv.dev_min` / `inv.dev_max` | status page, `… accepts only −120…130 W` | **NO — MEASURED.** Transposed in place, **0 bytes**, `main.py` fingerprint `61aa413f…` → `ac36a97b…`, purged, `:ro`, **21/21 green**. The row then tells the operator the device accepts `130…−120 W`. |
|
||||
| **B2** | `main.py:3362` | `inv.dev_min` / `inv.dev_max` | status page, `setpoint … (range −120…130 W)` | **NO** — the literal `setpoint … (range` is in no `test_*.py`. |
|
||||
| **B3** | `main.py:3435` | `sign_wd.cfg.hold_s` / `.cfg.min_w` | status page, `sign watchdog: armed (trips after 180 s opposed, both over 300 W)` | **NO** — `s opposed` is in no `test_*.py`. |
|
||||
| **B4** | `main.py:3460` | `site_sign_wd.cfg.hold_s` / `.min_w` | status page, the site sign row | **NO** — `armed against the sum` is in no `test_*.py`. |
|
||||
| **B5** | `main.py:3491` | `circ_wd.cfg.hold_s` / `.min_w` | status page, the circulation row | **NO** — `circulation watchdog: armed` is in no `test_*.py`. |
|
||||
| **B6** | `circulation.py:317` | `cfg.min_w` / `cfg.hold_s` | **`circ_reason` — persisted, and re-read after a restart** | **NO** — `test_watchdog.py` asserts only `"CIRCULATING_POWER" in reason`. **This is SAFETY-31's twin of the exact defect this card just closed for SAFETY-02/30**, on a string with the same persistence. |
|
||||
| **B7** | `main.py:2454` | `self.site_max_w` / `self.target` | the clamp action-log line | **NO** — `was clamped to` is in no `test_*.py`. |
|
||||
| **B8** | `main.py:3386` | `clamp_wd.engagements` / `.episodes` | status page, `engaged N time(s) in M episode(s)` | **NO** — `time(s) in` is in no `test_*.py`. |
|
||||
| **B9–B11** | `main.py:3169` (three pairs) | `p1.net_w` / `.samples` / `.parse_errors` | status page, the P1 row | **NO** — `P1 meter (` is in no `test_*.py`. |
|
||||
| **B12** | `arbiter.py:264` | `c.source` / `c.actuator` | `scope_errors`, the arbiter's mis-routing refusal | **NO, and this one is instructive.** `test_arbiter.py:452` says it *"names the source and the actuator it does not belong on"* and then asserts `"peak" in r_mis.scope_errors[0]`. Under the swap the sentence becomes `"inverter-b: a SITE claim filed on unit 'peak'"` — and `"peak" in …` is **still true**. A claim wider than its assertion, which is habit 14's own shape. |
|
||||
| **B13** | `validate.py:723` | `meter_phases` / `meter_max_age_s` | the startup refusal | **NO** for the ordered pair. |
|
||||
| **B14** | `validate.py:1211` | `arbitrage_soc_floor` / `arbitrage_soc_ceiling` | the startup refusal | **NO** for the ordered pair. |
|
||||
| **B15** | `watchdog.py:667` | `cfg.min_w` / `cfg.hold_s` | the `SIGN_DISAGREEMENT` sentence's `(limits: both over 300 W, held over 180 s)` clause | **YES**, and it is worth naming because it shows the habit works. `test_watchdog.py`'s SAFETY-30 section asserts the site instance's **whole sentence with `==` against a literal**, the `(limits: …)` clause included. Full-string equality is habit 14 taken to its limit. |
|
||||
| B16 | `main.py:237` | `i.name` / `i.max_w` | the roster log line | **not real** — a `str` beside a `:.0f` format. The same over-report as A5–A6: a mixed-type transposition raises rather than lying. |
|
||||
|
||||
### 7d. What this enumeration does NOT close, with sizes
|
||||
|
||||
* **A3 is a measured, open survivor.** `dev_min`/`dev_max` transposed in
|
||||
`rail_unreachable()`'s NOT COVERED row: **0 bytes, 21/21 green.** It is
|
||||
disclosed here rather than fixed, because closing it wants a `test_freeze.py`
|
||||
or status-page fixture with a device whose two bounds are **asymmetric** —
|
||||
every fixture in the repository uses `±d` — and that is a different file's
|
||||
card. It is a **status-page row**, not a latching alarm.
|
||||
* **B1 is a second measured, open survivor.** `inv.dev_min` and `inv.dev_max`
|
||||
transposed in `main.py:3231`'s status row: **0 bytes**, fingerprint
|
||||
`61aa413f…` → `ac36a97b…`, **21/21 green**. The page then tells the operator
|
||||
the device accepts `130…−120 W`. Same disposition as A3: **A3 is the row the
|
||||
freeze detector prints when the rail is UNREACHABLE, B1 the clause the ARMED
|
||||
row prints when the device is what set the rail** — the two rows in which that
|
||||
detector ever names a device range, and neither one's operand order is
|
||||
asserted anywhere.
|
||||
* **Thirteen further census-B sites are unpinned and UNMEASURED.** B2–B14 above
|
||||
are determined at the **source level** — the distinguishing literal appears in
|
||||
no `test_*.py`, or the check that exists asserts a substring wider than its
|
||||
own claim — and were **not** mutated. That is a weaker statement than A1–A4
|
||||
and B1, and is written down as one.
|
||||
* **B12 is a check whose CLAIM is wider than its ASSERTION**, which is habit
|
||||
14's own shape one module over. `test_arbiter.py:452` says
|
||||
`scope_violations` *"names the source and the actuator it does not belong
|
||||
on"* and then asserts `"peak" in r_mis.scope_errors[0]`. Swap `c.source` and
|
||||
`c.actuator` in `arbiter.py:264` and the sentence becomes
|
||||
*"inverter-b: a SITE claim filed on unit 'peak'"* — and the substring is still
|
||||
found. Source-level, not mutated, and not fixed here.
|
||||
* **B6 is the closest thing to a repeat.** SAFETY-31's `circ_reason` is
|
||||
persisted and re-read after a restart, exactly like `sign_reason`, and its
|
||||
two limits are graded by a bare `"CIRCULATING_POWER" in reason`. This card
|
||||
does not fix it; §6a already declines to touch `circ_wd`, and this is the
|
||||
precise shape of what that declining costs.
|
||||
* **The instrument's own blind spots** are printed by the instrument, every
|
||||
run: semantic symmetry it cannot spell (it under-reports), census B pairs
|
||||
that are the same *kind* but not the same *shape*, `%s`-style logging and
|
||||
non-f-string payloads, anything outside `app/`, and the one way it
|
||||
**over-reports** — it erases the sentence, so a swap that would make the
|
||||
formatting itself raise is scored as changing no decision (A5–A6 above).
|
||||
|
||||
@@ -276,7 +276,25 @@ same place twice, the check is decoration.
|
||||
The fix is to assert the **ORDERED PAIR**,
|
||||
`f"commanded {sum:+.0f} W against a meter reading {grid:+.0f} W"`.
|
||||
|
||||
The general habit is **14** below.
|
||||
⚠️ **AND THE ELEVENTH WAS THE SAME DEFECT ONE CALL SITE OVER — THE ONE A
|
||||
SHIPPED INSTALL CAN ACTUALLY SEE.** Round 2 pinned the operand order of
|
||||
`SignWatchdog.evaluate()` at `main.py:1620`. That call site is the **site**
|
||||
detector, and `test_watchdog.py` part 9c asserts it is **never armed at a
|
||||
roster of one** — which is every install this add-on has. The **per-unit**
|
||||
call at `main.py:1560` was left unpinned, and swapping its first two
|
||||
arguments is the same **ZERO-BYTE** edit: `disagrees()` is symmetric in them
|
||||
(`commanded_w * against_w < 0` with an `abs()` gate on both), so **no trip
|
||||
decision can change** and the full 21-file suite stays green — measured. What
|
||||
moves is five banner lines, including the one persisted to `sign_reason` and
|
||||
re-read after a restart: *"commanded −1000 W against its own measured power
|
||||
+1000 W"*, operands inverted, on the only sign-disagreement sentence a
|
||||
one-inverter install can ever print. Closed in round 3 by the ordered pair on
|
||||
**both** per-unit banners — the N≥2 `its own measured power` one and the N=1
|
||||
`a meter reading` one — red on four named checks, green in the other twenty
|
||||
files. **Habit 15 is what falls out of finding the same shape twice in the
|
||||
same card.**
|
||||
|
||||
The general habits are **14** and **15** below.
|
||||
|
||||
Note what these have in common with a masked rung: the mutation *runs*, and the
|
||||
suite is *honestly* green. Nothing in a purge, a fingerprint or a disassembly
|
||||
@@ -427,6 +445,56 @@ Cheap habits fall out of it, and all of them are now used in this repository:
|
||||
this one was found: **inside the fix written to close the previous instance
|
||||
of the same family**, which is the second time in this list.
|
||||
|
||||
⚠️ **AND THE FIX FOR IT WAS ONE CALL SITE WIDE, WHICH IS THE HALF WORTH
|
||||
KEEPING.** The ordered pair went onto `main.py:1620`, the **site** detector
|
||||
— armed only at N≥2, and part 9c asserts it is **absent at a roster of
|
||||
one**. The identical swap at `main.py:1560`, the **per-unit** detector, was
|
||||
still **ZERO BYTES**, still changed no decision, still left the 21-file
|
||||
suite green — and it is the **only** sign-disagreement sentence a shipped
|
||||
one-inverter install can ever print: *"commanded −1000 W against its own
|
||||
measured power +1000 W"*, backwards, in the log, on the status page and in
|
||||
`sign_reason` after the restart the latch is designed to survive. Measured
|
||||
size: **main.py:1560, 0 bytes, 21/21 green.** So habit 14 is not "pin the
|
||||
banner", it is **"pin every call site of the banner, and say which one the
|
||||
shipped roster reaches"** — and habit 15 is how you find them all.
|
||||
|
||||
15. **A SYMMETRIC PREDICATE MAKES ARGUMENT ORDER A PROPERTY OF THE MESSAGE
|
||||
ALONE — so ENUMERATE THAT FAMILY BY MACHINE, and grade the CALL SITES, not
|
||||
the function.** Where a callee is symmetric in two arguments, a swap at any
|
||||
call site is invisible to every behavioural check **by construction**: not
|
||||
"no fixture happens to separate them" but *no fixture can*. There is
|
||||
nothing for a mutation battery to find, because the mutant and the shipped
|
||||
tree take identical actions on every input. The only surface that moves is
|
||||
the sentence, and the only check that can see it is one asserting the
|
||||
**ordered pair** (habit 14) at **that** call site.
|
||||
Three consequences, and all three are why this is its own habit:
|
||||
* **The unit of coverage is the CALL SITE.** SAFETY-38 pinned
|
||||
`evaluate()`'s operand order and still shipped the defect, because the
|
||||
pin was on the call two lines below the one that matters. Ask of every
|
||||
banner: *how many places call this, and which does the shipped roster
|
||||
reach?*
|
||||
* **The scope must be COMPUTED** — habit 12's argument, and SAFETY-38 is
|
||||
its proof: the family was enumerated by hand twice and was short both
|
||||
times. `tools/symmetric_message_scan.py` renames each parameter pair,
|
||||
erases every formatted string, canonicalises the commutative operators
|
||||
and iterates to a **fixpoint** over `app/` (the symmetry is usually one
|
||||
call deep — `evaluate()` is symmetric only *through* `disagrees()`), then
|
||||
classifies each sentence by where it GOES. A returned string may be a
|
||||
**key** — swapping `store.unit_key(unit, key)` renames a store key, which
|
||||
is behaviour and is MAINT-05's card — or a **sentence**, which is this
|
||||
family.
|
||||
* **Say what the instrument cannot see, with shapes** (habit 13). That scan
|
||||
under-reports semantic symmetry no canonicaliser on its finite list can
|
||||
spell; its second census, for sentences the CALLER builds, pairs only
|
||||
expressions **one leaf apart** and never sees `%s`-style logging; it
|
||||
covers only `app/`; and it **over-reports in exactly one way** — it
|
||||
erases the sentence, so a swap that would make the FORMATTING itself
|
||||
raise is scored as changing no decision. It prints all of that on every
|
||||
run. The family, every member's call sites, and which of them are pinned
|
||||
are in `DECISIONS/SAFETY-38-site-standing-pin.md` §7 — **with two
|
||||
measured, still-open survivors named there rather than rounded off**,
|
||||
both zero bytes and both 21/21 green.
|
||||
|
||||
And the mutation-table consequence: **a variant is not covered because a
|
||||
near-miss of it is.** DEV-02's table listed the *actuator* variant of the
|
||||
per-unit claim defect (`device`/`supervised` filed without `inv`), which the
|
||||
|
||||
@@ -145,14 +145,94 @@ from datetime import datetime
|
||||
# nothing else in this add-on uses it. See "DISTINCT FROM SATURATION".
|
||||
REASON_CODE = "OUTPUT_CLAMP"
|
||||
|
||||
# Re-assert the notification this often while the clamp stays engaged. 30 s is
|
||||
# the planning cadence (2.3), i.e. slow enough not to flood and fast enough
|
||||
# that a pinned loop is never quiet for a whole billing quarter.
|
||||
REASSERT_S = 30.0
|
||||
# Re-assert the notification this often while the clamp stays engaged.
|
||||
#
|
||||
# ⚠️ RAISED 30 s -> 150 s on 2026-09-05, and the reason is a REGIME CHANGE, not a
|
||||
# change of mind about the alarm. 30 s was the planning cadence (2.3), chosen
|
||||
# when a loop-side engagement was a rare event: `main.py`'s note at the action
|
||||
# log records "60 cycles of the §10.3 runaway produce 0 write-path engagements
|
||||
# and 2 loop-side ones". On the live install the counter reached 48 loop-side
|
||||
# ENGAGEMENTS in ~25 minutes of ordinary operation - roughly one cycle in six,
|
||||
# not a pinned rail, but far more often than a guard sized for 2-in-60 assumed -
|
||||
# and the owner received a persistent notification about it all day. ⚠️ NOT
|
||||
# because of the 30 s re-assert - see the block below; those notifications never
|
||||
# consulted this constant at all.
|
||||
#
|
||||
# ⚠️ BOTH FIGURES ARE ENGAGEMENT COUNTS; only the regime differs - 2-in-60 was
|
||||
# measured over a §10.3 RUNAWAY, 48-in-305 was ordinary running on the live
|
||||
# install. The 48 is read off the live log, not measured in this repo.
|
||||
#
|
||||
# ⚠️ AND RAISING THIS CONSTANT ALONE DID NOT FIX THAT FLOOD - review found it
|
||||
# and the first version of this change was wrong. At ~5 s cycles a 1-in-6
|
||||
# engagement leaves ~25 s of clean cycles between engagements, past
|
||||
# CLEAR_CYCLES x 5 s = 15 s, so the alarm DE-ASSERTED between almost every one
|
||||
# and came back as a fresh rising edge. The rising edge used to fire
|
||||
# unconditionally, so those notifications never consulted this constant at all:
|
||||
# 48 notifications at 30 s, and still 48 at 150 s. The fix was to rate-limit the
|
||||
# RISING EDGE too and let `last_alarm` survive a de-assert, so this interval
|
||||
# bounds the notification rate across episodes rather than only within one.
|
||||
# Measured on that same window afterwards: 51 episodes, 11 notifications.
|
||||
#
|
||||
# WHY the loop reaches its integrator bound so much more often than the guard's
|
||||
# author saw: `max_w` on the live install is 5000 W, while the loop's slew was
|
||||
# derived against a 1500 W clamp - admin/goodwe `NOTES.md` §"Simulated 2 kW load
|
||||
# step (gain 0.6, clamp 1500)". ⚠️ THAT DERIVATION IS IN THE OTHER REPO; nothing
|
||||
# in this one corroborates it, so do not go looking here. SAFETY-39 is the ticket
|
||||
# that re-derives it, and fixing that is what would stop the railing; this
|
||||
# constant only changes how often the alarm repeats.
|
||||
#
|
||||
# ⚠️ 150 s IS THE REQUIREMENT'S EDGE WITH MARGIN, AND THE CHECK NOW ENFORCES THAT
|
||||
# EDGE. `test_clamp.py` part 2 grades the cadence in wall-clock terms - a pinned
|
||||
# loop must speak at least MIN_PER_QUARTER = 5 times inside the 900 s quarter
|
||||
# that bills the capacity tariff - which caps this constant at 900/5 = 180 s.
|
||||
# Measured on the worst sliding 900 s window: 180 s earns 5 alarms, 181 s earns
|
||||
# 4. 150 s earns 6.
|
||||
#
|
||||
# ⚠️ THAT CHECK USED TO PERMIT 224 s, AND THE FIX SHIPPED WITH THIS COMMIT. Its
|
||||
# sweep starts at i=0, where the rising edge is free, so it measured one alarm
|
||||
# more than a real quarter earns. A later editor raising this to 200 s would have
|
||||
# had a green suite and a requirement quietly unmet. `test_clamp.py` now grades
|
||||
# floor(QUARTER_S / REASSERT_S) directly.
|
||||
#
|
||||
# ⚠️ MIN_PER_QUARTER = 5 IS A TEST-LOCAL INTEGER, not a spec figure - it exists
|
||||
# only in `test_clamp.py` and the CHANGELOG entry that introduced it. The 900 s
|
||||
# quarter IS system-wide (`tariff.py`, `freeze.py`, `DOCS.md`). So the 180 s
|
||||
# ceiling rests on one number a test author chose; if that number is ever
|
||||
# revisited, this constant moves with it.
|
||||
#
|
||||
# ⚠️ THIS IS A STOP-GAP AND DOES NOT MAKE THE ALARM CORRECT. The message still
|
||||
# calls the integrator bound a BUG SIGNAL and still pairs a request from one
|
||||
# moment with a `self.target` read at another - SAFETY-40. Nothing here is
|
||||
# suppressed: every engagement is still counted, the action-log row is written on
|
||||
# every engaged cycle regardless, and the rising edge of every episode alarms
|
||||
# unconditionally, so no alarm is DROPPED - only delayed.
|
||||
REASSERT_S = 150.0
|
||||
|
||||
# Consecutive clean cycles before the alarm de-asserts (10.2 hysteresis).
|
||||
CLEAR_CYCLES = 3
|
||||
|
||||
# ⚠️ SAFETY-41. Consecutive ENGAGED cycles after which an episode speaks even if
|
||||
# the notification budget is spent. THE ESCALATION EXISTS BECAUSE THE RATE LIMIT
|
||||
# ALONE DELAYS A RUNAWAY, and review measured it: a benign one-cycle clamp
|
||||
# followed by a continuous runaway held the runaway's first notification for up
|
||||
# to 140 s - one REASSERT_S minus a cycle. On the live install, where ordinary
|
||||
# railing spends the budget essentially all the time, a runaway arriving during
|
||||
# normal operation would be delayed with near-certainty. That is the "benign
|
||||
# noise suppresses the bug signal" shape this module's docstring is written
|
||||
# against.
|
||||
#
|
||||
# THE DISCRIMINATOR IS RUN LENGTH, and the two populations genuinely differ: the
|
||||
# live flood was runs of length ONE (engage, de-assert, engage), while a §10.3
|
||||
# runaway engages on EVERY cycle. So a sustained run speaks regardless of budget,
|
||||
# and scattered chatter does not.
|
||||
#
|
||||
# Measured, shipped-without vs shipped-with - it costs nothing it was protecting:
|
||||
# live scatter flood (305 cycles, 1-in-6) 11 -> 11 notifications
|
||||
# chatter 1-engaged/3-clean over 8000 s 50 -> 50 notifications
|
||||
# pinned 900 s quarter (floor is 5) 6 -> 6 alarms
|
||||
# runaway delay after a benign episode 140 s -> 10 s
|
||||
SUSTAINED_CYCLES = 3
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClampResult:
|
||||
@@ -258,6 +338,10 @@ class ClampWatchdog:
|
||||
self.last_alarm: datetime | None = None
|
||||
self.reason = ""
|
||||
self.clean_cycles = 0
|
||||
# SAFETY-41. Consecutive engaged cycles, and whether THIS episode has
|
||||
# spoken yet - the two the escalation clause needs.
|
||||
self.run_len = 0
|
||||
self.spoken = False
|
||||
|
||||
def note(self, engaged: bool, reason: str, now: datetime) -> bool:
|
||||
"""Advance one cycle. True when the caller should raise the alarm.
|
||||
@@ -269,26 +353,72 @@ class ClampWatchdog:
|
||||
self.engagements += 1
|
||||
self.clean_cycles = 0
|
||||
self.reason = reason
|
||||
self.run_len += 1
|
||||
if not self.active:
|
||||
self.active = True
|
||||
self.since = now
|
||||
self.episodes += 1
|
||||
self.spoken = False
|
||||
# ⚠️ SAFETY-41. THE RISING EDGE IS RATE-LIMITED TOO, and it did
|
||||
# not used to be. It fired unconditionally, so a condition that
|
||||
# engages and clears repeatedly raised one notification PER
|
||||
# EPISODE no matter how fast the episodes came - and
|
||||
# `REASSERT_S` never entered into it, because it only governs
|
||||
# the still-engaged path below.
|
||||
#
|
||||
# That is the shape the live install actually produced: 48
|
||||
# engagements in 25 minutes, scattered roughly one cycle in six.
|
||||
# At a ~5 s cycle the gaps are ~25 s of clean cycles, which
|
||||
# exceeds CLEAR_CYCLES x 5 s = 15 s, so the alarm DE-ASSERTED
|
||||
# between almost every engagement and each one came back as a
|
||||
# fresh rising edge. Measured on this class over that window:
|
||||
# 48 episodes and 48 notifications at REASSERT_S = 30 - and
|
||||
# still 48 at REASSERT_S = 150. Raising the re-assert removed
|
||||
# NONE of them, because none of them came from the re-assert.
|
||||
#
|
||||
# So the interval now bounds the NOTIFICATION RATE rather than
|
||||
# the repeat rate within one episode: `last_alarm` survives a
|
||||
# de-assert, and a new episode speaks only if the interval has
|
||||
# elapsed. ⚠️ NOTHING IS DROPPED. Every engagement is still
|
||||
# counted, every episode still increments, the action-log row is
|
||||
# still written on every engaged cycle, and a genuine runaway -
|
||||
# which by definition keeps engaging - is picked up by the
|
||||
# re-assert path within REASSERT_S. Delay, not loss.
|
||||
if (self.last_alarm is None
|
||||
or (now - self.last_alarm).total_seconds() >= REASSERT_S):
|
||||
self.last_alarm = now
|
||||
self.spoken = True
|
||||
return True
|
||||
return False
|
||||
# ⚠️ THE ESCALATION. An episode that has not yet spoken because the
|
||||
# budget was spent by an EARLIER, unrelated episode speaks anyway
|
||||
# once it has engaged SUSTAINED_CYCLES times in a row. A runaway
|
||||
# engages every cycle and reaches it; the scattered one-cycle
|
||||
# railing that produced the live flood never does.
|
||||
if not self.spoken and self.run_len >= SUSTAINED_CYCLES:
|
||||
self.last_alarm = now
|
||||
self.spoken = True
|
||||
return True
|
||||
# Still engaged: re-assert on the slow cadence, not every cycle.
|
||||
if (self.last_alarm is None
|
||||
or (now - self.last_alarm).total_seconds() >= REASSERT_S):
|
||||
self.last_alarm = now
|
||||
self.spoken = True
|
||||
return True
|
||||
return False
|
||||
|
||||
self.run_len = 0
|
||||
if not self.active:
|
||||
return False
|
||||
self.clean_cycles += 1
|
||||
if self.clean_cycles >= CLEAR_CYCLES:
|
||||
self.active = False
|
||||
self.since = None
|
||||
self.last_alarm = None
|
||||
# ⚠️ `last_alarm` DELIBERATELY SURVIVES THE DE-ASSERT. Clearing it
|
||||
# here is what made the next rising edge speak immediately however
|
||||
# soon it arrived, which is the flood described at the rising edge
|
||||
# above. The episode ends; the notification budget does not reset
|
||||
# with it.
|
||||
self.reason = ""
|
||||
self.clean_cycles = 0
|
||||
return False
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: GoodWe RS485 Controller
|
||||
version: "0.6.0"
|
||||
version: "0.7.0"
|
||||
slug: goodwe_controller
|
||||
description: >-
|
||||
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
|
||||
|
||||
@@ -78,7 +78,8 @@ import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from app.clamp import (
|
||||
CLEAR_CYCLES, REASON_CODE, REASSERT_S, ClampWatchdog, clamp_to_rating,
|
||||
CLEAR_CYCLES, REASON_CODE, REASSERT_S, SUSTAINED_CYCLES, ClampWatchdog,
|
||||
clamp_to_rating,
|
||||
is_commandable,
|
||||
)
|
||||
from app.allocator import Allocation, UnitPlan, plan
|
||||
@@ -239,11 +240,112 @@ print(f" pinned on the rail for {QUARTER_S:.0f} s at 1 s cycles: "
|
||||
check(f"a loop pinned for a whole billing quarter speaks at least "
|
||||
f"{MIN_PER_QUARTER} times (it spoke {alarms})",
|
||||
alarms >= MIN_PER_QUARTER)
|
||||
# ⚠️ REVIEW-FOUND, SAFETY-41, and it is the SAME DEFECT as the one recorded
|
||||
# twenty lines above, one notch weaker. The sweep above runs i = 0..899 and the
|
||||
# rising edge at i=0 is FREE, so it measures `1 + floor(899 / REASSERT_S)` - one
|
||||
# alarm more than a real quarter earns. Measured on this very check:
|
||||
# REASSERT_S = 180 -> 5 alarms, green (and 180 is the true edge: on the worst
|
||||
# REASSERT_S = 224 -> 5 alarms, GREEN sliding 900 s window 180 gives 5 and
|
||||
# REASSERT_S = 225 -> 4 alarms, red 181 gives 4)
|
||||
# So the check as written permits 224 s while the REQUIREMENT permits 180 s, and
|
||||
# anyone raising the constant to 200 s would get a green suite and conclude the
|
||||
# floor was met when a real quarter would earn 4 alarms. Grade the cadence
|
||||
# DIRECTLY, so the check enforces the ceiling its own comment claims for it.
|
||||
check(f"...and the CADENCE itself honours the floor, independent of where the "
|
||||
f"sweep happens to start: floor({QUARTER_S:.0f}/{REASSERT_S:.0f}) = "
|
||||
f"{int(QUARTER_S // REASSERT_S)} >= {MIN_PER_QUARTER}",
|
||||
QUARTER_S // REASSERT_S >= MIN_PER_QUARTER)
|
||||
check(f"...and on no more than {MAX_FRACTION:.0%} of cycles (it was "
|
||||
f"{alarms / QUARTER_S:.1%})", alarms <= MAX_FRACTION * QUARTER_S)
|
||||
check("...while every single cycle is still counted",
|
||||
pinned.engagements == int(QUARTER_S))
|
||||
|
||||
# --- THE SCATTERED REGIME, WHICH IS THE ONE THE OWNER ACTUALLY HIT -----------
|
||||
# ⚠️ REVIEW-FOUND, SAFETY-41, and it falsified the first version of this card.
|
||||
# The rising edge used to fire UNCONDITIONALLY, so REASSERT_S governed only the
|
||||
# still-engaged path. A condition that engages and clears repeatedly therefore
|
||||
# raised one notification PER EPISODE at any value of the constant.
|
||||
#
|
||||
# That is what the live install produced: 48 engagements in ~25 minutes,
|
||||
# scattered about one cycle in six. At the measured ~5 s cycle the gaps are ~25 s
|
||||
# of clean cycles, past CLEAR_CYCLES x 5 s = 15 s, so the alarm de-asserted
|
||||
# between almost every engagement. Raising REASSERT_S 30 -> 150 removed NONE of
|
||||
# them. The interval now bounds the notification RATE across episodes.
|
||||
#
|
||||
# The stimulus is written in wall-clock seconds and in the shape the field
|
||||
# actually produced, so it does not move when the constant does.
|
||||
SCATTER_CYCLE_S = 5.0
|
||||
SCATTER_ONE_IN = 6
|
||||
SCATTER_CYCLES = 305
|
||||
|
||||
scat = ClampWatchdog()
|
||||
scat_alarms = sum(1 for i in range(SCATTER_CYCLES)
|
||||
if scat.note(i % SCATTER_ONE_IN == 0, "scattered",
|
||||
T0 + timedelta(seconds=SCATTER_CYCLE_S * i)))
|
||||
_scat_span = SCATTER_CYCLE_S * SCATTER_CYCLES
|
||||
print(f" scattered 1-in-{SCATTER_ONE_IN} over {_scat_span:.0f} s at "
|
||||
f"{SCATTER_CYCLE_S:.0f} s cycles: {scat.episodes} episodes, "
|
||||
f"{scat.engagements} engagements, {scat_alarms} alarm(s)")
|
||||
check(f"a condition that de-asserts between engagements is rate-limited ACROSS "
|
||||
f"episodes, not once per episode ({scat_alarms} alarms for "
|
||||
f"{scat.episodes} episodes)",
|
||||
scat_alarms < scat.episodes)
|
||||
check(f"...and the rate honours the same interval the pinned case does: at most "
|
||||
f"ceil({_scat_span:.0f}/{REASSERT_S:.0f}) + 1 = "
|
||||
f"{int(_scat_span // REASSERT_S) + 1}",
|
||||
scat_alarms <= int(_scat_span // REASSERT_S) + 1)
|
||||
check("...while EVERY episode and EVERY engagement is still counted - the "
|
||||
"budget is on notifications, never on evidence",
|
||||
scat.episodes == scat.engagements == SCATTER_CYCLES // SCATTER_ONE_IN + 1)
|
||||
# ⚠️ THE COUNTERFACTUAL, AND THE FIRST VERSION OF IT COULD NOT FAIL FOR THE
|
||||
# DEFECT IT NAMED. It read `ClampWatchdog().note(True, ...) is True` - a FRESH
|
||||
# object, so it only ever exercised the `last_alarm is None` branch, which is the
|
||||
# trivially-true one. A fresh watchdog has no earlier episode to be held back BY.
|
||||
# That is the third instance in this file of the family recorded at line 216 and
|
||||
# at the 224 s sweep, and it was guarding the most safety-critical property in
|
||||
# the change. Review found it.
|
||||
#
|
||||
# The stimulus must therefore SPEND THE BUDGET FIRST: a benign one-cycle clamp,
|
||||
# a gap long enough to de-assert, then a continuous runaway. Without the
|
||||
# escalation clause the runaway's first notification is held for up to
|
||||
# REASSERT_S - measured at 140 s before it was added.
|
||||
_esc = ClampWatchdog()
|
||||
_esc.note(True, "benign", T0) # spends the budget
|
||||
for _i in range(1, 1 + CLEAR_CYCLES + 1): # de-assert
|
||||
_esc.note(False, "", T0 + timedelta(seconds=5.0 * _i))
|
||||
_RUNAWAY_AT = 5.0 * (CLEAR_CYCLES + 2)
|
||||
_esc_fires = [5.0 * (CLEAR_CYCLES + 2 + _j)
|
||||
for _j in range(200)
|
||||
if _esc.note(True, "runaway",
|
||||
T0 + timedelta(seconds=5.0 * (CLEAR_CYCLES + 2 + _j)))]
|
||||
_esc_delay = _esc_fires[0] - _RUNAWAY_AT if _esc_fires else None
|
||||
print(f" budget spent by a benign episode, then a continuous runaway from "
|
||||
f"t={_RUNAWAY_AT:.0f}s: first alarm at t={_esc_fires[0]:.0f}s "
|
||||
f"(delay {_esc_delay:.0f}s)")
|
||||
check(f"⚠️ a SUSTAINED runaway speaks even when an earlier, unrelated episode "
|
||||
f"spent the budget - it is not held for REASSERT_S ({_esc_delay:.0f}s "
|
||||
f"delay, escalation is {SUSTAINED_CYCLES} cycles)",
|
||||
_esc_delay is not None and _esc_delay < REASSERT_S / 2)
|
||||
check("...and it speaks at the escalation boundary rather than immediately, "
|
||||
"which is what keeps the scattered flood suppressed",
|
||||
_esc_delay >= 5.0 * (SUSTAINED_CYCLES - 1))
|
||||
|
||||
# ⚠️ `last_alarm` SURVIVING THE DE-ASSERT IS NOW LOAD-BEARING, AND NOTHING
|
||||
# PINNED IT. Review built the mutation: if the de-assert path ALSO refreshed
|
||||
# `last_alarm = now`, the chatter case collapses from 50 notifications to 1 - the
|
||||
# alarm never fires again. That is the "alarm that never fires" class, which is a
|
||||
# different bug from one that fires later, and the suite was green on it.
|
||||
_starve = ClampWatchdog()
|
||||
_starve_fires = sum(1 for _i in range(1600)
|
||||
if _starve.note(_i % 4 == 0, "chatter",
|
||||
T0 + timedelta(seconds=5.0 * _i)))
|
||||
print(f" chatter 1-engaged/{CLEAR_CYCLES}-clean over 8000 s: "
|
||||
f"{_starve.episodes} episodes, {_starve_fires} notification(s)")
|
||||
check(f"a de-assert does not RESET the notification budget, so repeated chatter "
|
||||
f"cannot starve the alarm to silence ({_starve_fires} notifications over "
|
||||
f"8000 s, not 1)",
|
||||
_starve_fires > 10)
|
||||
|
||||
# --- THE HYSTERESIS, PINNED AGAINST THE CHATTER DIRECTION --------------------
|
||||
# ⚠️ REVIEW-FOUND. This used to issue `range(CLEAR_CYCLES - 1)` clean cycles,
|
||||
# which at CLEAR_CYCLES = 1 issues ZERO of them and passes while printing
|
||||
|
||||
@@ -1508,6 +1508,50 @@ check("⚠️ AC 1. THE REVERSED UNIT TRIPS AND ITS SIBLING DOES NOT. The eviden
|
||||
check("...and the reason names the instrument, so the operator is sent to the "
|
||||
"right one", AGAINST_OWN_POWER in _b30.sign_wd.reason)
|
||||
print(f" {_b30.sign_wd.reason!r}")
|
||||
# ⚠️ THE LAST CYCLE BEFORE B LATCHED, NAMED RATHER THAN INDEXED - part 5's
|
||||
# lesson and 9a's. After the trip B's share is 0 W and its measured power
|
||||
# follows it down, so a fixed index reads the REMEDY instead of the evidence.
|
||||
# Both operands are read off THIS OBSERVED CYCLE - `last_write` and `batt` off
|
||||
# the objects - and not off the fixture's constants, so the check cannot be
|
||||
# satisfied by two numbers the fixture happens to hold anyway (habit 2).
|
||||
_preb30 = [x for x in _s30 if not x["b"]]
|
||||
_midb30 = _preb30[-1]
|
||||
print(f" last cycle before B latched: B stood "
|
||||
f"{_midb30['stood'][1]:+.0f} W, B measured {_midb30['meas'][1]:+.0f} W")
|
||||
check("⚠️ ...AND THE ORDERED PAIR, on the ONE SIGN-DISAGREEMENT SENTENCE A "
|
||||
"SHIPPED INSTALL CAN EVER PRINT. Part 9a pins the SITE banner's operand "
|
||||
"order at main.py:1620; that call site is armed only at N>=2 and part 9c "
|
||||
"asserts it is ABSENT at a roster of one, so on the shipped install the "
|
||||
"PER-UNIT banner is the only one there is - and its call site is a "
|
||||
"different line. Swapping the first two arguments at main.py:1560 - "
|
||||
"`evaluate(stood, against, ...)` -> `evaluate(against, stood, ...)` - is "
|
||||
"a ZERO-BYTE edit that changes NO trip decision, because `disagrees()` "
|
||||
"is symmetric in those two arguments (`commanded_w * against_w < 0` with "
|
||||
"an abs() gate on both). Nothing behavioural can see it and the full "
|
||||
"21-file suite stayed green. What it changes is the SENTENCE: 'commanded "
|
||||
"-1000 W against its own measured power +1000 W', the exact opposite of "
|
||||
"which instrument read what, on the line somebody woken by this alarm "
|
||||
"reads first - and it is persisted to `sign_reason` and re-read after a "
|
||||
"restart. Graded on the CONTIGUOUS LITERAL with each value in its own "
|
||||
"slot, per MUTATION.md habit 14: an `in` test on a number grades "
|
||||
"PRESENCE, never ARRANGEMENT",
|
||||
f"commanded {_midb30['stood'][1]:+.0f} W against its own measured power "
|
||||
f"{_midb30['meas'][1]:+.0f} W" in _b30.sign_wd.reason)
|
||||
check("PRECONDITION for the line above, and without it that check is "
|
||||
"decoration: the two operands are DIFFERENT NUMBERS and OPPOSITE IN "
|
||||
"SIGN, so transposing them really does produce a different string. A "
|
||||
"cycle where B's share and B's measured power were equal, or where "
|
||||
"either was 0 W, would make the swapped sentence identical to the "
|
||||
"correct one and the assertion could not reach the failure it names",
|
||||
_midb30["stood"][1] != _midb30["meas"][1]
|
||||
and _midb30["stood"][1] * _midb30["meas"][1] < 0
|
||||
and f"{_midb30['stood'][1]:+.0f}" != f"{_midb30['meas'][1]:+.0f}")
|
||||
check("...and the SAME ordered pair is what an operator finds AFTER A RESTART, "
|
||||
"because `sign_reason` is the persisted copy of this string. The swap "
|
||||
"survives the container restart the same way the latch does",
|
||||
f"commanded {_midb30['stood'][1]:+.0f} W against its own measured power "
|
||||
f"{_midb30['meas'][1]:+.0f} W"
|
||||
in str(_c30.store.data.get(unit_key("inverter-b", "sign_reason"), "")))
|
||||
# ⚠️ READ OFF THE ALLOCATION RECORD, NOT OFF `Controller.reason`. At a roster
|
||||
# of two `reason` is the SITE resolution's line - "loop -> 2000 W" - because
|
||||
# B's failsafe is a UNIT claim resolved on B's own actuator and never competes
|
||||
@@ -1764,11 +1808,20 @@ async def _n1_sign():
|
||||
return _orig_eval(*a, **kw)
|
||||
|
||||
c.site_sign_wd.evaluate = _spy
|
||||
await _drive(c, n=12, dt=0.2)
|
||||
return c, _n1_calls
|
||||
# ⚠️ THE WATCH READS THE CONTROLLER'S OWN STATE, NOT `_spy`'s ARGUMENTS.
|
||||
# `_n1_calls` records what was HANDED to a detector, so grading the banner
|
||||
# against it would compare the swap against itself - the DEV-02 defect.
|
||||
# `last_write` and `c.grid` are read off the objects the plant moved.
|
||||
seen = await _drive(c, n=12, dt=0.2, watch=lambda c: dict(
|
||||
grid=c.grid,
|
||||
stood=(c.inverters[0].last_write
|
||||
if c.inverters[0].last_write is not None
|
||||
else c.inverters[0].target),
|
||||
tripped=c.inverters[0].sign_wd.tripped))
|
||||
return c, _n1_calls, seen
|
||||
|
||||
|
||||
_c1s, _n1_site_calls = asyncio.run(_n1_sign())
|
||||
_c1s, _n1_site_calls, _seen1s = asyncio.run(_n1_sign())
|
||||
_inv1 = _c1s.inverters[0]
|
||||
print(f" roster={_c1s.units} armed against {_inv1.sign_wd.against!r} "
|
||||
f"at {_inv1.sign_wd.cfg.min_w:.0f} W / {_inv1.sign_wd.cfg.hold_s:.0f} s")
|
||||
@@ -1786,6 +1839,43 @@ check("...the reason sentence still says 'a meter reading', word for word - it "
|
||||
"is what an operator reads, what the action log carries and what is "
|
||||
"persisted, so it is graded on the literal",
|
||||
"against a meter reading" in _inv1.sign_wd.reason)
|
||||
# The last cycle before the latch, named rather than indexed: afterwards the
|
||||
# failsafe stands 0 W and the plant's meter relaxes, so a fixed index reads the
|
||||
# remedy. Both operands come off the controller, not off the fixture's options.
|
||||
_pre1s = [x for x in _seen1s if not x["tripped"]]
|
||||
_mid1s = _pre1s[-1]
|
||||
print(f" last cycle before the latch: stood {_mid1s['stood']:+.0f} W, "
|
||||
f"meter {_mid1s['grid']:+.0f} W")
|
||||
print(f" {_inv1.sign_wd.reason!r}")
|
||||
check("⚠️ ...AND THE ORDERED PAIR ON THE SHIPPING CALL SITE. This is the SAME "
|
||||
"zero-byte defect part 9a closed at main.py:1620, one call site over at "
|
||||
"main.py:1560 - and this is the one a one-inverter install actually "
|
||||
"reaches, since the site detector is never armed here (asserted below). "
|
||||
"`evaluate(stood, against, ...)` -> `evaluate(against, stood, ...)` "
|
||||
"changes no trip decision - `disagrees()` is symmetric in those two "
|
||||
"arguments - and inverts the banner to 'commanded +4000 W against a "
|
||||
"meter reading -2000 W': the loop is standing at -2000 W of charge INTO "
|
||||
"a +4000 W import and the latching banner says the opposite. The "
|
||||
"substring check above passes under the swap, because both numbers are "
|
||||
"still in the sentence; only the ORDERED PAIR can tell the slots apart "
|
||||
"(MUTATION.md habit 14)",
|
||||
f"commanded {_mid1s['stood']:+.0f} W against a meter reading "
|
||||
f"{_mid1s['grid']:+.0f} W" in _inv1.sign_wd.reason)
|
||||
check("PRECONDITION: the two operands are DIFFERENT NUMBERS and OPPOSITE IN "
|
||||
"SIGN on that cycle, so a transposition genuinely produces a different "
|
||||
"string. Equal or zero operands would make the swapped banner identical "
|
||||
"and the check above vacuous",
|
||||
_mid1s["stood"] != _mid1s["grid"]
|
||||
and _mid1s["stood"] * _mid1s["grid"] < 0
|
||||
and f"{_mid1s['stood']:+.0f}" != f"{_mid1s['grid']:+.0f}")
|
||||
check("...and the persisted copy carries the same ordered pair, under BOTH the "
|
||||
"namespaced key and the flat rollback mirror - which is what an operator "
|
||||
"reads after the restart the latch is designed to survive",
|
||||
f"commanded {_mid1s['stood']:+.0f} W against a meter reading "
|
||||
f"{_mid1s['grid']:+.0f} W" in str(_c1s.store.data.get(REASON_KEY, ""))
|
||||
and f"commanded {_mid1s['stood']:+.0f} W against a meter reading "
|
||||
f"{_mid1s['grid']:+.0f} W"
|
||||
in str(_c1s.store.data.get("sign_reason", "")))
|
||||
check("...the latch is under the same namespaced key with the same flat "
|
||||
"rollback mirror, and SAFETY-30's site pair is untouched at its default. "
|
||||
"A one-inverter install's state file is byte-identical to what it was",
|
||||
|
||||
@@ -0,0 +1,565 @@
|
||||
#!/usr/bin/env python3
|
||||
"""SAFETY-38 round 3 - the SYMMETRIC-PREDICATE FAMILY, enumerated by machine.
|
||||
|
||||
WHAT IT LOOKS FOR, in one sentence: a call whose argument ORDER is load-bearing
|
||||
for a MESSAGE and for nothing else - so swapping the arguments cannot change a
|
||||
single decision, is invisible to every behavioural check BY CONSTRUCTION, and
|
||||
still inverts what an operator reads on the line they are woken by.
|
||||
|
||||
⚠️ WHY IT IS AN INSTRUMENT AND NOT A LIST. MUTATION.md habit 12: "a hand-written
|
||||
scope is the same defect as a hand-written fixture". SAFETY-38 met this family
|
||||
twice - `SignWatchdog.evaluate()` called at `main.py:1620` (round 2) and at
|
||||
`main.py:1560` (round 3), ONE CALL SITE OVER - and both times the enumeration
|
||||
was written by hand and both times it was short. So the scope is computed.
|
||||
|
||||
------------------------------------------------------------------------------
|
||||
THE TEST IT APPLIES
|
||||
|
||||
For every function in `app/` and every pair (a, b) of its parameters:
|
||||
|
||||
rename a -> b and b -> a throughout the body, ERASE every string literal
|
||||
and f-string, CANONICALISE the commutative operators, and compare the two
|
||||
ASTs. If they are EQUAL, nothing the function DECIDES can depend on which
|
||||
of the two values arrived first.
|
||||
|
||||
Then, if some f-string in that function mentions BOTH names, the order is
|
||||
load-bearing for the MESSAGE ALONE, and the pair is reported.
|
||||
|
||||
⚠️ THE SYMMETRY IS USUALLY ONE CALL DEEP, WHICH IS WHY THIS IS A FIXPOINT AND
|
||||
NOT A SINGLE PASS. `SignWatchdog.evaluate()` does not compare its two arguments
|
||||
itself; it hands them to `watchdog.disagrees()`, whose body is
|
||||
`commanded_w * against_w < 0 and abs(commanded_w) > min_w and
|
||||
abs(against_w) > min_w`. An intraprocedural scan sees `disagrees(a, b, ...)`
|
||||
become `disagrees(b, a, ...)`, calls that a difference, and reports NOTHING -
|
||||
which is exactly what the first version of this file did, on a tree where the
|
||||
answer was already known. So symmetric pairs discovered in one round are used
|
||||
to canonicalise CALL ARGUMENTS in the next, and the scan iterates until the set
|
||||
stops growing.
|
||||
|
||||
CANONICALISATION, and each entry is here because something in `app/` needs it:
|
||||
* `a * b`, `a + b` - operand order sorted (`disagrees`)
|
||||
* `x and y`, `x or y` - term order sorted (`disagrees`' conjunction)
|
||||
* `a == b`, `a != b` - operand order sorted
|
||||
* `min(a, b)`, `max(a, b)` - argument order sorted (`clamp_to_rating`)
|
||||
* a tuple/list/set LITERAL being iterated - element order sorted
|
||||
(`rail_ceiling_w`'s `for bound in (dev_min, dev_max)`)
|
||||
* arguments at a KNOWN SYMMETRIC POSITION PAIR of a called function - sorted
|
||||
(the fixpoint)
|
||||
|
||||
Equal ASTs after the rename is a SUFFICIENT condition for behavioural
|
||||
invariance, not a necessary one. This scan therefore UNDER-REPORTS and says so
|
||||
- see "WHAT THIS CANNOT SEE". That is the conservative direction: everything it
|
||||
names is genuinely in the family, and the gap is stated with its shape.
|
||||
|
||||
Run: `python3 tools/symmetric_message_scan.py [--quiet]` from goodwe_controller/.
|
||||
Exit status is 0 always; it is a census, not a gate.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import itertools
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
APP = pathlib.Path(__file__).resolve().parent.parent / "app"
|
||||
|
||||
# {function name: {(i, j) positional index pairs that are interchangeable}}
|
||||
_SYM: dict[str, set[tuple[int, int]]] = {}
|
||||
# ⚠️ AND THE SAME THING FOR KEYWORD-ONLY PARAMETERS, WHICH THE FIRST FIXPOINT
|
||||
# DROPPED. `freeze.rail_ceiling_w(max_w, *, dev_min=None, dev_max=None)` folds
|
||||
# its two device bounds with `for bound in (dev_min, dev_max): ceiling = min(
|
||||
# ceiling, abs(bound))` - perfectly symmetric - and `rail_unreachable()` prints
|
||||
# `"a range of {dev_min}...{dev_max} W"`. A scan that only followed POSITIONAL
|
||||
# arguments called that pair asymmetric and hid a genuine member of the family
|
||||
# behind an implementation detail of its own traversal.
|
||||
_SYM_KW: dict[str, set[tuple[str, str]]] = {}
|
||||
|
||||
|
||||
class _Canon(ast.NodeTransformer):
|
||||
"""Rename one pair, erase every string, and sort what commutes."""
|
||||
|
||||
COMMUTATIVE_BINOP = (ast.Mult, ast.Add, ast.BitAnd, ast.BitOr, ast.BitXor)
|
||||
COMMUTATIVE_CMP = (ast.Eq, ast.NotEq)
|
||||
COMMUTATIVE_CALL = ("min", "max")
|
||||
|
||||
def __init__(self, a: str = "", b: str = ""):
|
||||
self.a, self.b = a, b
|
||||
|
||||
# -- the rename ---------------------------------------------------------
|
||||
def visit_Name(self, node):
|
||||
self.generic_visit(node)
|
||||
if self.a and node.id == self.a:
|
||||
node.id = self.b
|
||||
elif self.b and node.id == self.b:
|
||||
node.id = self.a
|
||||
return node
|
||||
|
||||
def visit_arg(self, node):
|
||||
if self.a and node.arg == self.a:
|
||||
node.arg = self.b
|
||||
elif self.b and node.arg == self.b:
|
||||
node.arg = self.a
|
||||
return node
|
||||
|
||||
# -- the message is exactly what is ALLOWED to differ --------------------
|
||||
def visit_JoinedStr(self, node):
|
||||
return ast.Constant(value="<msg>")
|
||||
|
||||
def visit_Constant(self, node):
|
||||
if isinstance(node.value, str):
|
||||
return ast.Constant(value="<msg>")
|
||||
return node
|
||||
|
||||
# -- canonicalisation ---------------------------------------------------
|
||||
def visit_BinOp(self, node):
|
||||
self.generic_visit(node)
|
||||
if isinstance(node.op, self.COMMUTATIVE_BINOP):
|
||||
l, r = ast.dump(node.left), ast.dump(node.right)
|
||||
if l > r:
|
||||
node.left, node.right = node.right, node.left
|
||||
return node
|
||||
|
||||
def visit_BoolOp(self, node):
|
||||
self.generic_visit(node)
|
||||
node.values = sorted(node.values, key=ast.dump)
|
||||
return node
|
||||
|
||||
def visit_Compare(self, node):
|
||||
self.generic_visit(node)
|
||||
if (len(node.ops) == 1
|
||||
and isinstance(node.ops[0], self.COMMUTATIVE_CMP)):
|
||||
l, r = ast.dump(node.left), ast.dump(node.comparators[0])
|
||||
if l > r:
|
||||
node.left, node.comparators[0] = node.comparators[0], node.left
|
||||
return node
|
||||
|
||||
def visit_For(self, node):
|
||||
self.generic_visit(node)
|
||||
if isinstance(node.iter, (ast.Tuple, ast.List, ast.Set)):
|
||||
node.iter.elts = sorted(node.iter.elts, key=ast.dump)
|
||||
return node
|
||||
|
||||
def visit_Call(self, node):
|
||||
self.generic_visit(node)
|
||||
name = (node.func.id if isinstance(node.func, ast.Name)
|
||||
else node.func.attr if isinstance(node.func, ast.Attribute)
|
||||
else "")
|
||||
if name in self.COMMUTATIVE_CALL and len(node.args) == 2 \
|
||||
and not node.keywords:
|
||||
if ast.dump(node.args[0]) > ast.dump(node.args[1]):
|
||||
node.args = [node.args[1], node.args[0]]
|
||||
return node
|
||||
# the fixpoint: sort the arguments at a known symmetric position pair
|
||||
for (i, j) in sorted(_SYM.get(name, ())):
|
||||
if j < len(node.args):
|
||||
if ast.dump(node.args[i]) > ast.dump(node.args[j]):
|
||||
node.args[i], node.args[j] = node.args[j], node.args[i]
|
||||
# ...and at a known symmetric KEYWORD pair
|
||||
for (ka, kb) in sorted(_SYM_KW.get(name, ())):
|
||||
ia = next((k for k, w in enumerate(node.keywords)
|
||||
if w.arg == ka), None)
|
||||
ib = next((k for k, w in enumerate(node.keywords)
|
||||
if w.arg == kb), None)
|
||||
if ia is not None and ib is not None:
|
||||
va, vb = node.keywords[ia].value, node.keywords[ib].value
|
||||
if ast.dump(va) > ast.dump(vb):
|
||||
node.keywords[ia].value, node.keywords[ib].value = vb, va
|
||||
return node
|
||||
|
||||
|
||||
def _canon(src: str, a: str = "", b: str = "") -> str:
|
||||
return ast.dump(_Canon(a, b).visit(ast.parse(src)))
|
||||
|
||||
|
||||
# ⚠️ THE SINK IS HALF THE CLAIM, AND THE FIRST VERSION OF THIS SCAN OMITTED IT.
|
||||
# Erasing every f-string makes a function whose ONLY job is to FORMAT one look
|
||||
# perfectly symmetric - `store.unit_key(unit, key)` and
|
||||
# `mqtt.unit_state_key(slug, key)` both came back as "the swap changes nothing"
|
||||
# when in fact the string IS the return value: swapping there renames a STORE
|
||||
# KEY and an MQTT TOPIC, which is a behaviour change every ordinary check can
|
||||
# see, and MAINT-05 is the card about exactly that. The family this file is
|
||||
# looking for is narrower - the string goes to an OPERATOR and to nobody else -
|
||||
# so each sentence is classified by where it GOES.
|
||||
_LOG_SINKS = {"debug", "info", "warning", "warn", "error", "exception",
|
||||
"critical", "log", "log_event", "notify", "persistent_notify"}
|
||||
_MSG_ATTRS = {"reason", "message", "msg", "banner", "detail", "note",
|
||||
"explanation", "why", "text"}
|
||||
|
||||
|
||||
def _sink_of(fn, target, follow=True):
|
||||
"""RETURN | MESSAGE | OTHER - where this f-string node ends up.
|
||||
|
||||
`follow` is the ONE step of data flow below, and it is off on the recursive
|
||||
call so that two locals that interpolate each other cannot loop forever.
|
||||
"""
|
||||
for stmt in ast.walk(fn):
|
||||
for child in ast.iter_child_nodes(stmt):
|
||||
pass
|
||||
# a parent map is cheaper than repeated walks
|
||||
parent = {}
|
||||
for node in ast.walk(fn):
|
||||
for child in ast.iter_child_nodes(node):
|
||||
parent[id(child)] = node
|
||||
cur = target
|
||||
while id(cur) in parent:
|
||||
up = parent[id(cur)]
|
||||
if isinstance(up, ast.Return):
|
||||
return "RETURN"
|
||||
if isinstance(up, ast.Raise):
|
||||
return "MESSAGE"
|
||||
if isinstance(up, (ast.Assign, ast.AnnAssign, ast.AugAssign)):
|
||||
tgts = (up.targets if isinstance(up, ast.Assign) else [up.target])
|
||||
for t in tgts:
|
||||
if isinstance(t, ast.Attribute) and t.attr in _MSG_ATTRS:
|
||||
return "MESSAGE"
|
||||
if isinstance(t, ast.Name) and t.id in _MSG_ATTRS:
|
||||
return "MESSAGE"
|
||||
# ⚠️ ONE STEP OF DATA FLOW, AND IT IS NOT OPTIONAL. `main.py`'s
|
||||
# status page builds the device-range clause into a LOCAL - `_cap =
|
||||
# "; ".join(f"... accepts only {inv.dev_min}...{inv.dev_max} W" ...)`
|
||||
# - and only then concatenates it into the row's `text`. Stopping at
|
||||
# the assignment scores that OTHER and drops a member this card
|
||||
# MEASURED as a live survivor (0 bytes, 21/21 green). So: if the
|
||||
# name assigned here is interpolated into some other sentence in the
|
||||
# same function that IS a message, this one is a message too.
|
||||
if follow:
|
||||
for t in tgts:
|
||||
if isinstance(t, ast.Name) and _feeds_a_message(fn, t.id,
|
||||
up):
|
||||
return "MESSAGE"
|
||||
return "OTHER"
|
||||
if isinstance(up, ast.Call):
|
||||
name = (up.func.attr if isinstance(up.func, ast.Attribute)
|
||||
else up.func.id if isinstance(up.func, ast.Name) else "")
|
||||
if name in _LOG_SINKS:
|
||||
return "MESSAGE"
|
||||
if name == "append":
|
||||
return "MESSAGE"
|
||||
cur = up
|
||||
return "OTHER"
|
||||
|
||||
|
||||
def _feeds_a_message(fn, name, origin):
|
||||
"""Is `name` interpolated into some OTHER sentence that IS a message?"""
|
||||
for node in ast.walk(fn):
|
||||
if not isinstance(node, ast.JoinedStr) or node is origin:
|
||||
continue
|
||||
used = any(isinstance(x, ast.Name) and x.id == name
|
||||
for v in node.values if isinstance(v, ast.FormattedValue)
|
||||
for x in ast.walk(v.value))
|
||||
if used and _sink_of(fn, node, follow=False) == "MESSAGE":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _fstring_pairs(fn, params):
|
||||
"""{(a, b): [(lineno, sink), ...]} for every f-string mentioning both."""
|
||||
out: dict[tuple[str, str], list[tuple[int, str]]] = {}
|
||||
for node in ast.walk(fn):
|
||||
if not isinstance(node, ast.JoinedStr):
|
||||
continue
|
||||
used = set()
|
||||
for v in node.values:
|
||||
if isinstance(v, ast.FormattedValue):
|
||||
for x in ast.walk(v.value):
|
||||
if isinstance(x, ast.Name) and x.id in params:
|
||||
used.add(x.id)
|
||||
if len(used) < 2:
|
||||
continue
|
||||
sink = _sink_of(fn, node)
|
||||
for a, b in itertools.combinations(sorted(used), 2):
|
||||
out.setdefault((a, b), []).append((node.lineno, sink))
|
||||
return out
|
||||
|
||||
|
||||
def _functions():
|
||||
for path in sorted(APP.glob("*.py")):
|
||||
tree = ast.parse(path.read_text())
|
||||
for fn in ast.walk(tree):
|
||||
if isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
yield path, fn
|
||||
|
||||
|
||||
def _positional(fn):
|
||||
return [a.arg for a in fn.args.posonlyargs + fn.args.args
|
||||
if a.arg != "self"]
|
||||
|
||||
|
||||
def _all_params(fn):
|
||||
return _positional(fn) + [a.arg for a in fn.args.kwonlyargs]
|
||||
|
||||
|
||||
def _body_src(fn) -> str:
|
||||
"""The function's BODY alone.
|
||||
|
||||
⚠️ NOT `ast.unparse(fn)`. The signature lists the parameters IN ORDER, so
|
||||
renaming a pair inside it reorders `arguments.args` and every function in
|
||||
the repository compares unequal to itself - which is what the first version
|
||||
of this scan did, silently, and it reported an empty family on a tree whose
|
||||
answer was already known. The claim being tested is about what the body
|
||||
DECIDES, and the body is what is compared.
|
||||
"""
|
||||
return ast.unparse(ast.Module(body=list(fn.body), type_ignores=[]))
|
||||
|
||||
|
||||
def _symmetric(fn, a, b) -> bool:
|
||||
src = _body_src(fn)
|
||||
return _canon(src) == _canon(src, a, b)
|
||||
|
||||
|
||||
def build_fixpoint(max_rounds=6):
|
||||
"""Fill `_SYM` until it stops growing. Returns the number of rounds."""
|
||||
for round_no in range(1, max_rounds + 1):
|
||||
grew = False
|
||||
for _path, fn in _functions():
|
||||
pos = _positional(fn)
|
||||
kws = [a.arg for a in fn.args.kwonlyargs]
|
||||
if len(pos) + len(kws) < 2:
|
||||
continue
|
||||
found = set()
|
||||
for i, j in itertools.combinations(range(len(pos)), 2):
|
||||
if _symmetric(fn, pos[i], pos[j]):
|
||||
found.add((i, j))
|
||||
found_kw = set()
|
||||
for ka, kb in itertools.combinations(sorted(kws), 2):
|
||||
if _symmetric(fn, ka, kb):
|
||||
found_kw.add((ka, kb))
|
||||
if found - _SYM.get(fn.name, set()):
|
||||
_SYM.setdefault(fn.name, set()).update(found)
|
||||
grew = True
|
||||
if found_kw - _SYM_KW.get(fn.name, set()):
|
||||
_SYM_KW.setdefault(fn.name, set()).update(found_kw)
|
||||
grew = True
|
||||
if not grew:
|
||||
return round_no
|
||||
return max_rounds
|
||||
|
||||
|
||||
def scan():
|
||||
rows = []
|
||||
for path, fn in _functions():
|
||||
params = _all_params(fn)
|
||||
if len(params) < 2:
|
||||
continue
|
||||
kwonly = {a.arg for a in fn.args.kwonlyargs}
|
||||
for (a, b), hits in sorted(_fstring_pairs(fn, params).items()):
|
||||
rows.append(dict(
|
||||
module=path.name, func=fn.name, line=fn.lineno, a=a, b=b,
|
||||
msg_lines=sorted(set(hits)),
|
||||
sinks={s for _l, s in hits},
|
||||
symmetric=_symmetric(fn, a, b),
|
||||
positional=not (a in kwonly or b in kwonly)))
|
||||
return rows
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CENSUS B - THE SENTENCE THE CALLER BUILDS
|
||||
# =============================================================================
|
||||
# ⚠️ CENSUS A ABOVE ONLY SEES A CALLEE FORMATTING ITS OWN PARAMETERS, AND THAT
|
||||
# WAS GAP 2 OF ITS OWN "WHAT THIS CANNOT SEE" LIST. The other half of the family
|
||||
# never passes through a function boundary at all: an operator sentence built
|
||||
# where it is printed, interpolating two values that are INTERCHANGEABLE BY
|
||||
# SHAPE. `main.py`'s status page says
|
||||
#
|
||||
# f"{inv.setpoint_entity or inv.name} accepts only {inv.dev_min}...{inv.dev_max} W"
|
||||
#
|
||||
# and transposing those two costs ZERO BYTES, changes nothing any check can
|
||||
# observe, and tells the operator a range backwards. No predicate is involved,
|
||||
# so census A's symmetry test has nothing to test.
|
||||
#
|
||||
# THE TEST: inside one MESSAGE-sink f-string, take every pair of interpolated
|
||||
# expressions, blank every attribute NAME and every string CONSTANT, and compare.
|
||||
# If the blanked forms are EQUAL while the originals DIFFER, the two expressions
|
||||
# are the same shape with one leaf changed - `inv.dev_min` / `inv.dev_max`,
|
||||
# `site_dev_bound("dev_min")` / `site_dev_bound("dev_max")` - which is exactly
|
||||
# the pair a transposition swaps. Same direction as census A: it under-reports
|
||||
# (two values of the same KIND spelled differently are missed) rather than
|
||||
# inventing members.
|
||||
|
||||
|
||||
class _Blank(ast.NodeTransformer):
|
||||
def visit_Attribute(self, node):
|
||||
self.generic_visit(node)
|
||||
node.attr = "<leaf>"
|
||||
return node
|
||||
|
||||
def visit_Constant(self, node):
|
||||
if isinstance(node.value, str):
|
||||
return ast.Constant(value="<leaf>")
|
||||
return node
|
||||
|
||||
|
||||
def _blanked(expr):
|
||||
return ast.dump(_Blank().visit(ast.parse(ast.unparse(expr))))
|
||||
|
||||
|
||||
def scan_caller():
|
||||
rows = []
|
||||
for path in sorted(APP.glob("*.py")):
|
||||
tree = ast.parse(path.read_text())
|
||||
for fn in ast.walk(tree):
|
||||
if not isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
continue
|
||||
for node in ast.walk(fn):
|
||||
if not isinstance(node, ast.JoinedStr):
|
||||
continue
|
||||
# ⚠️ MESSAGE SINKS ONLY, AND THE FIRST VERSION OF THIS
|
||||
# CENSUS SAID "anything but a RETURN". That let in
|
||||
# `p1.py`'s `self.url = f"http://{self.host}:{self.port}…"` -
|
||||
# an f-string that is DATA, used to make a request, where a
|
||||
# transposition is an ordinary behavioural change every check
|
||||
# can see. Same distinction as census A's RETURN bucket: a
|
||||
# formatted string is only in this family when its only
|
||||
# consumer is a person.
|
||||
if _sink_of(fn, node) != "MESSAGE":
|
||||
continue
|
||||
exprs = [v.value for v in node.values
|
||||
if isinstance(v, ast.FormattedValue)]
|
||||
for a, b in itertools.combinations(exprs, 2):
|
||||
ua, ub = ast.unparse(a), ast.unparse(b)
|
||||
if ua == ub:
|
||||
continue
|
||||
if _blanked(a) == _blanked(b):
|
||||
rows.append((path.name, node.lineno, fn.name, ua, ub))
|
||||
# de-duplicate: the same sentence reached through several enclosing defs
|
||||
return sorted(set(rows), key=lambda r: (r[0], r[1], r[3]))
|
||||
|
||||
|
||||
# ⚠️ THE UNIT OF COVERAGE IS THE CALL SITE, NOT THE FUNCTION - which is the
|
||||
# whole lesson of SAFETY-38 round 3. `SignWatchdog.evaluate()` had its operand
|
||||
# order pinned and the defect still shipped, because the pin was on the call two
|
||||
# lines below the one a one-inverter roster actually reaches. So a family member
|
||||
# is reported WITH EVERY PLACE IT IS CALLED.
|
||||
def call_sites(name):
|
||||
out = []
|
||||
for path in sorted(APP.glob("*.py")):
|
||||
tree = ast.parse(path.read_text())
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
called = (node.func.attr if isinstance(node.func, ast.Attribute)
|
||||
else node.func.id if isinstance(node.func, ast.Name)
|
||||
else "")
|
||||
if called == name:
|
||||
# ⚠️ THE RECEIVER IS PRINTED, because matching on the bare
|
||||
# attribute name conflates methods: `evaluate` is the name of
|
||||
# SAFETY-02's, SAFETY-18's and SAFETY-31's watchdogs alike, and
|
||||
# a list of four lines with no receiver tells a reader nothing
|
||||
# about which detector they are looking at. `inv.sign_wd` and
|
||||
# `self.freeze_wd` are the disambiguation, and they are the
|
||||
# caller's own words.
|
||||
out.append(f"{path.name}:{node.lineno} "
|
||||
f"`{ast.unparse(node.func)}`")
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
quiet = "--quiet" in sys.argv
|
||||
rounds = build_fixpoint()
|
||||
rows = scan()
|
||||
fam = [r for r in rows if r["symmetric"] and "MESSAGE" in r["sinks"]
|
||||
and "RETURN" not in r["sinks"]]
|
||||
valued = [r for r in rows if r["symmetric"] and "RETURN" in r["sinks"]]
|
||||
print("SYMMETRIC-PREDICATE FAMILY - argument order load-bearing for a "
|
||||
"MESSAGE only")
|
||||
print("=" * 78)
|
||||
print(f"fixpoint reached after {rounds} round(s); "
|
||||
f"{sum(len(v) for v in _SYM.values())} interchangeable positional "
|
||||
f"and {sum(len(v) for v in _SYM_KW.values())} keyword pair(s) "
|
||||
f"across {len(set(_SYM) | set(_SYM_KW))} function(s) in app/")
|
||||
print(f"functions formatting >=2 of their own parameters into one "
|
||||
f"sentence: {len(rows)} pair(s)")
|
||||
print(f"of those, pairs whose SWAP LEAVES EVERY DECISION IDENTICAL: "
|
||||
f"{len(fam) + len(valued)}")
|
||||
print(f" ...of which the sentence is RETURNED (so the swap is a VALUE "
|
||||
f"change an ordinary check can see): {len(valued)}")
|
||||
print(f" ...and the sentence goes ONLY TO AN OPERATOR - THE FAMILY: "
|
||||
f"{len(fam)}")
|
||||
print()
|
||||
print("THE FAMILY - a swap here is invisible to every behavioural check "
|
||||
"BY CONSTRUCTION:")
|
||||
for r in sorted(fam, key=lambda r: (r["module"], r["line"])):
|
||||
kind = ("POSITIONAL - a transposition at a call site costs ZERO BYTES"
|
||||
if r["positional"] else
|
||||
"KEYWORD-ONLY at the definition - a transposition has to be "
|
||||
"written out by name, which is not free and not silent")
|
||||
print(f" {r['module']}:{r['line']} {r['func']}() pair "
|
||||
f"({r['a']}, {r['b']})")
|
||||
print(f" sentence at line(s): "
|
||||
f"{', '.join(f'{l} -> {s}' for l, s in r['msg_lines'])}")
|
||||
print(f" {kind}")
|
||||
sites = call_sites(r["func"])
|
||||
print(f" CALL SITES ({len(sites)}) - each one is a separate pin, "
|
||||
f"and the receiver says which detector it is:")
|
||||
for site in sites or ["(none inside app/)"]:
|
||||
print(f" {site}")
|
||||
print()
|
||||
print("SYMMETRIC, BUT THE SENTENCE IS THE RETURN VALUE - READ THIS BUCKET "
|
||||
"BY HAND.")
|
||||
print("A returned string is sometimes a KEY (a swap renames a store key or "
|
||||
"an MQTT topic,")
|
||||
print("which is behaviour every ordinary check can see - MAINT-05 is the "
|
||||
"card about it)")
|
||||
print("and sometimes a SENTENCE (a swap is message-only after all, and it "
|
||||
"IS this family):")
|
||||
for r in sorted(valued, key=lambda r: (r["module"], r["line"])):
|
||||
print(f" {r['module']}:{r['line']} {r['func']}() pair "
|
||||
f"({r['a']}, {r['b']})")
|
||||
print()
|
||||
print("=" * 78)
|
||||
print("CENSUS B - SENTENCES THE CALLER BUILDS, interpolating two values of "
|
||||
"the SAME SHAPE")
|
||||
print("A transposition inside one of these is ZERO BYTES and no predicate "
|
||||
"is involved at")
|
||||
print("all, so nothing behavioural can ever see it. Same rule: assert the "
|
||||
"ORDERED PAIR.")
|
||||
cb = scan_caller()
|
||||
print(f"candidates: {len(cb)}")
|
||||
for mod, line, fn, ua, ub in cb:
|
||||
print(f" {mod}:{line} in {fn}()")
|
||||
print(f" {ua} <-> {ub}")
|
||||
print()
|
||||
print("WHAT THIS CANNOT SEE - the shape of each gap, named rather than "
|
||||
"rounded off:")
|
||||
print(" 1. SEMANTIC symmetry no canonicaliser above spells. The list of "
|
||||
"commutative")
|
||||
print(" forms is finite and hand-written; a function symmetric only "
|
||||
"through a form")
|
||||
print(" not on it is REJECTED and never appears. This under-reports.")
|
||||
print(" 2. In CENSUS B, two interpolated values of the same KIND that "
|
||||
"are not the same")
|
||||
print(" SHAPE - `a.lo` beside `hi`, or one of them behind a local "
|
||||
"variable. Only")
|
||||
print(" one-leaf-apart expressions are paired. `%s`-style logging "
|
||||
"(`_LOG.error('%s")
|
||||
print(" ... %s', a, b)`) and non-f-string payloads are not scanned at "
|
||||
"all.")
|
||||
print(" 3. Anything outside `app/` - `sim/`, `tools/`, the firmware, the "
|
||||
"witness.")
|
||||
print(" 4. WHETHER each call site is pinned. The sites are listed above; "
|
||||
"whether a")
|
||||
print(" check asserts the ORDERED PAIR at each one is a fact about "
|
||||
"`test_*.py` and is")
|
||||
print(" recorded in DECISIONS/SAFETY-38-site-standing-pin.md section "
|
||||
"7. Call sites")
|
||||
print(" outside `app/` are not listed either, and the site list matches "
|
||||
"on the bare")
|
||||
print(" METHOD NAME - three different watchdogs are all called "
|
||||
"`evaluate`, so the")
|
||||
print(" receiver is printed beside each one and a reader has to pick.")
|
||||
print(" 5. ⚠️ IT CAN ALSO OVER-REPORT, IN ONE EXACT WAY. The sentence is "
|
||||
"ERASED before")
|
||||
print(" the comparison, so a swap that would make the FORMATTING "
|
||||
"ITSELF fail - a")
|
||||
print(" `DAY_NAMES[start]` where `start` is 1320 - is scored as "
|
||||
"'changes no decision'.")
|
||||
print(" `_raise_gap`'s (start, weekday) and (end, weekday) pairs are "
|
||||
"that: mixed")
|
||||
print(" types, and a real transposition there raises IndexError rather "
|
||||
"than lying")
|
||||
print(" quietly. The hazard is a SAME-TYPED pair; read the list with "
|
||||
"that in hand.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user