ARB-03 left allocate() as a documented seam that passed through at N≤1 and declined at N≥2, with the reasoning at the line: the obvious placeholder — hand the site target to each inverter — is the ARCH-01 fault. This fills the seam with app/allocator.py, host-side and strictly above the driver seam.
One integrator on P1. sum(shares) + unmet_w == S, in integer arithmetic. Each inverter told a function of its own share, never of the shared measurement.
The red baseline, reproduced against this tree
admin/goodwe @ 0c31e6f, sim/sitesim.py — the add-on's own compute() in the loop, the firmware's own encoder on the wire, plantsim.Plant as the battery, watchdog.SignWatchdog as the detector:
gain 0.6 broadcast 0 -400 [1000, 1000, 1000, 1000]
gain 1.0 broadcast 4000 -2000 [1000, 2000, 1000, 0]
inject.py two_master_broadcast passes, i.e. it still reproduces the fault: 4000 W p2p at gain 1.0, clamped/i_clamped/frozen/sat/sign all quiet over 40 cycles; over 600 s the shipped sign predicate fired on 30/120 cycles and never held longer than 0 s against its 180 s window. It is looking and it cannot see this.
The green, on the same harness
tools/alloc_sim.py wraps sitesim.allocate so one new mode routes to the shipped arbiter.allocate(). run_site() itself is used unmodified — that wrapper is the entire diff between the red run and the green run.
=== AC 3 / broadcast vs allocation, 200 cycles ===
gain 0.6 broadcast 0 -400
gain 0.6 alloc01:50/50 0 30
gain 0.6 alloc01:90/10 0 10
gain 1.0 broadcast 4000 -2000
gain 1.0 alloc01:50/50 0 0
gain 1.0 alloc01:90/10 0 0
gain 1.0 alloc01:99/1 0 0
gain 1.0 alloc01:1/0 0 0
=== AC 3 / gain sweep to 1.0 at every split ===
mode 0.05 0.2 0.4 0.6 0.8 0.9 1.0
broadcast 0 0 0 0 40 120 4000
alloc01:50/50 0 0 0 0 0 0 0
alloc01:90/10 0 0 0 0 0 0 0
alloc01:10/90 0 0 0 0 0 0 0
alloc01:99/1 0 0 0 0 0 0 0
alloc01:1/0 0 0 0 0 0 0 0
=== AC 2 / split-invariance at gain 1.0 ===
alloc01:90/10 IDENTICAL to alloc01:50/50 worst |delta| 0.000000 W
alloc01:10/90 IDENTICAL | alloc01:99/1 IDENTICAL | alloc01:1/0 IDENTICAL
⚠️plantsim.INVERTER_B carries measured=False — its rating, ramp and lag are A's numbers copied, because inverter B does not exist yet (SPIKE-04). Every verdict involving B is a verdict about the architecture, not about that inverter, and the runs say so.
The design
Envelope clamp — ARB-03 stage 1's UnitBounds, read per tick. plan() is a pure function; the module holds no state, so there is nowhere for a construction-time envelope to be cached, and test_alloc.py part 10 grades that by moving an envelope between two calls.
Water-fill by weight, surplus of a full unit moving to the others. Weights express preference, envelopes express permission: a zero weight is honoured while another unit has headroom and overridden when it does not, because the sum is the safety property and stranding capacity behind a comfort choice manufactures a standing error the site integrator chases forever. To make a unit unusable, close its envelope.
Largest-remainder rounding. The target is rounded once, at the top; everything below that line is integer arithmetic, so the sum cannot drift. Measured: a per-unit round() drifts −125 W over 50 000 ticks at three inverters. 250 000 randomised ticks: 0 sum failures, 0 envelope escapes, 0 W accumulated bias.
Allocation record — unmet_w, dropped, per-unit reason. ok is one boolean for SAFETY-28 to gate on, and unmet_w is deliberately not fused into it: a saturated inverter must not park the whole site.
The stability property is structural, and it is graded rather than argued (part 9): with Σa_i = 1 the gain at the meter is Σa_i·g_i, a convex combination, so it lies in [min g_i, max g_i] for any split. Under broadcast the same sum is N·g — outside that interval and growing in N.
The two things the ticket warned about
1. test_peakguard.py part 10h — re-aimed, not deleted.allocated=False no longer means "no allocator"; it means no safe split exists: a mis-routed claim, an empty envelope set, or a split that could only be made by commanding one pack to charge while another discharges. Part 10h now drives three real cycles through Controller.cycle() and grades all three:
10h-1 — the allocator succeeds (995/995) and main.py refuses to act on a split it cannot deliver;
10h-2 — one master stale: the survivor carries the whole target, which is deliverable, so it is written, and the drop is announced;
10h-3 — every master stale: allocated=False reached for real, 0 W substituted and written, the allocator's own sentence in the action log.
⚠️The deliverability guard is the price of making allocate() succeed, not something inherited. There is one setpoint_entity. Handed a valid 995/995 split and a roster of two, main.py would have written self.target — 1990, the site number — to the single inverter it can reach while a second sat uncommanded. Not the broadcast fault, but the same shape of over-command. Without this guard ALLOC-01 would have been a regression. DEV-02 deletes it by giving main.py the second writer.
2. scope_violations() — consumed, and consumed as a REFUSAL. My decision, with the reasoning at allocator.plan() REFUSAL 1 and graded in test_arbiter.py part 8. By the time stage 3 runs, the mis-routed claim has already been partitioned, so its effect is baked into S or into somebody's interval; consuming it as an allocation input would mean re-routing, which makes actuator advisory and hands every filing site a second chance to be wrong. And promoting a unit-scoped claim that landed on the site requires guessing which unit — the answer actuator_for() exists to fix at the filing site. What is left is that S itself may be wrong, and dividing a number that may be wrong is worse than not dividing it. Refuse; same answer clause 3 already gives a contradiction.
Verification
All 18 suites green in the CI image (17 existing, unedited except the two re-aimed fixtures, plus test_alloc.py).
16 mutations run through tools/mutation_run.sh on a purged tree. 13 red — broadcast, the envelope clamp, live, the sign gate, the N≤1 pass-through, unmet_w folded into ok, the pin gate, scope_errors, the leftover pass, truncation instead of rounding, and all three main.py guards. A no-op control mutation correctly survived, so the harness is not reporting red unconditionally.
3 survivors, recorded in test_alloc.py part 12 with the reason. Two of them I had listed as caught in a first draft and were not: the sum is protected by the integer leftover pass, not by the floor before it, and the tie-break's determinism is delivered upstream by sorted(bounds). The third is AC 2 itself — nothing at the meter can distinguish two weightings, which is the property rather than a gap.
What I am not sure about, and what I chose
Split-invariance does not hold below gain 1.0 on the rig, and the allocator is not the cause.sitesim.run_site() feeds compute()one inverter's power as actual_w, because the add-on binds a single inverter_power sensor — its docstring says so, and says the rig must not be gentler than the deployment. That is the sensing half of the same N=1 identity this card removes from the commanding half. Measured, with the counterfactual:
gain 0.6, actual_w = inverter A only : 90/10 DIFFERS from 50/50 by up to 20 W; 10/90 by 50 W
gain 0.6, actual_w = the site total : every split bit-identical, at every gain
The allocator's shares sum to S in every one of those runs. I have not fixed it here — a site-level power roll-up is DEV-02's, and inventing one would be building DEV-02 inside this card. It is graded in test_alloc.py part 8 so it cannot rot, and flagged in the CHANGELOG.
Refusal vs unmet_w — I drew the boundary tighter than the ticket's phrasing. The ticket lists "every unit stale, gated, or pinned" as the refusal case. I refuse only where no safe share vector exists at all; "every unit pinned" produces a fully-unmet allocation instead, because the share vector is each unit's own highest-priority claim and acting on it is right. Say if you want that widened.
A residual in the pin gate, enumerated rather than glossed (part 11). pre_resolve() filters a unit's limits by that unit's own gate, while clause 2 says a limit binds only at or above the winner's priority — which at N≥2 may be the site's, chosen afterwards. I enumerated all 27 combinations main.py can file: exactly one is applied more tightly than clause 2 strictly requires (the maintenance charge-only hi under a site safety stop), and a safety stop resolves to 0 W, which a hi of 0 does not move. Erring tight allocates less, never more. A ninth claim source could change that, which is why it is written down.
Refusing on scope_errors is a behaviour change beyond the ACs. It cannot fire on the shipped one-inverter roster with correct routing, so the live risk is zero — but it turns a log line into a 0 W site stop, and that is a judgement call I would rather have looked at than assumed.
Weights are not wired into main.py. It has nothing to weigh with — unit B's SoC and thermals are unknown to it. resolve(..., weights=) exists and is graded; filling it is DEV-02's.
## What this does
`ARB-03` left `allocate()` as a documented seam that passed through at N≤1 and **declined** at N≥2, with the reasoning at the line: the obvious placeholder — hand the site target to each inverter — *is* the ARCH-01 fault. This fills the seam with `app/allocator.py`, host-side and strictly above the driver seam.
**One integrator on P1. `sum(shares) + unmet_w == S`, in integer arithmetic. Each inverter told a function of its own share, never of the shared measurement.**
## The red baseline, reproduced against this tree
`admin/goodwe` @ `0c31e6f`, `sim/sitesim.py` — the add-on's own `compute()` in the loop, the firmware's own encoder on the wire, `plantsim.Plant` as the battery, `watchdog.SignWatchdog` as the detector:
```
gain 0.6 broadcast 0 -400 [1000, 1000, 1000, 1000]
gain 1.0 broadcast 4000 -2000 [1000, 2000, 1000, 0]
```
`inject.py two_master_broadcast` passes, i.e. it still reproduces the fault: 4000 W p2p at gain 1.0, `clamped/i_clamped/frozen/sat/sign` all quiet over 40 cycles; over 600 s the shipped sign predicate fired on 30/120 cycles and never held longer than 0 s against its 180 s window. **It is looking and it cannot see this.**
## The green, on the same harness
`tools/alloc_sim.py` wraps `sitesim.allocate` so one new mode routes to the shipped `arbiter.allocate()`. `run_site()` itself is used unmodified — that wrapper is the entire diff between the red run and the green run.
```
=== AC 3 / broadcast vs allocation, 200 cycles ===
gain 0.6 broadcast 0 -400
gain 0.6 alloc01:50/50 0 30
gain 0.6 alloc01:90/10 0 10
gain 1.0 broadcast 4000 -2000
gain 1.0 alloc01:50/50 0 0
gain 1.0 alloc01:90/10 0 0
gain 1.0 alloc01:99/1 0 0
gain 1.0 alloc01:1/0 0 0
=== AC 3 / gain sweep to 1.0 at every split ===
mode 0.05 0.2 0.4 0.6 0.8 0.9 1.0
broadcast 0 0 0 0 40 120 4000
alloc01:50/50 0 0 0 0 0 0 0
alloc01:90/10 0 0 0 0 0 0 0
alloc01:10/90 0 0 0 0 0 0 0
alloc01:99/1 0 0 0 0 0 0 0
alloc01:1/0 0 0 0 0 0 0 0
=== AC 2 / split-invariance at gain 1.0 ===
alloc01:90/10 IDENTICAL to alloc01:50/50 worst |delta| 0.000000 W
alloc01:10/90 IDENTICAL | alloc01:99/1 IDENTICAL | alloc01:1/0 IDENTICAL
```
⚠️ `plantsim.INVERTER_B` carries `measured=False` — its rating, ramp and lag are A's numbers copied, because **inverter B does not exist yet** (SPIKE-04). Every verdict involving B is a verdict about the architecture, not about that inverter, and the runs say so.
## The design
- **Envelope clamp** — ARB-03 stage 1's `UnitBounds`, **read per tick**. `plan()` is a pure function; the module holds no state, so there is nowhere for a construction-time envelope to be cached, and `test_alloc.py` part 10 grades that by moving an envelope between two calls.
- **Water-fill** by weight, surplus of a full unit moving to the others. **Weights express preference, envelopes express permission**: a zero weight is honoured while another unit has headroom and overridden when it does not, because the sum is the safety property and stranding capacity behind a comfort choice manufactures a standing error the site integrator chases forever. To make a unit unusable, close its envelope.
- **Largest-remainder rounding.** The target is rounded **once**, at the top; everything below that line is integer arithmetic, so the sum cannot drift. Measured: a per-unit `round()` drifts **−125 W over 50 000 ticks at three inverters**. 250 000 randomised ticks: **0 sum failures, 0 envelope escapes, 0 W accumulated bias.**
- **Allocation record** — `unmet_w`, `dropped`, per-unit reason. `ok` is one boolean for SAFETY-28 to gate on, and `unmet_w` is deliberately **not** fused into it: a saturated inverter must not park the whole site.
**The stability property is structural**, and it is graded rather than argued (part 9): with `Σa_i = 1` the gain at the meter is `Σa_i·g_i`, a convex combination, so it lies in `[min g_i, max g_i]` for any split. Under broadcast the same sum is `N·g` — outside that interval and growing in N.
## The two things the ticket warned about
**1. `test_peakguard.py` part 10h — re-aimed, not deleted.** `allocated=False` no longer means "no allocator"; it means **no safe split exists**: a mis-routed claim, an empty envelope set, or a split that could only be made by commanding one pack to charge while another discharges. Part 10h now drives **three real cycles** through `Controller.cycle()` and grades all three:
- *10h-1* — the allocator succeeds (995/995) and main.py refuses to act on a split it cannot **deliver**;
- *10h-2* — one master stale: the survivor carries the whole target, which **is** deliverable, so it is written, and the drop is announced;
- *10h-3* — every master stale: `allocated=False` reached for real, 0 W substituted and written, the allocator's own sentence in the action log.
⚠️ **The deliverability guard is the price of making `allocate()` succeed, not something inherited.** There is one `setpoint_entity`. Handed a valid 995/995 split and a roster of two, main.py would have written `self.target` — 1990, the *site* number — to the single inverter it can reach while a second sat uncommanded. Not the broadcast fault, but the same shape of over-command. Without this guard ALLOC-01 would have been a regression. DEV-02 deletes it by giving main.py the second writer.
**2. `scope_violations()` — consumed, and consumed as a REFUSAL.** My decision, with the reasoning at `allocator.plan()` REFUSAL 1 and graded in `test_arbiter.py` part 8. By the time stage 3 runs, the mis-routed claim has *already* been partitioned, so its effect is baked into `S` or into somebody's interval; consuming it as an allocation input would mean **re-routing**, which makes `actuator` advisory and hands every filing site a second chance to be wrong. And promoting a unit-scoped claim that landed on the site requires guessing **which** unit — the answer `actuator_for()` exists to fix at the filing site. What is left is that `S` itself may be wrong, and dividing a number that may be wrong is worse than not dividing it. Refuse; same answer clause 3 already gives a contradiction.
## Verification
- **All 18 suites green** in the CI image (17 existing, unedited except the two re-aimed fixtures, plus `test_alloc.py`).
- **16 mutations run through `tools/mutation_run.sh` on a purged tree.** 13 red — broadcast, the envelope clamp, `live`, the sign gate, the N≤1 pass-through, `unmet_w` folded into `ok`, the pin gate, `scope_errors`, the leftover pass, truncation instead of rounding, and all three main.py guards. A no-op control mutation correctly survived, so the harness is not reporting red unconditionally.
- **3 survivors, recorded in `test_alloc.py` part 12 with the reason.** Two of them I had listed as *caught* in a first draft and were not: the sum is protected by the integer leftover pass, not by the `floor` before it, and the tie-break's determinism is delivered upstream by `sorted(bounds)`. The third is AC 2 itself — nothing at the meter can distinguish two weightings, which is the property rather than a gap.
## What I am not sure about, and what I chose
1. **Split-invariance does not hold below gain 1.0 on the rig, and the allocator is not the cause.** `sitesim.run_site()` feeds `compute()` **one inverter's power** as `actual_w`, because the add-on binds a single `inverter_power` sensor — its docstring says so, and says the rig must not be gentler than the deployment. That is the **sensing** half of the same N=1 identity this card removes from the **commanding** half. Measured, with the counterfactual:
```
gain 0.6, actual_w = inverter A only : 90/10 DIFFERS from 50/50 by up to 20 W; 10/90 by 50 W
gain 0.6, actual_w = the site total : every split bit-identical, at every gain
```
The allocator's shares sum to `S` in every one of those runs. I have **not** fixed it here — a site-level power roll-up is DEV-02's, and inventing one would be building DEV-02 inside this card. It is graded in `test_alloc.py` part 8 so it cannot rot, and flagged in the CHANGELOG.
2. **Refusal vs `unmet_w` — I drew the boundary tighter than the ticket's phrasing.** The ticket lists "every unit stale, gated, or pinned" as the refusal case. I refuse only where **no safe share vector exists at all**; "every unit pinned" produces a fully-unmet allocation instead, because the share vector *is* each unit's own highest-priority claim and acting on it is right. Say if you want that widened.
3. **A residual in the pin gate, enumerated rather than glossed** (part 11). `pre_resolve()` filters a unit's limits by that unit's *own* gate, while clause 2 says a limit binds only at or above the *winner's* priority — which at N≥2 may be the site's, chosen afterwards. I enumerated all 27 combinations main.py can file: exactly **one** is applied more tightly than clause 2 strictly requires (the maintenance charge-only `hi` under a site safety stop), and a safety stop resolves to 0 W, which a `hi` of 0 does not move. Erring tight allocates less, never more. A ninth claim source could change that, which is why it is written down.
4. **Refusing on `scope_errors` is a behaviour change beyond the ACs.** It cannot fire on the shipped one-inverter roster with correct routing, so the live risk is zero — but it turns a log line into a 0 W site stop, and that is a judgement call I would rather have looked at than assumed.
5. **Weights are not wired into `main.py`.** It has nothing to weigh with — unit B's SoC and thermals are unknown to it. `resolve(..., weights=)` exists and is graded; filling it is DEV-02's.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
ARB-03 left `allocate()` as a documented seam that passed through at N<=1 and
DECLINED at N>=2, with the reasoning recorded at the line: the obvious
placeholder - hand the site target to each inverter - IS the ARCH-01 fault, and
"a placeholder that cannot be told apart from the real thing by any guard we
have is worse than no placeholder". This fills the seam.
WHY A BROADCAST IS A FAULT AND NOT AN INEFFICIENCY. 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 this
add-on and "the grid is at X W" to the inverter - an identity that holds only at
N=1. Hand two inverters the same target and each supplies it IN FULL, so the
loop gain at the meter becomes N times the per-unit gain. Reproduced on
admin/goodwe's two-master rig against this tree: -400 W export overshoot at
gain 0.6, and at gain 1.0 - which config.yaml accepts as its documented maximum
- a permanent 4000 W peak-to-peak limit cycle, commands alternating
1000/2000/1000/0 W, with NOT ONE GUARD FIRING. The sign watchdog is evaluated on
every cycle and never trips, because the disagreement flips on alternate ticks
and never holds its 180 s window.
WHAT WAS BUILT. `app/allocator.py`, host-side and strictly above the driver
seam. It consumes intervals and a number and nothing else - no claims, no
priorities, and no measurement, because an allocator that read P1 would be a
SECOND integrator on the site measurement.
* ENVELOPE CLAMP, from ARB-03 stage 1's `UnitBounds`, read PER TICK. `plan()`
is a pure function and the module holds no state, so there is nowhere for a
construction-time envelope to be cached.
* WATER-FILL by weight, with the surplus of a full unit moving to the others.
Weights express PREFERENCE, envelopes express PERMISSION: a zero weight is
honoured while another unit has headroom and overridden when it does not,
because the SUM is the safety property and stranding capacity behind a
comfort choice manufactures a standing error at the meter.
* LARGEST-REMAINDER ROUNDING. The target is rounded ONCE, at the top; every
sum below that line is integer arithmetic, so it cannot drift. Measured: a
per-unit round() drifts -125 W over 50 000 ticks at three inverters.
* THE ALLOCATION RECORD, with `unmet_w`, `dropped` and a per-unit reason.
`ok` is one boolean for SAFETY-28 to gate on, and `unmet_w` is deliberately
NOT fused into it - a saturated inverter must not park the whole site.
THE STABILITY PROPERTY IS STRUCTURAL. With sum(a_i) = 1 the gain at the meter is
sum(a_i * g_i), a convex combination of the per-unit gains, so it lies in
[min g_i, max g_i] FOR ANY SPLIT. That is what makes the weights a comfort
choice rather than a tuning parameter, and it is why a bad split cannot
destabilise the site. Graded directly, and measured end to end: 90/10, 50/50 and
100/0 give a bit-identical aggregate trace at the meter, and a gain sweep to 1.0
at every split has a flat tail at all 84 combinations.
THE TWO REFUSALS main.py NOW MAKES, AND WHY THERE ARE TWO. `allocated=False` no
longer means "no allocator"; it means no safe split EXISTS - a mis-routed claim,
an empty envelope set, or a split that could only be made by commanding one pack
to charge while another discharges. Separately, and this guard is the price of
making allocate() succeed rather than something inherited, main.py refuses to
act on a valid split it cannot DELIVER: there is one `setpoint_entity`, so
writing the site target to the one inverter it can reach would over-command that
inverter by exactly the other's share. Commanding a partial site is a worse
instruction than commanding nothing. DEV-02 gives it the second writer.
scope_violations() IS NOW CONSUMED, AND AS A REFUSAL. ARB-03 left it logging
without changing the resolution. A mis-routed claim means the number about to be
divided may itself be wrong, and dividing a number that may be wrong is worse
than not dividing it - the same answer clause 3 already gives a contradiction.
It is NOT promoted into an envelope: a unit-scoped claim that landed on the site
names no unit, so promoting it would mean guessing one, or applying one
inverter's rating to a sibling that does not have it.
AT N<=1 IT IS A PASS-THROUGH, BYTE-IDENTICAL: the share is the site target, as a
float, unrounded and unclamped. All 17 existing suites replay unedited.
test_peakguard.py part 10h and test_arbiter.py part 7 were RE-AIMED, not
deleted. PR #40's reviewer proved that refusal branch was unreachable and
untested once; removing its trigger without giving it a new one would have
rebuilt the hole. Part 10h now drives three real cycles - a deliverable split, a
dead sibling, and every master stale - and grades all three.
KNOWN AND NOT CLOSED HERE: at a roster of two the loop's `actual_w` is one
inverter's power used as a site measurement, because the add-on binds a single
`inverter_power` sensor. That is the SENSING half of the same N=1 identity this
card removes from the COMMANDING half, and it makes the aggregate trace
split-dependent below gain 1.0. Measured with its counterfactual in test_alloc.py
part 8 and tools/alloc_sim.py --splits. It belongs to DEV-02.
Verified: all 18 suites green in the CI image. 16 mutations run through
tools/mutation_run.sh on a purged tree; 13 red, and the 3 survivors are recorded
in test_alloc.py part 12 with the reason each cannot be caught.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two blocking findings from the review of 186e13c. The second is why the first
survived, and it is the one this commit is really about.
THE CLAMP. `_clamp_int()` folded both ends of the interval into one
`min(max(...))` and substituted `v` itself for a bound that was infinite. That
reads as "an absent bound cannot bind", and it is not what it does: with
`hi == inf` the expression collapses to `min(max(v, lo), v) == v`, so A MISSING
CEILING SILENTLY DISCARDED THE FLOOR. `_clamp_int(0, 2142, inf)` returned 0. It
reached the shares through all three call sites - the base, the pin clamp and
the post-floor clamp.
The worked failure: bounds `{a: [2000, +inf], b: [-5000, 5000]}` against a
+1000 W site target returned `a=+500, b=+500` - inverter-a commanded at a
quarter of its own 2000 W floor - while THE SAME CASE WITH A FINITE CEILING
correctly REFUSES on sign coherence. So one defect broke the ticket's first
named deliverable ("each share clipped to that unit's UnitBounds interval") and
suppressed a circulating-power refusal that should have fired. Both now behave
identically, and the refusal is the behaviour.
Not reachable on the live install - N=1 is a pass-through, and via main.py the
supervised claim unconditionally files a finite +/-max_w LIMIT at P_SAFETY that
pre_resolve's gate can never filter because nothing outranks P_SAFETY. But
`plan()` and `arbiter.allocate()` are the public envelope contract SAFETY-28 and
DEV-02 are being asked to build on, and `UnitBounds()` DEFAULTS TO
`(-inf, +inf)`, so a one-sided envelope is the ordinary case at N>=2 rather than
an edge, and any caller with one inherited the defect.
Fixed by clamping the two ends independently: cast to int, apply `lo` only if it
is not -inf, apply `hi` only if it is not +inf. `_int_envelope()` already passes
infinities through untouched and needed no change; it is the only other place in
the module that special-cases infinity, and the rest of the tree's `isfinite`
calls are input validation in unrelated modules.
THE RECORD. `UnitPlan.explain()` printed
`inverter-a -> +500 W in [2000, +inf] (weighted share)` - a line asserting a
share is inside an interval that excludes it, in the record SAFETY-28 gates on.
It now ASKS: `within_envelope` checks the share against the interval, and a
violated plan prints `OUTSIDE ... - ENVELOPE VIOLATED, DO NOT WRITE` instead. A
dropped unit is exempt and prints "not commanded" - it was told nothing, its
interval may be a contradiction no number is inside, and a banner there would
cry wolf on the one row that is working.
THE REPLAY THAT COULD NOT SEE IT, WHICH IS THE FINDING THAT MATTERS. The
250 000-tick replay drew every envelope as `B(-uniform(0, RATING),
uniform(0, RATING))` - finite on both ends and containing zero, every time -
while the check it fed claimed "no share ever leaves the envelope it was clamped
into", which reads as a statement about `UnitBounds`. The data could only
exercise a symmetric closed interval around zero. That is why it reported 0
escapes against a live defect while the reviewer's own 120 000 ticks reported
6 254.
The generator now draws from every shape `UnitBounds` can express: finite
straddling zero, half-open above, half-open below, finite excluding zero in each
direction, half-open AND excluding zero, and bare `UnitBounds()`. Over the same
seed and the same 250 000 ticks it draws 166 657 half-open intervals, 55 233
bare ones and 222 082 that exclude zero, and those counts are PRINTED and
asserted so "the replay covers half-open intervals" is a number a reader can see
rather than a sentence the file asserts about itself. The escape check also
dropped its 1 W tolerance: `_int_envelope()` narrows inward, so there is nothing
for slack to absorb except a defect.
GRADED, NOT MERELY APPLIED - the acceptance bar for this round. A/B on one seed
and one tick count, against the defective clamp: THE OLD GENERATOR SEES 0
ESCAPES, THE NEW ONE SEES 39 991. Reverting the fix under the widened generator
takes test_alloc.py RED on four checks (the escape count, the record check, and
both halves of the new half-open block); re-applying it takes it green. The
collapse is now a row in part 12's mutation table, recorded as the mutation that
actually shipped rather than a hypothetical one.
TWO CHECKS RE-AIMED RATHER THAN LOOSENED.
* Part 6's "the sign gate never fires on an ordinary tick" counted refusals over
the whole replay. Envelopes that EXCLUDE zero are precisely what that gate is
for, so the widened generator makes refusals correct there. It now counts an
ORDINARY tick explicitly - no pin, every envelope contains zero - and asserts
0 refusals over 77 533 of them, half-open and bare envelopes among them. That
is stronger than what it replaced, not weaker.
* Part 1's third check summed `sum_w - sum(shares)` over ticks where
`unmet_w == 0`, and `unmet_w` IS that difference - so it was true by
construction and would have gone quiet rather than red. It now measures the
one rounding that is actually left: the site-level `quantisation_w`, totalling
-39.8 W over 137 964 allocations, i.e. -0.0003 W per tick. Largest remainder
against the per-unit alternative is still graded in part 2, where the
counterfactual drifts -125 W over 50 000 ticks at three inverters.
Verified: all 18 suites green in the CI image on a tree with no bytecode cache;
test_alloc.py also re-run through tools/mutation_run.sh, which purges. AC 2 and
AC 3 are untouched by this change - the rig and part 8 use symmetric finite
envelopes, which the old and new clamp treat identically - and part 8 is green.
No hardware touched; admin/goodwe not fetched, not modified, not pushed to.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
admin
merged commit 3030f72b9e into release/1.02026-09-02 12:13:34 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
What this does
ARB-03leftallocate()as a documented seam that passed through at N≤1 and declined at N≥2, with the reasoning at the line: the obvious placeholder — hand the site target to each inverter — is the ARCH-01 fault. This fills the seam withapp/allocator.py, host-side and strictly above the driver seam.One integrator on P1.
sum(shares) + unmet_w == S, in integer arithmetic. Each inverter told a function of its own share, never of the shared measurement.The red baseline, reproduced against this tree
admin/goodwe@0c31e6f,sim/sitesim.py— the add-on's owncompute()in the loop, the firmware's own encoder on the wire,plantsim.Plantas the battery,watchdog.SignWatchdogas the detector:inject.py two_master_broadcastpasses, i.e. it still reproduces the fault: 4000 W p2p at gain 1.0,clamped/i_clamped/frozen/sat/signall quiet over 40 cycles; over 600 s the shipped sign predicate fired on 30/120 cycles and never held longer than 0 s against its 180 s window. It is looking and it cannot see this.The green, on the same harness
tools/alloc_sim.pywrapssitesim.allocateso one new mode routes to the shippedarbiter.allocate().run_site()itself is used unmodified — that wrapper is the entire diff between the red run and the green run.⚠️
plantsim.INVERTER_Bcarriesmeasured=False— its rating, ramp and lag are A's numbers copied, because inverter B does not exist yet (SPIKE-04). Every verdict involving B is a verdict about the architecture, not about that inverter, and the runs say so.The design
UnitBounds, read per tick.plan()is a pure function; the module holds no state, so there is nowhere for a construction-time envelope to be cached, andtest_alloc.pypart 10 grades that by moving an envelope between two calls.round()drifts −125 W over 50 000 ticks at three inverters. 250 000 randomised ticks: 0 sum failures, 0 envelope escapes, 0 W accumulated bias.unmet_w,dropped, per-unit reason.okis one boolean for SAFETY-28 to gate on, andunmet_wis deliberately not fused into it: a saturated inverter must not park the whole site.The stability property is structural, and it is graded rather than argued (part 9): with
Σa_i = 1the gain at the meter isΣa_i·g_i, a convex combination, so it lies in[min g_i, max g_i]for any split. Under broadcast the same sum isN·g— outside that interval and growing in N.The two things the ticket warned about
1.
test_peakguard.pypart 10h — re-aimed, not deleted.allocated=Falseno longer means "no allocator"; it means no safe split exists: a mis-routed claim, an empty envelope set, or a split that could only be made by commanding one pack to charge while another discharges. Part 10h now drives three real cycles throughController.cycle()and grades all three:allocated=Falsereached for real, 0 W substituted and written, the allocator's own sentence in the action log.⚠️ The deliverability guard is the price of making
allocate()succeed, not something inherited. There is onesetpoint_entity. Handed a valid 995/995 split and a roster of two, main.py would have writtenself.target— 1990, the site number — to the single inverter it can reach while a second sat uncommanded. Not the broadcast fault, but the same shape of over-command. Without this guard ALLOC-01 would have been a regression. DEV-02 deletes it by giving main.py the second writer.2.
scope_violations()— consumed, and consumed as a REFUSAL. My decision, with the reasoning atallocator.plan()REFUSAL 1 and graded intest_arbiter.pypart 8. By the time stage 3 runs, the mis-routed claim has already been partitioned, so its effect is baked intoSor into somebody's interval; consuming it as an allocation input would mean re-routing, which makesactuatoradvisory and hands every filing site a second chance to be wrong. And promoting a unit-scoped claim that landed on the site requires guessing which unit — the answeractuator_for()exists to fix at the filing site. What is left is thatSitself may be wrong, and dividing a number that may be wrong is worse than not dividing it. Refuse; same answer clause 3 already gives a contradiction.Verification
test_alloc.py).tools/mutation_run.shon a purged tree. 13 red — broadcast, the envelope clamp,live, the sign gate, the N≤1 pass-through,unmet_wfolded intook, the pin gate,scope_errors, the leftover pass, truncation instead of rounding, and all three main.py guards. A no-op control mutation correctly survived, so the harness is not reporting red unconditionally.test_alloc.pypart 12 with the reason. Two of them I had listed as caught in a first draft and were not: the sum is protected by the integer leftover pass, not by thefloorbefore it, and the tie-break's determinism is delivered upstream bysorted(bounds). The third is AC 2 itself — nothing at the meter can distinguish two weightings, which is the property rather than a gap.What I am not sure about, and what I chose
Split-invariance does not hold below gain 1.0 on the rig, and the allocator is not the cause.
sitesim.run_site()feedscompute()one inverter's power asactual_w, because the add-on binds a singleinverter_powersensor — its docstring says so, and says the rig must not be gentler than the deployment. That is the sensing half of the same N=1 identity this card removes from the commanding half. Measured, with the counterfactual:The allocator's shares sum to
Sin every one of those runs. I have not fixed it here — a site-level power roll-up is DEV-02's, and inventing one would be building DEV-02 inside this card. It is graded intest_alloc.pypart 8 so it cannot rot, and flagged in the CHANGELOG.Refusal vs
unmet_w— I drew the boundary tighter than the ticket's phrasing. The ticket lists "every unit stale, gated, or pinned" as the refusal case. I refuse only where no safe share vector exists at all; "every unit pinned" produces a fully-unmet allocation instead, because the share vector is each unit's own highest-priority claim and acting on it is right. Say if you want that widened.A residual in the pin gate, enumerated rather than glossed (part 11).
pre_resolve()filters a unit's limits by that unit's own gate, while clause 2 says a limit binds only at or above the winner's priority — which at N≥2 may be the site's, chosen afterwards. I enumerated all 27 combinations main.py can file: exactly one is applied more tightly than clause 2 strictly requires (the maintenance charge-onlyhiunder a site safety stop), and a safety stop resolves to 0 W, which ahiof 0 does not move. Erring tight allocates less, never more. A ninth claim source could change that, which is why it is written down.Refusing on
scope_errorsis a behaviour change beyond the ACs. It cannot fire on the shipped one-inverter roster with correct routing, so the live risk is zero — but it turns a log line into a 0 W site stop, and that is a judgement call I would rather have looked at than assumed.Weights are not wired into
main.py. It has nothing to weigh with — unit B's SoC and thermals are unknown to it.resolve(..., weights=)exists and is graded; filling it is DEV-02's.🤖 Generated with Claude Code
https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
ARB-03 left `allocate()` as a documented seam that passed through at N<=1 and DECLINED at N>=2, with the reasoning recorded at the line: the obvious placeholder - hand the site target to each inverter - IS the ARCH-01 fault, and "a placeholder that cannot be told apart from the real thing by any guard we have is worse than no placeholder". This fills the seam. WHY A BROADCAST IS A FAULT AND NOT AN INEFFICIENCY. 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 this add-on and "the grid is at X W" to the inverter - an identity that holds only at N=1. Hand two inverters the same target and each supplies it IN FULL, so the loop gain at the meter becomes N times the per-unit gain. Reproduced on admin/goodwe's two-master rig against this tree: -400 W export overshoot at gain 0.6, and at gain 1.0 - which config.yaml accepts as its documented maximum - a permanent 4000 W peak-to-peak limit cycle, commands alternating 1000/2000/1000/0 W, with NOT ONE GUARD FIRING. The sign watchdog is evaluated on every cycle and never trips, because the disagreement flips on alternate ticks and never holds its 180 s window. WHAT WAS BUILT. `app/allocator.py`, host-side and strictly above the driver seam. It consumes intervals and a number and nothing else - no claims, no priorities, and no measurement, because an allocator that read P1 would be a SECOND integrator on the site measurement. * ENVELOPE CLAMP, from ARB-03 stage 1's `UnitBounds`, read PER TICK. `plan()` is a pure function and the module holds no state, so there is nowhere for a construction-time envelope to be cached. * WATER-FILL by weight, with the surplus of a full unit moving to the others. Weights express PREFERENCE, envelopes express PERMISSION: a zero weight is honoured while another unit has headroom and overridden when it does not, because the SUM is the safety property and stranding capacity behind a comfort choice manufactures a standing error at the meter. * LARGEST-REMAINDER ROUNDING. The target is rounded ONCE, at the top; every sum below that line is integer arithmetic, so it cannot drift. Measured: a per-unit round() drifts -125 W over 50 000 ticks at three inverters. * THE ALLOCATION RECORD, with `unmet_w`, `dropped` and a per-unit reason. `ok` is one boolean for SAFETY-28 to gate on, and `unmet_w` is deliberately NOT fused into it - a saturated inverter must not park the whole site. THE STABILITY PROPERTY IS STRUCTURAL. With sum(a_i) = 1 the gain at the meter is sum(a_i * g_i), a convex combination of the per-unit gains, so it lies in [min g_i, max g_i] FOR ANY SPLIT. That is what makes the weights a comfort choice rather than a tuning parameter, and it is why a bad split cannot destabilise the site. Graded directly, and measured end to end: 90/10, 50/50 and 100/0 give a bit-identical aggregate trace at the meter, and a gain sweep to 1.0 at every split has a flat tail at all 84 combinations. THE TWO REFUSALS main.py NOW MAKES, AND WHY THERE ARE TWO. `allocated=False` no longer means "no allocator"; it means no safe split EXISTS - a mis-routed claim, an empty envelope set, or a split that could only be made by commanding one pack to charge while another discharges. Separately, and this guard is the price of making allocate() succeed rather than something inherited, main.py refuses to act on a valid split it cannot DELIVER: there is one `setpoint_entity`, so writing the site target to the one inverter it can reach would over-command that inverter by exactly the other's share. Commanding a partial site is a worse instruction than commanding nothing. DEV-02 gives it the second writer. scope_violations() IS NOW CONSUMED, AND AS A REFUSAL. ARB-03 left it logging without changing the resolution. A mis-routed claim means the number about to be divided may itself be wrong, and dividing a number that may be wrong is worse than not dividing it - the same answer clause 3 already gives a contradiction. It is NOT promoted into an envelope: a unit-scoped claim that landed on the site names no unit, so promoting it would mean guessing one, or applying one inverter's rating to a sibling that does not have it. AT N<=1 IT IS A PASS-THROUGH, BYTE-IDENTICAL: the share is the site target, as a float, unrounded and unclamped. All 17 existing suites replay unedited. test_peakguard.py part 10h and test_arbiter.py part 7 were RE-AIMED, not deleted. PR #40's reviewer proved that refusal branch was unreachable and untested once; removing its trigger without giving it a new one would have rebuilt the hole. Part 10h now drives three real cycles - a deliverable split, a dead sibling, and every master stale - and grades all three. KNOWN AND NOT CLOSED HERE: at a roster of two the loop's `actual_w` is one inverter's power used as a site measurement, because the add-on binds a single `inverter_power` sensor. That is the SENSING half of the same N=1 identity this card removes from the COMMANDING half, and it makes the aggregate trace split-dependent below gain 1.0. Measured with its counterfactual in test_alloc.py part 8 and tools/alloc_sim.py --splits. It belongs to DEV-02. Verified: all 18 suites green in the CI image. 16 mutations run through tools/mutation_run.sh on a purged tree; 13 red, and the 3 survivors are recorded in test_alloc.py part 12 with the reason each cannot be caught. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>