ARB-03: restore Claim.actuator and make resolve() three-stage
checks / unit (pull_request) Successful in 3m45s

specs.md:467 declares `actuator: str` as the FIRST field of `Claim`.
app/arbiter.py:55 had no such field. The specification designed
per-actuator claims and the implementation dropped it, so every claim was
implicitly about "the one and only actuator", resolve() returned one scalar
Resolution.target_w, and main.py assigned it to one self.target. THERE WAS
NO SEAM AN ALLOCATOR COULD OCCUPY - and ARCH-01 (specs.md §6.2, 2026-09-02)
decided the site grid target is ALLOCATED across several inverters and never
broadcast. This is a regression fix against the written design; creating that
seam is the whole of the change.

WHY IT MATTERS, IN THE UNITS THE SYSTEM IS BILLED IN. The control primitive
is meter emulation: the master reports a fabricated grid reading and the
inverter regulates itself to it, so one number means "the battery moves X W"
to the add-on and "the grid is at X W" to the inverter - an identity that
holds only at N=1. Broadcast one target to two inverters and each supplies it
in full; the loop gain at the meter becomes N times the per-unit gain, which
at the gain config.yaml:374 accepts is a permanent limit cycle with every
watchdog in §11.2 reporting healthy. And a per-inverter reserve filed as a
site `hi` against the peak guard's site `lo` is `lo > hi`, arbiter clause 3,
0 W IN THE BILLED QUARTER - the parked battery SAFETY-25's memo calls fatal.

THE SPLIT, VERIFIED AT EACH CONSTRUCTION SITE RATHER THAN ACCEPTED FROM THE
SURVEY. Four sources are about the GRID CONNECTION and are SITE claims:
`loop` (compute() regulates grid_w, the P1 measurement, and §6.2's whole
architecture is one integrator on P1 whose output is then split), `peak` (the
15-minute average offtake AT the grid connection, which is what the capacity
tariff bills - one quarter-hour peak per site, not one per pack),
`arbitrage` (buys and sells at the grid against a day-ahead price; the price
is a property of the connection and the energy crosses the meter) and
`safety` (both construction sites read: "stopped" is the operator switching
control off for the site, "inputs missing" is the site's own meter gone -
neither is about a pack, and both must stop every inverter).

Four are about a pack, a device or one write and are PER-INVERTER: `device`
(one inverter's own reported min/max - there is no site rating),
`supervised` (max_w caps what may be WRITTEN to one setpoint entity, §11.3,
so it is scoped like `device` for the same reason), `maintenance` (§12's
recalibration is a pack procedure - drain THIS pack, charge THIS pack,
recalibrate THIS pack's counter) and `failsafe`. `failsafe` is the entry that
needed the reading rather than the survey: SAFETY-02's EVIDENCE is site-wide
(it compares P1 against the standing command) but its CONCLUSION is
"the unit I am driving is pushing power the wrong way" and its remedy is to
park that unit. At N>=2 the same evidence cannot be attributed to a sibling
this controller never commanded, so a site-wide stop would park an inverter
this watchdog never graded. The reasoning for all eight is recorded AT THE
LINE, in arbiter.CLAIM_SCOPE and again at each claims.append in main.py.

WHAT WAS BUILT. resolve() is three stages, each a separate function so a test
can construct a case at any one of them:

  pre_resolve()  - one inverter's own claims -> that unit's interval. Clauses
                   1 and 2 transposed: the unit's own winning SET gates its
                   own limits. The SET's value travels BESIDE the interval,
                   not inside it - folding a -2500 W drain into
                   [-2500, -2500] would turn "clamped to max_w" into
                   "contradicts max_w", a different and wrong answer.
  site_resolve() - THE SHIPPED ARBITER, UNCHANGED. Same code, new name.
  allocate()     - THE SEAM, AND DELIBERATELY NOT AN ALLOCATOR. ALLOC-01
                   builds it. At N<=1 it passes the target through (§6.2's
                   identity). At N>=2 it DECLINES - no shares,
                   allocated=False - and main.py refuses to write rather than
                   broadcast, because the obvious placeholder (hand the target
                   to each) is the fault ARCH-01 was filed about and no guard
                   we have can tell it apart from the real thing.

THE CONTRADICTION IS NOT 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 - 40
filings (every per-inverter source x unit x bound, including a unit
contradicting ITSELF at lo 900 > hi 100) against a live site floor, and not
one moves the site target or raises a contradiction. A unit's own impossible
pair parks that unit and says so on its own bounds; it does not park the site.
That is SAFETY-25 §0c's table made mechanical.

AT N=1 THIS IS A PURE REFACTOR, AND THAT IS PROVEN RATHER THAN ASSERTED. With
one inverter the fabricated grid reading and the battery setpoint are the same
number, so the grid connection and the only unit ARE one actuator:
actuator_for() routes every source to the site, no claim is filed on a unit,
stage 2 sees the whole list and answers what the shipped arbiter answered.
All 17 suites replay UNEDITED above the ARB-03 sections and are green on the
same numbers; test_arbiter.py additionally runs 13 claim lists through both
resolve() and site_resolve() and compares target, winner, binder,
contradiction flag, the considered list and the operator line.

EVIDENCE, BY BREAKING EACH THING (tools/mutation_run.sh, purged tree):
  * Claim's dataclass default emptied            -> RED (test_arbiter)
    ...and it survived until the check was added: the two helpers pass their
    own default down, so the FIELD's default never ran. Now checked directly.
  * `peak` reclassified SCOPE_UNIT               -> RED (arbiter x4, peakguard x2)
  * `maintenance` reclassified SCOPE_SITE        -> RED (arbiter x4, peakguard)
  * the N=1 identity dropped in actuator_for()   -> RED (arbiter, peakguard
    incl. the maintenance/peak suppression checks, freeze) - which is the
    measurement that says splitting at N=1 would be a behaviour change
  * resolve() stops partitioning                 -> RED (arbiter x4)
  * allocate() broadcasts at N>=2                -> RED (arbiter x2)
  * stage 1 drops clause-2 gating                -> RED (arbiter)
  * a call site routes `device` through `loop`   -> RED (peakguard 10g)
  * SAFETY-25 Option B built as a ninth source   -> RED (peakguard 10f, both
    halves, plus 10g) - the tripwire still bites

SAFETY-24's asymmetry proof is re-run and holds: 0 negative floors over the
shipped sweep, `hi` still +inf on every claim the guard files, and it now also
asserts every one of them is on the SITE actuator - a floor bound to one
inverter's share would leave the site, the thing the capacity tariff meters,
with no floor at all.

test_peakguard.py part 10f is kept, not adapted around. `source` is still
Claim.set()'s first positional argument precisely so its AST scan keeps
reading it, which is why `actuator` is the LAST field of Claim and not the
first - moving `source` off position 0 would silently blind the tripwire that
guards SAFETY-25's deferred option. The one adaptation is `**kw` on its spy,
so main.py's roster passes through unread rather than being silently dropped.
New part 10g pins CLAIM_SCOPE to the same eight-source set and 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.

No config key, no option, no operator surface, no default moved. Inverter B
does not exist yet and nothing here measures it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
2026-09-02 01:54:29 +02:00
co-authored by Claude Opus 5
parent 0c7e786ed5
commit 06f95e0a64
7 changed files with 955 additions and 24 deletions
+73
View File
@@ -2,6 +2,79 @@
## Unreleased
**`Claim` carries the `actuator` specs.md always said it did, and `resolve()`
is three-stage (ARB-03).** `specs.md` §6.1 declares `actuator: str` as the first
field of `Claim`; the shipped `app/arbiter.py` had no such field, so every claim
was implicitly about "the one and only actuator", `resolve()` returned one
scalar, and `main.py` assigned it to one `self.target`. **There was no seam an
allocator could occupy** - and `ARCH-01` decided the site grid target is
ALLOCATED across several inverters and never broadcast. This is a regression fix
against the written design, and it creates that seam. **At N=1 it is a pure
refactor: every one of the 17 suites replays unedited and green, on the same
numbers.**
* **The split, verified at each construction site rather than assumed.** Four
sources are about the **grid connection** and are SITE claims - `loop`
(regulates `grid_w`, the P1 measurement), `peak` (the quarter-hour average
offtake the capacity tariff bills), `arbitrage` (buys and sells at the grid
against a day-ahead price) and `safety` (`stopped` is the site's switch,
`inputs missing` is the site's own meter). Four are about **a pack, a device
or one write** and are PER-INVERTER - `device` (one inverter's own reported
rating), `supervised` (`max_w` caps what may be WRITTEN to one setpoint
entity), `failsafe` (**site-wide evidence, per-unit remedy**: SAFETY-02 grades
the command THIS controller put on THIS actuator, and at N>=2 that evidence
cannot be pinned on a sibling nobody here commands) and `maintenance` (§12's
recalibration is a pack procedure). The reasoning is recorded **at the line**,
in `arbiter.CLAIM_SCOPE` and again at each `claims.append` in `main.py`.
* **Getting it wrong is the parked-battery bug, so it is routed rather than
written.** A per-inverter reserve filed as a site `hi` against the peak guard's
site `lo` is `lo > hi`, arbiter clause 3, **0 W in the billed quarter** -
SAFETY-25 §0a's fatal case. `actuator_for()` is the one routing point and it
**raises `KeyError` on an unclassified source** rather than defaulting to the
site, because defaulting to the site is the failure itself.
* **Three stages, each a function a test can construct a case at.**
`pre_resolve()` turns one inverter's own claims into that unit's interval;
`site_resolve()` turns the site claims into one grid target and **is the
shipped arbiter, unchanged**; `allocate()` is **the seam, and deliberately not
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.
* **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 -
40 filings (every per-inverter source x unit x bound, a unit contradicting
ITSELF included) against a live site floor, and not one moves the site target
or raises a contradiction. A unit's own impossible pair parks that unit and
says so on its own bounds; it does not park the site. That is SAFETY-25 §0c's
table made mechanical.
* **At N=1 nothing splits, and that is §6.2's identity rather than the
classification being ignored.** With one inverter the fabricated grid reading
and the battery setpoint are the same number, so the grid connection and the
only unit ARE one actuator and every source routes to the site. **Measured:
dropping that identity - splitting at N=1 - takes `test_peakguard.py`,
`test_freeze.py` and part 10g red**, because the `failsafe` and `maintenance`
SET claims would stop competing for the winner, `explain()` would name a
different source and `loop_is_following()` reads that name.
* **SAFETY-24's floor asymmetry survives, and now has a second half.** The
combination sweep is re-run and still holds - no negative floor, `hi` still
`+inf` on every claim the guard files - and it now also asserts every one of
them is on the **site** actuator, because a floor bound to one inverter's share
would leave the site, the thing the capacity tariff meters, with no floor.
* **The part 10f tripwire is kept and widened, not adapted around.** Its static
AST scan and its dynamic SoC sweep are untouched in substance - `source` is
still `Claim.set()`'s first positional argument precisely so the scan keeps
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
line it is written.
**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
DEFERRED to `ALLOC-01` rather than rejected (SAFETY-25).** Decided by the