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 2026-09-21 15:49:36 +02:00
SAFETY-57 into release/1.0
2
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
43c144b8d9 |
SAFETY-57: the stale-reading verdict gets an exit, because its own consequence is what stopped it being tested
checks / unit (pull_request) Successful in 7m58s
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
|
||
|
|
6f5e8e378a |
PKG-15: bring release/1.0 to master at 0.10.0, so the default branch is the add-on
`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 |