15 Commits
Author SHA1 Message Date
glenn schrooyenandClaude Opus 5.5 1c41a1e412 SAFETY-86 review: hold the site at the peak floor while a HOLD gives
checks / unit (pull_request) Successful in 24m23s
infra-pin / fresh (pull_request) Successful in 1s
At N>=2 the holding pack's pin follows the guard's floor while the loop
still asks for +5000 W, so the ALLOCATOR "could not be placed" row was
re-logged on every 1 W change and filled the 40-row action log. While the
pin is raised, cycle() now files a site `hold` ceiling at the floor
(P_MAINTENANCE), as SAFETY-84 does for a charge. Filed only when the
holding pack gives; hi == the floor, so it never clashes with it.

Tests: a noisy N=2 house logs no ALLOCATOR row and at most one clamp
row (20 and 20 without the ceiling); the ceiling is the floor itself;
a CHARGE is not refiled at N=1. The DECISIONS entry records the owner's
2026-10-01 decision: the HOLD ends on its clock, no top-up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 02:00:20 +02:00
glenn schrooyenandClaude Opus 5.5 43a52e7f60 SAFETY-84: maintenance charge at N=2 no longer stalls
checks / unit (pull_request) Successful in 23m57s
infra-pin / fresh (pull_request) Successful in 2s
At N>=2 a cheap-window maintenance charge files a site `hi` (`charge`,
P_MAINT_SHAPE) equal to what the charging pack's own `hi` places, so the
other pack never has to give against it. The charge is sized to the
quarter's headroom under the peak aim so it yields to the peak guard, and
to 0 W under a site safety stop. N=1 byte-identical (safety84_identity).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 19:47:15 +02:00
Claude 5d27f8496f SAFETY-78: maintenance drain at N=2 no longer stalls
checks / unit (pull_request) Failing after 17m13s
infra-pin / fresh (pull_request) Successful in 2s
At two inverters the drain pins its pack and the site target is split
around the pin. Below the pin the other pack had to charge, the allocator
refused the opposite directions, and the site sat at 0 W with an ARBITER
line every cycle (house below or above the drain rate, or a grid charge).

main.py now files a site limit `drain` (lo = what the pin places, at
P_MAINTENANCE) at N>=2 while a pack drains, so the other pack only adds
power in the same direction. A drain that cannot give power waits with
one log line. With battery modes on, no grid charge while a pack drains,
and the other battery's card says why. N=1 is byte-identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 19:52:51 +02:00
glenn schrooyenandClaude Opus 5.5 ffc9a4d721 OPT-11 P1 review: a mode never beats the peak floor; SAFETY-03 kept at the rail
checks / unit (pull_request) Successful in 11m4s
infra-pin / fresh (pull_request) Successful in 2s
- arbiter: a mode now gives way only as far as a site limit above P_MODE
  needs (_unit_modes at N>1, _site_mode at N=1). Before, the modes were
  released only when the peak floor bound S, so a trade won above the floor
  kept them and placed less than the floor (an Off battery at N=1 went to 0 W
  under a 2000 W floor), and a capped grid charge released an Off battery.
  While released, held batteries weigh 0 and carry only the shortfall.
- control: a side of the loop bound that reaches the rail is left to the
  rail, so SAFETY-03's i_clamped still fires with every battery on.
- opt11_identity: add maintenance scenarios and a 6000-case arbiter digest
  (peak floor, trades, maintenance, refusals); a5dddad digests pinned.
- tests: test_arbiter part 11, test_control (g), test_modes (0/1 refused,
  loop bound counts live units only). DOCS and DECISIONS say the new rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UBtqPrG1mDLXzTEBnhEkkV
2026-09-28 19:41:36 +02:00
Claude dev agent d69fd8fbfd OPT-11 P1: per-battery mode limits, on/off, solar order
checks / unit (pull_request) Successful in 10m59s
infra-pin / fresh (pull_request) Successful in 1s
A battery's mode is a soft unit limit (source `mode`, P_MODE 35) kept
apart from the hard limits in pre_resolve(). The allocator applies it
only while the site target is decided at or below 35 (new
Resolution.deciding_priority), so the peak floor releases it and a mode
`hi` never meets the site `lo`. At N=1 resolve() applies the same rule
to the site target after stage 2. A mode range without 0 W is dropped
as a software fault, so REFUSE_SIGN stays unreachable from a mode.

compute() gets the loop bound (sum over live units of hard limits with
modes applied), so the loop never asks for watts no battery may give
and the saturation freeze never parks on them. While charging, Leftover
solar weighs 0 so Solar first fills first. Store keys pack_use and
pack_mode per battery, set by two new /action values; Off stops and
skips maintenance with a log line. Minimal Batteries cards (EN/NL).

Off by default (battery_modes_enabled); off, commands are byte-identical
to a5dddad at N=1 and N=2 (test_modes.py part 1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 18:51:29 +02:00
glenn schrooyenandClaude Opus 5.5 016ed5a5a3 SAFETY-73: a battery that refuses, per inverter, with two or more
checks / unit (pull_request) Successful in 10m1s
infra-pin / fresh (pull_request) Successful in 1s
SAFETY-52's hold applied per unit at N>1 (owner, 2026-09-27, option R).
A unit that gives < 100 W for 12 cycles in one direction while asked
> 200 W, or trips its stale-reading gate on a ~0 W reading, gets a
P_SAFETY `refused` unit limit closing that direction, so the allocator
hands its share to the others within their own limits. Probe: a +-200 W
pin every 15 min, one unit at a time, only while the site asks 200 W that
way. All units refusing: the site law holds 0 W. Exits reseed the site
target from the wire. INFO plus one WARNING (REFUSES_ABOVE_CUT_OUT) above
the configured cut-out; SAFETY-42/43 no longer report a refusing unit.
N=1 unchanged.

test_refusal_units.py: cases a/b/c on flat and jitter registers against
the old behaviour, probes, recovery, exits; mutations U1-U13 all red.
TEST-19 no-fault campaign: 0 engagements in 5280 runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 00:13:26 +02:00
glenn schrooyenandClaude Opus 5 f8e501393f OPT-09: the weights parameter nothing ever passed, and what it cannot do
`allocator.plan()` has taken a `weights` argument since ALLOC-01 and NOTHING
had ever passed one - main.py called `resolve()` without it and the arbiter
defaulted it to 1.0 per unit - so the site target was split 50/50 regardless
of what either pack could do. Two things now feed it, and they are different
kinds of thing:

  * a `pack` UNIT claim at P_SAFETY that CLOSES a unit's envelope at its own
    `bms_soc_floor` or at 100 % - a PERMISSION, obeyed absolutely, so an empty
    pack is not asked to discharge and its share goes to the sibling;
  * SoC-aware weights by ENERGY headroom in kWh - a PREFERENCE, which
    `_fill()`'s last-resort pass will override the moment the sibling saturates.

Measured (test_packshare.py part 8): at a load one pack cannot cover, the
weighted arm and the shipped arm command the IDENTICAL watts into an empty pack
and deliver the IDENTICAL watts. Only the envelope stops the ask, and what it
buys is not delivery - it is a reported `unmet_w` where the record used to read
impeccable. Weights are a comfort choice for STABILITY and NOT for DELIVERY.

Also: an optional per-entry `capacity_kwh` (schema change, all-or-nothing across
the roster, rule 9 refuses a partial one); per-pack arbitrage sizing, with the
sell side's no-op stated rather than hidden; and a 0 W trade refused by name,
because a 0 W arbitrage claim is a park order that outranks the loop.

SAFETY-42 and this card meet at one hazard from opposite sides - OPT-09 stops
the site ASKING a pack whose SoC says it cannot deliver, SAFETY-42 alarms when a
pack that said it could does not - and its end-to-end fixture moved as a result.
Its finding is unchanged and is now stated on the two quantities its rejected
discriminator actually reads.

Not filed at all at a roster of one: at N=1 every source routes to the site, so
the interval would bound the SITE TARGET, and SAFETY-25's decided Option A
stands untouched on the live install.

⚠️ INVERTER B DOES NOT EXIST. Its 4600 W / 20.48 kWh / 10 % are FROM DATASHEET
AND SERIAL, and `sim/plantsim.py`'s INVERTER_B is inverter A copied
(`measured=False`), so every two-inverter figure here is arithmetic or two
copies of A. No hardware was touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-06 22:55:20 +02:00
Claude Opus 5 e2efc765af SAFETY-38 round 4: the enumeration covered one sink, and it was not the one this codebase uses
checks / unit (pull_request) Successful in 5m12s
The scanner's two censuses between them scored ONE syntactic sink and called it
the family. Every one of census B's 16 members was a sentence that is APPENDED,
ASSIGNED to a `reason`-shaped name, or passed as a LOG ARGUMENT. Not one was
RETURNED - and census A's return bucket only ever held a callee formatting its
own PARAMETERS, so a method formatting its own ATTRIBUTES and handing the string
back was invisible to both. That is the whole describe() / explain() /
reason-builder family, which is where this repository actually keeps its
operator sentences. Worse than the gap: it was in NONE of the five blind spots
the tool printed about itself.

THE SCANNER, WIDENED - and it rediscovered both members without being told:

  * CENSUS C, the return sink, resolved through the returning method's CALL
    SITES so a returned KEY (store.unit_key, mqtt.unit_state_key - MAINT-05's
    card) is not mistaken for a returned SENTENCE. 53 returned pairs, 39
    reaching an operator, 16 of those sharing a format spec.
  * A sentence handed to a constructor as `reason=` or placed under a "text"
    dict key is a MESSAGE. That one line of the sink walker was hiding SIX more
    caller-built members, census B 16 -> 22.
  * Every census B and C row now prints whether the two interpolations share a
    FORMAT SPEC - the difference between a transposition that is silent and one
    that merely looks broken.
  * The blind-spot list is now TWO lists: what it scans (printed from the
    constants, so they cannot drift) and WHAT IT DOES NOT SCAN AT ALL, largest
    hole first - `%s`-style lazy logging, MEASURED at 31 calls across 8 modules
    and each one listed, not asserted.

CLOSED, both zero-byte survivors of a green 21-file suite:

  * clamp.py:253 ClampResult.describe(), requested_w <-> applied_w. No
    test_*.py mentioned describe() at all. It is the SS15 action-log row
    (main.py:1366) AND the ClampWatchdog notification reason (main.py:2452) -
    the alarm SAFETY-40 and SAFETY-41 are about. Under the swap the guard
    reports a setpoint clamped UPWARD, to +9999 W by a bound whose rating the
    same sentence gives as 2000 W. Pinned on the ORDERED PAIR on the contiguous
    literal at BOTH consumers, operands read off ctl.target and
    hass.writes[-1] - what the loop asked and what the device was sent - not
    off the ClampResult the swap lives in. 4 checks red.
  * peakguard.py:493 PeakDecision.explain(), projected_w <-> ceiling_w. "peak
    guard idle" was in no test_*.py. The row then reads "projects 5013 W under
    a 2300 W ceiling" - 2713 W OVER - on a row whose own words say idle and
    whose own flag says ok. Pinned on the decision and on the shipped status
    row. 3 checks red.
  * test_arbiter.py's scope-violation check, whose CLAIM was wider than its
    ASSERTION. Measured this round rather than left at source level: 0 bytes,
    21/21 green before, red now. 2 checks.

DISCLOSED, NOT CLOSED - 11 measured zero-byte survivors, each with its file
fingerprint and its 21/21 verdict, in the decision doc SS7e-SS7g. Three in
allocator.py's UnitPlan.explain(), three across arbiter.py's two explain()s,
two in tariff.py, two in arbitrage.py's trade reasons, one in main.py's
SoC-floor notification. An enumerated list with honest gaps beats a fifth round
finding a fifth instance.

CORRECTIONS THIS ROUND OWES:

  * The idle sentence does NOT reach main.py:3299/3302 - those are the
    floor-limited branch, where explain() returns self.reason before the idle
    literal is built. It reaches main.py:3313 and, on the release edge, 1951.
    1951 is disclosed unpinned.
  * SS4's "Every one of the ten is red" sat under a twelve-row table: it is the
    ten site_standing entries.
  * SS6b said "weaker evidence than A1-A3"; A4 was measured, so SS7d's
    "A1-A4 and B1" is the right list and SS6b now says it.
  * AND THE PROBE THAT GRADES THESE MUTATIONS WAS THE DEFECT, ONCE. Its first
    cut numbered the operands BY ORDER OF APPEARANCE, so after a transposition
    the other leaf took the same number and all thirteen mutant renders came
    back byte-identical - which reads exactly like "the edit is inert". Keying
    the value to the NAME fixed it. Habit 4, in the measuring instrument:
    MUTATION.md habit 16 is the new entry, and the count there is corrected to
    eleven rather than inflated.

14 mutations measured: fresh full-repo copy each, mounted :ro, bytecode purged
and the purge COUNT printed, the edit re-read from disk, length and sha256
printed on both sides, and every one required to change the EMITTED SENTENCE
and not merely the bytes. 12 survived the pre-round suite, 1 was already
caught, 3 are caught now. Full 21-file suite green on the final tree.

NOT ONE BYTE OF app/ MOVES. This card changes coverage only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-05 22:38:40 +02:00
adminandClaude Opus 5 f000547518 SAFETY-30: grade each inverter against its own power, and the site's total against the meter
checks / unit (pull_request) Successful in 4m38s
THE DEFECT, in one line. `inv.sign_wd.evaluate(stood, self.grid, ...)` compared
the command THIS controller put on ONE actuator against what the WHOLE SITE is
exchanging with the network. At a roster of one those are two views of the same
thing - specs.md §6.2's identity - and that is what makes the check sound and
what earned it the best record in the safety table (NOTES.md: 93 % agreement
while healthy against 4 % during runaway, four observed episodes, zero false
positives). At two inverters they are not, and it fails in BOTH directions:

  FALSE TRIP  inverter A commanded -1500 W while the meter reads +1500 W is
              evidence of nothing when inverter B is discharging 3000 W exactly
              as told. Nothing is faulty and the highest-value check fires.
  MISSED      the converse, and it is worse. A unit commanded +1500 W and
              MEASURED at -1500 W, beside a sibling at +1500 W, leaves P1
              exactly where a healthy site leaves it. The summation destroys the
              evidence and no threshold recovers it.

DEV-02 had already shipped the per-INSTANCE half - one SignWatchdog per
inverter, namespaced latch, a trip on B no longer latching A. What was left was
the COMPARISON, and it is two questions rather than one:

  PER UNIT  this unit's standing command against ITS OWN measured power
            (`Inverter.batt`, holding register 0x0518, per-instance since
            DEV-02). Conclusion: "the actuator I am driving is not doing what I
            told it" - a fact about one device, so the remedy stays the per-unit
            `failsafe` SET that arbiter.CLAIM_SCOPE has classified SCOPE_UNIT
            from the day it was written. That entry read "the evidence is
            site-wide and the remedy is not"; the first half is now simply true.
  SITE      the SUM of the standing commands against the meter, as a new
            `sign_site` SET at P_SAFETY on the site actuator, latched under flat
            `site_sign_tripped` / `site_sign_reason`. This is the 2026-08-20
            fault day and NO per-unit check can see it, because every inverter
            may be faithfully executing its share of a number that is itself
            wrong. It stops every inverter and the banner names the site.

WHAT IT IS NOT, said here because two guards that overlap without saying so is
how a false-alarm generator gets built. It is not SAFETY-31: circulation.py
compares the units AGAINST EACH OTHER and catches them pushing power THROUGH
each other, which nets to zero at the meter and about which no single command is
evidence. This compares each unit against ITS OWN COMMAND. They overlap in
exactly one scenario - a unit reversing while its sibling pulls the other way -
and test_watchdog.py asserts on that fixture that both fire and what each
concludes. It is also not the saturation freeze, which is a MAGNITUDE test
(|command - measured| > 500 W) that shapes the loop and fires on a merely
saturated unit; this is a DIRECTION test that latches and parks one.

THE FOUR NUMBERS, RE-DERIVED AT TWO INVERTERS - one carried with a justification
and one refused. 1890 closed-loop runs on admin/goodwe's TEST-19 two-master rig
in which NOTHING is faulty, swept over the whole range config.yaml accepts for
`gain` and `slew_w`, across load reversals with write staggers and flapping
loads, and RUN AT BOTH ROSTERS so the N=1 arm is the control:

  unit_sign_disagree_w  250   `saturation_w / 2`. The check gates on both sides,
                              opposed, so the smallest gap it can see is 500 W -
                              exactly where compute()'s own anti-windup test
                              already says the inverter is not following. It
                              cannot fire on a divergence the control law calls
                              ordinary tracking. Worst legitimate opposition at
                              250 W: 10.0 s; the curve is nearly flat, because a
                              unit's own power chases its own command.
  unit_sign_disagree_s   85   8.5x that 10.0 s, 2.8x the firmware's own 30 s
                              link veto (inverter.LINK_STALE_S), under half the
                              180 s site hold.
  site_sign_disagree_w  300   CARRIED. Its argument is the meter's noise floor
                              and the 15 W deadband; the roster touches neither,
                              and the campaign agrees - the gate opens in 994 of
                              1050 runs at two inverters and 955 of 1050 at one.
  site_sign_disagree_s  325   NOT CARRIED, and this is the number the card
                              exists to not assume. The worst legitimate
                              crossing at 300 W is 110 s with one inverter and
                              215 s with TWO, because the command must travel a
                              rail that is the roster sum at a `slew_w` that is
                              not - reachable at gain 0.05, slew_w 50 and a
                              2.2 kW load reversal, all schema-legal. 180 s
                              would have shipped a false trip. 325 s is 1.51x
                              the measured worst, inside the 900 s billing
                              quarter and inside every episode of the fault day.

AT ONE INVERTER NOTHING CHANGES, and "nothing" is asserted rather than assumed.
The two questions are the same sentence there, so only the detector that shipped
is armed: same comparison against the meter, same 300 W / 180 s, same reason
string byte for byte, same namespaced latch and flat rollback mirror, same
`failsafe` claim, same banner, same single status row - and the site detector is
NEVER CALLED, counted on the object over twelve cycles.

VERIFIED BY BREAKING IT. Thirteen mutations on fresh per-mutation trees with no
__pycache__, mounted :ro, with the battery refusing to report at all unless the
baseline is green. Twelve red on the first pass. M4 - the site half armed at N=1
as well - SURVIVED, because the check read `disagree_since is None` and the unit
detector trips first, parks the inverter and leaves the site standing at 0 W:
the assertion ran, the sentence was true, and the data could not reach the
failure it named. Replaced with a call counter on the object; M4 is red now.
Recorded as a survivor in DECISIONS/SAFETY-30-per-unit-sign.md §5, with the
first battery's own RED BASELINE - a shared mutation directory read mid-copy -
discarded rather than reported.

RECORDED, NOT FIXED: the site comparison is defeated by a square-wave load whose
half period is about one control cycle, which holds it opposed for ~1000 s. The
N=1 control arm is what makes that interpretable - it is identical at the roster
this add-on ships, so it is a property of comparing a lagged command against a
meter and has nothing to do with the second inverter. It wants an alternation
test rather than a number, and a card of its own.

SAFETY-24's lo-only asymmetry proof re-run unchanged: 1296 combinations, 0
negative floors, `hi` +inf on every claim, every claim on the SITE actuator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-04 09:33:20 +02:00
adminandClaude Opus 5 7ef3d23245 SAFETY-31: measure circulating power, which nets to zero at the meter
checks / unit (pull_request) Successful in 4m28s
Inverter A charging at 3 kW while inverter B discharges at 3 kW nets ~0 W at
P1. The site reads idle while both packs are cycled through two round-trip
conversion losses continuously, for no benefit, and the wear is real.

NOTHING EXISTING CAN SEE IT, AND THAT IS STRUCTURAL. Every guard in this
add-on is a consumer of the NET figure at the grid connection: SAFETY-02's
sign watchdog gates on abs(grid_w) > 300 and the gate never opens at a netted
zero; the peak guard and the capacity tariff both bill grid exchange.
Circulating power is precisely the component of per-inverter power that the
sum destroys, so no threshold on any existing detector could reach it.

WHAT ALLOC-01 ALREADY COVERED, AND IS NOT RE-IMPLEMENTED HERE. Its AC 7
shipped the sign-coherence gate (allocator.REFUSE_SIGN, graded by
test_alloc.py part 6): an allocation whose shares carry two signs is REFUSED,
both units named, no shares returned. That is the COMMAND half - the allocator
can no longer command circulating power. This commit is the MEASUREMENT half:
circulation the allocator NEVER COMMANDED.

  app/circulation.py   the sensor and the trip
  main.py              site_circulating_w, the evaluation, the claim, the alarm
  arbiter.py           `circulation` classified SCOPE_SITE, with the argument
  store.py             a flat, site-level durable latch
  mqtt.py              sensor.goodwe_circulating_power
  config.yaml          two options, two schema ranges, CFG-03 discipline

THE SENSOR: sum(|Bi|) - |sum(Bi)|. Zero when the units pull the same way,
2*min(|Ba|,|Bb|) when they oppose. DERIVED FROM MEASURED PER-INVERTER POWER -
Inverter.batt, register 0x0518 - and never from the allocation record. The
cheap version would be simpler and worth nothing: ALLOC-01's gate already
guarantees the record is sign-coherent, so a record-derived sensor reads zero
BY CONSTRUCTION and can only confirm that the allocator agrees with itself,
which is the third failure mode MUTATION.md names. What this catches is a unit
ignoring or lagging its share, a stale share still being executed, a
maintenance or SAFETY-25 pin holding one unit while the loop drives the other,
or HW-01's power stage running its last instruction for 6 h 25 m with the
comms dead and the allocation record impeccable throughout. batt became
per-instance in DEV-02; before that this could not have been written.

VERIFIED BY MAKING ONE UNIT IGNORE ITS SHARE, on the TEST-19 two-master rig:

  S = -2500 W   shares a = -1250 W  b = -1250 W   <- record ok, ONE sign, sums
  MEASURED      A = -1250 W  B = +1000 W          <- hardware
  sensor over the RECORD     :    0 W
  sensor over MEASURED power : 2000 W  -> trips at 190 s

and the card's own scenario, A -3000 W against B +3000 W at zero house load:
the meter reads +0 W, the sensor reads 6000 W, and the shipped SignWatchdog's
predicate is FALSE on 30 of 30 cycles.

WHAT "ACTED ON" MEANS - decided, and recorded in DECISIONS/. The trip files
Claim.set("circulation", P_SAFETY, 0.0) on the SITE actuator: every inverter
to 0 W, latched. HW-01 established that commanding a wedged inverter achieves
nothing, so the remedy is not aimed at it. A circulating loop needs TWO
participants and one of them is THIS CONTROLLER, faithfully driving the
listening sibling into the one that stopped listening. Stopping (1) halves the
loop, which needs no cooperation from the wedged unit, and (2) - the more
important half - makes the residue VISIBLE at the meter instead of cancelled
at it, where every ordinary guard can finally see it. What it cannot do is
stop the wedged unit; the notification says so and names the isolator as the
last actuator. SITE-scoped because the quantity is defined over the roster and
is identically zero for any one unit - there is no fact of the matter about
which of two opposing inverters is the offender, so filing it per-unit would
invent an attribution and could park the only unit still listening.

THE WINDOW IS FROM THE RIG, NOT A ROUND NUMBER (AC 3). 924 closed-loop runs in
which NOTHING is faulty, over both families of legitimate opposition (7
reversals x 5 write staggers; 7 flap periods x 4 amplitudes), swept across the
whole range the schema accepts for gain and slew_w. Worst CONTINUOUS
legitimate opposition by threshold: 505.0 s at 100 W, 40.0 s at 250 W, 10.0 s
at 300 W, 5.0 s at 400 W and above. The knee is 400 W and it is the schema
floor - below it, ordinary operation holds the sensor over the line for
minutes and no window inside the 900 s billing quarter could tell that from a
fault. circulation_w 700 is 1.75x the knee, the headroom bought for inverter
B's ramp and lag being PLACEHOLDERS until SPIKE-04 measures them.
circulation_hold_s 75 clears three bounds: 15x the worst legitimate episode,
2.5x the firmware's own LINK_STALE_S veto, 12x inside the quarter. A momentary
opposition while one unit slews through zero is LEGITIMATE - writes are
serialised, so B's number lands up to a cycle after A's - and 0 of the 924
runs trip.

THE ALARM SURVIVES AN HA RESTART (SAFETY-34). persistent_notification is in
memory, which is exactly how the 2026-09-02 outage went unseen for 6 h 25 m
with the detector correct throughout. The latch is persisted to /data as flat
site-level keys and the notification is RE-RAISED on the first cycle after
every start, through the same code path as a fresh trip - two paths would be
two chances for the restart one to rot unnoticed.

AT N=1 NOTHING CHANGES (AC 5). |B| - |B| is 0 for every reading including one
nobody took, no claim is ever filed, the arbitration is byte-identical, and
the status page says n/a rather than claiming to watch something that cannot
happen.

VERIFIED BY BREAKING IT (AC 4). 18 mutations, all RED on named checks, each on
a freshly copied tree with no __pycache__ mounted :ro. M10 - the sensor
rewritten to read self.allocation.shares - is the headline: it takes AC 2 red.
Three mutations are +0 B, the risky delta, and are marked as such. One
equivalent mutant (the max(0.0,...) guard) is ARGUED rather than claimed:
searched over 700 000 rosters without a negative. One genuine SURVIVOR was
found and closed - publish() turning an unknown into a published 0 W, the
sensor's own None rule defeated one layer out. Full ledger in DECISIONS/.

arbiter.CLAIM_SCOPE gains its ninth source, so test_peakguard.py's 10f and 10g
tripwires and test_arbiter.py's scope table went red on the line the append
was written. Updated deliberately, with the argument, as their own comments
require. Full 19-file suite green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-04 01:56:23 +02:00
glenn schrooyenandClaude Opus 5 186e13ceca 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>
2026-09-02 10:42:33 +02:00
glenn schrooyenandClaude Opus 5 38f6680639 ARB-03: pin all eight actuator call sites statically
checks / unit (pull_request) Successful in 3m45s
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
adminandClaude Opus 5 06f95e0a64 ARB-03: restore Claim.actuator and make resolve() three-stage
checks / unit (pull_request) Successful in 3m45s
specs.md:467 declares `actuator: str` as the FIRST field of `Claim`.
app/arbiter.py:55 had no such field. The specification designed
per-actuator claims and the implementation dropped it, so every claim was
implicitly about "the one and only actuator", resolve() returned one scalar
Resolution.target_w, and main.py assigned it to one self.target. THERE WAS
NO SEAM AN ALLOCATOR COULD OCCUPY - and ARCH-01 (specs.md §6.2, 2026-09-02)
decided the site grid target is ALLOCATED across several inverters and never
broadcast. This is a regression fix against the written design; creating that
seam is the whole of the change.

WHY IT MATTERS, IN THE UNITS THE SYSTEM IS BILLED IN. 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 the add-on and "the grid is at X W" to the inverter - an identity that
holds only at N=1. Broadcast one target to two inverters and each supplies it
in full; the loop gain at the meter becomes N times the per-unit gain, which
at the gain config.yaml:374 accepts is a permanent limit cycle with every
watchdog in §11.2 reporting healthy. And a per-inverter reserve filed as a
site `hi` against the peak guard's site `lo` is `lo > hi`, arbiter clause 3,
0 W IN THE BILLED QUARTER - the parked battery SAFETY-25's memo calls fatal.

THE SPLIT, VERIFIED AT EACH CONSTRUCTION SITE RATHER THAN ACCEPTED FROM THE
SURVEY. Four sources are about the GRID CONNECTION and are SITE claims:
`loop` (compute() regulates grid_w, the P1 measurement, and §6.2's whole
architecture is one integrator on P1 whose output is then split), `peak` (the
15-minute average offtake AT the grid connection, which is what the capacity
tariff bills - one quarter-hour peak per site, not one per pack),
`arbitrage` (buys and sells at the grid against a day-ahead price; the price
is a property of the connection and the energy crosses the meter) and
`safety` (both construction sites read: "stopped" is the operator switching
control off for the site, "inputs missing" is the site's own meter gone -
neither is about a pack, and both must stop every inverter).

Four are about a pack, a device or one write and are PER-INVERTER: `device`
(one inverter's own reported min/max - there is no site rating),
`supervised` (max_w caps what may be WRITTEN to one setpoint entity, §11.3,
so it is scoped like `device` for the same reason), `maintenance` (§12's
recalibration is a pack procedure - drain THIS pack, charge THIS pack,
recalibrate THIS pack's counter) and `failsafe`. `failsafe` is the entry that
needed the reading rather than the survey: SAFETY-02's EVIDENCE is site-wide
(it compares P1 against the standing command) but its CONCLUSION is
"the unit I am driving is pushing power the wrong way" and its remedy is to
park that unit. At N>=2 the same evidence cannot be attributed to a sibling
this controller never commanded, so a site-wide stop would park an inverter
this watchdog never graded. The reasoning for all eight is recorded AT THE
LINE, in arbiter.CLAIM_SCOPE and again at each claims.append in main.py.

WHAT WAS BUILT. resolve() is three stages, each a separate function so a test
can construct a case at any one of them:

  pre_resolve()  - one inverter's own claims -> that unit's interval. Clauses
                   1 and 2 transposed: the unit's own winning SET gates its
                   own limits. The SET's value travels BESIDE the interval,
                   not inside it - folding a -2500 W drain into
                   [-2500, -2500] would turn "clamped to max_w" into
                   "contradicts max_w", a different and wrong answer.
  site_resolve() - THE SHIPPED ARBITER, UNCHANGED. Same code, new name.
  allocate()     - THE SEAM, AND DELIBERATELY NOT AN ALLOCATOR. ALLOC-01
                   builds it. At N<=1 it passes the target through (§6.2's
                   identity). At N>=2 it DECLINES - no shares,
                   allocated=False - and main.py refuses to write rather than
                   broadcast, because the obvious placeholder (hand the target
                   to each) is the fault ARCH-01 was filed about and no guard
                   we have can tell it apart from the real thing.

THE CONTRADICTION IS NOT GUARDED AGAINST, IT CANNOT BE WRITTEN DOWN. The
partition is by `actuator` and it is a partition: a unit's `hi` and the site's
`lo` are never both terms of one `lo > hi` test. Constructed and swept - 40
filings (every per-inverter source x unit x bound, including a unit
contradicting ITSELF at lo 900 > hi 100) against a live site floor, and not
one moves the site target or raises a contradiction. A unit's own impossible
pair parks that unit and says so on its own bounds; it does not park the site.
That is SAFETY-25 §0c's table made mechanical.

AT N=1 THIS IS A PURE REFACTOR, AND THAT IS PROVEN RATHER THAN ASSERTED. With
one inverter the fabricated grid reading and the battery setpoint are the same
number, so the grid connection and the only unit ARE one actuator:
actuator_for() routes every source to the site, no claim is filed on a unit,
stage 2 sees the whole list and answers what the shipped arbiter answered.
All 17 suites replay UNEDITED above the ARB-03 sections and are green on the
same numbers; test_arbiter.py additionally runs 13 claim lists through both
resolve() and site_resolve() and compares target, winner, binder,
contradiction flag, the considered list and the operator line.

EVIDENCE, BY BREAKING EACH THING (tools/mutation_run.sh, purged tree):
  * Claim's dataclass default emptied            -> RED (test_arbiter)
    ...and it survived until the check was added: the two helpers pass their
    own default down, so the FIELD's default never ran. Now checked directly.
  * `peak` reclassified SCOPE_UNIT               -> RED (arbiter x4, peakguard x2)
  * `maintenance` reclassified SCOPE_SITE        -> RED (arbiter x4, peakguard)
  * the N=1 identity dropped in actuator_for()   -> RED (arbiter, peakguard
    incl. the maintenance/peak suppression checks, freeze) - which is the
    measurement that says splitting at N=1 would be a behaviour change
  * resolve() stops partitioning                 -> RED (arbiter x4)
  * allocate() broadcasts at N>=2                -> RED (arbiter x2)
  * stage 1 drops clause-2 gating                -> RED (arbiter)
  * a call site routes `device` through `loop`   -> RED (peakguard 10g)
  * SAFETY-25 Option B built as a ninth source   -> RED (peakguard 10f, both
    halves, plus 10g) - the tripwire still bites

SAFETY-24's asymmetry proof is re-run and holds: 0 negative floors over the
shipped sweep, `hi` still +inf on every claim the guard files, and it now also
asserts every one of them is on the SITE actuator - a floor bound to one
inverter's share would leave the site, the thing the capacity tariff meters,
with no floor at all.

test_peakguard.py part 10f is kept, not adapted around. `source` is still
Claim.set()'s first positional argument precisely so its AST scan keeps
reading it, which is why `actuator` is the LAST field of Claim and not the
first - moving `source` off position 0 would silently blind the tripwire that
guards SAFETY-25's deferred option. The one adaptation is `**kw` on its spy,
so main.py's roster passes through unread rather than being silently dropped.
New part 10g pins CLAIM_SCOPE to the same eight-source set and grades a REAL
two-inverter cycle, which is the only place a mis-typed actuator_of(...) at a
call site is observable - at N=1 every source legitimately routes to the site,
so a swap there is invisible by construction.

No config key, no option, no operator surface, no default moved. Inverter B
does not exist yet and nothing here measures it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-02 01:54:29 +02:00
glenn schrooyenandClaude Opus 5 7b4877d56d TEST-15: purge the bytecode cache before a mutation run, not after
checks / unit (pull_request) Successful in 3m35s
FCST-03's developer reverted `self.provisional` to `here.provisional`, re-ran,
and reported the mutation as a survivor. It was not one. CPython validates a
cached `.pyc` on (source size, source mtime truncated to whole seconds), and
that edit moved neither number - same length, same second - so the interpreter
loaded the bytecode it already had. Disassembly settled it at the time: the
source read `here`, the running code loaded `self`. A mutation run had graded
code that was not there, with no error and exit status 0.

This does not make the results already on the record wrong. It makes them
UNVERIFIED, and that distinction is the whole card - so nothing here re-runs a
campaign and nothing here quietly assumes the old numbers hold.

REPRODUCED, THEN CLOSED. test_bytecode.py builds a real package whose two
return values are the same length, imports it in a real subprocess, rewrites it
inside the same second (retrying until both writes land in one whole second -
milliseconds, no sleep) and reads back which value ran. Part 1 shows the
interpreter running SELF off a file that says HERE.

AND THE TWO OBVIOUS MITIGATIONS DO NOT WORK. Measured, not read off
documentation: `python3 -B` and PYTHONDONTWRITEBYTECODE=1 are write-side only.
Neither stops an EXISTING stale .pyc being read, and a mutation run always has
one, because the previous run wrote it. Part 2 grades both still running the old
code. The advice in test_control.py's coverage audit led with `-B`; it is
corrected here - the purge is the half that works, and `-B` earns its place by
keeping THIS run from leaving a cache for the NEXT edit to inherit.

NO SLEEP ANYWHERE. One-second mtime resolution is the mechanism, not the enemy.
Waiting it out would slow every run down and would still only cover the edit
somebody remembered to slow down.

THE HARNESS. tools/mutation_run.sh purges, prints a sha256/byte fingerprint of
every app/*.py it is about to grade - so a mutation report can say WHICH source
produced it, which is the reviewer's addition to this card - runs each suite
under `python3 -B`, refuses a tree with no test_*.py (CI-01's rule, for CI-01's
reason), and fails if a cache appears during the run. It takes the tree as an
argument so the infra repo's replay harnesses can call it without being moved.

WHERE IT IS SAID (AC 2). MUTATION.md carries the mechanism, the measurement,
the reporting rules and the survey, and every one of the 16 suites now points at
it from its docstring. That list is DERIVED FROM THE TREE by test_bytecode.py
part 5, so a new suite that joins this directory without the pointer is red -
the same failure shape CI-01's file glob exists to prevent.

CI WAS ALREADY IMMUNE, AND IS DESCRIBED THAT WAY (AC 4). Verified rather than
reasoned: with the checkout bind mounted `-v "$WS:/w:ro"` the interpreter writes
no __pycache__ into the tree at all, and it fails SILENTLY - no error, exit
status 0 - while the same probe on a writable mount produces one. Each job also
rm -rf's its workspace and clones afresh, so nothing crosses between jobs on the
shared runner. PYTHONDONTWRITEBYTECODE=1 is added as belt and braces only: the
`:ro` is one character wide and the next person debugging a write will be
tempted by it. Part 6 grades both ON THE DOCKER COMMAND LINE rather than on the
file - the first draft searched the whole workflow and stayed green after the
flag was deleted, because the comment explaining it still named it.

THE SHIPPED ADD-ON IS NOT AFFECTED and the question is closed:
goodwe_controller/Dockerfile has set PYTHONDONTWRITEBYTECODE=1 since it was
written. There is no deploy-side finding.

THE SURVEY (AC 3), in MUTATION.md, scoped BY THE PROPERTY. The defect is
one-directional - a mutation that never took leaves the suite green, so stale
bytecode manufactures SURVIVORS and cannot manufacture a red - which puts every
"all red, no survivors" claim in the window out of scope, along with mutations
executed in-process (SAFETY-07's B1/B2/B3, test_peakguard parts 7 and 10, where
no source file is edited) and everything run after the purge discipline landed
on 2026-08-31 (SAFETY-24, SAFETY-26, OPT-04, FCST-03's re-run). What is left is
the recorded SURVIVORS from source-edit runs before that date. Two were re-run
here under the harness and both still stand: TARIFF-01's record_block() ignoring
its policy argument, and SAFETY-03's abs() on the clamp limit - 15 suites green
each, on the tree as it stood. The rest are labelled still-unverified rather
than assumed or re-run; each is one harness run to close, and the moment to
spend it is when somebody next touches that module.

OUT OF SCOPE AND RECORDED: the infra repo's sim/ replay harnesses import the
add-on and write bytecode into both trees (goodwe/sim/__pycache__ exists today),
so SAFETY-02's headline replay carries the same exposure. mutation_run.sh takes
a tree argument for exactly that, but the change belongs in that repo and some
of those harnesses want the rig.

RED AND GREEN. Every new check was broken and watched fail: the purge commented
out of the harness turns part 4 red (and its non-vacuity probe already proves
the fixture would mis-grade without it); one docstring pointer removed turns
part 5 red naming the file; `:ro` and the `-e` flag removed from the workflow
turn part 6 red. Full suite green, 16 files, in the CI image on a read-only
mount - the shape the runner uses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 02:07:09 +02:00
adminandClaude Opus 5 017b798fe6 Precedence arbiter: one rule instead of an if/else ladder
Two controllers writing one actuator is the failure this system exists to
avoid. "Exactly one writer" was true, but only as a convention held up by
careful reading - which does not survive an EV charger and a heat pump wanting
the same battery.

Strategies now return claims and arbiter.py resolves them:

  highest-priority `set` wins (none at all means 0 W), then every `limit` whose
  priority is >= that set's applies, most restrictive first; contradictory
  limits command 0 W and are flagged as the bug they are.

The second clause is the whole point. "Money outranks maintenance" used to be a
hand-written exception inside a Jinja template; it is now a consequence of the
priorities - the charge-only limit binds the loop but cannot bind a
higher-priority peak claim.

Also: maintenance shaping moved out of control.py, which is a controller again
and not a policy engine; the loop now tracks the arbiter's actual output rather
than its own last wish, so it does not jump when it regains control; and every
decision explains itself ("loop -> 0 W, limited by maintenance(charge-only)")
in the UI and the log.

19 new assertions in test_arbiter.py, each one a precedence question someone
will eventually ask in the field. Deployed to the reference site as 0.2.0 and
holding grid within a few watts of zero.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016NckgXecasQb2eSsPYNSW6
2026-08-23 02:45:32 +02:00