Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3de4453956 | ||
|
|
f6f3f227c1 | ||
|
|
91b0d5e4dc | ||
|
|
806390d8b1 | ||
|
|
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,94 @@ 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)
|
||||
|
||||
# ⭐ `M12` IS BACK, AND THE CLAIM THAT KILLED IT WAS WRONG.
|
||||
#
|
||||
# M12 is SAFETY-44's end-to-end control for the off-roster narrowing: a mutant
|
||||
# makes one ROSTER unit's latch key vanish while a sibling holds the same
|
||||
# value, and it must go red when the narrowing is removed. INSTR-01's first
|
||||
# draft DELETED it, on the claim that the provenance clause made it
|
||||
# unrestorable and that "NO FIXTURE CAN RESTORE IT, measured both ways".
|
||||
#
|
||||
# ⚠️ TWO MEASUREMENTS ARE NOT AN IMPOSSIBILITY PROOF, AND THIS ONE WAS FALSE.
|
||||
# Both arrangements I measured were SAME-STEP shaped, and the structural
|
||||
# argument - `run()` builds both snapshots from one state file, so "already
|
||||
# latched" and "made by this transition" are incompatible - is TRUE OF
|
||||
# SAME-STEP AND FALSE OF STAGED. `snapshot_over()` starts the after-leg over
|
||||
# the file the BEFORE-leg wrote, so on the staged leg a transition-made
|
||||
# destination is available and the two questions stop being incompatible.
|
||||
# Review found the third arrangement I had generalised past.
|
||||
#
|
||||
# HOW IT WORKS. The fixture is `state-legacy.json` plus a second roster unit
|
||||
# already latched. The mutant makes `shed` POP its own latch key and come up
|
||||
# untripped - a truer SAFETY-02 loss than the original, which left `tripped`
|
||||
# True in memory. On the staged leg, losing shed's latch makes
|
||||
# `_grade_store_migration()` fall through `on_roster_latched` and RE-ARM
|
||||
# `unit/garage/sign_tripped`, which is absent from `before.data` - so the
|
||||
# destination is transition-made and C7 lets it through. The off-roster
|
||||
# `inverter-a` litter is present in BOTH snapshots on this leg, so it never
|
||||
# vanishes and never claims the destination, and C6 stays out of the way.
|
||||
#
|
||||
# ⚠️ AND WHAT ACTUALLY BROKE WAS THE PRECONDITION, NOT THE CONTROL. M12's
|
||||
# `exit == EXIT_OK` baseline does not survive a legacy-shaped fixture. That
|
||||
# precondition is the one SAFETY-44's own commit called "belt-and-braces,
|
||||
# load-bearing only if the exit-0 requirement is ever relaxed", saying that
|
||||
# what closed the first draft's gap is `_m12_new` BEING A SET DIFFERENCE. A
|
||||
# stable dirty baseline is fine for a set difference. So the control was
|
||||
# deleted on the strength of a precondition the same commit had already
|
||||
# characterised as not load-bearing - which is a worse mistake than the
|
||||
# original inert check, because it removed coverage rather than failing to add
|
||||
# it.
|
||||
_M12_OLD = 'tripped=bool(data.get(self.key("sign_tripped"), False)),'
|
||||
# ⚠️ THE MUTANT POPS **AND COMES UP UNTRIPPED**, which is the whole point and
|
||||
# which the first draft got wrong: `data.pop(k, False)` RETURNS the value, so
|
||||
# shed stayed latched in memory, `_grade_store_migration()` saw
|
||||
# `on_roster_latched` and never re-armed garage - no transition-made
|
||||
# destination, and the finding was reported whether or not C5 existed. The
|
||||
# control looked restored and discriminated nothing. `and False` is the
|
||||
# difference between "the key is gone" and "the LATCH is gone", and only the
|
||||
# second reaches the re-arm this arrangement needs.
|
||||
_M12_NEW = ('tripped=bool(data.pop(self.key("sign_tripped"), False) and False'
|
||||
+ chr(10) + ' if self.name == "shed" else'
|
||||
+ chr(10) + ' data.get(self.key("sign_tripped"), False)),')
|
||||
|
||||
|
||||
def m12_findings(tree):
|
||||
"""The finding SET of a staged-leg run over the legacy+shed fixture."""
|
||||
code, out = run(Path(tree), "state-legacy-plus-shed.json", "roster.yaml",
|
||||
"--paths", "staged")
|
||||
return code, set(re.findall(r"⚠️ ([A-Z ]+): (\S+)", out)), out
|
||||
|
||||
|
||||
_m12 = mutant_tree([("app/inverter.py", _M12_OLD, _M12_NEW)])
|
||||
_m12_clean_code, _m12_clean, _ = m12_findings(HERE)
|
||||
_m12_code, _m12_mut, _ = m12_findings(Path(_m12))
|
||||
_m12_new = _m12_mut - _m12_clean
|
||||
print(f" staged leg, legacy+shed: shipped exit {_m12_clean_code}, "
|
||||
f"one roster unit's latch popped -> exit {_m12_code}")
|
||||
# ⚠️ THE PRINTED SET IS LOSSIER THAN IT LOOKS: the regex captures the first
|
||||
# TOKEN of each finding's text, so two findings of one kind collapse into one
|
||||
# tuple. Harmless here - the difference is one-directional and the pair M12
|
||||
# asserts on is exact - but do not read this line as an inventory.
|
||||
print(f" findings the mutation ADDS (first token only): {sorted(_m12_new)}")
|
||||
check("⚠️ PRECONDITION: the baseline is STABLE and does not already carry the "
|
||||
"finding this mutation exists to produce. It is NOT clean - a legacy "
|
||||
"fixture never is - and the SET DIFFERENCE is what makes that fine, "
|
||||
"which is the sentence the deletion should have read before acting on it",
|
||||
not any(k == "unit/shed/sign_tripped" for _, k in _m12_clean))
|
||||
# ⚠️ AND IT MATCHES THE **KIND** AS WELL AS THE KEY, which SAFETY-44's original
|
||||
# did not. Review measured it: renaming the finding kind in `diff()` from
|
||||
# "VANISHED STORE KEY" to "GONE STORE KEY" left M12 GREEN. A vanished latch
|
||||
# quietly downgraded to a softer heading is exactly the shape part 3 requires
|
||||
# every mutation to be graded against - "the SPECIFIC finding it should produce"
|
||||
# - and this file was not holding itself to its own rule.
|
||||
check("⭐ M12 RESTORED: a ROSTER unit's latch key that vanishes is a "
|
||||
"VANISHED STORE KEY finding even though a sibling holds True - and this "
|
||||
"is the STAGED leg, where `snapshot_over()` supplies a transition-made "
|
||||
"destination, so the provenance clause does not foreclose the narrowing",
|
||||
("VANISHED STORE KEY", "unit/shed/sign_tripped") in _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 +675,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,77 +709,348 @@ 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()))
|
||||
# ⚠️ THIS BLOCK USED TO SAY THOSE HOLES WERE KNOWN AND NOT FIXED, AND IT IS
|
||||
# NOW STALE IN THE OTHER DIRECTION - INSTR-01 fixed them, and left the
|
||||
# paragraph saying "BOTH ARE REVERTED OUT OF THIS CARD, because THIS SUITE
|
||||
# CANNOT MEASURE EITHER ONE" describing a tree that no longer exists. Review
|
||||
# found it. A comment that outlives its subject is the same defect as a
|
||||
# citation that outlives its check.
|
||||
#
|
||||
# WHERE EACH ONE NOW LIVES, and every clause of `moved_to` has a row in 5e:
|
||||
#
|
||||
# INJECTIVITY (C6) R5, and removing the clause flips exactly R5
|
||||
# PROVENANCE (C7) R3, likewise
|
||||
# SAME VALUE (C3) R4
|
||||
# SAME BARE KEY(C2) R7 - which had NO row until review deleted the clause
|
||||
# and the whole 28-file suite stayed green
|
||||
# MACHINE-READ (C1) R6
|
||||
# OFF-ROSTER (C5) R2, and M12 end-to-end on the staged leg
|
||||
# PREFIX GATE (C4) NO 5e ROW, deliberately - `vanished_unit is not None`
|
||||
# is reached end-to-end by part 3's M9, and a synthetic
|
||||
# row would duplicate that rather than add separability.
|
||||
# Listed because C5 lives in the same `if` and omitting
|
||||
# one guard while naming the other reads as an oversight.
|
||||
#
|
||||
# ⚠️ ONE HOLE IS STILL OPEN AND IS **UNMEASURED**, stated so nobody reads
|
||||
# `sorted()` as guarded: ATTRIBUTION. Which vanished key gets a destination is
|
||||
# decided by `sorted()` order, 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. Review measured that replacing `sorted(` with `list(` leaves the
|
||||
# suite green: every 5e case has at most one candidate destination, so no row
|
||||
# can see ordering. The run is still exit 1, so this is a WRONG SENTENCE rather
|
||||
# than a hidden loss - an operator chasing the named key looks at the wrong
|
||||
# inverter. Fixing it needs the destination's provenance rather than
|
||||
# first-come, which is more than a rename diff currently knows.
|
||||
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)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("5e. INSTR-01: `diff()`'s suppression rule, ONE FIXTURE PER CLAUSE")
|
||||
# --------------------------------------------------------------------------- #
|
||||
# ⚠️ THIS SECTION EXISTS BECAUSE SAFETY-44 PRODUCED THREE INERT CONTROLS FOR
|
||||
# THIS ONE RULE, EACH INERT DIFFERENTLY:
|
||||
#
|
||||
# 1. a check over part 5's real destinations passed with the whole mechanism
|
||||
# deleted - its two destinations are `sign_reason` and `sign_tripped`,
|
||||
# DIFFERENT bare keys, which can never contend;
|
||||
# 2. a dedicated end-to-end fixture discriminated nothing - both snapshots
|
||||
# read ONE state file, so nothing vanishes and there are no migrated lines
|
||||
# with the clause or without it;
|
||||
# 3. rebuilding M12's fixture to reach a new clause DISARMED M12 for an older
|
||||
# one, and all three clause-removals went green at once.
|
||||
#
|
||||
# ⭐ (3) IS THE STRUCTURAL FINDING AND IT IS WHY THIS SECTION IS SHAPED THIS WAY.
|
||||
# `moved_to` is ONE comprehension carrying FIVE conditions with five different
|
||||
# reasons to exist, and an END-TO-END fixture cannot separate them: both
|
||||
# snapshots come from one state file, so the arrangement that reaches clause N
|
||||
# forecloses clause M. Rebuilding the fixture to reach one moves the others.
|
||||
#
|
||||
# So the rule is graded at the level it is written at - `diff()` on constructed
|
||||
# Snapshots - with ONE CASE PER CLAUSE. Nothing here is a fiction: the inputs
|
||||
# are the same three fields `diff()` reads (`data`, `machine_read`, `units`),
|
||||
# and the end-to-end controls above - M12 on the STAGED leg, and part 5 -
|
||||
# still grade the same rule through a real start. This is the separability
|
||||
# those cannot have, not a replacement for them.
|
||||
CLAUSE_CASES = {
|
||||
# name: (units, machine_read, before, after)
|
||||
"R1-rename-legit": (
|
||||
["garage"], ["unit/garage/sign_tripped"],
|
||||
{"unit/old/sign_tripped": True}, {"unit/garage/sign_tripped": True}),
|
||||
"R2-narrowing": (
|
||||
["garage", "shed"], ["unit/garage/sign_tripped", "unit/shed/sign_tripped"],
|
||||
{"unit/shed/sign_tripped": True}, {"unit/garage/sign_tripped": True}),
|
||||
"R3-provenance": (
|
||||
["garage"], ["unit/garage/sign_tripped"],
|
||||
{"unit/old/sign_tripped": True, "unit/garage/sign_tripped": True},
|
||||
{"unit/garage/sign_tripped": True}),
|
||||
"R4-value": (
|
||||
["garage"], ["unit/garage/sign_reason"],
|
||||
{"unit/old/sign_reason": "X-unique"},
|
||||
{"unit/garage/sign_reason": "Y-different"}),
|
||||
"R5-injectivity": (
|
||||
["garage"], ["unit/garage/sign_tripped"],
|
||||
{"unit/old-a/sign_tripped": True, "unit/old-b/sign_tripped": True},
|
||||
{"unit/garage/sign_tripped": True}),
|
||||
"R6-machine-read": (
|
||||
["garage"], [],
|
||||
{"unit/old/sign_tripped": True}, {"unit/garage/sign_tripped": True}),
|
||||
# ⚠️ R7 EXISTS BECAUSE THE BARE-KEY MATCH HAD NO ROW AT ALL, and review
|
||||
# deleted it and left the whole 28-file suite GREEN. The rule's own comment
|
||||
# says "THE TEST IS NOT 'the name appears somewhere'. It is: the SAME bare
|
||||
# key" - and nothing measured that sentence. It is load-bearing: a vanished
|
||||
# SAFETY-02 `sign_tripped` was suppressed by a SAFETY-42
|
||||
# `delivery_tripped` on a roster unit, i.e. by a DIFFERENT FAILSAFE's key.
|
||||
# `unit/*/delivery_tripped` is machine-read, so this is reachable on any
|
||||
# real two-latch store rather than a contrivance.
|
||||
"R7-bare-key": (
|
||||
["garage"], ["unit/garage/delivery_tripped"],
|
||||
{"unit/old/sign_tripped": True},
|
||||
{"unit/garage/delivery_tripped": True}),
|
||||
}
|
||||
# What the SHIPPED rule must answer. `migrated` is the suppression firing.
|
||||
CLAUSE_EXPECT = {
|
||||
"R1-rename-legit": ([], ["unit/old/sign_tripped"]),
|
||||
"R2-narrowing": (["unit/shed/sign_tripped"], []),
|
||||
"R3-provenance": (["unit/old/sign_tripped"], []),
|
||||
"R4-value": (["unit/old/sign_reason"], []),
|
||||
"R5-injectivity": (["unit/old-b/sign_tripped"], ["unit/old-a/sign_tripped"]),
|
||||
"R6-machine-read": (["unit/old/sign_tripped"], []),
|
||||
"R7-bare-key": (["unit/old/sign_tripped"], []),
|
||||
}
|
||||
# ⚠️ THE DRIVER **IMPORTS** THE TREE UNDER TEST AND THE TABLE STAYS HERE, so
|
||||
# expectation and implementation sit on opposite sides of the subprocess and
|
||||
# a mutation to the tool cannot also move the answer it is graded against.
|
||||
# (It is WRITTEN to a tempdir - see `clause_table()`. The first draft of this
|
||||
# sentence said "written into the tree", which is the read-only-CI bug the
|
||||
# same commit says it fixed, stated as if it were the design.)
|
||||
CLAUSE_DRIVER = """
|
||||
import json, sys
|
||||
import rehearse_roster as RR
|
||||
spec = json.load(sys.stdin)
|
||||
out = {}
|
||||
for name, case in spec.items():
|
||||
units, mread, before, after = case
|
||||
b = object.__new__(RR.Snapshot); a = object.__new__(RR.Snapshot)
|
||||
for snap, data in ((b, before), (a, after)):
|
||||
snap.label = name
|
||||
snap.data = dict(data)
|
||||
snap.units = list(units)
|
||||
snap.machine_read = set(mread)
|
||||
snap.entities = {}
|
||||
snap.latched = {u: False for u in units}
|
||||
snap.latch_reason = {u: "" for u in units}
|
||||
snap.flat_tripped = False
|
||||
snap.history = {}
|
||||
snap.read = set()
|
||||
snap.stranded_at_default = []
|
||||
snap.vanished_at_default = []
|
||||
snap.vanished_but_migrated = []
|
||||
f = RR.diff(b, a)
|
||||
out[name] = [
|
||||
sorted(x.text.split(" = ")[0] for x in f
|
||||
if x.kind == "VANISHED STORE KEY"),
|
||||
sorted(t.split(" = ")[0] for t in a.vanished_but_migrated),
|
||||
]
|
||||
print(json.dumps(out))
|
||||
"""
|
||||
|
||||
|
||||
def clause_table(tree) -> dict:
|
||||
"""Run CLAUSE_CASES through `diff()` in `tree`, as the tool would see it."""
|
||||
# ⚠️ THE DRIVER GOES IN A TEMPDIR, NOT IN THE TREE. `checks / unit` mounts
|
||||
# the checkout READ-ONLY, so writing beside the tool works on a dev box and
|
||||
# is `OSError: Read-only file system` in CI - a difference that would have
|
||||
# turned this section red on every PR and green for whoever wrote it.
|
||||
tools = Path(tree) / "tools"
|
||||
tmp = tempfile.mkdtemp(prefix="clause-drv-")
|
||||
drv = Path(tmp) / "_clause_driver.py"
|
||||
drv.write_text(CLAUSE_DRIVER, encoding="utf-8")
|
||||
proc = subprocess.run(
|
||||
[sys.executable, str(drv)],
|
||||
input=json.dumps(CLAUSE_CASES), capture_output=True, text=True,
|
||||
cwd=tmp,
|
||||
env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1",
|
||||
"PYTHONPATH": f"{tools}{os.pathsep}{Path(tree)}"})
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
assert proc.returncode == 0, (
|
||||
"the clause driver did not run, so every row below would be "
|
||||
f"fabricated:\n{proc.stdout}\n{proc.stderr}")
|
||||
return {k: (v[0], v[1]) for k, v in json.loads(proc.stdout).items()}
|
||||
|
||||
|
||||
_ct = clause_table(HERE)
|
||||
for _name in sorted(CLAUSE_CASES):
|
||||
_got, _want = _ct[_name], CLAUSE_EXPECT[_name]
|
||||
print(f" {_name:<18} vanished={_got[0]} migrated={_got[1]}")
|
||||
check(f"{_name}: the shipped rule answers as specified", list(_got) == [list(_want[0]), list(_want[1])])
|
||||
check("⚠️ ...and the LEGITIMATE case is still suppressed - a rule that reports "
|
||||
"every rename is a rule an operator turns off, which is the failure "
|
||||
"CONTROL-C exists to prevent",
|
||||
_ct["R1-rename-legit"][1] == ["unit/old/sign_tripped"])
|
||||
|
||||
# ⭐ AND EACH CLAUSE REMOVAL MUST FLIP **EXACTLY ITS OWN ROW**. That second half
|
||||
# is the acceptance criterion this card was filed with: a removal that flips
|
||||
# nothing is an unmeasured clause, and a removal that flips someone else's row
|
||||
# means the cases are not separable after all - which is (3) above, caught.
|
||||
CLAUSE_MUTATIONS = [
|
||||
("C2 same bare key", "R7-bare-key",
|
||||
' if k.rsplit("/", 1)[-1] == bare and after.data.get(k) == value',
|
||||
" if True and after.data.get(k) == value"),
|
||||
("C1 machine_read", "R6-machine-read",
|
||||
" k for k in after.machine_read",
|
||||
" k for k in after.data"),
|
||||
("C3 same value", "R4-value",
|
||||
" and after.data.get(k) == value\n", "\n"),
|
||||
("C5 off-roster", "R2-narrowing",
|
||||
" if vanished_unit is not None and vanished_unit not in after.units \\\n"
|
||||
" and moved_to:",
|
||||
" if vanished_unit is not None and moved_to:"),
|
||||
("C6 injectivity", "R5-injectivity",
|
||||
" and k not in claimed)", ")"),
|
||||
("C7 provenance", "R3-provenance",
|
||||
" and before.data.get(k) != value", " and True"),
|
||||
]
|
||||
for _label, _owns, _old, _new in CLAUSE_MUTATIONS:
|
||||
_t = mutant_tree([("tools/rehearse_roster.py", _old, _new)])
|
||||
_mt = clause_table(_t)
|
||||
_flipped = sorted(n for n in CLAUSE_CASES if _mt[n] != _ct[n])
|
||||
print(f" {_label:<16} removed -> rows that changed: {_flipped}")
|
||||
check(f"⭐ removing {_label} flips {_owns} - the clause is load-bearing and "
|
||||
f"this row is what measures it",
|
||||
_flipped == [_owns])
|
||||
shutil.rmtree(Path(_t).parent, ignore_errors=True)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("6. THE REVERSE DIRECTION: the retreat, and the return from it")
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"_comment": "INSTR-01 SYNTHETIC FIXTURE - `state-legacy.json` with a SECOND roster unit already latched. Not a copy of any live state file. It exists to restore M12 END-TO-END on the STAGED leg, which is the arrangement SAFETY-44 and INSTR-01's first draft both missed: `snapshot_over()` starts the after-leg over the file the BEFORE-leg wrote, so a transition-made destination is available there and the two questions the provenance clause and the narrowing ask are NOT incompatible. Every same-step arrangement makes them incompatible; that is what was measured and over-generalised to 'no fixture can'.",
|
||||
"phase": "idle",
|
||||
"phase_started": "2026-08-07T10:00:00+00:00",
|
||||
"last_completed": "2026-08-07T18:42:00+00:00",
|
||||
"last_start_attempt": "2026-08-07T10:00:00+00:00",
|
||||
"sign_tripped": true,
|
||||
"sign_reason": "SIGN_DISAGREEMENT: commanded -1800 W against a meter reading -1450 W for 190 s",
|
||||
"auto": true,
|
||||
"unit/shed/sign_tripped": true,
|
||||
"unit/shed/sign_reason": "SIGN_DISAGREEMENT: shed, latched before the switch"
|
||||
}
|
||||
@@ -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):
|
||||
@@ -544,12 +545,84 @@ def diff(before: Snapshot, after: Snapshot) -> list:
|
||||
# name and whose roster start writes them under another reported two
|
||||
# VANISHED findings about a pair of defaults, and CONTROL-C went red on a
|
||||
# build that was CORRECT.
|
||||
# ⚠️ `claimed` MAKES THE MOVE INJECTIVE. Without it N vanished keys can all
|
||||
# claim the SAME destination - and `DEFAULTS["sign_tripped"] is False`, so
|
||||
# `True` is the ONLY non-default value a latch can hold, which means they
|
||||
# collide by CONSTRUCTION rather than by coincidence. Two keys cannot both
|
||||
# have become one key; the second claimant has not moved anywhere.
|
||||
claimed = set()
|
||||
for key in sorted(set(before.data) - set(after.data)):
|
||||
value = before.data[key]
|
||||
bare = key.rsplit("/", 1)[-1]
|
||||
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.
|
||||
#
|
||||
# ⚠️ AND THE REPLACEMENT CITATION WENT STALE THE SAME WAY. It read "the
|
||||
# branch is now bounded by M12" through a card that DELETED M12 and a
|
||||
# card that restored it on a different leg - eight lines below the
|
||||
# paragraph condemning exactly this. Every clause of this comprehension
|
||||
# is now graded by a row in test_rehearse.py part 5e, one case per
|
||||
# clause, and each clause-removal must flip exactly its own row. That
|
||||
# is a property of the section, not a pointer at a named check.
|
||||
# ⚠️ AND THE DESTINATION MUST BE ONE **THIS TRANSITION MADE**
|
||||
# (`before.data.get(k) != value`) AND NOT ALREADY SPOKEN FOR
|
||||
# (`k not in claimed`). Without the first, a roster unit that was
|
||||
# already latched in its own right is a free suppression slot that
|
||||
# nobody moved into.
|
||||
moved_to = sorted(
|
||||
k for k in after.machine_read
|
||||
if k.rsplit("/", 1)[-1] == bare and after.data.get(k) == value
|
||||
and before.data.get(k) != value and k not in claimed)
|
||||
# ⚠️ 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:
|
||||
claimed.add(moved_to[0])
|
||||
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 +919,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