ALLOC-01: allocate the site grid target, sum-invariant, never broadcast
checks / unit (pull_request) Successful in 4m6s

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>
This commit is contained in:
glenn schrooyen
2026-09-02 10:42:33 +02:00
co-authored by Claude Opus 5
parent df88b323f5
commit 186e13ceca
9 changed files with 2205 additions and 95 deletions
+106 -36
View File
@@ -45,9 +45,10 @@ test can construct a case at any one of them:
stage sees the whole list and answers exactly what the
shipped arbiter answered.
3. `allocate()` - THE SEAM. It turns S into per-inverter shares subject to
stage 1's intervals. ⚠️ IT IS NOT AN ALLOCATOR AND MUST
NOT BECOME ONE HERE - see its own docstring. ALLOC-01
builds it.
stage 1's intervals. ALLOC-01 filled it: the allocator
itself is `app/allocator.py` and the reasoning lives
there, because it consumes intervals and a number and
must never learn about claims, priorities or P1.
⚠️ THE STRUCTURAL PROPERTY, AND IT IS THE REASON FOR THE SPLIT. A per-inverter
constraint is consumed by the allocator as an INTERVAL; it is never promoted
@@ -63,6 +64,13 @@ strategy doing it by hand.
from dataclasses import dataclass, field
from math import inf
# ⚠️ ONE WAY ONLY. `allocator` knows nothing about claims, priorities or
# this module - it consumes intervals and a number - so the dependency
# cannot become a cycle and the allocator stays testable on its own, which
# is what lets test_alloc.py sweep it hundreds of thousands of times
# without building a claim list for each one.
from . import allocator
# Priorities live here and nowhere else. The moment these become magic numbers
# scattered through the strategies, the whole point of this module is lost.
P_SAFETY = 100 # stopped, inputs missing, shutting down, device limits
@@ -305,6 +313,24 @@ class Resolution:
shares: dict = field(default_factory=dict) # stage 3, per actuator
allocated: bool = True # False when stage 3 declined to split
scope_errors: list = field(default_factory=list) # mis-routed claims
# ALLOC-01, appended for the reason the four above were: `target_w` still
# means the SITE grid target and every existing construction is untouched.
#
# `allocation` is stage 3's full record - what each unit was told and why,
# what could not be placed, what was dropped. `shares` and `allocated` are
# two fields OF that record, kept at the top level because main.py and four
# test files already read them there. ⚠️ IT IS THE RECORD, NOT THE PAIR,
# THAT SAFETY-28 GATES THE WRITE PATH ON: `allocated` says the allocator
# stands behind the split, and `unmet_w` says how much of the site target it
# could not place, and fusing those two would make one saturated inverter
# park the whole site.
allocation: object | None = None
# The priority of the SITE claim that chose `target_w`. Stage 3 weighs a
# unit's OWN winning SET against it - see `allocator.plan()`'s PINS note -
# and getting it from here rather than recomputing it is what stops the two
# rules drifting apart: this is set by the same line that picks the winner
# `explain()` names.
winner_priority: int | None = None
def explain(self) -> str:
"""One line an operator can act on - this is the point of the module."""
@@ -347,35 +373,55 @@ def pre_resolve(unit_claims: list) -> UnitBounds:
contradiction=lo > hi)
def allocate(target_w: float, bounds: dict) -> tuple:
"""STAGE 3. THE SEAM. Returns `(shares, allocated)`.
def allocate(target_w: float, bounds: dict, *, weights=None, live=None,
scope_errors=(), site_priority=None) -> tuple:
"""STAGE 3. THE SEAM, AND SINCE ALLOC-01 IT IS OCCUPIED. `(shares, allocated)`.
⚠️ THIS IS NOT THE ALLOCATOR AND MUST NOT BECOME ONE HERE. ALLOC-01 owns
that, and §6.2 already fixed its shape: SUM-INVARIANT SHARE ALLOCATION - the
shares sum to `target_w` exactly, each unit is told a function of its OWN
share, and the weights are a comfort choice (SoC, thermals, wear) and
explicitly not a tuning parameter, because the effective gain at the meter is
then a convex combination of the per-unit gains for any split.
⚠️ THE ALLOCATOR ITSELF IS `app/allocator.py` AND THE REASONING LIVES THERE.
This is the adapter: it keeps the two-value shape every caller in this
repository already reads, and it exists as a separate name so that the
arbiter's three stages are still three functions a test can enter at any
one of them. `allocator.plan()` returns the full record - use that when you
need `unmet_w`, `dropped` or the per-unit reasons; `resolve()` stashes it on
`Resolution.allocation` for exactly that.
What it does today, and why each half is what it is:
What §6.2 fixed and what this delivers:
* WITH AT MOST ONE UNIT there is nothing to divide. The share IS the site
target, and that is not an approximation - it is §6.2's N=1 identity, the
one that makes "the battery moves X W" and "the grid is at X W" the same
sentence. Pass-through.
* WITH TWO OR MORE UNITS IT DECLINES: no shares, `allocated=False`. ⚠️ THE
OBVIOUS PLACEHOLDER - hand `target_w` to each unit - IS THE FAULT ARCH-01
WAS FILED ABOUT. Each inverter would supply it in full, the loop gain at
the meter would be N times 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. A placeholder that cannot
be told apart from the real thing by any guard we have is worse than no
placeholder, so this one refuses out loud instead.
* SUM-INVARIANT. `sum(shares) + unmet_w == round(target_w)`, in integer
arithmetic, so it cannot drift. Rounding is largest-remainder because a
per-unit `round()` biases the sum by up to N/2 W every tick and at 1-2 Hz
that is a PERMANENT standing error the site integrator chases forever.
* EACH UNIT IS TOLD A FUNCTION OF ITS OWN SHARE, never of the shared
measurement. That makes the gain at the meter a convex combination of the
per-unit gains for ANY split, which is why the weights are a comfort
choice and a bad split cannot destabilise the site.
* WITH AT MOST ONE UNIT IT IS STILL A PASS-THROUGH, byte-identical to what
ARB-03 shipped: the share IS the site target, as a float, unrounded and
unclamped. §6.2's N=1 identity, and the compatibility promise every
existing replay in this repository depends on.
⚠️ AND IT STILL DECLINES, WHICH IS NOT THE SAME LIST OF CASES AS BEFORE.
ARB-03 declined at N>=2 because it had no allocator; that trigger is gone
and the branch is NOT. It now fires when no safe split exists at all: a
mis-routed claim, no unit left in the envelope set, or a split that could
only be made by commanding one pack to charge while another discharges.
Read `allocator.plan()` for each. main.py's refusal path and
test_peakguard.py part 10h were re-aimed onto those rather than deleted -
PR #40's reviewer proved that guard was unreachable and untested once, and
removing its trigger without giving it a new one would rebuild the hole.
"""
if len(bounds) <= 1:
only = next(iter(bounds), ACTUATOR_SITE)
return {only: float(target_w)}, True
return {}, False
return allocate_record(target_w, bounds, weights=weights, live=live,
scope_errors=scope_errors,
site_priority=site_priority)[:2]
def allocate_record(target_w: float, bounds: dict, *, weights=None, live=None,
scope_errors=(), site_priority=None) -> tuple:
"""`allocate()` plus the record - `(shares, allocated, allocation)`."""
rec = allocator.plan(target_w, bounds, weights=weights, live=live,
scope_errors=scope_errors,
site_priority=site_priority, site_key=ACTUATOR_SITE)
return rec.shares, rec.ok, rec
def site_resolve(claims: list) -> Resolution:
@@ -412,7 +458,8 @@ def site_resolve(claims: list) -> Resolution:
if lo > hi:
return Resolution(0.0, winner.source, bound_by=None,
contradiction=True, considered=considered)
contradiction=True, considered=considered,
winner_priority=winner.priority)
target = max(lo, min(hi, winner.value))
bound_by = None
@@ -421,17 +468,29 @@ def site_resolve(claims: list) -> Resolution:
if binder is not None:
bound_by = f"{binder.source}({binder.reason})" if binder.reason else binder.source
return Resolution(float(target), winner.source, bound_by, False, considered)
return Resolution(float(target), winner.source, bound_by, False, considered,
winner_priority=winner.priority)
def resolve(claims: list, *, units=None) -> Resolution:
def resolve(claims: list, *, units=None, weights=None, live=None) -> Resolution:
"""The three stages, in order, over one cycle's claims.
`units` is the site's roster of inverters. Left None it is derived from the
claims themselves, which 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, so ALLOC-01 will pass the real roster rather than let a
silent unit look like a smaller site.
one that does not, so main.py passes the real roster rather than let a silent
unit look like a smaller site.
`weights` and `live` are ALLOC-01's two per-tick inputs to stage 3, and both
are per-TICK on purpose. `weights` is the comfort choice - SoC, thermals,
wear - and cannot destabilise the site whatever it says. `live` is the set
of units whose master has reported recently enough to be commanded; a unit
left out of it is DROPPED from the envelope set and the record says so,
because the survivor carrying a dead unit's share silently is the failure
SAFETY-30 is about. ⚠️ NEITHER MAY BE CACHED BY A CALLER. A unit's range
moves for reasons unrelated to the allocator - a stale link, a pack at its
floor, a device rating that failed to read - so an envelope read once at
construction over-allocates into a unit that cannot take it.
⚠️ AND THE ARGUMENT HAS TO BE HONOURED, WHICH IS NOT THE SAME AS EXISTING.
Ignore it - derive always - and a unit that filed nothing this cycle ceases
@@ -460,8 +519,19 @@ def resolve(claims: list, *, units=None) -> Resolution:
# `claims`, so this is the shipped arbiter on the shipped list.
out = site_resolve(site_claims)
# STAGE 3 - the seam.
out.shares, out.allocated = allocate(out.target_w, bounds)
out.unit_bounds = bounds
# STAGE 3 - the seam, and since ALLOC-01 the allocator behind it.
#
# ⚠️ THE ORDER MATTERS: `scope_violations()` IS COMPUTED BEFORE THE SPLIT,
# NOT AFTER, because stage 3 consumes it. A claim filed on an actuator its
# source's scope does not permit 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 - `allocator.plan()` carries the full argument, including
# why the mis-routed claim is NOT promoted into somebody's envelope. This
# was the open question ARB-03 left, and it is answered here rather than
# left as a log line nothing grades.
out.scope_errors = scope_violations(claims)
out.unit_bounds = bounds
out.shares, out.allocated, out.allocation = allocate_record(
out.target_w, bounds, weights=weights, live=live,
scope_errors=out.scope_errors, site_priority=out.winner_priority)
return out