ARB-03: restore Claim.actuator and make resolve() three-stage #40

Merged
admin merged 2 commits from ARB-03 into release/1.0 2026-09-02 04:16:45 +02:00
Owner

The defect

specs.md:467 declares actuator: str as the first field of Claim. The
shipped app/arbiter.py:55 had no such field:

shipped:    Claim(source, priority, kind, value, lo, hi, reason)
specified:  Claim(actuator, owner, setpoint_w, reason, priority, expires_at)

The specification designed per-actuator claims and the implementation dropped
it. So every claim was implicitly about the one and only actuator, resolve()
returned a single scalar Resolution.target_w, and main.py assigned it to a
single self.target. There was no seam an allocator could occupy. This is a
regression fix against the written design, not a new feature.

Why it is worth a P0

ARCH-01 (specs.md §6.2, 2026-09-02) decided the site grid target is
allocated across several inverters and never broadcast. The control
primitive is meter emulation: the master reports a fabricated grid reading
(0x056E/0x056F, failsafe.h transcodes it verbatim) and the inverter
regulates itself to it. One number therefore 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 × the per-unit gain, and at the gain
config.yaml accepts as its documented maximum that is a permanent ±2 kW limit
cycle with every watchdog in §11.2 reporting healthy.

And the other half, which is what this card actually removes: a per-inverter
reserve filed as a site hi while the peak guard files a site lo is
lo > hi → arbiter.py:114-116 → 0 W in the billed quarter. That is the
parked-battery failure SAFETY-25's memo marks "⚠️ fatal if missed".

The split — verified at each construction site, not accepted from the survey

source scope why, read off what the claim is ABOUT
loop site compute() regulates grid_w, the P1 measurement. §6.2's architecture is one integrator on P1 whose output is then split — this is the claim the allocator exists to divide.
peak site 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 site 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.
safety site 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; both must stop every inverter.
device unit ONE inverter's own reported min/max. A second unit has a second rating; there is no site rating.
supervised unit max_w caps what may be written to one setpoint entity (§11.3) — a bound on one device, like device, for the same reason.
maintenance unit §12's recalibration is a pack procedure: drain THIS pack, charge THIS pack, recalibrate THIS pack's coulomb counter.
failsafe unit ⚠️ the entry that needed reading rather than the survey. SAFETY-02's evidence is site-wide — it compares P1 against standing — 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 pinned on a sibling this controller never commanded, so a site-wide stop would park an inverter this watchdog never graded.

The survey's proposed split is confirmed. The reasoning is recorded at the
line
— in arbiter.CLAIM_SCOPE and again at each claims.append in
main.py.

What was built

Three stages, each a separate function so a test can construct a case at any one
of them:

  1. 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.

  2. site_resolve() — the shipped arbiter, unchanged. Same code, new
    name.

  3. allocate() — the seam, and deliberately not an allocator. At N ≤ 1
    it passes the target through (§6.2's identity). At N ≥ 2 it declines — no
    shares, allocated=False. ⚠️ The obvious placeholder (hand the target to each
    unit) IS the fault ARCH-01 was filed about, and no guard we have can tell it
    apart from a real allocator, so a placeholder is worse than none. ALLOC-01
    builds this.

    What main.py then does is substitute, not abstain — and the earlier
    wording here ("refuses to write") overstated the code. It replaces the
    resolution with a 0 W failsafe, logs "refusing to broadcast the site
    target"
    , and then calls write_setpoint(0.0) on the same line as every
    other cycle. The refusal is to BROADCAST; the write still happens, in the
    fail-toward-inaction direction. Graded end to end by new part 10h.

The contradiction cannot be written down (AC 4)

The partition is by actuator, and it is a partition — every claim lands in
exactly one stage. 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
× unit × 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: not guarded against, not expressible.

The same construction filed on one actuator still contradicts, and that is
left exactly as it is — at N = 1 the two really are bounds on one number, and
that is today's behaviour.

At N = 1 this is a pure refactor, and it is proven rather than asserted (AC 3)

With one inverter, actuator_for() routes every source to the site, no
claim is filed on a unit, and stage 2 sees the whole list. All 17 suites
replay unedited
above the new 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.

Measured, not assumed: dropping the N = 1 identity takes test_arbiter.py,
test_peakguard.py (including the maintenance/peak suppression checks) and
test_freeze.py 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.

Verified by breaking it (AC 6) — tools/mutation_run.sh, purged tree

mutation result
Claim's dataclass default emptied RED (test_arbiter)
peak reclassified SCOPE_UNIT RED (arbiter ×4, peakguard ×2)
maintenance reclassified SCOPE_SITE RED (arbiter ×4, peakguard)
the N=1 identity dropped in actuator_for() RED (arbiter, peakguard, freeze)
resolve() stops partitioning RED (arbiter ×4)
allocate() broadcasts at N≥2 RED (arbiter ×2)
stage 1 drops clause-2 gating RED (arbiter)
⚠️ each of the 8 direct claims.append sites routed to a wrong actuator, one at a time 8 mutations, 8 RED — see the table below
resolve() ignores its units argument (if units is None: → if True:) RED (arbiter part 7, peakguard 10h)
main.py's N≥2 refusal deleted (if not resolution.allocated: → if False:) RED (peakguard 10h)
SAFETY-25 Option B built as a ninth source RED (10f both halves, plus 10g)

⚠️ One of these started green and that is worth reporting. Emptying the
Claim dataclass default survived the first version of the check, because
Claim.set() and Claim.limit() pass their own default down and the field's
default never runs. Anything building a Claim(...) directly would have got the
unspecified actuator this card exists to remove. The check now grades the
dataclass constructor as well.

The tripwire is kept and widened, not adapted around

test_peakguard.py part 10f's static AST scan and 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. specs.md's point is that a
claim NAMES its actuator; the tuple position is not the part that carries
meaning, and moving source off position 0 would silently blind the tripwire
that guards SAFETY-25's deferred option.

One adaptation: the spy gained **kw, so main.py's roster passes through
unread rather than being silently dropped (a fixed signature would raise on
every real cycle; a signature that dropped the roster would grade a different
arbitration from the one main.py performs). What is observed — the claim list
— is unchanged, and building Option B as a ninth source still takes it red.

New part 10g pins CLAIM_SCOPE to the same eight-source set (a ninth source
now goes red twice), pins all eight call sites statically (below), and keeps
the real two-inverter cycle as the dynamic half — now labelled with the
three of eight appends it actually reaches.

⚠️ AC 6 was met at 3 of 8 call sites and the check claimed the class — fixed

This is the blocking finding from the review of 06f95e0a, and it was
right.
10g's only call-site check was the real two-inverter cycle, and its own
text read "the line a mis-typed actuator_of(...) at a call site goes red
on"
. It was not. That cycle is auto, P1-healthy, no maintenance procedure,
no tripped sign watchdog, the guard idle and arbitrage off — it reaches
device, supervised and loop and nothing else, three of the eight
direct appends in Controller.cycle(). 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. scope_violations() does name it at
runtime, but it only logs; nothing graded it.

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. 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 now required 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). All eight, on the line the typo is written, in every
configuration and at every roster. Two further lines stop the scan going vacuous:
every direct append must be readable in that shape, and the ten appends must
still account for 10f's eight sources.

# append (main.py) source mutated to suite
1 rating limit device loop RED
2 max_w limit supervised peak RED
3 sign-disagreement stop failsafe safety RED
4 control stopped safety device RED
5 inputs missing safety maintenance RED
6 procedure owns setpoint maintenance loop RED
7 charge-only shaping maintenance peak RED
8 grid-following loop loop supervised RED

Eight mutations, eight reds, each on its own purged tree via
tools/mutation_run.sh over all 17 files, and every one of them red on the
new line
rather than incidentally.

Which sites this does NOT reach, said at the line rather than rounded up.
The two indirect appends — peak_claim and arb_claim — file a claim built
elsewhere in the method and carry no actuator= at the call site to
mis-type
. They are pinned where their routing is actually chosen:
peakguard.claim() by the SWEEP_SITE sweep (every load × elapsed × battery ×
SoC × floor combination), arbitrage.claim() by test_arbitrage.py. 10g says
so, and says which three of the eight its dynamic cycle sees.

Two more mutations that survived, 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: 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 arbiter and peakguard.
  • The 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 it
    . Mutation RED.

SAFETY-24 (AC 5)

The combination sweep is re-run and holds: 0 negative floors, hi still
+inf on every claim the guard files. It now also asserts every one of them
is on the SITE actuator
— the other half of the same asymmetry now that a
claim has an actuator, because a floor bound to one inverter's share would leave
the site (the thing the capacity tariff meters) with no floor at all.

Things I am unsure about, or that a reviewer should push on

  1. The 19,800 figure. SAFETY-25's memo §3 says a reviewer proved the lo-only
    asymmetry "across 19,800 combinations". The sweep actually in the tree
    (test_peakguard.py part 8) prints 1,296 — 6 loads × 3 elapsed × 4
    battery × 6 SoC × 3 floor. I re-ran the shipped proof and it holds, and I
    report the number it prints rather than the memo's. I did not inflate the
    sweep to reach 19,800: I could not find where that figure comes from, and
    inventing dimensions to match a number in a memo is the wrong direction. If
    the memo's figure refers to a sweep that was never committed, that is a
    separate finding and probably a separate card. It is now SAFETY-33.
  2. The allocated=False refusal in main.py is arguably scope creep.
    RESOLVED. It was unreachable and ungraded — if False: left the suite
    green. The reviewer's call was to keep it (the alternative default is the
    ARCH-01 fault) and grade it, which part 10h now does. What it does is also
    now stated accurately: it substitutes a 0 W failsafe and writes it, it does
    not refuse to write.
  3. UnitBounds carries gate and pinned_w, which is more than "an
    interval".
    They are there because clause 2 gates a limit on the winning
    set's priority, and at N ≥ 2 the winner is chosen at stage 2 — after stage 1.
    Stage 1 states the constraint and records what it is gated on; deciding
    whether it applies is stage 3's business. Guessing at stage 1 is how a
    maintenance charge-only hi would come to bind a peak discharge it does not
    outrank. If ALLOC-01 wants a plainer interval it can narrow this.
  4. resolve(claims, units=None) derives the roster from the claims when it is
    not given.
    That is right for a caller that files a claim for every unit
    every cycle (main.py does — supervised is unconditional) and wrong for one
    that does not. main.py passes the real roster; the default is documented as
    a convenience, not a contract. ⚠️ And the argument now has to be
    honoured, which is not the same as existing
    — see the mutation above.
  5. What is NOT restored: the spec's owner, setpoint_w and expires_at.
    owner is source under another name; expires_at is deliberately absent
    because claims in this implementation are re-filed every cycle and a
    sticky claim is the failure main.py's comments already warn about twice. I
    did not want to smuggle a lifetime model in under a card about the actuator.
  6. 10f's dynamic half still cannot express two packs, exactly as SAFETY-25
    §0d says. 10g's two-inverter cycle is a routing check, not a two-pack SoC
    fixture; whoever reaches the ALLOC-01 revisit still inherits that job.
  7. The static call-site pin reads actuator_of()'s argument, not its
    answer.
    It is deliberately textual — that is the only thing that separates
    two routings which are the same value at N = 1 — so it grades what is
    written
    , and the routing table itself is what 10g's other halves grade. An
    append that computed its actuator would take the readability line red rather
    than be skipped, which is the failure shape I was most worried about
    re-introducing.

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

Full suite: 17/17 green in goodwe-addon-ci. Mutations this round: 10
run, 10 red
— the eight call sites above, plus if units is None: → if True: and if not resolution.allocated: → if False: — each on its own purged
tree via tools/mutation_run.sh over all 17 files.

## The defect `specs.md:467` declares `actuator: str` as the **first field of `Claim`**. The shipped `app/arbiter.py:55` had no such field: ``` shipped: Claim(source, priority, kind, value, lo, hi, reason) specified: Claim(actuator, owner, setpoint_w, reason, priority, expires_at) ``` The specification designed per-actuator claims and the implementation dropped it. So every claim was implicitly about *the one and only actuator*, `resolve()` returned a single scalar `Resolution.target_w`, and `main.py` assigned it to a single `self.target`. **There was no seam an allocator could occupy.** This is a regression fix against the written design, not a new feature. ## Why it is worth a P0 `ARCH-01` (specs.md §6.2, 2026-09-02) decided the site grid target is **allocated** across several inverters and never broadcast. The control primitive is meter emulation: the master reports a fabricated grid reading (`0x056E`/`0x056F`, `failsafe.h` transcodes it verbatim) and the inverter regulates itself to it. One number therefore 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 × the per-unit gain, and at the gain `config.yaml` accepts as its documented maximum that is a permanent ±2 kW limit cycle *with every watchdog in §11.2 reporting healthy.* And the other half, which is what this card actually removes: a per-inverter reserve filed as a **site `hi`** while the peak guard files a **site `lo`** is `lo > hi` → `arbiter.py:114-116` → **0 W in the billed quarter.** That is the parked-battery failure `SAFETY-25`'s memo marks *"⚠️ fatal if missed"*. ## The split — verified at each construction site, not accepted from the survey | source | scope | why, read off what the claim is ABOUT | |---|---|---| | `loop` | **site** | `compute()` regulates `grid_w`, the P1 measurement. §6.2's architecture is one integrator on P1 whose output is then split — this is the claim the allocator exists to divide. | | `peak` | **site** | 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` | **site** | 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. | | `safety` | **site** | 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; both must stop every inverter. | | `device` | **unit** | ONE inverter's own reported min/max. A second unit has a second rating; there is no site rating. | | `supervised` | **unit** | `max_w` caps what may be **written** to one setpoint entity (§11.3) — a bound on one device, like `device`, for the same reason. | | `maintenance` | **unit** | §12's recalibration is a **pack** procedure: drain THIS pack, charge THIS pack, recalibrate THIS pack's coulomb counter. | | `failsafe` | **unit** | ⚠️ **the entry that needed reading rather than the survey.** SAFETY-02's *evidence* is site-wide — it compares P1 against `standing` — 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 pinned on a sibling this controller never commanded, so a site-wide stop would park an inverter this watchdog never graded. | The survey's proposed split is confirmed. The reasoning is recorded **at the line** — in `arbiter.CLAIM_SCOPE` and again at each `claims.append` in `main.py`. ## What was built Three stages, each a separate function so a test can construct a case at any one of them: 1. **`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. 2. **`site_resolve()`** — **the shipped arbiter, unchanged.** Same code, new name. 3. **`allocate()`** — **the seam, and deliberately not an allocator.** At N ≤ 1 it passes the target through (§6.2's identity). At N ≥ 2 it **declines** — no shares, `allocated=False`. ⚠️ The obvious placeholder (hand the target to each unit) IS the fault ARCH-01 was filed about, and no guard we have can tell it apart from a real allocator, so a placeholder is worse than none. **ALLOC-01 builds this.** What `main.py` then does is **substitute, not abstain** — and the earlier wording here ("refuses to write") overstated the code. It replaces the resolution with a **0 W `failsafe`**, logs *"refusing to broadcast the site target"*, and then calls `write_setpoint(0.0)` on the same line as every other cycle. The refusal is to BROADCAST; the write still happens, in the fail-toward-inaction direction. Graded end to end by new **part 10h**. ## The contradiction cannot be written down (AC 4) The partition is **by `actuator`, and it is a partition** — every claim lands in exactly one stage. 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 × unit × 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: not *guarded against*, **not expressible.** The same construction filed on **one** actuator still contradicts, and that is left exactly as it is — at N = 1 the two really are bounds on one number, and that is today's behaviour. ## At N = 1 this is a pure refactor, and it is proven rather than asserted (AC 3) With one inverter, `actuator_for()` routes **every** source to the site, no claim is filed on a unit, and stage 2 sees the whole list. All **17 suites replay unedited** above the new 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. Measured, not assumed: **dropping the N = 1 identity takes `test_arbiter.py`, `test_peakguard.py` (including the maintenance/peak suppression checks) and `test_freeze.py` 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. ## Verified by breaking it (AC 6) — `tools/mutation_run.sh`, purged tree | mutation | result | |---|---| | `Claim`'s dataclass default emptied | **RED** (test_arbiter) | | `peak` reclassified `SCOPE_UNIT` | **RED** (arbiter ×4, peakguard ×2) | | `maintenance` reclassified `SCOPE_SITE` | **RED** (arbiter ×4, peakguard) | | the N=1 identity dropped in `actuator_for()` | **RED** (arbiter, peakguard, freeze) | | `resolve()` stops partitioning | **RED** (arbiter ×4) | | `allocate()` broadcasts at N≥2 | **RED** (arbiter ×2) | | stage 1 drops clause-2 gating | **RED** (arbiter) | | ⚠️ **each of the 8 direct `claims.append` sites routed to a wrong actuator, one at a time** | **8 mutations, 8 RED** — see the table below | | `resolve()` ignores its `units` argument (`if units is None:` → `if True:`) | **RED** (arbiter part 7, peakguard 10h) | | `main.py`'s N≥2 refusal deleted (`if not resolution.allocated:` → `if False:`) | **RED** (peakguard 10h) | | SAFETY-25 **Option B built as a ninth source** | **RED** (10f both halves, plus 10g) | ⚠️ **One of these started green and that is worth reporting.** Emptying the `Claim` dataclass default *survived* the first version of the check, because `Claim.set()` and `Claim.limit()` pass their own default down and the field's default never runs. Anything building a `Claim(...)` directly would have got the unspecified actuator this card exists to remove. The check now grades the dataclass constructor as well. ## The tripwire is kept and widened, not adapted around `test_peakguard.py` part 10f's **static AST scan and 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. specs.md's point is that a claim NAMES its actuator; the tuple position is not the part that carries meaning, and moving `source` off position 0 would silently blind the tripwire that guards SAFETY-25's deferred option. One adaptation: the spy gained `**kw`, so `main.py`'s roster passes through **unread** rather than being silently dropped (a fixed signature would raise on every real cycle; a signature that dropped the roster would grade a different arbitration from the one `main.py` performs). What is observed — the claim list — is unchanged, and building Option B as a ninth source still takes it red. New **part 10g** pins `CLAIM_SCOPE` to the same eight-source set (a ninth source now goes red twice), pins **all eight call sites statically** (below), and keeps the **real two-inverter cycle** as the dynamic half — now labelled with the three of eight appends it actually reaches. ## ⚠️ AC 6 was met at 3 of 8 call sites and the check claimed the class — fixed **This is the blocking finding from the review of `06f95e0a`, and it was right.** 10g's only call-site check was the real two-inverter cycle, and its own text read *"the line a mis-typed `actuator_of(...)` at a call site goes red on"*. **It was not.** That cycle is auto, P1-healthy, no maintenance procedure, no tripped sign watchdog, the guard idle and arbitrage off — it reaches `device`, `supervised` and `loop` and **nothing else**, three of the eight direct appends in `Controller.cycle()`. 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. `scope_violations()` *does* name it at runtime, but it only logs; nothing graded it. 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. 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 now required 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). All eight, on the line the typo is written, in every configuration and at every roster. Two further lines stop the scan going vacuous: every direct append must be readable in that shape, and the ten appends must still account for 10f's eight sources. | # | append (`main.py`) | source | mutated to | suite | |---|---|---|---|---| | 1 | rating limit | `device` | `loop` | **RED** | | 2 | `max_w` limit | `supervised` | `peak` | **RED** | | 3 | sign-disagreement stop | `failsafe` | `safety` | **RED** | | 4 | control stopped | `safety` | `device` | **RED** | | 5 | inputs missing | `safety` | `maintenance` | **RED** | | 6 | procedure owns setpoint | `maintenance` | `loop` | **RED** | | 7 | charge-only shaping | `maintenance` | `peak` | **RED** | | 8 | grid-following loop | `loop` | `supervised` | **RED** | Eight mutations, eight reds, each on its own purged tree via `tools/mutation_run.sh` over all 17 files, and **every one of them red on the new line** rather than incidentally. **Which sites this does NOT reach, said at the line rather than rounded up.** The two *indirect* appends — `peak_claim` and `arb_claim` — file a claim built elsewhere in the method and carry **no `actuator=` at the call site to mis-type**. They are pinned where their routing is actually chosen: `peakguard.claim()` by the `SWEEP_SITE` sweep (every load × elapsed × battery × SoC × floor combination), `arbitrage.claim()` by `test_arbitrage.py`. 10g says so, and says which three of the eight its dynamic cycle sees. ## Two more mutations that survived, 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: 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 arbiter and peakguard. * **The 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 it**. Mutation **RED**. ## SAFETY-24 (AC 5) The combination sweep is re-run and holds: **0 negative floors**, `hi` still `+inf` on every claim the guard files. It now also asserts **every one of them is on the SITE actuator** — the other half of the same asymmetry now that a claim has an actuator, because a floor bound to one inverter's share would leave the site (the thing the capacity tariff meters) with **no floor at all**. ## Things I am unsure about, or that a reviewer should push on 1. **The 19,800 figure.** SAFETY-25's memo §3 says a reviewer proved the `lo`-only asymmetry "across 19,800 combinations". The sweep actually in the tree (`test_peakguard.py` part 8) prints **1,296** — 6 loads × 3 elapsed × 4 battery × 6 SoC × 3 floor. I re-ran the shipped proof and it holds, and I report the number it prints rather than the memo's. I did **not** inflate the sweep to reach 19,800: I could not find where that figure comes from, and inventing dimensions to match a number in a memo is the wrong direction. If the memo's figure refers to a sweep that was never committed, that is a separate finding and probably a separate card. **It is now `SAFETY-33`.** 2. ~~**The `allocated=False` refusal in `main.py` is arguably scope creep.**~~ **RESOLVED.** It was unreachable *and ungraded* — `if False:` left the suite green. The reviewer's call was to **keep** it (the alternative default is the ARCH-01 fault) and grade it, which part 10h now does. What it does is also now stated accurately: it substitutes a 0 W failsafe and writes it, it does not refuse to write. 3. **`UnitBounds` carries `gate` and `pinned_w`, which is more than "an interval".** They are there because clause 2 gates a limit on the *winning* set's priority, and at N ≥ 2 the winner is chosen at stage 2 — after stage 1. Stage 1 states the constraint and records what it is gated on; deciding whether it applies is stage 3's business. Guessing at stage 1 is how a maintenance charge-only `hi` would come to bind a peak discharge it does not outrank. If ALLOC-01 wants a plainer interval it can narrow this. 4. **`resolve(claims, units=None)` derives the roster from the claims when it is not given.** That is right for a caller that files a claim for every unit every cycle (`main.py` does — `supervised` is unconditional) and wrong for one that does not. `main.py` passes the real roster; the default is documented as a convenience, not a contract. ⚠️ **And the argument now has to be *honoured*, which is not the same as existing** — see the mutation above. 5. **What is NOT restored:** the spec's `owner`, `setpoint_w` and `expires_at`. `owner` is `source` under another name; `expires_at` is deliberately absent because claims in this implementation are re-filed **every cycle** and a sticky claim is the failure `main.py`'s comments already warn about twice. I did not want to smuggle a lifetime model in under a card about the actuator. 6. **10f's dynamic half still cannot express two packs**, exactly as SAFETY-25 §0d says. 10g's two-inverter cycle is a routing check, not a two-pack SoC fixture; whoever reaches the ALLOC-01 revisit still inherits that job. 7. **The static call-site pin reads `actuator_of()`'s argument, not its answer.** It is deliberately textual — that is the only thing that separates two routings which are the same value at N = 1 — so it grades *what is written*, and the routing table itself is what 10g's other halves grade. An append that computed its actuator would take the readability line red rather than be skipped, which is the failure shape I was most worried about re-introducing. No config key, no option, no operator surface, no default moved. **Inverter B does not exist yet and nothing here measures it.** Full suite: **17/17 green** in `goodwe-addon-ci`. Mutations this round: **10 run, 10 red** — the eight call sites above, plus `if units is None:` → `if True:` and `if not resolution.allocated:` → `if False:` — each on its own purged tree via `tools/mutation_run.sh` over all 17 files.
admin added 1 commit 2026-09-02 01:54:37 +02:00
ARB-03: restore Claim.actuator and make resolve() three-stage
checks / unit (pull_request) Successful in 3m45s
06f95e0a64
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
admin added 1 commit 2026-09-02 03:46:48 +02:00
ARB-03: pin all eight actuator call sites statically
checks / unit (pull_request) Successful in 3m45s
38f6680639
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>
admin merged commit df88b323f5 into release/1.0 2026-09-02 04:16:45 +02:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: admin/goodwe-addon#40