Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
775f3a2e82 | ||
|
|
f109153216 | ||
|
|
f6d7bfcb04 | ||
|
|
d1b7e3f113 | ||
|
|
5e382f52f6 | ||
|
|
46c022c926 |
+159
-17
@@ -247,7 +247,40 @@ class Controller:
|
||||
hold_s=float(opts.get("delivery_hold_s",
|
||||
DELIVERY_OPTION_DEFAULTS["delivery_hold_s"])),
|
||||
)
|
||||
self.store_migration = migrate_unit_keys(store, UNIT_A)
|
||||
# SAFETY-44. THE NAMES THE MACHINES READ, HANDED TO THE MIGRATION THAT
|
||||
# WRITES FOR THEM. This line used to read `migrate_unit_keys(store,
|
||||
# UNIT_A)`, and the constant was wrong in exactly the way the
|
||||
# `maint_migration` call below says the SAME constant would be wrong -
|
||||
# ⚠️ this comment used to cite "line 315" and "two hundred lines
|
||||
# below", and both were wrong. The correction I wrote for them was
|
||||
# wrong too: it said "~54 lines down", which was review's distance to
|
||||
# the WRONG line, carried to a new referent without re-measuring -
|
||||
# inside the sentence apologising for a wrong number. There is no
|
||||
# distance here now, because a distance is a coordinate and the
|
||||
# sentence above already names the thing. On a declared roster the flat
|
||||
# latch landed under
|
||||
# `unit/inverter-a/`, which is on no roster, was read back under the
|
||||
# name it was handed, and stamped the marker. `roster_names()` is the
|
||||
# naming rule itself and returns `[UNIT_A]` on the flat shape, so the
|
||||
# upgrade path every install runs today is byte-identical.
|
||||
#
|
||||
# ⚠️ AND `misdirected` IS UNREACHABLE FROM **THIS** CALL SITE, WHICH THE
|
||||
# COMMIT MESSAGE ORIGINALLY CLAIMED AS A BENEFIT. `unit` is
|
||||
# `roster_names(opts)[0]` and `build_roster()` names its entries from
|
||||
# the same `roster_names()` list, so `unit` is a member of `roster` by
|
||||
# construction and the refusal can never fire here. Review measured it:
|
||||
# dropping the `roster` argument from the `_verify_moves()` call inside
|
||||
# `migrate_unit_keys()` survives 28/28.
|
||||
#
|
||||
# THE ARGUMENT STAYS ANYWAY, and the reason is the one `_verify_moves()`
|
||||
# already gives for keeping its own `roster=None` branch: it is what a
|
||||
# NEW caller gets, and the grading it enables is measured at the
|
||||
# function's own level in test_maintenance.py rather than through here.
|
||||
# What is NOT true is that this call site buys the refusal, so that
|
||||
# claim is gone rather than restated.
|
||||
_roster_names = roster_names(opts)
|
||||
self.store_migration = migrate_unit_keys(
|
||||
store, _roster_names[0], _roster_names)
|
||||
self.inverters = build_roster(opts, store, sign_cfg, delivery_cfg)
|
||||
# ⚠️ CFG-04. SAY WHICH SHAPE IS IN FORCE, BECAUSE THE OTHER ONE'S KEYS
|
||||
# ARE STILL SITTING IN THE UI. A declared `inverters:` roster reads none
|
||||
@@ -751,23 +784,132 @@ class Controller:
|
||||
legacy_tripped = bool(data.get("sign_tripped", False))
|
||||
if not legacy_tripped or not self.inverters:
|
||||
return
|
||||
# ⚠️ THE QUESTION IS "IS ANY NAMESPACED LATCH SET", AND IT IS ASKED OF
|
||||
# THE STORE RATHER THAN OF THE ROSTER. At a roster of two a trip on B
|
||||
# leaves A untripped, so re-arming A off a flat key that B's own trip
|
||||
# mirrored would latch a healthy inverter on every restart - the
|
||||
# flat-key defect this card removed, coming back through its own guard.
|
||||
# And the STORE is the right place to ask: it holds every
|
||||
# `unit/*/sign_tripped` this install has ever written, including one
|
||||
# belonging to a unit that is not in TODAY's roster (a roster is
|
||||
# configuration and it changes), whereas the roster only knows what is
|
||||
# configured right now. The flat key carries new information only when
|
||||
# nothing namespaced is latched at all.
|
||||
namespaced_latched = any(
|
||||
bool(v) for k, v in data.items()
|
||||
if k.startswith("unit/") and k.endswith("/sign_tripped"))
|
||||
if namespaced_latched or any(inv.sign_wd.tripped
|
||||
for inv in self.inverters):
|
||||
# ⚠️ THE QUESTION IS "IS ANY NAMESPACED LATCH SET **THAT A MACHINE
|
||||
# READS**", AND THE LAST CLAUSE IS SAFETY-44's SECOND HALF. At a roster
|
||||
# of two a trip on B leaves A untripped, so re-arming A off a flat key
|
||||
# that B's own trip mirrored would latch a healthy inverter on every
|
||||
# restart - the flat-key defect DEV-02 removed, coming back through its
|
||||
# own guard. That much stands, and it is why the question is asked of
|
||||
# the STORE's keys rather than of the roster's objects.
|
||||
#
|
||||
# ⚠️ BUT IT USED TO ACCEPT **ANY** `unit/*/sign_tripped`, and the
|
||||
# paragraph here defended that: the store "holds every one this install
|
||||
# has ever written, including one belonging to a unit that is not in
|
||||
# TODAY's roster ... whereas the roster only knows what is configured
|
||||
# right now". THE PREMISE IS TRUE AND IT ARGUES THE WRONG WAY. A latch
|
||||
# under a name no unit answers to is not protection an operator has - it
|
||||
# is a key nothing reads, which is the definition of the state this
|
||||
# method exists to repair. Counting it as protection made the guard
|
||||
# return early on precisely the install that needed it, and that is not
|
||||
# hypothetical: it is where the migration above USED to put the latch
|
||||
# on every declared roster, so the guard was blinded by its own upgrade.
|
||||
#
|
||||
# THE MIGRATION FIX DOES NOT REACH THOSE INSTALLS. A store stamped by
|
||||
# the shipped version already has the marker, so `migrate_unit_keys()`
|
||||
# returns at the version check and never runs again. This guard is the
|
||||
# ONLY repair path for a site that upgraded before this card, and it
|
||||
# re-arms onto `inverters[0]` - which lands the latch back under a name
|
||||
# the roster reads.
|
||||
# ⚠️ AND "OFF THE ROSTER" IS NOT ENOUGH ON ITS OWN - IT HAS TO BE THE
|
||||
# MIGRATION'S OWN FINGERPRINT, WHICH IS `UNIT_A`. The first draft of
|
||||
# this treated EVERY off-roster latch as litter, and test_inverter.py's
|
||||
# DEV-02 restart case caught it: there the store carries
|
||||
# `unit/inverter-b/sign_tripped = True` from B's own trip while the
|
||||
# roster being graded holds only A, and the blunt rule re-armed a
|
||||
# HEALTHY A off the flat key that B's trip had mirrored - DEV-02's
|
||||
# defect rebuilt inside its own guard, which is exactly what the
|
||||
# paragraph above was right to be afraid of.
|
||||
#
|
||||
# `UNIT_A` is the discriminator because it is the ONLY name the broken
|
||||
# migration ever wrote. `roster_names()` falls back to a POSITIONAL
|
||||
# `inverter-N`, so `inverter-a` is not a name this code hands to anyone
|
||||
# else. A latch stranded under any OTHER name is a real unit that is
|
||||
# out of today's config - a roster is configuration and it changes -
|
||||
# and it still counts as protection.
|
||||
#
|
||||
# ⚠️ THE RESIDUAL, STATED RATHER THAN LEFT TO BE FOUND: a site whose
|
||||
# own first unit is NAMED `inverter-a`, latched, and then taken out of
|
||||
# the roster gets re-armed on `inverters[0]`. That is a healthy unit
|
||||
# held at 0 W until an operator clears it, which is the conservative
|
||||
# direction; SAFETY-44's is a site DRIVING on a state file that says it
|
||||
# is latched.
|
||||
#
|
||||
# ⚠️ AND THE SECOND HALF OF THAT RESIDUAL IS THE **SENTENCE**, NOT THE
|
||||
# BOOLEAN. The reason re-armed from is the FLAT mirror, which carries
|
||||
# whatever tripped LAST - so on a multi-unit site this path can print
|
||||
# one inverter's evidence under another inverter's name. The latch is
|
||||
# right and the explanation may not be. It is disclosed here because the
|
||||
# banner is what an operator acts on, and `test_maintenance.py`'s check
|
||||
# that the reason is not the removed unit's is guaranteed by that
|
||||
# fixture's choice of flat string, NOT by this code.
|
||||
# ⚠️ AND THE THREE CASES ARE THREE VARIABLES, NOT ONE SHARED FLAG.
|
||||
# THIS IS WHERE THE FIRST VERSION OF THIS FIX WAS DEFEATED, BY ONE KEY.
|
||||
# It wrote `namespaced_latched = True` for BOTH "on the roster" and "off
|
||||
# the roster but not UNIT_A", so a single latched key belonging to a
|
||||
# unit the operator had removed from `inverters:` vetoed the repair of
|
||||
# the misdirected `inverter-a` latch sitting beside it. Measured on the
|
||||
# shipped build, roster ['garage', 'shed'], store stamped so the
|
||||
# migration cannot reach it:
|
||||
#
|
||||
# sign_tripped + unit/inverter-a/sign_tripped
|
||||
# -> [('garage', True), ('shed', False)] repaired
|
||||
# ...AND unit/old-shed/sign_tripped
|
||||
# -> [('garage', False), ('shed', False)] ⚠️ DRIVING
|
||||
#
|
||||
# A site DRIVING on a state file that reads `sign_tripped: true`, which
|
||||
# is the sentence this whole card exists to make false - and
|
||||
# `tools/rehearse_roster.py` said so, on the unmodified tree, exit 1.
|
||||
#
|
||||
# ⚠️ IT IS ALSO INSTANCE #17 OF HABIT 4, IN THE EXTENSION MUTATION.md
|
||||
# ADDED TWO DAYS AGO. The two fixtures written for this card each hold
|
||||
# ONE arrangement - `_staged_store` has only `inverter-a`, `_other_store`
|
||||
# has only `inverter-b` - and the flag is written twice with opposite
|
||||
# intents only when BOTH are present. A per-key check whose keys have
|
||||
# never disagreed WITHIN ONE STORE is a check over whichever key came
|
||||
# last. The fixture below now holds both.
|
||||
#
|
||||
# THE THREE CASES, AND WHY THE ANSWER DIFFERS FOR EACH:
|
||||
# on the roster a machine reads it: real protection, stop.
|
||||
# off-roster, `UNIT_A` the broken migration's fingerprint - this
|
||||
# IS the flat latch, misdirected. Repair.
|
||||
# off-roster, other name a real unit out of today's config. Its
|
||||
# latch is unread too, but it is NOT the flat
|
||||
# key's misdirection, and re-arming a healthy
|
||||
# sibling off it is DEV-02's defect. Stop -
|
||||
# UNLESS a stranded `UNIT_A` latch is also
|
||||
# present, because then the flat key IS
|
||||
# accounted for and the repair is the point.
|
||||
on_roster = {inv.name for inv in self.inverters}
|
||||
stranded, on_roster_latched, other_off_roster = [], False, False
|
||||
for k, v in data.items():
|
||||
if not (k.startswith("unit/") and k.endswith("/sign_tripped")):
|
||||
continue
|
||||
if not bool(v):
|
||||
continue
|
||||
# `unit_key()` builds `unit/<unit>/<key>`, so the unit is field 1 -
|
||||
# split through the writer's own spelling rather than re-deriving.
|
||||
unit = k.split("/")[1]
|
||||
if unit in on_roster:
|
||||
on_roster_latched = True
|
||||
elif unit == UNIT_A:
|
||||
stranded.append(k)
|
||||
else:
|
||||
other_off_roster = True
|
||||
if on_roster_latched or any(inv.sign_wd.tripped
|
||||
for inv in self.inverters):
|
||||
return
|
||||
if other_off_roster and not stranded:
|
||||
# A removed unit's own latch, and no misdirected one. The flat key
|
||||
# is that unit's mirror, so it carries no new information and
|
||||
# re-arming `inverters[0]` from it would latch a healthy pack.
|
||||
return
|
||||
if stranded:
|
||||
_LOG.error(
|
||||
"SAFETY-44: this store carries a LATCHED sign-disagreement "
|
||||
"failsafe under %s, and that unit is NOT on today's roster "
|
||||
"%s - so no machine reads it and the site came up DRIVING on "
|
||||
"a state file that says it is latched. Re-arming below.",
|
||||
", ".join(sorted(stranded)), sorted(on_roster))
|
||||
first = self.inverters[0]
|
||||
# The one state this method exists for.
|
||||
reason = str(data.get("sign_reason", "") or "")
|
||||
|
||||
@@ -253,7 +253,7 @@ class MigrationReport:
|
||||
return out
|
||||
|
||||
|
||||
def migrate_unit_keys(store, unit: str) -> MigrationReport:
|
||||
def migrate_unit_keys(store, unit: str, roster=None) -> MigrationReport:
|
||||
"""Move the pre-DEV-02 flat per-inverter keys into `unit`'s namespace.
|
||||
|
||||
`unit` is the FIRST inverter in the roster, which on every install that can
|
||||
@@ -261,6 +261,24 @@ def migrate_unit_keys(store, unit: str) -> MigrationReport:
|
||||
version that knew about more than one inverter is already at
|
||||
SCHEMA_VERSION and is not touched.
|
||||
|
||||
⚠️ `roster` IS THE NAMES THE MACHINES READ, AND IT IS SAFETY-44. This
|
||||
function shipped WITHOUT it, taking a hardcoded `inverter.UNIT_A` at its one
|
||||
call site - which is, exactly, the MAINT-05 defect `_verify_moves()` spends
|
||||
a paragraph describing, still live in the sibling that paragraph was written
|
||||
beside. On a store that ran pre-DEV-02 flat and declares
|
||||
`inverters: [garage, shed]`, the latch moved to `unit/inverter-a/
|
||||
sign_tripped`, was read back under that same name, was SATISFIED, and
|
||||
stamped the marker - so the next start skipped the migration too and NO
|
||||
inverter came up latched on an install whose state file says `sign_tripped:
|
||||
true`. A disarmed SAFETY-02 guard, every symptom of a clean migration.
|
||||
|
||||
`roster_names(opts)` answers this BEFORE any `Inverter` exists - it is
|
||||
defined to, for `MqttPublisher`, which is constructed before the roster for
|
||||
the same reason - so the "there is no roster yet" that kept this argument
|
||||
out was never true. On the flat shape it returns `[UNIT_A]`, so the upgrade
|
||||
every install runs today passes `unit == roster[0] == UNIT_A` and is
|
||||
byte-identical.
|
||||
|
||||
THE THREE RULES, each one a case that would otherwise be a wrong answer:
|
||||
|
||||
1. IT NEVER OVERWRITES AN EXISTING NAMESPACED VALUE. A store that has both
|
||||
@@ -302,7 +320,7 @@ def migrate_unit_keys(store, unit: str) -> MigrationReport:
|
||||
|
||||
rep.ran = True
|
||||
_move_flat_keys(store, unit, LEGACY_UNIT_KEYS, rep)
|
||||
_verify_moves(store, rep)
|
||||
_verify_moves(store, rep, roster)
|
||||
|
||||
if rep.ok:
|
||||
store.set(SCHEMA_KEY, SCHEMA_VERSION)
|
||||
@@ -383,12 +401,26 @@ def _verify_moves(store, rep: MigrationReport, roster=None) -> None:
|
||||
itself. `test_maintenance.py` section 9c calls this twice with the SAME
|
||||
wrong `unit` over one store, which is the shape nothing in the suite had.
|
||||
|
||||
`roster=None` means "not graded", and the one caller that passes it is
|
||||
`migrate_unit_keys()`, which runs BEFORE `build_roster()` for the reason its
|
||||
own docstring gives - the `Inverter` objects restore their latch in their
|
||||
constructors, so the keys have to be in place before there is a roster to
|
||||
check against. That gap is real and is recorded as a running check rather
|
||||
than left to a comment: see test_maintenance.py section 13.
|
||||
`roster=None` still means "not graded", and SAFETY-44 is what it cost. This
|
||||
paragraph used to end here, saying that the one caller passing it was
|
||||
`migrate_unit_keys()`, "which runs BEFORE `build_roster()` ... so the keys
|
||||
have to be in place before there is a roster to check against", and calling
|
||||
that gap real. THE FIRST HALF IS TRUE AND THE CONCLUSION IS NOT. The keys do
|
||||
have to be written before the `Inverter`s restore from them - but the NAMES
|
||||
do not come from the `Inverter`s. `roster_names(opts)` reads them straight
|
||||
off the options, before anything is constructed, which is the whole reason
|
||||
it exists (`MqttPublisher` is built before the roster too). So the argument
|
||||
that could have graded that write was available at the call site the entire
|
||||
time, and the sibling function three screens down had already been fixed by
|
||||
passing it.
|
||||
|
||||
⚠️ SO NO CALLER PASSES `None` TODAY, and this branch is kept for one reason:
|
||||
it is what a NEW caller gets by DEFAULT. A future migration that forgets the
|
||||
roster gets the ungraded read-back and stamps its marker over a namespace no
|
||||
machine reads - silently, exactly as this one did. That is the shape of both
|
||||
defects, and the gap is recorded as a running check rather than left to a
|
||||
comment: test_maintenance.py section 13, and test_rehearse.py part 5, which
|
||||
drives it end-to-end through the harness an operator actually runs.
|
||||
"""
|
||||
for target, value in rep.moved.items():
|
||||
got = getattr(store, "data", {}).get(target, "<<missing>>")
|
||||
|
||||
@@ -21,6 +21,7 @@ Read MUTATION.md before you report a survivor.
|
||||
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -34,7 +35,8 @@ from app.inverter import UNIT_A
|
||||
from app.main import Controller
|
||||
from app.store import (
|
||||
DEFAULTS, MAINT_SCHEMA_KEY, MAINT_SCHEMA_VERSION, MAINT_UNIT_KEYS,
|
||||
migrate_maint_keys, unit_key,
|
||||
SCHEMA_KEY, SCHEMA_VERSION, migrate_maint_keys, migrate_unit_keys,
|
||||
unit_key,
|
||||
)
|
||||
|
||||
fails = []
|
||||
@@ -1411,45 +1413,39 @@ check("...while the SAME site with the house importing more than the drain "
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("13. RECORDED, NOT FIXED: SAFETY-02's migration has the same "
|
||||
"wrong-namespace hole, and it is a LATCH")
|
||||
print("13. FIXED (SAFETY-44): SAFETY-02's migration takes the roster too, and "
|
||||
"the guard is no longer defeated by the key the defect wrote")
|
||||
|
||||
# ⚠️ FOUND WHILE FIXING THIS CARD'S OWN INSTANCE, AND WRITTEN DOWN AS A RUNNING
|
||||
# CHECK RATHER THAN A COMMENT SO IT CANNOT BE FORGOTTEN OR QUIETLY "FIXED" BY AN
|
||||
# UNRELATED EDIT. It is the SAME defect one module over, and it costs more.
|
||||
# ⚠️ THIS SECTION USED TO BE THE SPECIFICATION OF A DEFECT. It was written while
|
||||
# fixing MAINT-05's own instance, as a RUNNING check rather than a comment, with
|
||||
# the instruction that "the card that fixes it should expect them to go red and
|
||||
# should replace them". SAFETY-44 is that card and this is that replacement.
|
||||
#
|
||||
# WHAT IT IS. `Controller.__init__` calls `migrate_unit_keys(store, UNIT_A)` -
|
||||
# a hardcoded constant, where the maintenance migration now passes the roster's
|
||||
# own first name. It has to run BEFORE `build_roster()`, because each
|
||||
# `Inverter` restores its sign latch in its constructor, so there is no roster
|
||||
# to name yet and `_verify_moves()` is passed `roster=None`. On a DECLARED
|
||||
# roster the pre-DEV-02 flat `sign_tripped: true` therefore lands under
|
||||
# `unit/inverter-a/sign_tripped`, which no unit on this site reads.
|
||||
# WHAT IT WAS. `Controller.__init__` called `migrate_unit_keys(store, UNIT_A)` -
|
||||
# a hardcoded constant, where the maintenance migration already passed the
|
||||
# roster's own first name. On a DECLARED roster the pre-DEV-02 flat
|
||||
# `sign_tripped: true` landed under `unit/inverter-a/sign_tripped`, which no
|
||||
# unit on the site reads, and `_verify_moves()` read it back under the name it
|
||||
# had been handed and was satisfied.
|
||||
#
|
||||
# WHY THE EXISTING GUARD DOES NOT CATCH IT, WHICH IS THE PART WORTH PINNING.
|
||||
# `_grade_store_migration()` re-arms the latch when the flat key says tripped
|
||||
# and NOTHING NAMESPACED IS LATCHED. The misdirected write IS namespaced and IS
|
||||
# latched, so `namespaced_latched` is True, the guard returns, and no inverter
|
||||
# on the roster comes up tripped. The guard is defeated by the very key the
|
||||
# defect wrote.
|
||||
# AND THE GUARD DID NOT CATCH IT, WHICH WAS THE PART WORTH PINNING.
|
||||
# `_grade_store_migration()` re-arms when the flat key says tripped and NOTHING
|
||||
# NAMESPACED IS LATCHED - and the misdirected write IS namespaced and IS
|
||||
# latched. The guard was defeated by the very key the defect wrote.
|
||||
#
|
||||
# WHY IT IS NOT FIXED HERE. The remedy is to name the migration's target from
|
||||
# the config before any `Inverter` exists, which means factoring
|
||||
# `build_roster()`'s naming rule out of `build_roster()` so there is still only
|
||||
# ONE spelling of it - inverter.py and SAFETY-02's latch semantics, with its own
|
||||
# mutation battery over test_inverter.py and test_watchdog.py. MAINT-05 moves
|
||||
# the maintenance history and nothing about the sign latch.
|
||||
#
|
||||
# ⚠️ AND IT IS A DISARMED SAFETY GUARD, NOT LOST HISTORY. Read the checks below
|
||||
# as the SPECIFICATION OF A DEFECT: every one of them is asserting the WRONG
|
||||
# behaviour, on purpose, and the card that fixes it should expect them to go
|
||||
# red and should replace them.
|
||||
# ⚠️ THE REASON IT WAS LEFT - "there is no roster to name yet, because each
|
||||
# `Inverter` restores its latch in its constructor" - WAS THE HALF-TRUTH THAT
|
||||
# KEPT IT ALIVE. The keys must be written before the `Inverter`s read them; the
|
||||
# NAMES do not come from the `Inverter`s. `roster_names(opts)` reads them off
|
||||
# the options before anything is constructed, which is what it already existed
|
||||
# to do for `MqttPublisher`. The factoring this section said the fix would need
|
||||
# had been done a card earlier.
|
||||
#
|
||||
# ⚠️ NO `schema_version` IN THIS STORE, AND THAT IS THE PRE-DEV-02 SHAPE
|
||||
# ITSELF: an install that has never run a version with namespaced keys, which is
|
||||
# what makes `migrate_unit_keys()` run at all.
|
||||
_lat_store = MemStore({"sign_tripped": True,
|
||||
"sign_reason": "SIGN_DISAGREEMENT: measured pre-DEV-02"})
|
||||
"sign_reason": "SIGN_DISAGREEMENT: SYNTHETIC FIXTURE, not measured"})
|
||||
_lat, _ = controller(NAMED_OPTS, store=_lat_store)
|
||||
print(f" pre-DEV-02 flat latch + declared roster {_C2_NAMES} -> "
|
||||
f"{_lat.store_migration.explain()}")
|
||||
@@ -1457,29 +1453,179 @@ print(f" latch keys in the store: "
|
||||
f"{sorted(k for k in _lat_store.data if k.endswith('sign_tripped'))}")
|
||||
print(f" roster came up tripped: "
|
||||
f"{[(i.name, i.sign_wd.tripped) for i in _lat.inverters]}")
|
||||
check("the flat pre-DEV-02 latch is migrated into `unit/inverter-a/` - a name "
|
||||
"that is NOT on this roster, so no unit reads it. This is the same "
|
||||
"wrong-namespace write `migrate_maint_keys()` now refuses, in the "
|
||||
"migration that runs before there is a roster to grade against",
|
||||
_lat_store.data.get(unit_key(UNIT_A, "sign_tripped")) is True
|
||||
and unit_key(_C2_NAMES[0], "sign_tripped") not in _lat_store.data)
|
||||
check("⚠️ AND NO INVERTER ON THE ROSTER COMES UP LATCHED, on a store whose file "
|
||||
"still reads `sign_tripped: true`. That is DEV-02's own sentence for the "
|
||||
"worst case - a disarmed safety guard on an install that believes it is "
|
||||
"protected",
|
||||
not any(i.sign_wd.tripped for i in _lat.inverters)
|
||||
check("⭐ the flat pre-DEV-02 latch is migrated into the ROSTER'S OWN first "
|
||||
"namespace and NOT into `unit/inverter-a/` - the same wrong-namespace "
|
||||
"write `migrate_maint_keys()` refuses, now refused in the migration that "
|
||||
"runs before the `Inverter` objects exist",
|
||||
_lat_store.data.get(unit_key(_C2_NAMES[0], "sign_tripped")) is True
|
||||
and unit_key(UNIT_A, "sign_tripped") not in _lat_store.data)
|
||||
check("⭐ AND AN INVERTER ON THE ROSTER COMES UP LATCHED, which is the whole "
|
||||
"card: a store whose file reads `sign_tripped: true` no longer produces "
|
||||
"a site that drives",
|
||||
_lat.inverters[0].sign_wd.tripped
|
||||
and _lat_store.data["sign_tripped"] is True)
|
||||
check("...and `_grade_store_migration()` is DEFEATED BY THE KEY THE DEFECT "
|
||||
"WROTE, which is why this needs a card rather than a tweak: its "
|
||||
"'is anything namespaced latched' test is satisfied by the misdirected "
|
||||
"value itself, so it returns instead of re-arming",
|
||||
any(k.startswith("unit/") and k.endswith("/sign_tripped") and v
|
||||
for k, v in _lat_store.data.items())
|
||||
and not any(i.sign_wd.tripped for i in _lat.inverters))
|
||||
check("...and the latch that came back carries the REASON, not just the "
|
||||
"boolean - an operator meeting a FAILSAFE banner with no sentence under "
|
||||
"it has been told the site stopped and not why",
|
||||
"SIGN_DISAGREEMENT" in _lat.inverters[0].sign_wd.reason)
|
||||
check("⚠️ ...and the OTHER units on the roster are NOT latched by it. The flat "
|
||||
"key is one site-level boolean and re-arming every unit off it would be "
|
||||
"DEV-02's defect in the opposite direction - a healthy pack parked "
|
||||
"because its sibling once disagreed",
|
||||
not any(i.sign_wd.tripped for i in _lat.inverters[1:]))
|
||||
|
||||
# ⚠️ AND THE OTHER PATH, WHICH THE MIGRATION FIX ALONE DOES NOT REACH: a store
|
||||
# ALREADY STAMPED by the shipped version. `migrate_unit_keys()` returns at its
|
||||
# `from_version >= SCHEMA_VERSION` check and never moves anything, so the
|
||||
# stranded `unit/inverter-a/sign_tripped` just sits there. This is the STAGED
|
||||
# shape - "upgrade now, declare the roster later" - and it is the one an
|
||||
# operator actually produces. Only the GUARD repairs it.
|
||||
_staged_store = MemStore({
|
||||
"sign_tripped": True,
|
||||
"sign_reason": "SIGN_DISAGREEMENT: SYNTHETIC FIXTURE, not measured",
|
||||
unit_key(UNIT_A, "sign_tripped"): True,
|
||||
unit_key(UNIT_A, "sign_reason"): "SIGN_DISAGREEMENT: SYNTHETIC FIXTURE, not measured",
|
||||
SCHEMA_KEY: SCHEMA_VERSION})
|
||||
_staged, _ = controller(NAMED_OPTS, store=_staged_store)
|
||||
print(f" already-stamped store + declared roster -> ran="
|
||||
f"{_staged.store_migration.ran}, came up tripped: "
|
||||
f"{[(i.name, i.sign_wd.tripped) for i in _staged.inverters]}")
|
||||
check("⚠️ PRECONDITION: the migration really does NOT run on this store - the "
|
||||
"marker is already there - so anything below is the guard's doing and "
|
||||
"not the migration's",
|
||||
not _staged.store_migration.ran)
|
||||
check("⭐ and the roster's first unit STILL comes up latched: the guard no "
|
||||
"longer counts a latch stranded under `UNIT_A`, which is not on this "
|
||||
"roster, as protection an operator has",
|
||||
_staged.inverters[0].sign_wd.tripped)
|
||||
check("...and the re-armed latch is written where a machine reads it, so the "
|
||||
"NEXT start finds it without needing the guard again",
|
||||
_staged_store.data.get(unit_key(_C2_NAMES[0], "sign_tripped")) is True)
|
||||
|
||||
# ⚠️ THE NEGATIVE CONTROL FOR THAT NARROWING, because "off the roster" on its
|
||||
# own was too blunt and test_inverter.py's DEV-02 restart case caught it: a
|
||||
# latch stranded under a name that is NOT `UNIT_A` is a real unit out of
|
||||
# today's config, and it MUST still count as protection.
|
||||
_other_store = MemStore({
|
||||
"sign_tripped": True,
|
||||
"sign_reason": "SIGN_DISAGREEMENT: B disagreed, mirrored flat",
|
||||
unit_key("inverter-b", "sign_tripped"): True,
|
||||
unit_key("inverter-b", "sign_reason"): "SIGN_DISAGREEMENT: B disagreed",
|
||||
SCHEMA_KEY: SCHEMA_VERSION})
|
||||
_other, _ = controller(NAMED_OPTS, store=_other_store)
|
||||
print(f" stranded latch under `inverter-b` -> came up tripped: "
|
||||
f"{[(i.name, i.sign_wd.tripped) for i in _other.inverters]}")
|
||||
check("⚠️ a latch stranded under a name that is not `UNIT_A` is NOT re-armed "
|
||||
"onto the roster: it belongs to a unit the operator has taken out of "
|
||||
"the config, the flat key is its own mirror, and re-arming a healthy "
|
||||
"sibling off it is the defect DEV-02 removed",
|
||||
not any(i.sign_wd.tripped for i in _other.inverters))
|
||||
|
||||
# ⭐ AND THE ARRANGEMENT NEITHER FIXTURE ABOVE HOLDS: **BOTH** KEYS AT ONCE.
|
||||
# ⚠️ THIS IS INSTANCE #17 OF HABIT 4 AND IT WAS FOUND IN THIS CARD'S OWN FIX.
|
||||
# The first version of the guard classified each key and then wrote ONE shared
|
||||
# `namespaced_latched` flag for both "on the roster" and "off-roster but not
|
||||
# UNIT_A". `_staged_store` above holds only an `inverter-a` latch and
|
||||
# `_other_store` below holds only an `inverter-b` one, so the flag was never
|
||||
# written twice with opposite intents in a single store - and a per-key check
|
||||
# whose keys have never disagreed WITHIN ONE STORE is a check over whichever
|
||||
# key happened to come last. Measured on that build, one added key:
|
||||
#
|
||||
# sign_tripped + unit/inverter-a/sign_tripped -> garage latched, repaired
|
||||
# ...AND unit/old-shed/sign_tripped -> ⚠️ NOTHING latched
|
||||
#
|
||||
# A site DRIVING on a state file that reads `sign_tripped: true` - the sentence
|
||||
# this card exists to make false - reached by an operator taking one unit out of
|
||||
# `inverters:` after it had tripped. "A roster is configuration and it changes"
|
||||
# is this module's own phrase, and the second inverter goes in this month.
|
||||
_both_store = MemStore({
|
||||
"sign_tripped": True,
|
||||
"sign_reason": "SIGN_DISAGREEMENT: SYNTHETIC FIXTURE, not measured",
|
||||
unit_key(UNIT_A, "sign_tripped"): True,
|
||||
unit_key(UNIT_A, "sign_reason"): "SIGN_DISAGREEMENT: SYNTHETIC FIXTURE, not measured",
|
||||
unit_key("old-shed", "sign_tripped"): True,
|
||||
unit_key("old-shed", "sign_reason"): "SIGN_DISAGREEMENT: old-shed, since removed",
|
||||
SCHEMA_KEY: SCHEMA_VERSION})
|
||||
_both, _ = controller(NAMED_OPTS, store=_both_store)
|
||||
print(f" stranded `inverter-a` AND an unrelated off-roster `old-shed` -> "
|
||||
f"{[(i.name, i.sign_wd.tripped) for i in _both.inverters]}")
|
||||
check("⭐ A STRANDED `inverter-a` LATCH IS STILL REPAIRED WHEN AN UNRELATED "
|
||||
"OFF-ROSTER LATCH SITS BESIDE IT. One shared flag let `old-shed` veto the "
|
||||
"repair, and the site came up driving on a file that says it is latched",
|
||||
_both.inverters[0].sign_wd.tripped)
|
||||
check("...and the repair still lands on the roster's first unit, not on the "
|
||||
"removed one",
|
||||
_both_store.data.get(unit_key(_C2_NAMES[0], "sign_tripped")) is True)
|
||||
check("⚠️ ...and the reason carried across is the FLAT one - the misdirected "
|
||||
"latch's own - and not `old-shed`'s, which belongs to a different "
|
||||
"physical inverter",
|
||||
"old-shed" not in _both.inverters[0].sign_wd.reason)
|
||||
|
||||
# ⚠️ AND THE OPERATOR MESSAGE IS ASSERTED, because review deleted it whole
|
||||
# (`if stranded:` -> `if False:`) and 28/28 stayed green. `stranded` has no
|
||||
# other consumer, so the list existed only to print a line nothing checked -
|
||||
# and the line is the entire operator-facing half of this repair: it names the
|
||||
# key that was stranded and the roster it was measured against. A repair an
|
||||
# operator cannot see is a repair they cannot confirm.
|
||||
_log_store = MemStore({
|
||||
"sign_tripped": True,
|
||||
"sign_reason": "SIGN_DISAGREEMENT: SYNTHETIC FIXTURE, not measured",
|
||||
unit_key(UNIT_A, "sign_tripped"): True,
|
||||
unit_key(UNIT_A, "sign_reason"): "SIGN_DISAGREEMENT: SYNTHETIC FIXTURE, not measured",
|
||||
SCHEMA_KEY: SCHEMA_VERSION})
|
||||
_records = []
|
||||
_h = logging.Handler()
|
||||
_h.emit = lambda r: _records.append(r.getMessage())
|
||||
logging.getLogger("goodwe").addHandler(_h)
|
||||
try:
|
||||
_logged, _ = controller(NAMED_OPTS, store=_log_store)
|
||||
finally:
|
||||
logging.getLogger("goodwe").removeHandler(_h)
|
||||
_s44_lines = [m for m in _records if "SAFETY-44" in m]
|
||||
print(f" SAFETY-44 operator lines logged: {len(_s44_lines)}")
|
||||
check("⭐ the repair SAYS SO, at ERROR, naming SAFETY-44 - deleting this line "
|
||||
"left the whole suite green, and it is how an operator learns their "
|
||||
"latch was re-armed rather than never lost",
|
||||
len(_s44_lines) == 1)
|
||||
check("...and it names the STRANDED KEY, so the operator can find it in the "
|
||||
"state file rather than being told something happened",
|
||||
any(unit_key(UNIT_A, "sign_tripped") in m for m in _s44_lines))
|
||||
check("...and the ROSTER it was measured against, which is the half that says "
|
||||
"WHY no machine read it",
|
||||
any("garage" in m and "shed" in m for m in _s44_lines))
|
||||
|
||||
# ⭐ AND `misdirected` IS MEASURED AT THE FUNCTION'S OWN LEVEL, BECAUSE IT IS
|
||||
# UNREACHABLE FROM THE CALL SITE. `Controller.__init__` passes
|
||||
# `roster_names(opts)[0]`, which `build_roster()` also names its first entry
|
||||
# from, so `unit` is a member of `roster` by construction and the refusal can
|
||||
# never fire there. Review measured that: dropping the `roster` argument from
|
||||
# `_verify_moves()` inside `migrate_unit_keys()` survives 28/28. The argument is
|
||||
# kept for the reason `_verify_moves()` gives for its own `roster=None` branch -
|
||||
# it is what a NEW caller gets by default - and a guard nothing exercises is a
|
||||
# guard nobody knows works, so it is exercised HERE rather than claimed.
|
||||
_mis_store = MemStore({"sign_tripped": True,
|
||||
"sign_reason": "SIGN_DISAGREEMENT: SYNTHETIC FIXTURE, not measured"})
|
||||
_mis = migrate_unit_keys(_mis_store, UNIT_A, ["garage", "shed"])
|
||||
print(f" migrate_unit_keys(store, 'inverter-a', ['garage','shed']) -> "
|
||||
f"ok={_mis.ok} misdirected={sorted(_mis.misdirected)}")
|
||||
check("⭐ a WRONG `unit` - one that is not on the roster it was handed - comes "
|
||||
"back `misdirected`, which is the refusal `migrate_maint_keys()` already "
|
||||
"had and this one did not",
|
||||
bool(_mis.misdirected) and not _mis.ok)
|
||||
check("...and the schema marker is NOT stamped, so the migration runs again on "
|
||||
"the next start rather than being recorded as finished - the one thing "
|
||||
"store.py's header says must never happen",
|
||||
_mis_store.data.get(SCHEMA_KEY) != SCHEMA_VERSION)
|
||||
check("⚠️ ...while the SAME call with a unit that IS on the roster stamps the "
|
||||
"marker - so the refusal is caused by the name and not by the fixture",
|
||||
migrate_unit_keys(
|
||||
MemStore({"sign_tripped": True, "sign_reason": "x"}),
|
||||
"garage", ["garage", "shed"]).ok)
|
||||
|
||||
# The counterfactual, and it is the accepted baseline habit 7 asks for: the
|
||||
# same store, the same migration, one thing different - the roster shape.
|
||||
_flat_lat_store = MemStore({"sign_tripped": True,
|
||||
"sign_reason": "SIGN_DISAGREEMENT: measured pre-DEV-02"})
|
||||
"sign_reason": "SIGN_DISAGREEMENT: SYNTHETIC FIXTURE, not measured"})
|
||||
_flat_lat, _ = controller(FLAT_OPTS, store=_flat_lat_store)
|
||||
print(f" the same store on the FLAT config -> roster "
|
||||
f"{[(i.name, i.sign_wd.tripped) for i in _flat_lat.inverters]}")
|
||||
|
||||
@@ -411,6 +411,73 @@ check("...and `phase` is NOT among them, because its value 'idle' IS the "
|
||||
"phase" not in _m9_vanished)
|
||||
shutil.rmtree(Path(_m9).parent, ignore_errors=True)
|
||||
|
||||
# ⚠️ AND `M12` BOUNDS THE **MOVED-NOT-LOST** RULE, WHICH NOTHING DID - AND ITS
|
||||
# FIRST DRAFT DID NOT EITHER, WHICH IS THE WORSE FAILURE OF THE TWO.
|
||||
#
|
||||
# The rule cited M3 as its bound, and the commit message cited it too. M3 drops
|
||||
# `sign_reason` from `LEGACY_UNIT_KEYS`; M9, the one actually meant, vanishes
|
||||
# only FLAT keys, so neither can satisfy the `unit/` gate the rule sits behind.
|
||||
# THE BRANCH HAD NO CONTROL AT ALL.
|
||||
#
|
||||
# ⚠️ THE FIRST FIX FOR THAT WAS INERT, AND REVIEW MEASURED IT: run M12's mutant
|
||||
# over `state-legacy.json` and the findings diff against the UNMUTATED tree is
|
||||
# EMPTY. It "passed" because that fixture's STAGED leg is already exit 1 on
|
||||
# pre-existing litter, and `"migrated unit/shed/" not in out` was trivially
|
||||
# true because a pre-DEV-02 FLAT store has no shed latch to lose - after
|
||||
# migration only the FIRST roster entry is ever latched. A control written
|
||||
# against a fixture that cannot hold the failure is habit 4 wearing the clothes
|
||||
# of its own fix, and it is WORSE than the honest "no control" it replaced,
|
||||
# because it reads as checked.
|
||||
#
|
||||
# ⭐ SO THE FIXTURE CAME FIRST. `state-both-latched.json` is a store at
|
||||
# SCHEMA_VERSION with `unit/garage/*` AND `unit/shed/*` both latched, by ONE
|
||||
# site event so the reasons match too - the arrangement in which the
|
||||
# suppression is most dangerous, and the one no existing fixture could produce.
|
||||
# The mutant then makes ONE roster unit's latch key VANISH while its sibling's
|
||||
# survives at the same value.
|
||||
#
|
||||
# THE DECISIVE MEASUREMENT, which is what the previous draft could not make:
|
||||
# reverting the `vanished_unit not in after.units` narrowing - restoring exactly
|
||||
# the rule review proved hides a real loss - turns this RED. Before, it was
|
||||
# 28/28 green.
|
||||
_M12_OLD = 'tripped=bool(data.get(self.key("sign_tripped"), False)),'
|
||||
_M12_NEW = ('tripped=bool(data.pop(self.key("sign_tripped"), False)'
|
||||
+ chr(10) + ' if self.name == "shed"'
|
||||
+ chr(10) + ' else data.get(self.key("sign_tripped"), False)),')
|
||||
_m12 = mutant_tree([("app/inverter.py", _M12_OLD, _M12_NEW)])
|
||||
_m12_code, _m12_out = run(Path(_m12), "state-both-latched.json", "roster.yaml")
|
||||
_m12_clean_code, _m12_clean_out = run(HERE, "state-both-latched.json", "roster.yaml")
|
||||
_m12_new = (set(re.findall(r"⚠️ ([A-Z ]+): (\S+)", _m12_out))
|
||||
- set(re.findall(r"⚠️ ([A-Z ]+): (\S+)", _m12_clean_out)))
|
||||
print(f" both-latched store, shipped -> exit {_m12_clean_code}; "
|
||||
f"one unit's latch key deleted -> exit {_m12_code}")
|
||||
print(f" findings the mutation ADDS: {sorted(_m12_new)}")
|
||||
# ⚠️ THE SECOND CONJUNCT IS IMPLIED BY THE FIRST TODAY - on a clean run the
|
||||
# finding regex matches nothing - and the commit message that introduced it
|
||||
# overclaimed by saying the exit code "cannot say" it. It is belt-and-braces,
|
||||
# load-bearing only if the exit-0 requirement is ever relaxed. WHAT ACTUALLY
|
||||
# CLOSED THE FIRST DRAFT'S GAP IS `_m12_new` BEING A SET DIFFERENCE: the
|
||||
# mutation must ADD a finding, which is the check the narrowing's removal turns
|
||||
# red. Said plainly here because the wrong half was credited once already.
|
||||
check("⚠️ PRECONDITION: the fixture is clean on the shipped tree AND does not "
|
||||
"already report the finding this mutation exists to produce - the second "
|
||||
"is implied by the first today and kept as a belt-and-braces guard",
|
||||
_m12_clean_code == RR.EXIT_OK
|
||||
and not any(k == "unit/shed/sign_tripped"
|
||||
for _, k in re.findall(r"⚠️ ([A-Z ]+): (\S+)", _m12_clean_out)))
|
||||
check("⭐ M12: a ROSTER unit's latch key that VANISHES is a finding even though "
|
||||
"a sibling on the same roster still holds True - the moved-not-lost rule "
|
||||
"suppresses a RENAME (a key leaving a name this roster does not carry), "
|
||||
"never a LOSS",
|
||||
_m12_code == RR.EXIT_FINDING
|
||||
and any(k == "unit/shed/sign_tripped" for _, k in _m12_new))
|
||||
check("...and the mutation ADDS findings rather than merely coinciding with "
|
||||
"ones the fixture already had - an empty diff here is what made the "
|
||||
"previous draft inert",
|
||||
bool(_m12_new))
|
||||
shutil.rmtree(Path(_m12).parent, ignore_errors=True)
|
||||
|
||||
|
||||
# ⚠️ AND THE INPUT FILE IS NEVER WRITTEN. The owner runs this against a copy of
|
||||
# a live state file; a tool that edited it would be a tool that had to be
|
||||
# trusted rather than one that cannot do harm.
|
||||
@@ -587,33 +654,32 @@ shutil.rmtree(Path(_tree2).parent, ignore_errors=True)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("5. SAFETY-44: a pre-DEV-02 flat store meeting a DECLARED ROSTER, which "
|
||||
"is the path nobody had established either way")
|
||||
print("5. SAFETY-44: a pre-DEV-02 flat store meeting a DECLARED ROSTER - "
|
||||
"FIXED, with each half of the fix as its own negative control")
|
||||
# --------------------------------------------------------------------------- #
|
||||
# The question on the card: `migrate_unit_keys(store, UNIT_A)` takes a hardcoded
|
||||
# constant and has no `misdirected` guard, while `migrate_maint_keys(store,
|
||||
# unit, roster)` takes the roster and does. Is that asymmetry safe?
|
||||
# The question on the card: `migrate_unit_keys(store, UNIT_A)` took a hardcoded
|
||||
# constant and had no `misdirected` guard, while `migrate_maint_keys(store,
|
||||
# unit, roster)` takes the roster and does. That asymmetry was NOT safe, and
|
||||
# this part used to assert the defect. It now asserts the FIX.
|
||||
#
|
||||
# ⚠️ MEASURED, NOT ARGUED. The two checks below are the answer, and they are
|
||||
# written as REQUIREMENTS so that a fix to `migrate_unit_keys` turns them red
|
||||
# and this file has to be updated deliberately rather than drifting.
|
||||
# ⚠️ THE FIX IS TWO EDITS AND THEY DO DIFFERENT JOBS, WHICH IS THE WHOLE REASON
|
||||
# THIS PART IS SHAPED THE WAY IT IS. Measured, per leg, by reverting each edit
|
||||
# on its own (5b and 5c below):
|
||||
#
|
||||
# SAME-STEP leg STAGED leg
|
||||
# both edits (shipped) clean latch armed, litter
|
||||
# guard reverted latch armed ⚠️ DISARMED
|
||||
# call site reverted latch armed, litter latch armed, litter
|
||||
#
|
||||
# So the GUARD carries the safety and the CALL SITE carries the hygiene. Saying
|
||||
# that plainly matters more than it looks: a reader who assumes the roster-aware
|
||||
# migration is what re-arms the latch would delete the guard as redundant during
|
||||
# a tidy-up and take the STAGED column with it - and STAGED is the path
|
||||
# "upgrade now, declare the roster later" produces, which is the path an
|
||||
# operator actually takes.
|
||||
code44, out44 = run(HERE, "state-legacy.json", "roster.yaml")
|
||||
print(f" flat store + roster ['garage','shed'] -> exit {code44}")
|
||||
check("⚠️ SAFETY-44 IS A DEFECT AGAIN: a store that ran pre-DEV-02 flat and "
|
||||
"declares a multi-inverter roster comes up with its SAFETY-02 latch "
|
||||
"DISARMED - the file says `sign_tripped: true` and not one inverter is "
|
||||
"latched",
|
||||
code44 == RR.EXIT_FINDING and "LATCH CAME UP DISARMED" in out44)
|
||||
check("...and the harness names the two latch keys SAFETY-44 asks about, by "
|
||||
"the namespace they were stranded in",
|
||||
"unit/inverter-a/sign_tripped" in out44
|
||||
and "unit/inverter-a/sign_reason" in out44)
|
||||
check("...and the mechanism is visible in the report: the keys are stranded "
|
||||
"under `inverter-a`, which is not on the roster, so no machine reads them",
|
||||
"STRANDED STORE KEY" in out44 and "['garage', 'shed']" in out44)
|
||||
# ⚠️ PER LEG, NOT A COUNT OVER THE WHOLE REPORT. See `legs()` for the three
|
||||
# builds this distinction was measured on and for what the count missed.
|
||||
_L = legs(out44)
|
||||
print(f" flat store + roster ['garage','shed'] -> exit {code44}")
|
||||
print(f" legs in the report: {sorted(_L)}")
|
||||
for _leg in ("STAGED", "SAME-STEP"):
|
||||
print(f" LEG {_leg}: "
|
||||
@@ -622,74 +688,188 @@ check("the report is split into legs this check can grade SEPARATELY - the "
|
||||
"tool prints a `LEG <id>` marker, because counting a finding's text over "
|
||||
"the whole report cannot tell one red leg from two",
|
||||
set(_L) == {"STAGED", "SAME-STEP"})
|
||||
check("⚠️ SAME-STEP is disarmed: the roster is declared in the SAME step as "
|
||||
"the upgrade, the migration runs and misdirects the latch to "
|
||||
"`inverter-a`",
|
||||
"LATCH CAME UP DISARMED" in _L.get("SAME-STEP", ""))
|
||||
check("⚠️ AND IT IS NOT THE SAME-STEP PATH ALONE - the STAGED leg reaches the "
|
||||
"same disarmed latch by a DIFFERENT route: the flat start stamps "
|
||||
"`schema_version`, so the roster start's migration takes its early "
|
||||
"return and the latch never moves at all",
|
||||
"LATCH CAME UP DISARMED" in _L.get("STAGED", ""))
|
||||
|
||||
# ⚠️ AND THE CONTRAST, WHICH IS WHAT STOPS THE CHECK ABOVE BEING VACUOUS. The
|
||||
# SAME state file onto a roster whose first entry IS `inverter-a` comes up
|
||||
# LATCHED. The two runs differ in one thing - the first roster name - so the
|
||||
# finding is attributable to it and to nothing else.
|
||||
code_ok, out_ok = run(HERE, "state-legacy.json", "roster-inverter-a.yaml")
|
||||
check("⭐ SAFETY-44 IS FIXED ON BOTH PATHS: a store that ran pre-DEV-02 flat "
|
||||
"and declares a multi-inverter roster comes up LATCHED - neither leg "
|
||||
"reports a disarmed guard",
|
||||
"LATCH CAME UP DISARMED" not in out44)
|
||||
check("...and the latch is under a name THE ROSTER READS, which is the whole "
|
||||
"of the claim - `garage` is on the roster and `inverter-a` is not",
|
||||
all("after : which units came up latched : ['garage']" in v
|
||||
for v in _L.values()))
|
||||
check("⚠️ the SAME-STEP leg is now CLEAN OUTRIGHT: the migration writes the "
|
||||
"latch straight into the roster's first namespace, so there is no "
|
||||
"stranded copy left behind and nothing for an operator to read past",
|
||||
"none in LEG SAME-STEP" in _L.get("SAME-STEP", ""))
|
||||
check("⚠️ ...while the STAGED leg still reports the two STRANDED keys, and "
|
||||
"that is REPORTED not hidden: its flat start stamped `schema_version` "
|
||||
"before the roster existed, so the migration takes its early return and "
|
||||
"the old copies stay in the file with nothing reading them. The latch is "
|
||||
"armed anyway - by the guard - and the litter is true",
|
||||
"STRANDED STORE KEY" in _L.get("STAGED", "")
|
||||
and "unit/inverter-a/sign_tripped" in _L.get("STAGED", ""))
|
||||
check("...and the run as a whole is a FINDING rather than silence, because "
|
||||
"keys nothing reads are still keys nothing reads",
|
||||
code44 == RR.EXIT_FINDING)
|
||||
check("...and no leg's text has swallowed the CLOSING SUMMARY, which repeats "
|
||||
"every finding from every leg - the failure that made the last leg read "
|
||||
"red whenever any leg was",
|
||||
not any("FINDING(S) - this transition is not clean" in v
|
||||
for v in _L.values()))
|
||||
# ⚠️ TWO FURTHER HOLES IN THIS RULE ARE **KNOWN, MEASURED BY REVIEW, AND NOT
|
||||
# FIXED HERE** - deliberately, and this paragraph is the whole of the entry.
|
||||
#
|
||||
# INJECTIVITY. `moved_to` is "any machine-read key with the same bare name
|
||||
# and the same value", so N vanished keys can all claim the SAME destination.
|
||||
# Review measured a rename carrying `inverter-a` -> `garage` correctly while
|
||||
# DROPPING `inverter-b` -> `shed`: both reported as migrated to
|
||||
# `unit/garage/sign_tripped`, zero store-key findings, `shed` unlatched.
|
||||
#
|
||||
# PROVENANCE. A destination is accepted regardless of whether the transition
|
||||
# put the value there, so a roster unit already latched in its own right is a
|
||||
# free suppression slot. Review measured a build deleting `old-a` while
|
||||
# renaming `old-b` -> `shed`: exit 0, NO FINDINGS, `old-a` reported as having
|
||||
# moved into a key that already held True.
|
||||
#
|
||||
# ATTRIBUTION. Which vanished key gets a destination is decided by
|
||||
# `sorted()`, so on a build renaming `inverter-b` -> `garage` while DELETING
|
||||
# `inverter-a`, the deleted key sorts first and takes it: the report names the
|
||||
# DELETED key as migrated and the one that really moved as VANISHED. The run
|
||||
# is still exit 1, so this is a wrong sentence rather than a hidden loss - but
|
||||
# an operator chasing the named key looks at the wrong inverter.
|
||||
#
|
||||
# VALUE MATCHING. `and after.data.get(k) == value` survives the suite and is
|
||||
# load-bearing: without it the rule degenerates to BARE-NAME matching, and
|
||||
# review measured a deleted `unit/old-x/sign_reason` reported as having
|
||||
# migrated into a `sign_reason` holding a COMPLETELY DIFFERENT SENTENCE.
|
||||
# Essentially every off-roster vanished `unit/*` key would be suppressed.
|
||||
#
|
||||
# ⚠️ BOTH FIXES ARE ONE LINE AND BOTH WERE WRITTEN, AND BOTH ARE REVERTED OUT
|
||||
# OF THIS CARD, because THIS SUITE CANNOT MEASURE EITHER ONE. Three attempts:
|
||||
# a check over part 5's destinations passed with the whole injectivity
|
||||
# mechanism deleted (its two destinations are `sign_reason` and `sign_tripped`
|
||||
# - different bare keys, which can never contend); a dedicated fixture
|
||||
# discriminated nothing (both snapshots read one state file, so nothing
|
||||
# vanishes and there are no migrated lines either way); and rebuilding M12's
|
||||
# fixture to reach the provenance case disarmed M12 for the NARROWING, so all
|
||||
# three clause-removals came back green at once.
|
||||
#
|
||||
# ⚠️ THAT LAST ONE IS THE REASON THIS IS A SPLIT AND NOT A JUDGEMENT CALL: a
|
||||
# later fix to the same function silently disarmed the control for an earlier
|
||||
# one, and I only saw it because the removal was re-driven. Shipping two more
|
||||
# unmeasured clauses into the instrument, on a card whose subject is checks
|
||||
# that cannot reach what they name, would be the fourth instance in one PR.
|
||||
# They go on their own card, with review's measurements as the starting
|
||||
# evidence and a fixture built BEFORE the fix.
|
||||
#
|
||||
# ⚠️ ALL FOUR ARE CLAUSES OF **ONE COMPREHENSION**, WHICH IS THE STRUCTURAL
|
||||
# POINT AND NOT A LIST OF CHORES. `diff()`'s `moved_to` now carries four
|
||||
# conditions with four different reasons to exist, against one fixture set that
|
||||
# can reach only some of them at a time - so rebuilding a fixture to reach
|
||||
# clause N moves the others. That is exactly how (3) happened, and it is what
|
||||
# INSTR-01 has to design against rather than patch around.
|
||||
#
|
||||
# What IS measured here is the narrowing, by M12 above, both ways.
|
||||
check("⚠️ and the key that MOVED is not reported as lost: the before-snapshot "
|
||||
"holds `unit/inverter-a/sign_tripped = True` and the after-snapshot "
|
||||
"holds it under `garage`, three lines apart in the same report. A "
|
||||
"VANISHED finding there is the harmless-case alarm CONTROL-C exists to "
|
||||
"prevent",
|
||||
"migrated unit/inverter-a/sign_tripped = True -> "
|
||||
"unit/garage/sign_tripped" in out44
|
||||
and "VANISHED STORE KEY" not in out44)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("5b. THE SPLITTER'S POSITIVE CONTROL: a build in which exactly ONE leg "
|
||||
"is clean")
|
||||
print("5b. NEGATIVE CONTROL, AND THE SPLITTER'S POSITIVE ONE: revert THE "
|
||||
"GUARD, and exactly one leg goes red")
|
||||
# --------------------------------------------------------------------------- #
|
||||
# ⚠️ A PER-LEG CHECK THAT HAS NEVER SEEN THE LEGS DISAGREE IS A COUNT WITH
|
||||
# EXTRA STEPS. Every fixture above has both legs red, so nothing so far
|
||||
# separates "reads each leg" from "reads the whole report twice". This part
|
||||
# builds the one case that does: `migrate_unit_keys()` handed the roster's own
|
||||
# first name instead of the hardcoded `UNIT_A`.
|
||||
#
|
||||
# ⚠️ AND WHAT IT MEASURES IS ALSO THE ANSWER TO "IS THAT THE FIX". It is NOT.
|
||||
# It repairs the SAME-STEP path only. The STAGED path is untouched, because
|
||||
# there the flat start stamps `schema_version = 1` and the roster start's
|
||||
# migration takes its `from_version >= SCHEMA_VERSION` early return before the
|
||||
# name it would be handed matters at all. A fix that stops at this line would
|
||||
# close the half an operator is least likely to take and leave the half that
|
||||
# "upgrade first, declare the roster later" produces.
|
||||
_one_leg = mutant_tree([(
|
||||
# EXTRA STEPS, and MUTATION.md's extension says one that has seen them disagree
|
||||
# in a single arrangement is a total over that arrangement. This build is the
|
||||
# one that makes them disagree, and it is a negative control for part 5 at the
|
||||
# same time: put back the clause that counted ANY `unit/*/sign_tripped` as
|
||||
# protection, including one under a name no unit answers to.
|
||||
# ⚠️ WHAT THIS CONTROL PINS IS THE SHIPPED SPELLING, NOT THE RULE, and review
|
||||
# is owed that distinction. `mutant_tree()` asserts its target exists - rightly,
|
||||
# because a mutation that did not happen yields a fabricated result - but the
|
||||
# consequence is that EDITING the guard's line makes this file red at the
|
||||
# ANCHOR before any behaviour is measured. Demonstrated: rewriting the same
|
||||
# semantics as `on_roster = set()` leaves the anchor intact and IS caught, by
|
||||
# M4's control; rewriting the line itself is caught by the anchor, which looks
|
||||
# identical from the exit code and proves much less. Read 5b and 5c as controls
|
||||
# over this text, and the behavioural coverage as living in test_maintenance.py
|
||||
# section 13 and test_inverter.py, which construct stores rather than strings.
|
||||
_no_guard = mutant_tree([(
|
||||
"app/main.py",
|
||||
" self.store_migration = migrate_unit_keys(store, UNIT_A)",
|
||||
" self.store_migration = migrate_unit_keys(store, "
|
||||
"roster_names(opts)[0])")])
|
||||
_ol_code, _ol_out = run(Path(_one_leg), "state-legacy.json", "roster.yaml")
|
||||
_OL = legs(_ol_out)
|
||||
" if unit in on_roster:",
|
||||
" if True: # MUTANT: any namespaced latch is protection")])
|
||||
_ng_code, _ng_out = run(Path(_no_guard), "state-legacy.json", "roster.yaml")
|
||||
_NG = legs(_ng_out)
|
||||
for _leg in ("STAGED", "SAME-STEP"):
|
||||
print(f" LEG {_leg}: "
|
||||
f"{'DISARMED' if 'LATCH CAME UP DISARMED' in _OL.get(_leg, '') else 'clean'}")
|
||||
check("⭐ with `migrate_unit_keys()` handed the roster's own first name, the "
|
||||
"SAME-STEP leg comes up CLEAN - so the splitter really does read the "
|
||||
"legs separately, and a check that only counted would have missed this",
|
||||
"LATCH CAME UP DISARMED" not in _OL["SAME-STEP"])
|
||||
check("⚠️ ...while the STAGED leg is STILL DISARMED on the same build: that "
|
||||
"line is not the whole fix, because the staged path never reaches it - "
|
||||
"the first start stamps `schema_version` and the second start's "
|
||||
"migration returns early",
|
||||
"LATCH CAME UP DISARMED" in _OL["STAGED"])
|
||||
check("...and the run as a whole is still a finding, so nobody reads this "
|
||||
"partial fix as done",
|
||||
_ol_code == RR.EXIT_FINDING)
|
||||
shutil.rmtree(Path(_one_leg).parent, ignore_errors=True)
|
||||
f"{'DISARMED' if 'LATCH CAME UP DISARMED' in _NG.get(_leg, '') else 'clean'}")
|
||||
check("⭐ with the off-roster clause reverted the STAGED leg comes up "
|
||||
"DISARMED - so the guard's roster test is load-bearing, and it is the "
|
||||
"half of the fix that repairs an install already stamped by the shipped "
|
||||
"version",
|
||||
"LATCH CAME UP DISARMED" in _NG.get("STAGED", ""))
|
||||
check("⚠️ ...while the SAME-STEP leg stays ARMED on the very same build - the "
|
||||
"legs DISAGREE, so the splitter really does read them separately and a "
|
||||
"check that only counted findings would have missed this",
|
||||
"LATCH CAME UP DISARMED" not in _NG.get("SAME-STEP", ""))
|
||||
check("...and the run is a finding, so the partial build is never read as "
|
||||
"clean",
|
||||
_ng_code == RR.EXIT_FINDING)
|
||||
shutil.rmtree(Path(_no_guard).parent, ignore_errors=True)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("5c. back to the shipped code")
|
||||
print("5c. NEGATIVE CONTROL: revert THE CALL SITE, and the litter comes back "
|
||||
"without the latch going out")
|
||||
# --------------------------------------------------------------------------- #
|
||||
# ⚠️ THIS IS THE CONTROL THAT KEEPS 5b HONEST ABOUT WHAT IT PROVED. 5b shows
|
||||
# the guard carries the latch; on its own that is an argument for deleting the
|
||||
# roster-aware migration as decoration. This build is the measurement instead:
|
||||
# hand `migrate_unit_keys()` the hardcoded `UNIT_A` again, with the guard left
|
||||
# fixed. The latch stays armed - the guard catches it - and the SAME-STEP leg
|
||||
# gains the two stranded keys it does not have on the shipped code. The
|
||||
# migration fix buys a store with nothing dead in it, and buys the
|
||||
# `misdirected` refusal that stops a WRONG marker being stamped; it does not
|
||||
# buy the latch. Both edits stay.
|
||||
_CALL_NOW = (" self.store_migration = migrate_unit_keys(" + chr(10) +
|
||||
" store, _roster_names[0], _roster_names)")
|
||||
_old_call = mutant_tree([(
|
||||
"app/main.py", _CALL_NOW,
|
||||
" self.store_migration = migrate_unit_keys(store, UNIT_A)")])
|
||||
_oc_code, _oc_out = run(Path(_old_call), "state-legacy.json", "roster.yaml")
|
||||
_OC = legs(_oc_out)
|
||||
for _leg in ("STAGED", "SAME-STEP"):
|
||||
print(f" LEG {_leg}: "
|
||||
f"{'DISARMED' if 'LATCH CAME UP DISARMED' in _OC.get(_leg, '') else 'clean'}")
|
||||
check("⚠️ with `UNIT_A` handed back to the migration the latch is STILL "
|
||||
"armed on both legs - the guard is what re-arms it, and saying so is "
|
||||
"the difference between this fix and a story about it",
|
||||
"LATCH CAME UP DISARMED" not in _oc_out)
|
||||
check("⭐ ...but the SAME-STEP leg is no longer clean: the two SAFETY-02 keys "
|
||||
"are written under `inverter-a` on a roster of ['garage', 'shed'] and "
|
||||
"nothing reads them - which is the state the shipped code does not "
|
||||
"produce",
|
||||
"STRANDED STORE KEY" in _OC.get("SAME-STEP", "")
|
||||
and "none in LEG SAME-STEP" not in _OC.get("SAME-STEP", ""))
|
||||
shutil.rmtree(Path(_old_call).parent, ignore_errors=True)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("5d. THE CONTRAST: the same file onto a roster that keeps the name")
|
||||
# --------------------------------------------------------------------------- #
|
||||
# ⚠️ AND THE CONTRAST, WHICH IS WHAT STOPS EVERY CHECK ABOVE BEING VACUOUS. The
|
||||
# SAME state file onto a roster whose first entry IS `inverter-a` comes up
|
||||
# LATCHED AND CLEAN. The two runs differ in one thing - the first roster name -
|
||||
# so a finding above is attributable to it and to nothing else.
|
||||
code_ok, out_ok = run(HERE, "state-legacy.json", "roster-inverter-a.yaml")
|
||||
check("the SAME state file onto a roster whose first entry IS `inverter-a` "
|
||||
"comes up latched and clean - so the finding above is caused by the "
|
||||
"roster NAME and not by the fixture being tripped",
|
||||
"comes up latched and clean - so anything part 5 reports is caused by "
|
||||
"the roster NAME and not by the fixture being tripped",
|
||||
code_ok == RR.EXIT_OK and "LATCH CAME UP DISARMED" not in out_ok)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"_comment": "SAFETY-44 SYNTHETIC FIXTURE - not a copy of any live state file; this session cannot read that one and did not try. It exists for ONE control: a store in which TWO ROSTER UNITS ARE LATCHED AT THE SAME VALUE. `state-legacy.json` cannot serve it - it is pre-DEV-02 and flat, so after migration only the FIRST roster entry is ever latched, and a rule that suppresses a vanished key when another key holds the same value can never be exercised against it. That is exactly how M12's first draft came to pass on the UNMUTATED tree. Both units are latched by ONE site event, so the reasons match too, which is the arrangement in which the suppression is most dangerous.",
|
||||
"schema_version": 1,
|
||||
"maint_schema_version": 1,
|
||||
"auto": true,
|
||||
"unit/garage/sign_tripped": true,
|
||||
"unit/garage/sign_reason": "SIGN_DISAGREEMENT: grid meter reversed, both units",
|
||||
"unit/shed/sign_tripped": true,
|
||||
"unit/shed/sign_reason": "SIGN_DISAGREEMENT: grid meter reversed, both units"
|
||||
}
|
||||
@@ -460,6 +460,7 @@ class Snapshot:
|
||||
# finding: see `diff()`.
|
||||
self.stranded_at_default = []
|
||||
self.vanished_at_default = []
|
||||
self.vanished_but_migrated = []
|
||||
|
||||
@property
|
||||
def unit_keys(self):
|
||||
@@ -550,6 +551,58 @@ def diff(before: Snapshot, after: Snapshot) -> list:
|
||||
if value in (None, DEFAULTS.get(bare)):
|
||||
after.vanished_at_default.append(f"{key} = {value!r}")
|
||||
continue
|
||||
# ⚠️ AND A KEY THAT MOVED TO A NAMESPACE A MACHINE READS HAS NOT
|
||||
# VANISHED, WHICH IS THE SAME CORRECTION ONE PARAGRAPH UP, ONE CARD
|
||||
# LATER. SAFETY-44's fix made `migrate_unit_keys()` write under the
|
||||
# ROSTER's first name instead of the hardcoded `inverter-a`, so on a
|
||||
# renamed roster the before-snapshot holds `unit/inverter-a/
|
||||
# sign_tripped = True` and the after-snapshot holds
|
||||
# `unit/garage/sign_tripped = True`. Measured on that build: two
|
||||
# VANISHED findings for a LATCH THAT IS SITTING ON THE SAME PAGE OF THE
|
||||
# REPORT, three lines above them, under the name the roster reads.
|
||||
#
|
||||
# ⚠️ THE TEST IS NOT "the name appears somewhere". It is: the SAME bare
|
||||
# key, at the SAME value, under a key in `machine_read` - the set built
|
||||
# by driving a real start, not by pattern-matching a namespace.
|
||||
#
|
||||
# ⚠️ THIS USED TO CITE "M3 (delete the flat rollback view)" AS WHAT
|
||||
# BOUNDS IT, AND THAT CITATION WAS WRONG IN BOTH HALVES. M3 drops
|
||||
# `sign_reason` from `LEGACY_UNIT_KEYS` and expects `LATCH KEY NOT UNDER
|
||||
# A ROSTER NAME`; the delete-the-rollback-view mutant is M9, and every
|
||||
# key M9 vanishes is FLAT, so it can never satisfy the `unit/` gate
|
||||
# here. Neither mutation exercises this branch. The sentence "what keeps
|
||||
# this from being a softening of the check" was not supported by the
|
||||
# thing it named - and a citation is the worst possible carrier for that,
|
||||
# because it reads as having been checked. The branch is now bounded by
|
||||
# M12, written for it, in test_rehearse.py.
|
||||
moved_to = sorted(
|
||||
k for k in after.machine_read
|
||||
if k.rsplit("/", 1)[-1] == bare and after.data.get(k) == value)
|
||||
# ⚠️ AND THE VANISHED KEY'S OWN UNIT MUST BE **OFF THE ROSTER**, WHICH
|
||||
# THE FIRST VERSION OF THIS RULE DID NOT REQUIRE AND WHICH IS WHAT MADE
|
||||
# IT A SOFTENING. The match was bare-key-plus-value over the whole of
|
||||
# `machine_read`, with no identity - and `DEFAULTS["sign_tripped"] is
|
||||
# False`, so the ONLY non-default value a latch can hold is `True`.
|
||||
# Any surviving latched roster unit therefore suppressed any OTHER
|
||||
# unit's vanished latch. Measured: a build that DROPS
|
||||
# `unit/shed/sign_*` while `unit/garage/sign_*` survives, both latched
|
||||
# by one site event so the reasons match too, reported
|
||||
#
|
||||
# none in LEG SAME-STEP: nothing vanished, nothing stranded ...
|
||||
# NO FINDINGS in the rehearsed transitions
|
||||
#
|
||||
# exit 0, on a roster unit's SAFETY-02 latch that had been deleted.
|
||||
#
|
||||
# The legitimate case is a RENAME: the key left `inverter-a`, which is
|
||||
# not on this roster, and arrived under `garage`, which is. A key whose
|
||||
# own unit is still on the roster has not been renamed - it has been
|
||||
# LOST - and no amount of the same value elsewhere makes that untrue.
|
||||
vanished_unit = key.split("/")[1] if key.startswith("unit/") else None
|
||||
if vanished_unit is not None and vanished_unit not in after.units \
|
||||
and moved_to:
|
||||
after.vanished_but_migrated.append(
|
||||
f"{key} = {value!r} -> {moved_to[0]}")
|
||||
continue
|
||||
findings.append(Finding(
|
||||
"VANISHED STORE KEY",
|
||||
f"{key} = {value!r} was in the store before the "
|
||||
@@ -846,6 +899,15 @@ def report_transition(leg_id, name, before, after, findings) -> None:
|
||||
mark = " " if key in after.data else " (absent, defaulted) "
|
||||
print(f" {key}{mark}")
|
||||
|
||||
if after.vanished_but_migrated:
|
||||
print()
|
||||
print(" keys that MOVED to the namespace this roster reads - not "
|
||||
"lost, and shown")
|
||||
print(" because a rename that relocates a LATCH is the operator's "
|
||||
"business:")
|
||||
for text in after.vanished_but_migrated:
|
||||
print(f" migrated {text}")
|
||||
|
||||
if after.stranded_at_default or after.vanished_at_default:
|
||||
print()
|
||||
print(" keys whose value IS the default `Store.load()` supplies, so "
|
||||
|
||||
Reference in New Issue
Block a user