Commit Graph
3 Commits
Author SHA1 Message Date
Claude 591c35ed01 SAFETY-28: gate the write path on the allocation record, not on key count
checks / unit (pull_request) Successful in 4m19s
specs §1.5 makes "any configuration in which two writers can reach one
actuator" a non-goal. That non-goal was enforced by counting config keys:
validate.py's ACTUATOR_WRITE_KEYS = ("setpoint_entity",), consumed at rule 1,
with a comment stating the guarantee outright - "there is exactly one such
key, so a second writer is not expressible in the options schema at all".

That was true and it graded THE SHAPE OF THE OPTIONS SCHEMA, not the writes.
Add CFG-04's per-inverter config - a roster of inverters each with its own
setpoint_entity - and there are two real writers with len() of that tuple
still equal to 1. The check stays green while the property it protects is
gone. sim/inject.py's two_writers scenario asserted on the same tuple, so it
went green too. That is this repository's named failure mode: a check that
observes something other than what it claims to observe.

Demonstrated rather than argued. test_validate.py part 2 now constructs the
hazardous per-inverter config, asserts that the OLD proxy is still green on
it, and then asserts the new check refuses it. Against release/1.0's
validator the updated sim scenario reports: "validate_startup_config()
ACCEPTED a configuration in which 3 declarations reach the ONE actuator
number.goodwe_setpoint ... the old proxy reports 1 write key(s) on this same
config and is therefore GREEN on it".

THE WRITE PATH IS NOW GATED ON THE RECORD
=========================================
Controller.write_refusal() asks Allocation.authorises() whether the single
allocator placed exactly this number, for this unit, on this tick.
write_setpoint() refuses anything else. A second writer with arithmetic of
its own cannot satisfy that - its number appears in no record - so it has to
file a Claim and go through resolve(), which is the one allocator. "Exactly
one allocator per shared resource" stops being a convention somebody can
forget and becomes something the write path checks.

The refusal is a SUBSTITUTION, not a return: 0 W goes on the wire in place of
the number nothing stands behind. Returning would leave the inverter latched
on its last command with the ESP32 heartbeat still being fed - holding an
unauthorised setpoint quietly, which is the outcome the gate exists to stop.
AC 1 is "refused at the write path, not merely logged", and test_clamp.py
part 7a grades the device's writes, not the log.

The gate sits BELOW the non-finite coercion and ABOVE the clamp. Below,
because a nan is loss of input and rule 1 has already turned it into 0 W.
Above, because the question is who chose this number, and the answer is only
meaningful about the number that was asked for - gating the CLAMPED value
would refuse exactly the cycles SAFETY-03 exists for, since a clamped write
differs from the allocator's share by construction. Mutation M7 moves the
gate below the clamp and turns nine checks red, part 6c among them.

A STALE RECORD CANNOT BE REPLAYED
=================================
Every Allocation now carries the `tick` it was made for. It is handed IN by
the caller - plan(..., tick=) - rather than counted inside the allocator,
because test_alloc.py part 10 grades that the module holds no state at all,
and a serial number incremented in there would be the cached-across-ticks
thing the envelope rule forbids wearing a different hat.

Controller.alloc_tick advances ONCE PER RESOLUTION, not once per loop
iteration, and both halves are load-bearing:

  * a new allocation makes the previous record stale immediately, so a record
    whose envelopes have moved cannot authorise a write. That is the replay
    half (AC 2), graded by capturing a real record, running another real
    cycle, putting the old one back and observing refusal.
  * the "inputs missing, not yet stale" hold returns BEFORE the allocator
    runs, exactly so one dropped meter reading does not zero the inverter,
    and the heartbeat re-affirms self.target through this same gate while it
    does. A tick incremented per loop iteration would refuse that heartbeat
    and turn the hold into the outage it exists to prevent. Graded.

An UNSTAMPED record (tick None) authorises nothing: "I cannot say when I was
made" fails closed rather than reading as tick zero.

THE FAILSAFE CANNOT DEADLOCK BEHIND THE GATE, AND HERE IS WHY
=============================================================
write_setpoint() is also how layer 2 commands 0 W. cycle()'s two refusal
branches substitute a 0 W failsafe and write it like any other cycle;
shutdown() and the operator's stop button do the same. EVERY ONE OF THOSE
RUNS WHILE THE ALLOCATOR HAS REFUSED - ok is False and there are no shares -
so a gate that asked the allocator's permission to command zero would block
the safety write behind the very component whose failure the safety write
answers. A gate that can do that is worse than the hole it closes.

Resolved by authorising 0 W unconditionally, and on the VALUE rather than on
a failsafe=True flag: a flag is an assertion by the caller, which is exactly
what a second writer would pass; the value is a fact about what will be
written. The argument that this is safe and not merely convenient is that the
hazard §1.5 names is N-times loop gain from BROADCAST - handing every inverter
the same target so each supplies it in full (allocator.py's opening argument;
TEST-19 measured a permanent 4000 W p2p limit cycle at gain 1.0 with no guard
firing). At S = 0 the broadcast and the correct allocation COINCIDE: a_i = 1
for every unit gives every unit 0 W, which is exactly its share. There is no
over-command to make at zero. Said at the line, in write_refusal().

test_clamp.py part 7c drives it end to end through the real cycle() and the
real REFUSE_EMPTY branch: the allocator refuses every tick, the device still
sees 0 W, and the action log says ARBITER: REFUSED rather than WRITE REFUSED
so the two failures stay distinguishable to whoever has to debug them.

unmet_w IS STILL NOT ok
=======================
The gate reads ok and the unit's share and never unmet_w. allocator.py:179
says why: an allocation that could not place every watt is still safe to
write, and fusing them would let one saturated inverter park the whole site.
Graded on a record with 3800 W unmet and every share legal - mutation M5 adds
`or self.unmet_w` to the gate and that check goes red.

THE CONFIG CHECK, REPLACED RATHER THAN DELETED
==============================================
actuator_writers() counts WRITERS PER ACTUATOR instead of option keys. It
walks the options TREE rather than looking for a container called `inverters`,
because CFG-04 has not chosen that name yet and a census that guessed wrong
would go green while two writers existed - the same defect one step along.
Entity ids are folded to lower case so a second writer cannot be smuggled in
as Number.X versus number.x.

Two inverters with two entities are ACCEPTED - that is what the per-inverter
roster is for, and a validator that refuses the feature it guards is one
somebody switches off. Two declarations aimed at ONE entity are refused,
naming the actuator and every writer pointed at it. That includes the case an
author will meet first - keeping setpoint_entity as A's and listing A again -
because "they happen to be the same entity, so it is fine" is precisely the
reasoning that makes two writers invisible.

ACTUATOR_WRITE_KEYS survives ONLY as the enumeration a census needs, with its
guarantee comment deleted; nothing asserts on its length any more. Keeping the
name is also what stops admin/goodwe's current two_writers scenario failing on
an AttributeError between the two merges.

WHAT NEITHER CHECK OBSERVES, SAID OUT LOUD
==========================================
The census sees CONFIGURED writers. A second write path added in CODE - a
hass.set_number() call next to some new strategy - declares nothing and is
invisible to it, exactly as it was invisible to the tuple. That half is not
closed at startup and cannot be; it is closed at runtime by the record gate,
which does not care what the options look like or who called the method. The
two checks are deliberately different kinds, and only the runtime one survives
a schema this file has not seen yet.

EVIDENCE
========
* Full 18-file suite green on a purged tree (tools/mutation_run.sh).
* Verified by breaking it - seven mutations, each on a purged tree, each
  reverted and re-verified:
    M1 remove the gate                -> test_clamp red (4 checks)
    M2 make the zero exit an amnesty  -> test_clamp red (12 checks)
    M3 drop the tick comparison       -> test_clamp red (5 checks)
    M4 delete the census refusal      -> test_validate red (3 checks)
    M5 fuse unmet_w into ok           -> test_clamp red (1 check)
    M6 freeze the tick on the live path -> test_clamp red (2 checks)
    M7 move the gate below the clamp  -> test_clamp red (9 checks)
* AC 5: no existing check was weakened. The two edits to test_clamp.py are
  fixtures, not assertions - parts 5 and 6c call write_setpoint() directly
  with a number nothing allocated, which is now refused, so they say where
  their number came from. Every assertion in both parts is unchanged, and
  part 7f asserts that across part 3's sixty real cycles the gate refused
  zero writes.
* admin/goodwe's TEST-19 norig scenarios (planner_binding, two_master_plant,
  two_master_broadcast) still pass against this tree - they import the
  add-on's control law across the repo boundary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 13:56:48 +02:00
glenn schrooyenandClaude Opus 5 3fe3c7acb0 ALLOC-01: clamp each envelope end on its own, and widen the replay that missed it
checks / unit (pull_request) Successful in 4m9s
Two blocking findings from the review of 186e13c. The second is why the first
survived, and it is the one this commit is really about.

THE CLAMP. `_clamp_int()` folded both ends of the interval into one
`min(max(...))` and substituted `v` itself for a bound that was infinite. That
reads as "an absent bound cannot bind", and it is not what it does: with
`hi == inf` the expression collapses to `min(max(v, lo), v) == v`, so A MISSING
CEILING SILENTLY DISCARDED THE FLOOR. `_clamp_int(0, 2142, inf)` returned 0. It
reached the shares through all three call sites - the base, the pin clamp and
the post-floor clamp.

The worked failure: bounds `{a: [2000, +inf], b: [-5000, 5000]}` against a
+1000 W site target returned `a=+500, b=+500` - inverter-a commanded at a
quarter of its own 2000 W floor - while THE SAME CASE WITH A FINITE CEILING
correctly REFUSES on sign coherence. So one defect broke the ticket's first
named deliverable ("each share clipped to that unit's UnitBounds interval") and
suppressed a circulating-power refusal that should have fired. Both now behave
identically, and the refusal is the behaviour.

Not reachable on the live install - N=1 is a pass-through, and via main.py the
supervised claim unconditionally files a finite +/-max_w LIMIT at P_SAFETY that
pre_resolve's gate can never filter because nothing outranks P_SAFETY. But
`plan()` and `arbiter.allocate()` are the public envelope contract SAFETY-28 and
DEV-02 are being asked to build on, and `UnitBounds()` DEFAULTS TO
`(-inf, +inf)`, so a one-sided envelope is the ordinary case at N>=2 rather than
an edge, and any caller with one inherited the defect.

Fixed by clamping the two ends independently: cast to int, apply `lo` only if it
is not -inf, apply `hi` only if it is not +inf. `_int_envelope()` already passes
infinities through untouched and needed no change; it is the only other place in
the module that special-cases infinity, and the rest of the tree's `isfinite`
calls are input validation in unrelated modules.

THE RECORD. `UnitPlan.explain()` printed
`inverter-a -> +500 W in [2000, +inf] (weighted share)` - a line asserting a
share is inside an interval that excludes it, in the record SAFETY-28 gates on.
It now ASKS: `within_envelope` checks the share against the interval, and a
violated plan prints `OUTSIDE ... - ENVELOPE VIOLATED, DO NOT WRITE` instead. A
dropped unit is exempt and prints "not commanded" - it was told nothing, its
interval may be a contradiction no number is inside, and a banner there would
cry wolf on the one row that is working.

THE REPLAY THAT COULD NOT SEE IT, WHICH IS THE FINDING THAT MATTERS. The
250 000-tick replay drew every envelope as `B(-uniform(0, RATING),
uniform(0, RATING))` - finite on both ends and containing zero, every time -
while the check it fed claimed "no share ever leaves the envelope it was clamped
into", which reads as a statement about `UnitBounds`. The data could only
exercise a symmetric closed interval around zero. That is why it reported 0
escapes against a live defect while the reviewer's own 120 000 ticks reported
6 254.

The generator now draws from every shape `UnitBounds` can express: finite
straddling zero, half-open above, half-open below, finite excluding zero in each
direction, half-open AND excluding zero, and bare `UnitBounds()`. Over the same
seed and the same 250 000 ticks it draws 166 657 half-open intervals, 55 233
bare ones and 222 082 that exclude zero, and those counts are PRINTED and
asserted so "the replay covers half-open intervals" is a number a reader can see
rather than a sentence the file asserts about itself. The escape check also
dropped its 1 W tolerance: `_int_envelope()` narrows inward, so there is nothing
for slack to absorb except a defect.

GRADED, NOT MERELY APPLIED - the acceptance bar for this round. A/B on one seed
and one tick count, against the defective clamp: THE OLD GENERATOR SEES 0
ESCAPES, THE NEW ONE SEES 39 991. Reverting the fix under the widened generator
takes test_alloc.py RED on four checks (the escape count, the record check, and
both halves of the new half-open block); re-applying it takes it green. The
collapse is now a row in part 12's mutation table, recorded as the mutation that
actually shipped rather than a hypothetical one.

TWO CHECKS RE-AIMED RATHER THAN LOOSENED.
* Part 6's "the sign gate never fires on an ordinary tick" counted refusals over
  the whole replay. Envelopes that EXCLUDE zero are precisely what that gate is
  for, so the widened generator makes refusals correct there. It now counts an
  ORDINARY tick explicitly - no pin, every envelope contains zero - and asserts
  0 refusals over 77 533 of them, half-open and bare envelopes among them. That
  is stronger than what it replaced, not weaker.
* Part 1's third check summed `sum_w - sum(shares)` over ticks where
  `unmet_w == 0`, and `unmet_w` IS that difference - so it was true by
  construction and would have gone quiet rather than red. It now measures the
  one rounding that is actually left: the site-level `quantisation_w`, totalling
  -39.8 W over 137 964 allocations, i.e. -0.0003 W per tick. Largest remainder
  against the per-unit alternative is still graded in part 2, where the
  counterfactual drifts -125 W over 50 000 ticks at three inverters.

Verified: all 18 suites green in the CI image on a tree with no bytecode cache;
test_alloc.py also re-run through tools/mutation_run.sh, which purges. AC 2 and
AC 3 are untouched by this change - the rig and part 8 use symmetric finite
envelopes, which the old and new clamp treat identically - and part 8 is green.
No hardware touched; admin/goodwe not fetched, not modified, not pushed to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 11:49:18 +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