SAFETY-28: gate the write path on the allocation record, not on key count
checks / unit (pull_request) Successful in 4m19s
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>
This commit is contained in:
@@ -299,6 +299,25 @@ class Controller:
|
||||
# the cached envelope this card exists to forbid, wearing a different
|
||||
# hat.
|
||||
self.allocation = None
|
||||
# SAFETY-28. THE TICK THE RECORD ABOVE BELONGS TO, and the counter that
|
||||
# mints it. It is incremented ONCE PER RESOLUTION - not once per loop
|
||||
# iteration - and that distinction is load-bearing in both directions:
|
||||
#
|
||||
# * a NEW resolution makes the previous record stale IMMEDIATELY, so a
|
||||
# record cannot be replayed into a later tick after the envelopes it
|
||||
# describes have moved. That is the replay half of the gate.
|
||||
# * a cycle that produced NO resolution does not bump it. The "inputs
|
||||
# missing, not yet stale" hold above returns before the allocator
|
||||
# runs precisely so that a single missed poll does not zero the
|
||||
# inverter, and the heartbeat re-affirms `self.target` through the
|
||||
# write path while it does. Bumping here would make that heartbeat
|
||||
# unauthorised and drop the site to 0 W on the first dropped poll -
|
||||
# turning a hold into the outage it exists to prevent.
|
||||
#
|
||||
# So the record is SUPERSEDED, never expired: it stands until the
|
||||
# allocator produces its successor, and nothing else has moved in
|
||||
# between that it does not still describe.
|
||||
self.alloc_tick = 0
|
||||
# What the last cycle's allocator dropped, so the log line above is
|
||||
# edge-triggered. Starts as the empty tuple - nothing has been dropped
|
||||
# yet and nothing has been said about it.
|
||||
@@ -406,6 +425,67 @@ class Controller:
|
||||
elif self.inputs_bad_since is None:
|
||||
self.inputs_bad_since = datetime.now(timezone.utc)
|
||||
|
||||
def write_refusal(self, value: float) -> str | None:
|
||||
"""Why `value` may NOT be written right now, or None if it may.
|
||||
|
||||
--- SAFETY-28: THE WRITE PATH IS GATED ON THE ALLOCATION RECORD --------
|
||||
specs §1.5 makes "any configuration in which two writers can reach one
|
||||
actuator" a non-goal. Until this method that non-goal was enforced by
|
||||
`len(validate.ACTUATOR_WRITE_KEYS) == 1` - there is one option key, so a
|
||||
second writer is not expressible. True, and a statement about the SHAPE
|
||||
OF THE OPTIONS SCHEMA rather than about the writes. Add CFG-04's
|
||||
per-inverter config and there are two real writers with that length
|
||||
still 1: the check stays green while the property it protects is gone.
|
||||
That is this repository's named failure mode - a check that observes
|
||||
something other than what it claims to observe - so the enforcement
|
||||
moved here, onto the writes themselves.
|
||||
|
||||
The rule is: A NUMBER REACHES THE WIRE ONLY IF THE SINGLE ALLOCATOR
|
||||
PLACED IT, ON THIS TICK, FOR THIS UNIT. A second writer with arithmetic
|
||||
of its own cannot satisfy that - its number is not in any 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 a thing the write path
|
||||
checks. `Allocation.authorises()` holds the per-record half of the
|
||||
argument.
|
||||
|
||||
⚠️ 0 W IS ALWAYS AUTHORISED, AND THAT IS THE ANSWER TO "WHAT IF THE
|
||||
ALLOCATOR IS THE THING THAT FAILED". This is not a convenience exit; it
|
||||
is the one value whose correctness does not depend on a split existing:
|
||||
|
||||
* The hazard the non-goal names is N-times loop gain from BROADCAST -
|
||||
handing every inverter the same target so each supplies it in full
|
||||
(see allocator.py's opening argument, and TEST-19's measured
|
||||
`N=2 gain 1.0` limit cycle). At S = 0 the broadcast and the correct
|
||||
allocation COINCIDE: a_i = 1 for every unit gives every unit 0 W,
|
||||
and 0 W is exactly its share. There is no over-command to make.
|
||||
* It is the fail-toward-inaction direction §11.1 rule 1 already
|
||||
names, and it is what layer 2 commands. `cycle()`'s two refusal
|
||||
branches replace the resolution with a 0 W failsafe and then write
|
||||
it through here like any other cycle; `shutdown()` and the
|
||||
operator's stop button do the same. Every one of those runs while
|
||||
the allocator has REFUSED - `Allocation.ok` is False and there are
|
||||
no shares - so a gate that asked the allocator's permission to
|
||||
command zero would deadlock the failsafe behind the very component
|
||||
whose failure the failsafe exists to answer.
|
||||
|
||||
A gate that can block the safety write is worse than the hole it closes,
|
||||
so it cannot block it: the test for zero is first and reads nothing.
|
||||
|
||||
⚠️ AND IT IS `value == 0.0`, THE REQUESTED NUMBER - not a `failsafe=True`
|
||||
argument. A flag is an assertion by the caller, which is precisely what
|
||||
a second writer would pass; the value is a fact about what is going to
|
||||
be written. -0.0 == 0.0 in Python, and a nan never reaches here (the
|
||||
caller has already turned it into 0.0), so the predicate is total.
|
||||
"""
|
||||
if value == 0.0:
|
||||
return None
|
||||
rec = self.allocation
|
||||
if rec is None:
|
||||
return ("no allocation record exists yet - nothing has authorised "
|
||||
f"{value:+.0f} W")
|
||||
return rec.authorises(self.unit, value, self.alloc_tick)
|
||||
|
||||
async def write_setpoint(self, value: float, *, force: bool = False) -> None:
|
||||
entity = self.o.get("setpoint_entity", "")
|
||||
if not entity:
|
||||
@@ -430,6 +510,39 @@ class Controller:
|
||||
"0 W and continuing to command. This is a bug upstream, "
|
||||
"not a limit.", value)
|
||||
value = 0.0
|
||||
|
||||
# --- SAFETY-28: AND IT IS REFUSED HERE, NOT LOGGED SOMEWHERE ----------
|
||||
# ⚠️ ABOVE THE CLAMP AND BELOW THE NON-FINITE COERCION, and both halves
|
||||
# of that placement are decisions.
|
||||
#
|
||||
# Below the coercion, because a nan is loss of input rather than an
|
||||
# unauthorised write: rule 1 has already turned it into 0 W, which this
|
||||
# gate then authorises unconditionally, so a transport bug does not get
|
||||
# a second, differently-worded refusal.
|
||||
#
|
||||
# Above the clamp, because the question is WHO CHOSE THIS NUMBER and the
|
||||
# answer is only meaningful about the number that was asked for. The
|
||||
# clamp then narrows what was authorised, always toward safety, and it
|
||||
# stays the last thing between a bug and the hardware. Gating the
|
||||
# CLAMPED value would be worse than useless: on every cycle the clamp
|
||||
# engages, the emitted watts differ from the allocator's share by
|
||||
# construction, so the gate would refuse exactly the cycles SAFETY-03
|
||||
# exists for.
|
||||
#
|
||||
# The refusal is a SUBSTITUTION, not a return: 0 W goes on the wire in
|
||||
# place of the number nothing stands behind. Returning instead would
|
||||
# leave the inverter latched on its last command with the ESP32's
|
||||
# heartbeat still being fed - holding an unauthorised setpoint quietly,
|
||||
# which is the outcome this gate exists to prevent.
|
||||
refusal = self.write_refusal(value)
|
||||
if refusal is not None:
|
||||
self.log_event(f"WRITE REFUSED {value:+.0f} W - commanding 0 W")
|
||||
_LOG.error("SAFETY-28 REFUSED a write of %+.0f W: %s. Commanding "
|
||||
"0 W instead. Every setpoint must be one the allocator "
|
||||
"placed on THIS tick - see Controller.write_refusal().",
|
||||
value, refusal)
|
||||
value = 0.0
|
||||
|
||||
result = clamp_to_rating(value, float(self.o.get("max_w", 2000)),
|
||||
self.dev_min, self.dev_max)
|
||||
self.clamp_last = result
|
||||
@@ -881,7 +994,12 @@ class Controller:
|
||||
# de-assert when control is stopped.
|
||||
self.loop_clamped = (False, "")
|
||||
|
||||
resolution = resolve(claims, units=self.units, live=self.live_units())
|
||||
# SAFETY-28. A new resolution is a new tick, and the previous record is
|
||||
# stale from this line onwards. Incremented BEFORE resolve() so that the
|
||||
# record resolve() builds is stamped with the tick it is for.
|
||||
self.alloc_tick += 1
|
||||
resolution = resolve(claims, units=self.units, live=self.live_units(),
|
||||
tick=self.alloc_tick)
|
||||
self.allocation = resolution.allocation
|
||||
# --- THE TWO REFUSALS, AND THEY ARE NOT THE SAME REFUSAL --------------
|
||||
# Both do the same thing - REPLACE the resolution with a 0 W failsafe
|
||||
|
||||
Reference in New Issue
Block a user