SAFETY-37 builds the single freshness policy the architect pass asked for, wires
it to the two consumers the card names, and says what TEL-06 and TEL-08
still owe. One criterion in the card could not be implemented as written; §1
says which one and what replaced it, because that is the most useful thing in
this PR.
Branch off release/1.0 @ a4290d3. No new configuration key and no schema
change, so no version bump.
1. ⚠️ The criterion that had to be rewritten
The card says:
"A pack legitimately resting at 40 % and a dead sensor reading 40 % are the
same number; only arrival separates them."
At this layer, arrival does not separate them, and cannot.read_inputs()
polls Home Assistant's state cache about once a second (run_control()'s await asyncio.sleep(1) bounds the loop body), and while the entity exists Home
Assistant answers 200 with a number whether or not the publisher behind it
is alive. Both members of the pair arrive, identically, for ever.
That is not an argument, it is graded. test_freshness.py part 1a builds the
arrival-age gate the card asks for and runs it against both members of the pair:
ok ⚠️ AN ARRIVAL-AGE GATE CANNOT TELL THE PAIR APART … the age since arrival
is 0.0 s at every instant of BOTH runs - a threshold on it is a threshold
that can never fire, which is TEL-06's defect rebuilt in this repository
The other half of the card's rule is honoured absolutely.last_updated
does not move on an unchanged rewrite; neither does last_changed; and this
project already measured on the ENV-01 rig (70 s of a frozen meter) and against
the live house that last_reported does not advance on a repeat on either
serialiser — p1.HomeWizardLocalSource's docstring records both. freshness.py
reads no Home Assistant timestamp at all, and part 1a asserts that its body
contains no last_updated. Every clock in the verdict is time.monotonic()
inside this process, stamped at the instant an arrival carried a different
number.
So the criterion became: a reading is stale when it has not moved through a
change it was OBLIGED to follow. The obligation, not the age, separates the
pair.
2. The policy
STALE ⟺ unchanged for ≥ 35 s
AND |standing command now| ≥ 250 W
AND |standing command now − standing command when the reading last
changed| ≥ 250 W … latched until a different value arrives
Inverter.batt becomes None — never a held number, never a zero — which
is a MISSING INPUT, which §11.1 rule 1 already answers with 0 W and keeps
commanding.
Both consumers are gated by that one change, and neither consumer needed a
line. They already do the right thing with a None and neither could ever
receive one: SignWatchdog.evaluateclears its window on a None
measurement, and circulating_w returns None for a roster with a missing
term. The gate is the piece that was missing between the shipped read and the
shipped checks. Both facts are asserted on the shipped functions as
preconditions, not assumed.
Why it is a conjunction and not a threshold — measured, this run
Replaying the committed 30-day capture of this house's own 1 W-resolution power
entity (scenarios/p1-net-30d-2026-08/, 485,841 samples out of Home Assistant's
recorder, where a row exists only when the state string changed, so the gap
between rows is the unchanged duration every consumer saw):
longest unchanged run over 30 days
263.2 s (held −899.0 W, 2026-08-11 17:04)
runs ≥ 35 s
10 of 485,840 (0.0021 %)
runs ≥ 17 s
386 of 485,840 (0.0794 %)
A real power figure at 1 W resolution held one value for four and a half
minutes with nothing wrong. A false-positive-free unchanged threshold would
have to be over 263 s — and 263 s is 3.5× too slow to protect a consumer
that trips at 75 s. There is no admissible threshold. Without the
conjunction the card has no implementable verdict.
⚠️That is the METER's register, not 0x0518, and it is a surrogate. No
30-day capture of the inverter's own power exists — it is not in the recorder
export. The only thing it is used for is the sentence above; it does not set the
bound. test_freshness.py part 8 recomputes all three figures from the
committed files on every run, so a quoted number that drifts from its evidence
goes red rather than being re-quoted.
⚠️Every one of those long runs is under an unmoving load — the worst is
263 s of a steady −899 W export. That is exactly what the command-travel term
keys on: none of them arms the gate.
3. The bounds, per signal, with their derivations
⚠️One constant for both signals would be useless on SoC or trigger-happy on
power.freshness.BOUNDS carries them as data with a wired column, and part
7 walks the AST of every shipped module to check that column tells the truth.
(Its first draft grepped for the string and answered wrong — both inverter.py
and main.pynameBOUNDS["soc"] in a comment explaining why it is unwired.)
batt — 0x0518, S16, 1 W, published every 10 s — WIRED
Floor 17.0 s, the fastest a healthy register can be obliged to answer:
source, read this run unless dated
6.0 s
plant settle
goodwe/NOTES.md §5.9, measured 2026-08-22: 26 W at +1.43 s, 469 W (94 %) at +3.34 s, settled 507 W at +6.05 s
Ceiling 75.0 s, the tighter of the two consumers: circulation_hold_s75
(config.yaml:512) and unit_sign_disagree_s85 (config.yaml:451). A gate
slower than its consumer hands over the frozen number and explains afterwards.
Chosen 35.0 s. Band [2 × 17.0, 0.5 × 75.0] = [34.0, 37.5]. ⚠️The band
is derived; 35 is a CHOICE inside it — the band holds four integers, and my
first draft of that check claimed it held one and went red on its first run. It
is deliberately not 30 (validate.FIRMWARE_INGEST_VETO_S, a borrowed constant)
and not 60 (round). Part 6 computes both ends out of config.yaml: drop circulation_hold_s to 60 and this goes red.
MOVE_W = 250 W is the largest admissible value, not a sensitivity
preference: the gate must arm everywhere a stale reading could mislead a
consumer, and the earliest either consumer speaks is unit_sign_disagree_w
250 W (config.yaml:450) — below that watchdog.disagrees() is False whatever
the measurement says. Taking the ceiling minimises spurious arming. Pinned to config.yaml, not to a literal.
soc — 0x050E, U16, 1 %, published every 10 s — STATED, NOT WIRED
SoC cannot use this conjunction: a rested pack holds one percent for hours
under a 0 W command, which is exactly the card's own example. Its obliged
movement is energy: one percent of pack A is ≈ 153.6 Wh (15.36 kWh
nominal, goodwe/NOTES.md §1.1b — ⚠️ NOMINAL and FROM DATASHEET, not a measured
usable capacity), so one percent takes ≥ 110 s at 5000 W and ≈ 553 s at 1000 W.
The bound is "SoC unchanged while ∫|battery power| dt exceeded K × 153.6 Wh",
and K cannot be chosen from anything written down today. Shipping it with a
guessed K would be a gate that fires on a rested pack — the hazard, not the fix. Freshness takes its obligation term from the caller precisely so an energy
integral can be handed in where command travel is handed in here.
4. ⚠️ What changes on the install that is running in a house today
before
after
healthy register, N=1
value reaches consumers
identical, asserted: 60 reads of a dithering register under a reversing command all arrive unchanged, gate never arms, status page says nothing new
healthy register, N≥2
value reaches consumers
identical
frozen register, N=1
reads as a healthy measurement for ever
site stops — inputs_ok False → stale_input_s hold → 0 W, and keeps commanding
frozen register, N≥2
per-unit sign check false-trips at 85 s and parks a healthy inverter; circulation sensor reports a loop that is not happening
both consumers get None; both windows clear
The N=1 row is a real behaviour change on the shipped roster and is named
rather than hidden. It is also the point of the card: HW-01 was a
one-inverter site, and the pack drained to its floor over 6 h 25 m with every
detector reading healthy. The direction it fails in is the safe one — a false
verdict commands 0 W, behind the existing 15 s stale_input_s hold, and clears
itself on the register's next move (one watt is enough; 0x0518 dithers ±1 W
while holding, NOTES.md §5.11, 2026-08-22).
The counterfactual is run, not argued. Part 3 drives real cycle() calls at
N=2 with A's register frozen at −1200 W and B's live at +900 W, the command
travel produced by the shipped compute() and allocator rather than written
into the fixture:
standing on A: [0, 445, 890, 1335, 1335, …] ← the shipped control law
gated : sign trip tick=None circ=[1800, 1800, 1800, None, None, …]
counterfact : sign trip tick=7 circ=[1800, 1800, 1800, 1800, 1800, …]
⚠️BOTH UNITS IN EVERY TWO-INVERTER FIXTURE HERE ARE INVERTER A, with
different numbers passed in. Inverter B does not physically exist (≈ 2026-09-12; goodwe/NOTES.md §1.1b, and sim/plantsim.py:216's INVERTER_B is A copied
with measured=False). The 3000 W / 15 % second unit is illustrative and
deliberately not B's datasheet 4600 W / 10 %: the real pair differs by 400 W,
narrower than a slew step, and two near-equal numbers cannot show a check that
read the wrong unit's.
5. What TEL-06 and TEL-08 still owe
TEL-06 — layer 1, Blocked. Nothing in this card can reach it. The
ESPHome veto tests the value of sensor.goodwe_p1_sample_age; this policy
lives in the add-on, on the far side of a flash. What TEL-06 owes is the same
substitution inside the firmware: a millis() stamp taken when the entity
is written, and a veto on millis() − last_write_ms rather than on the
published number. The policy question — arrival, not value — is answered here.
The remaining work is a firmware stamp and a supervised flash. No firmware was
touched.
TEL-08 — stale SoC. It owes exactly three things and no mechanism:
a measured usable capacity for pack A (or a defensible bound) — the
153.6 Wh/percent above is nominal and from a datasheet;
the energy integral since the reading last changed;
the constant K.
It should consume freshness.Freshness with BOUNDS["soc"] filled in, not
restate the policy. The row is already in the table with wired=False and its
reason, and part 7 goes red the day that column stops being true.
6. ⚠️ Honest survivors, with their size
Graded in part 9, so they are residuals and not surprises.
kind
what survives
size
false positive
an inverter physically unable to follow — saturating, thermally derating, a BMS refusing a charge — is called stale while telling the truth
one spurious site stop, behind stale_input_s, self-clearing the moment the register moves. It cannot drive the battery anywhere
miss
a register frozen under a command that never travels 250 W is never detected, for 400 s or for ever
scoped: such a unit is one no per-unit sign check can speak about (unit_sign_disagree_w = 250 W) and one contributing at most 250 W to a loop whose floor is 700 W. Asserted, not argued
miss — the largest
a dead publisher whose last values alternate, or a cache replaying two old samples, never starts the unchanged clock
this gate detects a value that STOPPED; it cannot detect a value that is WRONG. Nothing in this add-on can. TEL-06's firmware arrival stamp is the only layer that could
Recorded because it looks like a survivor and is not: a publisher failure that
freezes both registers is caught on both units independently, on each unit's
own command travel and each unit's own number.
7. Mutation ledger — 2026-09-06
MUTATION.md's rule honoured throughout: purge first (count printed, 0 remaining
every run), tree mounted :ro, python3 -B, fingerprint before and after
the revert.
Clean tree: app/freshness.pysha256:f40fa3e8819014ce, 20,065 B · app/inverter.py7e760c6e8cdfa5b7, 30,778 B · app/main.py 7d7ff4ba0697ea64, 238,535 B. Each mutant graded by test_freshness.py + test_inverter.py.
Clean tree: app/freshness.pysha256:f40fa3e8819014ce, 20,065 B · app/inverter.py7e760c6e8cdfa5b7, 30,778 B · app/main.py 7d7ff4ba0697ea64, 238,535 B. Each mutant graded by test_freshness.py + test_inverter.py.
#
mutation
file
Δ B
result
M0
none (control)
freshness
+0
green — the harness is not red on everything
M1
reading() returns self.last unconditionally
freshness
−24
red, 3 named checks
M2
drop the command-travel term
freshness
−59
red
M3
drop the magnitude term
freshness
−54
SURVIVED, then red — the finding
M4
latch never holds
freshness
+13
red, 3 named checks
M5
bound 90 s (slower than both consumers)
freshness
+0
red
M6
bound 10 s (under the physical floor)
freshness
+0
red
M7
MOVE_W 700 W
freshness
+0
red, on part 6's config.yaml pin
M8
the wiring in _read_unit removed
main
−5
red, 3 named checks
M9
reset the unchanged clock on every arrival
freshness
−43
red
M10
standing_w() drops last_write
inverter
−73
red, and takes test_inverter.py with it
M11
standing_at_change refreshed on every arrival
freshness
+121
⚠️DISCOUNTED — the mutant does not parse
M11b
the same defect, written parseably
freshness
+121
red, 3 named checks
M12
BOUNDS["soc"].wired = True — the table lies
freshness
−1
red
M13
the wrong signal submitted (inv.soc for inv.batt)
main
+4
red, and test_inverter.py too
M14
one Freshness shared by every unit
inverter
+29
red
M15
latch cleared by a failed read
freshness
+31
SURVIVED, then red
⚠️M5, M6 and M7 are +0 B, MUTATION.md's risky delta. All three are reds, the direction stale bytecode cannot fabricate, every run purged with
the count printed, and the tree mounted :ro so it physically cannot hold a
cache.
⚠️M11 IS NOT A RESULT AND IS NOT COUNTED. Its edit dedents a statement into
an if body, so the module raises IndentationError at line 312 and both files
go red without a check being evaluated — verified with ast.parse, not assumed. M11b is the same defect written so it parses.
Six were re-run against the exact committed tree (test_freshness.py sha256:a62f3e1f1ac856cf, 44,913 B), because the two closures, the key=str
fix and the wall-clock hardening all landed after the sweep: M0 green, and M2,
M3, M8, M11b and M15 all red on named checks. The other ten are red on checks in
parts none of those edits touch.
⚠️ The one survivor, and it is the finding
M3, dropping the magnitude term, survived the first draft. Every check was
green, because in all of them a command that had travelled 250 W had also ended up above 250 W — the fixture never separated the two terms, so one of
them was decoration as far as the suite could tell. The case that separates
them is a site brought to rest: writes are serialised, so a register can
reach its new value on the same read at which the old command is still
standing; standing_at_change then captures 1300 W, the site is commanded to
0 W, and the travel term alone reads 1300 W of unfollowed command while the
register sits truthfully at +5 W for a minute. Part 4 now grades exactly that,
and M3 is red.
Recorded in MUTATION.md as habit 17 — twelfth instance of "a check whose
DATA cannot reach the failure it names", and the general form: ask of every
conjunction whether there is a fixture in which each term is the only false
one. If not, the suite cannot tell an AND from an OR.
⚠️ And one process defect worth more than the ledger
One mutation's container run overran a two-minute command budget and the wrapper
was killed between the edit and the revert, leaving app/freshness.py
mutated on disk. Nothing announced it; the next mutation's harness cheerfully
fingerprinted and graded the mutant plus a second mutation. It was caught by
comparing the tree fingerprint against the recorded clean one, restored, and
verified back to byte and sha256 before anything else ran. That is the same
defect stale bytecode causes, from a different cause, and it is now MUTATION.mdhabit 18: fingerprint after the revert, not only before the
run.
8. Suite
22/22 PASS in goodwe-addon-ci (the CI image, deps from apk), tree mounted :ro, PYTHONDONTWRITEBYTECODE=1, python3 -B, bytecode purged before the run
(count printed) and 0 caches present after it. test_freshness.py is the 22nd
file and takes 19 s, 3.5 s of which is replaying the 30-day capture.
test_inverter.py's config-reader audit caught the new option_default() on its
first run and named it — the table is signature-driven, exactly as its comment
promises — so it is registered there rather than left outside the audit.
⚠️ Part 3 touches the wall clock, so it MEASURES its own margin (CI-06)
The freshness verdict runs on an injected monotonic clock and is deterministic.
The sign watchdog inside cycle() cannot be injected. CI-06 is the record of
what that costs — test_peakguard.py answered two checks with the wall clock,
flaked at ~20 %, and cost a wrong merge diagnosis, because a flaky check does
not waste a re-run, it manufactures evidence for whichever hypothesis the reader
already holds. So the margin is asserted with its number:
gate closed after 0.61 s of WALL clock, against the 2 s watchdog window (3.3x margin)
If a loaded machine ever narrows that, the check fails naming the number —
the difference between "the box was slow" and "the code is broken".
SAFETY-37 builds the single freshness policy the architect pass asked for, wires
it to the two consumers the card names, and says what [[TEL-06]] and [[TEL-08]]
still owe. **One criterion in the card could not be implemented as written; §1
says which one and what replaced it, because that is the most useful thing in
this PR.**
Branch off `release/1.0` @ `a4290d3`. **No new configuration key and no schema
change**, so no version bump.
---
## 1. ⚠️ The criterion that had to be rewritten
The card says:
> *"A pack legitimately resting at 40 % and a dead sensor reading 40 % are the
> same number; **only arrival separates them**."*
**At this layer, arrival does not separate them, and cannot.** `read_inputs()`
polls Home Assistant's state cache about once a second (`run_control()`'s
`await asyncio.sleep(1)` bounds the loop body), and while the entity exists Home
Assistant answers `200` with a number **whether or not the publisher behind it
is alive**. Both members of the pair arrive, identically, for ever.
That is not an argument, it is graded. `test_freshness.py` part 1a builds the
arrival-age gate the card asks for and runs it against both members of the pair:
```
ok ⚠️ AN ARRIVAL-AGE GATE CANNOT TELL THE PAIR APART … the age since arrival
is 0.0 s at every instant of BOTH runs - a threshold on it is a threshold
that can never fire, which is TEL-06's defect rebuilt in this repository
```
**The other half of the card's rule is honoured absolutely.** `last_updated`
does not move on an unchanged rewrite; neither does `last_changed`; and this
project already measured on the ENV-01 rig (70 s of a frozen meter) and against
the live house that `last_reported` does not advance on a repeat on either
serialiser — `p1.HomeWizardLocalSource`'s docstring records both. **`freshness.py`
reads no Home Assistant timestamp at all**, and part 1a asserts that its body
contains no `last_updated`. Every clock in the verdict is `time.monotonic()`
inside this process, stamped at the instant an arrival carried a different
number.
So the criterion became: **a reading is stale when it has not moved through a
change it was OBLIGED to follow.** The obligation, not the age, separates the
pair.
---
## 2. The policy
```
STALE ⟺ unchanged for ≥ 35 s
AND |standing command now| ≥ 250 W
AND |standing command now − standing command when the reading last
changed| ≥ 250 W … latched until a different value arrives
```
`Inverter.batt` becomes **`None`** — never a held number, never a zero — which
is a MISSING INPUT, which §11.1 rule 1 already answers with 0 W and keeps
commanding.
**Both consumers are gated by that one change, and neither consumer needed a
line.** They already do the right thing with a `None` and neither could ever
receive one: `SignWatchdog.evaluate` **clears** its window on a `None`
measurement, and `circulating_w` returns `None` for a roster with a missing
term. The gate is the piece that was missing *between* the shipped read and the
shipped checks. Both facts are asserted on the shipped functions as
preconditions, not assumed.
### Why it is a conjunction and not a threshold — measured, this run
Replaying the committed 30-day capture of this house's own 1 W-resolution power
entity (`scenarios/p1-net-30d-2026-08/`, 485,841 samples out of Home Assistant's
recorder, where a row exists only when the state string **changed**, so the gap
between rows *is* the unchanged duration every consumer saw):
| | |
|---|---|
| longest unchanged run over 30 days | **263.2 s** (held −899.0 W, 2026-08-11 17:04) |
| runs ≥ 35 s | 10 of 485,840 (0.0021 %) |
| runs ≥ 17 s | 386 of 485,840 (0.0794 %) |
A real power figure at 1 W resolution held **one value for four and a half
minutes with nothing wrong**. A false-positive-free unchanged threshold would
have to be over 263 s — and 263 s is **3.5× too slow** to protect a consumer
that trips at 75 s. **There is no admissible threshold.** Without the
conjunction the card has no implementable verdict.
⚠️ **That is the METER's register, not `0x0518`, and it is a surrogate.** No
30-day capture of the inverter's own power exists — it is not in the recorder
export. The only thing it is used for is the sentence above; it does not set the
bound. `test_freshness.py` part 8 recomputes all three figures from the
committed files on every run, so a quoted number that drifts from its evidence
goes red rather than being re-quoted.
⚠️ **Every one of those long runs is under an unmoving load** — the worst is
263 s of a steady −899 W export. That is exactly what the command-travel term
keys on: none of them arms the gate.
---
## 3. The bounds, per signal, with their derivations
⚠️ **One constant for both signals would be useless on SoC or trigger-happy on
power.** `freshness.BOUNDS` carries them as data with a `wired` column, and part
7 walks the AST of every shipped module to check that column tells the truth.
(Its first draft grepped for the string and answered wrong — both `inverter.py`
and `main.py` *name* `BOUNDS["soc"]` in a comment explaining why it is unwired.)
### `batt` — `0x0518`, S16, 1 W, published every 10 s — **WIRED**
**Floor 17.0 s**, the fastest a healthy register can be *obliged* to answer:
| | | source, read this run unless dated |
|---|---|---|
| 6.0 s | plant settle | `goodwe/NOTES.md` §5.9, **measured 2026-08-22**: 26 W at +1.43 s, 469 W (94 %) at +3.34 s, settled 507 W at +6.05 s |
| 10.0 s | publisher | `firmware/goodwe-master.yaml:181`, `update_interval: 10s` |
| 1.0 s | this add-on's read period | `main.run_control()`'s `await asyncio.sleep(1)` |
**Ceiling 75.0 s**, the tighter of the two consumers: `circulation_hold_s` **75**
(config.yaml:512) and `unit_sign_disagree_s` **85** (config.yaml:451). A gate
slower than its consumer hands over the frozen number and explains afterwards.
**Chosen 35.0 s.** Band `[2 × 17.0, 0.5 × 75.0] = [34.0, 37.5]`. ⚠️ **The band
is derived; 35 is a CHOICE inside it** — the band holds four integers, and my
first draft of that check claimed it held one and went red on its first run. It
is deliberately not 30 (`validate.FIRMWARE_INGEST_VETO_S`, a borrowed constant)
and not 60 (round). Part 6 computes **both ends out of `config.yaml`**: drop
`circulation_hold_s` to 60 and this goes red.
**`MOVE_W` = 250 W** is the **largest** admissible value, not a sensitivity
preference: the gate must arm everywhere a stale reading could mislead a
consumer, and the earliest either consumer speaks is `unit_sign_disagree_w`
250 W (config.yaml:450) — below that `watchdog.disagrees()` is `False` whatever
the measurement says. Taking the ceiling minimises spurious arming. Pinned to
`config.yaml`, not to a literal.
### `soc` — `0x050E`, U16, 1 %, published every 10 s — **STATED, NOT WIRED**
SoC **cannot use this conjunction**: a rested pack holds one percent for hours
under a 0 W command, which is exactly the card's own example. Its obliged
movement is **energy**: one percent of pack A is ≈ **153.6 Wh** (15.36 kWh
nominal, `goodwe/NOTES.md` §1.1b — ⚠️ NOMINAL and FROM DATASHEET, not a measured
usable capacity), so one percent takes ≥ 110 s at 5000 W and ≈ 553 s at 1000 W.
The bound is *"SoC unchanged while ∫|battery power| dt exceeded K × 153.6 Wh"*,
and **K cannot be chosen from anything written down today**. Shipping it with a
guessed K would be a gate that fires on a rested pack — the hazard, not the fix.
`Freshness` takes its obligation term from the caller precisely so an energy
integral can be handed in where command travel is handed in here.
---
## 4. ⚠️ What changes on the install that is running in a house today
| | before | after |
|---|---|---|
| healthy register, N=1 | value reaches consumers | **identical**, asserted: 60 reads of a dithering register under a reversing command all arrive unchanged, gate never arms, status page says nothing new |
| healthy register, N≥2 | value reaches consumers | identical |
| **frozen register, N=1** | reads as a healthy measurement for ever | **site stops** — `inputs_ok` False → `stale_input_s` hold → 0 W, and keeps commanding |
| **frozen register, N≥2** | per-unit sign check false-trips at 85 s and parks a healthy inverter; circulation sensor reports a loop that is not happening | both consumers get `None`; both windows **clear** |
**The N=1 row is a real behaviour change on the shipped roster and is named
rather than hidden.** It is also the point of the card: HW-01 was a
one-inverter site, and the pack drained to its floor over 6 h 25 m with every
detector reading healthy. The direction it fails in is the safe one — a false
verdict commands 0 W, behind the existing 15 s `stale_input_s` hold, and clears
itself on the register's next move (one watt is enough; `0x0518` dithers ±1 W
while holding, `NOTES.md` §5.11, 2026-08-22).
**The counterfactual is run, not argued.** Part 3 drives real `cycle()` calls at
N=2 with A's register frozen at −1200 W and B's live at +900 W, the command
travel produced by the shipped `compute()` and allocator rather than written
into the fixture:
```
standing on A: [0, 445, 890, 1335, 1335, …] ← the shipped control law
gated : sign trip tick=None circ=[1800, 1800, 1800, None, None, …]
counterfact : sign trip tick=7 circ=[1800, 1800, 1800, 1800, 1800, …]
```
⚠️ **BOTH UNITS IN EVERY TWO-INVERTER FIXTURE HERE ARE INVERTER A**, with
different numbers passed in. Inverter B does not physically exist (≈ 2026-09-12;
`goodwe/NOTES.md` §1.1b, and `sim/plantsim.py:216`'s `INVERTER_B` is A copied
with `measured=False`). The 3000 W / 15 % second unit is **illustrative and
deliberately not B's datasheet 4600 W / 10 %**: the real pair differs by 400 W,
narrower than a slew step, and two near-equal numbers cannot show a check that
read the wrong unit's.
---
## 5. What TEL-06 and TEL-08 still owe
**[[TEL-06]] — layer 1, Blocked. Nothing in this card can reach it.** The
ESPHome veto tests the *value* of `sensor.goodwe_p1_sample_age`; this policy
lives in the add-on, on the far side of a flash. What TEL-06 owes is the same
substitution **inside the firmware**: a `millis()` stamp taken when the entity
is *written*, and a veto on `millis() − last_write_ms` rather than on the
published number. The policy question — arrival, not value — is answered here.
The remaining work is a firmware stamp and a supervised flash. **No firmware was
touched.**
**[[TEL-08]] — stale SoC.** It owes exactly three things and **no mechanism**:
1. a **measured** usable capacity for pack A (or a defensible bound) — the
153.6 Wh/percent above is nominal and from a datasheet;
2. the **energy integral** since the reading last changed;
3. the constant **K**.
It should consume `freshness.Freshness` with `BOUNDS["soc"]` filled in, not
restate the policy. The row is already in the table with `wired=False` and its
reason, and part 7 goes red the day that column stops being true.
---
## 6. ⚠️ Honest survivors, with their size
Graded in part 9, so they are residuals and not surprises.
| kind | what survives | size |
|---|---|---|
| **false positive** | an inverter physically unable to follow — saturating, thermally derating, a BMS refusing a charge — is called stale while telling the truth | one spurious **site stop**, behind `stale_input_s`, self-clearing the moment the register moves. It cannot drive the battery anywhere |
| **miss** | a register frozen under a command that never travels 250 W is never detected, for 400 s or for ever | scoped: such a unit is one no per-unit sign check can speak about (`unit_sign_disagree_w` = 250 W) and one contributing at most 250 W to a loop whose floor is 700 W. Asserted, not argued |
| **miss — the largest** | a dead publisher whose last values **alternate**, or a cache replaying two old samples, never starts the unchanged clock | **this gate detects a value that STOPPED; it cannot detect a value that is WRONG.** Nothing in this add-on can. TEL-06's firmware arrival stamp is the only layer that could |
Recorded because it looks like a survivor and is not: a publisher failure that
freezes **both** registers is caught on both units independently, on each unit's
own command travel and each unit's own number.
---
## 7. Mutation ledger — 2026-09-06
MUTATION.md's rule honoured throughout: purge first (count printed, 0 remaining
every run), tree mounted `:ro`, `python3 -B`, fingerprint before **and after**
the revert.
Clean tree: `app/freshness.py` `sha256:f40fa3e8819014ce`, 20,065 B ·
`app/inverter.py` `7e760c6e8cdfa5b7`, 30,778 B · `app/main.py`
`7d7ff4ba0697ea64`, 238,535 B. Each mutant graded by `test_freshness.py` +
`test_inverter.py`.
Clean tree: `app/freshness.py` `sha256:f40fa3e8819014ce`, 20,065 B ·
`app/inverter.py` `7e760c6e8cdfa5b7`, 30,778 B · `app/main.py`
`7d7ff4ba0697ea64`, 238,535 B. Each mutant graded by `test_freshness.py` +
`test_inverter.py`.
| # | mutation | file | Δ B | result |
|---|---|---|---|---|
| M0 | none (control) | freshness | +0 | **green** — the harness is not red on everything |
| M1 | `reading()` returns `self.last` unconditionally | freshness | −24 | red, 3 named checks |
| M2 | drop the command-travel term | freshness | −59 | red |
| M3 | drop the magnitude term | freshness | −54 | **SURVIVED**, then red — the finding |
| M4 | latch never holds | freshness | +13 | red, 3 named checks |
| M5 | bound 90 s (slower than both consumers) | freshness | **+0** | red |
| M6 | bound 10 s (under the physical floor) | freshness | **+0** | red |
| M7 | `MOVE_W` 700 W | freshness | **+0** | red, on part 6's `config.yaml` pin |
| M8 | the wiring in `_read_unit` removed | main | −5 | red, 3 named checks |
| M9 | reset the unchanged clock on every arrival | freshness | −43 | red |
| M10 | `standing_w()` drops `last_write` | inverter | −73 | red, and takes `test_inverter.py` with it |
| M11 | `standing_at_change` refreshed on every arrival | freshness | +121 | ⚠️ **DISCOUNTED — the mutant does not parse** |
| M11b | the same defect, written parseably | freshness | +121 | red, 3 named checks |
| M12 | `BOUNDS["soc"].wired = True` — the table lies | freshness | −1 | red |
| M13 | the wrong signal submitted (`inv.soc` for `inv.batt`) | main | +4 | red, and `test_inverter.py` too |
| M14 | one `Freshness` shared by every unit | inverter | +29 | red |
| M15 | latch cleared by a failed read | freshness | +31 | **SURVIVED**, then red |
⚠️ **M5, M6 and M7 are `+0 B`, MUTATION.md's risky delta.** All three are
**reds**, the direction stale bytecode cannot fabricate, every run purged with
the count printed, and the tree mounted `:ro` so it physically cannot hold a
cache.
⚠️ **M11 IS NOT A RESULT AND IS NOT COUNTED.** Its edit dedents a statement into
an `if` body, so the module raises `IndentationError` at line 312 and both files
go red without a check being evaluated — verified with `ast.parse`, not assumed.
**M11b** is the same defect written so it parses.
**Six were re-run against the exact committed tree** (`test_freshness.py`
`sha256:a62f3e1f1ac856cf`, 44,913 B), because the two closures, the `key=str`
fix and the wall-clock hardening all landed after the sweep: M0 green, and M2,
M3, M8, M11b and M15 all red on named checks. The other ten are red on checks in
parts none of those edits touch.
### ⚠️ The one survivor, and it is the finding
**M3, dropping the magnitude term, survived the first draft.** Every check was
green, because in all of them a command that had *travelled* 250 W had also
*ended up* above 250 W — **the fixture never separated the two terms, so one of
them was decoration as far as the suite could tell.** The case that separates
them is a site brought to **rest**: writes are serialised, so a register can
reach its new value on the same read at which the *old* command is still
standing; `standing_at_change` then captures 1300 W, the site is commanded to
0 W, and the travel term alone reads 1300 W of unfollowed command while the
register sits truthfully at +5 W for a minute. Part 4 now grades exactly that,
and M3 is red.
Recorded in `MUTATION.md` as **habit 17** — twelfth instance of "a check whose
DATA cannot reach the failure it names", and the general form: *ask of every
conjunction whether there is a fixture in which each term is the only false
one.* If not, the suite cannot tell an `AND` from an `OR`.
### ⚠️ And one process defect worth more than the ledger
One mutation's container run overran a two-minute command budget and the wrapper
was killed **between the edit and the revert**, leaving `app/freshness.py`
mutated on disk. Nothing announced it; the next mutation's harness cheerfully
fingerprinted and graded the mutant plus a second mutation. It was caught by
comparing the tree fingerprint against the recorded clean one, restored, and
verified back to byte and `sha256` before anything else ran. That is the same
defect stale bytecode causes, from a different cause, and it is now
`MUTATION.md` **habit 18**: *fingerprint after the revert, not only before the
run.*
---
## 8. Suite
`22/22 PASS` in `goodwe-addon-ci` (the CI image, deps from apk), tree mounted
`:ro`, `PYTHONDONTWRITEBYTECODE=1`, `python3 -B`, bytecode purged before the run
(count printed) and `0` caches present after it. `test_freshness.py` is the 22nd
file and takes 19 s, 3.5 s of which is replaying the 30-day capture.
`test_inverter.py`'s config-reader audit caught the new `option_default()` on its
first run and named it — the table is signature-driven, exactly as its comment
promises — so it is registered there rather than left outside the audit.
```
22 test file(s)
PASS test_alloc.py (25s) PASS test_maintenance.py (1s)
PASS test_arbiter.py (0s) PASS test_mqtt.py (2s)
PASS test_arbitrage.py (5s)PASS test_p1.py (7s)
PASS test_bytecode.py (1s) PASS test_peakguard.py (61s)
PASS test_ci_gate.py (1s) PASS test_price.py (1s)
PASS test_clamp.py (1s) PASS test_price_forecast.py (0s)
PASS test_control.py (0s) PASS test_static_tariff.py (4s)
PASS test_freeze.py (34s) PASS test_tariff.py (6s)
PASS test_freshness.py(16s)PASS test_translations.py (0s)
PASS test_inverter.py (7s) PASS test_validate.py (6s)
PASS test_loop.py (93s) PASS test_watchdog.py (53s)
bytecode entries found and purged: 0 ; remaining: 0 ; cache after run: 0
```
### ⚠️ Part 3 touches the wall clock, so it MEASURES its own margin (CI-06)
The freshness verdict runs on an injected monotonic clock and is deterministic.
The sign watchdog inside `cycle()` cannot be injected. CI-06 is the record of
what that costs — `test_peakguard.py` answered two checks with the wall clock,
flaked at ~20 %, and cost a wrong merge diagnosis, because *a flaky check does
not waste a re-run, it manufactures evidence for whichever hypothesis the reader
already holds.* So the margin is asserted with its number:
```
gate closed after 0.61 s of WALL clock, against the 2 s watchdog window (3.3x margin)
```
If a loaded machine ever narrows that, the check fails **naming the number** —
the difference between "the box was slow" and "the code is broken".
---
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
The architect pass made SAFETY-37, TEL-06 and TEL-08 one systemic problem -
"a stale reading holds its last value and reads healthy" - and this builds the
single policy. Both consumers the card names are gated by one change, and
neither consumer needed a line: `SignWatchdog.evaluate` already CLEARS its
window on a None measurement and `circulating_w` already returns None for a
roster with a missing term. Neither could ever receive one. The gate is the
piece that was missing BETWEEN the shipped read and the shipped checks.
⚠️ ONE CRITERION IN THE CARD COULD NOT BE IMPLEMENTED AS WRITTEN, AND SAYING SO
IS THE MAIN CONTENT. The card says "only arrival separates them". At this layer
arrival cannot: `read_inputs()` polls Home Assistant's state cache about once a
second and HA answers 200 with a number whether or not the publisher behind the
entity is alive, so both members of the pair arrive identically for ever.
test_freshness.py part 1a BUILDS the arrival-age gate the card asks for and
grades it failing - the age is 0.0 s at every instant of both runs, a threshold
that can never fire, which is TEL-06's own defect one layer up. The half of the
rule that survives is honoured absolutely: nothing here reads a Home Assistant
timestamp, and the check asserts the module contains no `last_updated`.
So the criterion became: a reading is stale when it has not moved through a
change it was OBLIGED to follow.
STALE <=> unchanged >= 35 s
AND |standing| >= 250 W
AND |standing - standing when the reading last changed| >= 250 W
Latched until a different value arrives. `Inverter.batt` becomes None - never a
held number - which is a missing input, which §11.1 rule 1 answers with 0 W.
WHY A CONJUNCTION AND NOT A THRESHOLD, MEASURED 2026-09-06. Replaying the
committed 30-day capture of this house's own 1 W power entity (485,841 samples
out of HA's recorder, where a row exists only on a CHANGE, so the gap between
rows IS the unchanged duration every consumer saw): the longest legitimate
unchanged run is 263.2 s, held at -899.0 W. A false-positive-free threshold
would have to exceed that, and 263 s is 3.5x too slow for a consumer that trips
at 75 s. There is no admissible threshold. Every one of those long runs is
under an UNMOVING load, which is what the travel term keys on.
THE BOUND, derived at both ends and graded against the shipped config.yaml.
Floor 17.0 s = 6.0 plant settle (NOTES §5.9, measured 2026-08-22) + 10.0
ESPHome publish (firmware/goodwe-master.yaml:181) + 1.0 read period
(run_control's asyncio.sleep(1)). Ceiling 75.0 s = min(circulation_hold_s 75,
unit_sign_disagree_s 85). Band [34.0, 37.5]; 35 is a CHOICE inside a DERIVED
band and the check says so - the first draft claimed the band held one integer
and went red on its own first run. MOVE_W is the LARGEST admissible value,
unit_sign_disagree_w, because below it the consumer cannot speak at all.
SoC is STATED, NOT WIRED, and BOUNDS carries it with wired=False and the reason.
Its obligation term is energy through the pack, not command travel - a rested
pack holds a percent for hours under a 0 W command - and K needs a usable
capacity nobody has measured. That is TEL-08's; the record says so.
⚠️ BEHAVIOUR CHANGES AT THE SHIPPED ROSTER OF ONE, and it is named rather than
hidden: a FROZEN register now stops the site where it used to read as a healthy
measurement for ever. That is HW-01, which was a one-inverter site. A healthy
install is unchanged, asserted over sixty reads of a dithering register under a
reversing command. A false verdict fails toward 0 W behind the existing
stale_input_s hold and clears itself on the register's next move.
⚠️ BOTH UNITS IN EVERY TWO-INVERTER FIXTURE ARE INVERTER A with different
numbers passed in; inverter B does not physically exist (~2026-09-12).
MUTATION: 16 mutations against a purged, :ro, python3 -B tree, fingerprinted
before and after every revert. One survivor, and it is the finding - dropping
the magnitude term left every check green because no fixture separated "the
command travelled 250 W" from "the command ended up above 250 W". Closed, and
recorded as MUTATION.md habit 17. A second process defect is habit 18: a
mutation run that timed out between the edit and the revert left the module
mutated on disk and nothing announced it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
Two blocking findings from review, both in prose, both corrected at every
site.
1. "trips on tick 7" was not what the fixture prints. It prints 11, five
runs out of five, and the developer's own report says 11. ⚠️ But the
deeper problem is that the number should never have been quoted: the
tick is counted on the WALL CLOCK inside cycle() (hold_s 2 s, ticks
0.20 s apart), so it is load-dependent - 7 is what a box slow enough
to stretch each tick to ~0.29 s would print - and nothing pins it.
The DECISIONS transcript now says 11 because it is a transcript of a
run; the CHANGELOG stops quoting a tick at all and states the
checkable thing instead: it trips without the gate and never with it,
which part 3 grades.
2. ⚠️ SURVIVOR 2's SIZE CLAIM WAS FALSE FOR ONE OF THE TWO CONSUMERS THIS
CARD EXISTS TO PROTECT. It read "one contributing at most 250 W to a
loop whose reporting floor is 700 W" at three sites. That bounds the
COMMAND. Once the register is frozen its REPORTED value is unrelated
to the command - which is exactly HW-01's shape: commanded ~0 W,
register stuck on the last large number.
Measured in review: a unit frozen at -1200 W beside a healthy +900 W
sibling yields circulating_w = 1800 W against the 700 W floor, 2.6x
OVER, with no gate armed.
So the scoping is TRUE for the per-unit sign check and FALSE for
circulation. Corrected to say exactly that, and the circulation
consumer's exposure below MOVE_W is now recorded as a KNOWN GAP rather
than a covered one.
It survived because part 9's S2 check asserts only `not stale` and
part 4 pairs the frozen unit with a RESTING sibling
(circulating_w([A_FROZEN_W, 0.0]) == 0.0) - neither grades the size
sentence.
No behaviour change. Full suite: 22 passed, 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
admin
merged commit c8dcb38a95 into release/1.02026-09-06 13:06:27 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
SAFETY-37 builds the single freshness policy the architect pass asked for, wires
it to the two consumers the card names, and says what TEL-06 and TEL-08
still owe. One criterion in the card could not be implemented as written; §1
says which one and what replaced it, because that is the most useful thing in
this PR.
Branch off
release/1.0@a4290d3. No new configuration key and no schemachange, so no version bump.
1. ⚠️ The criterion that had to be rewritten
The card says:
At this layer, arrival does not separate them, and cannot.
read_inputs()polls Home Assistant's state cache about once a second (
run_control()'sawait asyncio.sleep(1)bounds the loop body), and while the entity exists HomeAssistant answers
200with a number whether or not the publisher behind itis alive. Both members of the pair arrive, identically, for ever.
That is not an argument, it is graded.
test_freshness.pypart 1a builds thearrival-age gate the card asks for and runs it against both members of the pair:
The other half of the card's rule is honoured absolutely.
last_updateddoes not move on an unchanged rewrite; neither does
last_changed; and thisproject already measured on the ENV-01 rig (70 s of a frozen meter) and against
the live house that
last_reporteddoes not advance on a repeat on eitherserialiser —
p1.HomeWizardLocalSource's docstring records both.freshness.pyreads no Home Assistant timestamp at all, and part 1a asserts that its body
contains no
last_updated. Every clock in the verdict istime.monotonic()inside this process, stamped at the instant an arrival carried a different
number.
So the criterion became: a reading is stale when it has not moved through a
change it was OBLIGED to follow. The obligation, not the age, separates the
pair.
2. The policy
Inverter.battbecomesNone— never a held number, never a zero — whichis a MISSING INPUT, which §11.1 rule 1 already answers with 0 W and keeps
commanding.
Both consumers are gated by that one change, and neither consumer needed a
line. They already do the right thing with a
Noneand neither could everreceive one:
SignWatchdog.evaluateclears its window on aNonemeasurement, and
circulating_wreturnsNonefor a roster with a missingterm. The gate is the piece that was missing between the shipped read and the
shipped checks. Both facts are asserted on the shipped functions as
preconditions, not assumed.
Why it is a conjunction and not a threshold — measured, this run
Replaying the committed 30-day capture of this house's own 1 W-resolution power
entity (
scenarios/p1-net-30d-2026-08/, 485,841 samples out of Home Assistant'srecorder, where a row exists only when the state string changed, so the gap
between rows is the unchanged duration every consumer saw):
A real power figure at 1 W resolution held one value for four and a half
minutes with nothing wrong. A false-positive-free unchanged threshold would
have to be over 263 s — and 263 s is 3.5× too slow to protect a consumer
that trips at 75 s. There is no admissible threshold. Without the
conjunction the card has no implementable verdict.
⚠️ That is the METER's register, not
0x0518, and it is a surrogate. No30-day capture of the inverter's own power exists — it is not in the recorder
export. The only thing it is used for is the sentence above; it does not set the
bound.
test_freshness.pypart 8 recomputes all three figures from thecommitted files on every run, so a quoted number that drifts from its evidence
goes red rather than being re-quoted.
⚠️ Every one of those long runs is under an unmoving load — the worst is
263 s of a steady −899 W export. That is exactly what the command-travel term
keys on: none of them arms the gate.
3. The bounds, per signal, with their derivations
⚠️ One constant for both signals would be useless on SoC or trigger-happy on
power.
freshness.BOUNDScarries them as data with awiredcolumn, and part7 walks the AST of every shipped module to check that column tells the truth.
(Its first draft grepped for the string and answered wrong — both
inverter.pyand
main.pynameBOUNDS["soc"]in a comment explaining why it is unwired.)batt—0x0518, S16, 1 W, published every 10 s — WIREDFloor 17.0 s, the fastest a healthy register can be obliged to answer:
goodwe/NOTES.md§5.9, measured 2026-08-22: 26 W at +1.43 s, 469 W (94 %) at +3.34 s, settled 507 W at +6.05 sfirmware/goodwe-master.yaml:181,update_interval: 10smain.run_control()'sawait asyncio.sleep(1)Ceiling 75.0 s, the tighter of the two consumers:
circulation_hold_s75(config.yaml:512) and
unit_sign_disagree_s85 (config.yaml:451). A gateslower than its consumer hands over the frozen number and explains afterwards.
Chosen 35.0 s. Band
[2 × 17.0, 0.5 × 75.0] = [34.0, 37.5]. ⚠️ The bandis derived; 35 is a CHOICE inside it — the band holds four integers, and my
first draft of that check claimed it held one and went red on its first run. It
is deliberately not 30 (
validate.FIRMWARE_INGEST_VETO_S, a borrowed constant)and not 60 (round). Part 6 computes both ends out of
config.yaml: dropcirculation_hold_sto 60 and this goes red.MOVE_W= 250 W is the largest admissible value, not a sensitivitypreference: the gate must arm everywhere a stale reading could mislead a
consumer, and the earliest either consumer speaks is
unit_sign_disagree_w250 W (config.yaml:450) — below that
watchdog.disagrees()isFalsewhateverthe measurement says. Taking the ceiling minimises spurious arming. Pinned to
config.yaml, not to a literal.soc—0x050E, U16, 1 %, published every 10 s — STATED, NOT WIREDSoC cannot use this conjunction: a rested pack holds one percent for hours
under a 0 W command, which is exactly the card's own example. Its obliged
movement is energy: one percent of pack A is ≈ 153.6 Wh (15.36 kWh
nominal,
goodwe/NOTES.md§1.1b — ⚠️ NOMINAL and FROM DATASHEET, not a measuredusable capacity), so one percent takes ≥ 110 s at 5000 W and ≈ 553 s at 1000 W.
The bound is "SoC unchanged while ∫|battery power| dt exceeded K × 153.6 Wh",
and K cannot be chosen from anything written down today. Shipping it with a
guessed K would be a gate that fires on a rested pack — the hazard, not the fix.
Freshnesstakes its obligation term from the caller precisely so an energyintegral can be handed in where command travel is handed in here.
4. ⚠️ What changes on the install that is running in a house today
inputs_okFalse →stale_input_shold → 0 W, and keeps commandingNone; both windows clearThe N=1 row is a real behaviour change on the shipped roster and is named
rather than hidden. It is also the point of the card: HW-01 was a
one-inverter site, and the pack drained to its floor over 6 h 25 m with every
detector reading healthy. The direction it fails in is the safe one — a false
verdict commands 0 W, behind the existing 15 s
stale_input_shold, and clearsitself on the register's next move (one watt is enough;
0x0518dithers ±1 Wwhile holding,
NOTES.md§5.11, 2026-08-22).The counterfactual is run, not argued. Part 3 drives real
cycle()calls atN=2 with A's register frozen at −1200 W and B's live at +900 W, the command
travel produced by the shipped
compute()and allocator rather than writteninto the fixture:
⚠️ BOTH UNITS IN EVERY TWO-INVERTER FIXTURE HERE ARE INVERTER A, with
different numbers passed in. Inverter B does not physically exist (≈ 2026-09-12;
goodwe/NOTES.md§1.1b, andsim/plantsim.py:216'sINVERTER_Bis A copiedwith
measured=False). The 3000 W / 15 % second unit is illustrative anddeliberately not B's datasheet 4600 W / 10 %: the real pair differs by 400 W,
narrower than a slew step, and two near-equal numbers cannot show a check that
read the wrong unit's.
5. What TEL-06 and TEL-08 still owe
TEL-06 — layer 1, Blocked. Nothing in this card can reach it. The
ESPHome veto tests the value of
sensor.goodwe_p1_sample_age; this policylives in the add-on, on the far side of a flash. What TEL-06 owes is the same
substitution inside the firmware: a
millis()stamp taken when the entityis written, and a veto on
millis() − last_write_msrather than on thepublished number. The policy question — arrival, not value — is answered here.
The remaining work is a firmware stamp and a supervised flash. No firmware was
touched.
TEL-08 — stale SoC. It owes exactly three things and no mechanism:
153.6 Wh/percent above is nominal and from a datasheet;
It should consume
freshness.FreshnesswithBOUNDS["soc"]filled in, notrestate the policy. The row is already in the table with
wired=Falseand itsreason, and part 7 goes red the day that column stops being true.
6. ⚠️ Honest survivors, with their size
Graded in part 9, so they are residuals and not surprises.
stale_input_s, self-clearing the moment the register moves. It cannot drive the battery anywhereunit_sign_disagree_w= 250 W) and one contributing at most 250 W to a loop whose floor is 700 W. Asserted, not arguedRecorded because it looks like a survivor and is not: a publisher failure that
freezes both registers is caught on both units independently, on each unit's
own command travel and each unit's own number.
7. Mutation ledger — 2026-09-06
MUTATION.md's rule honoured throughout: purge first (count printed, 0 remaining
every run), tree mounted
:ro,python3 -B, fingerprint before and afterthe revert.
Clean tree:
app/freshness.pysha256:f40fa3e8819014ce, 20,065 B ·app/inverter.py7e760c6e8cdfa5b7, 30,778 B ·app/main.py7d7ff4ba0697ea64, 238,535 B. Each mutant graded bytest_freshness.py+test_inverter.py.Clean tree:
app/freshness.pysha256:f40fa3e8819014ce, 20,065 B ·app/inverter.py7e760c6e8cdfa5b7, 30,778 B ·app/main.py7d7ff4ba0697ea64, 238,535 B. Each mutant graded bytest_freshness.py+test_inverter.py.reading()returnsself.lastunconditionallyMOVE_W700 Wconfig.yamlpin_read_unitremovedstanding_w()dropslast_writetest_inverter.pywith itstanding_at_changerefreshed on every arrivalBOUNDS["soc"].wired = True— the table liesinv.socforinv.batt)test_inverter.pytooFreshnessshared by every unit⚠️ M5, M6 and M7 are
+0 B, MUTATION.md's risky delta. All three arereds, the direction stale bytecode cannot fabricate, every run purged with
the count printed, and the tree mounted
:roso it physically cannot hold acache.
⚠️ M11 IS NOT A RESULT AND IS NOT COUNTED. Its edit dedents a statement into
an
ifbody, so the module raisesIndentationErrorat line 312 and both filesgo red without a check being evaluated — verified with
ast.parse, not assumed.M11b is the same defect written so it parses.
Six were re-run against the exact committed tree (
test_freshness.pysha256:a62f3e1f1ac856cf, 44,913 B), because the two closures, thekey=strfix and the wall-clock hardening all landed after the sweep: M0 green, and M2,
M3, M8, M11b and M15 all red on named checks. The other ten are red on checks in
parts none of those edits touch.
⚠️ The one survivor, and it is the finding
M3, dropping the magnitude term, survived the first draft. Every check was
green, because in all of them a command that had travelled 250 W had also
ended up above 250 W — the fixture never separated the two terms, so one of
them was decoration as far as the suite could tell. The case that separates
them is a site brought to rest: writes are serialised, so a register can
reach its new value on the same read at which the old command is still
standing;
standing_at_changethen captures 1300 W, the site is commanded to0 W, and the travel term alone reads 1300 W of unfollowed command while the
register sits truthfully at +5 W for a minute. Part 4 now grades exactly that,
and M3 is red.
Recorded in
MUTATION.mdas habit 17 — twelfth instance of "a check whoseDATA cannot reach the failure it names", and the general form: ask of every
conjunction whether there is a fixture in which each term is the only false
one. If not, the suite cannot tell an
ANDfrom anOR.⚠️ And one process defect worth more than the ledger
One mutation's container run overran a two-minute command budget and the wrapper
was killed between the edit and the revert, leaving
app/freshness.pymutated on disk. Nothing announced it; the next mutation's harness cheerfully
fingerprinted and graded the mutant plus a second mutation. It was caught by
comparing the tree fingerprint against the recorded clean one, restored, and
verified back to byte and
sha256before anything else ran. That is the samedefect stale bytecode causes, from a different cause, and it is now
MUTATION.mdhabit 18: fingerprint after the revert, not only before therun.
8. Suite
22/22 PASSingoodwe-addon-ci(the CI image, deps from apk), tree mounted:ro,PYTHONDONTWRITEBYTECODE=1,python3 -B, bytecode purged before the run(count printed) and
0caches present after it.test_freshness.pyis the 22ndfile and takes 19 s, 3.5 s of which is replaying the 30-day capture.
test_inverter.py's config-reader audit caught the newoption_default()on itsfirst run and named it — the table is signature-driven, exactly as its comment
promises — so it is registered there rather than left outside the audit.
⚠️ Part 3 touches the wall clock, so it MEASURES its own margin (CI-06)
The freshness verdict runs on an injected monotonic clock and is deterministic.
The sign watchdog inside
cycle()cannot be injected. CI-06 is the record ofwhat that costs —
test_peakguard.pyanswered two checks with the wall clock,flaked at ~20 %, and cost a wrong merge diagnosis, because a flaky check does
not waste a re-run, it manufactures evidence for whichever hypothesis the reader
already holds. So the margin is asserted with its number:
If a loaded machine ever narrows that, the check fails naming the number —
the difference between "the box was slow" and "the code is broken".
🤖 Generated with Claude Code
https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU