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
+79 -13
View File
@@ -334,21 +334,54 @@ check("...and the SET's value travels BESIDE the interval, not inside it. "
and owned.gate == P_MAINTENANCE and not owned.contradiction)
print()
print("ARB-03 7. the seam is a seam, and NOT an allocator")
print("ARB-03 7 / ALLOC-01: the seam, and what now occupies it")
# ⚠️ THIS PART WAS "the seam is a seam, and NOT an allocator" AND IT GRADED THE
# DECLINE. ALLOC-01 filled the seam, so the decline at N>=2 is gone - and the
# checks that graded it are RE-AIMED rather than deleted, because the two halves
# they were really about are both still true and both still worth a line: the
# N<=1 pass-through is byte-identical, and the seam still does not BROADCAST.
check("with no units the whole target goes to the site actuator",
allocate(1500.0, {}) == ({ACTUATOR_SITE: 1500.0}, True))
check("with one unit the share IS the site target - §6.2's N=1 identity, "
"pass-through", allocate(1500.0, {"inverter-a": UnitBounds()})
"pass-through. ⚠️ A FLOAT, UNROUNDED AND UNCLAMPED: this is the "
"compatibility promise every N=1 replay in this repository depends on, "
"and the general path below does neither of those things",
allocate(1500.0, {"inverter-a": UnitBounds()})
== ({"inverter-a": 1500.0}, True))
check("...and it is byte-identical on a fractional target too, which is where a "
"'tidy this into the general path' refactor would show up: the general "
"path rounds to the 1 W register, the pass-through must not",
allocate(1500.4, {"inverter-a": UnitBounds()})
== ({"inverter-a": 1500.4}, True))
_shares, _ok = allocate(1500.0, {"inverter-a": UnitBounds(),
"inverter-b": UnitBounds()})
print(f" two units, no allocator: shares={_shares} allocated={_ok}")
check("with two it DECLINES rather than inventing a split. ⚠️ THE OBVIOUS "
"PLACEHOLDER - hand the target to each - IS THE FAULT ARCH-01 WAS FILED "
"ABOUT: each inverter supplies it in full, the gain at the meter becomes "
"N times the per-unit gain, and no watchdog in the table fires",
_shares == {} and _ok is False)
check("...and specifically it does not broadcast", 1500.0 not in _shares.values())
print(f" two units, ALLOC-01: shares={_shares} allocated={_ok}")
check("⚠️ AND THE ONE THING THE SEAM MUST NEVER DO, WHICH IS THE WHOLE OF "
"ARCH-01: it does not BROADCAST. Hand the site target to each inverter "
"and each supplies it IN FULL - the gain at the meter becomes N times the "
"per-unit gain, which admin/goodwe's two-master rig measures as a 4000 W "
"peak-to-peak limit cycle at gain 1.0 with not one guard firing. This "
"line went red on ARB-03's decline and it goes red on a broadcast, which "
"is the point: it grades the OUTPUT, not the mechanism",
1500.0 not in _shares.values())
check("...it allocates instead, and the shares SUM to the site target exactly. "
"That is the safety property: sum(s_i) = S is what makes the gain at the "
"meter a convex combination of the per-unit gains for ANY split, so the "
"weights are a comfort choice and a bad split cannot destabilise the site",
_ok is True and sum(_shares.values()) == 1500
and sorted(_shares) == ["inverter-a", "inverter-b"])
check("...and an odd target still sums exactly, which is the half a per-unit "
"round() gets wrong: 1501/2 is 750.5 twice, and two roundings of that are "
"either 1500 or 1502. Largest remainder gives 751 and 750",
sum(allocate(1501.0, {"inverter-a": UnitBounds(),
"inverter-b": UnitBounds()})[0].values()) == 1501)
check("⚠️ AND THE DECLINE IS NOT GONE, IT IS RE-AIMED. It is still correct when "
"no safe split exists at all, and PR #40's reviewer proved that branch was "
"unreachable and untested once already - removing its trigger without "
"giving it a new one would rebuild that hole. Here: every inverter's "
"master stale, so there is no envelope set to allocate into",
allocate(1500.0, {"inverter-a": UnitBounds(),
"inverter-b": UnitBounds()}, live=()) == ({}, False))
# ⚠️ AND `resolve()` HAS TO HONOUR THE ROSTER IT IS GIVEN, WHICH IS NOT THE SAME
# THING AS HAVING A `units` PARAMETER. Left None the roster is DERIVED from the
@@ -370,13 +403,17 @@ print(f" roster of two, only inverter-a files: bounds="
check("resolve() HONOURS the roster it is handed rather than inferring one from "
"who happened to speak. inverter-b filed nothing this cycle and is still "
"a unit: it gets its own interval, and the site is still a site of TWO, "
"so the seam still DECLINES",
"so the target is SPLIT across two inverters",
sorted(_silent.unit_bounds) == ["inverter-a", "inverter-b"]
and _silent.allocated is False and _silent.shares == {})
and _silent.shares == {"inverter-a": 450, "inverter-b": 450})
check("...and that is the whole cost of ignoring the argument, stated as the "
"number it would produce: derive the roster from the claims instead and "
"the site is a site of ONE, so the seam passes 900 W through to "
"inverter-a - the silent inverter's absence read as a smaller site",
"the site is a site of ONE, so the seam passes ALL 900 W through to "
"inverter-a - the silent inverter's absence read as a smaller site, and "
"inverter-a told to do the whole site's work on its own. ⚠️ ALLOC-01 "
"makes this mutation WORSE, not better: before, both answers refused, so "
"only the roster listing distinguished them; now the wrong one produces a "
"plausible number that is twice what inverter-a should be given",
resolve([Claim.set("loop", P_LOOP, 900.0),
Claim.limit("device", P_SAFETY, -2000, 2000, "rating",
actuator="inverter-a")]).shares
@@ -424,6 +461,35 @@ check("and the eight, routed by actuator_for() at a two-inverter site, are "
actuator=actuator_for(s, "inverter-a", TWO))
for s in CLAIM_SCOPE]) == [])
# --- ALLOC-01: AND NOW SOMETHING HAPPENS ABOUT IT ---------------------------
# ⚠️ ARB-03 LEFT THIS DELIBERATELY INERT - `scope_violations()` logged and did
# not change the resolution, and nothing graded the consequence. ALLOC-01 had to
# decide, and decided the allocator CONSUMES it as a REFUSAL rather than as an
# input. The reasoning is at `allocator.plan()`'s REFUSAL 1 and the short form
# is: the mis-routed claim has already been partitioned by the time stage 3
# runs, so its effect is baked into S or into somebody's interval; "consuming"
# it as an allocation input would mean RE-ROUTING it, which needs a guess about
# which inverter it belonged to that nothing here can make. What is left is that
# S itself may be wrong, and dividing a number that may be wrong is worse than
# not dividing it.
print(f" and stage 3's answer to it: {r_mis.allocation.explain()}")
check("⚠️ the mis-routing is no longer only a log line: stage 3 REFUSES to "
"allocate on a claim list it has been told is mis-routed, so main.py "
"fails toward inaction and shouts instead of dividing a number that may "
"be wrong. That is the same answer clause 3 already gives a contradiction",
r_mis.allocated is False and r_mis.shares == {}
and "mis-routed" in r_mis.allocation.refused)
check("...and it is NOT consumed as an ENVELOPE, which is the tempting reading "
"and the wrong one: a unit-scoped claim that landed on the site names no "
"unit, so promoting it means either guessing one or applying one "
"inverter's rating to a sibling that does not have it. Nothing here is "
"promoted - the refusal carries no shares at all",
r_mis.allocation.plans == () and r_mis.allocation.dropped == ())
check("...and a correctly routed cycle is unaffected, which is what says this "
"grades ROUTING and not the presence of units: the same site claims with "
"the floor where it belongs allocate normally",
resolve(SITE_SIDE, units=TWO).allocated is True)
print()
if fails: