Files
goodwe-addon/goodwe_controller/app/arbiter.py
T
glenn schrooyenandClaude Opus 5 38f6680639
checks / unit (pull_request) Successful in 3m45s
ARB-03: pin all eight actuator call sites statically
The independent review of PR #40 found AC 6 met at three of the eight claim
construction sites while the check claimed to cover the class - this
repository's named failure mode, a check observing something other than what
it claimed, reproduced inside the check written to prevent it.

WHAT WAS WRONG. Part 10g's only call-site check was a real two-inverter cycle.
That cycle is auto, P1-healthy, no maintenance procedure, no tripped sign
watchdog, the guard idle and arbitrage off, so it reaches `device`,
`supervised` and `loop` and nothing else - three of the eight direct appends in
`Controller.cycle()`. Its own check text nevertheless read "the line a
mis-typed `actuator_of(...)` at a call site goes red on", and the PR body's
mutation table presented it as covering the class. The reviewer proved the gap
on the shipped tree: `actuator_of("maintenance")` -> `actuator_of("peak")` at
the maintenance-shaping append left test_arbiter.py, test_peakguard.py and
test_arbitrage.py ALL GREEN, while at a roster of two it built a site loop of
-1500 W against the guard's site `lo` of 1500 W and the now-misrouted
charge-only `hi = 0` - `lo > hi`, arbiter clause 3, 0 W IN THE BILLED QUARTER,
SAFETY-25 §0a's fatal case from a one-word typo. The same mutation at the
`failsafe` append survived too. The runtime detector does fire -
`scope_violations()` names it - but it only logs; nothing graded it.

THE FIX, over machinery already present. Part 10f's AST scan already reads each
`claims.append(...)` in `Controller.cycle()`. All eight direct appends expose
their actuator statically as `actuator=self.actuator_of('<literal>')`, so 10g
now requires that literal to EQUAL the source the claim is filed under, read as
SOURCE TEXT rather than evaluated - `actuator_of("peak")` at the maintenance
append is the same VALUE at the shipped roster of one and a different string
here. That pins all eight routing sites, on the line the typo is written, in
every configuration and at every roster, exactly the way 10f pins the source
set. Two further lines keep the scan from going vacuous: every direct append
must be readable in that shape (a computed or hand-written actuator fails
there), and the ten appends must still account for 10f's eight sources.

The two INDIRECT appends carry no `actuator=` at the call site to mis-type.
`peak_claim` and `arb_claim` are routed where their claim is built, and are
pinned there - `peakguard.claim()` by the SWEEP_SITE sweep over every load x
elapsed x battery x SoC x floor combination, `arbitrage.claim()` by
test_arbitrage.py. 10g now says so at the line, and says which three of the
eight its dynamic cycle actually sees, rather than rounding up.

MEASURED, NOT ARGUED. Each of the eight direct appends mutated in turn to a
wrong actuator - device->loop, supervised->peak, failsafe->safety,
safety(stopped)->device, safety(inputs missing)->maintenance,
maintenance(SET)->loop, maintenance(shaping)->peak, loop->supervised - each on
a purged tree via tools/mutation_run.sh over all 17 files. All eight RED, every
one of them on the new line.

TWO SURVIVING MUTATIONS OF THE SAME CLASS, ALSO CLOSED.

* `resolve()` did not have to honour the roster it was given: `if units is
  None:` -> `if True:` left the whole suite green. Ignoring the argument makes a
  unit that filed nothing this cycle cease to exist - a site of two reads as a
  site of one and stage 3 passes the whole grid target through to the inverter
  that spoke, which is ARCH-01's fault with its own evidence removed.
  test_arbiter.py part 7 now grades it on the one case that separates the two, a
  SILENT unit. Mutation RED in test_arbiter.py and test_peakguard.py.
* main.py's N>=2 refusal was unreachable and ungraded: `if not
  resolution.allocated:` -> `if False:` left the whole suite green, so the right
  instinct was one refactor away from being deleted as dead code. The guard is
  KEPT - the alternative default is the ARCH-01 fault - and new part 10h grades
  it on the two-inverter cycle that already existed: the 0 W, the `failsafe`
  winner, the log line naming the roster size, and the write that follows.
  Mutation RED.

AND THE WORDING CORRECTED. main.py does not "refuse to write" on
`allocated=False`. It SUBSTITUTES a 0 W failsafe and then calls
`write_setpoint(0.0)` like any other cycle. 0 W is the correct
fail-toward-inaction direction and the log text is accurate about what it
refuses - to BROADCAST the site target - but "refuses to write" overstates the
code. Said as what it does, in the CHANGELOG, at the branch in main.py, and in
10h's own check text. `resolve()`'s docstring now says the roster argument has
to be HONOURED, which is not the same as existing.

17/17 green on a purged tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 03:46:40 +02:00

468 lines
24 KiB
Python

"""Who gets to command the inverter, and why.
Every strategy - the grid-following loop, the maintenance cycle, peak shaving,
the safety stops - stops writing the setpoint directly and instead returns a
CLAIM each cycle. This module resolves the claims into one number.
⚠️ THE REASON THIS EXISTS. Two controllers writing one actuator is the failure
this whole system is built to avoid: it is what the vendor controller did to the
reference site, and §4.4 of the project notes calls it out by name. Before this
module, "exactly one writer" was a convention held up by an if/else ladder and
careful reading. With an EV charger and a heat pump eventually wanting the same
battery, a convention is not good enough - so precedence is now a first-class
object with one rule and a printable explanation.
THE RULE, in full:
1. The highest-priority `set` claim wins. If there is no set claim at all, the
target is 0 W - fail toward inaction, never "hold the last value".
2. Every `limit` claim whose priority is >= the winning set's priority is then
applied. Most restrictive wins.
3. Contradictory limits (lo > hi) are a BUG, not a tie to break: command 0 W
and say so loudly.
Clause 2 is the interesting one. It is what makes "money outranks maintenance"
a consequence of the priorities instead of a special case somebody can forget:
the maintenance charge-only limit binds the loop, but a higher-priority peak
shaving claim simply is not bound by it.
⚠️ AND WHICH ACTUATOR IS A CLAIM ABOUT? THAT QUESTION HAS AN ANSWER AGAIN.
specs.md §6.1 declared `actuator: str` as the first field of `Claim` and the
implementation shipped without it, so every claim was implicitly about "the one
and only actuator" and `resolve()` returned one scalar. ARB-03 restores the
field, because §6.2 decided the site grid target is ALLOCATED across several
inverters and never broadcast to each of them - and an allocator needs a seam to
occupy that a single scalar does not have.
So resolution is now THREE STAGES, and they are separate functions so that a
test can construct a case at any one of them:
1. `pre_resolve()` - each inverter's OWN claims become an interval for that
unit. One unit's claims, one unit's bounds.
2. `site_resolve()` - the SITE claims become one grid target S. This is the
rule above, unchanged, and it is deliberately the same
code it was: at N=1 there are no unit claims, so this
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.
⚠️ 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
into the site claim list. So a unit `hi` and a site `lo` are never terms of the
same `lo > hi` test, and the contradiction clause below - which resolves to 0 W,
a battery parked inside a billed quarter - cannot be reached across two
inverters. That is DECISIONS/SAFETY-25-loop-soc-floor.md §0c, made mechanical:
not "guarded against", NOT EXPRESSIBLE. Filing a unit constraint as a site claim
to "keep it simple" is the whole bug, and `actuator_for()` is what stops a
strategy doing it by hand.
"""
from dataclasses import dataclass, field
from math import inf
# 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
P_MAINTENANCE = 60 # maintenance owns the actuator outright (drain, hold)
P_PEAK = 50 # capacity-tariff peak shaving - costs real money
P_MAINT_SHAPE = 40 # maintenance shaping the loop (charge-only)
# OPT-04. §10.1 priority 3 - price arbitrage. ⚠️ ITS POSITION IS THE SPEC, NOT A
# preference: P_PEAK (50) is ABOVE it so clause 2 lets the capacity-tariff floor
# clamp an arbitrage charge, which is §9.1's "give peak shaving priority over
# arbitrage whenever the two conflict" expressed as arithmetic rather than as a
# rule somebody has to remember. P_MAINT_SHAPE (40) is above it so a speculative
# spread cannot override the pack's balancing cycle. P_LOOP (10) is below it
# because trading against the meter is the entire point - the grid-following
# loop would otherwise cancel every charge the moment it created import.
P_ARBITRAGE = 30 # price arbitrage - trades against the meter on purpose
P_LOOP = 10 # ordinary grid-following
SET = "set"
LIMIT = "limit"
# --- actuators, and the two scopes a claim can have -------------------------
# ⚠️ §6.2: THE GRID CONNECTION IS AN ACTUATOR TOO. It is the one every inverter
# at the site shares, and two inverters regulating against one meter are two
# writers on it - which is the failure this module exists to prevent, one level
# up from the one it was built for. So it gets a name, and claims about it are
# filed on that name rather than on nothing in particular.
ACTUATOR_SITE = "site"
SCOPE_SITE = "site" # about the grid connection: one of these per site
SCOPE_UNIT = "unit" # about one pack, one inverter, one device
# ⚠️ THE CLASSIFICATION, AND IT IS THE REAL OUTPUT OF ARB-03. Each entry is read
# off what the claim is ABOUT at its construction site in main.py, not off which
# module builds it. Getting an entry wrong is not cosmetic: a per-inverter
# reserve filed as a site `hi` against the peak guard's site `lo` is `lo > hi`,
# clause 3, 0 W - the battery parked in the billed quarter that SAFETY-25's memo
# calls fatal. The reasoning is recorded here, at the line, because this table is
# where a ninth source will be classified and the reasoning is what a future
# reader needs in order to classify it correctly.
CLAIM_SCOPE = {
# --- SITE: about the grid connection ---------------------------------
# main.py: `Claim.set("loop", P_LOOP, decision.target_w, ...)`. compute()
# regulates `grid_w` - the P1 measurement - to zero. Its subject is the
# meter, and §6.2's whole architecture is ONE integrator on P1 whose output
# is then split. A site claim, and the one the split exists for.
"loop": SCOPE_SITE,
# main.py: `peakguard.claim(...)`. The quantity is the 15-minute average
# OFFTAKE AT THE GRID CONNECTION, which is what the Belgian capacity tariff
# bills. There is one quarter-hour peak per site, not one per pack.
"peak": SCOPE_SITE,
# main.py: `arbitrage.claim(...)`. It buys and sells at the grid against a
# day-ahead price. The price is a property of the connection, and the energy
# it trades crosses the meter.
"arbitrage": SCOPE_SITE,
# main.py: `Claim.set("safety", P_SAFETY, 0.0, "stopped" | "inputs missing")`.
# ⚠️ READ BOTH CONSTRUCTION SITES BEFORE MOVING THIS ONE. Neither is about a
# pack. "stopped" is the operator switching control off for the SITE, and
# "inputs missing" is the SITE's own telemetry gone - the meter this whole
# controller regulates against. Both must stop every inverter, and a site
# SET at P_SAFETY is how that is said once rather than N times.
"safety": SCOPE_SITE,
# --- UNIT: about one pack, one inverter, one device -------------------
# main.py: `Claim.limit("device", P_SAFETY, self.dev_min, self.dev_max,
# "rating")`. The numbers are read off ONE inverter's own reported min/max.
# Another inverter has another rating; there is no site rating.
"device": SCOPE_UNIT,
# main.py: `Claim.limit("supervised", P_SAFETY, -max_w, max_w, "max_w")`.
# `max_w` is the supervised cap on what this controller may WRITE to a
# setpoint entity - §11.3's "the actuator is capped below the inverter
# rating". It bounds a write to one device, like `device` does and for the
# same reason, so it is scoped like `device`.
"supervised": SCOPE_UNIT,
# main.py: `Claim.set("failsafe", P_SAFETY, 0.0, "sign disagreement")`.
# ⚠️ THE EVIDENCE IS SITE-WIDE AND THE REMEDY IS NOT, and that is why this
# entry needs its reasoning written down. SAFETY-02 compares P1 - a site
# measurement - against `standing`, THE COMMAND THIS CONTROLLER PUT ON THIS
# ACTUATOR. What it concludes is "the unit I am driving is pushing power the
# wrong way", and what it does is park that unit. At N>=2 the same evidence
# cannot be attributed to a sibling nobody here commands, so a site-wide
# remedy would be this watchdog stopping an inverter it never graded.
"failsafe": SCOPE_UNIT,
# main.py: `Claim.set("maintenance", P_MAINTENANCE, ...)` when the cycle owns
# the setpoint, and `Claim.limit("maintenance", P_MAINT_SHAPE, hi=...)` when
# it only shapes. §12's monthly recalibration is a PACK procedure - drain
# this pack to its floor, charge this pack to full, recalibrate this pack's
# coulomb counter. Two packs recalibrate on their own schedules.
"maintenance": SCOPE_UNIT,
}
def actuator_for(source: str, unit: str, units=()) -> str:
"""Which actuator a claim from `source` belongs on, given the site's roster.
⚠️ ROUTE THROUGH THIS, DO NOT PASS `actuator=` BY HAND. An unclassified
source raises `KeyError` here rather than defaulting to the site, which is
the one direction that is not safe to default in: a per-inverter constraint
that lands in the site claim list is exactly the `lo > hi` this card exists
to make unreachable. A ninth claim source has to be classified in
CLAIM_SCOPE before it can be filed, and test_peakguard.py part 10f is the
other half of that - it goes red on the line the ninth append is written.
⚠️ AND AT N=1 EVERY SOURCE ROUTES TO THE SITE. That is not the classification
being ignored, it is §6.2's identity: with one inverter the fabricated grid
reading and the battery setpoint are the same number, so the grid connection
and the only unit ARE the same actuator and there is nothing to allocate.
Splitting the list at N=1 would be a behaviour change on a live safety path -
the `failsafe` and `maintenance` SET claims would stop competing with the
site's for the winner, `explain()` would name a different source, and
`loop_is_following()` reads that name. The identity holds only at N=1, so
this function is the ONE place that stops relying on it, the day the roster
grows.
"""
if CLAIM_SCOPE[source] == SCOPE_SITE:
return ACTUATOR_SITE
return ACTUATOR_SITE if len(units) <= 1 else unit
def scope_violations(claims) -> list:
"""Claims filed on an actuator their source's scope does not permit.
A list of one-line complaints, empty when the routing is right. This is the
check `actuator_for()` cannot make on its own, because a caller can always
write `actuator=` by hand: it grades the claim list that actually reached the
arbiter, whatever built it.
"""
out = []
# ⚠️ THE SECOND TEST IS CONDITIONAL ON THE SITE HAVING NAMED UNITS AT ALL,
# and that is not laxity. At N=1 §6.2's identity holds and a per-inverter
# source filed on the site is CORRECT - it is what `actuator_for()` returns.
# The direction that produces `lo > hi` only exists once there is a second
# actuator for the constraint to have belonged to.
split = any(c.actuator != ACTUATOR_SITE for c in claims)
for c in claims:
scope = CLAIM_SCOPE.get(c.source)
if scope is None:
out.append(f"{c.source}: not classified in CLAIM_SCOPE")
elif scope == SCOPE_SITE and c.actuator != ACTUATOR_SITE:
out.append(f"{c.source}: a SITE claim filed on unit {c.actuator!r} - "
f"it would bound one inverter's share instead of the grid")
elif scope == SCOPE_UNIT and c.actuator == ACTUATOR_SITE and split:
out.append(f"{c.source}: a UNIT claim filed on the site while the "
f"site has named units - it can cross a site limit")
return out
@dataclass(frozen=True)
class Claim:
source: str
priority: int
kind: str = SET
value: float = 0.0 # for SET
lo: float = -inf # for LIMIT
hi: float = inf # for LIMIT
reason: str = ""
# ⚠️ specs.md §6.1 DECLARES THIS FIELD FIRST AND IT IS LAST HERE, ON PURPOSE.
# The spec's point is that a claim NAMES its actuator; the tuple position is
# not the point, and it is load-bearing for two things that are. Every
# construction in this repository passes `source` first, and
# test_peakguard.py part 10f reads `Claim.set(...)`/`Claim.limit(...)`'s
# FIRST POSITIONAL ARGUMENT off main.py's AST to pin the claim-source set -
# the tripwire that guards SAFETY-25's deferred option. Moving `source` off
# position 0 would silently blind that scan, which is this repository's named
# failure mode. So the field is restored where it costs nothing.
#
# ⚠️ AND ITS DEFAULT IS THE SITE, EXPLICITLY. Not None, not "". A claim that
# does not name an actuator is a claim about the grid connection and says so;
# "unspecified" is what the shipped code already had, and it is what made
# every claim implicitly about the one and only actuator.
actuator: str = ACTUATOR_SITE
@staticmethod
def set(source: str, priority: int, value: float, reason: str = "", *,
actuator: str = ACTUATOR_SITE) -> "Claim":
return Claim(source, priority, SET, value=value, reason=reason,
actuator=actuator)
@staticmethod
def limit(source: str, priority: int, lo: float = -inf, hi: float = inf,
reason: str = "", *, actuator: str = ACTUATOR_SITE) -> "Claim":
return Claim(source, priority, LIMIT, lo=lo, hi=hi, reason=reason,
actuator=actuator)
@dataclass(frozen=True)
class UnitBounds:
"""What one inverter's OWN claims say about that inverter's share.
STAGE 1's output, one per unit, and the thing ALLOC-01 consumes. It is an
INTERVAL plus the two facts an allocator cannot reconstruct from an interval:
* `pinned_w` - the value that unit's own winning SET asked for. A SET is not
a bound: `resolve()` CLAMPS a set value into the limits rather than
intersecting with it, and folding a 2500 W drain into `[2500, 2500]` would
turn "clamped to the rating" into "contradicts the rating". So it travels
beside the interval, not inside it.
* `gate` - the priority of that winning SET, or None. Clause 2 applies a
limit only at or above the winner's priority, and at N>=2 the winner may be
a SITE set chosen at stage 2, i.e. AFTER this. Stage 1 states the
constraint and records what it is gated on; deciding whether it applies is
stage 3's business. Guessing here is how a maintenance charge-only `hi`
would come to bind a peak discharge it does not outrank.
`contradiction` is a unit contradicting ITSELF - its own `lo` above its own
`hi`. It parks that unit and says so; it does not park the site, which is the
entire difference this card is about.
"""
lo: float = -inf
hi: float = inf
lo_src: str | None = None
hi_src: str | None = None
gate: int | None = None
pinned_w: float | None = None
pinned_by: str | None = None
contradiction: bool = False
def explain(self) -> str:
if self.contradiction:
return (f"CONTRADICTION within the unit ({self.lo_src} lo="
f"{self.lo:.0f} > {self.hi_src} hi={self.hi:.0f})")
span = (f"[{'-inf' if self.lo == -inf else format(self.lo, '.0f')}, "
f"{'+inf' if self.hi == inf else format(self.hi, '.0f')}]")
if self.pinned_by is not None:
return f"{span} around {self.pinned_by} -> {self.pinned_w:.0f} W"
return span
@dataclass
class Resolution:
target_w: float
winner: str # which source set the value
bound_by: str | None = None # which limit actually changed it, if any
contradiction: bool = False
considered: list = field(default_factory=list)
# --- the three-stage fields. ⚠️ APPENDED, AND DEFAULTED, ON PURPOSE: every
# existing construction of this type is positional in its first five fields
# and `target_w` still means exactly what it meant - the SITE grid target.
# main.py assigns it to `self.target` unchanged.
unit_bounds: dict = field(default_factory=dict) # stage 1, per actuator
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
def explain(self) -> str:
"""One line an operator can act on - this is the point of the module."""
if self.contradiction:
return f"CONTRADICTION between limits - commanding 0 W ({self.winner})"
if self.bound_by:
return f"{self.winner} -> {self.target_w:.0f} W, limited by {self.bound_by}"
return f"{self.winner} -> {self.target_w:.0f} W"
def pre_resolve(unit_claims: list) -> UnitBounds:
"""STAGE 1. One inverter's own claims -> that inverter's interval.
Clauses 1 and 2 of the rule, transposed: the unit's highest-priority SET
names what the unit wants and gates which of the unit's limits apply, and the
most restrictive of those limits gives the interval. Read UnitBounds on why
the SET value is not folded into the interval.
⚠️ THE INPUT IS ONE UNIT'S CLAIMS AND NOTHING ELSE. A site claim reaching
this function would become a bound on one inverter's share - the mirror image
of the fault this card removes, and the reason `resolve()` partitions rather
than filters twice.
"""
sets = sorted((c for c in unit_claims if c.kind == SET),
key=lambda c: c.priority, reverse=True)
gate = pinned_w = pinned_by = None
if sets:
gate, pinned_w, pinned_by = sets[0].priority, sets[0].value, sets[0].source
lo, hi = -inf, inf
lo_src = hi_src = None
for c in unit_claims:
if c.kind != LIMIT or (gate is not None and c.priority < gate):
continue
if c.lo > lo:
lo, lo_src = c.lo, c.source
if c.hi < hi:
hi, hi_src = c.hi, c.source
return UnitBounds(lo, hi, lo_src, hi_src, gate, pinned_w, pinned_by,
contradiction=lo > hi)
def allocate(target_w: float, bounds: dict) -> tuple:
"""STAGE 3. THE SEAM. Returns `(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.
What it does today, and why each half is what it is:
* 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.
"""
if len(bounds) <= 1:
only = next(iter(bounds), ACTUATOR_SITE)
return {only: float(target_w)}, True
return {}, False
def site_resolve(claims: list) -> Resolution:
"""STAGE 2. The SITE claims -> one grid target.
⚠️ THIS FUNCTION IS THE SHIPPED ARBITER, UNCHANGED. It is a separate name and
the same code, deliberately: at N=1 nothing is filed on a unit, so `resolve()`
hands it the whole claim list and it answers what it has always answered.
Every check in test_arbiter.py, test_watchdog.py, test_validate.py and
test_peakguard.py replays through here unedited, which is the proof that
ARB-03 changed no behaviour on the live path.
"""
sets = sorted((c for c in claims if c.kind == SET),
key=lambda c: c.priority, reverse=True)
if sets:
winner = sets[0]
else:
# No strategy asked for anything. That is not "carry on as before" - the
# inverter holds its last command forever, so silence must mean zero.
winner = Claim.set("failsafe", P_SAFETY, 0.0, "no claim was made")
lo, hi = -inf, inf
lo_src = hi_src = None
for c in claims:
if c.kind != LIMIT or c.priority < winner.priority:
continue
if c.lo > lo:
lo, lo_src = c.lo, c
if c.hi < hi:
hi, hi_src = c.hi, c
considered = [f"{c.source}:{c.kind}" for c in claims]
if lo > hi:
return Resolution(0.0, winner.source, bound_by=None,
contradiction=True, considered=considered)
target = max(lo, min(hi, winner.value))
bound_by = None
if target != winner.value:
binder = lo_src if target == lo else hi_src
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)
def resolve(claims: list, *, units=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.
⚠️ 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
to exist: a site of two reads as a site of one and stage 3 passes the whole
grid target through to the inverter that spoke, which is ARCH-01's fault
with its own evidence removed. That mutation survived the whole suite until
test_arbiter.py part 7 graded it on a SILENT unit.
⚠️ THE PARTITION IS BY `actuator`, AND IT IS A PARTITION, NOT A FILTER. Every
claim lands in exactly one stage. That is the whole structural argument: a
unit's `hi` and the site's `lo` are never both terms of the same `lo > hi`
test, so SAFETY-25 §0a's cross-inverter contradiction - 0 W inside the billed
quarter - is not something to guard against, it is something that cannot be
written down.
"""
site_claims = [c for c in claims if c.actuator == ACTUATOR_SITE]
if units is None:
units = {c.actuator for c in claims if c.actuator != ACTUATOR_SITE}
units = tuple(sorted(units))
# STAGE 1 - each unit's own claims become that unit's interval.
bounds = {u: pre_resolve([c for c in claims if c.actuator == u])
for u in units}
# STAGE 2 - the site claims become the grid target. At N=1 `site_claims` IS
# `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
out.scope_errors = scope_violations(claims)
return out