SAFETY-42: close two mutation gaps, and correct what makes N=1 inert
checks / unit (pull_request) Successful in 6m3s

The battery is 12 mutations, one freshly copied tree each, purged, `:ro`,
`python3 -B`, byte deltas measured. First round: 9 red, 3 survivors. All three
are now accounted for and the ledger is in DECISIONS §8.

M5 - `share <= 0` relaxed to `share == 0`, +0 B - WAS A REAL GAP AND THE TWO
OBVIOUS CHECKS DID NOT CLOSE IT. `short_by(-2000, 0) is None` and
`short_by(-2000, -100) is None` both stayed GREEN under the mutant: for a charge
share that is UNDER-delivered `share - measured` is negative and the `gap > 0`
test answers None anyway, so neither input can separate the two spellings. The
one that can is a unit moving MORE than its charge share - -2000 W told,
-3000 W measured, `share - measured` = +1000 W - which the mutant reports as a
shortfall. It is not one; it is something else driving that pack, which is
SAFETY-30's and SAFETY-31's question. That input is now the check, and M5 is RED.
MUTATION.md's third failure mode in miniature: the assertion ran, the sentence
was true, and the data could not reach the failure it named.

M11 - `inv.is_live(now)[0]` replaced by a literal `True`, -15 B - was a WIRING
gap the pure checks structurally could not see. `account(live=False, ...)` was
graded as a FUNCTION; nothing graded that `cycle()` asks the question, because no
fixture had a unit whose writes were being rejected. Such a unit still has its
last ACCEPTED command standing on the wire (`write_setpoint` leaves `last_write`
alone on a rejected write) and still measures nothing, so it looks exactly like a
wedged one to every number this check reads - and reporting it would be the
second, louder alarm about a state the allocator has already handled and logged.
`test_delivery.py` part 6 now builds that unit, with `rejected_since` back-dated
past LINK_STALE_S because the fixture runs in two seconds and the timer is
test_inverter.py's to grade. M11 is RED.

M3 - `len(asked) < 2` relaxed to `< 1`, +0 B - IS STILL A SURVIVOR, and it is an
EQUIVALENT MUTANT. With one asked unit `unaccounted` is a subset of a one-element
set, so it is either empty (`not unaccounted` returns {}) or equal to `asked`
(`len(unaccounted) == len(asked)` returns {}); with zero asked units both
spellings return {} as well. No input separates them, so no check can.

⚠️ AND FINDING IT CORRECTED THE MODULE'S OWN PROSE, WHICH IS THE MORE USEFUL
HALF. `delinquent()`'s docstring claimed `len(asked) < 2` was what makes N=1
inert. It is not - the PROPER-SUBSET rule is, and that line is a fast path and a
signpost. A false attribution of the mechanism in the file's own prose is exactly
what this repository keeps finding, and a mutation that survives is how it
surfaced. The line stays, with the equivalence written down beside it the way
circulation.circulating_w() records its own max(0.0, ...).

Also recorded, rather than left implicit: `account()`'s `parked` branch is graded
as a function but its WIRING is not mutated on its own; part 6's reversed-unit
fixture exercises it end to end, which is weaker, and the ledger says so.

Baseline 23/23 green before and after. app/main.py is byte-identical to the
previous commit - both closures are a test and a comment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
glenn schrooyen
2026-09-06 17:31:35 +02:00
co-authored by Claude Opus 5
parent ccc4bf100c
commit eae9e23e95
3 changed files with 190 additions and 6 deletions
+94 -1
View File
@@ -28,7 +28,7 @@ from app.delivery import (
DELIVERY_OPTION_DEFAULTS, REASON_CODE, DeliveryConfig, DeliveryWatchdog,
UnitDelivery, account, delinquent, short_by,
)
from app.inverter import Inverter
from app.inverter import LINK_STALE_S
from app.main import Controller
from app.peakguard import discharge_permitted
from app.store import unit_key
@@ -61,6 +61,19 @@ check("...a CHARGE share answers None. There is no configured charge ceiling "
"in this add-on, so a pack in constant-voltage taper cannot be explained "
"and arming it would fire on every maintenance charge",
short_by(-2000, 0) is None and short_by(-2000, -100) is None)
# ⚠️ THE CASE THAT ACTUALLY GRADES THE `share <= 0` GUARD, AND THE MUTATION
# BATTERY IS WHAT FOUND IT MISSING. Relaxing that guard to `share == 0` left the
# two rows above GREEN - for a charge share that is UNDER-delivered,
# `share - measured` is negative and the `gap > 0` test answers None anyway, so
# the mutant is masked. The one input where the two differ is a unit moving MORE
# than its charge share: -2000 W told, -3000 W measured, `share - measured` =
# +1000 W, which the mutant reports as a SHORTFALL. It is not one - it is
# something else driving that pack, which is SAFETY-30's and SAFETY-31's
# question - and reading it as "short of its share" would name the wrong fault.
check("...and a unit moving MORE than its charge share is not short of it "
"either. This is the input that separates `share <= 0` from `share == 0`; "
"the two rows above do not, because the `gap > 0` test masks them",
short_by(-2000, -3000) is None and short_by(-2000, -2600) is None)
check("...a missing reading is None, never 'the whole share'. Zero would be "
"this check inventing a fault out of an absence",
short_by(2000, None) is None and short_by(None, 0) is None)
@@ -590,6 +603,86 @@ check("...and it fires IDENTICALLY on the benign arm, on the identical trace, "
and _cf.site_batt_w == _cw.site_batt_w
and _cf.target == _cw.target)
print()
print(" ...and a unit the ALLOCATOR HAS ALREADY DROPPED is not reported twice")
# ⚠️ A WIRING CHECK THE PURE ONE IN PART 1 CANNOT MAKE, AND THE MUTATION BATTERY
# IS WHAT FOUND IT MISSING. `account(live=False, ...)` is graded up there; what
# is graded HERE is that `cycle()` actually ASKS `inv.is_live(now)`. Replacing
# that call with a literal `True` left every check green, because no fixture had
# a unit whose writes were being rejected. A dropped unit still has its last
# ACCEPTED command standing on the wire (`write_setpoint` leaves `last_write`
# alone on a rejected write) and still measures nothing, so it looks exactly
# like a wedged one - and reporting it would be a second, louder alarm about a
# state the allocator has already handled and logged, which is the
# SAFETY-15/SAFETY-19 failure this module takes its 30 s floor from.
class _LinkDeadHass(_DeliveryHass):
"""B is healthy, then its LINK dies: writes rejected and output gone.
⚠️ `rejected_since` IS BACK-DATED RATHER THAN WAITED OUT. `is_live()`'s
window is `LINK_STALE_S` = 30 s of wall clock and this fixture runs in about
two; waiting it out would make the check's runtime the thing it grades. What
is graded is the CALL, not the timer - `test_inverter.py` part 3 owns the
timer.
"""
def __init__(self, dead_from=5, **kw):
super().__init__(**kw)
self.dead_from = int(dead_from)
self.cycle_n = 0
def measured(self):
a, b = self.ctrl.inverters
w = lambda i: float((i.last_write if i.last_write is not None
else i.target) or 0.0)
return [w(a), 0.0 if self.cycle_n >= self.dead_from else w(b)]
async def set_number(self, entity, value):
self.writes.append((entity, float(value)))
if entity == "number.sp_b" and self.cycle_n >= self.dead_from:
return False
return True
async def _link_dead():
hass = _LinkDeadHass(dead_from=5)
c = _ctrl(hass)
b = c.inverters[1]
seen = []
for n in range(18):
hass.cycle_n = n
if n == 5:
# the moment the link dies, back-dated past the firmware's window
b.rejected_since = (datetime.now(timezone.utc)
- timedelta(seconds=LINK_STALE_S + 10))
await c.read_inputs()
await c.cycle()
seen.append(dict(live=b.is_live(datetime.now(timezone.utc))[0],
stood=float(b.standing_w()), meas=b.batt,
named=b.delivery_wd.tripped))
await asyncio.sleep(0.15)
return c, seen
_cld, _sld = asyncio.run(_link_dead())
_bld = _cld.inverters[1]
check("the fixture REACHES the case: B is not live, is still standing at a "
"discharge command, and is measuring nothing - i.e. it looks exactly like "
"a wedged unit to every number this check reads",
not _sld[-1]["live"] and _sld[-1]["stood"] > _cld.tuning.saturation_w
and _sld[-1]["meas"] == 0.0)
check("...and it is NOT reported, because `account()` was asked and answered. "
"The allocator has already dropped it and said so in the action log; a "
"second alarm about that is the SAFETY-15/19 failure",
not _bld.delivery_wd.tripped and _bld.delivery_wd.since is None)
check("...and the answer is the LINK one specifically, not the SoC one - so a "
"reader can tell which branch withheld the alarm",
"link" in (account(live=False, parked=False, soc=_bld.soc,
bms_floor=_bld.bms_soc_floor) or ""))
print(f" B: live={_sld[-1]['live']} standing={_sld[-1]['stood']:.0f} W "
f"measured={_sld[-1]['meas']:.0f} W -> not reported")
print()
print(" ...and the one case where BOTH have something to say: a unit that is")
print(" REVERSED is short too, and SAFETY-30 gets there first")