DEV-02: merge release/1.0, and close the two review blockers
checks / unit (pull_request) Successful in 4m15s
checks / unit (pull_request) Successful in 4m15s
PR #43 was rejected on two findings and had gone unmergeable behind SAFETY-28 and the ALLOC-01 fix. This is the merge commit; the conflict resolution and both fixes are below, because a merge that also changes behaviour has to say so. THE MERGE, AND WHAT IT COST =========================== Two conflicts in main.py, both where SAFETY-28 and this card touched the same method: * `write_setpoint()` split into a site entry point plus a per-unit `_write_unit()` here, while SAFETY-28 put its record gate in the old single-unit body. `write_refusal()` now takes the INVERTER it is about. Asking it with `self.unit` - as release/1.0 does - would authorise A's share on B's wire, and would also trip test_inverter.py part 1's AST scan, which goes red on any read of a Controller N=1 view inside Controller. * the SAFETY-28 gate sits above the clamp and below the non-finite coercion, exactly where release/1.0 put it, and is now asked about `inv`. Fallout the merge produced in the checks, re-aimed rather than relaxed: * every fixture that calls `write_setpoint()` DIRECTLY - the clamp probe, the hung-heartbeat experiment and its serialised counterfactual, the split deadline probe - now arms a real allocation record first. test_inverter.py grows an `arm()` built through the shipped `resolve()`, the same shape test_clamp.py's is: the per-unit envelopes are filed as real `supervised` claims and the site target is their sum, so the water-fill has exactly one answer and the fixture cannot quietly become its own allocator. * test_clamp.py's two-unit failsafe fixture assigned `c.units`, which DEV-02 made a derived property with no setter. It appends a real `Inverter`. BLOCKER 1 - AC 4's ARBITER-BOUND EVIDENCE WAS VACUOUS ===================================================== test_peakguard.py claimed "each unit's `supervised` bound is ITS OWN `max_w` - give inverter B a different rating and the claim filed on inverter B moves with it", and compared B's bound against `_b2.max_w`. `second_inverter()` DEFAULTED B's rating to `LIVE_MAX_W`, which is A's. IT COMPARED (-5000, 5000) AGAINST (-5000, 5000): a number against itself, unable to observe the failure it named. Reproduced: filing the per-unit `device`/`supervised` claims from `self.inverters[0]`'s numbers while leaving `actuator=self.actuator_of(..., inv)` alone left ALL 19 FILES GREEN. At a roster of two with A=5000/B=3000 that makes inverter B's planned envelope (-5000, 5000) instead of (-3000, 3000) - a 2000 W over-command the arbiter believes is supervised. It is a DIFFERENT defect from the actuator variant the static routing pin catches, and only the actuator variant was in the mutation table. * `second_inverter()` gives B 3000 W - a hypothesis, marked as one - for every fixture in the file, so no check there can compare B against A by accident. * the claim is asserted against LITERAL numbers rather than against an expression that can read the same object twice, for `supervised` AND for `device` (whose `dev` had the same defect), plus the envelope the ARBITER RESOLVED for B - a claim filed right and resolved wrong is the same defect one layer down. * a PRECONDITION check fails if the two ratings are ever made equal again. * the BOUND mutation is in the table, beside the actuator one, and was observed RED (3 checks in test_peakguard.py) via tools/mutation_run.sh. ⚠️ THIRD INSTANCE THIS SESSION of a check whose DATA cannot reach the failure it names - ARB-03's call-site scan saw 3 of 8 appends, ALLOC-01's replay generator saw 0 escapes where a widened one saw 39,991. MUTATION.md gains a section for it beside the stale-bytecode and masking ones, and the rule is written at the line: ANY CHECK ASSERTING "X DIFFERS FROM Y" MUST BE RUN ONCE WITH X AND Y ACTUALLY MADE DIFFERENT. BLOCKER 2 - THE LATCH WAS SILENTLY DISARMED BY THE ROLLBACK ROUND TRIP ====================================================================== store.py's rule 2 keeps the flat keys "so a rollback still finds its latch". The operator path that rule contemplates is a ROUND TRIP, and this card lost the latch on it: 1. install migrates -> schema_version=1, unit/inverter-a/... = False 2. operator DOWNGRADES; SAFETY-02 trips inside the downgrade window and the old add-on writes only the flat `sign_tripped: true` 3. operator upgrades again -> `rep.ran` is False, so `_grade_store_migration()` returned early, and `Inverter.__init__` read the namespaced key: False Observed: the controller came up with `sign_wd.tripped = False` while /data/state.json said `sign_tripped: true`. That is store.py's own header sentence - "the file still says sign_tripped: true, the banner is green, and the controller resumes driving the wrong way" - rebuilt by the version marker. A firmware version change with a rollback path was performed on this site on 2026-09-02, so this is not hypothetical. Three changes, each graded by a mutation observed RED: * THE OPERATOR'S CLEAR CLEARS BOTH SPELLINGS. That is what removes the "re-arm forever" objection the `rep.ran` gate existed to answer: with the fossil cleared, `flat True + namespaced False` is unambiguously a downgrade-window latch. * A TRIP MIRRORS THE FLAT PAIR. Rule 2 only ever covered the latch present AT MIGRATION TIME - nothing flat was written for a trip taken afterwards, so an operator who downgraded found `sign_tripped: false` and came up DRIVING. store.py claimed the staleness could only fail toward a SPURIOUS latch. It could not; that sentence is corrected rather than left standing. * THE GUARD RUNS ON EVERY START, and asks the STORE - not the roster - whether anything namespaced is latched. A mirrored trip on B must not re-arm a healthy A, and the store is the right place to ask because it holds every `unit/*/sign_tripped` this install has written, including one belonging to a unit that is not in today's roster. Graded by the three-step scenario itself, run end to end in test_inverter.py part 2, with all three preconditions asserted - including that the catching start did NOT migrate anything, which is what used to skip the guard. Part 2's idempotence check now PRESSES the button instead of hand-writing the store, because the state it used to construct is not one the shipped clear path can produce. Part 3 grades the mirror and the clear on a real roster of two. test_watchdog.py's "the flat key is NOT written" assertion is re-aimed rather than deleted: its argument was right about the READ and wrong about the WRITE. Nothing at runtime reads that key, and the one thing that does ignores it whenever any namespaced latch is set - which a sibling's trip always leaves. What its absence actually cost was the rollback. HANDOVER.md and DOCS.md said the flat key "is never read again", which was the instruction an operator clearing a trip by hand would follow. Both corrected: it is a maintained rollback view, it IS read on every start, and clearing a trip by hand means clearing both spellings. MUTATIONS THIS ROUND, each applied on a purged tree via tools/mutation_run.sh and observed RED: * per-unit `device`/`supervised` BOUNDS from `self.inverters[0]`, actuator left correct -> test_peakguard.py 10g * `_grade_store_migration()` gated on `rep.ran` -> test_inverter.py part 2 * the operator's clear leaving the flat keys set -> test_inverter.py part 2/3 * a trip not mirroring the flat pair -> test_inverter.py part 3 * the guard asking only `self.inverters[0]` -> test_inverter.py part 3 19/19 files green on the merged tree. No hardware was touched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
@@ -66,6 +66,7 @@ from app.store import (
|
||||
)
|
||||
from app.validate import FIRMWARE_INGEST_VETO_S
|
||||
from app.freeze import rail_w
|
||||
from app.arbiter import P_LOOP, P_SAFETY, Claim, resolve
|
||||
|
||||
fails = []
|
||||
HERE = Path(__file__).resolve().parent
|
||||
@@ -213,6 +214,36 @@ def inverter_b(store=None, max_w=B_RATING_HYPOTHESIS, dev=None,
|
||||
return inv
|
||||
|
||||
|
||||
def arm(c, shares):
|
||||
"""Give `c` a real allocation record authorising exactly `shares`.
|
||||
|
||||
⚠️ SAFETY-28 MADE THIS NECESSARY AND THE FACT THAT IT IS NECESSARY IS THAT
|
||||
TICKET. `write_setpoint()` refuses any number the single allocator did not
|
||||
place on this tick, and the probes below call it directly - the way the
|
||||
heartbeat and the operator's buttons do - so they have to say where their
|
||||
number came from. Before that gate they did not, and that was the hole.
|
||||
|
||||
⚠️ AND IT IS BUILT THROUGH THE SHIPPED `resolve()`, NOT BY HAND, for the
|
||||
reason test_clamp.py's twin says: a helper that assembled an `Allocation`
|
||||
itself would be a second allocator living in the test file. The per-unit
|
||||
numbers are forced the way the add-on forces them - each unit files its own
|
||||
`supervised` envelope and the site target is their sum, so the water-fill
|
||||
has exactly one answer - and the assert is what says this fixture got it.
|
||||
"""
|
||||
c.alloc_tick += 1
|
||||
claims = [Claim.set("loop", P_LOOP, float(sum(shares.values())),
|
||||
"test stimulus")]
|
||||
for unit, watts in shares.items():
|
||||
claims.append(Claim.limit("supervised", P_SAFETY, -abs(float(watts)),
|
||||
abs(float(watts)), "max_w", actuator=unit))
|
||||
res = resolve(claims, units=c.units, live=c.units, tick=c.alloc_tick)
|
||||
c.allocation = res.allocation
|
||||
assert res.allocation.shares == {u: float(w) for u, w in shares.items()}, (
|
||||
f"arm() asked for {shares} and the shipped allocator placed "
|
||||
f"{res.allocation.shares} - fix the fixture, not the assert")
|
||||
return res.allocation
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
@@ -320,6 +351,15 @@ check("...and the views are PROPERTIES, not attributes, so `c.soc = 4` writes "
|
||||
print()
|
||||
print("2. AC 2: the store migration, and it is GRADED rather than performed")
|
||||
|
||||
async def _one_cycle_early(c):
|
||||
"""One real cycle. `_one_cycle` below is the same three lines; this one is
|
||||
needed before it, and duplicating three lines beats moving a definition
|
||||
somebody is reading in place."""
|
||||
await c.read_inputs()
|
||||
await c.cycle()
|
||||
return c
|
||||
|
||||
|
||||
# ⚠️ THE RISKY HALF, IN THE CARD'S OWN WORDS. Namespacing `sign_tripped` is
|
||||
# right - flat, a trip on B latches A - but a latch that silently fails to move
|
||||
# is a disarmed safety guard on an install whose state file still says
|
||||
@@ -360,24 +400,109 @@ check("...and the keys that moved are exactly the ones store.py names as "
|
||||
and "phase" not in LEGACY_UNIT_KEYS)
|
||||
|
||||
# IDEMPOTENCE, AND IT IS THE ONE THAT PROTECTS THE OPERATOR'S CLEAR.
|
||||
_pre.set(unit_key(UNIT_A, "sign_tripped"), False)
|
||||
_pre.set(unit_key(UNIT_A, "sign_reason"), "")
|
||||
#
|
||||
# ⚠️ THE CLEAR IS PRESSED, NOT SIMULATED, AND THAT CHANGE IS THE TICKET. This
|
||||
# check used to clear the NAMESPACED key by hand and leave the flat one saying
|
||||
# `true`, then assert that a restart did not resurrect the latch. It graded a
|
||||
# state the shipped clear path does not produce: `handle_action("auto_toggle")`
|
||||
# clears BOTH spellings, precisely so that `flat True + namespaced False` can
|
||||
# only ever mean a trip taken while the install was rolled back (see below).
|
||||
# Driving the button is also the only way to find that out - a fixture that
|
||||
# hand-writes the store is a check on the fixture.
|
||||
run(_c2.handle_action("auto_toggle")) # the operator presses START
|
||||
_writes_before = _pre.writes
|
||||
_c2b, _ = built(store=_pre)
|
||||
check("⚠️ RUNNING AGAIN AFTER AN OPERATOR CLEARED THE LATCH DOES NOT RESURRECT "
|
||||
"IT. The flat key still says `true` - it is a fossil, left in the file on "
|
||||
"purpose - and a migration that re-read it would re-arm a failsafe the "
|
||||
"operator has already dealt with, once per restart, forever",
|
||||
"IT. A guard that went on comparing against a flat key nothing cleared "
|
||||
"would re-arm a failsafe the operator has already dealt with, once per "
|
||||
"restart, forever - which is what the `rep.ran` gate used to buy, and "
|
||||
"what clearing both spellings buys instead",
|
||||
not _c2b.inverters[0].sign_wd.tripped
|
||||
and _pre.data[unit_key(UNIT_A, "sign_tripped")] is False)
|
||||
check("...and it wrote nothing at all the second time, which is what "
|
||||
"`schema_version` buys",
|
||||
_pre.writes == _writes_before)
|
||||
check("...and the flat keys are still there, unread. Deleting them costs a "
|
||||
"ROLLBACK: an operator who downgrades the add-on after a migration would "
|
||||
"find no latch at all. Left in place the staleness fails toward a "
|
||||
"spurious 0 W hold, which is the safe direction",
|
||||
_pre.data["sign_tripped"] is True)
|
||||
check("⚠️ AND THE OPERATOR'S PRESS CLEARED THE PRE-DEV-02 FLAT KEYS TOO. That "
|
||||
"is what makes the two spellings comparable at all: cleared together, "
|
||||
"the only disagreement they can reach is `flat says tripped, nothing "
|
||||
"namespaced does` - which is a latch written by a version that had no "
|
||||
"namespaces, i.e. a trip taken while this install was ROLLED BACK",
|
||||
_pre.data["sign_tripped"] is False and _pre.data["sign_reason"] == "")
|
||||
|
||||
# ⚠️ THE ROLLBACK ROUND TRIP, RUN END TO END. THIS IS THE STATE THE `rep.ran`
|
||||
# GATE SWALLOWED, and it is not a thought experiment - a firmware version change
|
||||
# with a rollback path was performed on this site on 2026-09-02. store.py's
|
||||
# rule 2 keeps the flat keys "so a rollback still finds its latch"; the operator
|
||||
# path that rule contemplates is a ROUND TRIP, and the guard used to return
|
||||
# early on every start that did not migrate, which is every start after the
|
||||
# first.
|
||||
#
|
||||
# 1. install migrates -> schema_version=1, unit/inverter-a/... = False
|
||||
# 2. operator DOWNGRADES, and SAFETY-02 trips inside the downgrade window.
|
||||
# The old add-on knows only the flat key, so that is all it writes.
|
||||
# 3. operator upgrades again. `rep.ran` is False - the store is already at
|
||||
# schema 1 - so the migration and the guard were both skipped and
|
||||
# `Inverter.__init__` read the namespaced key, which is still False.
|
||||
#
|
||||
# Observed before this fix: the controller came up with `sign_wd.tripped =
|
||||
# False` while /data/state.json said `sign_tripped: true`. That is store.py's
|
||||
# own header sentence - the file still says tripped, the banner is green, and
|
||||
# the controller resumes driving the wrong way - rebuilt by the version marker.
|
||||
_trip = MemStore()
|
||||
_c2r0, _ = built(store=_trip) # 1. install, migrate
|
||||
check("PRECONDITION 1: the install migrated and came up armed and untripped",
|
||||
_trip.data[SCHEMA_KEY] == SCHEMA_VERSION
|
||||
and _trip.data[unit_key(UNIT_A, "sign_tripped")] is False
|
||||
and not _c2r0.inverters[0].sign_wd.tripped)
|
||||
# 2. THE DOWNGRADE WINDOW. The old add-on is the one that shipped before this
|
||||
# card: it writes the flat pair and knows nothing about `unit/...`. Written the
|
||||
# way that version wrote them - two `Store.set()` calls - rather than by
|
||||
# reaching into `data`, so this is the file an old add-on would leave behind.
|
||||
_trip.set("sign_reason", "SIGN_DISAGREEMENT: commanded -1800 W against a "
|
||||
"meter reading +2100 W for 181 s")
|
||||
_trip.set("sign_tripped", True)
|
||||
check("PRECONDITION 2: the rolled-back add-on latched under the flat key ONLY, "
|
||||
"and left the namespaced one exactly as the migration wrote it. This is "
|
||||
"the state that made `rep.ran` a trap - the store is already at schema "
|
||||
"1, so nothing on the next start has any reason to run",
|
||||
_trip.data["sign_tripped"] is True
|
||||
and _trip.data[unit_key(UNIT_A, "sign_tripped")] is False
|
||||
and _trip.data[SCHEMA_KEY] == SCHEMA_VERSION)
|
||||
_c2r, _ = built(store=_trip) # 3. upgrade again
|
||||
print(f" the rollback round trip: {_c2r.store_migration.explain()}; "
|
||||
f"inverter-a came up tripped={_c2r.inverters[0].sign_wd.tripped}")
|
||||
check("PRECONDITION 3: this start did NOT migrate anything, which is exactly "
|
||||
"why the guard used to be skipped on it",
|
||||
not _c2r.store_migration.ran)
|
||||
check("⚠️ AND THE LATCH IS CAUGHT ANYWAY. The guard runs on EVERY start now, "
|
||||
"so a trip taken while the install was rolled back is re-armed instead "
|
||||
"of being silently discarded by the version marker. A latch that a "
|
||||
"version change disarms is worse than one that was never persisted, "
|
||||
"because the state file is evidence saying it is fine",
|
||||
_c2r.inverters[0].sign_wd.tripped
|
||||
and "SIGN_DISAGREEMENT" in _c2r.inverters[0].sign_wd.reason
|
||||
and "-1800 W" in _c2r.inverters[0].sign_wd.reason)
|
||||
check("...and it is persisted under the NAMESPACED key as it re-arms, so the "
|
||||
"next start reads it from the live spelling rather than re-deriving it",
|
||||
_trip.data[unit_key(UNIT_A, "sign_tripped")] is True)
|
||||
check("...and the operator is told in the action log, because a latch re-armed "
|
||||
"on their behalf that only appears in a banner is a 0 W hold with no "
|
||||
"explanation",
|
||||
any("re-armed" in e for e in _c2r.events))
|
||||
run(_one_cycle_early(_c2r))
|
||||
check("...and it commands 0 W on its first cycle, which is what makes the "
|
||||
"re-arm a latch rather than a log line",
|
||||
_c2r.target == 0.0 and "failsafe" in _c2r.reason)
|
||||
run(_c2r.handle_action("auto_toggle"))
|
||||
_c2r2, _ = built(store=_trip)
|
||||
check("⚠️ AND THE OPERATOR CAN STILL GET OUT OF IT - ONCE. The press clears "
|
||||
"both spellings, so the next start does NOT re-arm. This is the "
|
||||
"'re-arm forever' objection the `rep.ran` gate existed to answer, "
|
||||
"answered where it belongs: by clearing the fossil rather than by "
|
||||
"refusing to look at it",
|
||||
not _c2r2.inverters[0].sign_wd.tripped
|
||||
and _trip.data["sign_tripped"] is False
|
||||
and _trip.data[unit_key(UNIT_A, "sign_tripped")] is False)
|
||||
|
||||
# A FRESH INSTALL MUST NOT INVENT A LATCH.
|
||||
_fresh = MemStore()
|
||||
@@ -518,6 +643,15 @@ check("⚠️ AND THE LATCHES ARE UNDER SEPARATE KEYS, which is the half that "
|
||||
"it, and every Controller built afterwards read it as A's",
|
||||
_s3.data.get(unit_key("inverter-b", "sign_tripped")) is True
|
||||
and _s3.data.get(unit_key(UNIT_A, "sign_tripped")) is False)
|
||||
check("⚠️ ...AND THE TRIP IS MIRRORED ONTO THE PRE-DEV-02 FLAT PAIR, WHICH IS "
|
||||
"THE ROLLBACK VIEW. store.py's rule 2 keeps the flat keys 'so a rollback "
|
||||
"still finds its latch', and that only ever covered the latch present AT "
|
||||
"MIGRATION TIME: nothing flat was written for a trip taken afterwards, so "
|
||||
"an operator who downgraded found `sign_tripped: false` and came up "
|
||||
"DRIVING, into the fault. The site-level spelling is right for it - the "
|
||||
"version being rolled back TO drives one actuator",
|
||||
_s3.data.get("sign_tripped") is True
|
||||
and "SIGN_DISAGREEMENT" in str(_s3.data.get("sign_reason", "")))
|
||||
|
||||
# THE RESTART, which is the whole reason the latch is persisted at all.
|
||||
_h3b = Hass({"sensor.grid": 2000.0, "sensor.soc_a": 50.0,
|
||||
@@ -563,6 +697,19 @@ check("pressing start clears EVERY latched inverter, under its own key. One "
|
||||
not _c3r.inverters[1].sign_wd.tripped
|
||||
and _s3.data[unit_key("inverter-b", "sign_tripped")] is False
|
||||
and _s3.data[unit_key("inverter-b", "sign_reason")] == "")
|
||||
check("...and the same press cleared the flat rollback view, so a downgrade "
|
||||
"after a clear does not find a trip the operator has already dealt with "
|
||||
"- and neither does the next start, which now reads that key on EVERY "
|
||||
"boot rather than only on the one that migrates",
|
||||
_s3.data["sign_tripped"] is False and _s3.data["sign_reason"] == "")
|
||||
_c3r2, _ = built({"sign_disagree_w": 300, "sign_disagree_s": 1}, store=_s3,
|
||||
hass=_h3b)
|
||||
check("⚠️ AND THE RESTART AFTER THAT CLEAR COMES UP UNLATCHED. This is the "
|
||||
"'re-arm forever' hazard the every-start guard would have if the clear "
|
||||
"did not reach both spellings, driven through the real button and a real "
|
||||
"reconstruction rather than argued",
|
||||
not _c3r2.inverters[0].sign_wd.tripped
|
||||
and not any("re-armed" in e for e in _c3r2.events))
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
@@ -612,6 +759,10 @@ check("...and a bound that could NOT be read is skipped rather than read as "
|
||||
# THE CLAMP. Same requested value, two inverters, two answers.
|
||||
async def _clamp_both():
|
||||
c, hass = built(store=MemStore(), second=inverter_b())
|
||||
# SAFETY-28's gate is upstream of the clamp, so the out-of-range request
|
||||
# has to be one the allocator actually placed or it is substituted with
|
||||
# 0 W and this part grades the gate instead of the clamp.
|
||||
arm(c, {"inverter-a": 9999.0, "inverter-b": 9999.0})
|
||||
await c.write_setpoint(9999.0, force=True, unit=c.inverters[0])
|
||||
await c.write_setpoint(9999.0, force=True, unit=c.inverters[1])
|
||||
return c, hass
|
||||
@@ -811,6 +962,7 @@ async def _hung_heartbeat():
|
||||
hass = Hass({}, {}, hang={"number.sp_a"}, hang_s=30.0)
|
||||
c, _ = built(store=store, hass=hass, second=inverter_b(store))
|
||||
c.inverters[0].target, c.inverters[1].target = 500.0, 700.0
|
||||
arm(c, {"inverter-a": 500.0, "inverter-b": 700.0})
|
||||
t0 = time.monotonic()
|
||||
await c.send_heartbeats(1000.0, 10.0)
|
||||
return c, hass, time.monotonic() - t0, t0
|
||||
@@ -851,6 +1003,7 @@ async def _serialised():
|
||||
hass = Hass({}, {}, hang={"number.sp_a"}, hang_s=30.0)
|
||||
c, _ = built(store=store, hass=hass, second=inverter_b(store))
|
||||
c.inverters[0].target, c.inverters[1].target = 500.0, 700.0
|
||||
arm(c, {"inverter-a": 500.0, "inverter-b": 700.0})
|
||||
t0 = time.monotonic()
|
||||
b_at = None
|
||||
for inv in c.inverters: # <- the shape this card replaces
|
||||
@@ -880,6 +1033,7 @@ async def _split_deadlines():
|
||||
hass = Hass()
|
||||
c, _ = built(store=store, hass=hass, second=inverter_b(store))
|
||||
c.inverters[0].target, c.inverters[1].target = 500.0, 700.0
|
||||
arm(c, {"inverter-a": 500.0, "inverter-b": 700.0})
|
||||
# A was refreshed a moment ago; B has not been refreshed for a long time.
|
||||
c.inverters[0].last_beat, c.inverters[1].last_beat = 1000.0, 0.0
|
||||
await c.send_heartbeats(1005.0, 10.0)
|
||||
@@ -1152,6 +1306,26 @@ MUTATIONS = [
|
||||
"test_inverter.py part 2: 'the migration REPORTS that it failed'"),
|
||||
("`_grade_store_migration()` reduced to a log line",
|
||||
"test_inverter.py part 2: 'the controller REFUSES to come up disarmed'"),
|
||||
# ⚠️ THE THREE ROWS BELOW ARE ONE DEFECT SEEN FROM THREE SIDES, and it was
|
||||
# found by review rather than by a check: the latch was silently DISARMED
|
||||
# by the rollback ROUND TRIP store.py's rule 2 exists for. The guard used to
|
||||
# return early on `not rep.ran`, which is every start after the first, so a
|
||||
# trip taken inside a downgrade window was discarded by the version marker.
|
||||
("`_grade_store_migration()` gated on `rep.ran` again",
|
||||
"test_inverter.py part 2, the ROLLBACK ROUND TRIP: 'AND THE LATCH IS "
|
||||
"CAUGHT ANYWAY'. Every other check in that part passes with the gate "
|
||||
"restored, because every other one runs on the start that migrates"),
|
||||
("the operator's clear leaving the pre-DEV-02 flat keys set",
|
||||
"test_inverter.py part 2: 'THE OPERATOR CAN STILL GET OUT OF IT - ONCE' "
|
||||
"and part 3's 'the restart after that clear comes up UNLATCHED' - i.e. "
|
||||
"the re-arm-forever hazard, which is why the `rep.ran` gate existed"),
|
||||
("a trip writing only the namespaced keys, not the flat rollback view",
|
||||
"test_inverter.py part 3: 'THE TRIP IS MIRRORED ONTO THE PRE-DEV-02 FLAT "
|
||||
"PAIR'. A downgrade after a post-migration trip found no latch at all"),
|
||||
("the guard asking only `self.inverters[0]` whether anything is latched "
|
||||
"instead of asking the STORE - so B's own mirrored trip re-arms A",
|
||||
"test_inverter.py part 3: 'a brand new Controller comes back with B "
|
||||
"latched and A NOT' - the flat-key defect returning through its own guard"),
|
||||
("one shared SignWatchdog: every unit graded on inverter A's command",
|
||||
"test_inverter.py part 3: 'inverter A did NOT trip'"),
|
||||
("`sign_tripped` written flat again instead of namespaced",
|
||||
@@ -1180,6 +1354,20 @@ MUTATIONS = [
|
||||
"test_inverter.py part 4: 'the pack floor is the EMPTIEST pack'"),
|
||||
("`device`/`supervised` filed without `inv` - B's rating on A",
|
||||
"test_peakguard.py 10g: the static routing pin, at the line"),
|
||||
# ⚠️ THE ROW ABOVE IS THE *ACTUATOR* VARIANT AND IT WAS THE ONLY ONE HERE
|
||||
# FOR A WHOLE REVIEW ROUND. The BOUND variant below routes every claim
|
||||
# correctly and builds the NUMBERS from the wrong unit - a different defect
|
||||
# with a different fix, invisible to the routing pin. It survived all 19
|
||||
# files, because test_peakguard.py's AC 4 check compared B's bound against
|
||||
# B's `max_w` while the fixture defaulted B's `max_w` to A's. Both halves
|
||||
# are fixed: B now has a different rating and the bound is asserted against
|
||||
# literal numbers. ANY CHECK ASSERTING "X DIFFERS FROM Y" MUST BE RUN ONCE
|
||||
# WITH X AND Y ACTUALLY MADE DIFFERENT.
|
||||
("the per-unit `device`/`supervised` BOUNDS read off `self.inverters[0]` "
|
||||
"while the `actuator=` stays `inv` - right envelope, wrong numbers",
|
||||
"test_peakguard.py 10g: 'AND EACH UNIT'S supervised BOUND IS ITS OWN "
|
||||
"max_w' and the two lines under it, which read literal 5000/3000 rather "
|
||||
"than an expression that can compare a number against itself"),
|
||||
("the stop button zeroing `self.target` but not each `inv.target`",
|
||||
"test_inverter.py part 6: 'the next heartbeat does NOT put the old shares "
|
||||
"back' - a hazard THIS CARD created, because the heartbeat re-emits a "
|
||||
@@ -1198,7 +1386,7 @@ for what, caught in MUTATIONS:
|
||||
check(f"all {len(MUTATIONS)} defeats of this card are caught by a named check "
|
||||
f"above. A guard whose checks survive its own defeat is not a guard, and "
|
||||
f"this repository has three separate records of exactly that",
|
||||
len(MUTATIONS) == 19)
|
||||
len(MUTATIONS) == 24)
|
||||
|
||||
|
||||
print()
|
||||
|
||||
Reference in New Issue
Block a user