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
This commit is contained in:
2026-09-05 03:01:57 +02:00
committed by glenn schrooyen
co-authored by Claude Opus 5
parent a4c95f83ce
commit 8beba0a3e7
5 changed files with 1106 additions and 1 deletions
+65
View File
@@ -302,6 +302,71 @@ thing that stops. Rule 8's own failure mode, reached through rule 8.
cannot permit anything either way because `pack_floor_soc` is `None` at an
empty roster.
* ⚠️ **THIRD FIX ROUND — the same family was alive at the SIBLING ARGUMENT of
the same call.** Rounds 2 and 3 hardened the FLOOR axis; `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), `vals[-2:]` (+5) and `vals[:3]` (+4), 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: a three-pack roster at
50 / 60 / 11 % with the emptiest pack THIRD answers 11 % shipped and 50 %
mutated, so the site is graded for discharge PERMISSION against a pack 39
points fuller than the emptiest and spends a pack its own BMS stops. Round 3's
machinery transfers directly — new **part 12i**: six real `cycle()`s with the
emptiest pack at every position of a three-pack roster and both positions of a
two-pack one (the observable read off `PeakDecision.soc`, cut-outs held flat
so the floor axis cannot be what moved the answers); computed slice and
non-slice tables that go red naming any member nothing kills; a **length
sweep** of 78 real rosters, lengths 1–12, closing head and tail windows of
width 1–11 and recording width 12 as honestly unreached; a **none-guard
sweep** over the same 78; and **three exact structural pins** — the `vals`
comprehension must iterate the bare `self.inverters`, and both `any()` and
`min()` must reduce the whole `vals` — which close every slice width at every
roster length. No shipped behaviour changed; only what observes it.
* ⚠️ **SPIKE-04: production is DEGENERATE on the floor axis, so the
differing-floor fixtures are the entire exposure.** Pack B is 4 × Dyness
DL5.0C, DoD 90 → floor 10 % — **FROM THE DATASHEET AND THE SERIAL; inverter B
does not exist and nothing about it is measured.** Pack A is DoD 90 → 10 % as
well, and that one *is* measured. Both packs therefore floor at the same value
in the field, `max(10, 10)` is 10 whatever it reduces, and a differing floor
exists **only in the fixtures**. That is the argument for keeping them, not for
simplifying them: anyone who collapses `A_PACK_FLOOR != B_PACK_FLOOR_SIM` to
one number deletes the only data this repository has that can see the
reduction at all, and part 12c2's precondition check goes red if they do. The
**SoC** axis has no such degeneracy — two batteries at the same state of
charge is a coincidence, not a configuration — which is what makes
`pack_floor_soc` the half of the pair that will be live from the day inverter
B is wired up.
* ⚠️ **The FAMILY enumerated rather than the member closed, and it found four
more survivors in code this card never touched.** 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
(`site_batt_w`, `site_rating_w`, `site_max_w`, `site_dev_bound`,
`site_circulating_w`, `inputs_ok`, `units`, `Controller.packs`, `maint_unit`,
the maintenance-due minimum, the store-migration roster, `validate_unit_ranges`
and the round-2/3 sites). **Four were not, and three of them are SAFETY-30's,
all `any(i.sign_wd.tripped for i in self.inverters)`** — `cycle()`'s
stale-input hold, `publish()`'s status vocabulary and `status_payload()`'s
banner — each +4 bytes and green across all 19 files, for the 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`.** The first has
teeth: a latched unit must take the roster past that hold or its `failsafe`
SET stops being re-filed the moment a poll is missed, which is main.py's own
sentence for it. New **test_watchdog.py part 8** grades all three with the
latch at EACH position plus a no-latch counterfactual, and 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 required. Two rows are reported open
rather than closed: `_grade_store_migration()`'s second disjunct (now red on
12j, and argued to be an equivalent mutant because a unit tripped at startup
implies its namespaced key is set) and `cycle()`'s undeliverable-unit **log
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.
⚠️ **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
config.yaml's commented example — is **simulated and labelled**; only inverter
@@ -311,6 +311,173 @@ refuses the two-model config that used to start; the permission is answered per
pack, so the alarm names the pack that stopped the site rather than "the
battery"; and an entry with no stated cut-out is withheld instead of defaulted.
## 4b. ⚠️ ROUND 4 — the sibling argument, and SPIKE-04 making this the live axis
Rounds 2 and 3 hardened the **floor** axis: the `max` inside
`binding_soc_floor()`, the roster handed to it, `Controller.packs`, and
`pack_floor_pct()`'s conjunction. Round 4's blocker is the same family at the
**sibling argument of the same call**:
```python
self.peak_guard = peakguard.decide(
...,
soc=self.pack_floor_soc, # MINIMUM <- round 4
soc_floor=peakguard.binding_soc_floor(self.packs, ...), # MAXIMUM <- rounds 2-3
```
`peakguard.py`'s own docstring names them as a pair — *"MAXIMUM here pairs with
the MINIMUM in `pack_floor_soc`"* — and a conjunction is exactly as strong as
its weaker half. On `52380c0`, with all 19 files green and verified again after
the rebase onto `3934ad7`:
| mutation in `Controller.pack_floor_soc` | bytes | before round 4 | now |
|---|---|---|---|
| `min(float(v) for v in vals[:2])` | +4 | **GREEN** | RED |
| `min(float(v) for v in vals[-2:])` | +5 | **GREEN** | RED |
| `min(float(v) for v in vals[:3])` | +4 | **GREEN** | RED |
| `vals[:1]`, `vals[-1:]`, `vals[1:]`, `vals[:-1]`, `max(vals)`, the mean | | RED | RED |
The suite's separating power on the SoC axis stopped at exactly **N = 2** —
precisely the state the floor axis was in before round 3, and which round 3
itself named as its blocker 2. A fixture of N packs cannot separate reductions
that agree on N packs.
**It changes a real answer.** A three-pack roster at 50 / 60 / 11 %, emptiest
pack **third**: the shipped property answers 11 %, `vals[:2]` answers 50 %. 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 at all. The verdict flips with it:
11 % is below the 20 % EMS floor, so the shipped site is floor-limited and sizes
no forced discharge, where the mutant is permitted and sizes a positive one.
### ⚠️ SPIKE-04 makes the differing-floor discipline the entire exposure
SPIKE-04 reads pack B as **4 × Dyness DL5.0C, DoD 90 → floor 10 %** — **FROM THE
DATASHEET AND THE SERIAL. Inverter B does not physically exist (~8 days out) and
nothing about it has been measured.** Pack A is DoD 90 → 10 % as well, and *that
one is measured*, off register `0x0560` on the reference GW5000S-BP.
So **production is degenerate on the floor axis**: both packs floor at the same
value, `max(10, 10) == 10`, and a differing floor exists **only in the
fixtures**. That is not an argument for simplifying the fixtures to matching
values — it is the argument for keeping them. `binding_soc_floor()`'s reduction
is observable *nowhere else*, and the moment a third pack, a replacement pack or
a different chemistry arrives it is observable in the field. **Anyone who
"simplifies" `A_PACK_FLOOR != B_PACK_FLOOR_SIM` to one number deletes the only
data this repository has that can see the reduction at all**; part 12c2's
precondition check goes red if they do, and this paragraph is why.
The **SoC axis has no such degeneracy**. Two batteries at the same state of
charge is a coincidence, not a configuration, so `pack_floor_soc` is the half of
the pair that will be non-degenerate in the field from the day inverter B is
wired up.
### The treatment, transferred from round 3
* **the roster SET** — six real `Controller.cycle()` runs, the emptiest pack at
every position of a three-pack roster and both positions of a two-pack one,
the observable read off `PeakDecision.soc` (the argument, echoed) rather than
off a spy or a grep. Cut-outs held flat across all of them, so the floor axis
cannot be what moved the answers.
* **the positional-slice table and the non-slice table** — computed against the
real `Controller.inverters` each cycle carried and the real decision it
produced, printed, with a member that nothing kills taking the check red.
* **the length sweep** — the real property on real rosters of 1 to 12 inverters,
the empty pack at every position. Closes head and tail windows of width 1-11
at once. Width 12 and wider is **honestly not reached**, and asserted to be
unreached so a later sweep that lengthens the rosters must rewrite the
sentence.
* **the exact structural pin** — three of them, because there are three
reductions over roster length in this one property: `vals` is built by one
comprehension over the bare `self.inverters`; `any(... for v in vals)` reduces
the whole list; `min(... for v in vals)` reduces the whole list. Every slice
of either is red at **every** roster length, including the `[:3]` and `[:12]`
the runs and the sweep cannot see.
Unlike the floor axis, **there is no uncaught row here**: the claim at
`pack_floor_soc` is an *identity* ("this list"), so a shape pin is the right
instrument, where `binding_soc_floor()`'s claim is *semantic* ("the maximum")
and a shape pin there would refuse honest refactors. That asymmetry is why one
carries an open row and the other does not.
## 4c. ⚠️ THE ENUMERATION — what OTHER reductions over roster length are graded
This card has now been rejected three times for the same shape one step over, so
round 4 stopped closing members and enumerated the **family**. Every reduction
over `self.inverters` / `self.packs` in `Controller` was found from the AST and
mutated to a slice, one at a time, against the full 19-file suite.
| # | site | reduction | consumer | mutation | result |
|---|---|---|---|---|---|
| 1 | `pack_floor_soc` | `min` over SoC | `decide(soc=)`, `arbitrage.decide(soc=)` | `vals[:2]` +4 | **was GREEN → now RED** (12i) |
| 2 | `pack_floor_soc` | `any(v is None)` | same | `vals[:1]` +4 | RED (raises in 12d; pinned in 12i) |
| 3 | `pack_floor_soc` | the `vals` comprehension | same | `self.inverters[:2]` +4 | RED (12i sweep + pin) |
| 4 | `binding_soc_floor` | `max` over floors | `decide(soc_floor=)` | `min`, windows | RED (12c2, round 2-3) |
| 5 | call site | the roster argument | same | `self.packs[:1..3]` | RED (12g, round 3) |
| 6 | `Controller.packs` | the comprehension | `binding_soc_floor`, `pack_permissions` | `self.inverters[:2]` +4 | RED (12g three-pack runs) |
| 7 | `site_batt_w` | `sum` + `any(None)` | `decide(batt_w=)` — sizes the floor | `self.inverters[:1]` +4 | RED (test_inverter.py) |
| 8 | `site_rating_w` | `sum` of per-unit conjunction | `decide(achievable_w=)`, freeze rail | `[:1]` +4 | RED (test_inverter.py) |
| 9 | `site_max_w` | `sum` of `max_w` | the site loop's supervised bound | `[:1]` +4 | RED (test_inverter.py) |
| 10 | `site_dev_bound` | `sum` + `any(None)` | `looks_frozen`, `rail_unreachable` | `[:1]` +4 | RED (test_inverter.py) |
| 11 | `site_circulating_w` | `circulating_w` over measured power | SAFETY-31 trip | `[:1]` +4 | RED (test_watchdog.py) |
| 12 | `inputs_ok` | `all` over both readings | gates the whole cycle (§11.1 rule 1) | `[:1]` +4 | RED (test_inverter.py) |
| 13 | `units` | `tuple` of names | the arbiter's scope table | `[:1]` +4 | RED (5 files) |
| 14 | `status_payload` | `min(_dues)` over `self.maints` | the operator's "maintenance due" row | `_dues[:1]` +4 | RED (test_maintenance.py) |
| 15 | `maint_unit` | `self.inverters[self.maint_index]` | which pack the FSM drives | `[0]` −15 | RED (test_maintenance.py) |
| 16 | store migration | the roster name list | MAINT-05's key migration | `[:1]` +4 | RED (test_maintenance.py) |
| 17 | `validate_unit_ranges` | the roster argument | startup refusal | `[:1]` +4 | RED (test_inverter.py) |
| 18 | `cycle()` stale-input hold | `any(sign_wd.tripped)` | **a latched unit's failsafe SET** | `[:1]` +4 | **was GREEN → now RED** (test_watchdog.py part 8) |
| 19 | `publish()` status | `any(sign_wd.tripped)` | the MQTT status vocabulary | `[:1]` +4 | **was GREEN → now RED** (part 8) |
| 20 | `status_payload()` banner | `any(sign_wd.tripped)` | the one headline on the page | `[:1]` +4 | **was GREEN → now RED** (part 8) |
| 21 | `_grade_store_migration` | `any(sign_wd.tripped)` | SAFETY-02's re-arm guard | `[:1]` +4 | was GREEN → now RED (12j), see below |
| 22 | `cycle()` undeliverable list | `sorted(...)` over shares | the *text* of one event line | `[:1]` +4 | **STILL GREEN — honest gap, below** |
**Rows 18-20 are the finding, and they are not this card's code.** All three are
`any(i.sign_wd.tripped for i in self.inverters)`, all three are SAFETY-30's, and
all three survived a green 19-file suite for exactly the reason every blocker on
this card survived: *the only fixture that reached them was a roster of ONE,
where `self.inverters[:1]` IS `self.inverters`.* Part 3 of test_watchdog.py has
built a two-inverter roster with only inverter B latched since SAFETY-30
shipped — the precise data all three needed — and none of them read it.
Row 18 is the one with teeth. A latched unit must take the whole roster past the
stale-input hold, because that unit's `failsafe` SET is the thing parking it;
main.py's own comment says *"the trip would stop acting the moment a poll was
missed"*. With `[:1]`, a latched inverter B plus a one-cycle gap in inverter A's
SoC returns early, and B's last share stands for the heartbeat to re-emit.
test_watchdog.py part 8 now drives that with the latch at **each** position, plus
the no-latch counterfactual that proves the hold really fires.
**Row 21 — graded structurally, and argued to be redundant.** The `[:1]` there
is now red on 12j's enumeration, but I could not construct a *reachable* state in
which it changes an answer: `_grade_store_migration()` runs at startup, before
any cycle, so a unit is tripped in memory only if its own `unit/<name>/sign_tripped`
key was truthy — which makes `namespaced_latched` True and short-circuits the
disjunct. I believe it is an equivalent mutant at that call site rather than a
live hole, and I am recording the argument instead of asserting the conclusion.
**Row 22 — STILL GREEN, and reported with its size.** `", ".join(sorted(u for u
in resolution.shares if not self._can_write(u)))[:1]` truncates the **names in
one event-log line**. It is +4 bytes and no check bites. The refusal itself —
`elif {u for u in resolution.shares if not self._can_write(u)}` — is a different
expression and *is* graded (test_peakguard.py part 10h-1b), so the site still
stops; what a `[:1]` costs is an operator being told one name when two units have
no writer. It is not a roster comprehension (it iterates `resolution.shares`), so
12j does not cover it and does not pretend to. Closing it needs a three-inverter
fixture with two writer-less entries, which is ALLOC-01's fixture and not this
card's. **Filed as open, with its size and its consequence, rather than closed
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.
## 5. ⚠️ THE RESIDUAL — a per-pack EMS floor is NOT this card
`peak_soc_floor` is still **one number for the site**, so the card's own
+32
View File
@@ -300,6 +300,38 @@ Cheap habits fall out of it, and all of them are now used in this repository:
assert the **shape** of that second report, not only its verdict, so the
check cannot silently revert to grading the first path.
10. **A fixture of N members cannot separate two reductions that agree on N
members — so grade the FAMILY, and grade it from the SOURCE.** This is the
only lesson SAFETY-32 cost four review rounds to learn, and it generalises
past packs to every roster, list and window in this repository. Two packs
cannot tell `max` from `min` from first from last. Three cannot tell `max`
from `max(x[-2:])`. **The regress does not terminate in a fixture**: `[:1]`
needs two members, `[:2]` three, `[:3]` four, and adding one more member
moves the hole rather than closing it. Three things follow, and all three
are now in `test_peakguard.py` parts 12c2, 12g, 12i and 12j:
* the fixture is a **SET** — the extreme at every position of the roster,
not one roster with the extreme somewhere;
* the family table is **COMPUTED and PRINTED**, so a member no fixture kills
shows up as a named survivor and takes the check red, instead of being
absent from a list nobody re-derived;
* where the claim is an **IDENTITY** ("this roster", "this list"), pin it
**exactly on the AST** — `ast.unparse(arg) == "self.packs"` is false of
every slice, where `"self.packs" in src` is true of all of them. Where the
claim is **SEMANTIC** ("the maximum"), a shape pin would refuse honest
refactors, so report the unreached width **with its size** instead.
11. **When a check of this shape is found, mutate its SIBLINGS before shipping
the fix.** SAFETY-32 was rejected three times for the same defect one step
over: the reduction, then the roster handed to it, then the reduction handed
to the *other argument of the same call*. Enumerating the family from the
AST and mutating each member took one afternoon and found **four more
survivors in code the card never touched** — three of SAFETY-30's
`any(i.sign_wd.tripped for i in self.inverters)` reductions, invisible
because the only fixture that reached them was a roster of ONE, where
`self.inverters[:1]` **is** `self.inverters`. Ask of every fix: *what is the
sibling of the thing I just hardened, and does any fixture separate it from
a slice of itself?*
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
+667 -1
View File
@@ -4159,6 +4159,590 @@ check("⚠️ AC 5. At a roster of ONE the same notification carries NO per-pack
and "peak_soc_floor bounds THE PEAK GUARD ONLY" in _body32one)
# --- 12i. THE SIBLING ARGUMENT: `pack_floor_soc`, THE OTHER HALF OF THE PAIR --
# ⚠️ THIS IS ROUND 4's BLOCKER, AND IT IS THE SAME FAMILY ONE ARGUMENT OVER.
# Everything from 12c2 to 12h grades reductions on the FLOOR axis: the max
# inside `binding_soc_floor()`, the roster handed to it, `Controller.packs`, and
# `pack_floor_pct()`'s conjunction. `Controller.pack_floor_soc` is the MINIMUM
# over the same roster, handed to the SAME `peakguard.decide()` call as `soc=`,
# and until this block nothing graded its reduction past N=2.
#
# ⚠️ THE PAIRING IS DOCUMENTED, WHICH IS WHY HARDENING ONE HALF IS NOT ENOUGH.
# main.py's call site says "MAXIMUM here pairs with the MINIMUM in
# `pack_floor_soc`: both reductions move the verdict towards refusing a shave
# and away from spending a pack". A conjunction whose two halves have different
# amounts of evidence behind them is exactly as strong as its weaker half.
#
# THE SURVIVORS THIS BLOCK EXISTS FOR, all confirmed GREEN across all 19 files
# at 52380c0 and again on the rebase onto 3934ad7:
#
# min(float(v) for v in vals) -> ... for v in vals[:2] +4 bytes
# -> ... for v in vals[-2:] +5 bytes
# -> ... for v in vals[:3] +4 bytes
#
# while `vals[:1]`, `vals[-1:]`, `vals[1:]`, `vals[:-1]`, `max(vals)` and the
# mean were all killed. The suite's separating power on the SoC axis stopped at
# exactly N=2 - which is the state the FLOOR axis was in before round 3, and
# which round 3 named as its own blocker 2. The evidence is a fixture of two
# packs, and A FIXTURE OF N PACKS CANNOT SEPARATE REDUCTIONS THAT AGREE ON N.
#
# IT CHANGES A REAL ANSWER, which is why it is a defect and not a tidiness
# point: a three-pack roster at 50 / 60 / 11 % with the emptiest pack THIRD.
# The shipped property answers 11 %; `vals[:2]` answers 50 %. The site is then
# graded for discharge PERMISSION against a pack 39 points fuller than the
# emptiest one, and spends a pack its own BMS stops - through `sim/inject.py`,
# which runs no validator rule at all.
#
# ⚠️ AND PRODUCTION IS DEGENERATE ON THE FLOOR AXIS, WHICH MAKES THIS AXIS THE
# LIVE ONE. SPIKE-04 reads pack B as 4 x Dyness DL5.0C at DoD 90 [FROM THE
# DATASHEET AND THE SERIAL - inverter B does not exist and nothing about it has
# been measured], so its cut-out is 10 %, the same as pack A's MEASURED 10 %.
# Two packs that floor at the same value make `binding_soc_floor()` answer the
# option whatever it reduces. The SoC axis has no such degeneracy: two batteries
# at the same state of charge is a coincidence, not a configuration.
#
# THE TREATMENT IS ROUND 3's, TRANSFERRED: a roster SET with the extreme at
# every position, a LENGTH SWEEP, and an EXACT STRUCTURAL PIN on the reduction's
# argument. Nothing below is a measurement of a second or third pack.
_S_EMPTY_SIM, _S_MID_SIM, _S_FULL_SIM = 11.0, 50.0, 60.0
_S_TRIO_SIM = (_S_FULL_SIM, _S_MID_SIM, _S_EMPTY_SIM)
def _soc_reaching_decide(tag, *socs):
"""Drive ONE REAL `Controller.cycle()` on an N-pack roster and report the
SoC `peakguard.decide()` was actually handed, read back off the decision
main.py itself filed (`PeakDecision.soc` is the argument, echoed).
⚠️ THE EXACT MIRROR OF `_floor_reaching_decide` ABOVE, on the other axis.
Not a spy and not a grep: the shipped `cycle()` runs, the shipped
`pack_floor_soc` runs, and the observable is a field of the decision the
controller stored - so the whole path from `Controller.inverters` to the
guard's argument is under test. Every pack carries the SAME cut-out
(inverter A's measured 10 %, which is below `peak_soc_floor`, so
`binding_soc_floor()` answers the option for every roster here); the only
thing that varies between calls is the packs' states of charge. That is
habit 1: with the FLOOR axis held flat, nothing but the SoC reduction can
move these answers.
"""
def _prep(ctl):
for _i in range(len(socs) - 1):
_sfx = chr(ord("b") + _i)
second_inverter(ctl, name=f"inverter-{_sfx}",
setpoint=f"number.sp_{_sfx}")
for inv, so in zip(ctl.inverters, socs):
inv.soc, inv.batt, inv.bms_soc_floor = so, 0.0, A_PACK_FLOOR
ctl, _hs, _net, _rem = wired_over_limit(
TMP / f"s32s_{tag}.db", LIVE_MAX_W * 0.3, prepare=_prep)
return ctl, ctl.peak_guard
# BASELINE: three packs, all of them ABOVE `peak_soc_floor`, so the guard is
# permitted and sizes a real forced discharge. Every run below varies one thing
# from this baseline (habit 7) - which pack is the empty one, and nothing else.
_s_ctl_ok, _s_ok = _soc_reaching_decide("ok3", _S_FULL_SIM, _S_MID_SIM,
SOC_BETWEEN)
# THE SET: the SAME THREE readings, the emptiest at EACH position in turn. A
# reduction that reads a proper subset of the positions misses the empty pack on
# the run where it sits at the position that subset drops, and answers a fuller
# pack instead.
_s3_runs = []
for _pos in range(3):
_sc3 = [_S_FULL_SIM, _S_MID_SIM, _S_EMPTY_SIM]
_sc3.remove(_S_EMPTY_SIM)
_sc3.insert(_pos, _S_EMPTY_SIM)
_c3s, _d3s = _soc_reaching_decide(f"s3p{_pos}", *_sc3)
_s3_runs.append((f"3-pack, emptiest at index {_pos}", _sc3, _c3s, _d3s))
# ...and the two-pack mirror, which is the shape production reaches first.
_s2_runs = []
for _pos in range(2):
_sc2 = [_S_FULL_SIM, _S_EMPTY_SIM] if _pos else [_S_EMPTY_SIM, _S_FULL_SIM]
_c2s, _d2s = _soc_reaching_decide(f"s2p{_pos}", *_sc2)
_s2_runs.append((f"2-pack, emptiest at index {_pos}", _sc2, _c2s, _d2s))
print(f" REAL cycles, every cut-out held at inverter-a's measured "
f"{A_PACK_FLOOR:.0f} %, peak_soc_floor {SOC_FLOOR:.0f} % (every pack past "
f"the first is SIMULATED):")
print(f" 3-pack baseline, none empty: SoCs "
f"{[f'{s:g}' for s in (_S_FULL_SIM, _S_MID_SIM, SOC_BETWEEN)]} % -> "
f"decide() got soc={_s_ok.soc:g} %, floor_limited={_s_ok.floor_limited}")
for _rn, _sc, _ctl_s, _dec_s in _s3_runs + _s2_runs:
print(f" {_rn}: SoCs {[f'{s:g}' for s in _sc]} % -> decide() got "
f"soc={_dec_s.soc:g} %, floor_limited={_dec_s.floor_limited}")
check("⚠️ THE POSITIONAL PIN ON THE SoC AXIS, AND IT NEEDS THREE PACKS TO "
"EXIST. On a roster of THREE the SoC that reaches `decide()` is the "
"EMPTIEST pack wherever on the roster that pack sits - index 0, index 1 "
"and index 2 all answer 11 %, and all three rosters carry the SAME "
"multiset of readings. A property that reduced any PROPER SUBSET of the "
"positions would answer a fuller pack on the run whose empty one sits at "
"the position it dropped. `vals[:2]` and `vals[-2:]` - the +4- and "
"+5-byte mutations that walked through every fixture in this file up to "
"round 3, because at N=2 they ARE the roster - go red here",
[r[3].soc for r in _s3_runs] == [_S_EMPTY_SIM] * 3
and all(len(r[2].inverters) == 3 for r in _s3_runs)
and all(sorted(inv.soc for inv in r[2].inverters)
== sorted(_S_TRIO_SIM) for r in _s3_runs)
and [[inv.soc for inv in r[2].inverters] for r in _s3_runs]
== [r[1] for r in _s3_runs])
check("...and the TWO-pack mirror, which is what production reaches the moment "
"inverter B is installed: swap the same two readings between the packs "
"and the answer is the emptier one both times. `vals[:1]` and `vals[-1:]` "
"- grade the site against pack A alone, or against the last pack alone - "
"each agree with the roster on one of these two runs and disagree on the "
"other, so neither survives the pair",
[r[3].soc for r in _s2_runs] == [_S_EMPTY_SIM] * 2
and [[inv.soc for inv in r[2].inverters] for r in _s2_runs]
== [[_S_EMPTY_SIM, _S_FULL_SIM], [_S_FULL_SIM, _S_EMPTY_SIM]])
check("⚠️ ...AND THE SITE'S VERDICT FLIPS WITH IT, which is what makes this a "
"defect rather than a reporting difference. With the emptiest pack at "
"11 % - below the 20 % EMS floor - every one of those five sites is "
"FLOOR-LIMITED and sizes NO forced discharge at all, where the baseline "
"with its emptiest pack at 25 % is permitted and sizes a positive one. A "
"reduction that dropped the empty pack's position would grade this site "
"for PERMISSION against a pack 39 points fuller and spend, through "
"`sim/inject.py`, a pack its own BMS stops",
all(r[3].floor_limited is True and r[3].floor_w == 0.0
for r in _s3_runs + _s2_runs)
and _s_ok.floor_limited is False and _s_ok.floor_w > 0.0
and _s_ok.soc == SOC_BETWEEN)
check("...and the precondition that makes the six runs comparable (habit 1): "
"every pack in every roster carries the SAME cut-out, so "
"`binding_soc_floor()` answers the SAME site floor on all of them and the "
"FLOOR axis cannot be what separated the answers; the three readings "
"really are three different numbers; and the empty one is really below "
"the EMS floor while the other two are really above it - two readings on "
"the same side of it would make every line here vacuous",
len({r[3].soc_floor for r in _s3_runs + _s2_runs}) == 1
and _s_ok.soc_floor == _s3_runs[0][3].soc_floor == float(SOC_FLOOR)
and all(inv.bms_soc_floor == A_PACK_FLOOR
for r in _s3_runs + _s2_runs for inv in r[2].inverters)
and len({_S_EMPTY_SIM, _S_MID_SIM, _S_FULL_SIM}) == 3
and _S_EMPTY_SIM < SOC_FLOOR < SOC_BETWEEN < _S_MID_SIM < _S_FULL_SIM)
# ⚠️ AND THE ENUMERATION ITSELF, COMPUTED RATHER THAN CLAIMED - 12g's table on
# the other axis. Every positional slice of `vals`, applied to the REAL
# `Controller.inverters` each cycle above actually carried, compared against the
# SoC that REALLY reached `decide()` on that run. A slice with an empty kill
# list is a live survivor and this line goes red naming it.
#
# ⚠️ WHAT THIS DOES NOT CLAIM. It grades the members listed and nothing wider -
# a rewrite that is not a positional slice (a different reduction, a filter on
# pack NAME, a sort before the slice) is not covered here. `max` and the mean
# are graded on the same runs immediately below. The table is PRINTED so a
# reader can see exactly how far the claim reaches.
_S_RUNS = ([("3-pack baseline, none empty", _s_ctl_ok, _s_ok)]
+ [(rn, ctl, dec) for rn, _sc, ctl, dec in _s3_runs + _s2_runs])
def _soc_reduced(vals, fn):
"""`pack_floor_soc`'s body with ONE thing changed: what `min()` iterates.
The None guard still runs over the WHOLE list, because these mutations are
inside the `min()` generator and not in the guard - the guard is graded
separately below. An empty slice makes the real `min()` raise, which is a
crashing cycle and therefore a kill; it is reported as a distinct answer
rather than swallowed.
"""
if not vals or any(v is None for v in vals):
return None
sl = fn(vals)
return float(min(float(v) for v in sl)) if sl else "min() of an empty slice"
_SOC_SLICE_FAMILY = {
"vals[:1]": lambda vs: vs[:1],
"vals[-1:]": lambda vs: vs[-1:],
"vals[:2]": lambda vs: vs[:2],
"vals[-2:]": lambda vs: vs[-2:],
"vals[1:]": lambda vs: vs[1:],
"vals[:-1]": lambda vs: vs[:-1],
"vals[1:-1]": lambda vs: vs[1:-1],
"vals[::2]": lambda vs: vs[::2],
}
_SOC_REDUCTION_FAMILY = {
"max(vals)": lambda vs: max(vs),
"mean(vals)": lambda vs: sum(vs) / len(vs),
"median(vals), upper": lambda vs: sorted(vs)[len(vs) // 2],
"sorted(vals)[1] (second emptiest)": lambda vs: sorted(vs)[min(1, len(vs) - 1)],
}
_soc_slice_kills = {
_nm: [rn for rn, ctl, dec in _S_RUNS
if _soc_reduced([inv.soc for inv in ctl.inverters], _fn) != dec.soc]
for _nm, _fn in _SOC_SLICE_FAMILY.items()}
_soc_red_kills = {
_nm: [rn for rn, ctl, dec in _S_RUNS
if _fn([inv.soc for inv in ctl.inverters]) != dec.soc]
for _nm, _fn in _SOC_REDUCTION_FAMILY.items()}
print(" THE POSITIONAL-SLICE FAMILY inside `pack_floor_soc`, and which run "
"separates each from the whole roster:")
for _nm, _k in _soc_slice_kills.items():
print(f" {_nm:<12} killed by: "
+ ("; ".join(_k) if _k else "*** NOTHING HERE - SURVIVOR ***"))
print(" ...and the NON-slice rewrites of the reduction itself:")
for _nm, _k in _soc_red_kills.items():
print(f" {_nm:<34} killed by: "
+ ("; ".join(_k) if _k else "*** NOTHING HERE - SURVIVOR ***"))
check("⚠️ EVERY positional slice in the enumerated family answers something "
"OTHER than the SoC that really reached `decide()` on at least one of "
"the six runs above - and it is the THREE-pack runs that kill `[:2]` and "
"`[-2:]`, which the two-pack runs structurally could not, by exactly the "
"argument 12c2 makes about N packs and reductions that agree on N. The "
"kill list is computed against the REAL `Controller.inverters` each cycle "
"carried and the REAL `PeakDecision.soc` it produced, so a member with an "
"empty list is a live survivor and takes this line red naming it. This "
"grades the LISTED members only; the printed table is its extent",
all(_soc_slice_kills.values()))
check("...and the same for the rewrites that are not slices at all: `max` - "
"the FULLEST pack, which is `binding_soc_floor()`'s reduction pasted onto "
"the wrong axis and the single most likely wrong edit here - the mean, "
"the median and the second-emptiest each disagree with the shipped answer "
"on at least one run. `max(vals)` on the index-2 run answers 60 % where "
"the site is graded at 11 %",
all(_soc_red_kills.values()))
check("...and the baseline the tables are measured against is the shipped "
"property's OWN answer, not a re-derivation: on every run, the SoC that "
"reached `decide()` equals `Controller.pack_floor_soc` on the roster that "
"cycle carried. If it did not, every kill list above would be measuring "
"the wrong thing",
all(dec.soc == ctl.pack_floor_soc for _rn, ctl, dec in _S_RUNS)
and len(_S_RUNS) == 6
and sorted(len(ctl.inverters) for _rn, ctl, dec in _S_RUNS)
== [2, 2, 3, 3, 3, 3])
# ⚠️ AND THE HONEST LIMIT OF EVERY RUN ABOVE, FOUND BY THIS ROUND'S OWN BATTERY
# AND NOT ARGUED FROM: `vals[:3]` and `vals[-3:]` SURVIVE all six behavioural
# runs, for the same reason `[:2]` survived the two-pack ones - THE LONGEST
# ROSTER HERE IS THREE, and a slice that keeps three consecutive positions IS
# the roster at N=3. Adding a four-pack cycle would close those two and hand
# `[:4]` and `[-4:]` the same free pass. THE REGRESS DOES NOT TERMINATE IN A
# FIXTURE - round 3's lesson, arriving unchanged on this axis.
#
# So the same two halves, each saying what it reaches. First the SWEEP, which is
# cheap here because `pack_floor_soc` is a PROPERTY and needs no cycle to
# evaluate: rosters of 1 to 12 real `Inverter`s, the empty pack at EVERY
# position of each, and the property must answer the emptiest every time. That
# kills every fixed-width head or tail window narrower than the longest roster
# at once, rather than one mutation at a time.
_S_SWEEP_N = 12
def _roster_of(socs):
"""A real Controller carrying one real `Inverter` per reading given."""
ctl, _hs = built(TMP / f"s32sw{len(socs)}.db")
for _i in range(len(socs) - 1):
second_inverter(ctl, name=f"sweep-{_i}", setpoint=f"number.sw_{_i}")
for inv, so in zip(ctl.inverters, socs):
inv.soc, inv.batt, inv.bms_soc_floor = so, 0.0, A_PACK_FLOOR
return ctl
_s_sweep_rows = []
for _n in range(1, _S_SWEEP_N + 1):
for _pos in range(_n):
_sc = [_S_FULL_SIM] * _n
_sc[_pos] = _S_EMPTY_SIM
_s_sweep_rows.append((_n, _pos, tuple(_sc),
_roster_of(_sc).pack_floor_soc))
_s_sweep_bad = [(n, p, sc, got) for n, p, sc, got in _s_sweep_rows
if got != min(sc)]
_s_head_kill = {w: any(min(sc[:w]) != min(sc) for _n, _p, sc, _g in _s_sweep_rows)
for w in range(1, _S_SWEEP_N + 1)}
_s_tail_kill = {w: any(min(sc[-w:]) != min(sc) for _n, _p, sc, _g in _s_sweep_rows)
for w in range(1, _S_SWEEP_N + 1)}
print(f" LENGTH SWEEP on the real property (all SIMULATED past pack A): "
f"{len(_s_sweep_rows)} rosters, lengths 1..{_S_SWEEP_N}, the "
f"{_S_EMPTY_SIM:g} % pack at EVERY position of each among "
f"{_S_FULL_SIM:g} % siblings -> "
f"{len(_s_sweep_rows) - len(_s_sweep_bad)} answer the emptiest, "
f"{len(_s_sweep_bad)} do not")
print(" fixed-width windows killed by the sweep: head widths "
+ ",".join(str(w) for w in sorted(_s_head_kill) if _s_head_kill[w])
+ "; tail widths "
+ ",".join(str(w) for w in sorted(_s_tail_kill) if _s_tail_kill[w])
+ f"; NOT reached: head/tail width {_S_SWEEP_N} and wider")
check("⚠️ THE LENGTH SWEEP, on real `Controller.pack_floor_soc`. Over rosters "
"of 1 to 12 real inverters with the empty pack at every position of each, "
"the property answers the EMPTIEST every time - so every head window "
"`min(vals[:w])` and every tail window `min(vals[-w:])` of width 1 to 11 "
"disagrees with it on at least one roster here, and that is the whole "
"positional family below width 12 closed in one property rather than one "
"mutation at a time. ⚠️ THIS IS THE REAL PROPERTY ON A REAL ROSTER, not a "
"re-implementation: `_roster_of()` appends real `Inverter` objects and "
"reads `ctl.pack_floor_soc`, so it also sees a rewrite of the "
"comprehension that builds `vals`",
not _s_sweep_bad
and len(_s_sweep_rows) == _S_SWEEP_N * (_S_SWEEP_N + 1) // 2
and all(_s_head_kill[w] and _s_tail_kill[w]
for w in range(1, _S_SWEEP_N)))
check("⚠️ ...AND WIDTH 12 AND WIDER IS HONESTLY NOT REACHED, recorded rather "
"than papered over - the same ceiling 12c2's sweep records on the floor "
"axis. No roster in this file is longer than 12, so `min(vals[:12])` IS "
"`min(vals)` on every one of them and no check here can tell them apart. "
"A 13-pack roster would close width 12 and hand width 13 the same free "
"pass. That is why the STRUCTURAL PIN below exists alongside these runs, "
"and this line asserts the unreachedness itself so a later sweep that "
"lengthens the rosters takes it red and forces the sentence rewritten",
not _s_head_kill[_S_SWEEP_N] and not _s_tail_kill[_S_SWEEP_N]
and max(len(sc) for _n, _p, sc, _g in _s_sweep_rows) == _S_SWEEP_N)
# ...and the NONE GUARD is a reduction over roster length TOO, and it had
# exactly the same fixture problem: 12d sets the SECOND of two packs to None, so
# `any(v is None for v in vals[:1])` - grade only pack A for silence - answers
# "all present" there and hands a list containing None to `min()`. The sweep
# below moves the silent pack along the roster at every length, which is the
# data that reaches it. THE DIRECTION MATTERS: None must WIN, because a silent
# pack is a pack nothing can grade and `decide()` treats None as floor-limited.
def _pfs_answer(ctl):
"""`pack_floor_soc`, with a raise reported as an ANSWER rather than
ending the file. A guard narrowed to a slice does not return a wrong
number - it lets a None reach `min(float(v) ...)` and raises TypeError -
and a check that dies is a check that names nothing."""
try:
return ctl.pack_floor_soc
except Exception as exc: # noqa: BLE001 - reported
return f"raised {type(exc).__name__}"
_s_none_rows = []
for _n in range(1, _S_SWEEP_N + 1):
for _pos in range(_n):
_ctl_n = _roster_of([_S_FULL_SIM] * _n)
_ctl_n.inverters[_pos].soc = None
_s_none_rows.append((_n, _pos, _pfs_answer(_ctl_n)))
print(f" NONE-GUARD SWEEP: {len(_s_none_rows)} rosters, ONE silent pack at "
f"every position of every length 1..{_S_SWEEP_N} -> "
f"{sum(1 for _n, _p, g in _s_none_rows if g is None)} answer None")
check("⚠️ THE NONE GUARD IS THE SAME FAMILY. One silent pack at ANY position of "
"ANY roster length makes `pack_floor_soc` None - never a number, and "
"never the minimum of the packs that did answer. A guard narrowed to a "
"positional slice would let the site spend the packs that answered while "
"one sat unmeasured; 12d's two-pack fixture, with the SECOND pack silent, "
"cannot see a guard narrowed to the first",
all(g is None for _n, _p, g in _s_none_rows)
and len(_s_none_rows) == _S_SWEEP_N * (_S_SWEEP_N + 1) // 2)
# ⚠️ THE STRUCTURAL HALF: the reduction's ARGUMENT, pinned EXACTLY, at any N.
# This is 12g's treatment applied inside a property rather than at a call site,
# and it is NOT a substring test - `"vals" in src` is true of every slice of
# `vals`, whereas `ast.unparse(iter) == "vals"` is false of all of them.
# `vals[:3]` unparses to `vals[:3]`.
#
# THREE expressions are pinned, because there are three reductions over roster
# length in this one property and the runs above reach them unevenly:
# the LIST `[inv.soc for inv in self.inverters]` - the roster itself
# the GUARD `any(v is None for v in vals)`
# the REDUCTION `min(float(v) for v in vals)`
#
# ⚠️ AND THE PIN IS BLIND TO WHAT THE RUNS SEE, which is why both halves are
# here. It cannot see `min` become `max` (identical shape, different name -
# killed by the roster set), it cannot see `inv.soc` become `inv.bms_soc_floor`
# (killed by the runs' held-flat cut-outs), and it cannot see a change inside
# `Inverter.soc` at all.
_pfs_fn = next(n for n in ast.walk(ast.parse(_mainsrc))
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
and n.name == "pack_floor_soc")
_pfs_vals = next(n.value for n in _pfs_fn.body
if isinstance(n, ast.Assign)
and [ast.unparse(t) for t in n.targets] == ["vals"])
def _gen_iter_of(fn_node, name):
"""The iterable of the comprehension inside the single `name(...)` call."""
call = next(n for n in ast.walk(fn_node)
if isinstance(n, ast.Call) and isinstance(n.func, ast.Name)
and n.func.id == name)
comp = call.args[0]
return comp.generators[0].iter if len(comp.generators) == 1 else None
_pfs_min_iter = _gen_iter_of(_pfs_fn, "min")
_pfs_any_iter = _gen_iter_of(_pfs_fn, "any")
print(f" pack_floor_soc reduces: list over `{ast.unparse(_pfs_vals.generators[0].iter)}`"
f", any() over `{ast.unparse(_pfs_any_iter)}`, min() over "
f"`{ast.unparse(_pfs_min_iter)}`")
check("⚠️ THE STRUCTURAL HALF, EXACT RATHER THAN A SUBSTRING. `min()` reduces "
"the WHOLE list `vals` - a bare Name, not a Subscript, not a call, not a "
"second comprehension - and `vals` is built by ONE comprehension over the "
"WHOLE property `self.inverters`, an attribute of self and not a slice of "
"one. So EVERY slice of either is red here at EVERY roster length, "
"including `vals[:3]` and `vals[-3:]`, which survive all six runs above "
"because the longest roster they drive is three packs long, and including "
"`self.inverters[:2]` in the comprehension, which the sweep sees only up "
"to length 12. This grades the code's SHAPE and nothing about the values; "
"what the expression MEANS is graded by the runs",
isinstance(_pfs_min_iter, ast.Name) and _pfs_min_iter.id == "vals"
and ast.unparse(_pfs_min_iter) == "vals"
and isinstance(_pfs_vals, ast.ListComp)
and len(_pfs_vals.generators) == 1
and not _pfs_vals.generators[0].ifs
and isinstance(_pfs_vals.generators[0].iter, ast.Attribute)
and isinstance(_pfs_vals.generators[0].iter.value, ast.Name)
and _pfs_vals.generators[0].iter.value.id == "self"
and _pfs_vals.generators[0].iter.attr == "inverters"
and ast.unparse(_pfs_vals.generators[0].iter) == "self.inverters")
check("...and the NONE GUARD reduces that same whole list, pinned the same way. "
"`any(v is None for v in vals[:1])` is +4 bytes and leaves every other "
"line in this file identical; the sweep above reaches it up to length 12 "
"and this reaches it at every length",
isinstance(_pfs_any_iter, ast.Name) and _pfs_any_iter.id == "vals"
and ast.unparse(_pfs_any_iter) == "vals")
check("...and the counterfactual that shows these pins are not vacuous (habit "
"7): the very slices they exist for really do unparse to something else, "
"so the equalities above are doing work rather than being trivially true "
"of anything shaped like an argument",
all(ast.unparse(ast.parse(f"vals{sl}", mode="eval").body) != "vals"
and ast.unparse(ast.parse(f"self.inverters{sl}",
mode="eval").body) != "self.inverters"
for sl in ("[:1]", "[-1:]", "[:2]", "[-2:]", "[:3]", "[-3:]",
"[:12]", "[-12:]", "[1:]", "[:-1]", "[1:-1]", "[::2]"))
and ast.unparse(ast.parse("list(reversed(vals))",
mode="eval").body) != "vals")
# ...and BOTH consumers of this property are pinned to it by name, so a reduction
# hardened here cannot be bypassed by a call site that stops using it. The peak
# guard's `soc=` is already pinned in part 4; the OTHER one is arbitrage's, and
# it is a safety consumer too - `soc_floor_pct` is what stops a sell.
_arb_calls = [n for n in ast.walk(ast.parse(_mainsrc))
if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute)
and n.func.attr == "decide"
and isinstance(n.func.value, ast.Name)
and n.func.value.id == "arbitrage"]
_arb_soc = ast.unparse(next(k.value for k in _arb_calls[0].keywords
if k.arg == "soc"))
print(f" main.py's arbitrage.decide(soc=...) is `{_arb_soc}` "
f"({len(_arb_calls)} call site)")
check("⚠️ AND THE SECOND CONSUMER OF THE SAME REDUCTION. `arbitrage.decide()` "
"is handed the SAME `self.pack_floor_soc` and grades it against "
"`soc_floor_pct` to decide whether to SELL - so the mutation this block "
"exists for spends a pack on the arbitrage path as well as the peak-guard "
"path, and hardening the property covers both. A call site that reverted "
"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
# 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.
#
# 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.
#
# ⚠️ 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")
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 _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.
"""
return any(isinstance(n, ast.Subscript) and _is_roster(n.value)
for n in ast.walk(node))
_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:
#
# `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.
#
# 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`.
# ==============================================================================
print()
print("11. mutation: what defeats these guards, and which check bites")
@@ -4512,6 +5096,74 @@ MUT.append(("`binding_soc_floor()` reduced over a window 12 packs wide or "
"max is order- and duplicate-invariant; the second is nonetheless "
"red on 12g's structural pin"))
# --- ROUND 4: THE SIBLING ARGUMENT, AND THE ENUMERATION THAT FOUND THE REST --
# 28. `pack_floor_soc` reduced over a WINDOW of roster positions. THIS ROUND'S
# BLOCKER, and the same family as rows 25-26 one ARGUMENT over: the MINIMUM
# handed to the same `decide()` call as `soc=`. Green across all 19 files at
# 52380c0 and again on the rebase onto 3934ad7.
MUT.append(("`pack_floor_soc` reduced over a WINDOW - `vals[:2]` (+4 bytes) or "
"`vals[-2:]` (+5) - ROUND 4's BLOCKER, the SoC axis of the pair",
all(_soc_slice_kills[k] for k in ("vals[:2]", "vals[-2:]",
"vals[:1]", "vals[-1:]")),
"part 12i's THREE-pack runs and their POSITIONAL-SLICE TABLE: the "
"same three readings with the emptiest at index 0, 1 and 2, so "
"every proper subset of the positions drops the empty pack on some "
"run. ⚠️ At N=2 `[:2]` IS the roster - the two-pack mirror cannot "
"see it, which is why the suite's separating power on this axis "
"stopped at exactly N=2 for four rounds"))
# 29. ...and the three-wide window, invisible to every behavioural run here.
MUT.append(("`pack_floor_soc` reduced over a THREE-wide window - `vals[:3]` / "
"`vals[-3:]` - invisible to every cycle in this file",
_s_head_kill[3] and _s_tail_kill[3]
and ast.unparse(_pfs_min_iter) == "vals",
"part 12i's LENGTH SWEEP (widths 1-11, on the REAL property over "
"rosters of 1 to 12 real inverters) and its STRUCTURAL PIN. The "
"six real cycles cannot see it - three packs is the longest roster "
"they drive - and the sweep and the pin are what do"))
# 30. the reduction itself, on the wrong axis. Zero byte delta.
MUT.append(("`pack_floor_soc` reduced with `max()` - the FULLEST pack, which is "
"`binding_soc_floor()`'s own reduction pasted onto the SoC axis - "
"or with the mean, the median or the second-emptiest",
all(_soc_red_kills.values()),
"part 12i's NON-SLICE table, on the same six real cycles: the "
"emptiest pack is 11 % where the fullest is 60 %, so every one of "
"the four disagrees on every run. ⚠️ Zero byte delta, and the most "
"likely wrong edit here precisely because the sibling reduction one "
"argument over IS a max"))
# 31. the None guard, which is a reduction over roster length too.
MUT.append(("`pack_floor_soc`'s NONE GUARD narrowed to a slice - "
"`any(v is None for v in vals[:1])`, +4 bytes",
all(g is None for _n, _p, g in _s_none_rows)
and ast.unparse(_pfs_any_iter) == "vals",
"part 12i's NONE-GUARD SWEEP - one silent pack at every position of "
"every length 1..12 - and the same structural pin. ⚠️ 12d's "
"two-pack fixture silences the SECOND pack only, so a guard "
"narrowed to the FIRST answers 'all present' there and hands a None "
"to `min()`"))
# 32. and the roster the list itself is built from.
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,
"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"))
# 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"))
for label, caught, where in MUT:
print(f" {'caught' if caught else 'NOT CAUGHT':>10} {label}")
print(f" {where}")
@@ -4546,7 +5198,7 @@ check("...⚠️ AND SO ARE THE SEVEN DEFEATS THAT SURVIVED A GREEN 19-FILE SUIT
"consecutive cards and, twice running, inside the fix written to close "
"it. The last five were found by a battery rather than by argument, "
"which is the only thing that has ever found any of them",
all(m[1] for m in MUT[21:27]) and len(MUT) == 28)
all(m[1] for m in MUT[21:27]) and len(MUT) == 34)
check("...⚠️ AND ONE IS RECORDED AS NOT CAUGHT, which is the only honest row "
"this round could write for it: a reduction over a window TWELVE packs "
"wide is indistinguishable from `max(floors)` on every roster in this "
@@ -4559,6 +5211,20 @@ check("...⚠️ AND ONE IS RECORDED AS NOT CAUGHT, which is the only honest row
"with, and it exists to be argued with",
not MUT[27][1]
and all(_head_kill[w] and _tail_kill[w] for w in range(1, _SWEEP_N)))
check("...⚠️ AND ROUND 4's SIX, WHICH ARE THE SAME FAMILY AT THE SIBLING "
"ARGUMENT OF THE SAME CALL. `pack_floor_soc` is the MINIMUM over the "
"roster handed to `decide(soc=...)`; `binding_soc_floor()` is the MAXIMUM "
"handed to `decide(soc_floor=...)` beside it, and peakguard.py's own "
"docstring names them as a pair. Rounds 2 and 3 hardened one half; "
"`vals[:2]`, `vals[-2:]` and `vals[:3]` were still green on 52380c0 with "
"the other, and a three-pack roster at 50 / 60 / 11 % answers 11 % clean "
"and 50 % mutated - a discharge PERMISSION graded against a pack 39 "
"points fuller than the emptiest. The last row is the one that matters "
"most: closing the MEMBER four times has cost four review rounds, so the "
"FAMILY is now enumerated from the source, and that enumeration found "
"four more survivors in code this card never touched",
all(m[1] for m in MUT[28:34]) and len(MUT) == 34
and _S_EMPTY_SIM < SOC_FLOOR)
check("...and so is the last one, which is not a defeat at all but the EDGE OF "
"THIS CARD: the grid-following loop can still take the pack past this "
"floor, because the floor bounds the peak guard's own forced discharge "
+175
View File
@@ -1818,6 +1818,181 @@ check("...and no `sign_site` claim can be filed at a roster of one, so the "
"arbitration is byte-identical to what shipped",
"sign_site" not in _c1s.reason)
print()
print("8. SAFETY-32 ROUND 4's ENUMERATION: the two `any(...)` reductions over")
print(" the roster whose only evidence is a latched NON-FIRST unit")
# ⚠️ THIS SECTION IS NOT SAFETY-32's SUBJECT, AND IT IS HERE BECAUSE OF IT.
# SAFETY-32 was rejected three 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 4 therefore ENUMERATED every such reduction in
# `Controller` - min, max, sum, any, all, sorted, [0], [-1] over
# `self.inverters` / `self.packs` - and mutated each to `[:1]` to find out which
# were GRADED. Two survived a green 19-file suite, both `any(i.sign_wd.tripped
# for i in self.inverters)`, both SAFETY-30's, and this block is what closes
# them. No shipped behaviour changes; the reductions were already right.
#
# main.py `cycle()`, the stale-input hold - +4 bytes, 19/19 GREEN
# main.py `publish()`, the status vocabulary - +4 bytes, 19/19 GREEN
#
# ⚠️ WHY THE SUITE COULD NOT SEE THEM, and it is the same sentence every time:
# the only fixture that reached either was a roster of ONE, where
# `self.inverters[:1]` IS `self.inverters`. Part 3 above builds a two-inverter
# roster in which ONLY inverter B is latched - the exact data both reductions
# needed - and neither of them was read on it. A fixture of N units cannot
# separate reductions that agree on N units, one card over.
#
# ⚠️ AND BOTH ORDERS ARE RUN, for 12g's reason: a single "only B is latched"
# fixture kills `[:1]` and leaves `[-1:]` - grade the roster by its LAST unit -
# answering identically. The pair is what leaves no single-position slice.
async def _latched_then_input_gap(reversed_idx):
"""Latch ONE unit's sign watchdog, then take a SIBLING's reading away.
⚠️ A REAL LATCH FROM A REAL EPISODE, not `sign_wd.tripped = True`: the same
`_SignHass` plant and the same `_drive()` part 3 uses, so the detector
reaches its verdict through the shipped path. Then one pack's SoC goes
missing for LESS than `stale_input_s`, which is the state the hold exists
for - a single missed poll must not become a fault.
THE FAULT THIS GRADES: a LATCHED unit must take the whole roster past that
hold, because the unit's `failsafe` SET is the thing parking it. Return
early and the unit's last share stays standing for the heartbeat to
re-emit - main.py's own comment: "the trip would stop acting the moment a
poll was missed". A reduction that looked at one roster POSITION would do
exactly that whenever the latched unit was not the position it looked at.
"""
hass = _SignHass(load_w=2000.0, reversed_units={reversed_idx})
c = _sign_site(hass)
await _drive(c)
sibling = c.inverters[1 - reversed_idx]
sibling.soc = None
c.inputs_bad_since = datetime.now(timezone.utc)
await c.cycle()
return c
_gap_b = asyncio.run(_latched_then_input_gap(1))
_gap_a = asyncio.run(_latched_then_input_gap(0))
for _tag, _c, _i in (("B latched", _gap_b, 1), ("A latched", _gap_a, 0)):
_pl = [p for p in (_c.allocation.plans if _c.allocation else ())
if p.unit == _c.inverters[_i].name]
print(f" {_tag}, sibling's SoC missing 0 s: inputs_ok="
f"{_c.inputs_ok}, reason={_c.reason!r}, latched unit target="
f"{_c.inverters[_i].target}, why={_pl[0].why if _pl else '(no plan)'}")
check("PRECONDITION (habit 1): on BOTH runs the site really is in the state "
"the hold is for - one pack's reading is missing, `inputs_ok` is False, "
"the gap is younger than `stale_input_s`, and the SITE latch is NOT "
"tripped, so the only thing that can carry the roster past the hold is "
"the per-unit `any()`. And exactly ONE unit is latched on each run, at "
"the OTHER position",
_gap_b.inputs_ok is False and _gap_a.inputs_ok is False
and _gap_b.site_sign_wd.tripped is False
and _gap_a.site_sign_wd.tripped is False
and [i.sign_wd.tripped for i in _gap_b.inverters] == [False, True]
and [i.sign_wd.tripped for i in _gap_a.inverters] == [True, False])
check("⚠️ A LATCHED UNIT TAKES THE ROSTER PAST THE STALE-INPUT HOLD FROM EITHER "
"POSITION. Neither cycle parks on `inputs missing`; both go on to file "
"claims, and the latched unit is commanded 0 W by its OWN `failsafe` "
"claim, named in the allocation record. Grade this with "
"`self.inverters[:1]` and the B-latched run returns early - the +4-byte "
"mutation that was GREEN across all 19 files - and with "
"`self.inverters[-1:]` the A-latched run does. The remedy stops acting "
"the moment a poll is missed, which is main.py's own sentence for it",
not (_gap_b.reason or "").startswith("inputs missing")
and not (_gap_a.reason or "").startswith("inputs missing")
and _gap_b.inverters[1].target == 0.0
and _gap_a.inverters[0].target == 0.0
and all(any(p.unit == c.inverters[i].name and "failsafe" in p.why
for p in (c.allocation.plans if c.allocation else ()))
for c, i in ((_gap_b, 1), (_gap_a, 0))))
# ...and the counterfactual, which is what makes the two lines above a
# DIFFERENCE rather than a description: with NOTHING latched, the very same
# input gap DOES park the site on the hold. Without this the checks above would
# be satisfied by a `cycle()` that never had a hold at all.
async def _gap_no_latch():
c = _sign_site(_SignHass(load_w=2000.0))
await _drive(c, n=2)
c.inverters[0].soc = None
c.inputs_bad_since = datetime.now(timezone.utc)
await c.cycle()
return c
_gap_none = asyncio.run(_gap_no_latch())
print(f" nothing latched, same gap: reason={_gap_none.reason!r}")
check("...and the counterfactual (habit 7): with NO unit latched the SAME gap "
"DOES hold the site - `inputs missing 0s` - so the two runs above show "
"the latch carrying the roster past a hold that is really there, rather "
"than a hold that never fires",
(_gap_none.reason or "").startswith("inputs missing")
and not any(i.sign_wd.tripped for i in _gap_none.inverters))
# --- and the SECOND survivor: the status vocabulary publish() hands the broker
# ⚠️ THE EXISTING CHECK IS AT A ROSTER OF ONE, which is why this one exists.
# "MQTT's controller-status sensor reports 'failsafe'" above drives a
# single-inverter Controller, where `self.inverters[:1]` is the whole roster.
# A site at 0 W reporting `running` is the failure main.py's comment names, and
# at two inverters it is reachable whenever the latched unit is not the one a
# narrowed reduction happens to read.
_pub = {}
def _published(ctl):
_pub.clear()
ctl.mqtt = type("P", (), {"enabled": True,
"publish": lambda _s, v: _pub.update(v),
"close": lambda _s: None})()
ctl.publish()
return dict(_pub)
_pb, _pa = _published(_gap_b), _published(_gap_a)
_pn = _published(_gap_none)
print(f" published status: B latched -> {_pb.get('status')!r}, "
f"A latched -> {_pa.get('status')!r}, none latched -> "
f"{_pn.get('status')!r}")
check("⚠️ AND `publish()` SAYS `failsafe` WHICHEVER UNIT IS LATCHED. The status "
"sensor is the vocabulary every dashboard already reads, and a site with "
"one inverter parked by its own failsafe claim reporting `running` is "
"the exact failure main.py's comment names. `self.inverters[:1]` "
"publishes `running` on the B-latched site - +4 bytes, GREEN across all "
"19 files - and `self.inverters[-1:]` does it on the A-latched one",
_pb.get("status") == "failsafe" and _pa.get("status") == "failsafe")
check("...and the counterfactual: the same two-inverter site with NOTHING "
"latched publishes `running`, so the two above are a difference the "
"reduction produced and not a constant",
_pn.get("status") == "running" and _gap_none.auto is True)
# --- and the THIRD one the same enumeration found: the OPERATOR'S BANNER -----
# ⚠️ `status_payload()`'s failsafe branch is a fourth `any(... for i in
# self.inverters)`, and `self.inverters[:1]` there was +4 bytes and GREEN across
# all 19 files too - for the third time, because the only fixture that reached
# it was a roster of ONE. The banner is the ONE sentence at the top of the page;
# a site holding an inverter at 0 W under a "Stopped" or "NOT READY" headline
# sends the operator to the wrong question.
_bn_b = _gap_b.status_payload()
_bn_a = _gap_a.status_payload()
_bn_n = _gap_none.status_payload()
print(f" banner: B latched -> {_bn_b['banner']!r}")
print(f" A latched -> {_bn_a['banner']!r}")
print(f" none -> {_bn_n['banner']!r}")
check("⚠️ AND THE BANNER LEADS WITH THE FAILSAFE WHICHEVER UNIT IS LATCHED, at "
"level `bad`, with the N>=2 sentence that names the unit's OWN measured "
"power rather than the meter. `self.inverters[:1]` drops the headline on "
"the B-latched site and `self.inverters[-1:]` on the A-latched one, and "
"the page then leads with whatever the next branch says",
_bn_b["level"] == _bn_a["level"] == "bad"
and _bn_b["banner"] == _bn_a["banner"]
and _bn_b["banner"].startswith("FAILSAFE")
and "its own measured power" in _bn_b["banner"])
check("...and the counterfactual again: the same site with nothing latched "
"carries neither that level nor that sentence, so the banner really is "
"being produced by the reduction under test",
not _bn_n["banner"].startswith("FAILSAFE")
and _bn_n["banner"] != _bn_b["banner"])
print()
if fails:
print(f"{len(fails)} FAILED: {', '.join(fails)}")