ARB-03: pin all eight actuator call sites statically
checks / unit (pull_request) Successful in 3m45s

The independent review of PR #40 found AC 6 met at three of the eight claim
construction sites while the check claimed to cover the class - this
repository's named failure mode, a check observing something other than what
it claimed, reproduced inside the check written to prevent it.

WHAT WAS WRONG. Part 10g's only call-site check was a real two-inverter cycle.
That cycle is auto, P1-healthy, no maintenance procedure, no tripped sign
watchdog, the guard idle and arbitrage off, so it reaches `device`,
`supervised` and `loop` and nothing else - three of the eight direct appends in
`Controller.cycle()`. Its own check text nevertheless read "the line a
mis-typed `actuator_of(...)` at a call site goes red on", and the PR body's
mutation table presented it as covering the class. The reviewer proved the gap
on the shipped tree: `actuator_of("maintenance")` -> `actuator_of("peak")` at
the maintenance-shaping append left test_arbiter.py, test_peakguard.py and
test_arbitrage.py ALL GREEN, while at a roster of two it built a site loop of
-1500 W against the guard's site `lo` of 1500 W and the now-misrouted
charge-only `hi = 0` - `lo > hi`, arbiter clause 3, 0 W IN THE BILLED QUARTER,
SAFETY-25 §0a's fatal case from a one-word typo. The same mutation at the
`failsafe` append survived too. The runtime detector does fire -
`scope_violations()` names it - but it only logs; nothing graded it.

THE FIX, over machinery already present. Part 10f's AST scan already reads each
`claims.append(...)` in `Controller.cycle()`. All eight direct appends expose
their actuator statically as `actuator=self.actuator_of('<literal>')`, so 10g
now requires that literal to EQUAL the source the claim is filed under, read as
SOURCE TEXT rather than evaluated - `actuator_of("peak")` at the maintenance
append is the same VALUE at the shipped roster of one and a different string
here. That pins all eight routing sites, on the line the typo is written, in
every configuration and at every roster, exactly the way 10f pins the source
set. Two further lines keep the scan from going vacuous: every direct append
must be readable in that shape (a computed or hand-written actuator fails
there), and the ten appends must still account for 10f's eight sources.

The two INDIRECT appends carry no `actuator=` at the call site to mis-type.
`peak_claim` and `arb_claim` are routed where their claim is built, and are
pinned there - `peakguard.claim()` by the SWEEP_SITE sweep over every load x
elapsed x battery x SoC x floor combination, `arbitrage.claim()` by
test_arbitrage.py. 10g now says so at the line, and says which three of the
eight its dynamic cycle actually sees, rather than rounding up.

MEASURED, NOT ARGUED. Each of the eight direct appends mutated in turn to a
wrong actuator - device->loop, supervised->peak, failsafe->safety,
safety(stopped)->device, safety(inputs missing)->maintenance,
maintenance(SET)->loop, maintenance(shaping)->peak, loop->supervised - each on
a purged tree via tools/mutation_run.sh over all 17 files. All eight RED, every
one of them on the new line.

TWO SURVIVING MUTATIONS OF THE SAME CLASS, ALSO CLOSED.

* `resolve()` did not have to honour the roster it was given: `if units is
  None:` -> `if True:` left the whole suite green. Ignoring the argument makes a
  unit that filed nothing this cycle cease to exist - a site of two reads as a
  site of one and stage 3 passes the whole grid target through to the inverter
  that spoke, which is ARCH-01's fault with its own evidence removed.
  test_arbiter.py part 7 now grades it on the one case that separates the two, a
  SILENT unit. Mutation RED in test_arbiter.py and test_peakguard.py.
* main.py's N>=2 refusal was unreachable and ungraded: `if not
  resolution.allocated:` -> `if False:` left the whole suite green, so the right
  instinct was one refactor away from being deleted as dead code. The guard is
  KEPT - the alternative default is the ARCH-01 fault - and new part 10h grades
  it on the two-inverter cycle that already existed: the 0 W, the `failsafe`
  winner, the log line naming the roster size, and the write that follows.
  Mutation RED.

AND THE WORDING CORRECTED. main.py does not "refuse to write" on
`allocated=False`. It SUBSTITUTES a 0 W failsafe and then calls
`write_setpoint(0.0)` like any other cycle. 0 W is the correct
fail-toward-inaction direction and the log text is accurate about what it
refuses - to BROADCAST the site target - but "refuses to write" overstates the
code. Said as what it does, in the CHANGELOG, at the branch in main.py, and in
10h's own check text. `resolve()`'s docstring now says the roster argument has
to be HONOURED, which is not the same as existing.

17/17 green on a purged tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-09-02 03:46:40 +02:00
co-authored by Claude Opus 5
parent 06f95e0a64
commit 38f6680639
5 changed files with 252 additions and 16 deletions
+43 -7
View File
@@ -39,8 +39,22 @@ numbers.**
an allocator** - `ALLOC-01` builds it. With at most one unit it passes the
target through (§6.2's N=1 identity); with two or more **it declines, no
shares, `allocated=False`**, because the obvious placeholder - hand the target
to each - IS the fault `ARCH-01` was filed about, and `main.py` refuses to
write rather than broadcast.
to each - IS the fault `ARCH-01` was filed about. **What `main.py` then does
is SUBSTITUTE, not abstain**: it replaces the resolution with a 0 W `failsafe`,
logs "refusing to broadcast the site target", and writes that 0 W through
`write_setpoint()` like any other cycle. The refusal is to BROADCAST; the
write still happens, in the fail-toward-inaction direction. Graded end to end
on a real two-inverter cycle by new **part 10h** - the branch is unreachable
on the shipped roster, and replacing it with `if False:` had left all 17
suites green.
* **`resolve()` HONOURS the roster it is handed, and that is now graded too.**
Left `None` the roster is derived from the claims, which is the documented
default; passed explicitly it is used. Deriving it *always* - ignoring the
argument - makes a unit that filed nothing this cycle cease to exist, so a
site of two reads as a site of one and the seam passes the whole grid target
through to the inverter that spoke: `ARCH-01`'s fault with its own evidence
removed. `test_arbiter.py` part 7 grades it on the one case that separates the
two, a **silent unit**.
* **The contradiction is no longer guarded against, it CANNOT BE WRITTEN DOWN.**
The partition is by `actuator` and it is a partition: a unit's `hi` and the
site's `lo` are never both terms of one `lo > hi` test. Constructed and swept -
@@ -68,12 +82,34 @@ numbers.**
reading it, which is why `actuator` is the LAST field of `Claim` and not the
first. Its spy gained `**kw` so the roster passes through unread rather than
being silently dropped. New **part 10g** pins `CLAIM_SCOPE` to the same
eight-source set, so a ninth source now goes red twice; and it grades a **real
two-inverter cycle**, which is the only place a mis-typed `actuator_of(...)`
at a call site is observable - at N=1 every source legitimately routes to the
site, so a swap there is invisible by construction. Verified by breaking it:
building SAFETY-25's Option B as a ninth source still takes 10f red on the
eight-source set, so a ninth source now goes red twice. Verified by breaking
it: building SAFETY-25's Option B as a ninth source still takes 10f red on the
line it is written.
* **And the CALL SITES are pinned statically, at all eight of them.** 10g's
first cut graded routing only through a **real two-inverter cycle** and called
that "the line a mis-typed `actuator_of(...)` at a call site goes red on".
**It was not.** That cycle reaches `device`, `supervised` and `loop` and
nothing else - three of the eight direct appends - so a mis-type at the other
five survived every suite in this repository. Proved on the shipped tree by PR
#40's reviewer: `actuator_of("maintenance")` -> `actuator_of("peak")` at the
maintenance-shaping append left `test_arbiter.py`, `test_peakguard.py` and
`test_arbitrage.py` all green while, at a roster of two, it built a site loop
of -1500 W against the guard's site `lo` of 1500 W and the now-misrouted
charge-only `hi = 0` - `lo > hi`, arbiter clause 3, **0 W in the billed
quarter**, SAFETY-25 §0a's fatal case from a one-word typo. That is this
repository's named failure mode - **a check observing something other than
what it claimed** - reproduced inside the check written to prevent it, so it
is closed the way 10f closes it: over the **same AST scan**. All eight direct
appends expose their actuator as a literal `actuator=self.actuator_of('...')`,
so the literal is required to EQUAL the source the claim is filed under - all
eight, on the line the typo is written, in every configuration and at every
roster, whether or not any fixture reaches the state that files them.
**Measured: each of the eight mutated to a wrong actuator in turn, each one
red.** The two INDIRECT appends (`peak_claim`, `arb_claim`) carry no
`actuator=` at the call site to mis-type, and are pinned where their routing
is actually chosen - `peakguard.claim()` by the `SWEEP_SITE` sweep,
`arbitrage.claim()` by `test_arbitrage.py`. 10g's dynamic cycle is kept and
now says which three of the eight it sees instead of claiming the class.
**SAFETY-25's open question is ANSWERED: the BMS cut-out is the DELIBERATE and
only backstop for demand-driven discharge, and a loop-side reserve floor is