MAINT-05 fix round: grade the migration against the ROSTER, not against the name it was handed
checks / unit (pull_request) Successful in 4m47s
checks / unit (pull_request) Successful in 4m47s
REJECTED because a survivor undoes AC 5, and it survived for the recurring
reason: two fixture values that must be distinguishable were EQUAL.
THE SURVIVOR. app/main.py:282 mutated from `self.inverters[0].name` to `UNIT_A`
left the full 19-file suite green. `inverter.UNIT_A == "inverter-a"`, and every
two-pack fixture in test_maintenance.py named its first roster entry
"inverter-a" - so the two expressions were the same string in every check that
existed. main.py's own comment made the distinction load-bearing and nothing in
the repository could see whether that comment was true.
THE FAILURE IS REAL. `build_roster()` names a declared roster's entries from the
config and numbers a nameless one POSITIONALLY (`inverter-1`), so "inverter-a"
is not even the fallback. On ["garage","shed"] with a flat last_completed of
2026-08-07 the mutant migrates into unit/inverter-a/, pack 0 reads back None,
and both packs come up believing they have never been recalibrated.
AND IT FAILED SILENTLY, which was the worse half: `_verify_moves()` read back
the keys it had just WRITTEN, under the namespace it had just been HANDED, was
satisfied, and MAINT_SCHEMA_KEY was stamped - so the next start skipped the
migration too. A postcondition that reads back the namespace it was handed
cannot detect being handed the wrong one.
WHAT CHANGED
1. THE POSTCONDITION IS GRADED AGAINST THE ROSTER. `_verify_moves()` takes the
names the MACHINES read and reports a namespace that is not among them as
`misdirected` - a new, differently-named failure from `lost`:
* `lost` is "the store took the write and dropped it" - a storage fault,
with a harmless-default case (a dropped `last_completed: None` loses
nothing, because absent and never-recalibrated are the same statement);
* `misdirected` is "the store did exactly as it was told, by a caller that
was wrong". There is no harmless-default version of it - a write nobody
reads is not information whatever its value - so it fails `ok` alone.
`ok` gates the marker, so a misdirected migration is NOT stamped and the next
start re-runs it. Asymmetric on purpose: a re-run is recoverable (rule 1
makes it a no-op, rule 2 left the flat keys to recover from), a silent skip
is not. Part 9b MEASURES the recovery rather than asserting it.
2. `migrate_maint_keys()` TAKES THE ROSTER IN PLACE OF AN `n_units` COUNT. The
count was `len(self.inverters)` at the only call site, so the roster carries
it, and a count beside the names is a second opinion about how many packs
there are.
3. THE FIXTURES CAN NOW REACH THE FAILURE THEY NAME. Parts 9 and 11 run on
["garage","shed"], each behind a PRECONDITION check that the first name is
not UNIT_A - so an edit that makes them equal again goes red instead of
making the claims vacuous. Part 11's fixture date is relative to `now`, not
a literal, so "history preserved" and "history lost" cannot converge as the
file ages past `interval_days`.
MUTATION LEDGER - all red, purged tree, none at +0 B, full detail on the PR:
M1 main.py inverters[0].name -> UNIT_A -16 B 4 checks red
M2 store.py drop `roster=names` from _verify_moves -14 B 5 checks red
M3 store.py drop the misdirected.append -37 B 5 checks red
M4 store.py ok: drop `and not self.misdirected` -25 B 3 checks red
M5 fixture garage -> inverter-a (the trap itself) +4 B 5 checks red
M6 store.py parts[1] -> parts[-1] +1 B 1 check red
FOUND AND NOT FIXED - `migrate_unit_keys()` HAS THE SAME HOLE AND IT IS A LATCH.
It runs BEFORE `build_roster()` (each Inverter restores its latch in its
constructor), so it is handed the constant UNIT_A with no roster to grade
against. On a declared roster a pre-DEV-02 `sign_tripped: true` lands under
unit/inverter-a/sign_tripped, which no unit reads - and
`_grade_store_migration()`, whose whole job is to re-arm exactly this, is
DEFEATED BY THE KEY THE DEFECT WROTE: its "is anything namespaced latched" test
is satisfied by the misdirected value itself. Measured, with the flat-config
counterfactual, and pinned as a running check (test_maintenance.py part 13)
whose assertions state the WRONG behaviour on purpose. It wants its own card:
the remedy is in inverter.py and SAFETY-02's semantics, and a silent expansion
into a safety latch is how a rejected PR becomes a broken one.
Suite: 19/19 PASS, purged tree, bind-mounted :ro in goodwe-addon-ci.
No hardware was touched; every fixture here is SIMULATED and pack B's 3000 W
remains a marked hypothesis.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
co-authored by
Claude Opus 5
parent
1aef19e995
commit
65cddf35f1
@@ -30,6 +30,7 @@ from app.maintenance import (
|
||||
CHARGE, DRAIN, HOLD, IDLE, MAINT_OPTION_DEFAULTS, POWER_MOVING, MaintConfig,
|
||||
Maintenance, UnitHistory, arbitrate,
|
||||
)
|
||||
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,
|
||||
@@ -586,6 +587,30 @@ check("...and B's deferral produced no claim on the tick it was deferred: a "
|
||||
print()
|
||||
print("9. the store migration: no lost history, and the three rules hold (AC5)")
|
||||
|
||||
# ⚠️ THE ROSTER IS ["garage", "shed"], AND THE FIRST NAME IS THE WHOLE POINT OF
|
||||
# THIS SECTION'S FIX ROUND. Every check here used to name its packs
|
||||
# "inverter-a" / "inverter-b" - and "inverter-a" is the string
|
||||
# `inverter.UNIT_A` holds, so `self.inverters[0].name` and `UNIT_A` were the
|
||||
# SAME VALUE in every fixture in this file. main.py's migration call site could
|
||||
# be changed from the first to the second with all 19 suites still green:
|
||||
# MUTATION.md's third defect exactly, a check whose DATA cannot reach the
|
||||
# failure it names.
|
||||
#
|
||||
# A declared `inverters:` roster names its own entries (`build_roster()`), and
|
||||
# the fallback for a NAMELESS entry is a POSITIONAL `inverter-1` - so
|
||||
# `inverter-a` is not the default for a declared roster either. It is a name
|
||||
# nothing on this site reads, which is what makes migrating into it silent.
|
||||
ROSTER = ["garage", "shed"]
|
||||
|
||||
check("PRECONDITION, WRITTEN AS ITS OWN CHECK. The first pack on this "
|
||||
f"section's roster is NOT `UNIT_A`: {ROSTER[0]!r} != {UNIT_A!r}, and "
|
||||
f"{UNIT_A!r} is not on it at all. 'the first roster entry' and "
|
||||
"'inverter-a' are therefore DIFFERENT strings in every check below - and "
|
||||
"if a later edit makes them equal again this goes red rather than the "
|
||||
"claims going quietly vacuous",
|
||||
ROSTER[0] != UNIT_A and UNIT_A not in ROSTER)
|
||||
|
||||
|
||||
# A real pre-MAINT-05 single-pack store: a month of history under the flat keys,
|
||||
# with SAFETY-02's own migration already done (schema_version 1).
|
||||
def flat_store(**over):
|
||||
@@ -599,20 +624,28 @@ def flat_store(**over):
|
||||
|
||||
|
||||
_m1 = flat_store()
|
||||
_rep1 = migrate_maint_keys(_m1, "inverter-a", 2)
|
||||
_rep1 = migrate_maint_keys(_m1, ROSTER[0], ROSTER)
|
||||
print(f" a single-pack store growing to two: {_rep1.explain()}")
|
||||
print(f" keys after migration: "
|
||||
f"{sorted(k for k in _m1.data if k.startswith('unit/'))}")
|
||||
check("every flat maintenance key lands in the FIRST pack's namespace, because "
|
||||
"the flat keys can only ever have belonged to the pack the one FSM was "
|
||||
"driving",
|
||||
_rep1.ran and _rep1.ok and _rep1.verified
|
||||
and all(_m1.data.get(unit_key("inverter-a", k)) == _m1.data[k]
|
||||
_rep1.ran and _rep1.ok and _rep1.verified and not _rep1.misdirected
|
||||
and all(_m1.data.get(unit_key(ROSTER[0], k)) == _m1.data[k]
|
||||
for k in MAINT_UNIT_KEYS))
|
||||
check("...and the namespace is THE ROSTER'S FIRST NAME, not `UNIT_A` - which is "
|
||||
"a claim this file could not make until its packs stopped being called "
|
||||
"`inverter-a`. Not one key was written under `unit/inverter-a/`, on a "
|
||||
"store where that is the name a wrong call site would have used",
|
||||
not any(k.startswith(unit_key(UNIT_A, "")) for k in _m1.data)
|
||||
and unit_key(ROSTER[0], "last_completed") in _m1.data)
|
||||
check("...and the VALUES are the history, not defaults wearing its name - a "
|
||||
"migration that lands `last_completed: None` has lost the month it was "
|
||||
"moving and would recalibrate a pack that does not need it",
|
||||
_m1.data.get(unit_key("inverter-a", "last_completed"))
|
||||
_m1.data.get(unit_key(ROSTER[0], "last_completed"))
|
||||
== "2026-08-07T12:00:00+00:00"
|
||||
and _m1.data.get(unit_key("inverter-a", "phase")) == CHARGE)
|
||||
and _m1.data.get(unit_key(ROSTER[0], "phase")) == CHARGE)
|
||||
check("RULE 2: the flat keys are LEFT IN THE FILE, so an operator who rolls the "
|
||||
"add-on back to a single-FSM version finds the history it had",
|
||||
all(k in _m1.data for k in MAINT_UNIT_KEYS)
|
||||
@@ -623,31 +656,31 @@ check("RULE 3: the marker is written and the store is now at the per-pack "
|
||||
check("...and the pack that has just arrived gets NOTHING, which is the right "
|
||||
"answer rather than an omission: absent means never recalibrated, means "
|
||||
"due, and a brand-new pack should be",
|
||||
not any(k.startswith(unit_key("inverter-b", "")) for k in _m1.data)
|
||||
and Maintenance(cfg, _m1, unit="inverter-b").due(_t))
|
||||
not any(k.startswith(unit_key(ROSTER[1], "")) for k in _m1.data)
|
||||
and Maintenance(cfg, _m1, unit=ROSTER[1]).due(_t))
|
||||
|
||||
# Rule 1, and it is the rule that makes re-running safe.
|
||||
_m2 = flat_store(**{unit_key("inverter-a", "last_completed"):
|
||||
_m2 = flat_store(**{unit_key(ROSTER[0], "last_completed"):
|
||||
"2026-09-01T09:00:00+00:00"})
|
||||
_rep2 = migrate_maint_keys(_m2, "inverter-a", 2)
|
||||
_rep2 = migrate_maint_keys(_m2, ROSTER[0], ROSTER)
|
||||
check("RULE 1: an EXISTING namespaced value is never overwritten by the flat "
|
||||
"one. A store with both spellings has been written by a version that knew "
|
||||
"about the namespace, and preferring the flat one would roll a pack's "
|
||||
"history back to whatever it was on migration day",
|
||||
_m2.data.get(unit_key("inverter-a", "last_completed"))
|
||||
_m2.data.get(unit_key(ROSTER[0], "last_completed"))
|
||||
== "2026-09-01T09:00:00+00:00"
|
||||
and unit_key("inverter-a", "last_completed") in _rep2.kept)
|
||||
and unit_key(ROSTER[0], "last_completed") in _rep2.kept)
|
||||
check("...so running the migration TWICE is a no-op, which is what makes a "
|
||||
"crash between the copies and the marker safe",
|
||||
migrate_maint_keys(_m1, "inverter-a", 2).ran is False)
|
||||
migrate_maint_keys(_m1, ROSTER[0], ROSTER).ran is False)
|
||||
|
||||
# The store a multi-inverter version wrote. AC: not touched.
|
||||
_m3 = MemStore({MAINT_SCHEMA_KEY: MAINT_SCHEMA_VERSION,
|
||||
unit_key("inverter-a", "last_completed"): "2026-09-01T09:00:00+00:00",
|
||||
unit_key("inverter-b", "last_completed"): "2026-08-20T09:00:00+00:00",
|
||||
unit_key(ROSTER[0], "last_completed"): "2026-09-01T09:00:00+00:00",
|
||||
unit_key(ROSTER[1], "last_completed"): "2026-08-20T09:00:00+00:00",
|
||||
"last_completed": "2026-09-01T09:00:00+00:00"})
|
||||
_before3 = dict(_m3.data)
|
||||
_rep3 = migrate_maint_keys(_m3, "inverter-a", 2)
|
||||
_rep3 = migrate_maint_keys(_m3, ROSTER[0], ROSTER)
|
||||
print(f" a store already written by a multi-inverter version: {_rep3.explain()}")
|
||||
check("a store already at the per-pack schema is NOT TOUCHED - not one key "
|
||||
"written - and says so",
|
||||
@@ -671,7 +704,7 @@ class LosesTheHistory(MemStore):
|
||||
|
||||
|
||||
_m4 = LosesTheHistory(dict(flat_store().data))
|
||||
_rep4 = migrate_maint_keys(_m4, "inverter-a", 2)
|
||||
_rep4 = migrate_maint_keys(_m4, ROSTER[0], ROSTER)
|
||||
print(f" a store that silently drops the migrated keys: {_rep4.explain()}")
|
||||
check("⚠️ THE MIGRATION REPORTS THAT IT FAILED, because it READS BACK what it "
|
||||
"wrote. A month of coulomb-count history under a key nothing reads is a "
|
||||
@@ -686,7 +719,92 @@ check("...while a store whose only failed write was a DEFAULT is not reported "
|
||||
"'absent' and 'never recalibrated' are the same statement - an alarm that "
|
||||
"fires on the harmless case is one an operator learns to dismiss",
|
||||
migrate_maint_keys(LosesTheHistory({"schema_version": 1}),
|
||||
"inverter-a", 2).ok is True)
|
||||
ROSTER[0], ROSTER).ok is True)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("9b. THE OTHER HALF OF THE POSTCONDITION: a migration into a namespace "
|
||||
"the machines do not read (AC5)")
|
||||
|
||||
# ⚠️ WHY THIS EXISTS, IN ONE SENTENCE: A POSTCONDITION THAT READS BACK THE
|
||||
# NAMESPACE IT WAS HANDED CANNOT DETECT BEING HANDED THE WRONG ONE.
|
||||
#
|
||||
# `_verify_moves()` shipped reading back `rep.moved` - the keys it had just
|
||||
# written - so a migration handed a `unit` that is on no roster wrote four keys,
|
||||
# read all four back equal, set `verified = True`, and the caller stamped
|
||||
# `maint_schema_version`. Every symptom of a clean migration, and an install's
|
||||
# whole maintenance history parked under a name nothing will ever look up. The
|
||||
# next start then sees the marker and SKIPS the migration, which is the one
|
||||
# state store.py's header says must never happen: a failed migration a later
|
||||
# version treats as done.
|
||||
#
|
||||
# It is not a hypothetical failure and it is not a hypothetical call site.
|
||||
# main.py passes `self.inverters[0].name`; `inverter.UNIT_A` is one token away,
|
||||
# reads correctly, and was indistinguishable from it in every fixture in this
|
||||
# file until the roster above stopped being called `inverter-a`.
|
||||
_m5 = flat_store()
|
||||
_rep5 = migrate_maint_keys(_m5, UNIT_A, ROSTER)
|
||||
print(f" handed {UNIT_A!r} on a roster of {ROSTER}: {_rep5.explain()}")
|
||||
print(f" keys written: {sorted(k for k in _m5.data if k.startswith('unit/'))}"
|
||||
f"; marker {_m5.data.get(MAINT_SCHEMA_KEY, '<<absent>>')!r}")
|
||||
check("⚠️ THE MIGRATION REFUSES: a namespace that is not on the roster is not a "
|
||||
"namespace anything reads, so the four keys landing perfectly is not the "
|
||||
"question. `ok` is False and `verified` is False even though every single "
|
||||
"read-back matched, which is the difference this postcondition adds",
|
||||
_rep5.ran and not _rep5.ok and not _rep5.verified and _rep5.misdirected
|
||||
and all(_m5.data.get(unit_key(UNIT_A, k)) is not None
|
||||
for k in MAINT_UNIT_KEYS))
|
||||
check("...and it is reported as a DIFFERENT failure from a dropped write, "
|
||||
"because the remedy is different: `lost` is empty and `misdirected` is "
|
||||
"not. A store that dropped the write has a storage fault; this store did "
|
||||
"exactly as it was told, by a caller that was wrong",
|
||||
not _rep5.lost and len(_rep5.misdirected) == len(MAINT_UNIT_KEYS))
|
||||
check("...and the refusal NAMES the namespace it used AND the roster it was "
|
||||
"graded against, rather than merely saying something went wrong - the "
|
||||
"operator's next question is 'which name, and which one should it have "
|
||||
"been', and a bare `not ok` answers neither",
|
||||
"WRONG NAMESPACE" in _rep5.explain()
|
||||
and unit_key(UNIT_A, "last_completed") in _rep5.explain()
|
||||
and repr(ROSTER) in _rep5.explain())
|
||||
check("⚠️ AND THE MARKER IS NOT STAMPED, WHICH IS THE WHOLE REMEDY. A stamped "
|
||||
"marker makes the next start skip a migration that never reached a "
|
||||
"readable key; an unstamped one costs a repeated pass that rule 1 makes a "
|
||||
"no-op. Recoverable beats silent",
|
||||
MAINT_SCHEMA_KEY not in _m5.data)
|
||||
_rep5b = migrate_maint_keys(_m5, ROSTER[0], ROSTER)
|
||||
print(f" the same store, re-run with the roster's own first name: "
|
||||
f"{_rep5b.explain()}")
|
||||
check("...and 'recoverable' is measured, not asserted: the SAME store re-run "
|
||||
"with the roster's own first name migrates cleanly, and the pack reads "
|
||||
"back the month of history it started with. Rule 2 left the flat keys in "
|
||||
"the file, which is what there was to recover FROM",
|
||||
_rep5b.ran and _rep5b.ok
|
||||
and _m5.data.get(unit_key(ROSTER[0], "last_completed"))
|
||||
== "2026-08-07T12:00:00+00:00"
|
||||
and _m5.data[MAINT_SCHEMA_KEY] == MAINT_SCHEMA_VERSION)
|
||||
check("...and the misdirected keys are LEFT WHERE THEY LANDED rather than "
|
||||
"deleted: this function moves history, and a migration that starts "
|
||||
"removing keys it did not put there is a second way to lose a month",
|
||||
_m5.data.get(unit_key(UNIT_A, "last_completed"))
|
||||
== "2026-08-07T12:00:00+00:00")
|
||||
|
||||
# The counterfactual, so this is not a check that passes on a validator that
|
||||
# refuses everything: the SAME store, the SAME call, one argument different.
|
||||
_m6 = flat_store()
|
||||
_rep6 = migrate_maint_keys(_m6, ROSTER[0], ROSTER)
|
||||
check("...and the counterfactual: one argument different - the roster's own "
|
||||
"first name instead of `UNIT_A` - and the identical call is accepted and "
|
||||
"stamped. So the refusal above is about the NAME, not about this fixture "
|
||||
"being refused whatever it is handed",
|
||||
_rep6.ran and _rep6.ok and not _rep6.misdirected
|
||||
and _m6.data[MAINT_SCHEMA_KEY] == MAINT_SCHEMA_VERSION)
|
||||
check("...and a roster of ONE still returns before any of this, so the "
|
||||
"namespace postcondition cannot reach a single-pack install: nothing is "
|
||||
"moved, nothing is graded, nothing is stamped",
|
||||
migrate_maint_keys(MemStore({"phase": DRAIN}), UNIT_A, [UNIT_A]).ran
|
||||
is False)
|
||||
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -720,7 +838,8 @@ check("...and the flat keys are the ones that carried the cycle, so 'no new "
|
||||
== t0 + timedelta(hours=22, minutes=5))
|
||||
check("...and `migrate_maint_keys()` refuses to run at all on a one-pack "
|
||||
"roster, which is why the layout above stays put",
|
||||
migrate_maint_keys(MemStore({"phase": DRAIN}), "inverter-a", 1).ran is False)
|
||||
migrate_maint_keys(MemStore({"phase": DRAIN}), UNIT_A, [UNIT_A]).ran
|
||||
is False)
|
||||
|
||||
# ⚠️ THE REASON STRINGS, PINNED AS LITERALS. The card asks for byte-identical
|
||||
# behaviour AND reason strings at N=1, and the only way a check can see a
|
||||
@@ -1004,31 +1123,89 @@ check("...and a real cycle on it writes no MAINTENANCE key that did not exist "
|
||||
and all(k in _c1store.data for k in MAINT_UNIT_KEYS)
|
||||
and MAINT_SCHEMA_KEY not in _c1store.data)
|
||||
|
||||
# --- two inverters: A's history is namespaced AND mirrored flat -------------
|
||||
# --- two inverters: the first pack's history is namespaced AND mirrored flat -
|
||||
# ⚠️ THE ROSTER HERE IS ["garage", "shed"] AND THAT IS THE FIX ROUND. Run on
|
||||
# `TWO_OPTS`, whose entries are named `inverter-a` / `inverter-b`, this whole
|
||||
# block is satisfied by a Controller that migrates into `UNIT_A` instead of into
|
||||
# `self.inverters[0].name` - the two strings are equal there, so the check could
|
||||
# not reach the failure it names (MUTATION.md, third defect). On an operator's
|
||||
# real roster they are not equal, and `inverter-a` is not even what a nameless
|
||||
# entry falls back to (`build_roster()` numbers those positionally). The names
|
||||
# below are the reviewer's: a house with a garage pack and a shed pack.
|
||||
NAMED_OPTS = {k: v for k, v in TWO_OPTS.items() if k != "inverters"}
|
||||
NAMED_OPTS["inverters"] = [
|
||||
dict(name="garage", setpoint_entity="number.sp_a",
|
||||
soc_entity="sensor.soc_a", batt_entity="sensor.batt_a",
|
||||
max_w=A_RATING),
|
||||
# ⚠️ 3000 W IS THE MARKED HYPOTHESIS AGAIN - inverter B does not exist.
|
||||
dict(name="shed", setpoint_entity="number.sp_b",
|
||||
soc_entity="sensor.soc_b", batt_entity="sensor.batt_b",
|
||||
max_w=B_RATING_HYPOTHESIS),
|
||||
]
|
||||
_C2_NAMES = [e["name"] for e in NAMED_OPTS["inverters"]]
|
||||
check("PRECONDITION for the migration checks below: this roster's first entry "
|
||||
f"is {_C2_NAMES[0]!r}, which is NOT `UNIT_A` ({UNIT_A!r}), and `UNIT_A` "
|
||||
"is not on the roster at all. Every claim under it about 'the first "
|
||||
"roster entry's namespace' is therefore a claim a wrong constant would "
|
||||
"fail",
|
||||
_C2_NAMES[0] != UNIT_A and UNIT_A not in _C2_NAMES)
|
||||
|
||||
# ⚠️ THE DATE IS RELATIVE TO `_now`, NOT A LITERAL. A pack's history is only
|
||||
# observable through `due()` if the interval has not run out on its own: an
|
||||
# absolute date drifts towards `interval_days` as this file ages and the day it
|
||||
# crosses, "history preserved" and "history lost" stop being different answers -
|
||||
# which is the same degenerate-fixture defect one level up.
|
||||
_C2_LAST = _now - timedelta(days=3)
|
||||
_c2store = MemStore({"schema_version": 1,
|
||||
"last_completed": "2026-08-07T12:00:00+00:00"})
|
||||
_c2, _ = controller(TWO_OPTS, store=_c2store)
|
||||
print(f" two inverters -> namespaces "
|
||||
"last_completed": _C2_LAST.isoformat()})
|
||||
_c2, _ = controller(NAMED_OPTS, store=_c2store)
|
||||
print(f" roster {_C2_NAMES} -> namespaces "
|
||||
f"{[m.unit for m in _c2.maints]}, mirrors "
|
||||
f"{[m.store.mirror_flat for m in _c2.maints]}")
|
||||
print(f" flat last_completed {_C2_LAST:%Y-%m-%d} (3 d ago, interval "
|
||||
f"{cfg.interval_days} d) -> migration: {_c2.maint_migration.explain()}")
|
||||
print(f" namespaced keys now: "
|
||||
f"{sorted(k for k in _c2store.data if k.startswith('unit/') and not k.endswith(('sign_tripped', 'sign_reason')))}")
|
||||
check("a two-inverter Controller builds one machine per roster entry, each in "
|
||||
"its own namespace, and migrates the flat history into the FIRST pack's",
|
||||
[m.unit for m in _c2.maints] == ["inverter-a", "inverter-b"]
|
||||
and _c2.maints[0].store.get_time("last_completed")
|
||||
== datetime(2026, 8, 7, 12, 0, tzinfo=timezone.utc)
|
||||
"its own namespace, and migrates the flat history into the FIRST ROSTER "
|
||||
"ENTRY'S - `garage`, the name the config gave it, not a constant",
|
||||
[m.unit for m in _c2.maints] == _C2_NAMES
|
||||
and _c2.maints[0].store.get_time("last_completed") == _C2_LAST
|
||||
and _c2.maints[1].store.get_time("last_completed") is None)
|
||||
check("⚠️ AND NOT ONE MAINTENANCE KEY IS WRITTEN UNDER `unit/inverter-a/`. That "
|
||||
"is the namespace a call site handing the migration `UNIT_A` would use, "
|
||||
"and on this roster nothing reads it - so a store carrying one is an "
|
||||
"install whose whole maintenance history has been parked out of sight",
|
||||
not any(k.startswith(unit_key(UNIT_A, "")) for k in _c2store.data)
|
||||
and _c2store.data.get(unit_key(_C2_NAMES[0], "last_completed"))
|
||||
== _C2_LAST.isoformat())
|
||||
check("⚠️ AND THE PACK IS NOT DUE, WHICH IS WHAT 'NO LOST HISTORY' MEANS TO AN "
|
||||
"OPERATOR (AC5). It recalibrated 3 days ago and the interval is "
|
||||
f"{cfg.interval_days} days. A migration into a namespace this pack does "
|
||||
"not read leaves it reading NOTHING - absent means never recalibrated, "
|
||||
"means due now - so this one line separates a preserved month from a lost "
|
||||
"one",
|
||||
_c2.maints[0].store.get_time("last_completed") is not None
|
||||
and not _c2.maints[0].due(_now))
|
||||
check("...and the migration REPORTS clean and stamps the marker on this roster, "
|
||||
"so 'not due' above is a migration that landed rather than one that was "
|
||||
"refused",
|
||||
_c2.maint_migration.ok and _c2.maint_migration.verified
|
||||
and not _c2.maint_migration.misdirected
|
||||
and _c2store.data.get(MAINT_SCHEMA_KEY) == MAINT_SCHEMA_VERSION)
|
||||
check("...and ONLY the first pack mirrors back to the flat keys, because the "
|
||||
"flat spelling means pack A on every install that has one - a mirror from "
|
||||
"B would overwrite A's rollback view with B's phase",
|
||||
"flat spelling means the first roster entry on every install that has "
|
||||
"one - a mirror from the second would overwrite its rollback view",
|
||||
[m.store.mirror_flat for m in _c2.maints] == [True, False])
|
||||
_c2.maints[0].force_start(_now)
|
||||
_c2.maints[1].store.set("phase", HOLD)
|
||||
check("...and the mirror is LIVE: pack A's new phase reaches the flat key, so a "
|
||||
"rollback finds a current history rather than one dated migration day",
|
||||
_c2store.data.get(unit_key("inverter-a", "phase")) == DRAIN
|
||||
check("...and the mirror is LIVE: the first pack's new phase reaches the flat "
|
||||
"key, so a rollback finds a current history rather than one dated "
|
||||
"migration day",
|
||||
_c2store.data.get(unit_key(_C2_NAMES[0], "phase")) == DRAIN
|
||||
and _c2store.data["phase"] == DRAIN)
|
||||
check("...while pack B's write reaches only pack B's namespace",
|
||||
_c2store.data.get(unit_key("inverter-b", "phase")) == HOLD
|
||||
check("...while the second pack's write reaches only its own namespace",
|
||||
_c2store.data.get(unit_key(_C2_NAMES[1], "phase")) == HOLD
|
||||
and _c2store.data["phase"] == DRAIN)
|
||||
|
||||
|
||||
@@ -1081,6 +1258,91 @@ check("...while the SAME site with the house importing more than the drain "
|
||||
"'maintenance does not work at two inverters'",
|
||||
_cB.allocation.refused is None and _cB.inverters[1].target == 2000.0)
|
||||
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print()
|
||||
print("13. RECORDED, NOT FIXED: SAFETY-02's migration has the same "
|
||||
"wrong-namespace hole, and it is a LATCH")
|
||||
|
||||
# ⚠️ 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.
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
# ⚠️ 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"})
|
||||
_lat, _ = controller(NAMED_OPTS, store=_lat_store)
|
||||
print(f" pre-DEV-02 flat latch + declared roster {_C2_NAMES} -> "
|
||||
f"{_lat.store_migration.explain()}")
|
||||
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)
|
||||
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))
|
||||
# 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"})
|
||||
_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]}")
|
||||
_flat_lat_ok = (all(i.sign_wd.tripped for i in _flat_lat.inverters)
|
||||
and _flat_lat_store.data.get(unit_key(UNIT_A, "sign_tripped"))
|
||||
is True)
|
||||
check("...while the SAME store on the FLAT config - the shape where `UNIT_A` "
|
||||
"really is the roster's only name - migrates and re-arms correctly. So "
|
||||
"this is a defect of the DECLARED roster, not of the migration as such, "
|
||||
"and that is the counterfactual that says which half is broken",
|
||||
_flat_lat_ok)
|
||||
|
||||
print()
|
||||
print()
|
||||
if fails:
|
||||
|
||||
Reference in New Issue
Block a user