diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index 216cc45..43b2219 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -366,6 +366,91 @@ thing that stops. Rule 8's own failure mode, reached through rule 8. line**, where `sorted(...)[:1]` (+4 bytes) is still green — it truncates names in one event message; the refusal itself is a different expression and is graded, so the site still stops. +* ⚠️ **FOURTH FIX ROUND — the same family one MODULE over, and the pin widened + until it discovers its own scope.** Round 4 enumerated the family and closed + it inside the `Controller` class; the review found four more survivors + immediately, in **validate.py**, on a 19/19 green suite: `min(caps)` → + `min(caps[:1])` (+4) and `→ min(caps[-1:])` (+5) at `:779`, the caps + comprehension over `caps_ix[:1]` (+4) at `:778`, and + `min(caps_ix[-1:], key=…)` (+5) at `:893`. **None is an equivalent mutant.** + With `max_w: 5000` on inverter-a beside `2000` on inverter-b and + `sign_disagree_w: 3000`, the shipped validator refuses — *"the SAFETY-02 + sign-disagreement watchdog can never fire"* — and all four **accept**: the + site starts with a watchdog reporting `armed` that cannot trip on pack B. + Rule 5's own failure mode reached through rule 5; rule 6 loses its refusal the + same way. **No shipped behaviour changed by the pin; what changed is that a + hand-written scope stopped being trusted.** + * **`test_peakguard.py` part 12j now parses every `.py` under `app/`** — no + module is named in the check — and finds the roster by transitive closure + from **one seed**, the `inverters` option key: through returns, through + lists **accumulated in a loop over a roster** (which is what `caps`, + `roster` and `problems` are, and what round 4's rule could not express), + through attributes and properties, and **through call arguments**, which is + how it walks into `peakguard`, `arbiter`, `allocator`, `circulation`, + `maintenance` and `store` without being told they exist. A slice of a roster + stays a roster so a mutation cannot cut the taint; an index is one unit and + does not. **183 reductions across 9 modules** out of 23 files parsed — `main.py` 96, + `allocator.py` 24, `validate.py` 19, `arbiter.py` 14, `circulation.py` 10, + `maintenance.py` 10, `peakguard.py` 4, `inverter.py` 3, `store.py` 3. + * **The hand-written module list would have been wrong in BOTH directions.** + It named `watchdog.py`, which reduces over no roster at all — SAFETY-30's + sign watchdogs are strictly per-unit — and omitted `maintenance.py` and + `store.py`, which both do. A vacuous claim about one module and no claim + about two others. + * **The pin rediscovers all four blockers without being told where to look,** + and finds a fifth beside them (`validate.py:783`, rule 5's message) that no + review round named. Its counterfactual runs at two depths: every enumerated + site's own expression mutated with each of the four cuts this card has been + defeated by (732 spellings, all reported), and `[:1]` written into + `app/validate.py` at each blocker position with the **whole** analysis + re-run over the mutated source — a sliced site required at that exact line, + where the clean package reports none. + * **And the behavioural half, because a shape pin grades an identity and never + a meaning.** `test_validate.py` grows a **roster SET** for the three rules + that reduce over `unit_caps_indexed()` — widths 2, 3, 4, 5 with the one + small cap at every position, 42 runs of the real validator — plus a + **computed family table** of twelve candidate reductions each compared with + what the shipped validator actually said on the same roster. Only `min` + agrees everywhere; a member no roster kills is printed as a named survivor. + * ⚠️ **A THIRD RULE WAS READING THE FLAT `max_w`, AND IT IS SAFETY-30's** — + found by reading, **not** by the pin, which is the shape of what the pin + cannot see. `unit_sign_disagree_w` was graded against + `_number(opts, "max_w", 2000.0)` three lines under a branch that had already + stopped doing that. **Both directions were measured:** two 2000 W units + beside a leftover `max_w: 5000` and `unit_sign_disagree_w: 3000` produced + **zero problems** — the site started with a per-unit watchdog that could not + trip on either unit — and two 5000 W units with no flat key were **refused** + against a 2000 W default the operator never wrote. It takes the weakest + unit's own cap now and names that unit; at a roster of one it is the same + comparison, boundary and sentence it always was. + * ⚠️ **What the pin cannot see, with sizes**, in + `DECISIONS/SAFETY-32-per-pack-bms-floor.md` §4d: semantics (`min` vs `max` + is a zero-byte edit); direct index sites that are not reductions; a + subscript by a *variable* (`weights[u]` is why); a slice through an + intermediate call; anything outside `app/`, including `sim/inject.py`; and + the taint's own seam at `*args` and unresolvable receivers, where everything + downstream goes **unenumerated rather than reported unreachable**. + `cycle()`'s undeliverable-unit log line is still green at +4 bytes and still + filed open, unchanged from round 4. + * **`tools/supervisor_shape_probe.py` re-run again (2026-09-05, 2026.08.0).** + The rebase onto `67e649a` merged SPIKE-04a's `max_w: 4600` into the same + §C/§D blocks this card had rewritten, and a transcript stitched by hand out + of two runs is not a measurement. `all cases passed`, exit 0, with B's + rating reading 4600 — its **datasheet** figure, still not a measurement of + any unit. + * ⚠️ **THE SWEEP, AND THE NUMBER THAT MUST NOT BE QUOTED ALONE.** All 183 + sites mutated to `[:1]`, one at a time, against the full 19-file suite in + `goodwe-addon-ci` (`:ro`, `-B`, fresh rsync per mutant, 0 `.pyc` before and + after): **183/183 RED** — which is tautological on its own, because the pin + makes every slice red. The 51 whose first red was `test_peakguard.py` were + re-run with the pin **neutralised**: **145 of 183 have a behavioural fixture + that separates them from a slice of themselves; 38 have only the structural + claim.** All 38 are listed by site, with what a `[:1]` there would cost, in + §4d and in `test_peakguard.py` part 12j — 15 of them are rows and counts on + the operator's page, 3 are `len(targets)` arithmetic beside a write path + that IS graded, 6 are allocator internals ALLOC-01's fixtures reach but not + with enough units to separate. **None of the 38 is closed behaviourally by + this card, and none is counted as covered.** ⚠️ **No measurement of inverter B appears anywhere in this change.** It does not exist yet. Every second-pack figure — in fixtures, in the probe, in diff --git a/goodwe_controller/DECISIONS/SAFETY-32-per-pack-bms-floor.md b/goodwe_controller/DECISIONS/SAFETY-32-per-pack-bms-floor.md index c75cb86..2b0cdc9 100644 --- a/goodwe_controller/DECISIONS/SAFETY-32-per-pack-bms-floor.md +++ b/goodwe_controller/DECISIONS/SAFETY-32-per-pack-bms-floor.md @@ -470,13 +470,227 @@ quietly or left unmentioned.** ### And the family closed rather than the member -`test_peakguard.py` part 12j enumerates **every** comprehension in `Controller` -that iterates the roster — 26 of them across 23 methods, computed from main.py's -AST, printed, and required to iterate the roster **whole**. A new reduction added -over `self.inverters[:1]` anywhere in the class is red **on the line it is -written**, at every roster length, with no fixture required. That check is what -would have caught rounds 2, 3 and 4's blockers on the round they were written -instead of one review round each; it is also what found rows 18-21. +`test_peakguard.py` part 12j enumerates the family from the AST and requires +every member to take the roster **whole**, so a new reduction over a slice is red +**on the line it is written**, at every roster length, with no fixture required. +That check is what would have caught rounds 2, 3 and 4's blockers on the round +they were written instead of one review round each; it is also what found rows +18-21. + +⚠️ **AND IT WAS STILL TOO NARROW — see §4d.** Round 4's version enumerated the +26 roster comprehensions inside the `Controller` class, because that is where +the four known sites were. Round 5's blockers were four reductions in +`validate.py`, one module over, over a list *derived* from the roster three +calls away from it. The pin now parses every `.py` under `app/` and finds the +roster by transitive closure from one option key: **183 reductions across 9 +modules.** + +## 4d. ⚠️ ROUND 5 — the same family one MODULE over, and an instrument that discovers its own scope + +Round 4 enumerated the family and closed it **inside the `Controller` class**, +because that is where the four known sites lived. The review found four more +survivors immediately, in `validate.py`, on a 19/19 green suite: + +| site | mutation | delta | suite | +|---|---|---|---| +| `validate.py:779` | `min(caps)` → `min(caps[:1])` | +4 B | **GREEN** | +| `validate.py:779` | `min(caps)` → `min(caps[-1:])` | +5 B | **GREEN** | +| `validate.py:778` | `caps = [cap for _, cap in caps_ix[:1]]` | +4 B | **GREEN** | +| `validate.py:893` | `min(caps_ix[-1:], key=lambda p: p[1])` | +5 B | **GREEN** | + +**None of them is an equivalent mutant — a safety refusal disappears.** With +`max_w: 5000` on inverter-a beside `2000` on inverter-b and +`sign_disagree_w: 3000`, the shipped validator answers one problem — +*"sign_disagree_w is 3000 W but max_w (inverter-b) is 2000 W, so the SAFETY-02 +sign-disagreement watchdog can never fire"* — and every one of the four answers +**zero**. The site starts, with a watchdog reporting `armed` on the status page +that cannot trip on pack B. That is rule 5's own failure mode reached **through** +rule 5, which is the sentence `test_inverter.py:1657` says the rule exists to +prevent. Rule 6 loses its refusal the same way. + +### Why round 4's pin could not see them, and why the fix is not four more checks + +`caps` and `caps_ix` are not comprehensions over `self.inverters`; they are lists +**derived** from the roster, three function calls away from it, in a module the +enumeration never opened. Closing them by hand would have produced a fifth +rejection with the same shape one step over — that is the whole record of this +card. **A hand-written scope is the same defect as a hand-written fixture** +(MUTATION.md habit 12): a check whose data cannot reach the failure it names. + +### The instrument + +`test_peakguard.py` part 12j now parses **every `.py` under `app/`** off disk — +no module is named anywhere in the check — and finds the roster by transitive +closure from **one seed**, the `inverters` option key: + +* a function whose returns are roster-carrying is **roster-producing** + (`roster_entries` → `build_roster` → `unit_caps_indexed` → `unit_caps`); +* a name assigned one carries it, **and so does a list accumulated in a loop + over one** — which is exactly what `caps`, `roster` and `problems` are, and + what round 4's rule could not express; +* an attribute or property answering one carries it (`self.inverters`, + `Controller.packs`, `Controller.units`, `live_units()`); +* **an argument at a call site taints that parameter in the callee**, which is + how the pin walks into `peakguard.binding_soc_floor`, `arbiter.resolve`, + `allocator.plan`, `circulation.circulating_w`, `maintenance.arbitrate` and + `store.migrate_maint_keys` without being told they exist; +* and **a slice of a roster is still a roster**, so a mutation cannot cut the + taint and hide every reduction downstream of itself. An **index** is one unit + and is not. + +It then requires, at every place a roster-carrying value is reduced or iterated, +that the whole of it arrives: no `[:1]`, `[-1:]`, `[:2]` or `[0]` between the +roster and the reduction. + +### What it found — and the hand-written list was wrong in BOTH directions + +**183 reductions across 9 modules**, out of 23 `.py` files parsed: +`main.py` 96, `allocator.py` 24, `validate.py` 19, `arbiter.py` 14, +`circulation.py` 10, `maintenance.py` 10, `peakguard.py` 4, `inverter.py` 3, +`store.py` 3 — and 14 modules with no roster reduction at all. + +The card's own suggested module list named `watchdog.py`, which reduces over no +roster at all (SAFETY-30's sign watchdogs are strictly per-unit — there is +nothing to slice), and did **not** name `maintenance.py` or `store.py`, which +both do. A pin built from that list would have made a vacuous claim about one +module and no claim at all about two others. That is the argument for computing +the scope, made with data rather than with an assertion. + +The pin **independently rediscovers all four blockers** — it is told one option +key and nothing else — and finds a **fifth** beside them at +`validate.py:783`, the `min(caps_ix, key=...)` inside rule 5's message, which no +review round named. + +### The counterfactual, at two depths + +* **Per site, all of them.** Every enumerated site's own expression, in its own + scope, mutated with each of the four cuts this card has actually been defeated + by. **183 sites × 4 cuts = 732** mutated spellings; every one reported. The same run asserts + the predicate **accepts** the legitimate wrapped forms the package really uses + (`enumerate`, `zip`, `sorted`, `pack_permissions(packs, floor)`) and does not + treat a keyed lookup (`weights[u]` in `allocator._fill`) as a slice — which is + what four false SLICED reports cost before `shortens()` was written. +* **End to end, on the real files.** `[:1]` written into `app/validate.py` at + each blocker position, the **whole** analysis re-run over the mutated source, + and a sliced site required at that exact line — where the clean package + reports none. This is the check that would have taken PR #48 red four rounds + ago, with no fixture reaching it. + +### The sweep, and the number that must not be quoted alone + +All 183 sites were mutated to `[:1]`, one at a time, +4 bytes each, and each +mutant run against the full 19-file suite in `goodwe-addon-ci` (mounted `:ro`, +`python3 -B`, a fresh rsync per mutant; 0 `.pyc` in the tree before and after — +a read-only mount cannot write one, so TEST-15 is closed by construction here +rather than by discipline). **183 of 183 RED.** + +⚠️ **On its own that is tautological**: with the pin in the tree every slice is +red *by the pin*. So all 51 mutants whose first red was `test_peakguard.py` were +re-run with the pin **neutralised** (`_SLICED` blanked), leaving only the +behavioural checks: + +| | | +|---|---| +| **145 of 183** | a behavioural fixture in this repository separates the site from a slice of itself — red with the pin turned off | +| **38 of 183** | **nothing but this pin sees it.** Structurally closed by this card; behaviourally **UNGRADED**, and listed rather than counted as covered | + +The 38: `main.py` 27, `allocator.py` 6, `validate.py` 2, `arbiter.py` 1, +`circulation.py` 1, `maintenance.py` 1. Grouped by what a `[:1]` there costs: + +* **The operator's page — 15, the largest group.** `Controller.checks` + (3310, 3311, 3319, 3328, 3330, 3347, 3368, 3370, 3400, 3458) and + `status_payload` (3556, 3566, 3578). A slice reports one unit's row where two + exist, or "1 of 2 live" reading 1 of 1. Nothing commands differently; what is + lost is the evidence a human uses to notice that it should. +* **The write path's own counts.** `write_setpoint` 1267/1272/1280 — three + `len(targets)` guards. The write itself iterates `targets` at 1287 and *is* + graded (test_inverter.py); these three are its log arithmetic and refusal + count. +* **The allocator's internals.** `_fill`'s zero-weight last-resort pool (365), + `plan`'s `dropped` (526, 653), `free` (584), `frac_order` (591). ALLOC-01 + drives two and three units through the *weighted* pass; the last-resort pass + and the largest-remainder tie-break are reached, but not with enough units for + a slice to change the answer. +* **Startup and FSM plumbing.** `__init__` 235/351, `_only` 831, + `_maint_button` 805, `cycle`'s maintenance zip 1505, `send_heartbeats` 3015, + `check_output_clamp` 2445/2452, `check_circulating` 2701, + `maintenance.arbitrate` 547, `arbiter.site_resolve` 517, + `circulation.opposing` 242. +* **Two refusal-TEXT sites.** `validate._roster_problems`'s `len(entries)` (554) + and `refuse()`'s numbering loop (1263) — both shape the words of a refusal + that still happens. +* **`_grade_store_migration` (698)**, already row 21 of §4c, argued there to be + an equivalent mutant at that call site. The argument is unchanged and is still + an argument, not a proof. + +⚠️ **None of the 38 is closed behaviourally by this card, and saying so is the +point.** Closing sites one at a time is what cost this card four review rounds. +The pin closes the **family** — a slice at any of them is red on the line it is +written — and this list is what a shape pin can never promise. + +### And the behavioural half, because a shape pin cannot grade a meaning + +The pin grades an **identity** ("the whole roster arrives"). `min` → `max` is a +zero-byte edit and invisible to it, forever. So `test_validate.py` grows a +**roster SET** for the three rules that reduce over `unit_caps_indexed()`: +rosters of width 2, 3, 4 and 5 with the one small cap at **every position** — +14 rosters × 3 rules = 42 runs of the real validator — plus a **computed family +table** of twelve candidate reductions (`max`, `first`, `last`, the mean, and +`[:1]`, `[-1:]`, `[:2]`, `[:3]`, `[1:]`, `[:-1]`, `[::2]`) each compared against +what the shipped validator actually said on the same roster. Only `min` agrees +everywhere; any member no roster kills is **printed as a named survivor** and +takes the check red. + +### ⚠️ A THIRD RULE WAS READING THE FLAT `max_w`, AND IT IS SAFETY-30's + +Found by reading the three branches side by side — **not** by the pin, and said +that way because it is the shape of what the pin cannot see. Rule 5's sibling +branch, `unit_sign_disagree_w` (SAFETY-30's per-inverter sign watchdog), was +graded against `_number(opts, "max_w", 2000.0)` — the **flat** option — three +lines under a branch that had already stopped doing that. Both directions were +measured before the fix: + +* a roster of two 2000 W units beside a leftover `max_w: 5000` from a + single-inverter install, with `unit_sign_disagree_w: 3000`, produced **zero + problems**. The site started, the status page said `armed`, and the per-unit + watchdog could not trip on **either** unit. Identical sentence to the blocker, + reached through the branch beside it; +* and a roster of two 5000 W units with no flat key was **refused** at 3000 W + against a 2000 W *default the operator never wrote*, for a threshold both + units can reach. + +It takes the same `weakest` the branch above takes, from the same list, and +names the same unit. At a roster of one `unit_caps()` is `[max_w]`, so the +shipped single-inverter refusal is the same comparison, the same boundary and +the same sentence. Both directions are asserted in `test_validate.py`. + +### ⚠️ WHAT THE PIN CANNOT SEE — open rows, with sizes + +1. **Semantics.** `min` vs `max` vs the mean: zero-byte edits, invisible to any + shape pin. Only the behavioural sets above can see them, and they only see + the rules they drive. +2. **Direct index sites that are not reductions.** `self.inverters[0]` in + `_only()` and `actuator_of()`, `self.inverters[self.maint_index]` in + `maint_unit`, `entries[index]` in `_unit_label()`. Single-unit lookups, meant + to be, graded where they live. +3. **A subscript by a NAME.** `weights[u]`, `cap[u]`, `add[u]` in + `allocator._fill` are keyed lookups into a roster-derived mapping and are + accepted deliberately, so `caps[i]` with a variable `i` would pass. Not in + the family this card has been defeated by; buying it costs four false reports. +4. **A slice through an intermediate call.** `sorted(roster)[:1]` subscripts a + call result, not a roster reference. Nothing in `app/` is written that way + today; if something is, it would not be seen. +5. **Anything outside `app/`.** `tools/`, `sim/` and the checks themselves are + not parsed — and `sim/inject.py` reaches `validate_startup_config()`. +6. **The taint's own seam.** It crosses a call boundary only where the callee is + a plain function this package defines and the argument is positional or + keyword. A roster handed through `*args`, stored in a dict and read back, or + passed to a method on an unresolvable object, is lost at that hop — and + everything downstream goes **unenumerated** rather than reported unreachable. + That is the shape of the remaining exposure: not zero, and not measurable + from inside the check. +7. **`cycle()`'s undeliverable-unit log line** (row 22 of §4c) is still green at + +4 bytes, unchanged from round 4 and still filed open. ## 5. ⚠️ THE RESIDUAL — a per-pack EMS floor is NOT this card @@ -546,7 +760,7 @@ A's measured 10 % while the 35 % cut-out that moves along the roster is invented --- -## 7. The probe transcript, re-run 2026-09-04 on 2026.08.0 +## 7. The probe transcript, re-run 2026-09-05 on 2026.08.0 Command (unchanged, in `tools/supervisor_shape_probe.py`'s own docstring): @@ -561,6 +775,14 @@ It reads `config.yaml` and runs Supervisor's own `AppOptions` validator in a throwaway container. It touches no live Supervisor, POSTs nothing and installs nothing. +⚠️ **RE-RUN AGAIN ON 2026-09-05**, because the rebase onto `67e649a` merged +SPIKE-04a's `max_w: 4600` into the same §C/§D blocks this card had rewritten, +and MUTATION.md habit 8 says a committed transcript that a document quotes is a +check. A hand-merged transcript is not a measurement of anything. The block +below is the output of that run, not of the 2026-09-04 one: B's rating reads +**4600** — its datasheet figure, still not a measurement of any unit — and every +case still passes. + ``` == C. AC2: a two-inverter blob an operator would POST == PASS two inverters @@ -568,7 +790,7 @@ nothing. == D. what the schema itself refuses, per entry == PASS the per-entry BASELINE is ACCEPTED, so every refusal below has nowhere else to come from - [{"name": "inverter-b", "setpoint_entity": "number.b", "max_w": 3000, "bms_soc_floor": 10}] + [{"name": "inverter-b", "setpoint_entity": "number.b", "max_w": 4600, "bms_soc_floor": 10}] PASS an entry with no name is refused, and the refusal NAMES it Missing option 'name' in inverters in GoodWe RS485 Controller (local_goodwe_controller) PASS an entry with no setpoint_entity is refused, and the refusal NAMES it diff --git a/goodwe_controller/MUTATION.md b/goodwe_controller/MUTATION.md index a4dd296..c95bbed 100644 --- a/goodwe_controller/MUTATION.md +++ b/goodwe_controller/MUTATION.md @@ -332,6 +332,37 @@ Cheap habits fall out of it, and all of them are now used in this repository: sibling of the thing I just hardened, and does any fixture separate it from a slice of itself?* +12. **When the same shape has been found twice, STOP CLOSING SITES AND BUILD + THE INSTRUMENT — and make it DISCOVER its own scope.** SAFETY-32 was + rejected four times, and each round closed exactly the site it was handed: + the reduction, then the roster handed to it, then the *sibling argument* of + the same call, then the same family one *module* over, in validate.py. + Round 4 did enumerate — and enumerated inside the `Controller` class, + because that is where the four known sites lived. **A hand-written scope is + the same defect as a hand-written fixture**: it is a check whose data cannot + reach the failure it names, and it goes on passing while the thing it was + supposed to cover sits in the file nobody added to the list. Round 5's pin + parses every `.py` under `app/`, seeds itself from ONE option key, and + propagates through returns, accumulator loops, attributes and *call + arguments* to find the roster wherever it went — 183 reductions across 9 + modules. It rediscovered all four blockers without being told where to look, + and it named two modules the hand-written list would not have contained + (`maintenance.py`, `store.py`) while *omitting* one the list did contain + (`watchdog.py`, which is strictly per-unit and reduces over no roster at + all). **The list was wrong in both directions**, which is exactly what a + computed scope is for. + +13. **Say what the instrument CANNOT see, with sizes, in the same breath.** A + structural pin grades an **identity** ("this whole roster arrives"); it can + never grade a **meaning** (`min` vs `max` is a zero-byte edit and invisible + to it). Ship the pin AND the behavioural set, name the gap, and give every + unreached case its byte delta — `cycle()`'s undeliverable-unit log line + (+4 B, still green), a subscript by a *variable* rather than a constant, a + slice taken through an intermediate call, anything outside `app/`. **An + enumerated list with an honest gap is worth more than a claim of + completeness a reviewer falsifies in ten minutes**, and every round of this + card that claimed the latter was rejected for it. + 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 diff --git a/goodwe_controller/app/validate.py b/goodwe_controller/app/validate.py index ba8e253..7ce3720 100644 --- a/goodwe_controller/app/validate.py +++ b/goodwe_controller/app/validate.py @@ -806,21 +806,48 @@ def validate_startup_config(opts: dict, dev_min=None, dev_max=None) -> list[str] # second inverter is added - which is the day nobody is watching the startup # log. This is `meter_max_age_s`'s rule read the same way: the failure it # prevents is silent. + # + # ⚠️ AND IT READS THE WEAKEST UNIT'S CAP, NOT THE FLAT `max_w` - SAFETY-32 + # ROUND 5. This branch used to compare against `max_w = _number(opts, + # "max_w", 2000.0)`, the FLAT option, which on a roster config is either + # ABSENT (so it compared against a 2000 W DEFAULT describing no unit on the + # site) or a leftover from a single-inverter install (so it compared against + # a number no unit has). Both directions were live and both were measured: + # + # a roster of two 2000 W units beside a leftover `max_w: 5000` and + # `unit_sign_disagree_w: 3000` produced ZERO problems - the site STARTED, + # with a per-inverter watchdog reporting `armed` on the status page and + # incapable of tripping on EITHER unit. That is the identical sentence the + # branch above exists to refuse, reached through the branch beside it; + # + # and a roster of two 5000 W units with no flat key was REFUSED at + # `unit_sign_disagree_w: 3000` against a 2000 W default the operator never + # wrote, for a threshold both units can reach. + # + # `unit_caps()`'s own docstring already said why: there is no such thing as + # a site rating on the wire, and this watchdog grades ONE UNIT's standing + # command against THAT unit's `supervised` bound. So it takes the same + # `weakest` the branch above takes, from the same list, and names the same + # unit. At a roster of one `unit_caps()` is `[max_w]` and this is the + # identical comparison it always was - which is what keeps the shipped + # single-inverter refusal byte-for-byte what it was. unit_sign_w = _number(opts, "unit_sign_disagree_w", float(SIGN_OPTION_DEFAULTS["unit_sign_disagree_w"])) + unit_where = (_unit_label(opts, min(caps_ix, key=lambda p: p[1])[0]) + if caps_ix else "") if unit_sign_w is None: problems.append(f"unit_sign_disagree_w " f"{opts.get('unit_sign_disagree_w')!r} is not a number") - elif max_w is not None and max_w > 0 and unit_sign_w >= max_w: + elif weakest is not None and weakest > 0 and unit_sign_w >= weakest: problems.append( - f"unit_sign_disagree_w is {unit_sign_w:.0f} W but max_w is " - f"{max_w:.0f} W, so the SAFETY-30 per-inverter sign-disagreement " + f"unit_sign_disagree_w is {unit_sign_w:.0f} W but max_w{unit_where}" + f" is {weakest:.0f} W, so the SAFETY-30 per-inverter sign-disagreement " "watchdog can never fire: no inverter is ever commanded past max_w, " f"and the watchdog needs a command over {unit_sign_w:.0f} W. At two " "inverters that is the guard which catches a unit doing the " "OPPOSITE of what it was told while its sibling covers for it at " "the meter - the case the site-level watchdog is structurally " - f"blind to. Lower unit_sign_disagree_w below {max_w:.0f} W " + f"blind to. Lower unit_sign_disagree_w below {weakest:.0f} W " f"({SIGN_OPTION_DEFAULTS['unit_sign_disagree_w']:.0f} W is the " "measured default), or raise max_w.") diff --git a/goodwe_controller/test_peakguard.py b/goodwe_controller/test_peakguard.py index e2b7dce..2577647 100644 --- a/goodwe_controller/test_peakguard.py +++ b/goodwe_controller/test_peakguard.py @@ -4618,127 +4618,563 @@ check("⚠️ AND THE SECOND CONSUMER OF THE SAME REDUCTION. `arbitrage.decide() "to a per-unit reading, or defaulted it, would take this line red", len(_arb_calls) == 1 and _arb_soc == "self.pack_floor_soc") -# --- 12j. THE ENUMERATION ITSELF, AS A RUNNING CHECK ------------------------- -# ⚠️ THIS CARD HAS NOW BEEN REJECTED THREE TIMES FOR THE SAME SHAPE ONE STEP +# --- 12j. THE ENUMERATION ITSELF, AS A RUNNING CHECK, ACROSS THE PACKAGE ------ +# ⚠️ THIS CARD HAS NOW BEEN REJECTED FOUR TIMES FOR THE SAME SHAPE ONE STEP # OVER: a reduction over roster length whose fixture could not separate it from # a slice of the roster. Round 2 hardened `binding_soc_floor()`; round 3 found -# the call site's argument; round 4's blocker was `pack_floor_soc`, the SIBLING -# ARGUMENT of the same `decide()` call. Closing them one at a time has cost four -# rounds, so this block stops enumerating them by hand. +# the call site's argument; round 4 found `pack_floor_soc`, the SIBLING ARGUMENT +# of the same `decide()` call, and wrote the first version of this block - which +# enumerated the family inside `Controller` AND STOPPED AT THE CLASS BOUNDARY. +# Round 5's blocker was four survivors in validate.py, one module over, where +# rules 5 and 6 reduce over `unit_caps_indexed()`. Closing them one at a time +# has cost five rounds. This block does not do that any more. # -# EVERY comprehension in `Controller` that iterates the roster is listed here, -# COMPUTED from main.py's AST, PRINTED, and required to iterate the roster -# WHOLE. That is the family: `min`, `max`, `sum`, `any`, `all`, `sorted` and the -# list builders all take their roster through one of these, so a `[:1]`, a -# `[-1:]` or a `[:2]` anywhere in the class takes this line red on the line it -# was written - at every roster length, in every configuration, whether or not -# any fixture in this repository reaches it. +# THE INSTRUMENT IS NOW PACKAGE-WIDE AND THE MODULE LIST IS COMPUTED, NOT +# WRITTEN. Every `.py` under `app/` is read off disk and parsed; nothing here +# names a module, so a module added tomorrow is graded on the day it is added. +# ⚠️ A HARDCODED MODULE LIST WOULD ITSELF BE "A CHECK WHOSE DATA CANNOT REACH +# THE FAILURE IT NAMES" (MUTATION.md), which is the defect this file exists to +# complain about: the list would go on passing while the reduction it was +# supposed to cover lived in the module nobody added to it. # -# ⚠️ WHAT IT DOES NOT COVER, said plainly. It grades the ITERABLE and nothing -# else: not which attribute is read off each unit, not which reduction is -# applied, and not the direct index sites (`self.inverters[0]` in `_only()`, -# `actuator_of()` and the store migration, `self.inverters[self.maint_index]` in -# `maint_unit`), which are not comprehensions and are graded where they live. -# The behavioural halves above are what say what these reductions MEAN. -_ctrl_cls = next(n for n in ast.walk(ast.parse(_mainsrc)) - if isinstance(n, ast.ClassDef) and n.name == "Controller") -_ROSTER_ATTRS = ("inverters", "packs") +# WHAT COUNTS AS A ROSTER, DERIVED RATHER THAN DECLARED. The seed is ONE +# expression - reading the `inverters` option - and everything else is transitive +# closure over the AST: +# * a function all of whose returns are roster-carrying is roster-producing +# (`roster_entries` -> `build_roster` -> `unit_caps_indexed` -> `unit_caps`); +# * a name assigned a roster-carrying value carries it, and so does a list +# ACCUMULATED inside a loop over one (`caps`, `roster`, `problems`); +# * an attribute or property that answers one carries it (`self.inverters`, +# `Controller.packs`, `Controller.units`, `Controller.live_units()`); +# * an argument at a call site that is handed one taints THAT PARAMETER in the +# callee, which is how the pin crosses into `peakguard.binding_soc_floor`, +# `arbiter.resolve`, `allocator.plan`, `circulation.circulating_w`, +# `maintenance.arbitrate` and `store.migrate_maint_keys` without being told +# they exist; +# * and A SLICE OF A ROSTER IS STILL A ROSTER, so a mutation cannot cut the +# taint and hide the reductions downstream of itself. +# An INDEX of a roster is NOT a roster: `self.inverters[0]` is one unit, and +# treating it as a roster would spread this analysis over every unit attribute +# in the package. +# +# THE CLAIM, in one line: at every place in `app/` where a roster-carrying value +# is REDUCED or ITERATED, the whole of it arrives. A `[:1]`, a `[-1:]`, a `[:2]` +# or a `[0]` between the roster and the reduction is red on the line it is +# written, at every roster length, in every configuration, and with no fixture +# required. +_APP_SRC = {p.stem: p.read_text(encoding="utf-8") + for p in sorted((HERE / "app").glob("*.py"))} +_ROSTER_OPTION = "inverters" # the ONE seed. Everything else is closure. +_WRAPPERS = {"list", "tuple", "sorted", "enumerate", "zip", "reversed", + "filter", "iter", "set", "dict"} +_REDUCERS = {"min", "max", "sum", "any", "all", "len", "sorted", "next", "list", + "tuple", "set", "reversed", "enumerate", "zip", "map", "filter"} -def _is_roster(node): - return (isinstance(node, ast.Attribute) and node.attr in _ROSTER_ATTRS - and isinstance(node.value, ast.Name) and node.value.id == "self") +def _roster_sites(sources): + """Every roster-carrying expression in reduction/iteration position. - -def _mentions_roster(node): - return any(_is_roster(n) for n in ast.walk(node)) - - -_roster_comps = [] -for _fn in ast.walk(_ctrl_cls): - if not isinstance(_fn, (ast.FunctionDef, ast.AsyncFunctionDef)): - continue - for _n in ast.walk(_fn): - if isinstance(_n, (ast.ListComp, ast.SetComp, ast.DictComp, - ast.GeneratorExp)): - for _g in _n.generators: - if _mentions_roster(_g.iter): - _roster_comps.append((_fn.name, _g.iter.lineno, - ast.unparse(_g.iter))) -_roster_comps.sort(key=lambda r: (r[1], r[2])) - - -def _slices_roster(node): - """Is the roster SUBSCRIPTED anywhere inside this expression? - - ⚠️ THE TEST IS ON THE SUBSCRIPT, NOT ON THE SPELLING. `enumerate(...)`, - `zip(...)` and `pack_permissions(roster, floor)` all hand the WHOLE roster - on and are legitimate; `self.inverters[:1]` is the family this card has been - rejected over three times. Requiring the iterable to unparse to exactly - `self.inverters` would refuse the first three and grade the spelling instead - of the thing. + Returns `(rows, meta)`; each row is + `(module, lineno, col, end_lineno, end_col, qualname, kind, source, sliced)`. """ - return any(isinstance(n, ast.Subscript) and _is_roster(n.value) - for n in ast.walk(node)) + trees = {m: ast.parse(s) for m, s in sources.items()} + funcs, importmap = {}, {} + + for m, tree in trees.items(): + def walk(node, qual): + for ch in ast.iter_child_nodes(node): + if isinstance(ch, (ast.FunctionDef, ast.AsyncFunctionDef)): + funcs[(m, qual + ch.name)] = ch + walk(ch, qual + ch.name + ".") + elif isinstance(ch, ast.ClassDef): + walk(ch, qual + ch.name + ".") + else: + walk(ch, qual) + walk(tree, "") + for n in ast.walk(tree): + if isinstance(n, ast.ImportFrom): + if n.module: + base = n.module.split(".")[-1] + if base in trees: + for a in n.names: + importmap[(m, a.asname or a.name)] = (base, a.name) + else: + # `from . import peakguard` - a MODULE, not a name inside + # one. This branch is the only reason the pin can see + # peakguard.py, and it was missing from the first draft. + for a in n.names: + if a.name in trees: + importmap[(m, a.asname or a.name)] = (a.name, None) + elif isinstance(n, ast.Import): + for a in n.names: + base = a.name.split(".")[-1] + if base in trees: + importmap[(m, a.asname or base)] = (base, None) + + t_funcs, t_params, t_attrs, t_locals = set(), set(), set(), {} + bodies = {} + + def body_nodes(fn): + if id(fn) not in bodies: + out = [] + + def rec(n): + for ch in ast.iter_child_nodes(n): + if isinstance(ch, (ast.FunctionDef, ast.AsyncFunctionDef, + ast.Lambda)): + continue + out.append(ch) + rec(ch) + rec(fn) + bodies[id(fn)] = out + return bodies[id(fn)] + + def callee(m, qual, node): + f = node.func + if isinstance(f, ast.Name): + if (m, f.id) in importmap: + om, oname = importmap[(m, f.id)] + if oname and (om, oname) in funcs: + return (om, oname) + if (m, f.id) in funcs: + return (m, f.id) + elif isinstance(f, ast.Attribute): + if isinstance(f.value, ast.Name): + if f.value.id == "self" and "." in qual: + cls = qual.rsplit(".", 1)[0] + if (m, cls + "." + f.attr) in funcs: + return (m, cls + "." + f.attr) + if (m, f.value.id) in importmap: + om, _ = importmap[(m, f.value.id)] + if (om, f.attr) in funcs: + return (om, f.attr) + return None + + def derived(node, m, qual): + if node is None: + return False + if isinstance(node, ast.Subscript): + # A SLICE of a roster is still a roster - a defective one - so the + # taint survives the mutation and every reduction downstream of it + # reports too. An INDEX is one unit and is not a roster. + return (isinstance(node.slice, ast.Slice) + and derived(node.value, m, qual)) + if isinstance(node, ast.Call): + f = node.func + if (isinstance(f, ast.Attribute) and f.attr in ("get", "pop") + and node.args and isinstance(node.args[0], ast.Constant) + and node.args[0].value == _ROSTER_OPTION): + return True # THE SEED + if isinstance(f, ast.Name) and f.id in _WRAPPERS: + return any(derived(a, m, qual) for a in node.args) + tgt = callee(m, qual, node) + return bool(tgt and tgt in t_funcs) + if isinstance(node, ast.Name): + return (node.id in t_locals.get((m, qual), ()) + or (m, qual, node.id) in t_params) + if isinstance(node, ast.Attribute): + if isinstance(node.value, ast.Name) and node.value.id == "self": + if node.attr in t_attrs: + return True + cls = qual.rsplit(".", 1)[0] if "." in qual else None + return bool(cls and (m, cls + "." + node.attr) in t_funcs) + return False + if isinstance(node, (ast.ListComp, ast.SetComp, ast.DictComp, + ast.GeneratorExp)): + return any(derived(g.iter, m, qual) for g in node.generators) + if isinstance(node, ast.IfExp): + return (derived(node.body, m, qual) + or derived(node.orelse, m, qual)) + if isinstance(node, ast.BoolOp): + return any(derived(v, m, qual) for v in node.values) + if isinstance(node, (ast.List, ast.Tuple)): + return any(isinstance(e, ast.Starred) and derived(e.value, m, qual) + for e in node.elts) + return False + + for _ in range(40): # transitive closure, to fixpoint + before = (len(t_funcs), len(t_params), len(t_attrs), + sum(len(v) for v in t_locals.values())) + for (m, qual), fn in funcs.items(): + loc = t_locals.setdefault((m, qual), set()) + for n in body_nodes(fn): + if (isinstance(n, (ast.Assign, ast.AnnAssign)) + and getattr(n, "value", None) is not None + and derived(n.value, m, qual)): + for t in (n.targets if isinstance(n, ast.Assign) + else [n.target]): + if isinstance(t, ast.Name): + loc.add(t.id) + elif (isinstance(t, ast.Attribute) + and isinstance(t.value, ast.Name) + and t.value.id == "self"): + t_attrs.add(t.attr) + if (isinstance(n, (ast.For, ast.AsyncFor)) + and derived(n.iter, m, qual)): + # A list ACCUMULATED one-per-unit is a roster too: `caps`, + # `roster`, `problems` are all built this way, and round 5's + # blocker was two reductions over one of them. + for c in ast.walk(n): + if (isinstance(c, ast.Call) + and isinstance(c.func, ast.Attribute) + and c.func.attr in ("append", "extend") + and isinstance(c.func.value, ast.Name)): + loc.add(c.func.value.id) + if (isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute) + and n.func.attr == "extend" + and isinstance(n.func.value, ast.Name) + and n.args and derived(n.args[0], m, qual)): + loc.add(n.func.value.id) + if isinstance(n, ast.Return) and derived(n.value, m, qual): + t_funcs.add((m, qual)) + if isinstance(n, ast.Call): + tgt = callee(m, qual, n) + if tgt: + cfn = funcs[tgt] + pos = [a.arg for a in + cfn.args.posonlyargs + cfn.args.args] + off = 1 if (pos and pos[0] == "self") else 0 + for j, a in enumerate(n.args): + if j + off < len(pos) and derived(a, m, qual): + t_params.add((tgt[0], tgt[1], pos[j + off])) + for kw in n.keywords: + if kw.arg and derived(kw.value, m, qual): + t_params.add((tgt[0], tgt[1], kw.arg)) + after = (len(t_funcs), len(t_params), len(t_attrs), + sum(len(v) for v in t_locals.values())) + if after == before: + break + + def shortens(sub): + """Does this subscript change HOW MANY members come out? + + A SLICE does, and so does a CONSTANT index (N in, one out). A subscript + by a NAME does not: `weights[u]`, `cap[u]` and `add[u]` in + `allocator._fill` are keyed lookups into a roster-derived MAPPING, which + is what iterating it looks like. Flagging those would make this check cry + wolf on every dict the roster builds - it did, on four sites, before this + predicate was written. + """ + sl = sub.slice + if isinstance(sl, ast.Slice): + return True + if isinstance(sl, ast.Constant) and isinstance(sl.value, int): + return True + return (isinstance(sl, ast.UnaryOp) and isinstance(sl.op, ast.USub) + and isinstance(sl.operand, ast.Constant)) + + def sliced(node, m, qual): + return any(isinstance(n, ast.Subscript) and shortens(n) + and derived(n.value, m, qual) for n in ast.walk(node)) + + def leaves(node, m, qual): + """The roster REFERENCES inside a reduced expression. + + The place a slice goes is the roster reference, not the whole argument: + `sum(i.max_w for i in self.inverters)` is defeated by slicing + `self.inverters`, and slicing the generator would be a TypeError rather + than a wrong answer - a mutation that crashes proves nothing. + """ + out = [] + + def rec(n): + if (isinstance(n, (ast.Name, ast.Attribute, ast.Call, + ast.Subscript)) and derived(n, m, qual)): + out.append(n) + if isinstance(n, ast.Call): + for a in n.args: + rec(a) + return + for c in ast.iter_child_nodes(n): + rec(c) + rec(node) + return out + + seen = {} + for (m, qual), fn in funcs.items(): + def note(kind, expr): + bad = sliced(expr, m, qual) + for lf in leaves(expr, m, qual): + key = (m, lf.lineno, lf.col_offset, lf.end_lineno, + lf.end_col_offset) + seen.setdefault(key, (qual, kind, ast.unparse(lf), bad)) + for n in body_nodes(fn): + if isinstance(n, (ast.ListComp, ast.SetComp, ast.DictComp, + ast.GeneratorExp)): + for g in n.generators: + if derived(g.iter, m, qual) or sliced(g.iter, m, qual): + note("for..in", g.iter) + elif isinstance(n, (ast.For, ast.AsyncFor)): + if derived(n.iter, m, qual) or sliced(n.iter, m, qual): + note("for stmt", n.iter) + elif (isinstance(n, ast.Call) and isinstance(n.func, ast.Name) + and n.func.id in _REDUCERS): + for a in n.args: + if derived(a, m, qual) or sliced(a, m, qual): + note(n.func.id + "()", a) + def probe(m, qual, expr_src): + """Would this expression, IN THAT SCOPE, be reported as sliced? + + The per-site counterfactual runs through here: it is the SAME predicate + the enumeration above is graded with, asked about a mutated spelling of + the site's own expression, so a predicate that stopped answering would + take both to the same place. + """ + return sliced(ast.parse(expr_src, mode="eval").body, m, qual) + + rows = sorted((k + v) for k, v in seen.items()) + return rows, (sorted(t_funcs), sorted(t_params), sorted(t_attrs), probe) -_sliced = [r for r in _roster_comps - if _slices_roster(ast.parse(r[2], mode="eval").body)] -print(f" EVERY comprehension in Controller that iterates the roster " - f"({len(_roster_comps)} of them, in " - f"{len({m for m, _l, _s in _roster_comps})} methods), and what each " - f"iterates:") -for _mname, _ln, _src in _roster_comps: - print(f" main.py:{_ln:<5} {_mname:<26} for ... in {_src}" - + ("" if _src in ("self.inverters", "self.packs") - else " [whole roster, wrapped]")) -check("⚠️ THE FAMILY, ENUMERATED FROM THE SOURCE RATHER THAN FROM MEMORY. Every " - "comprehension in `Controller` that iterates the roster iterates it " - "WHOLE - `self.inverters` or `self.packs`, bare. This is the check that " - "would have caught rounds 2, 3 and 4's blockers on the round they were " - "written instead of one review each: `min(... for v in vals[:2])` is not " - "here (it reduces a derived list, which 12i pins directly), but every " - "`for inv in self.inverters[:1]` in the class is. A new reduction added " - "over a slice of the roster is red on the line it is written, at every " - "roster length and with no fixture required. ⚠️ THE ITERABLE ONLY - what " - "is read off each unit and what reduction is applied are graded by the " - "runs, and the direct index sites are not comprehensions and not here", - len(_roster_comps) > 0 and not _sliced) -check("...and the enumeration is not vacuously empty and its predicate is not " - "trivially true (habit 7): it found more than twenty roster " - "comprehensions across more than fifteen methods, it REJECTS the same " - "expressions with a slice on them - bare and wrapped in `enumerate()` " - "alike - and it ACCEPTS the three legitimate wrapped forms this class " - "actually uses. A predicate that answered the same thing for both would " - "make the line above a description rather than a check", - len(_roster_comps) >= 20 - and len({m for m, _l, _s in _roster_comps}) >= 15 - and all(_slices_roster(ast.parse(f"self.{a}{sl}", mode="eval").body) - and _slices_roster(ast.parse(f"enumerate(self.{a}{sl})", - mode="eval").body) - for a in _ROSTER_ATTRS - for sl in ("[:1]", "[-1:]", "[:2]", "[1:]", "[:-1]", "[::2]")) - and not any(_slices_roster(ast.parse(e, mode="eval").body) - for e in ("self.inverters", "self.packs", - "enumerate(self.inverters)", - "peakguard.pack_permissions(self.packs, f)"))) -# ⚠️ AND THE TWO THIS ENUMERATION ACTUALLY FOUND, recorded here because the -# lesson is the finding and not the fix. Mutating every roster reduction in -# `Controller` to `[:1]` one at a time turned up TWO that survived a green -# 19-file suite, both `any(i.sign_wd.tripped for i in self.inverters)` and both -# SAFETY-30's rather than this card's: +_SITES, (_TFUNCS, _TPARAMS, _TATTRS, _PROBE) = _roster_sites(_APP_SRC) +_SLICED = [r for r in _SITES if r[8]] +_BY_MOD = {} +for _r in _SITES: + _BY_MOD.setdefault(_r[0], []).append(_r) + + +def _slice_at(row, cut="[:1]"): + """The package source with ONE roster reference sliced, for a counterfactual.""" + m, _ln, _c, eln, ec = row[:5] + src = dict(_APP_SRC) + lines = src[m].split("\n") + lines[eln - 1] = lines[eln - 1][:ec] + cut + lines[eln - 1][ec:] + src[m] = "\n".join(lines) + return src + + +print(f" {len(_APP_SRC)} modules parsed from app/ (NOT a written list); " + f"{len(_BY_MOD)} of them reduce over a roster or a roster-derived list, " + f"and every such reduction is enumerated below " + f"({len(_SITES)} of them):") +for _m in sorted(_BY_MOD): + print(f" {_m}.py: {len(_BY_MOD[_m])}") +for _m in sorted(_BY_MOD): + for _row in _BY_MOD[_m]: + print(f" {'SLICED ' if _row[8] else ' '}{_m}.py:{_row[1]:<5}" + f" {_row[5]:<38} {_row[6]:<10} {_row[7][:56]}") +print(f" modules with NO roster reduction at all: " + f"{sorted(set(_APP_SRC) - set(_BY_MOD))}") +print(f" ...and what made each of them a roster, computed from ONE seed " + f"(`opts[{_ROSTER_OPTION!r}]`): {len(_TFUNCS)} roster-PRODUCING functions, " + f"{len(_TPARAMS)} roster-carrying PARAMETERS across module boundaries, " + f"and the attributes {_TATTRS}:") +print(f" returns a roster: " + + ", ".join(f"{m}.{q}" for m, q in _TFUNCS)) +print(f" is HANDED a roster: " + + ", ".join(f"{m}.{q}({a})" for m, q, a in _TPARAMS)) + +# ⚠️ THE PR #48 ROUND-5 BLOCKERS, REDISCOVERED BY THE INSTRUMENT RATHER THAN +# COPIED FROM THE REVIEW. If this pin did not independently name all of them, +# the pin would be too narrow and THAT would be the finding. # -# `cycle()`'s stale-input hold +4 bytes, 19/19 green - a LATCHED unit must -# take the roster past the hold, or its failsafe SET stops being re-filed -# the moment a poll is missed. Graded now in test_watchdog.py part 8, on -# a two-inverter roster with the latch at EACH position in turn. -# `publish()`'s status vocabulary +4 bytes, 19/19 green - a site with one -# inverter parked by its own failsafe claim published `running`. Same -# part, same two positions. +# ⚠️ MATCHED BY FUNCTION AND EXPRESSION, NOT BY LINE NUMBER. The blockers were +# reported as validate.py:778 `caps_ix`, :779 `caps`, :783 and :893 `caps_ix`; +# editing three lines into validate.py moves all four, and a check pinned to +# 778 would then be green because it found NOTHING - a check whose data cannot +# reach the failure it names, in the block that exists to complain about them. +# The lines the pin actually reports are PRINTED so a reviewer can match them +# against the review by eye. +_BLOCK_FN = "validate_startup_config" +_blockers = [r for r in _SITES + if r[0] == "validate" and r[5] == _BLOCK_FN + and r[7] in ("caps", "caps_ix")] +print(f" PR #48's round-5 blockers, as the pin names them " + f"(validate.{_BLOCK_FN}, reductions over the caps list): " + + ", ".join(f"validate.py:{r[1]} {r[6]} {r[7]}" for r in _blockers)) + +check("⚠️ THE FAMILY, ENUMERATED FROM THE SOURCE RATHER THAN FROM MEMORY, AND " + "NOW ACROSS THE WHOLE PACKAGE INSTEAD OF ONE CLASS. Every place in " + "`app/` where a roster or a roster-derived list is REDUCED or ITERATED " + "takes the whole of it: no `[:1]`, no `[-1:]`, no `[:2]`, no `[0]` " + "between the roster and the reduction. This is the check that would have " + "caught rounds 2, 3, 4 AND 5's blockers on the round they were written " + "instead of one review each - round 4's version stopped at `Controller` " + "and round 5's four survivors were one module over, in validate.py. A " + "new reduction added over a slice of a roster, in ANY module under " + "`app/`, is red on the line it is written, at every roster length and " + "with no fixture required", + len(_SITES) > 0 and not _SLICED) + +check("...and it INDEPENDENTLY REDISCOVERS EVERY ONE OF PR #48's ROUND-5 " + "BLOCKERS - the `for ... in caps_ix` the caps list is built from, the " + "`min(caps)` rule 5 reduces it with, and the `min(caps_ix, key=...)` " + "rule 5's message and rule 6's comparison each take - and it found a " + "FURTHER ONE beside them that no review round named. A pin that had to " + "be told where to look would be a transcription of the review rather " + "than an instrument, so it is told ONE option key and nothing else", + len([r for r in _blockers if r[7] == "caps_ix"]) >= 3 + and len([r for r in _blockers if r[7] == "caps"]) >= 1) + +check("...and it reaches the sites of EVERY PREVIOUS ROUND'S BLOCKER TOO: " + "round 2's `peakguard.binding_soc_floor`, round 4's " + "`cycle()`/`publish()` `any(... for i in self.inverters)`, and " + "SAFETY-30's `_grade_store_migration`. The whole point of a family is " + "that its members are found by one rule", + any(r[0] == "peakguard" and r[5] == "binding_soc_floor" for r in _SITES) + and any(r[0] == "main" and r[5] == "Controller.cycle" + and r[6] == "any()" for r in _SITES) + and any(r[0] == "main" and r[5] == "Controller.publish" + and r[6] == "any()" for r in _SITES) + and any(r[0] == "main" and r[5] == "Controller._grade_store_migration" + for r in _SITES)) + +# --- non-vacuity, habit 7: vary ONE thing from an ACCEPTED baseline ---------- +# ⚠️ EVERY ONE of the enumerated sites, mutated. Not a sample: the counterfactual +# asks the SAME predicate the enumeration is graded with whether a `[:1]` on +# THAT site's own expression, in THAT site's own scope, would be reported. A +# predicate that had quietly stopped answering would take the line above green +# and this one red. +_probe_fail = [] +for _r in _SITES: + for _cut in ("[:1]", "[-1:]", "[:2]", "[0]"): + if not _PROBE(_r[0], _r[5], _r[7] + _cut): + _probe_fail.append((_r[0], _r[1], _r[7] + _cut)) +print(f" per-site counterfactual: {len(_SITES)} sites x 4 cuts " + f"([:1], [-1:], [:2], [0]) = {len(_SITES) * 4} mutated spellings, " + f"{len(_probe_fail)} of them NOT reported") +check("...and the predicate is not trivially true: sliced at EVERY ONE of the " + "enumerated sites, in that site's own scope, with each of the four cuts " + "this card has actually been defeated by, it reports the slice. It also " + "ACCEPTS the legitimate wrapped forms the package really uses " + "(`enumerate(...)`, `zip(...)`, `sorted(...)`, " + "`pack_permissions(packs, floor)`) and REJECTS a keyed lookup being " + "treated as a slice (`weights[u]` in allocator._fill, which is what four " + "false SLICED reports cost before `shortens()` existed)", + not _probe_fail + and not _PROBE("main", "Controller.cycle", "self.inverters") + and not _PROBE("main", "Controller.cycle", "enumerate(self.inverters)") + and not _PROBE("main", "Controller.cycle", "zip(self.inverters, self.maints)") + and not _PROBE("main", "Controller.check_peak_floor_limited", + "peakguard.pack_permissions(self.packs, 10.0)") + and not _PROBE("allocator", "_fill", "[u for u in order if weights[u] > 0.0]") + and _PROBE("allocator", "_fill", "[u for u in order[:1] if weights[u] > 0.0]")) + +# --- and the END-TO-END counterfactual, on the REAL FILES -------------------- +# ⚠️ The probe above grades the predicate. This grades THE WHOLE INSTRUMENT: the +# text of a shipped module is mutated at a real byte offset, the entire analysis +# is re-run from scratch on it, and the site has to come back SLICED at exactly +# the line the mutation was written on. That is the difference between "the +# rule is right" and "the rule is wired to the files". +_e2e = [] +for _r in _blockers: + _mut, _ = _roster_sites(_slice_at(_r)) + _hit = [x for x in _mut if x[8] and x[0] == _r[0] and x[1] == _r[1]] + _e2e.append((_r[0], _r[1], _r[7], len(_hit), len([x for x in _mut if x[8]]))) +for _m, _ln, _s, _hit, _tot in _e2e: + print(f" {_m}.py:{_ln} `{_s}` -> `{_s}[:1]`: reported at that line " + f"{'YES' if _hit else 'NO'} ({_tot} sliced site(s) in the package)") +check("...and the INSTRUMENT is wired to the files, not only to itself: " + "writing `[:1]` into app/validate.py at each of the four round-5 blocker " + "positions - every reduction over the caps list - and re-running the WHOLE " + "analysis over the mutated source " + "reports a sliced site at that exact line every time, where the clean " + "package reports none. ⚠️ THIS IS THE ONE THAT WOULD HAVE CAUGHT PR #48: " + "the four mutations the reviewer ran by hand against a 19/19 green suite " + "are red HERE without any fixture reaching them", + len(_e2e) >= 4 and all(h for _m, _l, _s, h, _t in _e2e)) + + +# ⚠️ WHAT THIS INSTRUMENT CANNOT SEE, SAID PLAINLY, BECAUSE AN ENUMERATED LIST +# WITH AN HONEST GAP IS WORTH MORE THAN A CLAIM OF COMPLETENESS A REVIEWER CAN +# FALSIFY IN TEN MINUTES. Every item below is an OPEN ROW, not a closed one. # -# Both were invisible for the same reason every blocker on this card was -# invisible: the only fixture that reached them was a roster of ONE, where -# `self.inverters[:1]` IS `self.inverters`. +# IT GRADES AN IDENTITY, NOT A MEANING. The claim it makes is "the whole roster +# arrives". It says nothing about WHICH attribute is read off each unit, WHICH +# reduction is applied, or whether that reduction is the right one: +# `min(caps)` -> `max(caps)` is invisible here, and always will be, because a +# shape pin on a semantic claim would refuse honest refactors (MUTATION.md +# habit 10). The behavioural halves - 12a-12i above, test_validate.py's rule 5 +# and rule 6 roster SETS, test_watchdog.py part 8 - are what say what these +# reductions MEAN, and they are the only thing that can. +# +# AND SPECIFICALLY, THE FOUR THINGS IT WILL NOT REPORT: +# 1. DIRECT INDEX SITES THAT ARE NOT REDUCTIONS. `self.inverters[0]` in +# `_only()` and `actuator_of()`, `self.inverters[self.maint_index]` in +# `maint_unit`, `entries[index]` in `_unit_label()`. These are single-unit +# lookups, they are meant to be, and treating an index as a roster would +# spread this analysis over every unit attribute in the package. They are +# graded where they live. +# 2. A SUBSCRIPT BY A NAME. `weights[u]`, `cap[u]`, `add[u]` in +# `allocator._fill` are keyed lookups into a roster-derived MAPPING and are +# accepted deliberately - `shortens()` above. So a mutation that wrote +# `caps[i]` with `i` a variable would pass. It is not in the family this +# card has been defeated by four times (`[:1]`, `[-1:]`, `[:2]`, `[0]`), +# and buying it would cost four false SLICED reports in allocator.py. +# 3. A SLICE TAKEN THROUGH AN INTERMEDIATE REDUCTION. `sorted(roster)[:1]` is +# a subscript on a CALL RESULT rather than on a roster reference, and the +# pin does not flag it. Nothing in `app/` is written that way today; if +# something is, this line is the record that it would not be seen. +# 4. ANYTHING OUTSIDE `app/`. `tools/`, `sim/` and the checks themselves are +# not parsed. `sim/inject.py` reaches `validate_startup_config()` and is +# graded by nothing here. +# +# AND THE DISCOVERY ITSELF HAS A SEAM: the taint crosses a call boundary only +# where the callee is a plain function this package defines and the argument is +# passed positionally or by keyword. A roster handed through `*args`, stored in a +# dict and read back out, or passed to a method on an object the analysis cannot +# resolve, is lost at that hop - and everything downstream of the hop goes +# UNENUMERATED rather than reported as unreachable. That is the shape of the +# remaining exposure: not zero, and not measurable from inside this check. +# ⚠️ AND WHAT THE ENUMERATION ACTUALLY FOUND WHEN EVERY SITE WAS MUTATED - THE +# FINDING, RECORDED, BECAUSE THE LESSON IS THE MEASUREMENT AND NOT THE FIX. +# +# All 183 sites above were mutated to `[:1]` one at a time, +4 bytes each, and +# each mutant was run against the full 19-file suite in `goodwe-addon-ci` +# (mounted `:ro`, `python3 -B`, a fresh rsync per mutant, 0 `.pyc` in the tree +# before and after - a read-only mount cannot write one, which is TEST-15 closed +# by construction rather than by discipline). **183 of 183 RED.** +# +# ⚠️ THAT NUMBER IS TAUTOLOGICAL ON ITS OWN AND MUST NOT BE QUOTED WITHOUT THE +# SECOND RUN. With the check above in the tree, every slice is red BY THIS +# CHECK. So all 51 mutants whose first red was `test_peakguard.py` were re-run +# with this pin NEUTRALISED (`_SLICED` blanked), leaving only the behavioural +# checks. The answer: +# +# 145 of 183 a BEHAVIOURAL fixture in this repository separates the site +# from a slice of itself - it goes red with the pin turned off +# 38 of 183 NOTHING but this pin sees it. Structurally closed as of this +# card; behaviourally UNGRADED, and listed below rather than +# counted as covered +# +# THE 38, BY MODULE: main.py 27, allocator.py 6, validate.py 2, arbiter.py 1, +# circulation.py 1, maintenance.py 1. Grouped by what a `[:1]` there would cost: +# +# THE OPERATOR'S PAGE - 15 of them, and the largest group by far. +# `Controller.checks` (3310, 3311, 3319, 3328, 3330, 3347, 3368, 3370, +# 3400, 3458) and `status_payload` (3556, 3566, 3578) build the rows and +# the counts an operator reads. A slice reports ONE unit's row where two +# exist, or a "1 of 2 live" that says 1 of 1. Nothing commands differently; +# what is lost is the evidence a human uses to notice that it should. +# THE WRITE PATH's OWN COUNTS - `write_setpoint` 1267/1272/1280, three +# `len(targets)` guards around the one place this add-on writes. The +# write itself iterates `targets` at 1287 and IS graded (test_inverter); +# these three are the arithmetic in its log line and its refusal count. +# THE ALLOCATOR'S INTERNALS - `_fill`'s zero-weight last-resort pool (365), +# `plan`'s `dropped` (526, 653), `free` (584) and `frac_order` (591). +# ALLOC-01's fixtures drive two and three units through the WEIGHTED pass; +# the last-resort pass and the largest-remainder tie-break are reached but +# not with enough units for a slice to change the answer. +# STARTUP AND FSM PLUMBING - `__init__` 235/351, `_only` 831, `_maint_button` +# 805, `cycle`'s maintenance zip at 1505, `send_heartbeats` 3015, +# `check_output_clamp` 2445/2452, `check_circulating` 2701, +# `maintenance.arbitrate` 547, `arbiter.site_resolve` 517, +# `circulation.opposing` 242. +# TWO REFUSAL-TEXT SITES - `validate._roster_problems`'s `len(entries)` (554) +# and `refuse()`'s numbering loop (1263). Both shape the WORDS of a +# refusal that still happens. +# AND `_grade_store_migration` (698), which round 4 already recorded as row 21 +# of the DECISIONS table and argued to be an equivalent mutant at that call +# site. The argument is unchanged and is still an argument, not a proof. +# +# ⚠️ NONE OF THESE 38 IS CLOSED BEHAVIOURALLY BY THIS CARD, AND SAYING SO IS THE +# POINT. Closing them one at a time is exactly what cost this card four review +# rounds; the pin closes the FAMILY - a slice at any of them is red on the line +# it is written - and the list above is what a shape pin can never promise. The +# honest summary is: 145 sites have a fixture that can see them, 38 have only a +# structural claim, and every one of the 38 is named here with what it would +# cost rather than left out of a count. + @@ -5144,25 +5580,24 @@ MUT.append(("`pack_floor_soc`'s NONE GUARD narrowed to a slice - " MUT.append(("`pack_floor_soc` building `vals` from a SLICE of the roster - " "`[inv.soc for inv in self.inverters[:2]]`, +4 bytes", ast.unparse(_pfs_vals.generators[0].iter) == "self.inverters" - and _s_head_kill[2] and not _sliced, + and _s_head_kill[2] and not _SLICED, "part 12i's sweep reads the REAL property on real rosters so it " "sees this too, its pin is on the comprehension's own iterable, " - "and 12j's ENUMERATION covers every other comprehension in the " - "class at once")) + "and 12j's ENUMERATION covers every other roster reduction in the " + "PACKAGE at once")) # 33. THE ENUMERATION'S OWN FINDING, and the reason this row is worth more than # the four above: it is not this card's code. -MUT.append(("a reduction over the roster added ANYWHERE ELSE in `Controller` " - "over a slice - which is how rounds 2, 3 and 4 each began", - bool(_roster_comps) and not _sliced, - "part 12j: every roster comprehension in the class, enumerated " - "from main.py's AST and required to iterate the roster whole. ⚠️ " - "Mutating each of them to `[:1]` found FOUR that survived a green " - "19-file suite, none of them this card's: three `sign_wd.tripped` " - "reductions (SAFETY-30's stale-input hold, `publish()`'s status " - "vocabulary and `status_payload()`'s banner - now graded in " - "test_watchdog.py part 8) and the store-migration guard's redundant " - "second disjunct. THIS row is the one that closes the family " - "rather than the member")) +MUT.append(("a reduction over a roster added ANYWHERE ELSE IN THE PACKAGE over " + "a slice - which is how rounds 2, 3, 4 AND 5 each began", + bool(_SITES) and not _SLICED, + "part 12j: every roster reduction in EVERY module under `app/`, " + "enumerated from the AST off one seed and required to take the " + "roster whole. ⚠️ Round 4's version stopped at the `Controller` " + "class and round 5's four blockers were one module over, in " + "validate.py; this one enumerates " + f"{len(_SITES)} sites across {len(_BY_MOD)} modules and " + "rediscovers all four without being told where to look. THIS row " + "is the one that closes the family rather than the member")) for label, caught, where in MUT: print(f" {'caught' if caught else 'NOT CAUGHT':>10} {label}") diff --git a/goodwe_controller/test_validate.py b/goodwe_controller/test_validate.py index 2be9b3a..d482732 100644 --- a/goodwe_controller/test_validate.py +++ b/goodwe_controller/test_validate.py @@ -924,6 +924,248 @@ for _name, _got in sorted(_prov.items()): f"against, and the 4324 W the DC reading would make it {_got}", all(_got.values())) + +# --------------------------------------------------------------------------- # +print("rules 5, 5b and 6 on a ROSTER SET - the smallest cap at EVERY position") +# ⚠️ SAFETY-32 ROUND 5, AND THE REASON THIS SECTION EXISTS AT ALL. PR #48 was +# rejected on FOUR MUTATIONS THAT SURVIVED A 19/19 GREEN SUITE, all of them in +# the three lines above: +# +# validate.py:779 min(caps) -> min(caps[:1]) +4 B GREEN +# validate.py:779 min(caps) -> min(caps[-1:]) +5 B GREEN +# validate.py:778 caps = [cap for _, cap in caps_ix[:1]] +4 B GREEN +# validate.py:893 min(caps_ix[-1:], key=lambda p: p[1]) +5 B GREEN +# +# NONE of them is an equivalent mutant: each one deletes a safety refusal. +# `max_w: 5000` on inverter-a beside `2000` on inverter-b with +# `sign_disagree_w: 3000` is refused by the shipped validator with "the +# SAFETY-02 sign-disagreement watchdog can never fire" and ACCEPTED by every one +# of the four - the site starts, with a watchdog that reports `armed` on the +# status page and cannot trip on pack B. That is rule 5's own failure mode +# reached THROUGH rule 5. +# +# WHY THE OLD FIXTURE COULD NOT SEE IT, which is the whole lesson and is now +# MUTATION.md habit 10: the fixture was ONE roster, `[5000, 2000]`. On that +# roster `min` and `last` and `[-1:]` and `[:2]` all answer 2000. A fixture of N +# members cannot separate two reductions that agree on N members, and adding a +# member moves the hole instead of closing it. +# +# SO THE FIXTURE IS A SET, NOT A ROSTER: the smallest cap at EVERY POSITION of +# rosters of width 2, 3, 4 and 5. `[:1]` needs width 2 to be caught, `[:2]` +# needs 3, `[:3]` needs 4 - the sweep is what terminates the regress that a +# single fixture cannot. And the FAMILY TABLE below is COMPUTED AND PRINTED, so +# a member no fixture kills shows up as a NAMED SURVIVOR rather than as an +# absence from a list nobody re-derived. +# +# ⚠️ THE DIFFERING CAPS ARE A FIXTURE PROPERTY AND MUST STAY ONE. In production +# both packs are expected to floor at 10 % and SPIKE-04a puts inverter B at +# 4600 W against A's 5000 W - a 400 W gap, narrower than slew_w. A fixture built +# on the real pair would be degenerate on exactly the axis these checks measure. +# ⚠️ AND INVERTER B DOES NOT PHYSICALLY EXIST (SPIKE-04, ~2026-09-12). Nothing +# in this section is measured off a second unit; the rosters are simulated and +# are labelled so. +_BIG, _SMALL = 5000.0, 2000.0 +_RATE = 3000.0 # strictly between them, which is the point + + +def _roster_n(caps, **over): + """A roster of len(caps) units, unit-0..unit-N, each with its own cap.""" + o = base(**over) + for k in ("setpoint_entity", "soc_entity", "batt_entity", "max_w"): + o.pop(k, None) + o["inverters"] = [ + dict(name=f"unit-{i}", setpoint_entity=f"number.sp{i}", + soc_entity=f"sensor.soc{i}", batt_entity=f"sensor.batt{i}", + max_w=c, bms_soc_floor=10) + for i, c in enumerate(caps)] + o.update(over) + return o + + +# The three rules that reduce over `unit_caps_indexed()`, each as +# (option name, the token its refusal is recognised by). +_CAP_RULES = ( + ("sign_disagree_w", "SAFETY-02 sign-disagreement"), + ("unit_sign_disagree_w", "SAFETY-30 per-inverter sign-disagreement"), + ("maintenance_charge_w", "maintenance charge in a cheap window"), +) +_QUIET = {"sign_disagree_w": 300.0, "unit_sign_disagree_w": 300.0, + "maintenance_charge_w": 1000.0, "maintenance_discharge_w": 1000.0} + +# THE SET: width x position of the one small cap. +_SET = [] +for _n in (2, 3, 4, 5): + for _pos in range(_n): + _caps = [_BIG] * _n + _caps[_pos] = _SMALL + _SET.append((_n, _pos, tuple(_caps))) + +# habit 1: the PRECONDITION as its own check. If a later edit makes the caps +# equal, this goes red rather than the claims below going quietly vacuous. +check("PRECONDITION: every fixture in the set has a UNIQUE smallest cap, that " + "cap is at the stated position, and the rate under test sits STRICTLY " + "between it and the others - two equal caps could not tell `min` from " + "`first` from `last` and the whole set would be measuring nothing", + len(_SET) == 14 + and all(c[p] == _SMALL and c.count(_SMALL) == 1 and _SMALL < _RATE < _BIG + for _n2, p, c in _SET) + and {p for _n2, p, _c in _SET} == {0, 1, 2, 3, 4}) + + +def _verdict(opts, token): + """(refused?, the unit the message NAMES, the cap it QUOTES).""" + ps = [p for p in validate_startup_config(opts, -5000, 5000) if token in p] + if not ps: + return (False, None, None) + m = re.search(r"max_w \(([^)]+)\) is (\d+) W", ps[0]) + return (True, m.group(1) if m else None, float(m.group(2)) if m else None) + + +# --- the shipped answer, on every member of the set ------------------------- +_rows = [] +for _opt, _token in _CAP_RULES: + for _n, _pos, _caps in _SET: + _o = _roster_n(list(_caps), **dict(_QUIET, **{_opt: _RATE})) + _rows.append((_opt, _n, _pos, _caps, _verdict(_o, _token))) +print(f" {len(_CAP_RULES)} rules x {len(_SET)} rosters = {len(_rows)} runs " + f"of the REAL validator (rate {_RATE:.0f} W, caps {_SMALL:.0f}/" + f"{_BIG:.0f} W, simulated rosters - inverter B does not exist):") +for _opt in sorted({r[0] for r in _rows}): + _got = [(r[1], r[2], r[4]) for r in _rows if r[0] == _opt] + print(f" {_opt:<22} " + " ".join( + f"n{n}p{p}:{'REFUSE ' + str(u) if ok else 'starts'}" + for n, p, (ok, u, _c) in _got)) + +check("⚠️ EVERY ONE OF THE THREE CAP RULES REFUSES ON EVERY MEMBER OF THE SET, " + "and NAMES THE UNIT THE NUMBER CAME OFF - at width 2, 3, 4 and 5, with " + "the small cap at every position in turn. A reduction that answered " + "`first`, `last`, `[:1]`, `[-1:]`, `[:2]` or `[:-1]` instead of `min` " + "misses at least one member of this set, which is what four hand-run " + "mutations against a 19/19 green suite proved the single `[5000, 2000]` " + "fixture could not do", + all(ok and unit == f"unit-{p}" and cap == _SMALL + for _o2, _n2, p, _c2, (ok, unit, cap) in _rows)) + +check("...and it is a COMPARISON, not a blanket refusal of rosters: the same " + "three rates against a roster whose caps can ALL take them start " + "cleanly, at every width. Without this the line above is satisfied by a " + "validator that refuses everything (habit 2 in the negative direction)", + all(_verdict(_roster_n([_BIG] * n, **dict(_QUIET, **{opt: _RATE})), + tok) == (False, None, None) + for opt, tok in _CAP_RULES for n in (1, 2, 3, 4, 5))) + +# --- the POSITIONAL-SLICE FAMILY TABLE, computed and printed ---------------- +# ⚠️ COMPUTED, NOT LISTED. Each candidate is applied to the SAME caps the +# validator saw, and its (unit, cap) answer is compared against what the shipped +# validator actually said on that roster. A candidate that agrees everywhere is +# a reduction this fixture set CANNOT distinguish from the shipped one, and it +# is printed as a SURVIVOR rather than left out of a table nobody re-derived. +def _pick(caps, fn): + ix = list(enumerate(caps)) + try: + i, c = fn(ix) + except (ValueError, IndexError): + return None + return (f"unit-{i}", c) + + +_FAMILY = { + "min (SHIPPED)": lambda ix: min(ix, key=lambda p: p[1]), + "max": lambda ix: max(ix, key=lambda p: p[1]), + "first": lambda ix: ix[0], + "last": lambda ix: ix[-1], + "min(caps[:1])": lambda ix: min(ix[:1], key=lambda p: p[1]), + "min(caps[-1:])": lambda ix: min(ix[-1:], key=lambda p: p[1]), + "min(caps[:2])": lambda ix: min(ix[:2], key=lambda p: p[1]), + "min(caps[:3])": lambda ix: min(ix[:3], key=lambda p: p[1]), + "min(caps[1:])": lambda ix: min(ix[1:], key=lambda p: p[1]), + "min(caps[:-1])": lambda ix: min(ix[:-1], key=lambda p: p[1]), + "min(caps[::2])": lambda ix: min(ix[::2], key=lambda p: p[1]), + "mean": lambda ix: (0, sum(c for _i, c in ix) / len(ix)), +} +_survivors = [] +print(" the family table - each candidate against what the SHIPPED " + "validator answered on the same roster:") +for _name, _fn in _FAMILY.items(): + _misses = [] + for _opt, _n, _pos, _caps, (_ok, _unit, _cap) in _rows: + _pred = _pick(list(_caps), _fn) + _pred_refuses = _pred is not None and _pred[1] > 0 and _RATE >= _pred[1] + if _opt == "maintenance_charge_w": + _pred_refuses = (_pred is not None and _pred[1] > 0 + and _RATE > _pred[1]) + _said = ((True, _pred[0], _pred[1]) if _pred_refuses + else (False, None, None)) + if _said != (_ok, _unit, _cap): + _misses.append(f"{_opt[:4]}/n{_n}p{_pos}") + if not _misses and _name != "min (SHIPPED)": + _survivors.append(_name) + print(f" {_name:<16} {'AGREES EVERYWHERE (survivor)' if not _misses else 'differs on ' + str(len(_misses)) + ' run(s), first: ' + _misses[0]}") +check("⚠️ AND THE FAMILY TABLE HAS NO SURVIVOR BUT THE SHIPPED REDUCTION. " + "Eleven candidates - max, first, last, the mean, and every positional " + "slice this card has actually been defeated by - are each computed " + "against what the real validator answered on the same roster, and every " + "one of them differs somewhere in the set. This is the check that would " + "have taken PR #48 red four rounds ago: a member no fixture kills is " + "PRINTED AS A NAMED SURVIVOR here instead of being absent from a list " + f"nobody re-derived. survivors: {_survivors}", + _survivors == []) + +# --- the ROSTER OF ONE, where every one of them is the same thing ----------- +_at_one = {n: _pick([_SMALL], f) for n, f in _FAMILY.items()} +print(f" at a ROSTER OF ONE the twelve candidates answer: " + f"{sorted({str(v) for v in _at_one.values()})}") +check("...and at a ROSTER OF ONE every candidate that still has a member to " + "reduce answers THE SAME THING - which is exactly why no " + "single-inverter fixture in this repository could ever have caught any " + "of this, and why the shipped single-inverter refusals are what they " + "always were. The only two that do NOT coincide are `caps[1:]` and " + "`caps[:-1]`, which EMPTY the list and would raise: at N=1 they are " + "crashes rather than silent wrong answers, so they are not survivors " + "there either", + {v for v in _at_one.values() if v is not None} == {("unit-0", _SMALL)} + and {n for n, v in _at_one.items() if v is None} + == {"min(caps[1:])", "min(caps[:-1])"} + and _verdict(_roster_n([_SMALL], **dict(_QUIET, sign_disagree_w=_RATE)), + "SAFETY-02 sign-disagreement")[0]) + +# --- rule 5's SIBLING BRANCH, which was reading the FLAT max_w -------------- +# ⚠️ FOUND BY READING THE THREE BRANCHES SIDE BY SIDE, NOT BY THE STRUCTURAL +# PIN - and said that way on purpose, because it is the shape of what the pin +# cannot see. `unit_sign_disagree_w` is SAFETY-30's, not this card's. It was +# graded against `_number(opts, "max_w", 2000.0)`, the FLAT option, three lines +# under a branch that had already stopped doing that. Both directions were +# measured before the fix and both are asserted here. +_leftover = _roster_n([_SMALL, _SMALL], + **dict(_QUIET, unit_sign_disagree_w=_RATE)) +_leftover["max_w"] = _BIG # what a single-inverter install leaves behind +_lv = _verdict(_leftover, "SAFETY-30 per-inverter sign-disagreement") +print(f" leftover flat max_w {_BIG:.0f} beside a roster of two " + f"{_SMALL:.0f} W units, unit_sign_disagree_w {_RATE:.0f} -> {_lv}") +check("⚠️ A LEFTOVER FLAT `max_w` NO LONGER DISARMS SAFETY-30's PER-UNIT " + "WATCHDOG SILENTLY. Two 2000 W units beside a `max_w: 5000` left over " + "from a single-inverter install and `unit_sign_disagree_w: 3000` used to " + "produce ZERO problems - the site started, the status page said `armed`, " + "and the watchdog could not trip on EITHER unit. It is refused now, " + "against the weakest unit's own cap, and the message names that unit", + _lv == (True, "unit-0", _SMALL)) +check("...and the over-refusal in the OTHER direction is gone too: a roster of " + "two 5000 W units with NO flat `max_w` was refused at 3000 W against a " + "2000 W DEFAULT the operator never wrote, for a threshold both units can " + "reach. It starts", + _verdict(_roster_n([_BIG, _BIG], + **dict(_QUIET, unit_sign_disagree_w=_RATE)), + "SAFETY-30 per-inverter sign-disagreement") + == (False, None, None)) +check("...and the flat single-inverter config it always refused is refused " + "IDENTICALLY - `unit_caps()` at a roster of one is `[max_w]`, so this is " + "the same comparison, the same boundary and the same sentence", + why(validate_startup_config(base(max_w=2000, unit_sign_disagree_w=2000), + -5000, 5000), "unit_sign_disagree_w") + and validate_startup_config(base(max_w=2000, unit_sign_disagree_w=1999), + -5000, 5000) == []) + + # --------------------------------------------------------------------------- # print() if fails: diff --git a/goodwe_controller/tools/supervisor_shape_probe.py b/goodwe_controller/tools/supervisor_shape_probe.py index 9050e8f..85237d4 100644 --- a/goodwe_controller/tools/supervisor_shape_probe.py +++ b/goodwe_controller/tools/supervisor_shape_probe.py @@ -49,6 +49,15 @@ DECISIONS/CFG-04-inverter-roster.md: per-entry key. That change broke §C and, worse, left §D printing PASS on a refusal about the WRONG KEY - the exact decay MUTATION.md habit 8 describes. §C now carries the key and §D is an isolated counterfactual asserting the key name. + +⚠️ RE-RUN AGAIN 2026-09-05, on the same 2026.08.0 image, because SAFETY-32's +rebase onto SPIKE-04a merged two independent edits into §C and §D - the required +`bms_soc_floor` and inverter B's datasheet `max_w: 4600`. A transcript stitched +together by hand from two runs is not a measurement, so this one was measured: +`all cases passed`, exit 0. B's 4600 W is STILL its DATASHEET figure and not a +reading off any unit, and B's `bms_soc_floor: 15` in §C is illustrative - it +differs from A's 10 so that the per-entry number is exercised at all, while +production is expected to have BOTH packs at 10 %. """ import sys, json, copy sys.path.insert(0, "/usr/src/supervisor")