Files
glenn schrooyenandClaude Opus 5.5 fe80047235
checks / unit (pull_request) Failing after 3s
infra-pin / fresh (pull_request) Failing after 1s
CI-12 round 2: INFRA_READ_TOKEN for admin/goodwe; the pin tracks release/1.0
- checks.yml's infra checkout and infra-pin.yml's admin/goodwe ls-remote use
  the INFRA_READ_TOKEN secret; the self fetch keeps GITHUB_TOKEN.
- infra-pin.yml compares against admin/goodwe refs/heads/release/1.0; STALE
  message, comments and README follow.
- test_control.py / test_alloc.py: drop the claim that CI cannot reach
  admin/goodwe.
- .gitea/infra-pin is DELIBERATELY one release/1.0 merge behind (d84ac01) for
  the "infra-pin / fresh goes red" proof; the next commit sets it to HEAD.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 16:08:55 +02:00

1049 lines
57 KiB
Python

"""Runnable check for the site allocator. `python3 test_alloc.py`
ALLOC-01. The site grid target is ALLOCATED across N inverters and NEVER
broadcast, because 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" here and "the grid is at X W" there - an identity
that holds ONLY at N=1. Hand both inverters the same number and each supplies it
IN FULL.
⚠️ WHAT THIS FILE CAN AND CANNOT CLAIM, SAID BEFORE THE FIRST CHECK.
The two-master rig lives in admin/goodwe (TEST-19, `sim/sitesim.py`), where
`compute()` closes the loop against `plantsim.Plant` with the firmware's own
encoder on the wire. CI checks admin/goodwe out at `.gitea/infra-pin` (CI-12),
but this file does not import the rig.
The split:
* `tools/alloc_sim.py` drives the REAL rig, and its output is quoted in the
pull request. It is where "no limit cycle" is a claim about the shipped
control law against the modelled hardware.
* THIS FILE grades the allocator's own arithmetic exhaustively - which needs
no plant at all - and closes a loop around the REAL `compute()` against a
plant IT MODELS AND SAYS IT MODELS (part 8). The modelled plant is the
`lag_s=0` limit ARCH-01's evidence was computed in; it is not evidence about
a GW5000S-BP.
⚠️ INVERTER B DOES NOT EXIST YET. Nothing here contains a measured parameter for
it. Every two-unit case below is built from a rating passed IN, and the second
unit's rating is either the first one's or an explicit hypothetical.
⚠️ MUTATING THE MODULE THIS FILE GRADES? PURGE `__pycache__` FIRST - or run
`tools/mutation_run.sh`, which does. `python3 -B` alone does NOT save you.
Read MUTATION.md before you report a survivor.
"""
import random
import sys
from math import floor, inf
from app.allocator import (
DROP_CONTRADICTION, DROP_NO_INTEGER, DROP_STALE, REFUSE_EMPTY,
REFUSE_SCOPE, REFUSE_SIGN, Allocation, UnitPlan, _clamp_int, plan,
)
from app.arbiter import (
ACTUATOR_SITE, P_ARBITRAGE, P_LOOP, P_MAINT_SHAPE, P_MAINTENANCE, P_PEAK,
P_SAFETY, UnitBounds, Claim, allocate, resolve,
)
from app.control import Tuning, compute
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def _raises_value(fn):
"""True if `fn()` raises ValueError - a check that something is REFUSED."""
try:
fn()
except ValueError:
return True
return False
def B(lo=-inf, hi=inf, gate=None, pin=None, by=None, contra=False):
"""One unit's interval, the way ARB-03 stage 1 hands it over."""
return UnitBounds(lo=lo, hi=hi, gate=gate, pinned_w=pin, pinned_by=by,
contradiction=contra)
A, Bee = "inverter-a", "inverter-b"
RATING = 5000.0 # GW5000S-BP, measured. Inverter B's is a HYPOTHESIS.
TWO = {A: B(-RATING, RATING), Bee: B(-RATING, RATING)}
# ==============================================================================
print("1. AC 1: the shares sum to the site target, and it is INTEGER arithmetic")
# ⚠️ THE INVARIANT IS NOT `sum == S`. It is `sum + unmet == S`, and the
# difference is the whole of AC 4: an allocation the envelopes could not fully
# absorb is still a SAFE allocation, and reporting the shortfall is better than
# either refusing or pretending. `unmet_w` is 0 whenever the envelopes allow.
_r = plan(1990.0, TWO)
print(f" {_r.explain()}")
check("two open envelopes, an even weighting: the site target is halved and "
"the halves sum back to it",
_r.shares == {A: 995, Bee: 995} and _r.unmet_w == 0)
check("...and 995 is not 1990, which is the sentence ARCH-01 is about. A "
"broadcast hands each unit the site target itself, each supplies it IN "
"FULL, and the loop gain at the meter becomes N times the per-unit gain",
all(v != _r.sum_w for v in _r.shares.values()))
# THE LONG REPLAY. AC 1 says "verified over a long replay rather than a spot
# check", and it says so because the failure mode is a BIAS - a fraction of a
# watt per tick that only shows up in aggregate. A spot check at a round number
# cannot see it; 250 000 randomised ticks can.
#
# ⚠️ AND THE GENERATOR IS THE OTHER HALF OF THE CHECK, WHICH THIS FILE GOT
# WRONG ONCE AND IS THE REASON THIS PARAGRAPH EXISTS. The first version drew
# every envelope as `B(-uniform(0, RATING), uniform(0, RATING))` - finite on
# both ends and containing zero, EVERY TIME. The check it fed says "no share
# ever leaves the envelope it was clamped into", which reads as a statement
# about `UnitBounds`; the data could only ever exercise a symmetric closed
# interval around zero. So it reported 0 escapes over 250 000 ticks while a
# reviewer's own 120 000 - drawn over the whole shape space - reported 6 254,
# against a real clamp defect on half-open intervals.
#
# `UnitBounds` DEFAULTS TO `(-inf, +inf)`, so an envelope with one end missing
# is the ordinary case at N>=2, not an edge. `_shape()` therefore draws from
# every shape the type can express, and the counters below are printed so that
# "the replay covers half-open intervals" is a number a reader can see rather
# than a sentence this file asserts about itself.
def _shape(rnd):
"""One random envelope, drawn across EVERY SHAPE `UnitBounds` CAN HOLD."""
kind = rnd.randrange(9)
if kind <= 2: # finite, straddling zero
return B(-rnd.uniform(0.0, RATING), rnd.uniform(0.0, RATING))
if kind == 3: # a FLOOR and no ceiling
return B(rnd.uniform(-RATING, RATING), inf)
if kind == 4: # a ceiling and no floor
return B(-inf, rnd.uniform(-RATING, RATING))
if kind == 5: # finite, EXCLUDES zero, +ve
_lo = rnd.uniform(1.0, RATING)
return B(_lo, _lo + rnd.uniform(0.0, RATING))
if kind == 6: # finite, EXCLUDES zero, -ve
_hi = -rnd.uniform(1.0, RATING)
return B(_hi - rnd.uniform(0.0, RATING), _hi)
if kind == 7: # half-open AND excludes zero
return (B(rnd.uniform(1.0, RATING), inf) if rnd.random() < 0.5
else B(-inf, -rnd.uniform(1.0, RATING)))
return B() # bare: (-inf, +inf)
def _half_open(b):
return (b.lo == -inf) != (b.hi == inf)
def _excludes_zero(b):
return b.lo > 0.0 or b.hi < 0.0
random.seed(20260902)
_N = 250_000
_bad_sum = _bad_env = _refused = 0
_refused_ordinary = _ordinary = 0
_bad_record = 0
_quant = 0.0
_span = _n_open = _n_half = _n_nozero = _n_bare = 0
_esc_half = _esc_nozero = 0
for _i in range(_N):
_S = random.uniform(-6000.0, 6000.0)
_ba, _bb = _shape(random), _shape(random)
_env = {A: _ba, Bee: _bb}
for _b in (_ba, _bb):
_n_half += _half_open(_b)
_n_nozero += _excludes_zero(_b)
_n_bare += (_b.lo == -inf and _b.hi == inf)
_n_open += (_b.lo == -inf or _b.hi == inf)
# AN ORDINARY TICK: no pin, and every envelope contains zero. That is the
# population part 6 asks the sign gate never to fire on - and it is a
# POPULATION now rather than the whole replay, because the widened shapes
# include the forced floors the gate exists for.
_ord = all(_b.lo <= 0.0 <= _b.hi for _b in (_ba, _bb))
_ordinary += _ord
_wa = random.random()
_p = plan(_S, _env, weights={A: _wa, Bee: 1.0 - _wa})
if not _p.ok:
_refused += 1
_refused_ordinary += _ord
continue
if sum(_p.shares.values()) + _p.unmet_w != _p.sum_w:
_bad_sum += 1
for _u, _s in _p.shares.items():
_b = _env[_u]
# ⚠️ NO TOLERANCE. `_int_envelope()` narrows INWARD, so every integer
# the allocator may command is inside the real interval - there is
# nothing for a slack of a watt to absorb except a defect.
if not (_b.lo <= _s <= _b.hi):
_bad_env += 1
_esc_half += _half_open(_b)
_esc_nozero += _excludes_zero(_b)
# AND THE RECORD IS GRADED AGAINST THE SAME REPLAY, because the defect that
# produced this generator also produced a `UnitPlan.explain()` line reading
# `inverter-a -> +500 W in [2000, +inf]` - the record agreeing with itself
# about a share the interval excludes.
if not all(_pl.within_envelope for _pl in _p.plans):
_bad_record += 1
_quant += _p.quantisation_w
if _p.unmet_w == 0:
_span += 1
print(f" replayed {_N} ticks at random targets, random envelopes and random "
f"weights: {_bad_sum} sum failures, {_bad_env} envelope escapes, "
f"{_refused} refusals")
print(f" envelope shapes drawn ({2 * _N} in total): {_n_open} with an "
f"infinite end, of which {_n_half} HALF-OPEN and {_n_bare} bare "
f"UnitBounds(); {_n_nozero} that EXCLUDE zero")
check("⚠️ OVER A QUARTER OF A MILLION TICKS THE SUM IS EXACT EVERY TIME. Not "
"within a tolerance - EXACT - because `sum_w` is an int, the shares are "
"ints and the leftover is settled in integer arithmetic, so there is no "
"float in the identity for an error to accumulate in",
_bad_sum == 0)
check("...and no share ever leaves the envelope it was clamped into, over the "
"same replay - INCLUDING the half-open ones and the ones that exclude "
"zero, which is the coverage this check claimed before it had it. An "
"allocator that meets its sum by over-driving one unit past its rating "
"has met the wrong invariant",
_bad_env == 0)
check("...and the replay is NOT VACUOUS about that: it really did draw "
f"{_n_half} half-open intervals, {_n_bare} bare `UnitBounds()` and "
f"{_n_nozero} intervals excluding zero. Revert `_clamp_int` to the form "
"that substitutes `v` for a missing bound and this check goes red on the "
"half-open draws; under the old generator it could not",
_n_half > 10_000 and _n_bare > 10_000 and _n_nozero > 10_000)
check("...and every plan in the record AGREES with the share it carries: no "
"`UnitPlan` was built asserting a number is inside an interval that "
"excludes it. The record is what SAFETY-28 gates on, so a record that "
"describes a share it did not check is worth less than no record",
_bad_record == 0)
check("⚠️ AND THE ONLY ROUNDING LEFT IS THE SITE-LEVEL ONE, WHICH DOES NOT "
f"DRIFT. Over {_N - _refused} allocations the total of "
f"`quantisation_w` - `sum_w - target_w`, at most half a watt each and "
f"NEVER distributed into a per-unit bias - is {_quant:+.1f} W, i.e. "
f"{_quant / max(1, _N - _refused):+.5f} W per tick. A 1 W systematic "
"bias at 1-2 Hz is not a rounding artefact - it is a permanent standing "
"error with the site integrator wound around it. (Largest remainder "
"against the per-unit alternative is measured in part 2; the sum "
f"identity above holds on all {_span} unconstrained ticks.)",
abs(_quant) / max(1, _N - _refused) < 0.01)
# ==============================================================================
print()
print("2. AC 1 continued: WHY largest remainder, measured against the "
"alternative")
# The obvious implementation is `round(w_i * S)` per unit. It is wrong, and the
# amount it is wrong by is worth printing rather than asserting.
def _naive(S, ws):
"""The obvious implementation: round each unit's own share, independently."""
return [round(w * S) for w in ws]
random.seed(4242)
_naive2 = _naive3 = _ours = 0
_worst2 = _worst3 = 0
_C = "inverter-c"
_THREE = {A: B(-RATING, RATING), Bee: B(-RATING, RATING),
_C: B(-RATING, RATING)}
for _i in range(50_000):
_S = random.randrange(-4000, 4000)
_wa = random.random()
_n2 = _naive(_S, [_wa, 1.0 - _wa])
_naive2 += sum(_n2) - _S
_worst2 = max(_worst2, abs(sum(_n2) - _S))
_x, _y = sorted((random.random(), random.random()))
_w3 = [_x, _y - _x, 1.0 - _y]
_n3 = _naive(_S, _w3)
_naive3 += sum(_n3) - _S
_worst3 = max(_worst3, abs(sum(_n3) - _S))
_p3 = plan(float(_S), _THREE, weights=dict(zip([A, Bee, _C], _w3)))
_ours += sum(_p3.shares.values()) - _p3.sum_w
print(f" 50 000 ticks, per-unit round(): at N=2 it drifts {_naive2:+d} W in "
f"total (worst tick {_worst2} W); at N=3, {_naive3:+d} W (worst tick "
f"{_worst3} W). Largest remainder at N=3: {_ours:+d} W")
check("⚠️ AT THREE INVERTERS THE NAIVE PER-UNIT round() MISSES THE SUM ON REAL "
"INPUTS, repeatedly. This is what the card means by calling the rounding "
"a safety property: every missed watt is a standing error at the meter "
"that the site integrator chases and never reaches",
_worst3 >= 1)
check("...and largest remainder does not miss it, ever, over the same inputs "
"at the same roster - which is also this file's only check that the "
"allocator is general in N rather than special-cased at two",
_ours == 0 and len(plan(1000.0, _THREE).shares) == 3)
check("⚠️ AND AT TWO INVERTERS THE NAIVE VERSION IS ALMOST RIGHT, WHICH IS THE "
"DANGEROUS HALF AND IS WHY IT IS MEASURED RATHER THAN ASSUMED. With two "
"COMPLEMENTARY weights the two roundings cancel except on an exact "
f"half-watt tie, so over 50 000 ticks it drifted {_naive2} W in total. An "
"implementation that shipped at N=2 on per-unit rounding would look "
"correct and start leaking the day a third inverter arrived - and the tie "
"case below is wrong even at N=2",
_worst2 <= 1)
check("the two-unit case a per-unit round() gets wrong every time is the "
"half-watt one: 1501 W split evenly is 750.5 twice, which rounds to "
"either 1500 or 1502 - never 1501. Largest remainder gives 751 and 750, "
"and gives the extra watt to a NAMED unit rather than to whichever one "
"the tie-break happened to favour",
plan(1501.0, TWO).shares == {A: 751, Bee: 750}
and sum(plan(1501.0, TWO).shares.values()) == 1501)
check("...and the tie-break is DETERMINISTIC, so the same inputs give the same "
"allocation on every tick and on every host. A tie broken by dict order "
"would move a watt between two inverters at 1-2 Hz forever",
all(plan(1501.0, TWO).shares == {A: 751, Bee: 750} for _ in range(50)))
# THE SITE-LEVEL ROUNDING, graded over a replay for the same reason the sum is:
# the failure mode is a BIAS, and one spot check at one fractional target cannot
# see one. `int()` instead of `round()` is the mutation that matters here - it
# truncates toward zero, which is a systematic under-command of up to a watt on
# EVERY tick, in a consistent direction, which is precisely the standing error
# the card calls a safety property.
random.seed(99)
_q = [plan(random.uniform(-4000.0, 4000.0), TWO).quantisation_w
for _ in range(20_000)]
print(f" 20 000 fractional targets: |quantisation| <= {max(map(abs, _q)):.3f} W, "
f"mean {sum(_q) / len(_q):+.5f} W")
check("the site target itself is rounded ONCE, at the top, and the record says "
"by how much. It is never more than half a watt - a TRUNCATION would be "
"up to a whole one - and that half watt is a single site-level rounding "
"the integrator corrects on the next tick, not a per-unit bias it cannot "
"see",
max(map(abs, _q)) <= 0.5 and plan(1500.4, TWO).sum_w == 1500)
check("...and it is UNBIASED over the replay, which truncation is not: "
"`int(x)` rounds toward zero, so it would under-command on every single "
"tick in a consistent direction - a permanent standing error rather than "
"a rounding that averages out",
abs(sum(_q) / len(_q)) < 0.01)
check("...and the allocation does not depend on the ORDER the envelopes arrive "
"in, which is what makes it reproducible tick to tick: `plan()` sorts the "
"roster before it does anything else, so a dict built the other way round "
"gives the same shares",
plan(1501.0, {Bee: B(-RATING, RATING), A: B(-RATING, RATING)}).shares
== plan(1501.0, {A: B(-RATING, RATING), Bee: B(-RATING, RATING)}).shares
== {A: 751, Bee: 750})
# ==============================================================================
print()
print("3. AC 4: saturation - the surplus moves, and what will not fit is NAMED")
_sat = plan(4000.0, {A: B(-RATING, 1000.0), Bee: B(-RATING, RATING)},
weights={A: 0.5, Bee: 0.5})
print(f" {_sat.explain()}")
check("one inverter at its rating gives its surplus to the other rather than "
"being over-driven. An even weighting would have asked A for 2000 W; its "
"envelope stops at 1000 W and B takes the other 3000",
_sat.shares == {A: 1000, Bee: 3000} and _sat.unmet_w == 0)
check("...and the record says WHY it got 1000 and not 2000, per unit, in words "
"an operator can act on rather than as a silent clamp",
any(p.unit == A and "envelope full" in p.why for p in _sat.plans))
_unmet = plan(9000.0, {A: B(-RATING, 1000.0), Bee: B(-RATING, 2000.0)})
print(f" {_unmet.explain()}")
check("⚠️ AND WHEN NEITHER CAN TAKE IT, `unmet_w` IS REPORTED RATHER THAN THE "
"SUM QUIETLY MISSING. 9000 W asked for, 3000 W of envelope: both units go "
"to their ceilings and 6000 W is stated as unmet",
_unmet.shares == {A: 1000, Bee: 2000} and _unmet.unmet_w == 6000)
check("...and the allocation is still OK, which is the decision worth stating: "
"a saturated site is not a broken allocation. Commanding 3000 W of the "
"9000 the loop asked for is strictly better than commanding 0 W, and the "
"shortfall is a fact the write gate can act on. Fusing `unmet_w` into "
"`ok` would let one saturated inverter park the whole site",
_unmet.ok is True)
check("the identity that holds in every case, saturated or not: shares + unmet "
"== the site target",
sum(_unmet.shares.values()) + _unmet.unmet_w == _unmet.sum_w)
# ==============================================================================
print()
print("4. AC 5: a pack at its SoC floor - 0 W discharging, STILL CHARGEABLE")
# The floor arrives as a `hi` of 0 on that unit's interval: it may not
# discharge. It says nothing about charging, and reading it as "this unit is
# out" is the mistake - a unit at its floor is precisely the unit that most
# wants the charge.
_floor = {A: B(-RATING, 0.0), Bee: B(-RATING, RATING)}
_dis = plan(1990.0, _floor)
_chg = plan(-1990.0, _floor)
print(f" discharging: {_dis.explain()}")
print(f" charging: {_chg.explain()}")
check("in the DISCHARGING direction the floored unit is allocated 0 W and the "
"other carries the whole site target",
_dis.shares == {A: 0, Bee: 1990} and _dis.unmet_w == 0)
check("⚠️ AND IN THE CHARGING DIRECTION IT IS STILL AVAILABLE, which is the "
"half a `if soc <= floor: skip this unit` implementation gets wrong. Its "
"envelope forbids discharge, not participation, and a pack at its floor "
"is the one that most needs the charge",
_chg.shares == {A: -995, Bee: -995})
check("...and 0 W is not a sign of its own: the floored unit's 0 W sits happily "
"beside the other's +1990 W without tripping the sign-coherence gate, "
"because zero is coherent with every direction",
_dis.ok is True and _dis.refused is None)
# ==============================================================================
print()
print("5. AC 6: a dead link leaves the envelope set, and IS ANNOUNCED")
_dead = plan(1990.0, TWO, live=(A,))
print(f" {_dead.explain()}")
check("a unit whose master is stale is REMOVED from the envelope set. "
"Allocating into a link nobody can reach is writing into a void and "
"calling the site target met",
Bee not in _dead.shares and _dead.dropped == ((Bee, DROP_STALE),))
check("...and the survivor carries the whole site target, which is right: there "
"is one integrator on P1 and its output still has to be delivered",
_dead.shares == {A: 1990} and _dead.unmet_w == 0)
check("⚠️ AND IT DOES NOT ABSORB IT SILENTLY. The record NAMES the unit and the "
"reason, so `main.py` can put it in the action log and SAFETY-28's write "
"gate can refuse on it. A site running at half its inverters and "
"reporting nothing reads as a healthy site on every dashboard",
_dead.dropped[0][0] == Bee and _dead.dropped[0][1] == DROP_STALE
and any(p.unit == Bee and "DROPPED" in p.why for p in _dead.plans))
check("...and the per-unit plan for the dropped unit is present rather than "
"absent, so a gate iterating the roster finds an entry for every inverter "
"instead of having to notice a missing key",
{p.unit for p in _dead.plans} == {A, Bee})
check("a unit contradicting ITSELF - its own lo above its own hi - leaves the "
"set for its own reason, and does not park the site. That is stage 1's "
"decision honoured, not re-litigated here",
plan(1990.0, {A: B(-RATING, RATING),
Bee: B(500.0, -500.0, contra=True)}).dropped
== ((Bee, DROP_CONTRADICTION),))
check("an interval that contains NO INTEGER is dropped too, and says which case "
"it is. The register is 1 W: there is no number that unit could be "
"commanded that both of its own bounds accept",
plan(0.0, {A: B(-RATING, RATING), Bee: B(100.2, 100.8)}).dropped
== ((Bee, DROP_NO_INTEGER),))
check("⚠️ AND EVERY UNIT GONE IS A REFUSAL, NOT AN EMPTY SUCCESS. With nobody "
"in the envelope set there is nothing to allocate to, and inventing a "
"share for a link nobody can reach is the failure the refusal branch "
"exists for. THIS is the trigger `main.py`'s `if not resolution."
"allocated:` was re-aimed onto when ALLOC-01 removed its old one",
plan(1990.0, TWO, live=()).ok is False
and REFUSE_EMPTY in plan(1990.0, TWO, live=()).refused)
# ==============================================================================
print()
print("6. AC 7: opposite directions are REFUSED, not averaged")
# ⚠️ WHY THIS IS A SAFETY GATE AND NOT AN EFFICIENCY ONE. One pack discharging
# while another charges round-trips energy through two conversion stages, and it
# is INVISIBLE: the meter sees the net, which is exactly S, so the site
# integrator is content; each unit is meeting the target it was given; the sign
# watchdog compares a command against the meter and both agree. Nothing in this
# repository would ever report it.
_circ = plan(1000.0, {A: B(-RATING, RATING, gate=P_MAINTENANCE, pin=2500.0,
by="maintenance"),
Bee: B(-RATING, RATING)}, site_priority=P_LOOP)
print(f" {_circ.explain()}")
check("a maintenance drain of 2500 W on one pack against a site target of "
"1000 W would need the other pack to CHARGE at 1500 W to make the sum. "
"That nets to exactly the right number at the meter, which is precisely "
"why it has to be refused here - no downstream guard can see it",
_circ.ok is False and REFUSE_SIGN in _circ.refused)
check("...and the refusal NAMES both sides, so an operator is told which two "
"inverters were about to push against each other",
A in _circ.refused and Bee in _circ.refused)
check("...and it is a REFUSAL rather than a quiet re-plan: dropping the pinned "
"unit and re-filling would silently override a claim that outranked the "
"loop, which is a worse answer than stopping and saying so",
_circ.shares == {})
check("the same pin with a site target BEYOND it is fine, because the shares "
"then agree in sign - the gate grades the OUTPUT, not the presence of a "
"pin",
plan(3000.0, {A: B(-RATING, RATING, gate=P_MAINTENANCE, pin=2500.0,
by="maintenance"),
Bee: B(-RATING, RATING)},
site_priority=P_LOOP).shares == {A: 2500, Bee: 500})
check("⚠️ AND IT NEVER FIRES ON AN ORDINARY TICK, which is what makes it a gate "
"rather than a nuisance. Of the 250 000-tick replay in part 1 - random "
f"targets, random envelopes, random weights, no pins - {_ordinary} ticks "
"drew envelopes that all CONTAIN zero, half-open ones and bare "
f"`UnitBounds()` among them, and it refused {_refused_ordinary} of "
f"those. The other {_refused} refusals in that replay are this gate doing "
"its job on the forced floors the widened generator now draws",
_refused_ordinary == 0 and _ordinary > 50_000)
check("...and it cannot: with no pin and an envelope that contains zero, the "
"fill only ever moves shares in ONE direction away from zero, so two "
"signs are not expressible. The gate exists for the envelope that "
"EXCLUDES zero, which is a forced floor or a pin",
all(v >= 0 for v in plan(1990.0, TWO).shares.values())
and all(v <= 0 for v in plan(-1990.0, TWO).shares.values()))
# ⚠️ THE HALF-OPEN ENVELOPE, WHICH IS WHERE THIS GATE WAS ONCE SILENTLY
# DISARMED - and the ticket's first named deliverable, "each share clipped to
# that unit's UnitBounds interval", with it. `_clamp_int()` used to substitute
# `v` for a missing bound and feed both ends into one `min(max(...))`; with
# `hi == inf` that collapses to `min(max(v, lo), v) == v`, so THE FLOOR WAS
# DISCARDED BY THE ABSENCE OF A CEILING. The worked case: inverter-a with a
# 2000 W floor and no ceiling, inverter-b symmetric, a +1000 W site target.
_HALF = {A: B(2000.0, inf), Bee: B(-5000.0, 5000.0)}
_FIN = {A: B(2000.0, 9_000_000.0), Bee: B(-5000.0, 5000.0)}
_half, _fin = plan(1000.0, _HALF), plan(1000.0, _FIN)
print(f" floor 2000 W, NO ceiling: {_half.explain()}")
print(f" the same with a finite ceiling: {_fin.explain()}")
check("⚠️ A ONE-SIDED ENVELOPE REFUSES EXACTLY AS THE TWO-SIDED ONE DOES. Both "
"put inverter-a at its 2000 W floor against a +1000 W site target, so "
"inverter-b must charge for the shares to sum - circulating power, and "
"the gate fires. The defect made the half-open case return a=+500, "
"b=+500 instead: inverter-a commanded at a quarter of its own floor, and "
"a refusal that should have fired suppressed with it",
_half.ok is False and REFUSE_SIGN in _half.refused
and _fin.ok is False and REFUSE_SIGN in _fin.refused)
check("...and the two ends are applied INDEPENDENTLY, which is the shape of the "
"fix rather than a patch of the one case above: a missing ceiling does "
"not discard the floor, a missing floor does not discard the ceiling, and "
"a bare `UnitBounds()` binds nothing",
_clamp_int(0, 2142, inf) == 2142
and _clamp_int(9999, 2142, inf) == 9999
and _clamp_int(0, -inf, -500) == -500
and _clamp_int(-9999, -inf, -500) == -9999
and _clamp_int(-7, -inf, inf) == -7
and _clamp_int(0, 2142, 5000) == 2142)
check("...and the RECORD can no longer assert a share is inside an interval "
"that excludes it. The defect's log line read `inverter-a -> +500 W in "
"[2000, +inf] (weighted share)` - the record naming the floor and the "
"share beneath it in one breath and agreeing with itself. If that state "
"is ever reachable again the line says OUTSIDE, and says it in the line "
"an operator is already reading",
UnitPlan(A, 500, 2000.0, inf, 0.5, "weighted share").within_envelope
is False
and "OUTSIDE" in UnitPlan(A, 500, 2000.0, inf, 0.5, "x").explain()
and "DO NOT WRITE" in UnitPlan(A, 500, 2000.0, inf, 0.5, "x").explain()
and "in [2000, +inf]" in UnitPlan(A, 2500, 2000.0, inf, 0.5,
"weighted share").explain())
check("...and a DROPPED unit is not shouted about: it was commanded nothing, "
"and its interval may be a contradiction that no number is inside. That "
"case is already reported by name, and a violation banner on it would be "
"the guard crying wolf on the one row that is working",
"not commanded" in UnitPlan(A, 0, 3000.0, -3000.0, 0.0,
f"DROPPED: {DROP_CONTRADICTION}",
dropped=True).explain()
and UnitPlan(A, 0, 3000.0, -3000.0, 0.0, "DROPPED: x",
dropped=True).within_envelope is True)
# ==============================================================================
print()
print("7. AC 8: at N<=1 it is a pass-through, byte-identical to ARB-03")
check("with no units at all the whole target goes to the site actuator, as a "
"float", allocate(1500.0, {}) == ({ACTUATOR_SITE: 1500.0}, True))
check("with one unit the share IS the site target - §6.2's N=1 identity: with "
"one inverter the fabricated grid reading and the battery setpoint are "
"the same number, so there is nothing to divide",
allocate(1500.0, {A: UnitBounds()}) == ({A: 1500.0}, True))
check("⚠️ AND IT IS A FLOAT, UNROUNDED. The general path rounds to the 1 W "
"register; the pass-through must not, because every N=1 replay in this "
"repository - test_arbiter, test_watchdog, test_validate, test_peakguard "
"- expects the number it saw before ALLOC-01. This is the line a 'tidy "
"the special case away' refactor goes red on",
allocate(1500.4, {A: UnitBounds()}) == ({A: 1500.4}, True)
and isinstance(allocate(1500.4, {A: UnitBounds()})[0][A], float))
check("...and it is UNCLAMPED too, which is the other half of byte-identity: at "
"N=1 every per-inverter claim routes to the SITE actuator anyway, so "
"stage 2 has already applied the rating and re-applying an interval here "
"would be a second, different clamp on a live safety path",
allocate(9999.0, {A: B(-RATING, RATING)}) == ({A: 9999.0}, True))
_thru = plan(1500.0, {A: UnitBounds()})
check("...and the pass-through still produces a RECORD, so SAFETY-28's write "
"gate reads the same shape at every roster size and does not need an N=1 "
"special case of its own",
_thru.ok and _thru.passthrough and _thru.plans and _thru.unmet_w == 0)
# ==============================================================================
print()
print("8. AC 2 and AC 3, on a MODELLED plant: split-invariance and the sweep")
# ⚠️ READ THE FILE HEADER BEFORE READING THESE NUMBERS. The plant here is
# modelled IN THIS FILE - `lag_s=0`, unbounded ramp, which is the limit
# ARCH-01's own evidence was computed in - and the loop is the REAL `compute()`
# out of app/control.py. So the claim these checks support is about THE CONTROL
# LAW AND THE ALLOCATOR, not about a GW5000S-BP. The hardware claim is
# `tools/alloc_sim.py` against admin/goodwe's rig, quoted in the pull request.
CYCLE_S = 5.0
def close_loop(gain, split, cycles=200, load_w=2000.0, mode="alloc",
feedback="site", rating=RATING):
"""One integrator on the meter, N units, each told its OWN share.
`feedback` is what `compute()` is handed as `actual_w`, and it is a
PARAMETER because it turns out to be the whole of the AC 2 finding - see
below. "site" is the sum of the units' power; "unit" is inverter A's alone,
which is what the shipped add-on's single `inverter_power` sensor gives it.
"""
# ⚠️ `slew_w` IS PINNED HERE AND WAS NOT BEFORE, AND THAT IS SAFETY-39.
# ARCH-01's 4000 W peak-to-peak broadcast limit cycle was measured at the
# shipped pair of the day - `max_w: 2000` with `slew_w: 1000` - and this
# function reached that 1000 through `Tuning`'s DEFAULT. SAFETY-39 made that
# default 0, meaning "follow the rail" (`SLEW_RAIL_FRAC * max_w`, 222 W at
# this rail), so the same call would now sweep a DIFFERENT law and report
# ARCH-01's measurement as absent. A reproduction that silently re-tunes
# itself when a default moves is not a reproduction. The number is written
# here, next to the measurement it belongs to.
tuning = Tuning(gain=gain, slew_w=1000.0)
prev, i_w, sat = 0.0, None, 0
power = [0.0, 0.0]
grid = load_w
trace = []
for _c in range(cycles):
d = compute(prev, grid, sum(power) if feedback == "site" else power[0],
tuning, sat, i_w)
prev, i_w, sat = d.target_w, d.i_w, d.sat_count
if mode == "broadcast":
cmds = [d.target_w, d.target_w]
else:
p = plan(d.target_w, {A: B(-rating, rating), Bee: B(-rating, rating)},
weights={A: split, Bee: 1.0 - split})
assert p.ok, p.refused
cmds = [float(p.shares[A]), float(p.shares[Bee])]
# The modelled unit: it obeys within one cycle, bounded by its rating.
power = [max(-rating, min(rating, c)) for c in cmds]
grid = load_w - sum(power)
trace.append((round(d.target_w, 6), round(grid, 6)))
return trace
def p2p(trace, k=20):
tail = [g for _t, g in trace[-k:]]
return max(tail) - min(tail)
_ref = close_loop(1.0, 0.5)
_splits = [0.5, 0.9, 0.1, 0.99, 1.0, 0.0]
_same = {s: close_loop(1.0, s) == _ref for s in _splits}
print(f" aggregate trace vs the 50/50 reference, gain 1.0: "
+ ", ".join(f"{int(s*100)}/{int((1-s)*100)}={'same' if v else 'DIFFERS'}"
for s, v in _same.items()))
check("⚠️ AC 2. A 90/10 AND A 50/50 SPLIT PRODUCE AN IDENTICAL AGGREGATE TRACE "
"AT THE METER - and so do 99/1 and 100/0. Not close: the same numbers. "
"That is what says the weights are a COMFORT CHOICE and not a tuning "
"parameter, and it is the reason §6.2 chose share allocation over a "
"regulator/ballast arrangement, where the ballast unit's share is a "
"function of the shared measurement and the gain question comes back",
all(_same.values()))
check("...and it is not vacuous, because the SHARES really are different. An "
"allocator that ignored its weights would pass the line above trivially",
close_loop(1.0, 0.9) == close_loop(1.0, 0.5)
and plan(2000.0, TWO, weights={A: 0.9, Bee: 0.1}).shares
!= plan(2000.0, TWO, weights={A: 0.5, Bee: 0.5}).shares)
check("...and it holds at EVERY gain the schema accepts, not only at the one "
"the reference experiment uses",
all(close_loop(g, 0.9) == close_loop(g, 0.5)
for g in (0.05, 0.2, 0.4, 0.6, 0.8, 1.0)))
# THE SWEEP. AC 3 says the sweep is the check, not an argument.
_GAINS = (0.05, 0.2, 0.4, 0.6, 0.8, 0.9, 1.0)
_alloc_worst = max(p2p(close_loop(g, s, feedback=f))
for g in _GAINS for s in _splits for f in ("site", "unit"))
# ⚠️ THE BROADCAST RUNS ARE FED ONE INVERTER'S POWER, because that is what a
# broadcast INSTALL would have: the add-on binds a single `inverter_power`
# sensor, so an operator who wires up a second inverter has a loop that sees
# unit A only. Feeding it the site sum here would model an add-on that does not
# exist, and it would do so in the KIND direction - the sum tracks the command
# much worse at N=2, so the saturation freeze would fire and damp the very limit
# cycle this comparison exists to reproduce. sitesim.run_site() makes the same
# choice for the same reason, and the two agree to the watt: 40 W p2p at gain
# 0.8, 120 W at 0.9, 4000 W at 1.0.
_bcast = {g: p2p(close_loop(g, 0.5, mode="broadcast", feedback="unit"))
for g in _GAINS}
print(f" broadcast tail p2p by gain: "
+ ", ".join(f"{g}={v:.0f}" for g, v in _bcast.items()))
print(f" allocated, worst over {len(_GAINS)}x{len(_splits)}x2 "
f"(gain x split x feedback): {_alloc_worst:.0f} W")
check("⚠️ AC 3. SWEEPING THE GAIN TO 1.0 - WHICH config.yaml ACCEPTS AS ITS "
"DOCUMENTED MAXIMUM - AT EVERY SPLIT SHOWS NO LIMIT CYCLE. The tail "
"settles to a flat line at all 84 combinations - at BOTH the site-level "
"feedback the loop should have and the single-sensor feedback it has",
_alloc_worst == 0.0)
check("⚠️ AND THE SWEEP IS NOT VACUOUS, because the SAME sweep reproduces the "
"fault at the same gain. Broadcast at gain 1.0 is a permanent limit cycle "
"- this is ARCH-01's measured 4000 W peak-to-peak, rebuilt here from the "
"shipped control law rather than quoted",
_bcast[1.0] >= 3900.0)
check("...and the fault has an ONSET, which says it is a gain-margin failure "
"and not a bug in the rig: quiet at 0.6, visible at 0.9, unbounded at "
"1.0. An allocator that 'fixed' a cycle that was never there would show a "
"flat broadcast row",
_bcast[0.6] == 0.0 and 0.0 < _bcast[0.9] < _bcast[1.0])
check("...and the broadcast export overshoot at gain 0.6 - the half that has no "
"gain threshold at all - is present here too, at ARCH-01's measured "
"-400 W, while the allocated run does not export",
min(g for _t, g in close_loop(0.6, 0.5, mode="broadcast",
feedback="unit")[1:]) <= -400.0
and min(g for _t, g in close_loop(0.6, 0.5)[1:]) >= 0.0)
# ⚠️ THE ONE THAT DID NOT HOLD, AND DEV-02 IS WHAT CLOSED IT. This block was
# written as a DISCLOSURE - "the allocator is not the cause, and it is DEV-02's
# to close" - and it is kept, with its stimulus intact, because the defect it
# describes is exactly the thing a regression would restore. What has changed is
# only which of the two rows the SHIPPED add-on is on.
#
# THE DEFECT. There was one `batt_entity`, so at a roster of two `actual_w` was
# ONE unit's power used as a site measurement - the SENSING half of the same
# N=1 identity ALLOC-01 removed from the COMMANDING half. `compute()`'s
# anti-windup test is `abs(prev_w - actual_w) > saturation_w` with `prev_w` the
# SITE target, so that comparison put a site number against a unit number,
# tripped the saturation freeze permanently and left a standing grid error.
#
# MEASURED, on admin/goodwe's own two-master rig rather than only here
# (`tools/alloc_sim.py --splits --feedback unit`, 2 kW load, 40 cycles):
#
# gain 0.2 worst |delta| between splits 510 W
# gain 0.4 250 W
# gain 0.6 50 W
# gain 0.8 and above 0 W
#
# and with `--feedback site` - the counterfactual, which is what DEV-02 makes
# the shipped sensing - every split is bit-identical at every one of those
# gains. Both rows are still run below, because the comparison is the evidence.
_unit_fb = {s: close_loop(0.6, s, feedback="unit")
== close_loop(0.6, 0.5, feedback="unit") for s in _splits}
print(f" with the loop fed ONE inverter's power - the PRE-DEV-02 sensing - "
f"at gain 0.6: " + ", ".join(f"{int(s*100)}/{int((1-s)*100)}="
f"{'same' if v else 'DIFFERS'}"
for s, v in _unit_fb.items()))
_worst_unit = max(
max(abs(a[1] - b[1]) for a, b in zip(close_loop(g, s, feedback="unit"),
close_loop(g, 0.5, feedback="unit")))
for g in (0.2, 0.4, 0.6) for s in _splits)
print(f" worst split-dependence under that sensing, gains 0.2-0.6: "
f"{_worst_unit:.0f} W at the meter")
check("⚠️ THE DEFECT IS STILL REPRODUCIBLE, WHICH IS WHAT MAKES THE FIX BELOW "
"MEAN SOMETHING. Fed ONE inverter's power the aggregate trace at the "
"meter is a function of the split, by hundreds of watts below gain 1.0. "
"The allocator is not the cause - its shares sum to S in every one of "
"these runs - and a check that had DELETED this row when the sensing was "
"fixed would leave the next reader unable to tell a working loop from an "
"untested one",
not all(_unit_fb.values()) and _worst_unit > 100.0)
check("⚠️ AND DEV-02 PUT THE SHIPPED ADD-ON ON THE OTHER ROW: `actual_w` is now "
"the SITE SUM of the per-instance battery readings "
"(`Controller.site_batt_w`, pinned at main.py's call site by "
"test_inverter.py part 5), and every split is bit-identical again at "
"every gain. This is the line that goes red if anyone hands the loop one "
"unit's power again - which is the tempting shape, because it is what a "
"single `batt_entity` gives you",
all(close_loop(g, s, feedback="site") == close_loop(g, 0.5,
feedback="site")
for g in (0.05, 0.2, 0.4, 0.6, 0.8, 0.9, 1.0) for s in _splits))
check("...and the fix is not vacuous in the other direction either: the SHARES "
"still differ between splits under site feedback, so 'bit-identical at "
"the meter' is a statement about the aggregate and not about an allocator "
"that stopped allocating",
close_loop(0.6, 0.9, feedback="site")
== close_loop(0.6, 0.5, feedback="site")
and plan(2000.0, TWO, weights={A: 0.9, Bee: 0.1}).shares
!= plan(2000.0, TWO, weights={A: 0.5, Bee: 0.5}).shares)
# ==============================================================================
print()
print("9. the structural claim itself: the gain at the meter is a CONVEX "
"combination")
# ⚠️ THIS IS THE ARGUMENT THE WHOLE CARD RESTS ON, SO IT IS MEASURED. Give the
# two modelled units DIFFERENT per-unit gains - unit i delivers g_i of what it
# is told - and read the gain the site actually shows. Under broadcast it is the
# SUM. Under allocation it is a weighted average, so it lies between the two
# whatever the weights are.
def site_gain(g_a, g_b, split, mode):
told = 1000.0
if mode == "broadcast":
return (g_a * told + g_b * told) / told
p = plan(told, TWO, weights={A: split, Bee: 1.0 - split})
return (g_a * p.shares[A] + g_b * p.shares[Bee]) / told
_GA, _GB = 0.4, 1.0
_alloc_gains = [site_gain(_GA, _GB, s, "alloc") for s in (0.0, 0.25, 0.5,
0.75, 1.0)]
print(f" per-unit gains {_GA} and {_GB}: allocated site gain over splits "
f"0/25/50/75/100 = {[round(x, 3) for x in _alloc_gains]}; "
f"broadcast = {site_gain(_GA, _GB, 0.5, 'broadcast'):.3f}")
check("⚠️ FOR ANY SPLIT the gain at the meter lies between the two per-unit "
"gains. THAT is why a bad split cannot destabilise the site and why the "
"weights may be chosen for SoC, thermals and wear without anyone "
"re-deriving a stability margin",
all(_GA - 1e-9 <= g <= _GB + 1e-9 for g in _alloc_gains))
check("...and under broadcast it is their SUM, which is outside that interval "
"and grows with N. At two units of gain 1.0 that is 2.0 against a loop "
"whose config maximum is 1.0 - the 2.12 ARCH-01 measured, and the reason "
"the limit cycle has no upper bound",
site_gain(_GA, _GB, 0.5, "broadcast") > _GB)
check("...and the same holds for two units with the SAME gain, which is the "
"case that will actually ship: allocation leaves it at g, broadcast "
"doubles it",
site_gain(0.8, 0.8, 0.3, "alloc") == 0.8
and site_gain(0.8, 0.8, 0.3, "broadcast") == 1.6)
# ==============================================================================
print()
print("10. the envelope is read PER TICK, and the weights are not a constraint")
_moving = {A: B(-RATING, RATING), Bee: B(-RATING, RATING)}
_first = plan(2000.0, _moving)
_moving[Bee] = B(-RATING, 0.0) # B's pack hits its floor mid-run
_second = plan(2000.0, _moving)
print(f" same allocator object, envelope moved between ticks: "
f"{_first.shares} then {_second.shares}")
check("⚠️ THE SAME CALL WITH A MOVED ENVELOPE GIVES A DIFFERENT ANSWER, which "
"is what says nothing was cached at construction. There is no allocator "
"OBJECT to cache in - `plan()` is a pure function of this tick's "
"intervals - and an allocator holding a construction-time envelope "
"over-allocates into a unit that cannot take it",
_first.shares == {A: 1000, Bee: 1000}
and _second.shares == {A: 2000, Bee: 0})
check("...and the module holds no state at all, which is how that is enforced "
"rather than promised: nothing in it is written outside a function",
not [n for n, v in vars(sys.modules["app.allocator"]).items()
if not n.startswith("_") and isinstance(v, (list, dict, set))])
_zero = plan(4000.0, {A: B(-RATING, 1000.0), Bee: B(-RATING, RATING)},
weights={A: 1.0, Bee: 0.0})
print(f" weight 0 on B, but A's envelope is full: {_zero.explain()}")
check("a weight of 0 is honoured while the preferred unit has headroom",
plan(1000.0, TWO, weights={A: 1.0, Bee: 0.0}).shares == {A: 1000, Bee: 0})
check("⚠️ BUT IT IS A PREFERENCE, NOT A PERMISSION. When the preferred unit "
"fills up the rest goes to the zero-weight unit anyway, because the SUM "
"is the safety property and stranding capacity behind a comfort choice "
"would manufacture a standing error at the meter for the site integrator "
"to chase. To make a unit unusable, close its envelope",
_zero.shares == {A: 1000, Bee: 3000} and _zero.unmet_w == 0)
check("...and the record says that is what happened, rather than leaving a "
"reader to wonder why the weights were disregarded",
any(p.unit == Bee and "last resort" in p.why for p in _zero.plans))
check("a negative weight is REFUSED rather than normalised away - it is a "
"signed target wearing a comfort weight's clothes, and silently taking "
"its magnitude would allocate against the direction the caller asked for",
_raises_value(lambda: plan(1000.0, TWO, weights={A: -1.0, Bee: 1.0})))
# ==============================================================================
print()
print("11. the seam above: what resolve() hands stage 3, and the pin gate")
_res = resolve([Claim.set("loop", P_LOOP, 900.0),
Claim.limit("device", P_SAFETY, -2000, 2000, "rating",
actuator=A),
Claim.limit("supervised", P_SAFETY, -2000, 2000, "max_w",
actuator=A)],
units=(A, Bee))
print(f" {_res.explain()} -> {_res.allocation.explain()}")
check("resolve() carries the full record through, so SAFETY-28 has one place "
"to gate on and does not have to re-derive anything",
isinstance(_res.allocation, Allocation) and _res.allocation.ok
and _res.shares == _res.allocation.shares)
check("...and the priority stage 3 weighs a unit's own SET against is the "
"priority of the claim `explain()` NAMES, taken from stage 2 rather than "
"recomputed - so the two cannot drift apart",
_res.winner_priority == P_LOOP and _res.winner == "loop")
# THE PIN GATE. A unit's own winning SET competes with the site's.
_pin = {A: B(-RATING, RATING, gate=P_MAINTENANCE, pin=-1200.0, by="maintenance"),
Bee: B(-RATING, RATING)}
_low = plan(-3000.0, _pin, site_priority=P_LOOP)
_high = plan(0.0, _pin, site_priority=P_SAFETY)
print(f" maintenance pin vs the loop: {_low.explain()}")
print(f" maintenance pin vs a safety stop: {_high.explain()}")
check("a unit's own maintenance SET at P_MAINTENANCE outranks the grid-"
"following loop, so that pack does what its own cycle asked and the OTHER "
"unit carries the remainder of the site target. The shares still sum to S",
_low.shares == {A: -1200, Bee: -1800}
and sum(_low.shares.values()) == _low.sum_w)
check("⚠️ AND A SITE SAFETY STOP OUTRANKS THE PIN, which is the case that has "
"to be right: `safety` files a SITE set at P_SAFETY for 'stopped' and "
"'inputs missing', and a maintenance drain that survived it would be an "
"inverter this controller had stopped and then kept driving",
_high.shares == {A: 0, Bee: 0})
check("...and with no site priority named at all, the unit's own claim stands - "
"a caller that says nothing about the site has not said the site outranks "
"anything, and honouring the unit's highest-priority claim is the only "
"safe reading of silence",
plan(-3000.0, _pin).shares == {A: -1200, Bee: -1800})
# ⚠️ THE RESIDUAL, STATED RATHER THAN GLOSSED. `pre_resolve()` filters a unit's
# LIMITS by that unit's OWN gate, and clause 2 of the arbiter's rule says a
# limit binds only at or above the WINNER's priority - which at N>=2 may be the
# site's, chosen afterwards. Where the site winner outranks a unit's gate, a
# unit limit whose priority falls between them is applied more tightly than
# clause 2 strictly requires. Enumerated over what main.py can actually file:
_UNIT_LIMITS = {"device": P_SAFETY, "supervised": P_SAFETY,
"maintenance": P_MAINT_SHAPE}
_UNIT_SETS = {"failsafe": P_SAFETY, "maintenance": P_MAINTENANCE}
_SITE_SETS = {"loop": P_LOOP, "arbitrage": P_ARBITRAGE, "safety": P_SAFETY}
_affected = [(ls, lp, us, up, ss, sp)
for ls, lp in _UNIT_LIMITS.items()
for us, up in list(_UNIT_SETS.items()) + [(None, None)]
for ss, sp in _SITE_SETS.items()
# included by pre_resolve (no gate, or the gate does not exclude it)
if (up is None or lp >= up)
# but clause 2 against the SITE winner would have excluded it
and lp < sp]
print(f" unit-limit x unit-set x site-set combinations main.py can file: "
f"{len(_UNIT_LIMITS) * (len(_UNIT_SETS) + 1) * len(_SITE_SETS)}; "
f"tighter than clause 2 strictly requires: {len(_affected)}")
for _row in _affected:
print(f" {_row[0]}@{_row[1]} under {_row[2]}@{_row[3]} "
f"vs site {_row[4]}@{_row[5]}")
check("⚠️ THE ONLY COMBINATIONS AFFECTED ARE THE MAINTENANCE CHARGE-ONLY LIMIT "
"UNDER A SITE SAFETY STOP, and a safety stop resolves to 0 W - a "
"charge-only `hi` of 0 does not move 0. So the tighter reading is not "
"observable on anything main.py can file today. It is written down here "
"because a ninth source could change that, and because erring TIGHT is "
"the safe direction: a tighter envelope allocates less, never more",
all(row[0] == "maintenance" and row[4] == "safety" for row in _affected))
# ==============================================================================
print()
print("12. mutation: what defeats these guards, and which check bites")
MUT = []
def _largest_remainder_replaced_by_round(S, ws):
return {u: round(w * S) for u, w in ws.items()}
_m = _largest_remainder_replaced_by_round(1501.0, {A: 0.5, Bee: 0.5})
MUT.append(("largest remainder replaced by a per-unit round()",
sum(_m.values()) != 1501,
"part 2: 'largest remainder does not miss it, ever'"))
# The envelope clamp dropped: shares placed by weight alone.
_m2 = {A: 0.5 * 4000.0, Bee: 0.5 * 4000.0}
MUT.append(("the envelope clamp dropped - shares placed by weight alone",
_m2[A] > 1000.0 and plan(4000.0, {A: B(-RATING, 1000.0),
Bee: B(-RATING, RATING)}).shares[A]
== 1000,
"part 3: 'one inverter at its rating gives its surplus to the other'"))
# The liveness argument ignored - `live` never consulted.
MUT.append(("`live` ignored, so a stale master keeps being allocated into",
plan(1990.0, TWO, live=(A,)).shares != plan(1990.0, TWO).shares,
"part 5: 'a unit whose master is stale is REMOVED'"))
# The sign gate removed - the circulating case allocated instead of refused.
MUT.append(("the sign-coherence gate removed",
_circ.ok is False,
"part 6: 'opposite directions are REFUSED, not averaged'"))
# The N<=1 branch folded into the general path.
MUT.append(("the N<=1 pass-through folded into the general path (it would "
"round, and clamp)",
allocate(1500.4, {A: UnitBounds()})[0][A] == 1500.4
and allocate(9999.0, {A: B(-RATING, RATING)})[0][A] == 9999.0,
"part 7: 'a float, unrounded' and 'unclamped too'"))
# The allocator broadcasts - the ARCH-01 fault, rebuilt.
_bc = {u: 1990 for u in TWO}
MUT.append(("the allocator replaced by a broadcast - ARCH-01's own fault",
sum(_bc.values()) != 1990 and plan(1990.0, TWO).unmet_w == 0,
"part 1: 'the halves sum back to it', and part 8's sweep"))
# unmet_w folded into ok.
MUT.append(("`unmet_w != 0` folded into `ok`, so a saturated inverter parks "
"the site",
_unmet.ok is True and _unmet.unmet_w == 6000,
"part 3: 'a saturated site is not a broken allocation'"))
# The pin gate inverted: the site winner never outranks a unit SET.
MUT.append(("the pin gate inverted - a maintenance drain survives a site "
"safety stop",
_high.shares == {A: 0, Bee: 0},
"part 11: 'a site safety stop outranks the pin'"))
# scope_errors ignored by stage 3.
_mis = resolve([Claim.set("loop", P_LOOP, -1500.0),
Claim.limit("peak", P_PEAK, lo=1500.0, reason="q",
actuator=Bee)])
MUT.append(("`scope_errors` ignored by stage 3 - a mis-routed claim divided "
"anyway",
_mis.allocated is False and REFUSE_SCOPE in _mis.allocation.refused,
"test_arbiter.py part 8: 'stage 3 REFUSES to allocate on a claim "
"list it has been told is mis-routed'"))
MUT.append(("the site target TRUNCATED instead of rounded - a bias toward zero "
"on every tick",
max(map(abs, _q)) <= 0.5,
"part 2: '|quantisation| is never more than half a watt'"))
# ⚠️ THE ONE THAT SHIPPED, AND THE ROW THIS TABLE IS MOST INDEBTED TO. This
# mutation IS the code that was on `186e13c`, so it is not hypothetical: the
# question it answers is not "would a check catch it" but "why did none".
_collapsed = min(max(int(0), 2142), 0) # the old form, evaluated at hi=inf
MUT.append(("the two ends of `_clamp_int` collapsed back into one min(max()), "
"so a MISSING CEILING discards the FLOOR",
_collapsed == 0 and _clamp_int(0, 2142, inf) == 2142
and _half.ok is False,
"part 6: 'a one-sided envelope refuses exactly as the two-sided "
"one does', and part 1's replay - 39 991 envelope escapes over the "
"same 250 000 ticks. ⚠️ AND ZERO ESCAPES UNDER THE GENERATOR THIS "
"FILE ORIGINALLY SHIPPED WITH, which drew every envelope finite on "
"both ends and containing zero: the mutation was already live and "
"the table would have called it caught. The fix is graded by the "
"SHAPES the generator can express, not by the number of ticks"))
# ⚠️ AND NOW THE ONES THAT DO NOT BITE. Three of them, found by actually running
# the mutations rather than by reasoning about them, and each is reported with
# WHY - because a mutation table that only lists its successes is the same
# unverifiable claim as a green check that cannot fail.
MUT.append(("the FLOOR replaced by a round() before the leftover pass",
False,
"NOTHING - and it should not, because THE SUM IS NOT PROTECTED BY "
"THE FLOOR. It is protected by the integer leftover pass that "
"follows: whatever per-unit integerisation happens first, `debt = "
"remainder - sum(share)` is computed from it and handed out until "
"it is zero. Rounding first produces a DIFFERENT but still exactly "
"sum-invariant allocation. Largest remainder is what decides WHICH "
"unit is credited the leftover watt - the one that was shorted "
"most - which is a fairness property, not a safety one. Remove the "
"leftover pass itself and part 1 goes red, which is the row above"))
MUT.append(("the largest-remainder TIE-BREAK on the unit name dropped",
False,
"NOTHING - and by construction: `plan()` sorts the roster before it "
"does anything else and Python's sort is stable, so the order is "
"already by name when the tie-break is reached. The determinism is "
"delivered upstream; the explicit key is belt and braces. Part 2's "
"dict-order check is what actually grades the property"))
# ⚠️ THE ONE THAT DOES NOT BITE, AND IT IS STATED RATHER THAN HIDDEN.
MUT.append(("the WEIGHTS replaced by any other non-negative numbers",
False,
"NOTHING - AND BY DESIGN. That is AC 2: the aggregate trace at the "
"meter is identical for every split, so no check here can tell one "
"weighting from another by watching the site. The weights are "
"graded by what they do to the SHARES (parts 2 and 10), never by "
"what they do to the meter - and the fact that nothing at the "
"meter can see them is the property, not a gap"))
for _name, _bites, _where in MUT:
print(f" {'caught' if _bites else 'NOT CAUGHT':>10} {_name}")
print(f" {_where}")
check("the mutations that CAN disarm these guards are each caught by a check "
"above - a guard whose checks survive its own defeat is not a guard. "
"Every row here was RUN through tools/mutation_run.sh on a purged tree, "
"not reasoned about: three of them were reported as caught by an earlier "
"draft of this table and were not",
all(b for _n, b, _w in MUT[:-3]))
check("...and the three that are NOT caught are reported as such, with the "
"reason each one cannot be. Two are properties delivered somewhere other "
"than the line the mutation touches; the third is the acceptance "
"criterion itself - a weighting that changed the aggregate trace would be "
"a FAILURE of AC 2, and part 8 is the line that would go red",
all(b is False for _n, b, _w in MUT[-3:]))
print()
if fails:
print(f"{len(fails)} FAILED: {', '.join(fails)}")
sys.exit(1)
print("all checks passed")