SAFETY-37: a stale reading is None, and arrival cannot be what says so
checks / unit (pull_request) Successful in 5m34s

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
This commit is contained in:
glenn schrooyen
2026-09-06 11:53:26 +02:00
co-authored by Claude Opus 5
parent a4290d39ac
commit 6864109a01
9 changed files with 1854 additions and 8 deletions
+40
View File
@@ -546,6 +546,46 @@ Cheap habits fall out of it, and all of them are now used in this repository:
instance of habit 4 in this file, and the second one in an instrument
rather than in a check.**
17. ⚠️ **TWO TERMS OF A CONJUNCTION THAT MOVE TOGETHER IN EVERY FIXTURE ARE
ONE TERM AS FAR AS THE CHECKS ARE CONCERNED - TWELFTH INSTANCE OF HABIT 4,
and the third found by an author's own battery rather than by review.**
SAFETY-37's freshness gate is `unchanged >= 35 s AND |standing| >= 250 W
AND |travel| >= 250 W`. Deleting the MIDDLE term left every check in the
first draft 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,
so one of them was decoration as far as the suite could tell. The case that
separates them is a site being brought to REST - the register reaches its
new value on the same read at which the OLD command is still standing, so
the travel term alone reads 1300 W of unfollowed command while the site
sits truthfully at +5 W. Ask of every conjunction: **is there a fixture in
which each term is the ONLY one that is false?** If not, the suite cannot
tell an AND from an OR.
18. ⚠️ **AN UNVERIFIED REVERT IS THE SAME DEFECT AS STALE BYTECODE, AND TWO
HARNESSES SHARING ONE BACKUP PATH IS THE WORST VERSION OF IT.** SAFETY-37's
sweep hit this twice, in ascending order of nastiness.
* A mutation's container run overran a two-minute command budget and the
wrapper was killed **between the edit and the `cp` that puts the file
back**, 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.
* Then two copies of the sweep were started - a scheduling mistake, not a
tool defect - and the harness kept its backup at the FIXED path
`/tmp/mut.bak`. One copy saved `app/main.py` there; the other restored it
**over `app/freshness.py`**. The result was a 238 KB `freshness.py` that
still parsed as Python, plus `main.py` left holding a live mutation. No
error, no warning, and the only reason it was noticed is that the tree
fingerprint did not match the recorded clean one.
Two rules, and the first is the cheap one:
* **Fingerprint after the revert, not only before the run.**
`tools/mutation_run.sh` prints the tree it is about to grade for exactly
this reason; the revert deserves the same treatment, and a mismatch is a
stop-everything result rather than a note.
* **A harness's scratch file must be unique per invocation** (`mktemp`), so
that two of it cannot silently overwrite each other's work. A fixed path
in a tool that edits source is a shared mutable global with a filename.
And the mutation-table consequence: **a variant is not covered because a
near-miss of it is.** DEV-02's table listed the *actuator* variant of the
per-unit claim defect (`device`/`supervised` filed without `inv`), which the