SAFETY-57: the stale-reading verdict gets an exit, because its own consequence is what stopped it being tested #104

Merged
admin merged 2 commits from SAFETY-57 into release/1.0 2026-09-21 15:49:36 +02:00
Owner

The defect

  register stuck at -3 W  ->  gate trips  ->  inv.batt = None  ->  commands 0 W
         ^                                                              |
         +------------------ battery does nothing <---------------------+

SAFETY-37's freshness gate withholds Inverter.batt when the register stops
following its own command, and a missing input is command 0 W and keep
commanding
(§11.1 rule 1). The gate's only exit was "an arrival carrying a
DIFFERENT number" — and at 0 W nothing is asking the register to be anywhere.

The log line promised the opposite, in the same sentence that announced the 0 W.

Reproduced from the wire, not modelled

scenarios/safety57-2026-09-21/ — 3840 verbatim RS-485 frames, read from
the passive sniffer at 192.168.2.167, decoded by the committed
tools/decode_bus_capture.py (0 unparsed, 0 bad CRC, 0 unknown shapes; the
EMS-mode→sign convention is graded off the capture, not trusted). The box
was running 0.10.0, so the 0 W in the trace is the add-on's own claim — the
capture is closed-loop for the whole parked interval.

capture time on the wire
10:51:24.7 0x0518 = −3 W. Pack at 11 %, the BMS refuses the discharge, the inverter idles. The register is healthy and truthful.
10:51:29.7 the command reaches +1060 W — 800 W of travel
10:52:00 the conjunction arms. Inverter.batt becomes None
10:52:21.7 the site commands 0 W, on the wire
…850.0 s 0x0518 does not change value
…790.0 s the setpoint does not change value. The house exports 2.4 kW
~11:04 a person restarts the add-on. That is what ends it
11:05:31.7 the restarted loop commands −400 W of charge, which the pack accepts at 11 %
11:05:34.7 0x0518 = −137 W

Red before, green after — test_freshness.py part 10b

stale at clear at parked
before (release_s = inf) 10:52:00 11:05:35 815 s — reading() is None on all 812 reads from the trip to the first command a human put on the wire, with the register at −3.0 W in every one of them
after 10:52:00 10:52:39 39 s, with 0x0518 still answering the same −3 W

That is the card's acceptance criterion verbatim: the register is held constant,
the command is gated, and it recovers with no external change to the
register
. The counterfactual is release_s = inf, and it is graded rather
than claimed — part 10b drives part 1's own fresh/stale pair through both bounds
and asserts bit-identical verdict streams, so it is the shipped-before module
and not a second implementation of it.

And it had been escaping by luck — part 8b

scenarios/loop-2026-09-02_2026-09-17/ replayed at 1 Hz through the shipped
conjunction: 93 trips in 16 days. The only thing that could clear any of
them was the BMS keep-alive pulse twitching the register:

minimum 0.6 s
median 143.4 s
maximum 21440.1 s (5 h 57 m)
at or over the live episode's 710 s 17 of 93

⚠️ That capture predates the gate reaching an install (0.10.0 was cut
2026-09-16), so its setpoint column is an ungated controller's. The figure
taken from it is therefore the only one independent of the command — how long
this register goes without changing value — and it under-states the wait,
because a site parked at 0 W gives the register even less reason to move.

Which option, and why

Option 2 already holds and is not the bug. _grade() has required
|standing| >= MOVE_W since SAFETY-37, with a named check on that term. ⚠️ The
card's warning is the right one: the trip was at +1060 W and the stuck state
is at 0 W.
An entry-side rule cannot reach a state entered legitimately and
then persisted. Nothing in the conjunction is weakened here.

Option 3 was not taken. It writes to a real actuator to test an assumption,
and it is not needed: the site's normal control resuming is the test.

Option 1, shaped as SAFETY-13 shaped its own — which latches get an
exit, not how long the block lasts:

the verdict is withdrawn when the obligation that produced it has been gone
for RELEASE_S, and the register is then re-graded from scratch.

RELEASE_S is BATT_OBLIGED_S = 17.0 s — the obligation window already
derived in the file
(6.0 s plant + 10.0 s publish + 1.0 s read), taken at the
floor of its band, the mirror of MOVE_W taking its own band's ceiling.
Which half is safe is the opposite of SAFETY-13's, and for the reason its
§4.2 table gives: being wrong long is the failure being fixed; being wrong
short costs one bounded re-grading window. Below 17 s the release pre-empts
the cheaper exit — the register answering by itself — and that is graded
behaviourally: at release_s = 17.0 a register that answers at +16 s clears
by movement with releases still 0; at 5.0 the timer has already fired.

The family is computed and printed: 5 / 16 / 17 / 18 / 35 / inf give six
distinct release instants, so nothing in the file can confuse two of them.

The whole parked interval is now stale_input_s + RELEASE_S = 32 s,
bounded by two shipped numbers and by nothing the battery does.

What it costs — and it is not small

Over 1201 reads against a register dead for good, at the shipped defaults
(part 10c):

parked the held number reaches a consumer releases
before 1166 s 35 reads (2.9 %) 0
after 571 s 630 reads (52.5 %) 17

⚠️ 52 % is paid every cycle. It is payable only because of one inequality,
and it is the one part 6 has graded against the shipped config.yaml since
SAFETY-37:

35 s (the re-grading window)  <  75 s (circulation_hold_s)  <  85 s (unit_sign_disagree_s)

Every unbroken run of exposure is at most one window, so neither consumer
can reach a verdict inside one, and the re-trip that ends each run hands them a
None, which clears their windows. No detection SAFETY-37 had is given
back
— a permanent None clears those same windows for ever too. What the
held number does reach for 35 s is site_batt_w, compute()'s anti-windup
input, which answers a large unexplained divergence by freezing the output;
the command itself is derived from the P1 meter, never from batt.

Mutation battery — 22 mutations, 21 red, 1 argued equivalent

Run through a mirror of checks.yml's own step (purged copy of the tree,
-v …:ro, one file at a time, PYTHONDONTWRITEBYTECODE=1). Per MUTATION.md
habit 18 the harness's scratch file is mktemped per invocation and the tree is
fingerprinted after the revert as well as before; every revert restored the
recorded sha256 and byte count.

mutation Δ verdict
M1 delete the _release() call from submit() −44 B RED
M2 release_s=RELEASE_S → inf +3 B RED
M3 RELEASE_S 17 → BATT_UNCHANGED_S (35) +2 B RED
M4 RELEASE_S 17 → PLANT_SETTLE_S (6) +0 B RED
M5 delete the elapsed-rest test (release at once) −90 B RED
M6 rested_since = None when obliged → drop −37 B RED
M7 swap _release() / _grade() in submit() +0 B EQUIVALENT — argued, §7.1
M8 drop _open_window() from _release() −48 B RED
M9 _grade() reads unchanged_s() not graded_s() +3 B RED
M10 _open_window(None, …) on release −6 B RED
M11 releases += 1 dropped −27 B RED
M12 the reason's unchanged for slot takes the window −20 B RED
M13 unchanged_s() measured from graded_from −2 B RED
M14 transpose the escape clause's two operands (main.py) +0 B RED
M15 drop the STALE WITHDRAWN branch (main.py) −33 B RED
M16 swap MODE_CHARGE / MODE_DISCHARGE (decoder) +0 B RED
M17 unknown EMS mode defaults instead of raising (decoder) −181 B RED
M18 escape clause prints unchanged_s not release_s (main.py) +2 B RED
M19 alter one frame in the committed capture — RED
M20 _release()'s move_w test → unchanged_s +5 B RED (was a survivor — see below)
M21 graded_from re-anchored to changed_mono +82 B RED (was a false survivor — see below)
M22 release even while the obligation stands −9 B RED

Named checks per mutation are in the run logs; each red names at least one
check, and no mutation produced a syntax error rather than a named failure.

Three findings worth a reviewer's time

1. A sibling survived at +5 B (habit 11). _release()'s obligation test
abs(standing_w) >= bound.move_w with move_w swapped for unchanged_s —
comparing watts against 35 — survived the whole file, because every
fixture rested at exactly 0 W
and 0 is below both 35 and 250. Closed by a
three-point fixture at 0 / 100 / 300 W.

2. ⚠️ The code shipped a habit-22 comment that was false, and the battery is
what said so.
freshness.py carried ⚠️ RELEASE BEFORE GRADE, AND THE ORDER IS LOAD-BEARING with a worked reason. M7 falsifies it: the two act on
mutually exclusive predicates, so at most one can act on any read and the
order is not observable — not a missing fixture, and no fixture can close
it. The comment now states the disjointness and part 10 pins that, at 0,
100, 249, 250, 251 and 1300 W.

3. ⚠️ The instrument lied once — MUTATION.md gains habit 23. M21's first
form was float(self.changed_mono or now_mono). It reported a survivor at
+21 B
; purge clean, fingerprint clean, delta non-zero, mutant really ran.
changed_mono is 0.0 in that fixture and 0.0 or x is x, so the mutant
was a no-op on the stimulus — habit 4, one level up, in a place none of the
three things a mutation report must state can see.

What this does NOT do

  • It does not tell a BMS refusal from a dead register. Both are re-graded on
    the same timer. Narrowing it needs the pack floor (SAFETY-32's number) and
    SoC, which this module deliberately does not take — named in §5.1 for whoever
    wants it, and not needed to give the state an exit.
  • It does not touch the diagnosis wording. "is not following its own
    command"
    is wrong in a different way when the pack is simply empty; that is
    SAFETY-56's, together with main.py's NOT READY row, and it is left
    alone here — with a named check asserting it is still there, so that
    rewrite has to be a deliberate act. Only the escape clause changed,
    because this card is what made the old one false.
  • It does not close survivor 5, which predates it. After a long rest the
    unchanged clock is already past 35 s, so the first read carrying a travelled
    command trips the gate before the 17 s in which a healthy register could have
    answered. The live capture contains one (10:48:14, cleared one second later).
    Cost is one stale_input_s hold, during which cycle() commands nothing
    differently. Fixing it re-derives the 35.0 s bound and is a different card.
  • Nothing was measured on two inverters. Inverter B does not exist.

Suite

30/30 green in the CI image, checks.yml's exact command, purged tree,
:ro, one file at a time, everything under /home/homelab/.
test_freshness.py goes 16 s → 20 s (part 8b's 16-day replay).

Notes for the reviewer

  • Base. The card said to branch off origin/master @ 6f5e8e3; this
    targets release/1.0 instead, because that is where branch protection and
    every merged card live. The two are content-identical, so the PR carries
    6f5e8e3 (PKG-15's sync, already on master) plus this commit, and the diff
    against release/1.0 is exactly this card's files.
  • The CHANGELOG row is filed under ## 0.10.0 and that section says not to.
    Its preamble makes its "0.10.0 is not running anywhere" argument explicitly
    conditional, and the condition has now failed — this card exists because
    of a defect measured on 0.10.0 on the reference install. It is there only
    because test_version_gate.py 11b requires the newest ## heading to be a
    version. Whoever cuts the next version owns moving it; the entry says so
    in its own first paragraph. No deploy is proposed here.
  • Nothing reached the inverter. No contact with 192.168.2.158 or
    192.168.2.221. The one live system touched is the passive sniffer, read
    only.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU

## The defect ``` register stuck at -3 W -> gate trips -> inv.batt = None -> commands 0 W ^ | +------------------ battery does nothing <---------------------+ ``` [[SAFETY-37]]'s freshness gate withholds `Inverter.batt` when the register stops following its own command, and a missing input is *command 0 W and keep commanding* (§11.1 rule 1). **The gate's only exit was "an arrival carrying a DIFFERENT number" — and at 0 W nothing is asking the register to be anywhere.** The log line promised the opposite, in the same sentence that announced the 0 W. ## Reproduced from the wire, not modelled `scenarios/safety57-2026-09-21/` — **3840 verbatim RS-485 frames**, read from the passive sniffer at 192.168.2.167, decoded by the committed `tools/decode_bus_capture.py` (0 unparsed, 0 bad CRC, 0 unknown shapes; the EMS-mode→sign convention is **graded off the capture**, not trusted). The box was running 0.10.0, so **the 0 W in the trace is the add-on's own claim** — the capture is closed-loop for the whole parked interval. | capture time | on the wire | |---|---| | 10:51:24.7 | `0x0518` = **−3 W**. Pack at **11 %**, the BMS refuses the discharge, the inverter idles. **The register is healthy and truthful.** | | 10:51:29.7 | the command reaches **+1060 W** — 800 W of travel | | 10:52:00 | the conjunction arms. `Inverter.batt` becomes `None` | | 10:52:21.7 | **the site commands 0 W**, on the wire | | …850.0 s | `0x0518` does not change value | | …790.0 s | the setpoint does not change value. The house exports 2.4 kW | | ~11:04 | **a person restarts the add-on.** That is what ends it | | 11:05:31.7 | the restarted loop commands **−400 W of charge**, which the pack **accepts** at 11 % | | 11:05:34.7 | `0x0518` = **−137 W** | ### Red before, green after — `test_freshness.py` part 10b | | stale at | clear at | parked | |---|---|---|---| | **before** (`release_s = inf`) | 10:52:00 | 11:05:35 | **815 s** — `reading()` is `None` on all **812** reads from the trip to the first command a human put on the wire, with the register at **−3.0 W in every one of them** | | **after** | 10:52:00 | **10:52:39** | **39 s**, with `0x0518` **still answering the same −3 W** | That is the card's acceptance criterion verbatim: the register is held constant, the command is gated, and it recovers with **no external change to the register**. The counterfactual is `release_s = inf`, and it is **graded** rather than claimed — part 10b drives part 1's own fresh/stale pair through both bounds and asserts bit-identical verdict streams, so it is the shipped-before module and not a second implementation of it. ### And it had been escaping by luck — part 8b `scenarios/loop-2026-09-02_2026-09-17/` replayed at 1 Hz through the shipped conjunction: **93 trips in 16 days.** The only thing that could clear any of them was the BMS keep-alive pulse twitching the register: | | | |---|---| | minimum | 0.6 s | | **median** | **143.4 s** | | maximum | **21440.1 s** (5 h 57 m) | | at or over the live episode's 710 s | **17 of 93** | ⚠️ That capture predates the gate reaching an install (0.10.0 was cut 2026-09-16), so its setpoint column is an *ungated* controller's. The figure taken from it is therefore the only one independent of the command — how long this register goes without changing value — and it **under**-states the wait, because a site parked at 0 W gives the register even less reason to move. ## Which option, and why **Option 2 already holds and is not the bug.** `_grade()` has required `|standing| >= MOVE_W` since SAFETY-37, with a named check on that term. ⚠️ The card's warning is the right one: **the trip was at +1060 W and the stuck state is at 0 W.** An entry-side rule cannot reach a state entered legitimately and then persisted. Nothing in the conjunction is weakened here. **Option 3 was not taken.** It writes to a real actuator to test an assumption, and it is not needed: the site's *normal* control resuming is the test. **Option 1, shaped as [[SAFETY-13]] shaped its own** — which latches get an exit, not how long the block lasts: > the verdict is withdrawn when the obligation that produced it has been gone > for `RELEASE_S`, and the register is then re-graded from scratch. `RELEASE_S` is **`BATT_OBLIGED_S` = 17.0 s** — the obligation window *already derived in the file* (6.0 s plant + 10.0 s publish + 1.0 s read), taken at the **floor** of its band, the mirror of `MOVE_W` taking its own band's ceiling. Which half is safe is the **opposite** of SAFETY-13's, and for the reason its §4.2 table gives: being wrong *long* is the failure being fixed; being wrong *short* costs one bounded re-grading window. Below 17 s the release pre-empts the **cheaper exit** — the register answering by itself — and that is graded **behaviourally**: at `release_s = 17.0` a register that answers at +16 s clears by movement with `releases` still 0; at `5.0` the timer has already fired. The family is computed and printed: `5 / 16 / 17 / 18 / 35 / inf` give six distinct release instants, so nothing in the file can confuse two of them. **The whole parked interval is now `stale_input_s` + `RELEASE_S` = 32 s**, bounded by two shipped numbers and by nothing the battery does. ## What it costs — and it is not small Over 1201 reads against a register dead for good, at the shipped defaults (part 10c): | | parked | the held number reaches a consumer | releases | |---|---|---|---| | before | 1166 s | **35 reads (2.9 %)** | 0 | | after | 571 s | **630 reads (52.5 %)** | 17 | ⚠️ **52 % is paid every cycle.** It is payable only because of one inequality, and it is the one part 6 has graded against the shipped `config.yaml` since SAFETY-37: ``` 35 s (the re-grading window) < 75 s (circulation_hold_s) < 85 s (unit_sign_disagree_s) ``` Every unbroken run of exposure is **at most one window**, so neither consumer can reach a verdict inside one, and the re-trip that ends each run hands them a `None`, which **clears** their windows. **No detection SAFETY-37 had is given back** — a permanent `None` clears those same windows for ever too. What the held number does reach for 35 s is `site_batt_w`, `compute()`'s anti-windup input, which answers a large unexplained divergence by **freezing the output**; the command itself is derived from the **P1 meter**, never from `batt`. ## Mutation battery — 22 mutations, 21 red, 1 argued equivalent Run through a mirror of `checks.yml`'s own step (purged copy of the tree, `-v …:ro`, one file at a time, `PYTHONDONTWRITEBYTECODE=1`). Per MUTATION.md habit 18 the harness's scratch file is `mktemp`ed per invocation and the tree is **fingerprinted after the revert** as well as before; every revert restored the recorded sha256 and byte count. | | mutation | Δ | verdict | |---|---|---|---| | M1 | delete the `_release()` call from `submit()` | −44 B | RED | | M2 | `release_s=RELEASE_S` → `inf` | +3 B | RED | | M3 | `RELEASE_S` 17 → `BATT_UNCHANGED_S` (35) | +2 B | RED | | M4 | `RELEASE_S` 17 → `PLANT_SETTLE_S` (6) | **+0 B** | RED | | M5 | delete the elapsed-rest test (release at once) | −90 B | RED | | M6 | `rested_since = None` when obliged → drop | −37 B | RED | | **M7** | **swap `_release()` / `_grade()` in `submit()`** | **+0 B** | **EQUIVALENT — argued, §7.1** | | M8 | drop `_open_window()` from `_release()` | −48 B | RED | | M9 | `_grade()` reads `unchanged_s()` not `graded_s()` | +3 B | RED | | M10 | `_open_window(None, …)` on release | −6 B | RED | | M11 | `releases += 1` dropped | −27 B | RED | | M12 | the reason's `unchanged for` slot takes the window | −20 B | RED | | M13 | `unchanged_s()` measured from `graded_from` | −2 B | RED | | M14 | **transpose the escape clause's two operands** (`main.py`) | **+0 B** | RED | | M15 | drop the `STALE WITHDRAWN` branch (`main.py`) | −33 B | RED | | M16 | **swap `MODE_CHARGE` / `MODE_DISCHARGE`** (decoder) | **+0 B** | RED | | M17 | unknown EMS mode defaults instead of raising (decoder) | −181 B | RED | | M18 | escape clause prints `unchanged_s` not `release_s` (`main.py`) | +2 B | RED | | M19 | alter one frame in the **committed capture** | — | RED | | M20 | `_release()`'s `move_w` test → `unchanged_s` | +5 B | RED *(was a survivor — see below)* | | M21 | `graded_from` re-anchored to `changed_mono` | +82 B | RED *(was a false survivor — see below)* | | M22 | release even while the obligation stands | −9 B | RED | Named checks per mutation are in the run logs; each red names at least one check, and no mutation produced a syntax error rather than a named failure. ### Three findings worth a reviewer's time **1. A sibling survived at +5 B (habit 11).** `_release()`'s obligation test `abs(standing_w) >= bound.move_w` with `move_w` swapped for `unchanged_s` — comparing **watts against 35** — survived the whole file, because **every fixture rested at exactly 0 W** and 0 is below both 35 and 250. Closed by a three-point fixture at **0 / 100 / 300 W**. **2. ⚠️ The code shipped a habit-22 comment that was false, and the battery is what said so.** `freshness.py` carried `⚠️ RELEASE BEFORE GRADE, AND THE ORDER IS LOAD-BEARING` with a worked reason. M7 falsifies it: the two act on **mutually exclusive** predicates, so at most one can act on any read and the order is **not observable** — not a missing fixture, and no fixture can close it. The comment now states the disjointness and part 10 pins **that**, at 0, 100, 249, 250, 251 and 1300 W. **3. ⚠️ The instrument lied once — MUTATION.md gains habit 23.** M21's first form was `float(self.changed_mono or now_mono)`. It reported a **survivor at +21 B**; purge clean, fingerprint clean, delta non-zero, mutant really ran. `changed_mono` is **`0.0`** in that fixture and `0.0 or x` is `x`, so the mutant was a **no-op on the stimulus** — habit 4, one level up, in a place none of the three things a mutation report must state can see. ## What this does NOT do - **It does not tell a BMS refusal from a dead register.** Both are re-graded on the same timer. Narrowing it needs the pack floor (SAFETY-32's number) and SoC, which this module deliberately does not take — named in §5.1 for whoever wants it, and not needed to give the state an exit. - **It does not touch the diagnosis wording.** *"is not following its own command"* is wrong in a different way when the pack is simply empty; that is **[[SAFETY-56]]'s**, together with `main.py`'s NOT READY row, and it is left alone here — with a **named check asserting it is still there**, so that rewrite has to be a deliberate act. Only the **escape clause** changed, because this card is what made the old one false. - **It does not close survivor 5, which predates it.** After a long rest the unchanged clock is already past 35 s, so the *first* read carrying a travelled command trips the gate before the 17 s in which a healthy register could have answered. The live capture contains one (10:48:14, cleared one second later). Cost is one `stale_input_s` hold, during which `cycle()` commands nothing differently. Fixing it re-derives the 35.0 s bound and is a different card. - **Nothing was measured on two inverters.** Inverter B does not exist. ## Suite **30/30 green** in the CI image, `checks.yml`'s exact command, purged tree, `:ro`, one file at a time, everything under `/home/homelab/`. `test_freshness.py` goes 16 s → 20 s (part 8b's 16-day replay). ## Notes for the reviewer - **Base.** The card said to branch off `origin/master` @ `6f5e8e3`; this targets `release/1.0` instead, because that is where branch protection and every merged card live. The two are content-identical, so the PR carries `6f5e8e3` (PKG-15's sync, already on master) plus this commit, and the diff against `release/1.0` is exactly this card's files. - **The CHANGELOG row is filed under `## 0.10.0` and that section says not to.** Its preamble makes its "0.10.0 is not running anywhere" argument explicitly conditional, and **the condition has now failed** — this card exists because of a defect measured on 0.10.0 on the reference install. It is there only because `test_version_gate.py` 11b requires the newest `## ` heading to be a version. **Whoever cuts the next version owns moving it**; the entry says so in its own first paragraph. No deploy is proposed here. - **Nothing reached the inverter.** No contact with 192.168.2.158 or 192.168.2.221. The one live system touched is the **passive** sniffer, read only. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
admin added 2 commits 2026-09-21 12:35:49 +02:00
`master` has been `5184a2c` / v0.2.1 since 2026-08-25 - the tree that drives the
house - while every ticket since has landed on `release/1.0`. Three consequences
were live, and all three are what this merge closes:

 * `master` is the DEFAULT branch, so Gitea indexes dispatchable workflows from
   it. `master` had no `.gitea/` at all, which is why `GET /actions/workflows`
   answered empty and the UI's dispatch button was missing.
 * new pull requests default to `master`. #102 was opened against it by
   accident and its first run graded the change merged into a stale `master` -
   an honest-looking green answering the wrong question.
 * AND THE ONE THAT NOW MATTERS MOST: the repository has not yet been added to
   Home Assistant, and adding it is part of this release. `master` is the
   branch the Supervisor will read the moment it is added, so whatever stands
   here is what it shows and offers to install. This merge is what makes that
   0.10.0 rather than 0.2.1.

NO CONFLICT WAS POSSIBLE AND THAT WAS CHECKED, NOT ASSUMED. `master` is a
direct ancestor of `release/1.0` (`git merge-base --is-ancestor` true), the
merge base IS `5184a2c`, and `origin/release/1.0..origin/master` is empty -
`master` carries nothing `release/1.0` lacks. `--no-ff` is used anyway, so the
crossing from 0.2.1 to 0.10.0 is one addressable commit rather than a pointer
move.

WHAT CROSSES: config.yaml 0.2.1 -> 0.10.0. The add-on the Supervisor will build
is a different program from v0.2.1 in the ways `goodwe_controller/CHANGELOG.md`
sections 0.3.0 through 0.10.0 describe - among them P1 meter ingestion, the
separate integrator the velocity form did not have, shadow mode, the tariff
plan and the multi-inverter roster. The ROLLBACK POINT does not move: tag
`v0.2.1` still names `5184a2c` and still names the tree that ran the house.

NOT A DEPLOY. Nothing is installed, no `ha addons update` is run, `/addons/` is
untouched and neither 192.168.2.158 nor 192.168.2.221 was contacted. Adding the
repository to Home Assistant, and pressing Install, remain the owner's call.

Verified before merging: the full suite in the CI image on `release/1.0`'s tree
- `cp -a`, purged, `:ro` mount, one file at a time, exactly the command in
.gitea/workflows/checks.yml - 30/30, exit 0; and PR #103's own
`checks / unit (pull_request)` was a real `success` on the merge, not a skip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
SAFETY-37's freshness gate reports a battery-power register as MISSING when it
stops following its own command, and a missing input is "command 0 W and keep
commanding" (§11.1 rule 1). The gate's only exit was "an arrival carrying a
DIFFERENT number" - and at 0 W nothing is asking the register to be anywhere.
The state had no exit reachable from inside it, and the log line promised the
opposite in the same sentence that announced the 0 W.

REPRODUCED FROM THE WIRE, NOT MODELLED. scenarios/safety57-2026-09-21/ is 3840
verbatim frames off the RS-485 pair, read from the passive sniffer, decoded by
tools/decode_bus_capture.py (0 unparsed, 0 bad CRC, 0 unknown shapes; the
mode->sign convention is graded off the capture, not trusted). The box was
running 0.10.0, so the 0 W in the trace IS the add-on's own claim:

  10:51:24.7  0x0518 reaches -3 W. Pack at 11 %, the BMS refuses the discharge,
              the inverter idles. THE REGISTER IS HEALTHY AND TRUTHFUL.
  10:51:29.7  the command reaches +1060 W - 800 W of travel
  10:52:00    the conjunction arms. Inverter.batt becomes None
  10:52:21.7  the site commands 0 W, on the wire
  ...         0x0518 does not change value for 850.0 s. The setpoint does not
              change value for 790.0 s. The house exports 2.4 kW throughout
  ~11:04      a human restarts the add-on. That is what ends it

AND IT HAD BEEN ESCAPING BY LUCK. Replaying the committed 16-day loop capture
through the shipped conjunction finds 93 trips whose wait for the BMS
keep-alive pulse that cleared them has a median of 143.4 s, a worst case of
21440.1 s, and 17 of 93 at or beyond this episode's 710 s. That distribution
belongs to the battery's firmware, not to this code.

THE CARD'S OPTION 2 ALREADY HELD AND IS NOT THE BUG. _grade() has required
|standing| >= MOVE_W since SAFETY-37. The trip was at +1060 W and the stuck
state is at 0 W; they are different moments, and an entry-side rule cannot
reach a state entered legitimately and then persisted. Nothing in the
conjunction is weakened here.

THE RULE, SAFETY-13's SHAPE ONE MODULE OVER - which latches get an exit, not
how long the block lasts:

  the verdict is withdrawn when the obligation that produced it has been gone
  for RELEASE_S, and the register is then re-graded from scratch.

RELEASE_S is BATT_OBLIGED_S, 17.0 s - the obligation window already derived in
the file (6.0 plant + 10.0 publish + 1.0 read), taken at the FLOOR of its band
for the mirror of MOVE_W's reason. Which half is safe is the opposite of
SAFETY-13's: being wrong long is the failure being fixed, being wrong short
costs one bounded re-grading window. Below 17 s the release pre-empts the
cheaper exit - the register answering by itself - and that is graded
behaviourally, not asserted.

The whole parked interval is now stale_input_s + RELEASE_S = 32 s. On the live
episode: telemetry back at 10:52:39 instead of 11:05:35, with 0x0518 still
answering the same -3 W. RED BEFORE (release_s=inf, a graded counterfactual and
not a second implementation), GREEN AFTER.

WHAT IT COSTS, AND IT IS NOT SMALL. A register dead for good is re-graded for
ever: over 1201 reads the held number reaches a consumer on 630 (52.5 %)
against 35 (2.9 %) before. Payable only because one window is at most 35 s and
the two consumers hold for 75 s and 85 s, so neither can reach a verdict inside
one and the re-trip clears both windows. No detection SAFETY-37 had is given
back - a permanent None clears the same windows for ever too - and the command
is derived from the P1 meter, never from batt.

Two clocks, deliberately: changed_mono/unchanged_s() stay facts about the
REGISTER, graded_from/graded_s() are the VERDICT's window. Collapsing them
either lies to the operator or defeats the re-grading.

main.py's stale line had its ESCAPE CLAUSE corrected - it promised a clearance
the state could not produce - and gained a STALE WITHDRAWN edge that says in
its own words that nothing has been learned about the register. The DIAGNOSIS
half is SAFETY-56's and is deliberately untouched, with a named check asserting
it is still there so that rewrite has to be deliberate.

Battery: 22 mutations, 21 red, 1 argued equivalent, 0 unexplained survivors.
Two findings kept in DECISIONS: _release()'s move_w test swapped for
unchanged_s SURVIVED at +5 B because every fixture rested at exactly 0 W (habit
11, closed by a 0/100/300 W fixture); and the code shipped a habit-22 comment
claiming the _release/_grade order was load-bearing, which the battery
falsified - the two act on mutually exclusive predicates, so the order is not
observable. MUTATION.md gains habit 23 for a mutation that ran, fingerprinted
clean, and was a no-op on the fixture because `0.0 or x` is x.

Suite: 30/30 in the CI image, checks.yml's exact command, purged tree, :ro, one
file at a time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
Author
Owner

Review 1/7 — the acceptance criterion, re-driven from the bytes rather than from part 10b

Verified. This is the finding the card stands or falls on and it holds.

I did not take the decoder's word for the capture. I wrote my own Modbus reader
(CRC-16/Modbus computed from scratch, four frame shapes, S16 on 0x0518,
mode+magnitude on 0x056E/0x056F) and it agrees with
tools/decode_bus_capture.py and with manifest.json exactly:

sha256_gz    876fdaaf…9d134   == manifest
sha256_plain fa07e13b…438a48  == manifest
3840 lines, 0 bad CRC, 0 unparsed
Counter({'write': 1200, 'wecho': 1200, 'req': 720, 'rsp': 720})   == frame_census
longest 0x0518 hold: 850.0 s at -3 W, 10:51:24.736 -> 11:05:34.734
setpoint +260 -> +1060 at 10:51:29.695 (800 W of travel), -> 0 W at 10:52:21.695
SoC on 0x050E: 0x000B = 11 %, on 145 of the responses in the window

Then I drove the shipped Freshness at 1 Hz over those series with the
closed loop the gate itself creates (stale -> command 0 W), with no fixture
from test_freshness.py involved:

release_s=inf   stale 10:52:00 -> clear 11:05:35   815 s   0x0518 = -3.0 W on every read
release_s=17.0  stale 10:52:00 -> clear 10:52:18    18 s   0x0518 = -3.0 W on every read
                stale 11:05:31 -> clear 11:05:35     4 s   (re-trip on the human's -400 W,
                                                            cleared BY MOVEMENT 3 s later)

My 10:52:18 is earlier than part 10b's 10:52:39 only because my loop commands
0 W at the instant the verdict arms, where 10b honours the 21 s the wire took to
carry it. Both answer the card's question the same way: the verdict is
withdrawn with 0x0518 still answering the same −3 W it had answered since
10:51:24, with no external change of any kind.
A fix that still needed the
register to move would have left release_s=17.0 looking like release_s=inf
here, and it does not.

The third episode above is worth naming because it is the other half of the
claim: when the obligation comes back the gate re-arms, and that one clears
the way SAFETY-37 always meant it to — the register moved.

## Review 1/7 — the acceptance criterion, re-driven from the bytes rather than from part 10b **Verified. This is the finding the card stands or falls on and it holds.** I did not take the decoder's word for the capture. I wrote my own Modbus reader (CRC-16/Modbus computed from scratch, four frame shapes, S16 on `0x0518`, mode+magnitude on `0x056E`/`0x056F`) and it agrees with `tools/decode_bus_capture.py` and with `manifest.json` exactly: ``` sha256_gz 876fdaaf…9d134 == manifest sha256_plain fa07e13b…438a48 == manifest 3840 lines, 0 bad CRC, 0 unparsed Counter({'write': 1200, 'wecho': 1200, 'req': 720, 'rsp': 720}) == frame_census longest 0x0518 hold: 850.0 s at -3 W, 10:51:24.736 -> 11:05:34.734 setpoint +260 -> +1060 at 10:51:29.695 (800 W of travel), -> 0 W at 10:52:21.695 SoC on 0x050E: 0x000B = 11 %, on 145 of the responses in the window ``` Then I drove the **shipped** `Freshness` at 1 Hz over those series with the closed loop the gate itself creates (`stale -> command 0 W`), with no fixture from `test_freshness.py` involved: ``` release_s=inf stale 10:52:00 -> clear 11:05:35 815 s 0x0518 = -3.0 W on every read release_s=17.0 stale 10:52:00 -> clear 10:52:18 18 s 0x0518 = -3.0 W on every read stale 11:05:31 -> clear 11:05:35 4 s (re-trip on the human's -400 W, cleared BY MOVEMENT 3 s later) ``` My 10:52:18 is earlier than part 10b's 10:52:39 only because my loop commands 0 W at the instant the verdict arms, where 10b honours the 21 s the wire took to carry it. Both answer the card's question the same way: **the verdict is withdrawn with `0x0518` still answering the same −3 W it had answered since 10:51:24, with no external change of any kind.** A fix that still needed the register to move would have left `release_s=17.0` looking like `release_s=inf` here, and it does not. The third episode above is worth naming because it is the other half of the claim: when the obligation comes back the gate **re-arms**, and that one clears the way SAFETY-37 always meant it to — the register moved.
Author
Owner

Review 2/7 — the counterfactual is the shipped module, and the conjunction is untouched

Verified, and by a stronger test than part 10b's.

Part 10b asserts release_s=inf reproduces the pre-SAFETY-57 module against
part 1's own fresh/stale pair. That is two fixtures. I loaded
release/1.0:goodwe_controller/app/freshness.py side by side with this
branch's and compared (stale, reading(), reason) read by read:

randomised stimulus : 0 differing reads out of 240 000
  (400 trials x 600 reads; register drawn from {0,-3,-1200,1500,-2900,±250,99},
   command from {0,120,±260,1060,-1340,249,-251,3000}, 2 % None on each)
committed capture   : 0 differing reads out of 2 336

So the refactor — standing_at_change -> standing_at_grade, unchanged_s()
-> graded_s() in _grade(), _open_window() — is behaviour-preserving up
to the first release
, and release_s=inf is the shipped-before module rather
than a second implementation of it. The three arming terms (>= unchanged_s,
|standing| >= move_w, moved >= move_w) are byte-identical in effect.

Concretely: nothing in the staleness detection was weakened here. The only
behaviour added is the exit.

## Review 2/7 — the counterfactual is the shipped module, and the conjunction is untouched **Verified, and by a stronger test than part 10b's.** Part 10b asserts `release_s=inf` reproduces the pre-SAFETY-57 module against part 1's own fresh/stale pair. That is two fixtures. I loaded `release/1.0:goodwe_controller/app/freshness.py` side by side with this branch's and compared `(stale, reading(), reason)` read by read: ``` randomised stimulus : 0 differing reads out of 240 000 (400 trials x 600 reads; register drawn from {0,-3,-1200,1500,-2900,±250,99}, command from {0,120,±260,1060,-1340,249,-251,3000}, 2 % None on each) committed capture : 0 differing reads out of 2 336 ``` So the refactor — `standing_at_change` -> `standing_at_grade`, `unchanged_s()` -> `graded_s()` in `_grade()`, `_open_window()` — is **behaviour-preserving up to the first release**, and `release_s=inf` is the shipped-before module rather than a second implementation of it. The three arming terms (`>= unchanged_s`, `|standing| >= move_w`, `moved >= move_w`) are byte-identical in effect. Concretely: **nothing in the staleness detection was weakened here.** The only behaviour added is the exit.
Author
Owner

Review 3/7 — FINDING: "every unbroken run of exposure is at most one window" is a universal claim graded on a fixture that cannot break it

This is the load-bearing sentence of the whole cost argument. It appears four
times — app/freshness.py, CHANGELOG.md, DOCS.md, and as the named check in
part 10c — and it is stated without a qualifier:

Every unbroken run of exposure is exactly one 35.0 s grading window, and
35 < 75 (circulation_hold_s) < 85 (unit_sign_disagree_s) — so NEITHER
detector can reach a verdict inside one

It is not true in general. _dead_site() hard-codes
standing = 1300.0 the instant reading() is a number again, so the fixture
puts a 1300 W obligation back on every release and the run can only ever be
35 s long. Two reachable command shapes make the run unbounded, both driven
against the shipped module:

A. dead register at -1200 W; after the release the site regulates at 120 W
   (|command| < MOVE_W, so _grade() can never arm again)
   -> exposure 1159/1201 reads (96.5 %), releases=1, longest run 1124 s

B. dead register at -1200 W; the release lands while the command rests at
   200 W, so standing_at_grade = 200 W; the site then regulates at 400 W
   (|command| >= MOVE_W, but moved = 200 W < MOVE_W, so _grade() cannot arm)
   -> exposure 1159/1201 reads (96.5 %), releases=1, stale=False for ever

The concrete failure it implies: a permanently dead register that has
already been detected once hands the held number to consumers for an unbounded
time, where before this card it handed them None for an unbounded time. The
35 < 75 < 85 inequality does not save that case, because the window never
closes.

Why I am not blocking on it. I chased the consequence and on the reference
install there is none:

  • main.py:2284 — against = self.grid if n_units <= 1 else inv.batt. At the
    shipped roster of one inverter the per-unit sign watchdog is compared against
    the meter, so inv.batt never reaches it at all.
  • circulation.circulating_w() returns 0.0 identically at N <= 1.
  • In shape A the sign watchdog cannot speak anyway — disagrees() needs
    |commanded_w| > 250 W, which is the same threshold that stops _grade()
    re-arming. That symmetry is already written down in the batt row's
    "TRUE for the per-unit sign check" paragraph.
  • The residual that is left — the circulation consumer's unbounded exposure
    below MOVE_W — is named in this same docstring as "a known, stated gap
    rather than a covered one", and needs N >= 2, and inverter B does not exist.

So the behaviour is right and the scope of the claim is wrong. What I am
asking for is one qualifier, not a code change: the run is one window while
the obligation returns
, and part 10c's fixture is the one that forces it to.
As written it is the shape this repository keeps filing against itself — a
check whose data cannot reach the failure its sentence names — in the card whose
entire subject is that shape.

## Review 3/7 — FINDING: "every unbroken run of exposure is at most one window" is a universal claim graded on a fixture that cannot break it This is the load-bearing sentence of the whole cost argument. It appears four times — `app/freshness.py`, `CHANGELOG.md`, `DOCS.md`, and as the named check in part 10c — and it is stated without a qualifier: > Every unbroken run of exposure is exactly one 35.0 s grading window, and > 35 < 75 (circulation_hold_s) < 85 (unit_sign_disagree_s) — so NEITHER > detector can reach a verdict inside one **It is not true in general.** `_dead_site()` hard-codes `standing = 1300.0` the instant `reading()` is a number again, so the fixture puts a 1300 W obligation back on every release and the run can only ever be 35 s long. Two reachable command shapes make the run unbounded, both driven against the **shipped** module: ``` A. dead register at -1200 W; after the release the site regulates at 120 W (|command| < MOVE_W, so _grade() can never arm again) -> exposure 1159/1201 reads (96.5 %), releases=1, longest run 1124 s B. dead register at -1200 W; the release lands while the command rests at 200 W, so standing_at_grade = 200 W; the site then regulates at 400 W (|command| >= MOVE_W, but moved = 200 W < MOVE_W, so _grade() cannot arm) -> exposure 1159/1201 reads (96.5 %), releases=1, stale=False for ever ``` **The concrete failure it implies:** a permanently dead register that has already been detected once hands the held number to consumers for an unbounded time, where before this card it handed them `None` for an unbounded time. The 35 < 75 < 85 inequality does not save that case, because the window never closes. **Why I am not blocking on it.** I chased the consequence and on the reference install there is none: * `main.py:2284` — `against = self.grid if n_units <= 1 else inv.batt`. At the shipped roster of one inverter the per-unit sign watchdog is compared against the **meter**, so `inv.batt` never reaches it at all. * `circulation.circulating_w()` returns `0.0` identically at N <= 1. * In shape A the sign watchdog cannot speak anyway — `disagrees()` needs `|commanded_w| > 250 W`, which is the same threshold that stops `_grade()` re-arming. That symmetry is already written down in the `batt` row's "TRUE for the per-unit sign check" paragraph. * The residual that is left — the circulation consumer's unbounded exposure below `MOVE_W` — is named in this same docstring as "a known, stated gap rather than a covered one", and needs N >= 2, and inverter B does not exist. So the behaviour is right and the *scope* of the claim is wrong. What I am asking for is one qualifier, not a code change: the run is one window **while the obligation returns**, and part 10c's fixture is the one that forces it to. As written it is the shape this repository keeps filing against itself — a check whose data cannot reach the failure its sentence names — in the card whose entire subject is that shape.
Author
Owner

Review 4/7 — FINDING: the live episode is 790 s everywhere except the three places that call it 710 s

The PR body corrected my brief on exactly this point — "the episode was 790 s
not 710 s" — and the correction did not reach the tree. Three shipped artefacts
still attribute 710 s to the 2026-09-21 episode:

  • app/freshness.py:216 — "17 episodes at or over the live one's 710 s"
  • CHANGELOG.md:289 — "17 of the 93 at or beyond this episode's 710 s"
  • test_freshness.py:924-938 — the printed line and the named check:
    "at or beyond the 710 s the live site was parked for on 2026-09-21"

The card's own measurements, which I reproduced, are 790.0 s (setpoint hold),
815 s (the verdict, on the replay) and 850.0 s (register hold).
DOCS.md says 790 s. So the file that derives the number and the file that
explains it to an operator disagree inside one commit.

I re-ran part 8b's replay myself against the committed 16-day capture and the
distribution is exactly as claimed — and the threshold does not move the
conclusion:

93 trips, min 0.6 s, median 143.4 s, max 21440.1 s      (all confirmed)
  >= 710 s : 17 of 93      <- what ships
  >= 790 s : 16 of 93      <- the episode's actual parked setpoint
  >= 815 s : 15 of 93

The concrete failure it implies: nothing behavioural. It is an operator-
facing number in CHANGELOG.md and a module docstring that states the wrong
duration for the one episode this card exists for, and a named check that
asserts it. Cheap to fix, and this is the repository that files habit 22 against
a comment for less.

## Review 4/7 — FINDING: the live episode is 790 s everywhere except the three places that call it 710 s The PR body corrected my brief on exactly this point — "the episode was 790 s not 710 s" — and the correction did not reach the tree. Three shipped artefacts still attribute 710 s to the 2026-09-21 episode: * `app/freshness.py:216` — "17 episodes at or over the live one's **710 s**" * `CHANGELOG.md:289` — "17 of the 93 at or beyond this episode's **710 s**" * `test_freshness.py:924-938` — the printed line and the named check: "at or beyond the **710 s** the live site was parked for on 2026-09-21" The card's own measurements, which I reproduced, are 790.0 s (setpoint hold), 815 s (the verdict, on the replay) and 850.0 s (register hold). `DOCS.md` says 790 s. So the file that derives the number and the file that explains it to an operator disagree inside one commit. I re-ran part 8b's replay myself against the committed 16-day capture and the distribution is exactly as claimed — and the threshold does not move the conclusion: ``` 93 trips, min 0.6 s, median 143.4 s, max 21440.1 s (all confirmed) >= 710 s : 17 of 93 <- what ships >= 790 s : 16 of 93 <- the episode's actual parked setpoint >= 815 s : 15 of 93 ``` **The concrete failure it implies:** nothing behavioural. It is an operator- facing number in `CHANGELOG.md` and a module docstring that states the wrong duration for the one episode this card exists for, and a named check that asserts it. Cheap to fix, and this is the repository that files habit 22 against a comment for less.
Author
Owner

Review 5/7 — FINDING: the keep-alive pulse is an inference stated as a measurement

app/freshness.py:210 and CHANGELOG.md:285 both assert it as fact:

This BMS emits a keep-alive pulse of a few hundred watts, which twitches
0x0518 and satisfies the clearing rule by accident.

and part 8b's check name carries it into the suite:

The short waits are the pack's keep-alive pulse twitching 0x0518 and
satisfying the clearing rule BY ACCIDENT

The predicate that check actually grades is
sum(1 for w in _waits if w < 20.0) >= 20 — "a quarter of the waits are under
20 s". That is a fact about how often the register changes. Nothing in either
committed capture identifies a keep-alive pulse
, distinguishes one from the
loop's own command being followed, or shows a pulse of "a few hundred watts".
The 16-day capture's setpoint column is an ungated controller's, which the same
section is careful to say — so a short wait there is at least as likely to be
the register simply following a command as it is to be a spontaneous twitch.

You are right and my brief was wrong about the 2026-09-21 episode: I asserted a
keep-alive escape as fact and there is none. I confirmed it off the wire —
0x0518 answers FF FD (−3 W) on 90 consecutive responses and does not carry a
different number once between 10:51:24.736 and 11:05:34.734. The register never
moved at all.

The concrete failure it implies: a mechanism nobody measured is written into
a module docstring, a changelog an operator reads, and the name of a passing
check — so the next person to touch this bound inherits it as established. The
measured claim ("this register goes a median of 143 s without changing value,
and as long as 5 h 57 m") is strictly stronger and needs no firmware story.
Please either attribute the pulse to something, or say "for reasons that belong
to the battery's firmware".

## Review 5/7 — FINDING: the keep-alive pulse is an inference stated as a measurement `app/freshness.py:210` and `CHANGELOG.md:285` both assert it as fact: > This BMS emits a keep-alive pulse of a few hundred watts, which twitches > 0x0518 and satisfies the clearing rule by accident. and part 8b's check *name* carries it into the suite: > The short waits are the pack's keep-alive pulse twitching 0x0518 and > satisfying the clearing rule BY ACCIDENT The predicate that check actually grades is `sum(1 for w in _waits if w < 20.0) >= 20` — "a quarter of the waits are under 20 s". That is a fact about how often the register changes. **Nothing in either committed capture identifies a keep-alive pulse**, distinguishes one from the loop's own command being followed, or shows a pulse of "a few hundred watts". The 16-day capture's setpoint column is an ungated controller's, which the same section is careful to say — so a short wait there is at least as likely to be the register simply following a command as it is to be a spontaneous twitch. You are right and my brief was wrong about the 2026-09-21 episode: I asserted a keep-alive escape as fact and **there is none**. I confirmed it off the wire — `0x0518` answers `FF FD` (−3 W) on 90 consecutive responses and does not carry a different number once between 10:51:24.736 and 11:05:34.734. The register never moved at all. **The concrete failure it implies:** a mechanism nobody measured is written into a module docstring, a changelog an operator reads, and the name of a passing check — so the next person to touch this bound inherits it as established. The measured claim ("this register goes a median of 143 s without changing value, and as long as 5 h 57 m") is strictly stronger and needs no firmware story. Please either attribute the pulse to something, or say "for reasons that belong to the battery's firmware".
Author
Owner

Review 6/7 — the mutation battery, re-applied on my own purged tree

Eleven of the twenty-two re-applied from scratch, plus a green control and three
of my own, each on a fresh purged copy of 43c144b8, bind-mounted :ro into
checks.yml's own image with PYTHONDONTWRITEBYTECODE=1:

mutation Δ mine yours
CTRL one byte in a docstring (harness sanity) +1 B green —
M1 delete the _release() call from submit() −44 B RED RED
M2 release_s=RELEASE_S → inf +3 B RED RED
M4 RELEASE_S → PLANT_SETTLE_S +0 B RED RED
M7 swap _release()/_grade() +0 B SURVIVED equivalent
M9 _grade() reads unchanged_s() +3 B RED RED
M13 unchanged_s() measured from graded_from −1 B RED RED
M14 transpose the escape clause's operands (main.py) +0 B RED RED
M15 drop the STALE WITHDRAWN branch (main.py) −41 B RED RED
M20 _release()'s move_w test → unchanged_s +5 B RED RED
M21 graded_from re-anchored to changed_mono, explicit is None +121 B RED RED
M22 release even while the obligation stands −19 B RED RED
X1 (mine) RELEASE_S 17.0 → 16.0 +0 B RED —
X2 (mine) < release_s → <= release_s +1 B RED —
X3 (mine) _release() treats a None command as obliged −5 B SURVIVED —

The control is the part worth naming: a byte added to a docstring leaves the
suite green, so a RED above is the mutation and not my harness.

M7 — the argued equivalent is genuinely equivalent

An equivalence argument is exactly where a real survivor hides, so I did not
take the prose. I subclassed Freshness with the two calls swapped and compared
the full internal state — held, changed_mono, graded_from, standing_at_grade, rested_since, released_at, releases, stale, reason, last, arrivals — read by read:

600 trials x 400 reads = 240 000 reads
commands drawn from {0, 120, 200, 249, 250, 251, 400, 1060, -1340, 3000, None}
20 635 of those reads under a None command (no fixture in the file uses one)
  -> 0 differing reads

The reasoning holds too, in all three branches: at |standing| >= move_w
_release() only clears rested_since, which _grade() never reads; at
|standing| < move_w _grade() returns before its first write; and if a
release does fire, _open_window() sets graded_from = now, so the _grade()
that follows it in the same read sees graded_s() = 0 and cannot re-arm. The
survivor is real and the comment that now ships is true.

X3 — one survivor of my own, and it is the None seam in that same comment

The shipped comment gives the disjointness as

_grade() acts only when |standing| >= move_w and _release() only when
|standing| < move_w

which is literally false when standing_w is None: neither inequality holds,
_grade() withholds, and _release() acts — it treats an unknown command
as no obligation and will withdraw a verdict on the timer. Inverting that
(standing_w is None counted as obliged) survives the whole file at −5 B,
because Inverter.standing_w() is -> float and never returns None, so
nothing exercises the path.

The concrete failure it implies: none today — the path is dead on the wired
caller. But the None guards in _grade() are documented as being there for
TEL-08, whose obligation term is an energy integral that will have nothing to
say until it has a window, and TEL-08 will inherit "unknown obligation ⇒
release" as an undiscussed default under a comment that says the two are
partitioned by a single inequality. One clause in that comment settles it.

## Review 6/7 — the mutation battery, re-applied on my own purged tree Eleven of the twenty-two re-applied from scratch, plus a green control and three of my own, each on a fresh purged copy of `43c144b8`, bind-mounted `:ro` into `checks.yml`'s own image with `PYTHONDONTWRITEBYTECODE=1`: | | mutation | Δ | mine | yours | |---|---|---|---|---| | CTRL | one byte in a docstring (harness sanity) | +1 B | **green** | — | | M1 | delete the `_release()` call from `submit()` | −44 B | RED | RED | | M2 | `release_s=RELEASE_S` → `inf` | +3 B | RED | RED | | M4 | `RELEASE_S` → `PLANT_SETTLE_S` | **+0 B** | RED | RED | | **M7** | **swap `_release()`/`_grade()`** | **+0 B** | **SURVIVED** | equivalent | | M9 | `_grade()` reads `unchanged_s()` | +3 B | RED | RED | | M13 | `unchanged_s()` measured from `graded_from` | −1 B | RED | RED | | M14 | transpose the escape clause's operands (`main.py`) | **+0 B** | RED | RED | | M15 | drop the `STALE WITHDRAWN` branch (`main.py`) | −41 B | RED | RED | | M20 | `_release()`'s `move_w` test → `unchanged_s` | +5 B | RED | RED | | M21 | `graded_from` re-anchored to `changed_mono`, explicit `is None` | +121 B | RED | RED | | M22 | release even while the obligation stands | −19 B | RED | RED | | X1 | *(mine)* `RELEASE_S` 17.0 → 16.0 | +0 B | RED | — | | X2 | *(mine)* `< release_s` → `<= release_s` | +1 B | RED | — | | X3 | *(mine)* `_release()` treats a `None` command as obliged | −5 B | **SURVIVED** | — | The control is the part worth naming: a byte added to a docstring leaves the suite green, so a RED above is the mutation and not my harness. ### M7 — the argued equivalent is genuinely equivalent An equivalence argument is exactly where a real survivor hides, so I did not take the prose. I subclassed `Freshness` with the two calls swapped and compared **the full internal state** — `held, changed_mono, graded_from, standing_at_grade, rested_since, released_at, releases, stale, reason, last, arrivals` — read by read: ``` 600 trials x 400 reads = 240 000 reads commands drawn from {0, 120, 200, 249, 250, 251, 400, 1060, -1340, 3000, None} 20 635 of those reads under a None command (no fixture in the file uses one) -> 0 differing reads ``` The reasoning holds too, in all three branches: at `|standing| >= move_w` `_release()` only clears `rested_since`, which `_grade()` never reads; at `|standing| < move_w` `_grade()` returns before its first write; and if a release does fire, `_open_window()` sets `graded_from = now`, so the `_grade()` that follows it in the same read sees `graded_s() = 0` and cannot re-arm. The survivor is real and the comment that now ships is true. ### X3 — one survivor of my own, and it is the `None` seam in that same comment The shipped comment gives the disjointness as > `_grade()` acts only when `|standing| >= move_w` and `_release()` only when > `|standing| < move_w` which is literally false when `standing_w is None`: neither inequality holds, `_grade()` withholds, and **`_release()` acts** — it treats an unknown command as no obligation and will withdraw a verdict on the timer. Inverting that (`standing_w is None` counted as *obliged*) survives the whole file at −5 B, because `Inverter.standing_w()` is `-> float` and never returns `None`, so nothing exercises the path. **The concrete failure it implies:** none today — the path is dead on the wired caller. But the `None` guards in `_grade()` are documented as being there for TEL-08, whose obligation term is an energy integral that *will* have nothing to say until it has a window, and TEL-08 will inherit "unknown obligation ⇒ release" as an undiscussed default under a comment that says the two are partitioned by a single inequality. One clause in that comment settles it.
Author
Owner

Review 7/7 — the cost legs, the consumers, the suite, the capture, and the two minor slips

The 52.5 % is real and reproduces exactly

Written from the description rather than by importing _dead_site():

before (release_s=inf)  exposure   35/1201 (2.9 %)  releases= 0  run lengths {35}
after  (release_s=17.0) exposure  630/1201 (52.5 %) releases=17  run lengths {35}

The four legs of "why it is payable", each checked in the source

  1. 35 < 75 < 85. config.yaml:685 circulation_hold_s: 75,
    config.yaml:624 unit_sign_disagree_s: 85, and the same two defaults in
    circulation.py:180 / watchdog.py:392. Confirmed.
  2. A re-trip clears both windows. CirculationWatchdog.evaluate() —
    if circ_w is None or ... : self.over_since = None. SignWatchdog.evaluate()
    — if not disagrees(...): self.disagree_since = None, and disagrees() is
    False on a None against_w. Confirmed, both clear rather than accumulate.
  3. The command comes from the meter. control.compute() takes grid_w as
    the error term; actual_w (= site_batt_w) only feeds saturated_now,
    whose effect is frozen — magnitude may fall, never rise. Confirmed.
  4. Every run is one window. This is the one that does not hold universally —
    see comment 3/7. It holds in part 10c's fixture and on the reference install.

⚠️ And one thing the PR undersells in its own favour: at the shipped roster of
one inverter, main.py:2284 compares the sign watchdog against
self.grid, not inv.batt, and circulating_w() is identically 0.0 at
N <= 1. So on the install this card was measured on, neither of the two named
consumers sees the held number at all
. The 35 < 75 < 85 inequality is
load-bearing only at N >= 2, which does not exist yet.

RELEASE_S = 17.0

The floor-of-the-band reasoning is right and it is a knee rather than a first
defensible number: 17.0 is the smallest value that never pre-empts the free
exit, because 6.0 + 10.0 + 1.0 is the longest a healthy register can take to
answer the command now standing. Below it you buy a re-grading window you did
not need; above it you pay parked seconds for nothing. The family
5/16/17/18/35/inf does give six distinct instants (re-run), and the
behavioural check at +16 s is the right shape. Two mutations of my own on top of
the battery: RELEASE_S -> 16.0 (+0 B) is RED, and < release_s ->
<= release_s (+1 B) is RED, so both the literal and the boundary are
pinned.

The capture and the decoder earn their place

17 181 B gzipped plus a 3 050 B manifest — the whole scenario is smaller than
the test that reads it. Committing frames rather than a decoded table is the
right call for an episode about two views of the wire disagreeing, and the
decoder is the thing that makes it re-derivable: I wrote a second one and it
agrees frame for frame. It is loaded from tools/ by part 10a rather than
transcribed, which is what keeps it honest, and it raises on an unseen EMS mode
instead of defaulting to a sign.

Suite

checks.yml's exact command, its exact image, purged tree, :ro, one file at a
time: 30/30 PASS, exit 0, test_freshness.py 20 s. CI on
43c144b8 is a real run, not a CI-9 skip — pending -> in progress -> success
in 7m58s with a target_url of /actions/runs/811/jobs/1935.

Two minor slips, neither behavioural

  • DECISIONS/SAFETY-57-…md §7.3 ends "MUTATION.md gains habit 19 for it".
    It gained habit 23.
  • The brief's line numbers: def reading(self) on release/1.0 is at 360,
    not 369 and not 370 — 370 is the gated return inside it. The entity-blaming
    row at 4639-4640 is right, and my 4636 was wrong.
## Review 7/7 — the cost legs, the consumers, the suite, the capture, and the two minor slips ### The 52.5 % is real and reproduces exactly Written from the description rather than by importing `_dead_site()`: ``` before (release_s=inf) exposure 35/1201 (2.9 %) releases= 0 run lengths {35} after (release_s=17.0) exposure 630/1201 (52.5 %) releases=17 run lengths {35} ``` ### The four legs of "why it is payable", each checked in the source 1. **35 < 75 < 85.** `config.yaml:685 circulation_hold_s: 75`, `config.yaml:624 unit_sign_disagree_s: 85`, and the same two defaults in `circulation.py:180` / `watchdog.py:392`. Confirmed. 2. **A re-trip clears both windows.** `CirculationWatchdog.evaluate()` — `if circ_w is None or ... : self.over_since = None`. `SignWatchdog.evaluate()` — `if not disagrees(...): self.disagree_since = None`, and `disagrees()` is False on a `None` `against_w`. Confirmed, both clear rather than accumulate. 3. **The command comes from the meter.** `control.compute()` takes `grid_w` as the error term; `actual_w` (= `site_batt_w`) only feeds `saturated_now`, whose effect is `frozen` — magnitude may fall, never rise. Confirmed. 4. **Every run is one window.** This is the one that does not hold universally — see comment 3/7. It holds in part 10c's fixture and on the reference install. ⚠️ And one thing the PR undersells in its own favour: at the shipped roster of **one** inverter, `main.py:2284` compares the sign watchdog against `self.grid`, not `inv.batt`, and `circulating_w()` is identically `0.0` at N <= 1. So on the install this card was measured on, **neither of the two named consumers sees the held number at all**. The 35 < 75 < 85 inequality is load-bearing only at N >= 2, which does not exist yet. ### `RELEASE_S = 17.0` The floor-of-the-band reasoning is right and it is a knee rather than a first defensible number: 17.0 is the *smallest* value that never pre-empts the free exit, because 6.0 + 10.0 + 1.0 is the longest a healthy register can take to answer the command now standing. Below it you buy a re-grading window you did not need; above it you pay parked seconds for nothing. The family `5/16/17/18/35/inf` does give six distinct instants (re-run), and the behavioural check at +16 s is the right shape. Two mutations of my own on top of the battery: `RELEASE_S -> 16.0` (+0 B) is **RED**, and `< release_s` -> `<= release_s` (+1 B) is **RED**, so both the literal and the boundary are pinned. ### The capture and the decoder earn their place 17 181 B gzipped plus a 3 050 B manifest — the whole scenario is smaller than the test that reads it. Committing frames rather than a decoded table is the right call for an episode about two views of the wire disagreeing, and the decoder is the thing that makes it re-derivable: I wrote a second one and it agrees frame for frame. It is loaded from `tools/` by part 10a rather than transcribed, which is what keeps it honest, and it raises on an unseen EMS mode instead of defaulting to a sign. ### Suite `checks.yml`'s exact command, its exact image, purged tree, `:ro`, one file at a time: **30/30 PASS, exit 0**, `test_freshness.py` 20 s. CI on `43c144b8` is a real run, not a CI-9 skip — `pending -> in progress -> success` in 7m58s with a `target_url` of `/actions/runs/811/jobs/1935`. ### Two minor slips, neither behavioural * `DECISIONS/SAFETY-57-…md` §7.3 ends "MUTATION.md gains habit **19** for it". It gained habit **23**. * The brief's line numbers: `def reading(self)` on `release/1.0` is at **360**, not 369 and not 370 — 370 is the gated `return` inside it. The entity-blaming row at **4639-4640** is right, and my 4636 was wrong.
Author
Owner

Verdict — MERGING

The acceptance criterion is met and I re-drove it from the bytes rather than
from the fixtures: the verdict is withdrawn with 0x0518 held at the same −3 W
it answered for 850 s, with no external change of any kind (1/7). The
counterfactual is the shipped-before module to 0 differing reads in 242 336
(2/7), so the conjunction SAFETY-37 built is untouched and only the exit is new.
The 52.5 % cost reproduces exactly, three of its four legs check out in the
source, and the fourth is real on the install this was measured on (7/7). The
17.0 s is a knee and not a first defensible number, and both the literal and
the boundary are pinned against mutations of my own (7/7). Eleven mutations
re-applied on a purged tree behave as the table says, the argued equivalent is
equivalent over the full internal state including 20 635 None-command reads,
and a green control says the harness can tell (6/7). 30/30 in checks.yml's own
image; the CI status on 43c144b8 is a real 7m58s run with a job behind it, not
a CI-9 skip.

Scope holds: main.py changes the escape clause and adds the withdrawal branch,
and nothing else. SAFETY-56's diagnosis half is untouched and has a check
standing guard over it. Survivor 5 and the CHANGELOG preamble are left for their
own cards, as asked.

Three findings, none of them behavioural, all of them documentation, and I am
not holding the fix hostage to them
— the house spent thirteen minutes parked
this morning for want of this code. They should land as a follow-up before the
next version is cut, because two of the three are what an operator reads:

  • 3/7 — "every unbroken run of exposure is at most one window" is stated
    universally in freshness.py, CHANGELOG.md, DOCS.md and a named check,
    and graded on a fixture that forces a 1300 W obligation back on every release.
    Two reachable command shapes leave the run unbounded. It needs a qualifier,
    not a code change.
  • 4/7 — the live episode is 790 s, and three shipped places still call it
    710 s, including the check that asserts it.
  • 5/7 — the BMS keep-alive pulse is an inference written as a measurement,
    in a module docstring, a changelog, and the name of a passing check.

Plus two one-liners: the None-command seam in the new disjointness comment
(6/7, survivor X3), and §7.3 crediting habit 19 where MUTATION.md gained 23.

Merging.

## Verdict — MERGING The acceptance criterion is met and I re-drove it from the bytes rather than from the fixtures: the verdict is withdrawn with `0x0518` held at the same −3 W it answered for 850 s, with no external change of any kind (1/7). The counterfactual is the shipped-before module to 0 differing reads in 242 336 (2/7), so the conjunction SAFETY-37 built is untouched and only the exit is new. The 52.5 % cost reproduces exactly, three of its four legs check out in the source, and the fourth is real on the install this was measured on (7/7). The `17.0 s` is a knee and not a first defensible number, and both the literal and the boundary are pinned against mutations of my own (7/7). Eleven mutations re-applied on a purged tree behave as the table says, the argued equivalent is equivalent over the full internal state including 20 635 `None`-command reads, and a green control says the harness can tell (6/7). 30/30 in `checks.yml`'s own image; the CI status on `43c144b8` is a real 7m58s run with a job behind it, not a CI-9 skip. Scope holds: `main.py` changes the escape clause and adds the withdrawal branch, and nothing else. SAFETY-56's diagnosis half is untouched and has a check standing guard over it. Survivor 5 and the CHANGELOG preamble are left for their own cards, as asked. **Three findings, none of them behavioural, all of them documentation, and I am not holding the fix hostage to them** — the house spent thirteen minutes parked this morning for want of this code. They should land as a follow-up before the next version is cut, because two of the three are what an operator reads: * **3/7** — "every unbroken run of exposure is at most one window" is stated universally in `freshness.py`, `CHANGELOG.md`, `DOCS.md` and a named check, and graded on a fixture that forces a 1300 W obligation back on every release. Two reachable command shapes leave the run unbounded. It needs a qualifier, not a code change. * **4/7** — the live episode is 790 s, and three shipped places still call it 710 s, including the check that asserts it. * **5/7** — the BMS keep-alive pulse is an inference written as a measurement, in a module docstring, a changelog, and the name of a passing check. Plus two one-liners: the `None`-command seam in the new disjointness comment (6/7, survivor X3), and §7.3 crediting habit 19 where MUTATION.md gained 23. Merging.
admin merged commit b9f7bd0506 into release/1.0 2026-09-21 15:49:36 +02:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: admin/goodwe-addon#104