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
@@ -46,6 +46,35 @@ two power-moving phases on one grid connection pulling against each other.
|
||||
assumed: the state file keeps the flat keys and gains none, the migration does
|
||||
not run, the four cycle events are the shipped strings character for
|
||||
character, and the status banner is the bare phase with no pack name.
|
||||
* **THE MIGRATION IS GRADED AGAINST THE ROSTER, NOT AGAINST THE NAME IT WAS
|
||||
HANDED** (fix round). `migrate_maint_keys()` takes the roster rather than a
|
||||
pack count and `_verify_moves()` checks the namespace it wrote against the
|
||||
names the machines actually read. A postcondition that reads back the
|
||||
namespace it was given cannot detect being given the wrong one: handed
|
||||
`inverter.UNIT_A` on a roster of `["garage", "shed"]`, the first version of
|
||||
this card wrote all four keys under `unit/inverter-a/...`, read all four back
|
||||
equal, reported **verified**, and stamped `maint_schema_version` — so both
|
||||
packs came up believing they had never been recalibrated and the next start
|
||||
skipped the migration too. That is now `misdirected`: not `ok`, the marker is
|
||||
**not** written, and the next start re-runs and recovers (rule 1 makes the
|
||||
re-run a no-op). `build_roster()` numbers a nameless roster entry
|
||||
`inverter-1`, so `inverter-a` is not even the fallback for a declared roster.
|
||||
Every two-pack fixture that could see this now names its first pack
|
||||
`garage`; with them all called `inverter-a`, `self.inverters[0].name` and
|
||||
`UNIT_A` were the same string and the swap left all 19 suites green.
|
||||
* ⚠️ **FOUND, RECORDED, NOT FIXED (SECOND ONE).** `migrate_unit_keys()` —
|
||||
SAFETY-02's latch migration — has the identical hole and it costs a **guard**
|
||||
rather than a history. It runs before `build_roster()` (each `Inverter`
|
||||
restores its latch in its constructor), so it is handed the constant `UNIT_A`
|
||||
and there is 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 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. Fixing it means
|
||||
naming the migration's target from the config before any `Inverter` exists,
|
||||
which is `inverter.py` and SAFETY-02's semantics, with its own battery.
|
||||
Measured and pinned as a running check (`test_maintenance.py` part 13), whose
|
||||
assertions state the WRONG behaviour on purpose.
|
||||
* ⚠️ **FOUND, RECORDED, NOT FIXED.** A maintenance drain **pins** its pack while
|
||||
the loop's target is a **site** claim, so when the site target is below the
|
||||
pin the only split asks the sibling to charge against the drain and
|
||||
|
||||
@@ -8,6 +8,11 @@ when several packs are due in the same hour it goes to the one that has gone
|
||||
**longest without a completed cycle**. The schedule is **not** staggered by
|
||||
construction — the interlock defers, and the stagger emerges from it.
|
||||
|
||||
**Fix round 2026-09-04.** The store migration is now graded against the
|
||||
**roster** rather than against the unit name it was handed — see §2's
|
||||
*"The read-back postcondition is graded against the ROSTER"* — and the same
|
||||
hole in SAFETY-02's latch migration is recorded in §7.
|
||||
|
||||
Filed with MAINT-05. Card `6a9a9e8503872905f298d80a`.
|
||||
|
||||
---
|
||||
@@ -72,6 +77,69 @@ stay in the file), `_verify_moves()` is the read-back postcondition, and the
|
||||
marker is written last. A second transcription of a migration is a second
|
||||
opinion about what "it landed" means.
|
||||
|
||||
### The read-back postcondition is graded against the ROSTER (fix round)
|
||||
|
||||
**A postcondition that reads back the namespace it was handed cannot detect
|
||||
being handed the wrong one.** That sentence is the whole finding, and this card
|
||||
shipped it broken.
|
||||
|
||||
`_verify_moves()` iterated `rep.moved` — the keys it had just written — and
|
||||
asked whether each read back equal. On a store where the write lands, it always
|
||||
does. So a migration handed a `unit` that is on no roster wrote four keys, read
|
||||
four keys back, 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. And
|
||||
because the marker was stamped, **the next start skipped the migration too** —
|
||||
which is precisely the state `store.py`'s header says must never happen: a
|
||||
failed migration a later version treats as done.
|
||||
|
||||
Reproduced on the reviewer's roster, a house with a garage pack and a shed pack,
|
||||
carrying a flat `last_completed` of 2026-08-07:
|
||||
|
||||
| | `unit` the migration was handed | key written | pack 0 reads back | due now? |
|
||||
|---|---|---|---|---|
|
||||
| correct | `self.inverters[0].name` = `garage` | `unit/garage/last_completed` | `2026-08-07` | **False** |
|
||||
| wrong | `inverter.UNIT_A` = `inverter-a` | `unit/inverter-a/last_completed` | `None` | **True** |
|
||||
|
||||
`build_roster()` names a declared roster's entries **from the config**, and its
|
||||
fallback for a nameless entry is a **positional** `inverter-1` — so `inverter-a`
|
||||
is not the name of anything on a declared roster, not even by default.
|
||||
|
||||
**The remedy, and why this one.** `migrate_maint_keys()` now 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 passed beside the names is a
|
||||
second opinion about how many packs there are). `_verify_moves()` grades the
|
||||
namespace it wrote against those names, and a unit that is not among them is
|
||||
reported as `misdirected` — a **different** failure from `lost`:
|
||||
|
||||
* `lost` is *the store took the write and dropped it* — a storage fault, and it
|
||||
has a harmless-default case (a write of `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 anything in it fails `ok` on its own.
|
||||
|
||||
And **`ok` gates the marker**, so a misdirected migration is not stamped and the
|
||||
next start runs it again. That is the decision the fix round was asked to
|
||||
record, and the argument for it is asymmetric: a re-run is recoverable — rule 1
|
||||
makes it a no-op where keys did land, and rule 2 left the flat keys in the file
|
||||
to recover *from* — while a silent skip is not recoverable at all, because the
|
||||
store now claims the migration happened. `test_maintenance.py` part 9b measures
|
||||
the recovery rather than asserting it: the same store, re-run with the roster's
|
||||
own first name, comes back with its month of history.
|
||||
|
||||
**Why the fixtures had to change, and this is the part that got the card
|
||||
rejected.** Nothing in the repository could see whether any of the above was
|
||||
true, because `inverter.UNIT_A` **is** the string `"inverter-a"` and every
|
||||
two-pack fixture in `test_maintenance.py` named its first roster entry
|
||||
`inverter-a`. `self.inverters[0].name` and `UNIT_A` were therefore the same
|
||||
value in every check that existed, and swapping one for the other at the call
|
||||
site left **all 19 suites green**. MUTATION.md's third defect exactly — a check
|
||||
whose *data* cannot reach the failure it names — and the fifth consecutive card
|
||||
to carry it. Parts 9 and 11 now run on `["garage", "shed"]`, each behind a
|
||||
**precondition check** asserting that the first name is not `UNIT_A`, so an edit
|
||||
that makes them equal again goes red instead of making the claims vacuous.
|
||||
|
||||
### And the flat keys are kept LIVE, which is the half DEV-02 had to add later
|
||||
|
||||
Rule 2 originally said the flat keys are left in place and go stale. DEV-02
|
||||
@@ -180,3 +248,52 @@ subject. MAINT-05 moves which pack is pinned and nothing else about it. It is
|
||||
pinned as a running check (`test_maintenance.py` part 12) rather than left as a
|
||||
comment, so it cannot be forgotten or quietly tuned away, and it wants a card of
|
||||
its own.
|
||||
|
||||
## 7. FOUND AND NOT FIXED: `migrate_unit_keys()` has the same hole, and it is a LATCH
|
||||
|
||||
Found while fixing §2's instance. It is the same defect one module over and it
|
||||
costs a **disarmed safety guard** rather than a lost history.
|
||||
|
||||
`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 a pre-DEV-02
|
||||
`sign_tripped: true` therefore lands under `unit/inverter-a/sign_tripped`, which
|
||||
no unit on that site reads.
|
||||
|
||||
**And the guard that exists for exactly this is defeated by the key the defect
|
||||
wrote.** `_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 — on an install whose
|
||||
`/data/state.json` still reads `sign_tripped: true`. That is DEV-02's own
|
||||
sentence for the worst case: the state file says protected, the banner is green,
|
||||
and the controller resumes commanding into the fault that tripped it.
|
||||
|
||||
Measured, on the same `["garage", "shed"]` roster:
|
||||
|
||||
```
|
||||
pre-DEV-02 flat latch + declared roster ['garage', 'shed'] ->
|
||||
migrated 2 flat key(s): unit/inverter-a/sign_reason, unit/inverter-a/sign_tripped
|
||||
latch keys in the store: ['sign_tripped', 'site_sign_tripped', 'unit/inverter-a/sign_tripped']
|
||||
roster came up tripped: [('garage', False), ('shed', False)]
|
||||
the same store on the FLAT config -> [('inverter-a', True)]
|
||||
```
|
||||
|
||||
The last line is the counterfactual: on the flat shape, where `UNIT_A` really is
|
||||
the roster's only name, the same store migrates and comes up latched. So this is
|
||||
a defect of the **declared roster**, not of the migration as such.
|
||||
|
||||
**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 a silent expansion into a safety
|
||||
latch is how a rejected PR becomes a broken one.
|
||||
|
||||
It is pinned as a running check (`test_maintenance.py` part 13) rather than left
|
||||
as a comment. ⚠️ **Its assertions state the WRONG behaviour on purpose** — read
|
||||
them as the specification of a defect; the card that fixes it should expect them
|
||||
to go red and should replace them.
|
||||
|
||||
@@ -135,12 +135,19 @@ manufactures them from a **fixture**: the check runs, the assertion is
|
||||
evaluated, the sentence above it is true — and the numbers it compares are
|
||||
arranged so that the failure it describes could not have changed the answer.
|
||||
|
||||
It is not hypothetical and it is not rare. Six instances in two weeks; three
|
||||
It is not hypothetical and it is not rare. Seven instances in two weeks; four
|
||||
were found by review, two - CFG-04's fixture pair - by an author's own mutation
|
||||
battery, which is the cheaper place, and the sixth by review again, in the one
|
||||
battery, which is the cheaper place, and one by review again, in the one
|
||||
place nobody had thought to point the rule at: not a check in the suite, but a
|
||||
COMMITTED EVIDENCE ARTIFACT four documents cite.
|
||||
|
||||
⚠️ **AND IT HAS NOW APPEARED IN FIVE CONSECUTIVE CARDS.** The seventh instance,
|
||||
MAINT-05's, is the one that says why: the defect is not carelessness about
|
||||
fixtures, it is that **a check and the thing it checks were built from the same
|
||||
constant**. Ask of every difference-claim not "is this assertion true" but
|
||||
"where did each side of this comparison come from" - and if the answer is the
|
||||
same place twice, the check is decoration.
|
||||
|
||||
* **ARB-03** shipped a call-site scan asserting that every `claims.append` in
|
||||
`cycle()` routes through `actuator_of()`. Its own parse **saw 3 of the 8
|
||||
appends**; the other five were invisible to it and would have passed however
|
||||
@@ -167,6 +174,28 @@ COMMITTED EVIDENCE ARTIFACT four documents cite.
|
||||
than against its behaviour. It now drives the shipped `run_control()` on a
|
||||
two-inverter config, **under a deadline**, because the mutation's real symptom
|
||||
is that the controller enters its loop and never returns.
|
||||
* **MAINT-05** shipped `Controller.__init__` calling
|
||||
`migrate_maint_keys(store, self.inverters[0].name, ...)` under a comment
|
||||
saying *"it targets `self.inverters[0].name` and NOT `UNIT_A` ... writing
|
||||
`UNIT_A` here would deposit an install's whole maintenance history under a
|
||||
name that is not on the roster at all"*. `inverter.UNIT_A` **is** the string
|
||||
`"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 value
|
||||
in every check that existed, and swapping them left **all 19 files green**
|
||||
while losing a real install's whole maintenance history. Two-pack fixtures are
|
||||
now `["garage", "shed"]`, each behind a precondition check (habit 1) that the
|
||||
first name is not `UNIT_A`.
|
||||
|
||||
⚠️ **AND THE POSTCONDITION HAD THE SAME SHAPE AS THE FIXTURE.**
|
||||
`store._verify_moves()` read back the keys it had just written, under the
|
||||
namespace it had just been handed — so it verified clean, and the caller
|
||||
stamped the schema marker, on a migration nothing would ever read. *A check
|
||||
that reads back the namespace it was handed cannot detect being handed the
|
||||
wrong one*: it compared a thing against itself, exactly as DEV-02's fixture
|
||||
compared `(-5000, 5000)` against `(-5000, 5000)`. The habit-2 fix is the same
|
||||
one — grade against something the thing under test did not supply, here the
|
||||
ROSTER the machines read.
|
||||
|
||||
* **CFG-04's `tools/supervisor_shape_probe.py` §B** — and this one is the rule
|
||||
reaching outside the suite. The probe is the committed evidence that
|
||||
`config.yaml`'s `inverters: []` default is load-bearing, and four documents
|
||||
|
||||
@@ -276,11 +276,24 @@ class Controller:
|
||||
# It targets `self.inverters[0].name` and NOT `UNIT_A`: the flat keys
|
||||
# belonged to whichever pack the one FSM was driving, which is the first
|
||||
# roster entry, and a declared `inverters:` roster names its own entries
|
||||
# (`inverter.build_roster()` says so). Writing `UNIT_A` here would
|
||||
# deposit an install's whole maintenance history under a name that is
|
||||
# not on the roster at all.
|
||||
# (`inverter.build_roster()` says so, and its fallback for a nameless
|
||||
# entry is a POSITIONAL `inverter-1`, so `inverter-a` is not even the
|
||||
# default there). Writing `UNIT_A` here would deposit an install's whole
|
||||
# maintenance history under a name that is not on the roster at all.
|
||||
#
|
||||
# ⚠️ AND THAT SENTENCE USED TO BE A COMMENT NOTHING COULD GRADE, WHICH
|
||||
# IS WHY THE ROSTER IS PASSED RATHER THAN A COUNT. Every two-pack
|
||||
# fixture in the suite named its first entry `inverter-a`, so
|
||||
# `self.inverters[0].name` and `UNIT_A` were the same string in every
|
||||
# check that existed and swapping them left all 19 files green - while
|
||||
# on a roster of `["garage", "shed"]` it silently loses the history and
|
||||
# stamps the marker, so the next start skips the migration too.
|
||||
# `migrate_maint_keys()` now grades the namespace it wrote against the
|
||||
# names the machines read; test_maintenance.py sections 9 and 11 drive
|
||||
# a `["garage", "shed"]` roster, which is what makes the mutation red
|
||||
# instead of invisible.
|
||||
self.maint_migration = migrate_maint_keys(
|
||||
store, self.inverters[0].name, len(self.inverters))
|
||||
store, self.inverters[0].name, [inv.name for inv in self.inverters])
|
||||
_LOG.info("maintenance history: %s", self.maint_migration.explain())
|
||||
if not self.maint_migration.ok:
|
||||
# Not a disarmed guard - history, not a latch - so it does not
|
||||
|
||||
@@ -211,13 +211,23 @@ class MigrationReport:
|
||||
# to shout: an alarm that fires on the harmless case is an alarm an
|
||||
# operator learns to dismiss before the real one arrives.
|
||||
self.lost = []
|
||||
# ⚠️ A DIFFERENT FAILURE FROM `lost`, AND IT HAS TO BE, BECAUSE THE
|
||||
# READ-BACK CANNOT SEE IT. `lost` is "the store took the write and
|
||||
# dropped it". This is "the store took the write, kept it perfectly, and
|
||||
# NOTHING WILL EVER READ IT" - the migration was handed a unit name that
|
||||
# is not on the roster, so every machine looks under a different key and
|
||||
# finds nothing. There is no harmless-default version of this the way
|
||||
# there is for `lost`: a write nobody reads is not information, whatever
|
||||
# its value, so anything in here fails `ok` on its own.
|
||||
self.misdirected = []
|
||||
# Why nothing ran, when nothing ran. Empty means "already at schema".
|
||||
self.skipped = ""
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
"""Nothing that CARRIED STATE failed to land."""
|
||||
return not self.lost
|
||||
"""Nothing that CARRIED STATE failed to land, and it landed WHERE THE
|
||||
MACHINES LOOK."""
|
||||
return not self.lost and not self.misdirected
|
||||
|
||||
def explain(self) -> str:
|
||||
if not self.ran:
|
||||
@@ -238,6 +248,8 @@ class MigrationReport:
|
||||
out += " - PROBLEMS: " + "; ".join(self.problems)
|
||||
if self.lost:
|
||||
out += " - STATE LOST: " + "; ".join(self.lost)
|
||||
if self.misdirected:
|
||||
out += " - WRONG NAMESPACE: " + "; ".join(self.misdirected)
|
||||
return out
|
||||
|
||||
|
||||
@@ -329,7 +341,7 @@ def _move_flat_keys(store, unit: str, keys, rep: MigrationReport) -> None:
|
||||
rep.moved[target] = value
|
||||
|
||||
|
||||
def _verify_moves(store, rep: MigrationReport) -> None:
|
||||
def _verify_moves(store, rep: MigrationReport, roster=None) -> None:
|
||||
"""THE POSTCONDITION, AND IT IS A READ-BACK RATHER THAN AN ASSUMPTION.
|
||||
|
||||
This is the difference between these functions and a two-line copy: the
|
||||
@@ -339,6 +351,34 @@ def _verify_moves(store, rep: MigrationReport) -> None:
|
||||
same `unit_key()` those readers read through. A `False` here is a guard that
|
||||
is off, or a month of coulomb-count history gone, on an install whose state
|
||||
file says otherwise.
|
||||
|
||||
⚠️ AND `roster` IS THE OTHER HALF, WHICH THE READ-BACK ON ITS OWN CANNOT BE.
|
||||
A POSTCONDITION THAT READS BACK THE NAMESPACE IT WAS HANDED CANNOT DETECT
|
||||
BEING HANDED THE WRONG ONE. That is not a hypothetical: MAINT-05 shipped
|
||||
with `migrate_maint_keys()` taking a bare `unit` string, and a call site that
|
||||
passed `inverter.UNIT_A` instead of the first roster entry's name migrated an
|
||||
install's whole maintenance history into `unit/inverter-a/...` on a roster of
|
||||
`["garage", "shed"]`, read all four keys back under that same name, was
|
||||
SATISFIED, and stamped the schema marker - so the next start skipped the
|
||||
migration too and both packs came up believing they had never been
|
||||
recalibrated. Every symptom of a clean migration, and the history gone.
|
||||
`build_roster()` names a declared roster's entries from the config and falls
|
||||
back to a POSITIONAL `inverter-N`, so `inverter-a` is not even the default
|
||||
there; there is no roster on which that write could have been read.
|
||||
|
||||
So the namespace is graded against the one the MACHINES read - the roster -
|
||||
rather than against the argument this function was handed, which is the
|
||||
thing under test. A unit that is not on the roster is `misdirected`, which
|
||||
fails `ok`, which means the caller does NOT stamp the version marker and the
|
||||
next start runs the migration again. A re-run is recoverable, by rule 1; a
|
||||
silent skip is the state store.py's header says must never happen.
|
||||
|
||||
`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.
|
||||
"""
|
||||
for target, value in rep.moved.items():
|
||||
got = getattr(store, "data", {}).get(target, "<<missing>>")
|
||||
@@ -351,8 +391,24 @@ def _verify_moves(store, rep: MigrationReport) -> None:
|
||||
if value != DEFAULTS.get(legacy):
|
||||
rep.lost.append(note)
|
||||
|
||||
if roster is None:
|
||||
return
|
||||
names = [str(n) for n in roster]
|
||||
# `unit_key()` builds `unit/<unit>/<key>`, so the unit is field 1. Split
|
||||
# through the same spelling the writer used rather than re-deriving it.
|
||||
for target in sorted(rep.moved):
|
||||
parts = target.split("/")
|
||||
unit = parts[1] if len(parts) > 2 else ""
|
||||
if unit in names:
|
||||
continue
|
||||
rep.verified = False
|
||||
note = (f"{target} was migrated into namespace {unit!r}, which is NOT "
|
||||
f"on the roster {names!r} - nothing reads that key")
|
||||
rep.problems.append(note)
|
||||
rep.misdirected.append(note)
|
||||
|
||||
def migrate_maint_keys(store, unit: str, n_units: int) -> MigrationReport:
|
||||
|
||||
def migrate_maint_keys(store, unit: str, roster) -> MigrationReport:
|
||||
"""Move the flat maintenance history into the FIRST pack's namespace.
|
||||
|
||||
MAINT-05. Until this card there was one `Maintenance` and one history, and
|
||||
@@ -363,14 +419,32 @@ def migrate_maint_keys(store, unit: str, n_units: int) -> MigrationReport:
|
||||
what `Controller.maint_unit` named and what `validate.py` rule 6 graded
|
||||
against.
|
||||
|
||||
⚠️ `n_units <= 1` IS A RETURN, NOT A SPECIAL CASE. At one inverter the flat
|
||||
keys ARE that pack's namespace (§6.2's identity, the same one
|
||||
⚠️ `len(roster) <= 1` IS A RETURN, NOT A SPECIAL CASE. At one inverter the
|
||||
flat keys ARE that pack's namespace (§6.2's identity, the same one
|
||||
`arbiter.actuator_for()` leans on), `Maintenance` is constructed to read
|
||||
them directly, and a migration would add four keys plus a marker to a store
|
||||
that must come out byte-identical. The migration happens on the day the
|
||||
roster grows, which is the day the flat spelling stops being able to answer
|
||||
"which pack".
|
||||
|
||||
⚠️ `roster` IS REQUIRED, AND IT REPLACED AN `n_units: int`. Two reasons, and
|
||||
the second is the one MAINT-05 was rejected over.
|
||||
|
||||
The small one: `n_units` was `len(self.inverters)` at the only call site, so
|
||||
the roster carries it - `len(roster)` - and a count passed beside the names
|
||||
is a second opinion about how many packs there are.
|
||||
|
||||
The load-bearing one: THE NAMES ARE THE POSTCONDITION. `unit` says which
|
||||
pack the flat keys belonged to and that is genuinely a decision - the first
|
||||
roster entry, because that is the pack the one FSM was driving - but a
|
||||
decision the caller can get WRONG, and until this signature existed nothing
|
||||
could tell. `_verify_moves()` read back the namespace it was handed, so a
|
||||
migration into a name no machine reads verified clean and stamped the
|
||||
marker. Handing the roster in means the read-back is graded against the
|
||||
keys the MACHINES read, and a `unit` that is not among these names comes
|
||||
back `misdirected`: not `ok`, marker NOT written, migration runs again next
|
||||
start. See `_verify_moves()` for the install this was measured on.
|
||||
|
||||
THE THREE RULES ARE `migrate_unit_keys()`'s, SHARED IN CODE rather than
|
||||
restated: `_move_flat_keys()` is rule 1 (never overwrite a namespaced value)
|
||||
and rule 2 (the flat keys are left in the file), `_verify_moves()` is the
|
||||
@@ -390,8 +464,9 @@ def migrate_maint_keys(store, unit: str, n_units: int) -> MigrationReport:
|
||||
"""
|
||||
rep = MigrationReport()
|
||||
data = getattr(store, "data", {})
|
||||
names = [str(n) for n in roster]
|
||||
rep.from_version = int(data.get(MAINT_SCHEMA_KEY, 0) or 0)
|
||||
if n_units <= 1:
|
||||
if len(names) <= 1:
|
||||
rep.skipped = ("one inverter: the flat maintenance keys ARE that pack's "
|
||||
"history and are read in place, so there is nothing to "
|
||||
"move and the store layout does not change")
|
||||
@@ -404,13 +479,19 @@ def migrate_maint_keys(store, unit: str, n_units: int) -> MigrationReport:
|
||||
|
||||
rep.ran = True
|
||||
_move_flat_keys(store, unit, MAINT_UNIT_KEYS, rep)
|
||||
_verify_moves(store, rep)
|
||||
_verify_moves(store, rep, roster=names)
|
||||
|
||||
if rep.ok:
|
||||
store.set(MAINT_SCHEMA_KEY, MAINT_SCHEMA_VERSION)
|
||||
else:
|
||||
_LOG.error("per-pack maintenance-history migration LOST STATE and has "
|
||||
"not been marked done: %s", rep.explain())
|
||||
# ⚠️ NOT MARKED DONE, FOR EITHER FAILURE. The same sentence
|
||||
# `migrate_unit_keys()` carries: the one thing worse than a failed
|
||||
# migration is a failed migration a later version skips. Rule 1 makes
|
||||
# the re-run a no-op where the keys did land, so refusing the marker
|
||||
# costs one repeated pass and buys the difference between a migration
|
||||
# that can still be fixed and one the store says already happened.
|
||||
_LOG.error("per-pack maintenance-history migration did NOT land and "
|
||||
"has not been marked done: %s", rep.explain())
|
||||
return rep
|
||||
|
||||
|
||||
|
||||
@@ -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