Compare commits

..
7 Commits
Author SHA1 Message Date
adminandClaude Opus 5 19decf433f SAFETY-32 round 5: stop closing sites, build an instrument that finds its own scope
checks / unit (pull_request) Successful in 4m59s
Rounds 2, 3 and 4 each closed exactly the survivor they were handed and each time
the reviewer found the same shape one step over: the reduction, the call site, the
sibling argument, and now the sibling MODULE. Round 4's enumeration stopped at the
`Controller` class boundary; round 5's four blockers were reductions in validate.py,
over a list DERIVED from the roster three calls away from it. Closing four more sites
by hand would have produced a fifth rejection, so this round widens the instrument
instead and lets it enumerate.

⚠️ THE BLOCKERS ARE NOT EQUIVALENT MUTANTS - a safety refusal disappears, verified by
calling the shipped validator both ways on the real tree:

  roster max_w 5000 (inverter-a) beside 2000 (inverter-b), sign_disagree_w 3000
    clean                        -> 1 problem, "...max_w (inverter-b) is 2000 W, so the
                                    SAFETY-02 sign-disagreement watchdog can never fire"
    min(caps[:1])          +4 B  -> 0 problems. The site STARTS.
    caps_ix[:1]            +4 B  -> 0 problems.
  the SAME roster with the small cap FIRST (2000, 5000)
    clean                        -> 1 problem, naming inverter-a
    min(caps[-1:])         +5 B  -> 0 problems.

⚠️ AND THE ONE-ROSTER FIXTURE COULD NOT HAVE SEEN THAT. On [5000, 2000] the `[-1:]`
mutant still refuses, because last IS min there. A fixture of N members cannot separate
two reductions that agree on N members - which is why the behavioural half below is a
SET and not a roster.

THE INSTRUMENT (test_peakguard.py part 12j, widened past `Controller`)

Every `.py` under app/ is read off disk and parsed - NO MODULE IS NAMED IN THE CHECK -
and the roster is found by transitive closure from ONE seed, the `inverters` option key:
through returns, through lists ACCUMULATED in a loop over a roster (which is exactly
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 roster reductions across 9 modules, out of 23 files parsed:
  main 96, allocator 24, validate 19, arbiter 14, circulation 10, maintenance 10,
  peakguard 4, inverter 3, store 3.  14 modules reduce over no roster at all.

⚠️ 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 at all about two others - which is the argument for
computing the scope, made with data.

The pin rediscovers ALL FOUR blockers without being told where to look, and finds a
FIFTH beside them (validate.py:783, rule 5's own message) that no review round named.
Its counterfactual runs at two depths: every enumerated site's expression mutated with
each of the four cuts this card has been defeated by (183 x 4 = 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.

THE SWEEP, AND THE NUMBER THAT MUST NOT BE QUOTED ALONE

All 183 sites mutated to `[:1]`, +4 B each, against the full 19-file suite in
goodwe-addon-ci (:ro, -B, fresh rsync per mutant, 0 .pyc before and after - a read-only
mount cannot write one): 183/183 RED. ⚠️ That is TAUTOLOGICAL on its own, because the
pin makes every slice red. So the 51 whose first red was test_peakguard.py were re-run
with the pin NEUTRALISED:

  145 of 183   a behavioural fixture separates the site from a slice of itself
   38 of 183   ONLY the structural claim. Behaviourally UNGRADED.

All 38 are listed by site with what a `[:1]` there would cost, in the DECISIONS record
and in part 12j itself: 15 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 reaches but not with enough units to separate. NONE of the 38 is closed
behaviourally by this card and none is counted as covered.

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,
14 rosters x 3 rules = 42 runs of the real validator, each asserting the refusal fires
AND names the unit the number came off. Beside it a COMPUTED family table of twelve
candidates - max, first, last, the mean, [:1], [-1:], [:2], [:3], [1:], [:-1], [::2] -
each compared against what the shipped validator actually answered 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 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. `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 before the fix:

  two 2000 W units + a leftover `max_w: 5000` + unit_sign_disagree_w 3000 -> 0 problems.
  The site started, the status page said `armed`, and the per-inverter watchdog could
  not trip on EITHER unit. Identical sentence to the blocker, one branch over.
  two 5000 W units with NO flat key -> REFUSED 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 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.

MUTATION PROOF (each: bytecode purged and counted 0 before and 0 after, the edit
re-read FROM DISK with the new text present and the old absent, byte delta printed)

  B1  validate min(caps)     -> min(caps[:1])        +4 B  RED test_validate.py
  B2  validate min(caps)     -> min(caps[-1:])       +5 B  RED test_validate.py
  B3  validate caps_ix       -> caps_ix[:1]          +4 B  RED test_validate.py
  B4  validate rule 6 min    -> min(caps_ix[-1:])    +5 B  RED test_validate.py
  B5  validate rule 5 msg    -> min(caps_ix[:1])     +4 B  RED test_validate.py
  B6  validate rule 5b msg   -> min(caps_ix[-1:])    +5 B  RED test_validate.py
  B7  CONTROL: weakest -> max(0.0, weakest)         +10 B  GREEN, as intended
  R3a peakguard max(floors)  -> min(floors)           0 B  RED test_peakguard.py
  R3b peakguard max(floors)  -> max(floors[-2:])     +5 B  RED test_peakguard.py
  R3c main call site packs   -> self.packs[:2]       +4 B  RED test_peakguard.py
  R4a main pack_floor_soc    -> vals[:2]             +4 B  RED test_peakguard.py
  R4b main pack_floor_soc    -> vals[-2:]            +5 B  RED test_peakguard.py
  R4c main pack_floor_soc    -> vals[:3]             +4 B  RED test_peakguard.py
  R4d main cycle() any(...)  -> self.inverters[:1]   +4 B  RED test_watchdog.py

B1-B6 are caught by the BEHAVIOURAL set in test_validate.py, before the pin is reached.
B7 is a deliberate no-op control: `max(0.0, weakest)` is `weakest` for weakest > 0, and
it stays green, so the battery is not merely red for everything.

R3a is a ZERO-byte delta, so the shipped function was called both ways as well:
  packs floors [15.0, 30.0, 20.0], peak_soc_floor 15
  binding_soc_floor(...) = 30.0   the 0-byte mutant would answer 15.0   (+15.0 pp)
  -> the site would discharge to 15 % while pack-b's own BMS cuts out at 30 %.

⚠️ PRODUCTION IS DEGENERATE ON THE FLOOR AXIS. SPIKE-04: pack A and pack B BOTH floor
at 10 %, so a differing floor exists ONLY in the fixtures. That is the argument for
keeping them, not for simplifying them - collapsing them deletes the only data in this
repository that can see the reduction at all. Restated in config.yaml's example, in the
probe and in the fixtures.

⚠️ INVERTER B DOES NOT PHYSICALLY EXIST (~2026-09-12). Every second-unit figure here -
4600 W FROM ITS DATASHEET, 15 % illustrative, the whole roster SET - is SIMULATED and
labelled. Only inverter A's 10 % is read off hardware.

REBASED onto 67e649a. Two conflicts with SPIKE-04a, both in the blocks this card had
rewritten (config.yaml's roster example and the probe's C/D sections). Resolved to carry
BOTH claims, and the probe was RE-RUN rather than hand-merged: `all cases passed` on
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0, transcript updated with the
date. MUTATION.md habit 8 - a committed transcript stitched together out of two runs is
not a measurement.

Suite: 19/19 green in goodwe-addon-ci, mounted :ro, bytecode purged and verified 0
before and after. CI-06: test_peakguard.py run four times in isolation, green each time
(288 ok lines); test_validate.py three times, green each time.

MUTATION.md gains habits 12 and 13: build the instrument once the same shape has been
found twice AND make it discover its own scope, and say what the instrument cannot see
with sizes in the same breath.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-05 05:36:16 +02:00
adminandClaude Opus 5 8beba0a3e7 SAFETY-32 round 4: the same family at the SIBLING ARGUMENT, and the family enumerated
Rounds 2 and 3 hardened the FLOOR axis - the max inside binding_soc_floor(),
the roster handed to it, Controller.packs, pack_floor_pct()'s conjunction.
peakguard.decide() is handed a SECOND reduction over the same roster beside it,
soc=self.pack_floor_soc, and peakguard.py's own docstring names the two as a
pair. Three rewrites of Controller.pack_floor_soc were green across all 19 files
on 52380c0 and again after the rebase onto 3934ad7:

    min(float(v) for v in vals)  ->  ... vals[:2]   +4 bytes
                                 ->  ... vals[-2:]  +5 bytes
                                 ->  ... vals[:3]   +4 bytes

while [:1], [-1:], [1:], [:-1], max() and the mean were all red. The suite's
separating power on the SoC axis stopped at exactly N=2 - the state the floor
axis was in before round 3.

It changes a real answer: three packs at 50/60/11 %, emptiest THIRD, answers
11.0 shipped and 50.0 mutated. The site is then graded for discharge PERMISSION
against a pack 39 points fuller than the emptiest and spends a pack its own BMS
stops, through sim/inject.py which runs no validator rule.

Round 3's machinery transfers directly. New test_peakguard.py part 12i:
- six REAL Controller.cycle()s, emptiest pack at every position of a three-pack
  roster and both positions of a two-pack one, observable read off
  PeakDecision.soc; cut-outs held flat so the floor axis cannot move the answers
- computed slice and non-slice tables, printed, red naming any live survivor
- a LENGTH SWEEP over the real property, 78 rosters of 1..12 real inverters,
  closing head/tail widths 1-11 and asserting width 12 is unreached
- a NONE-GUARD sweep over the same 78
- THREE exact structural pins: the vals comprehension iterates the bare
  self.inverters, and both any() and min() reduce the whole vals - closing every
  slice width at every roster length, which no bounded fixture can

SPIKE-04: pack B is 4 x Dyness DL5.0C, DoD 90 -> floor 10 % [FROM DATASHEET AND
SERIAL - inverter B does not exist and nothing about it is measured]. Pack A is
DoD 90 -> 10 % and that one IS measured. So production is DEGENERATE on the
floor axis and a differing floor exists ONLY in the fixtures - which is the
argument for keeping them, not for simplifying them. Recorded in CHANGELOG and
DECISIONS so nobody later collapses them to one number. The SoC axis has no such
degeneracy.

AND THE FAMILY, not the member. Every reduction over self.inverters/self.packs
in Controller was found from the AST and mutated to a slice against the full
suite. Eighteen were already graded. FOUR were not, and three of them are
SAFETY-30's, all any(i.sign_wd.tripped for i in self.inverters), each +4 bytes
and green across 19 files - because the only fixture that reached them was a
roster of ONE, where self.inverters[:1] IS self.inverters:

- cycle()'s stale-input hold: a LATCHED unit must take the roster past it or its
  failsafe SET stops being re-filed the moment a poll is missed
- publish()'s status vocabulary: a site holding an inverter at 0 W published
  "running"
- status_payload()'s banner: the one headline on the page

New test_watchdog.py part 8 grades all three with the latch at EACH position
plus a no-latch counterfactual. New part 12j pins all 26 roster comprehensions
in Controller to iterating the roster WHOLE, so a reduction added over a slice
anywhere in the class is red on the line it is written, with no fixture needed.

Two rows reported OPEN rather than closed: _grade_store_migration()'s second
disjunct (now red on 12j, argued equivalent because a unit tripped at startup
implies its namespaced key is set) and cycle()'s undeliverable-unit LOG LINE,
where sorted(...)[:1] is still green - it truncates names in one event message;
the refusal itself is a different expression and is graded.

MUTATION.md gains habits 10 and 11: a fixture of N members cannot separate two
reductions that agree on N, and mutate the SIBLINGS of anything you harden.

No shipped behaviour changed - tests and documentation only. 19/19 green;
all three named survivors and all round-3 mutations verified RED.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-05 03:01:57 +02:00
glenn schrooyenandClaude Opus 5 a4c95f83ce SAFETY-32 round 3: a fixture of N packs cannot separate reductions that agree on N
Both blockers were the recurring defect sitting inside the fix written to close
it: a check whose TEXT claims more than its FIXTURE can observe. Both survived a
green 19-file suite on 8501e05.

  max(floors) -> max(floors[-2:])   +5 bytes.  12c2's only counterfactual raised
  the LAST pack, and every window ending at the roster's end follows the last
  pack. The check said "the reduction reads every entry"; the data reached "it
  reads the last entry".

  self.packs -> self.packs[:2]      +4 bytes.  All three 12g runs drove a TWO-pack
  roster, where [:2] IS the roster. The check said "what is reduced is the ROSTER
  and not a POSITION in it"; the true claim was the narrower "no SINGLE-POSITION
  slice passes all three runs".

The prose is cut back to what the old data reaches, and new data added for the
rest. No app/ file is touched - app/peakguard.py and app/main.py are byte-identical
to 8501e05.

12c2 - the pure function:
  * a ROSTER SET: the same four simulated cut-outs with the deepest moved to each
    of the four positions.
  * a computed FAMILY TABLE of fourteen rewrites, printed, with the roster that
    kills each. A member with an empty kill list takes the check RED naming it.
  * a LENGTH SWEEP: 78 rosters, lengths 1-12, deepest at every position of each.
    Closes every head window max(floors[:w]) and tail window max(floors[-w:]) of
    width 1-11 in one property.

12g - the call site:
  * _floor_reaching_decide() takes *floors; extra units join through the same
    second_inverter() hook.
  * THREE-pack runs with the deep cut-out at index 0, 1 and 2. Every positional
    slice of a three-pack roster drops a position, and the run whose deep pack
    sits there answers the option where the roster answers the cut-out.
  * a POSITIONAL-SLICE TABLE computed against the REAL Controller.packs each of
    the six cycles ran and the REAL PeakDecision.soc_floor it produced.
  * an EXACT STRUCTURAL PIN on the roster argument's identity. Not the substring
    test round 2 removed: "self.packs" in expr is TRUE of every slice;
    ast.unparse(arg) == "self.packs" is FALSE of all of them.

THE GENERAL LAW, and why the pin is there: a fixture of N packs cannot separate
two reductions that agree on N packs, and the regress does not terminate in a
fixture. [:1] needed two packs, [:2] three, [:3] four. This round's own battery
found self.packs[:3] (+4) and self.packs[-3:] (+5) surviving all six behavioural
runs; the structural pin is what closes them, and one more pack would only have
moved the hole.

Battery: 46 mutations over the reduction, the call site, Controller.packs and
pack_floor_pct()'s conjunction. Bytecode purged and counted 23 -> 0 for each.
43 red, 3 survivors, all three recorded:
  max(floors[:12]) / max(floors[-12:])  NOT CAUGHT - needs a 13-pack roster,
                                        reported with its size, row 27.
  max(set(floors))                      EQUIVALENT - max is duplicate-invariant.
(list(reversed(self.packs)) is equivalent too and is red on the structural pin.)

In-process mutation rows 24-27 record all of it, including the not-caught one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-05 03:01:57 +02:00
glenn schrooyenandClaude Opus 5 ffbabea8ce SAFETY-32 fix round: grade the reduction on packs whose floors DIFFER
AC 6 was not met for the reduction main.py actually hands decide(). The
reviewer removed the per-pack grading twice and the 19-file suite stayed green
both times. No shipped behaviour changes here - app/ is untouched. What changes
is what observes it.

WHY BOTH SURVIVED, and it is this repository's recurring defect landing on this
card's own Decision 2: under any rule-8-valid config every pack's
pack_floor_pct() equals peak_soc_floor, so `floors` is a list of IDENTICAL
numbers and max/min/first/last are four spellings of one answer. The only check
using a cut-out ABOVE the EMS floor - 12c's `_n1_deep` - ran on a roster of ONE,
where every reduction agrees. So no check ever evaluated binding_soc_floor() on
two packs whose floors differ. And the degeneracy is not a defence: this PR's own
argument ("unreachable is a claim about today's rule ordering, and
validate_startup_config() is also sim/inject.py's entry point") justifies writing
the reduction and equally obliges grading it.

  * test_peakguard.py part 12c2 - NEW. binding_soc_floor() on a roster whose
    per-pack floors ALL DIFFER: four SIMULATED cut-outs 30/25/40/35 %, each
    strictly above peak_soc_floor, so max (40), min (25), first (30), last (35)
    and the mean (32.5) are five different numbers and the extremes sit at
    INTERIOR roster positions. Two packs structurally cannot separate those -
    there max==last and min==first - and neither can three; four is the smallest
    roster that can. Precondition is its own check (habit 1); the counterfactual
    raises ONE pack - the last, neither extreme - past the old maximum and the
    answer follows (habit 7); peak_soc_floor is then lifted above every cut-out
    to grade the other side of the conjunction.
  * test_peakguard.py part 12g - REWRITTEN as behaviour (habit 4). The old
    assertion was `"self.packs" in <the unparsed call site>`, which `self.packs[:1]`
    still satisfies. It now drives REAL Controller.cycle()s on a two-pack roster,
    both packs at the same SoC, and reads PeakDecision.soc_floor - the argument,
    echoed back by the shipped decide(). The cut-outs are the only thing varied,
    and the SAME PAIR is carried in BOTH ORDERS.
  * ⚠️ TWO MORE SURVIVORS, found by this round's own battery and recorded rather
    than quietly repaired. (a) The first cut of 12g varied only pack B's cut-out,
    so `self.packs[-1:]` (+5 bytes) agreed with the roster on both runs and stayed
    green across all 19 files; the swapped pair closes it, and between the two
    directions no single-position slice passes all three runs. (b)
    binding_soc_floor()'s EMPTY-roster branch rewritten to `return None`
    (-43 bytes) was green too - a defensive branch nothing in the suite reached.
    Both directions of it are pinned now.
  * wired_over_limit() gained a prepare(ctl) hook, inside its retry loop so every
    attempt grades the same site.
  * In-process mutation rows 22, 22b and 23 record all four survivors.

Nine further source mutations went through the harness on the fixed tree
(binding_soc_floor -> floors[0] / floors[-1], pack_floor_pct conjoining with
min, pack_permissions grading against packs[0], `any` -> `all` on the unknown
floor, Controller.packs reversed, decide(soc=) taking inverters[0].soc, and the
two survivors above). Seven were red on first run; the two survivors are now red
as well. Three of the nine have a ZERO byte delta and still went red, which is
the direction stale bytecode cannot fabricate - and the harness purged anyway.

MANDATORY PROOF, all in the CI image over a tree rsynced to a plain path and
bind-mounted :ro, bytecode entry count printed before and after the purge:
each of the four mutations now takes test_peakguard.py RED, and the unmutated
tree is 19/19 green (twice, plus test_peakguard.py five times in isolation -
card CI-06's flake did not reappear). The zero-byte one was proved to have RUN rather than
assumed: dis.get_instructions LOAD_GLOBALs were [float, pack_floor_pct, any,
min] and binding_soc_floor() returned 25.0 on the 12c2 roster where the shipped
code returns 40.0.

No measurement of inverter B appears anywhere. It does not physically exist.
Every second-pack and fourth-pack figure added here is SIMULATED and labelled at
its definition; only inverter A's 10 % is read off hardware. No hardware was
touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-05 03:01:57 +02:00
adminandClaude Opus 5 f9e7fbf86d SAFETY-32: bms_soc_floor per pack, rule 8 per unit
`bms_soc_floor` describes A BATTERY, not a site. At two packs of different
models the cut-outs differ, and one number for both graded the shallower pack
against the deeper one's BMS - so a site with cut-outs of 10 % and 30 % under
`peak_soc_floor: 20` STARTED, and the guard would command a forced discharge
that pack B's BMS, not the operator's floor, is the thing that stops. That is
rule 8's own failure mode, reached through rule 8.

Scoped to the card's third bullet, per its PM PROOFREAD: the aggregate
anti-windup (DEV-02) and per-instance `achievable_w` (CFG-04) were verified as
already shipped and are untouched.

* config.yaml: `bms_soc_floor: int(0,50)` REQUIRED per `inverters:` entry.
  Nothing is inherited; the flat key stays the single-inverter shape's own
  cut-out. A per-entry DEFAULT was declined - a default is one number for every
  pack, which is this card's defect - and the decision is recorded.
* inverter.py: `Inverter.bms_soc_floor`; an entry that states none gets None,
  not a borrowed number and not 0.0 (the most permissive value the key has).
* peakguard.py: `Pack`, `pack_floor_pct()`, `pack_permissions()`,
  `binding_soc_floor()`. `discharge_permitted()` is unchanged and is still the
  one place the question is answered.
* validate.py: `unit_bms_floors_indexed()` carries the ROSTER index; rule 8
  grades EVERY unit and NAMES the one it refuses. Graded with the offender
  SECOND and with the same two numbers in the other order.
* main.py: `Controller.packs`; `decide(soc_floor=...)` is the per-pack
  reduction of the same option; the floor-limited notification names the pack
  that stopped the site. `pack_floor_soc` - the emptiest pack, and its
  None-on-missing behaviour - is untouched.
* tools/supervisor_shape_probe.py: RE-RUN and repaired. The required key broke
  §C and left §D printing PASS on a refusal about a different key. §D is now an
  isolated counterfactual over an accepted merged baseline, one key varied,
  asserting the key name. `all cases passed` on 2026.08.0.

Stated rather than hidden: under any config rule 8 accepts the per-pack FLOOR
reduces to `peak_soc_floor` for every pack. The reduction is written for
`unit_caps_indexed()`'s reason and graded on both sides - 1775 rule-8-valid
pairs answer the option, the pair rule 8 refuses answers the cut-out.
`peak_soc_floor` is still one number for the site, so the "deeper pack
needlessly withheld" half is filed as an open question, not closed.

Full 19-file suite green. Twelve source mutations through
tools/mutation_run.sh: eleven red, one argued survivor. The battery also found
`_flat_bms_floor()` defaulting in two places, each covering for the other.

No measurement of inverter B appears anywhere: it does not exist yet. Every
second-pack figure is simulated and labelled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-05 03:01:57 +02:00
admin 67e649ad6b Merge pull request 'SPIKE-04a: inverter B is 4600 W FROM ITS DATASHEET, and the three documented copies are pinned' (#49) from SPIKE-04a into release/1.0 2026-09-05 02:55:24 +02:00
glenn schrooyenandClaude Opus 5 a9993aeda2 SPIKE-04a: inverter B is 4600 W FROM ITS DATASHEET, and the copies are pinned
checks / unit (pull_request) Successful in 4m51s
The desk half of SPIKE-04. No control behaviour changes; this is documentation
and the check that stops it drifting again.

Three places stated inverter B's rating - config.yaml's commented roster
example, DOCS.md's, and tools/supervisor_shape_probe.py's "a blob an operator
would POST" - and all three said 3000 W, a number nobody had measured or read
off anything. The owner supplied B's model and serial on 2026-09-04: a GoodWe
GW5048D-ES, serial `55048ESU22AW0992`, max charging and discharging power
4600 W. Serial characters 6-8 are `ESU`, platform 105 / AA55, so SPIKE-01 does
not gate this site. All three copies now read 4600.

⚠️ 4600 W IS FROM A DATASHEET AND IS NOT A MEASUREMENT, and the one ambiguity
is recorded rather than guessed. The datasheet prints it under BATTERY INPUT
DATA, so it is probably DC, while max_w is graded against 0x0518 - the
inverter's own AC power. If it is DC the AC ceiling is nearer 4324 W. Neither
reading is written in as fact; SAFETY-20's min(max_w, dev_max) already bounds
it correctly under either, once dev_max is read off the unit, so `max_w: 4600`
is what an operator should configure today. goodwe/NOTES.md §1.1b carries the
full record with the date and the method on every figure.

A NEW CHECK, BECAUSE A DOCUMENTATION FIX WITHOUT ONE DRIFTS AGAIN.
test_validate.py now pins all three documented rosters: findable, in agreement,
and equal to 4600 - with an explicit len(...) == 3 guard so that a regex which
stopped matching cannot leave "they all agree" green on the two sites still
read. It also grades the PROVENANCE beside the number (the datasheet, the
0x0518 it is graded against, and the 4324 W the DC reading would make it)
WITHIN 2500 characters of the roster rather than file-wide.

⚠️ The first version of that provenance check searched whole files and was
ALREADY VACUOUS on two of them: 0x0518 appeared once in config.yaml and once in
DOCS.md before this card, for unrelated reasons, so `"0x0518" in src` was green
no matter what happened next to B's rating. That is a check whose data cannot
reach the failure it names, in the file that exists to complain about them. It
is fixed, and the window is what fixes it - deleting the caveat beside the
number now goes red even where the same token survives elsewhere in the file.
The windowed version immediately found that the DOCS.md caveat did not name
0x0518 at all; DOCS.md now says it.

Four mutations, bytecode purged and counted at 0 before and after each, all
RED (two of them 0-byte deltas that change a printed answer):
  M1  probe's operator blob 4600 -> 3000        0 B   RED (value + agreement)
  M2  DOCS.md's DC/AC caveat deleted         -157 B   RED (provenance)
  M3  config.yaml alone 4600 -> 4324            0 B   RED (agreement)
  M4  probe entity renamed, regex finds nothing       RED (the vacuity guard)
Restored, green again. The probe itself was re-run against Supervisor
2026.08.0's own AppOptions validator with the 4600: all cases passed, exit 0.

⚠️ THE TEST FIXTURES KEEP 3000 W ON PURPOSE AND NOW SAY WHY AT THE LINE.
test_inverter.py, test_maintenance.py, test_peakguard.py and test_watchdog.py
build a second inverter at 3000 W against A's 5000 W. The only property those
checks consume is that B's number is unmistakably not A's, and the real pair is
a 400 W gap - narrower than slew_w, inside the range where an off-by-one-unit
bug and ordinary control arithmetic produce indistinguishable numbers.
Collapsing them onto the real values would buy realism those checks do not use
and cost them their separating power, which is exactly what test_peakguard.py's
own §10g PRECONDITION exists to prevent. Their comments no longer call 3000 W a
hypothesis about B's rating; they call it illustrative, and not a placeholder
either - the placeholder era ended on 2026-09-04.

Also recorded in the changelog for the cards that own them: 94 % battery-to-AC
=> ~88 % round trip => ~360 W burned continuously to circulate 3 kW, which is
the number SAFETY-31 argues its threshold from without having one; < 13 W night
self-consumption per unit, which nothing accounts for; and the A/B asymmetry in
opposite directions (~33 % more energy, ~8 % less power), which means MAINT-05's
12 h drain timeout was derived on A and should be re-checked against B.

⚠️ Honest survivors:
- goodwe/NOTES.md §1.1b, where all of this is actually recorded, is graded by
  nothing here. Rewriting its 16 occurrences of 4600 to 3000 and turning every
  FROM DATASHEET label into MEASURED ON THE UNIT leaves all 19 files GREEN.
  This CI cannot fetch admin/goodwe - private, and GITHUB_TOKEN is minted for
  this repository only, as .gitea/workflows/checks.yml already states - so the
  cross-repo check is not buildable here.
- No check can see whether 4600 is the RIGHT number. Everything above pins a
  datasheet reading against itself. Only the bench half of SPIKE-04 can say
  whether the unit agrees, and the DC-vs-AC ambiguity is the live example.
- The constant is still named B_RATING_HYPOTHESIS in four test files, which
  reads as a hypothesis about B's rating when it is now an illustrative
  fixture. It is left alone deliberately: renaming ~20 sites across
  test_peakguard.py and test_inverter.py would collide hard with the open
  SAFETY-32 pull request, which rewrites 1255 lines of the first. The comment
  directly above each definition says what the value is and is not.

Suite: 19/19 green in goodwe-addon-ci, mounted :ro, bytecode purged and
verified 0 before and after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-04 22:19:31 +02:00
12 changed files with 1453 additions and 164 deletions
+146
View File
@@ -2,6 +2,67 @@
## Unreleased
**Inverter B's rating is 4600 W, FROM ITS DATASHEET, and every documented copy
of it now says so (SPIKE-04a).** Three places stated B's rating — `config.yaml`'s
commented roster example, `DOCS.md`'s, and `tools/supervisor_shape_probe.py`'s
"a blob an operator would POST" — and all three said **3000 W**, a number that
was never measured, never read off a datasheet, and simply wrong. The owner
supplied B's model and serial on 2026-09-04: a **GoodWe GW5048D-ES**, serial
`55048ESU22AW0992`, max charging and discharging power **4600 W**. No control
behaviour changes here; this is documentation and its pin.
* **Serial characters 6-8 read `ESU`** → platform 105, AA55, the supported set.
Not `ESN`/`ESC` (ES G2, platform 745). SPIKE-01 does not gate this site.
⚠️ A passed platform gate is **not** a proven control surface: `specs.md`
already calls other platform-105 serials *"same protocol family; untested
silicon"*, and this repo's `0x056E`/`0x056F` map was reverse-engineered from a
passive tap on **one BPS unit**. That check is the bench half of SPIKE-04.
* **⚠️ 4600 W IS FROM A DATASHEET AND IS NOT A MEASUREMENT, and one ambiguity is
recorded rather than guessed.** The datasheet prints it under *Battery Input
Data*, so it is probably **DC**, while `max_w` is graded against `0x0518` —
the inverter's own **AC** power. If it is DC the AC ceiling is nearer
4600 × 0.94 ≈ **4324 W**, and the charge direction moves the other way
(≈ 4894 W drawn AC-side). **Neither reading is written in as fact.**
SAFETY-20's `min(max_w, dev_max)` already bounds it correctly under either,
once `dev_max` is read off the unit — so `max_w: 4600` is what to configure
today. `goodwe/NOTES.md` §1.1b carries the full record, with the date and the
method on every figure.
* **A NEW CHECK, BECAUSE A DOCUMENTATION FIX WITHOUT ONE DRIFTS AGAIN.**
`test_validate.py` now pins all three documented rosters: they must be
findable, they must agree, and they must say **4600**. It also grades the
**provenance beside the number** — the datasheet it came from, the `0x0518`
it is graded against, and the 4324 W the DC reading would make it — **within
2500 characters of the roster, not file-wide.** The first version searched
whole files and was already vacuous on two of them, because `0x0518` appeared
in `config.yaml` and `DOCS.md` for unrelated reasons before this card: a
check whose data could not reach the failure it named, in the file that
exists to complain about those. Four mutations were run against the fixed
version — the probe back to 3000 (0-byte delta), the `DOCS.md` caveat
deleted, `config.yaml` alone "corrected" to 4324 (0-byte delta), and the
probe's entity renamed so the regex finds nothing — and all four are red.
* **THE TEST FIXTURES KEEP 3000 W ON PURPOSE, AND NOW SAY WHY AT THE LINE.**
`test_inverter.py`, `test_maintenance.py`, `test_peakguard.py` and
`test_watchdog.py` build a second inverter at 3000 W against A's 5000 W. The
only property those checks consume is that B's number is **unmistakably** not
A's, and the real pair is a **400 W** gap — narrower than `slew_w`, inside the
range where an off-by-one-unit bug and ordinary control arithmetic produce
indistinguishable numbers. Collapsing them onto the real values would buy
realism those checks do not use and cost them their separating power, which
is the defect `test_peakguard.py`'s own §10g precondition exists to prevent.
They are **illustrative, not B's rating, and no longer a placeholder for it**.
* **Two consequences with numbers, recorded in `goodwe/NOTES.md` §1.1b for the
cards that own them.** At 94 % battery-to-AC a round trip is ≈ 88 %, so A
charging 3 kW while B discharges 3 kW burns ≈ **360 W continuously** with the
meter at zero — SAFETY-31 argues its threshold from *"a few watts"* and has no
figure. And **< 13 W night self-consumption per unit** (≈ 26 W for two,
≈ 0.6 kWh/day), which nothing in this add-on accounts for anywhere.
* **The site is asymmetric in opposite directions:** A = 5000 W / 15.36 kWh
nominal, B = 4600 W / 20.48 kWh — ~33 % more energy, ~8 % less power. No
stability implication (ARCH-01's ḡ = Σαᵢgᵢ is a convex combination), but a
full recalibration of B moves 33 % more energy and **MAINT-05's 12 h `drain`
phase timeout was derived on A**; it should be re-checked against B rather
than assumed to carry.
**Every pack is recalibrated on its own schedule, and only one moves power at a
time (MAINT-05).** §12's monthly cycle drains a pack to empty and refills it to
true its coulomb counter. There was exactly one `Maintenance` state machine and
@@ -305,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
@@ -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
+12 -1
View File
@@ -78,9 +78,20 @@ inverters:
setpoint_entity: number.goodwe_b_setpoint_w
soc_entity: sensor.goodwe_b_soc
batt_entity: sensor.goodwe_b_battery_power
max_w: 3000
max_w: 4600
```
⚠️ **The 4600 W is inverter B's *datasheet* figure, not a measured one.** B is a
GoodWe GW5048D-ES (serial `55048ESU22AW0992`, identified 2026-09-04) whose
datasheet gives max charging and discharging power as 4600 W; with nothing
connected to its PV inputs the battery path is the ceiling, not the 5000 VA AC
nameplate. The datasheet prints that number under *Battery Input Data*, so it
may be a DC figure, while `max_w` is graded against register `0x0518` — the
inverter's own **AC** power. If it is DC the AC-side ceiling is nearer 4324 W.
`max_w: 4600` is still the right thing to configure, because the freeze rail is
`min(max_w, dev_max)` and the device range read off the unit is what binds.
`goodwe/NOTES.md` §1.1b records both readings and picks neither.
| per-entry key | required | meaning |
|---|---|---|
| `name` | yes | Short identifier. It namespaces this unit's SAFETY-02 latch, heartbeat and write bookkeeping in the add-on's state file, so **two units may not share one** |
+31
View File
@@ -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
+31 -4
View File
@@ -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.")
+23 -6
View File
@@ -61,14 +61,31 @@ options:
# setpoint_entity: number.goodwe_b_setpoint_w
# soc_entity: sensor.goodwe_b_soc
# batt_entity: sensor.goodwe_b_battery_power
# max_w: 3000
# max_w: 4600
# bms_soc_floor: 10
#
# ⚠️ THE SECOND ENTRY ABOVE IS AN EXAMPLE, NOT A MEASUREMENT. No second
# inverter exists on this site yet; its 3000 W rating and its 10 % cut-out are
# placeholders in a comment, and the cut-out in particular must be read off the
# pack that is actually installed (register 0x0560 on the reference unit)
# before that pack is commanded.
# ⚠️ THE 4600 IS INVERTER B'S DATASHEET FIGURE, NOT A MEASUREMENT AND NOT A
# PLACEHOLDER. SPIKE-04a (2026-09-04) identified B as a GoodWe GW5048D-ES,
# serial `55048ESU22AW0992`, whose datasheet gives Max. Charging / Discharging
# Power as 4600 W; nothing will be connected to its MPPTs, so the battery path
# is the ceiling and the 5000 VA AC nameplate is not. The 3000 W this example
# used to carry was a guess made before the unit was known. ⚠️ It is STILL not
# measured: the datasheet prints 4600 W under BATTERY INPUT DATA, so it may be
# DC, and `max_w` is graded against 0x0518 - the inverter's own AC power. If it
# is DC the AC ceiling is nearer 4324 W. SAFETY-20's `min(max_w, dev_max)` is
# what settles that once the device range is read; see goodwe/NOTES.md §1.1b,
# which records both readings and picks neither.
#
# ⚠️ AND THE `bms_soc_floor: 10` BESIDE IT IS FROM THE SAME PLACE - THE
# DATASHEET AND THE SERIAL, NOT A PACK ANYONE HAS READ. No second inverter
# exists on this site (SPIKE-04, ≈ 2026-09-12 for B); the cut-out must be read
# off the pack that is actually installed (register 0x0560 on the reference
# unit) before that pack is commanded. It reads 10 here because pack A reads
# 10, which makes this example DEGENERATE ON THE FLOOR AXIS: a site whose two
# packs floor at the SAME percent cannot separate `min`, `max`, first or last
# over the roster, and every SAFETY-32 fixture that DIFFERS the floors differs
# it for exactly that reason. Do not "simplify" those fixtures to match this
# example.
#
# ⚠️ NOTHING IS INHERITED FROM THE FLAT KEYS BY AN ENTRY THAT OMITS IT, and
# that is the whole of DEV-02 restated as a config rule. `soc`, `batt` and the
+10 -3
View File
@@ -192,9 +192,16 @@ class Hass:
A_ENT = {"setpoint_entity": "number.sp_a", "soc_entity": "sensor.soc_a",
"batt_entity": "sensor.batt_a"}
# ⚠️ A HYPOTHESIS, NOT A MEASUREMENT. Inverter B does not exist; 3000 W is
# chosen only because it DIFFERS from A's 5000 W, which is what lets a check
# tell B's number from A's. SPIKE-04 measures the real one.
# ⚠️ SPIKE-04a (2026-09-04) ESTABLISHED B'S REAL RATING FROM ITS DATASHEET:
# a GoodWe GW5048D-ES, serial `55048ESU22AW0992`, 4600 W on the battery path
# (goodwe/NOTES.md §1.1b). THIS FIXTURE DELIBERATELY DOES NOT USE IT. 3000 W is
# kept because the only property these checks consume is that B's number is
# UNMISTAKABLY not A's, and the real pair is 5000/4600 - a 400 W gap, narrower
# than `slew_w` and inside the range where an off-by-one-unit bug and ordinary
# control arithmetic produce indistinguishable numbers. Collapsing the fixture
# onto the real values would cost these checks their separating power to buy
# realism they do not use. So: ILLUSTRATIVE, NOT B'S RATING, and not a
# placeholder for it either - the placeholder era ended on 2026-09-04.
B_RATING_HYPOTHESIS = 3000.0
# SAFETY-32. Each pack's OWN BMS cut-out percent, now a REQUIRED roster key.
# ⚠️ 10 IS MEASURED (register 0x0560 on the reference GW5000S-BP and the shipped
+10 -3
View File
@@ -1059,9 +1059,16 @@ class Pub:
pass
# ⚠️ 3000 W FOR PACK B IS A HYPOTHESIS AND IS MARKED AS ONE EVERYWHERE IT
# APPEARS. Inverter B does not physically exist; the number is here only because
# it DIFFERS from pack A's 5000 W, which is the only property these checks use.
# ⚠️ SPIKE-04a (2026-09-04) ESTABLISHED B'S REAL RATING FROM ITS DATASHEET:
# a GoodWe GW5048D-ES, serial `55048ESU22AW0992`, 4600 W on the battery path
# (goodwe/NOTES.md §1.1b). THIS FIXTURE DELIBERATELY DOES NOT USE IT. 3000 W is
# kept because the only property these checks consume is that B's number is
# UNMISTAKABLY not A's, and the real pair is 5000/4600 - a 400 W gap, narrower
# than `slew_w` and inside the range where an off-by-one-unit bug and ordinary
# control arithmetic produce indistinguishable numbers. Collapsing the fixture
# onto the real values would cost these checks their separating power to buy
# realism they do not use. So: ILLUSTRATIVE, NOT B'S RATING, and not a
# placeholder for it either - the placeholder era ended on 2026-09-04.
B_RATING_HYPOTHESIS = 3000.0
A_RATING = 5000.0
FLAT_OPTS = dict(setpoint_entity="number.sp_a", soc_entity="sensor.soc_a",
+569 -124
View File
@@ -2668,6 +2668,16 @@ check("...and the arbiter agrees the routing is legal, on the list main.py "
# DATA could not reach the failure it named (ARB-03's call-site scan saw 3 of 8
# sites; ALLOC-01's replay generator saw 0 escapes where a widened one saw
# 39,991), so the number is different HERE, once, for every fixture in the file.
# ⚠️ SPIKE-04a (2026-09-04) ESTABLISHED B'S REAL RATING FROM ITS DATASHEET:
# a GoodWe GW5048D-ES, serial `55048ESU22AW0992`, 4600 W on the battery path
# (goodwe/NOTES.md §1.1b). THIS FIXTURE DELIBERATELY DOES NOT USE IT. 3000 W is
# kept because the only property these checks consume is that B's number is
# UNMISTAKABLY not A's, and the real pair is 5000/4600 - a 400 W gap, narrower
# than `slew_w` and inside the range where an off-by-one-unit bug and ordinary
# control arithmetic produce indistinguishable numbers. Collapsing the fixture
# onto the real values would cost these checks their separating power to buy
# realism they do not use. So: ILLUSTRATIVE, NOT B'S RATING, and not a
# placeholder for it either - the placeholder era ended on 2026-09-04.
B_RATING_HYPOTHESIS = LIVE_MAX_W - 2000.0
@@ -4608,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.
@@ -5134,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}")
+333
View File
@@ -833,6 +833,339 @@ check("...and MaintConfig's dataclass defaults carry no numeric literal either "
not re.search(r"^\s*(charge_w|discharge_w):\s*float\s*=\s*-?\d",
_srcs["maintenance.py"], re.M))
# --------------------------------------------------------------------------- #
print("SPIKE-04a: inverter B's documented rating, and the provenance beside it")
# ⚠️ WHY A DOCUMENTATION CARD LEFT A CHECK BEHIND. Inverter B's rating was
# written down in THREE places - config.yaml's commented roster example,
# DOCS.md's, and tools/supervisor_shape_probe.py's "a blob an operator would
# POST" - as 3000 W, a figure nobody had measured, read off a datasheet, or
# sourced at all. It was wrong: SPIKE-04a (2026-09-04) identified inverter B
# from the owner's model number and serial as a GoodWe GW5048D-ES rated 4600 W
# on the battery path. Three transcriptions of one number drifting away from
# the truth together is this repo's recurring defect in its plainest form, and
# correcting them once WITHOUT PINNING THEM buys nothing: the next editor
# touches one of the three and the other two stay wrong, silently.
#
# ⚠️ AND THE PROVENANCE IS GRADED, NOT ONLY THE VALUE, WHICH IS THE HALF THAT
# MATTERS. 4600 W IS FROM A DATASHEET AND IS NOT A MEASUREMENT. The datasheet
# prints it under BATTERY INPUT DATA, so it is probably DC, while `max_w` is
# graded against 0x0518 - the inverter's own AC power - which would put the AC
# ceiling nearer 4324 W. An editor who tidies that caveat away leaves a paper
# number reading as an observation of hardware that does not exist yet, and
# THAT is the failure this card exists to prevent. So the caveat is what these
# checks reach for; a check that graded 4600 alone would watch the value and
# miss the lie.
#
# ⚠️ WHAT THIS DELIBERATELY DOES NOT GRADE: the test fixtures. Several files
# build a second inverter at 3000 W on purpose, because the property those
# checks consume is that B's rating is UNMISTAKABLY not A's, and the real pair
# (5000/4600) is a 400 W gap - narrower than `slew_w`. Those are illustrative,
# they say so at the line, and they are not statements of B's rating. The three
# sites below are the ones that ARE such statements.
_HERE = os.path.dirname(os.path.abspath(__file__))
_docs_txt = open(os.path.join(_HERE, "DOCS.md"), encoding="utf-8").read()
_probe_txt = open(os.path.join(_HERE, "tools", "supervisor_shape_probe.py"),
encoding="utf-8").read()
# One regex per site, each anchored on something only THAT site has, so a match
# cannot silently wander into a neighbouring blob. The probe's anchor is the
# operator blob's real entity name: its section D refusal entries use
# `number.b` and one of them carries a deliberate 9999, so an anchor loose
# enough to reach them would grade the wrong dict and still look green.
_b_rating = {
"config.yaml": re.search(
r"#\s+- name: inverter-b\b(?:[^\n]*\n)*?\s*#\s+max_w:\s*(\d+)", _txt),
"DOCS.md": re.search(
r"^ - name: inverter-b\b(?:[^\n]*\n)*?^ max_w:\s*(\d+)",
_docs_txt, re.M),
"supervisor_shape_probe.py": re.search(
r'"name": "inverter-b",\s*"setpoint_entity": "number\.goodwe_b_setpoint_w"'
r'(?:[^}]*?)"max_w":\s*(\d+)', _probe_txt),
}
check(f"all three documented rosters were FOUND "
f"{ {k: bool(v) for k, v in _b_rating.items()} } - a regex that matches "
"nothing is a green check that read nothing",
all(_b_rating.values()))
# ⚠️ THE `len(...) == 3` IS NOT DECORATION. Without it a regex that stopped
# matching would shrink this dict, and "all the values agree" would go GREEN on
# the two sites that were still read - a check whose data no longer reaches the
# failure it names, which is the exact fault this whole section is about.
_b_vals = {k: int(m.group(1)) for k, m in _b_rating.items() if m}
check(f"...and all three state the SAME rating for inverter B {_b_vals}",
len(_b_vals) == 3 and len(set(_b_vals.values())) == 1)
check(f"...and it is 4600 W - the GW5048D-ES datasheet figure, not the 3000 W "
f"hypothesis that predated the unit being identified {_b_vals}",
len(_b_vals) == 3 and set(_b_vals.values()) == {4600})
# The provenance triple. Each site must still say WHERE 4600 came from, that it
# is graded against an AC register, and what the DC-vs-AC ambiguity does to it -
# the last carried by the 4324 W figure, which is the one marker that cannot
# survive a careless "it's 4600 now, done" rewrite.
#
# ⚠️ SEARCHED IN A WINDOW AROUND THE ROSTER, NOT OVER THE WHOLE FILE, AND THE
# FIRST VERSION OF THIS CHECK DID THE LATTER. `0x0518` already appeared once in
# config.yaml and once in DOCS.md before this card, for unrelated reasons - so a
# whole-file `"0x0518" in src` was GREEN on those two files no matter what
# happened next to inverter B's rating. That is a check whose data cannot reach
# the failure it names, in the same file that exists to complain about them.
# The window is the text either side of the roster match, so deleting the caveat
# beside the number goes red even where the same word survives elsewhere.
_sites = {"config.yaml": _txt, "DOCS.md": _docs_txt,
"supervisor_shape_probe.py": _probe_txt}
_MARKERS = ("datasheet", "0x0518", "4324")
_prov = {}
for _name, _src in _sites.items():
_m = _b_rating[_name]
_win = _src[max(0, _m.start() - 2500):_m.end() + 2500].lower() if _m else ""
_prov[_name] = {k: k in _win for k in _MARKERS}
for _name, _got in sorted(_prov.items()):
check(f"{_name} still carries B's provenance WITHIN 2500 chars of the "
f"roster - the datasheet it came from, the 0x0518 it is graded "
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:
+12 -3
View File
@@ -798,9 +798,18 @@ print(" circulation the allocator NEVER COMMANDED")
from app.inverter import Inverter # noqa: E402
# ⚠️ INVERTER B DOES NOT PHYSICALLY EXIST - it is ~10 days out and SPIKE-04
# measures it. Its rating here is a HYPOTHESIS, named as one, exactly as
# test_peakguard.py's helper names its own.
# ⚠️ INVERTER B DOES NOT PHYSICALLY EXIST - it is ~8 days out (2026-09-04) and
# the bench half of SPIKE-04 measures it.
# ⚠️ SPIKE-04a (2026-09-04) ESTABLISHED B'S REAL RATING FROM ITS DATASHEET:
# a GoodWe GW5048D-ES, serial `55048ESU22AW0992`, 4600 W on the battery path
# (goodwe/NOTES.md §1.1b). THIS FIXTURE DELIBERATELY DOES NOT USE IT. 3000 W is
# kept because the only property these checks consume is that B's number is
# UNMISTAKABLY not A's, and the real pair is 5000/4600 - a 400 W gap, narrower
# than `slew_w` and inside the range where an off-by-one-unit bug and ordinary
# control arithmetic produce indistinguishable numbers. Collapsing the fixture
# onto the real values would cost these checks their separating power to buy
# realism they do not use. So: ILLUSTRATIVE, NOT B'S RATING, and not a
# placeholder for it either - the placeholder era ended on 2026-09-04.
B_RATING_HYPOTHESIS = 3000.0
@@ -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")
@@ -152,22 +161,47 @@ show("the same blob MERGED with these defaults - what App.options actually is",
f"max_w={out.get('max_w')!r}" if ok else out)
print("\n== C. AC2: a two-inverter blob an operator would POST ==")
# ⚠️ SAFETY-32 ADDED `bms_soc_floor` AS A REQUIRED PER-ENTRY KEY, and this blob
# is what proves the operator can still POST a two-inverter config. The TWO
# VALUES DIFFER ON PURPOSE (10 and 15): equal values would not exercise a
# per-entry number at all, and this file's own §B is the record of what an
# instrument that cannot distinguish its two cases is worth.
# ⚠️ NEITHER IS A MEASUREMENT OF INVERTER B. Only inverter A's 10 % is read off
# hardware (register 0x0560 on the reference GW5000S-BP); there is no second
# pack on this site, so B's 3000 W and its 15 % are BOTH placeholders in a
# schema-shape probe and must not be quoted as anything else.
# ⚠️ B's 4600 W IS FROM ITS DATASHEET, NOT A PLACEHOLDER AND NOT A MEASUREMENT.
# SPIKE-04a (2026-09-04) identified inverter B from the owner's model number and
# serial - a GoodWe GW5048D-ES, serial `55048ESU22AW0992` - and the datasheet's
# Max. Charging / Discharging Power is 4600 W. The 3000 W that stood here was a
# hypothesis from before the unit was known and was simply wrong; the 5000 VA AC
# nameplate is wrong for this purpose too, because nothing will be connected to
# B's MPPTs so the battery path is the ceiling. See goodwe/NOTES.md §1.1b.
#
# ⚠️ AND IT IS STILL NOT MEASURED. The datasheet prints 4600 W under BATTERY
# INPUT DATA, so it is probably DC, while `max_w` is graded against 0x0518 - the
# inverter's own AC power. If it is DC the AC ceiling is nearer 4600 x 0.94 =
# 4324 W. NOTES.md §1.1b records both readings without picking one, and
# SAFETY-20's `min(max_w, dev_max)` is what bounds it correctly once dev_max is
# read off the unit. So this blob is what an operator would POST TODAY, not a
# claim about what inverter B will turn out to do.
#
# ⚠️ THE TWO RATINGS ARE STILL DIFFERENT (5000 / 4600) AND MUST STAY DIFFERENT.
# This file's own §B is the record of what an instrument that cannot distinguish
# its two cases is worth. Note that 5000/4600 is a much NARROWER separation than
# the 5000/3000 it replaces - fine here, where nothing computes on the numbers,
# and NOT fine in the test fixtures, which is why those keep a wide illustrative
# pair instead of the real one.
#
# ⚠️ AND `bms_soc_floor` IS A REQUIRED PER-ENTRY KEY SINCE SAFETY-32, so this
# blob is also what proves an operator can still POST a two-inverter config
# after that. THE TWO FLOORS DIFFER ON PURPOSE (10 and 15): equal values would
# not exercise a per-entry number at all, for the same reason the two ratings
# must stay different.
# ⚠️ NEITHER OF B'S NUMBERS IS A MEASUREMENT OF A PACK. Only inverter A's 10 %
# is read off hardware (register 0x0560 on the reference GW5000S-BP). B's 4600 W
# is its DATASHEET figure and B's 15 % is an illustrative value chosen to differ
# - there is no second pack on this site, and in production BOTH packs are
# expected to floor at 10 %, which is why the differing pair lives here and in
# the fixtures rather than in config.yaml's example.
TWO = dict(LIVE, setpoint_entity="", inverters=[
{"name": "inverter-a", "setpoint_entity": "number.goodwe_a_setpoint_w",
"soc_entity": "sensor.goodwe_a_soc", "batt_entity": "sensor.goodwe_a_battery_power",
"max_w": 5000, "bms_soc_floor": 10},
{"name": "inverter-b", "setpoint_entity": "number.goodwe_b_setpoint_w",
"soc_entity": "sensor.goodwe_b_soc", "batt_entity": "sensor.goodwe_b_battery_power",
"max_w": 3000, "bms_soc_floor": 15}])
"max_w": 4600, "bms_soc_floor": 15}])
ok, out = validate(MERGER.merge(copy.deepcopy(defaults), copy.deepcopy(TWO)))
show("two inverters", ok, json.dumps(out.get("inverters")) if ok else out)
# The validated blob, for anyone who wants to POST it. Optional: the probe's
@@ -186,7 +220,7 @@ print("\n== D. what the schema itself refuses, per entry ==")
# 'bms_soc_floor'`. A refusal of unknown provenance is not a measurement of
# anything, so the baseline below is COMPLETE and each case deletes exactly one
# key - or, for the range case, moves exactly one value out of range.
D_FULL = {"name": "inverter-b", "setpoint_entity": "number.b", "max_w": 3000,
D_FULL = {"name": "inverter-b", "setpoint_entity": "number.b", "max_w": 4600,
"bms_soc_floor": 10}
# MERGED with the shipped defaults, exactly as §C is and as App.options is: the
# raw LIVE blob is missing root keys unrelated to this section (`maintenance_