FCST-04
100
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b59f5ce31b |
FCST-04: the carry-forward stops decimating a quarter-hourly feed, and a broken cache stops stopping the add-on
checks / unit (pull_request) Successful in 7m22s
Rework of the FCST-04 branch against review. Every finding below was proven by a runnable script before it was touched and re-run after; every check added or repaired was driven red by a mutation, `__pycache__` purged first (MUTATION.md, TEST-15). 29/29 test files green. ⚠️ H1 - A SUB-HOURLY FEED WAS DECIMATED TO ONE POINT AN HOUR, AND THE SPREAD WENT WITH IT. `fill_from_yesterday()` keyed the published set as `{_utc_hour(p.start): p for p in r.points}` - LAST POINT OF THE HOUR WINS, the other three dropped. `eneco_eddy` is the Belgian imbalance product and settles per QUARTER, and `_validate_raw()` accepts any strictly-increasing spacing, so this is a shape the product supports today. Measured on a 96-point quarter-hourly feed - ALL FIRM, FULLY PUBLISHED, nothing to carry: the plan reached the optimiser as 15 points, the arbitrage spread fell from 0.143 to 0.014 EUR/kWh, and the cheapest "hour" became a `:45` point whose `at()` window runs into the next one. Not the windowing effect this card is about; a feed losing 84 % of its own prints, with no flag anywhere reporting it. The fill now decides only WHICH WHOLE HOURS NEED CARRYING: every published point inside the window passes through BY IDENTITY, and a carried point is added only for a whole hour with no published coverage at all. Same feed, same fixture: 60 of 60 firm points kept, spread 0.134 over the hours still ahead. ⚠️ AND `_audit()` GOT STRICTER, NOT LOOSER - that was the condition on doing it at all. The old post-condition refused any repeated `_utc_hour`, a count that cannot express four points in one hour. Two rules replace it and SUBSUME it: a published point must BE one of that hour's published points by identity and may be emitted only once; a carried point may stand only in an hour absent from the published set, must be flagged True, and may not repeat. So the duplicated carried hour it existed to catch still raises, the same published point emitted twice now raises, and a carried point sharing an hour with a real print now raises too - which the count never looked at. Three mutations, three reds. ⚠️ H2 - A DAY RECORD THAT WAS NOT A RECORD STOPPED THE ADD-ON FROM STARTING. `_load()` validated `version` and that `days` is a dict, never that each VALUE is one. `{"version": 1, "days": {"2026-01-14": "oops"}}` loaded as ok=True, last_error=None, and then `rec.get(...)` raised AttributeError out of `words`, `yesterday()` and `resolve_tariff()` alike. `PriceHistory(...)` and `.words` are both unguarded in `Controller.__init__`, so the controller did not come up - and it never self-healed, because `_remember()` raised on the same record before it could reach `_save()`. That falsifies the CHANGELOG's "A BROKEN CACHE IS NOT A BROKEN CONTROLLER" in the one direction that matters. The asymmetry that produced it is visible in the file: `_parse_points()` is carefully defensive about every field of a POINT and `_load()` was not defensive about the record holding them. Unusable records are now dropped and REPORTED (`words` says how many and why), and `words` is total by construction as a second line. M3 - `yesterday()` WAS OFF BY ONE DAY FOR 00:00-00:59 AFTER SPRING-FORWARD. `brussels()` renders at the FIXED offset for that instant, so `brussels(stamp) - timedelta(days=1)` subtracts 24 ABSOLUTE hours - and the Brussels day before the March transition is 23 hours long. At 2026-03-30T00:30+02:00 it returned 2026-03-28's prices as "yesterday", or `no_data` naming a date that is not yesterday. The arithmetic is now on the local DATE, which has no offset to lose, and both transitions have a check - the module's headline claim had none. M4 - WITH NO /data THE STORE STILL FILLED, IN MEMORY. `_save()` short-circuited on `not ok` while `_remember()` populated `self._days` anyway: `words` said "unavailable" and `yesterday()` answered A SERIES that is gone on the next restart. The module docstring rejects exactly that ("a yesterday that silently forgets") as its reason for not calling makedirs, so the code now matches the docstring rather than contradicting it. M5 - THE PRUNE MUTATION SURVIVED BECAUSE THE STIMULUS WAS DEGENERATE. §8's clock-jump check seeded ONE day, so `if len(self._days) <= KEEP_DAYS: return` fired and the body it is named after never ran: pruning by arithmetic against the clock - which empties the store on exactly the morning the fill is for - passed the whole 29-file suite. The fixture is now written to a file and reopened (`_load()` does not prune, which is how a store legitimately holds more than the cap) with KEEP_DAYS + 2 days, and the mutant is red. M6 - THE AST ORDERING CHECK COULD NOT CATCH A REVERSAL, AND TWO DOCUMENTS CITED IT AS A GUARANTEE. `_calls.index("_remember") < _calls.index("yesterday")` grades `ast.walk` BFS DEPTH: `self._remember(...)` is a statement and `self.yesterday(...)` is an ARGUMENT of the fill, one level deeper, so `_remember` came out first whichever statement was written first - shipped and reversed both scored True. It is now line numbers AND, the half that matters, the behaviour: a feed carrying yesterday alongside today into an EMPTY store must fill the whole horizon; reversed, the lookup finds nothing and the plan is only the published hours. Reversing the two statements is red on both. L14/L9 - KEEP_DAYS = 3 IS THE STEADY STATE EXACTLY, so a newest-N cap has no slack: a feed publishing to D+2, or ONE stray point in the wrong year - which sorts LAST and can never age out - pushes yesterday out for good. `_prune()` now PINS the two days a lookup can actually ask for on top of the cap. Pinning is additive, so it can only RETAIN and the WITNESS-03 property (nothing is deleted by clock arithmetic) is preserved by construction; `stamp` stops being a dead parameter; the store is bounded at KEEP_DAYS + 2. L8 - the horizon end was not floored while `since` was, so a 48 h request made at any instant off the hour returned 49 points. §1's count was green only because its NOW was exactly :00:00, a shape production never has; the fixture now sits at :17:43. L10 - a failed `_save()` leaked its temp file; at 48 writes a day a persistent write failure leaked 48 inodes a day onto the volume that was already the problem. L15 - `{"version": 1.0}` passed, since `1.0 == 1`. L16 - the `_remember` tree walk scanned `app/*.py` only and matched a bare string; it now parses `app/`, `tools/`, `scenarios/` and `estop/` and looks for the NAME, so a comment does not fail it and a call in `tools/` cannot pass unseen. CLAIM-ACCURACY DEFECTS, WHICH THIS REPO TREATS AS REAL: * L7. DOCS said "*Tariff* on the status page reads `provisional` through the morning". FALSE - `tariff_words` is assigned only from the HA price-entity branch and from `static_status()`, both on the 30 s tick, and the plan's own sentence reaches `_LOG.info`. The doc now says what an operator can see (the Price history row, the Arbitrage row's own refusal sentence, the refresh log line) and a check pins the PAIRING - wiring the plan into the row is fine, correcting the doc is fine, doing one without the other is red. * L11. "fills EVERY unpublished hour" is conditional: a partial yesterday leaves the local hours it never held out of the series (§11.4 for those hours, and `at()` raises rather than answering). Correct behaviour, overclaimed wording, and untested - all three fixed. * L12. DECISIONS §1 leaned on `TariffPlan.cheap_now()`, which has NO PRODUCTION CALLER on the provider plan (`self.cheap` comes from `static_status()`, which builds its own). The conclusion holds on `arbitrage()`'s firm-pair refusal alone, which does reach control; the evidence is now stated at the reach it has. * L13. §1's "passed through by identity" check compared `eur_per_kwh` between two SEPARATE `resolve_tariff()` calls, where `is` could never have held. The value comparison is kept and now says what it grades; identity is graded on a read the file constructs and still holds. * L17. `PriceHistory.words` and the new status row had no check at all - and `words` is one of the two things H2 crashed. Both now have one. * L18. main.py's inline note carried the "widens what the optimiser can SEE, never what it may act on" guarantee without the windowing caveat that the CHANGELOG, DOCS and `resolve_tariff()`'s docstring all carry. It is the one place a maintainer reads it unqualified. EVIDENCE. All eight reviewer proofs re-run: p1/p1b return 29 March, p4 keeps 60 of 60 firm points at spread 0.134, p5 starts and reports, p6 D leaves no temp, p6 E refuses 1.0, p6 F records nothing, p7 returns 48 for 48. p2's script still scores True/True because it re-implements the OLD algorithm - the reversal is now driven as a mutation instead, and is red. p3's script always showed the shipped code correct; what was wrong was the fixture, and its mutant is now red. Twenty mutations driven, no survivors, tree fingerprinted and `__pycache__` purged before each: decimation (2 suites red), audit x3, unfloored `until` (2), unvalidated day records (7 checks), instant-subtraction yesterday, in-memory fill on a volume-less store, clock-arithmetic prune, cap without the pin, leaked temp, loose schema type, empty `words` (5), reversed `carry()` (2), a `_remember` leak in tools/ spelled two ways, a neighbour-filling fallback, a copy instead of identity (2), the DOCS claim in both directions, and the caveat removed from main.py. Baseline and the unmutated tree both green. No option added, config.yaml untouched, and the CHANGELOG entry still picks no version: PKG-07's rule, and only the deployer knows whether 0.9.0's image has been pulled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
0618194d9c |
FCST-04: the carry-forward fill reaches the plan, and the day it carries survives a restart
checks / unit (pull_request) Successful in 7m11s
FCST-03 shipped `fill_from_yesterday()` complete and checked, and shipped the consumers written for it - `TariffPlan.firm_points()`, `cheap_now()`'s refusal of a provisional hour, and `arbitrage()`'s refusal 3, which drops provisional hours from the trade set rather than refusing the whole series. It shipped NO CALLER. Nothing in `app/` named the fill, so no series ever contained a carried point and every one of those consumers was exercising its all-firm branch. ⚠️ THE DEFECT THAT PRODUCED WAS NOT AN ERROR ANYWHERE. `load_prices()` returns what the feed published and nothing widened it to the requested horizon, so on a dynamic site between midnight and the 14-16 h CET publication the plan covered the rest of today and nothing more - measured on the fixture here, 24 h against a 48 h request. `arbitrage()` then took `max - min` over that truncated window and reported a spread. The number was real, the window was half the one configured, and nothing said so: tomorrow evening's peak, the hour a morning charge would be sold into, was not in the series to be compared against anything. A missing hour is not a wrong price - it is an hour the optimiser never learns exists. WHAT THIS ADDS. `app/price_history.py` keeps the PUBLISHED prices of the last KEEP_DAYS Brussels days in /data/price_history.json; `resolve_tariff()` gains a required keyword-only `history` and fills every unpublished hour of the horizon from yesterday's same-wall-clock hour, flagged. Persistence rather than a variable on the refresh task, because the window the fill exists for is midnight -> publication and an add-on restarted at 08:00 would otherwise spend all of it on the truncated series. Not in state.json: the refresh runs every 1800 s and that file holds the maintenance history and SAFETY-02's latch. ⚠️ ONE CHANGE HERE MOVES ENERGY, AND IT IS NOT THE ONE THE CARD IS NAMED FOR. Carried hours are provisional, so §8.3 keeps them out of the trade set: the fill widens what the optimiser can SEE, never what it may DO, and test_price_history.py checks that so it cannot be overclaimed later. What does change a trade is that the filled series is windowed to [since, until), so hours that have ALREADY ELAPSED are no longer in the spread. Before, a cheap night that had gone sat in the series as the day's cheapest hour - and a cycle cannot buy in an hour that is past, so the trade was quietly never taken. Section 2 grades this by finding the cheapest firm hour with and without the fill and showing it MOVES, from 02:00 UTC to 08:00 UTC on the fixture. ⚠️ A DEAD FEED IS STILL §11.4, NOT YESTERDAY. The fill runs only on a `series` read. The two are indistinguishable to the optimiser - both 100 % provisional, so arbitrage refuses on the firm-pair rung and `cheap_now()` is False - so preferring yesterday's feed over the `static_profile` an operator configured FOR that case would change a documented degradation for a difference only the status page can see. That, and two more, in DECISIONS/FCST-04-the-day-that-is-carried.md. ⚠️ THE ANTI-DECAY RULE IS AN ORDERING, NOT A VALIDATION, and saying so is more useful than a guarantee that would not hold. Feeding the filled series back in is how this rots - day 2 carries day 1's guesses, day 3 carries day 2's carry of them, behind a `provisional` flag that stays TRUE while the NUMBER decays. No value-level check can catch it: a carried point and `eneco_eddy`'s published-provisional quarter are the same three fields with the same flag. So `carry()` is the only public entry, it records BEFORE it fills, the filled series exists only as a return value the object never sees again, and test_price_history.py walks app/ for any other module naming `_remember` and reads `carry()`'s AST to pin the order. EVIDENCE. 29/29 test files pass in the CI image. Eight mutations driven against app/price_history.py and app/static_tariff.py, __pycache__ purged before each run per MUTATION.md/TEST-15: newest-record lookup (5 red), recording the filled series (1), replace-instead-of-merge (10), UTC-date keying (10), removing the fill call entirely (12), a lost price field defaulting to 0.0 (2), prune by clock arithmetic (1), a degraded reason that stops naming its cause (1). No survivors; baseline and revert both green. Three of those first crashed the harness instead of reporting a FAIL - "it was refused" and "it broke on the way to being refused" must not grade the same - so `why()` and `_priced()` were added and the run repeated. No option added, config.yaml untouched, and the CHANGELOG entry deliberately picks no version: PKG-07's rule, and only the deployer knows whether 0.9.0's image has been pulled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
3de4453956 |
INSTR-01: match the finding KIND, because M12 was not holding this file to its own rule
checks / unit (pull_request) Successful in 7m7s
Review APPROVED |
||
|
|
f6f3f227c1 |
INSTR-01: restore M12 on the leg I generalised past, and measure the clause that had no row
checks / unit (pull_request) Successful in 7m6s
Review found two things this PR asserted about itself that were false. Both are mine and both are worse than the defect the card was filed for. ⚠️ "NO FIXTURE CAN RESTORE IT, MEASURED BOTH WAYS" WAS NOT TRUE, AND TWO MEASUREMENTS ARE NOT AN IMPOSSIBILITY PROOF. I deleted M12 - SAFETY-44's working end-to-end control for the off-roster narrowing - on the argument that the provenance clause made it unrestorable. The structural claim behind it, that `run()` builds both snapshots from one state file so "already latched" and "made by this transition" are incompatible, IS TRUE OF SAME-STEP AND FALSE OF STAGED: `snapshot_over()` starts the after-leg over the file the BEFORE-leg wrote, so a transition-made destination is available there. Both arrangements I measured were same-step shaped. Review found the third. RESTORED, and it discriminates with all six clauses in: staged leg, legacy+shed, shipped _m12_new = VANISHED unit/shed/sign_tripped ...C5 narrowing removed _m12_new = [] ⚠️ suppressed ⚠️ AND MY FIRST RESTORATION DIDN'T DISCRIMINATE EITHER, for a reason worth recording: `data.pop(k, False)` RETURNS the value, so shed lost the KEY and kept the LATCH - `_grade_store_migration()` saw `on_roster_latched`, never re-armed garage, and there was no transition-made destination. The finding was reported whether or not C5 existed and the control looked restored. `and False` is the difference between "the key is gone" and "the LATCH is gone", and only the second reaches the re-arm this arrangement needs. ⚠️ WHAT ACTUALLY BROKE WAS M12's exit-0 PRECONDITION, NOT M12. A legacy-shaped fixture is never clean. That precondition is the one SAFETY-44's own commit called "belt-and-braces, load-bearing only if the exit-0 requirement is ever relaxed", saying what closed the gap is `_m12_new` BEING A SET DIFFERENCE - and a stable dirty baseline is fine for a set difference. So I deleted a control on the strength of a precondition the same commit had already called not load-bearing. That is removing coverage rather than failing to add it, which is the worse of the two directions. ⭐ AND THE BARE-KEY CLAUSE HAD NO ROW AT ALL. Review deleted `k.rsplit("/", 1)[-1] == bare` and the whole 28-file suite stayed GREEN. 5e mutated five clauses; the comprehension carries more, and this one is load-bearing: a vanished SAFETY-02 `sign_tripped` was suppressed by a SAFETY-42 `delivery_tripped` on a roster unit - a DIFFERENT FAILSAFE's key. The rule's own comment says "THE TEST IS NOT 'the name appears somewhere'. It is: the SAME bare key", and nothing measured that sentence. `unit/*/delivery_tripped` is machine-read, so it is reachable on any real two-latch store. R7 added; C2 now flips exactly R7. All six clause-removals now flip exactly their own row: C1 machine_read -> ['R6-machine-read'] C5 off-roster -> ['R2-narrowing'] C2 same bare key -> ['R7-bare-key'] C6 injectivity -> ['R5-injectivity'] C3 same value -> ['R4-value'] C7 provenance -> ['R3-provenance'] THREE DANGLING CITATIONS TO A CONTROL THIS PR HAD DELETED, one of them in the shipped tool eight lines below the paragraph condemning exactly that ("a citation is the worst possible carrier, because it reads as having been checked"). The tool no longer names a check; it states the property - every clause has a row, and each removal flips its own. A STALE SAFETY-44 BLOCK saying both fixes were "REVERTED OUT OF THIS CARD, because THIS SUITE CANNOT MEASURE EITHER ONE" - describing a tree this PR replaced. Rewritten as a map of where each clause is now measured. A comment that outlives its subject is the same defect as a citation that outlives its check. AND A SELF-CONTRADICTION about the driver, in the file and in the last commit message: "THE DRIVER IS WRITTEN INTO THE TREE UNDER TEST" eight lines above "THE DRIVER GOES IN A TEMPDIR, NOT IN THE TREE". The code does the tempdir; the first sentence stated the read-only-CI bug the same commit says it fixed, as if it were the design. It now says the driver IMPORTS the tree and is WRITTEN to a tempdir. ONE HOLE REMAINS OPEN AND IS STATED AS UNMEASURED: attribution. `sorted()` decides which vanished key claims a destination, so a deleted key can sort first and be named as the one that migrated while the key that really moved is reported VANISHED. Review measured that `sorted(` -> `list(` leaves the suite green - every 5e case has at most one candidate, so no row can see ordering. Still exit 1, so a wrong sentence rather than a hidden loss. Recorded rather than left looking guarded. 28/28 files pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
91b0d5e4dc |
INSTR-01: grade the suppression rule at the level it is written at, one case per clause
checks / unit (pull_request) Successful in 7m4s
SAFETY-44 produced THREE inert controls for this one rule, each inert differently, and split the two remaining fixes out rather than ship them unmeasured. This is that card, and the first thing it did was explain why the end-to-end route could never have worked. ⚠️ `moved_to` IS ONE COMPREHENSION CARRYING FIVE CONDITIONS WITH FIVE DIFFERENT REASONS TO EXIST, AND `run()` BUILDS BOTH SNAPSHOTS FROM ONE STATE FILE. So "the destination was already latched" and "the destination was made by this transition" are the same file answering two incompatible questions: the arrangement that reaches one forecloses the other. That is why rebuilding a fixture to reach clause N kept moving the others, and it is not a fixture problem. ⭐ SO THE RULE IS GRADED WHERE IT IS WRITTEN - `diff()` on constructed Snapshots, ONE CASE PER CLAUSE. Six rows, and on the SHIPPED tree before any fix they already separated the two known defects: R1 rename (legitimate) migrated correct R2 narrowing vanished correct R3 provenance MIGRATED ⚠️ the defect R4 same value vanished correct R5 injectivity BOTH migrated ⚠️ the defect R6 machine_read vanished correct The two one-line fixes SAFETY-44 reverted are back, and R3/R5 flip while R1/R2/R4/R6 do not - so the legitimate rename is still suppressed and this is not an over-correction. ⭐ AND EACH CLAUSE REMOVAL FLIPS **EXACTLY ITS OWN ROW**, which is the acceptance criterion this card was filed with: C1 machine_read removed -> ['R6-machine-read'] C3 same value removed -> ['R4-value'] C5 off-roster removed -> ['R2-narrowing'] C6 injectivity removed -> ['R5-injectivity'] C7 provenance removed -> ['R3-provenance'] A removal that flips nothing is an unmeasured clause; a removal that flips someone else's row means the cases are not separable after all. Neither happened. ⚠️ AND M12 IS DELETED, WHICH IS THE MOST USEFUL THING THIS CARD FOUND. M12 was SAFETY-44's end-to-end control for the narrowing and it was a REAL control - red when the narrowing was removed. Adding the provenance clause disarmed it, and NO FIXTURE CAN RESTORE IT, measured both ways: with the sibling latched in its own right, C7 rejects the destination before C5 is consulted, so the mutation is reported whether or not C5 exists; making the destination transition-made means a flat latch, which makes the shipped run dirty and breaks M12's own precondition. The second half of the card's acceptance criterion - "M12 still turns red with the new clauses in" - is therefore UNSATISFIABLE, and the honest answer is to remove the control rather than keep one that has become inert. R2 measures C5 strictly better. Part 5 keeps the end-to-end coverage; discrimination lives in 5e. `state-both-latched.json` goes with it. Nothing referenced it once M12 was gone, and the first draft of that paragraph said it was "kept, because 5e's R2/R3 are derived from the arrangements it represents" - a nice sentence and not a reference. An unused fixture a comment calls load-bearing is an inert check one directory over. TWO THINGS THE DRIVER GETS RIGHT ON PURPOSE: - it is written to a TEMPDIR, not beside the tool. `checks / unit` mounts the checkout READ-ONLY, so writing into the tree works on a dev box and is `OSError: Read-only file system` in CI - green for whoever wrote it and red on every PR. Caught by running it in the check image before committing. - the TABLE stays in the test file and the driver goes into the tree under test, so a mutation to the tool cannot also move the answer it is graded against. 28/28 files pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
775f3a2e82 |
SAFETY-44: keep attribution in-tree, add the third clause, and correct a claim I made for my own precondition
checks / unit (pull_request) Successful in 6m56s
Review APPROVED |
||
|
|
f109153216 |
SAFETY-44: ship only what this suite can measure, and split the two clauses it cannot
checks / unit (pull_request) Successful in 6m58s
⚠️ REVIEW FOUND THE INJECTIVITY CHECK INERT IN THE SAME WAY M12's FIRST DRAFT
WAS, three paragraphs below the comment diagnosing it. Removing the whole
mechanism left 28/28 GREEN: part 5's two destinations are `sign_reason` and
`sign_tripped`, DIFFERENT bare keys, which can never contend. The
`len(_dests) >= 2` precondition was true and established nothing - "not
asserting over an empty list" was correct, "this run can exercise injectivity"
was not.
Review also measured a second, narrower silent loss: `moved_to` accepts a
destination regardless of whether the TRANSITION put the value there, so a
roster unit already latched in its own right is a free suppression slot. A build
deleting `old-a` while renaming `old-b` -> `shed` reported exit 0, NO FINDINGS,
with `old-a` "migrated" into a key that already held True.
⚠️ I WROTE BOTH ONE-LINE FIXES AND HAVE REVERTED BOTH, because THIS SUITE
CANNOT MEASURE EITHER. Three attempts, three different ways of proving nothing:
1. assert injectivity over part 5's real destinations
-> passes with the entire mechanism deleted (non-contending bare keys)
2. build a dedicated fixture for the two-claimant case
-> discriminates NOTHING: both snapshots read one state file, so nothing
vanishes and there are no migrated lines with the clause or without it
3. rebuild M12's fixture so the destination is transition-made, to reach the
provenance case
-> ⚠️ DISARMED M12 FOR THE NARROWING. All three clause-removals came back
GREEN at once, and the control I had measured RED last round was gone.
⭐ (3) IS WHY THIS IS A SPLIT AND NOT A JUDGEMENT CALL. A later fix to the same
function silently disarmed the control for an earlier one, and I saw it only
because I re-drove the removal rather than trusting the suite's exit code.
Shipping two more unmeasured clauses into the rehearsal instrument, on a card
whose entire subject is checks that cannot reach what they name, would be the
fourth inert artifact in one PR. They go on their own card, with review's
measurements as the starting evidence and the fixture built BEFORE the fix -
which is the ordering that worked for M12 and failed every time I inverted it.
⚠️ AND THE SPLIT IS SAFE, WHICH IS WHY IT IS AVAILABLE. Both remaining holes are
in the REHEARSAL TOOL, not the controller. The round-2 narrowing guarantees a
suppressed key is always OFF the roster, so no roster unit loses protection;
what is left is rollback-view/history mis-reporting on a tool an operator runs
before commissioning. Nothing here can disarm a latch on a live site, and
inverter B goes in on ~2026-09-12. Review offered this split explicitly.
M12's PRECONDITION IS STRENGTHENED, because the exit code could not carry it.
It now requires the shipped tree to be clean AND to not already report the
finding the mutation exists to produce. The first draft passed on exactly that
gap: the fixture's own litter made the run red and nothing separated the two.
Re-driven after the revert, on the round-3 fixture:
shipped exit 0
`vanished_unit not in after.units` gone ⚠️ exit 1
28/28 files pass. The controller half is unchanged since
|
||
|
|
f6d7bfcb04 |
SAFETY-44: give the moved-not-lost branch a control that reaches it, and make the move injective
checks / unit (pull_request) Successful in 7m38s
⚠️ THE CONTROL I WROTE LAST ROUND WAS INERT, AND REVIEW MEASURED IT. M12 ran its
mutant over `state-legacy.json` and the findings diff against the UNMUTATED tree
was EMPTY. It "passed" because that fixture's STAGED leg is already exit 1 on
pre-existing litter, and `"migrated unit/shed/" not in out` was trivially true
because a pre-DEV-02 FLAT store has no shed latch to lose - after migration only
the FIRST roster entry is ever latched.
The decisive measurement is the one it could not make: reverting the
`vanished_unit not in after.units` narrowing - restoring exactly the rule that
hides a real latch loss - left 28/28 GREEN.
⚠️ SO IT WAS HABIT 4 WEARING THE CLOTHES OF ITS OWN FIX, AND WORSE THAN THE
"NO CONTROL" IT REPLACED, because it reads as checked. That is this commit's
own argument about the M3 mis-citation, turned on the thing I wrote to answer it.
⭐ THE FIXTURE CAME FIRST THIS TIME. `state-both-latched.json` is a store at
SCHEMA_VERSION with `unit/garage/*` AND `unit/shed/*` both latched by ONE site
event, so the reasons match too - the arrangement in which same-value
suppression is most dangerous, and the one no existing fixture could produce.
M12's mutant then makes ONE roster unit's latch key VANISH while its sibling's
survives at the same value:
shipped tree, both-latched fixture exit 0, no findings
one unit's latch key deleted exit 1, ADDS
VANISHED STORE KEY unit/shed/sign_tripped
narrowing reverted ⚠️ RED (was 28/28 green)
And it asserts the PRECONDITION - the fixture is clean on the shipped tree -
plus that the mutation ADDS findings rather than coinciding with ones the
fixture already had. An empty diff there is exactly what made the last draft
inert, and nothing said so.
⭐ AND THE MOVE IS NOW INJECTIVE, which is the narrower hole review found still
open. `moved_to` is "any machine-read key with the same bare name and the same
value", and `DEFAULTS["sign_tripped"] is False` means `True` is the ONLY
non-default value a latch can hold - so N vanished keys could all claim the SAME
destination, by construction rather than by coincidence. Measured on a rename
carrying `inverter-a` -> `garage` correctly while DROPPING `inverter-b` -> `shed`:
migrated unit/inverter-a/sign_tripped = True -> unit/garage/sign_tripped
migrated unit/inverter-b/sign_tripped = True -> unit/garage/sign_tripped
Zero store-key findings, on a run where `shed` came up UNLATCHED. Two keys
cannot both have become one key. `diff()` claims each destination once.
⚠️ AND THE CHECK FOR IT IS ANCHORED TO A RUN THAT ACTUALLY PRODUCES MIGRATED
LINES. A dedicated mutant was written first and asserted over an EMPTY list -
vacuous, passing, indistinguishable from working, the same defect twice in one
sitting. These fixtures cannot produce two claimants (`migrate_unit_keys()`
moves FLAT keys only, so one flat latch yields one migration), so rather than
build a fiction to grade, injectivity is asserted over part 5's real migrations,
with a PRECONDITION that there are at least two of them. THE TWO-CLAIMANT
ARRANGEMENT IS A STATED RESIDUAL: bounded in `diff()` by construction, not
measured end-to-end.
Finding 4, and my correction to it was also wrong. The comment cited "line 315"
and "two hundred lines below"; the fix I wrote said "~54 lines down", which was
review's distance to the WRONG line, carried to a new referent without
re-measuring - inside the sentence apologising for a wrong number. Recomputed at
|
||
|
|
d1b7e3f113 |
SAFETY-44: decide per key, because one shared flag let an unrelated latch veto the repair
checks / unit (pull_request) Successful in 6m58s
⚠️ REVIEW DEFEATED THIS CARD'S OWN FIX WITH ONE ADDED KEY, and the project's own instrument agreed on the unmodified tree. The guard classified each key and then wrote ONE site-wide `namespaced_latched` for BOTH "on the roster" and "off-roster but not UNIT_A", so a single latched key belonging to a unit the operator had removed from `inverters:` vetoed the repair of the misdirected `inverter-a` latch beside it. Measured, roster ['garage','shed'], store stamped so the migration fix cannot reach it: sign_tripped + unit/inverter-a/sign_tripped -> [('garage', True), ...] ...AND unit/old-shed/sign_tripped -> ⚠️ [('garage', False), ...] A site DRIVING on a state file that reads `sign_tripped: true` - the sentence in this card's own first paragraph - and `tools/rehearse_roster.py` printed `LATCH CAME UP DISARMED` on it, exit 1, against shipped code. It matters more than a normal hole because this guard is, as main.py says, the ONLY repair path for an install already stamped by 0.9.0. ⚠️ AND IT IS INSTANCE #17 OF HABIT 4, IN THE EXTENSION MUTATION.md ADDED TWO DAYS AGO. The two fixtures written for this card each hold ONE arrangement - `_staged_store` has only `inverter-a`, `_other_store` only `inverter-b` - so the flag was never written twice with opposite intents in a single store. A per-key check whose keys have never disagreed WITHIN ONE STORE is a check over whichever key came last. Three variables now, one per case, and a fixture holding BOTH. Four cases driven: stranded-only repairs, +1 unrelated repairs, +2 repairs, off-roster-only does NOT (which is DEV-02's control, still green). ⭐ AND THE MOVED-NOT-LOST RULE HID A GENUINE LATCH LOSS. The match was bare key plus value over the whole of `machine_read`, with no identity - and `DEFAULTS["sign_tripped"] is False`, so the ONLY non-default value a latch can hold is `True`. Any surviving latched roster unit therefore suppressed any other unit's vanished latch. Review measured a build that drops `unit/shed/sign_*` while `unit/garage/sign_*` survives, both latched by one site event so the reasons matched too: none in LEG SAME-STEP: nothing vanished, nothing stranded ... NO FINDINGS in the rehearsed transitions exit 0 A roster unit's persisted SAFETY-02 latch deleted, transition reported clean. The legitimate case is a RENAME - the key left `inverter-a`, which is not on this roster, and arrived under `garage`, which is - so the vanished key's own unit must be OFF the roster. A key whose unit is still on the roster has not been renamed, it has been lost, and no amount of the same value elsewhere makes that untrue. ⚠️ AND THE BOUND I CITED FOR IT DID NOT EXIST. Both the comment and the last commit message said "what M3 (delete the flat rollback view) measures". M3 is not that mutant - it drops `sign_reason` from `LEGACY_UNIT_KEYS` - and the one I meant, M9, vanishes only FLAT keys, so neither can satisfy the `unit/` gate the rule sits behind. NEITHER EXERCISED THE BRANCH; it had no negative control at all, in a card whose subject is checks that cannot reach the failure they name. A citation is the worst carrier for that, because it reads as having been checked. M12 is written for it and driven: a roster unit that stops restoring its latch is exit 1, and the suppression does not swallow it. `misdirected` IS UNREACHABLE FROM THE CALL SITE, and the claim that it was a benefit is withdrawn rather than restated. `unit` is `roster_names(opts)[0]` and `build_roster()` names its first entry from the same list, so `unit` is a member of `roster` by construction; review measured it - dropping the `roster` argument inside `migrate_unit_keys()` survives 28/28. The argument stays for the reason `_verify_moves()` gives for its own `roster=None` branch, it is what a NEW caller gets - and a guard nothing exercises is a guard nobody knows works, so it is now exercised at the function's own level: a wrong `unit` comes back misdirected, the marker is not stamped, and the same call with a roster name stamps it. Dead `if _roster_names else UNIT_A` removed; `roster_names()` never returns []. THE OPERATOR MESSAGE IS ASSERTED. Review deleted it whole (`if stranded:` -> `if False:`) and 28/28 stayed green - `stranded` had no other consumer, so the list existed only to print a line nothing checked, and that line is the entire operator-facing half of the repair. Now: exactly one SAFETY-44 line at ERROR, naming the stranded key and the roster it was measured against. A wrong line citation removed: the call-site comment said "line 315's comment" and "two hundred lines below", and both were wrong (315 is the CFG-03 defaults note; the comment meant is ~54 lines down). Cite the thing, not its coordinates. ⚠️ AND WHAT 5b/5c ACTUALLY PIN IS RECORDED, because review is owed the distinction: `mutant_tree()` asserts its target exists, so EDITING the guarded line makes the file red at the ANCHOR before any behaviour is measured - which looks identical from the exit code and proves much less. It happened twice while writing this commit. The behavioural coverage lives in test_maintenance.py section 13 and test_inverter.py, which build stores rather than strings. Fixture strings no longer say "measured" - they are synthetic, and the repo's other fixture carries an explicit SYNTHETIC header. 28/28 files pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
5e382f52f6 |
SAFETY-44: the latch migration takes the roster, and the guard stops being defeated by the key the defect wrote
checks / unit (pull_request) Successful in 6m56s
⚠️ A DISARMED SAFETY-02 GUARD ON AN INSTALL WHOSE STATE FILE SAYS IT IS PROTECTED. `Controller.__init__` called `migrate_unit_keys(store, UNIT_A)` - a hardcoded constant - while its sibling `migrate_maint_keys(store, unit, roster)` takes the roster and refuses a write no machine reads. On a store that ran pre-DEV-02 flat and declares `inverters: [garage, shed]`, the latch moved to `unit/inverter-a/sign_tripped`, `_verify_moves()` read it back under the name it had been HANDED, was satisfied, and stamped the marker. Not one inverter came up latched, and the next start skipped the migration too. That is the MAINT-05 defect verbatim, still live in the function whose own docstring describes it, and `_grade_store_migration()` did not catch it: its "is anything namespaced latched" test was satisfied BY THE MISDIRECTED VALUE ITSELF. ⚠️ THE REASON IT WAS LEFT WAS A HALF-TRUTH. "There is no roster yet - the `Inverter`s restore their latch in their constructors." The KEYS must be written before the `Inverter`s read them; the NAMES do not come from the `Inverter`s. `roster_names(opts)` reads them off the options before anything is constructed - which is exactly what it already existed to do for `MqttPublisher` - and returns `[UNIT_A]` on the flat shape, so today's upgrade path is byte-identical. TWO EDITS, AND THEY DO DIFFERENT JOBS. Measured per leg by reverting each one: SAME-STEP leg STAGED leg both edits (shipped) clean latch armed, litter reported guard reverted latch armed ⚠️ DISARMED call site reverted latch armed, litter latch armed, litter So the GUARD carries the safety and the CALL SITE carries the hygiene and the withheld marker. The table is in test_rehearse.py part 5 because a reader who assumed the roster-aware migration re-arms the latch would delete the guard as redundant in a tidy-up and take the STAGED column with it - and STAGED is what "upgrade now, declare the roster later" produces, which is the path an operator actually takes. Only the guard reaches an install already stamped by 0.9.0. ⚠️ AND "OFF THE ROSTER" ALONE WAS TOO BLUNT, WHICH test_inverter.py CAUGHT. The first draft treated every off-roster latch as litter. In DEV-02's restart case the store carries `unit/inverter-b/sign_tripped = True` from B's own trip while the roster being graded holds only A - and the blunt rule re-armed a HEALTHY A off the flat key B's trip had mirrored. DEV-02's defect rebuilt inside its own guard, which is what the paragraph I was overruling had been right to fear. Narrowed to `UNIT_A`, which is the only name the broken migration ever wrote - `roster_names()` falls back to a POSITIONAL `inverter-N`, so `inverter-a` is not a name this code hands to anyone else. A latch stranded under any other name is a real unit out of today's config and still counts as protection. Residual, stated in the code rather than left to be found: a site whose own first unit is NAMED `inverter-a`, latched, then taken out of the roster gets re-armed on `inverters[0]` - a healthy unit held at 0 W until an operator clears it. The conservative direction, against a site DRIVING on a file that says it is latched. ⭐ AND FIXING THE CODE EXPOSED A FALSE POSITIVE IN THE HARNESS. With the latch now landing under `garage`, `rehearse_roster.py` reported VANISHED STORE KEY for `unit/inverter-a/sign_tripped` three lines above the same value sitting under `unit/garage/sign_tripped`. A key that moved to a namespace a machine reads has not vanished - and an alarm that fires on the harmless case is one an operator learns to dismiss before the real one arrives, which is CONTROL-C's own argument. The test is not "the name appears somewhere": same bare key, same value, under a key in `machine_read`, the set built by driving a real start. A latch that genuinely went missing has no such key and is still a finding, which M3 (delete the flat rollback view) still measures red. Test parts replaced deliberately, as both of them asked to be: - test_rehearse.py part 5 asserted the defect "as REQUIREMENTS so that a fix turns them red". It now asserts the fix, with 5b and 5c as negative controls - one per edit - and 5b doubles as the leg-splitter's positive control, since reverting the guard is the build where the two legs DISAGREE. - test_maintenance.py section 13 was headed "RECORDED, NOT FIXED" and said the fixing card "should expect them to go red and should replace them". Replaced, plus two cases it could not have had: the already-stamped store, and the `inverter-b` negative control for the narrowing above. 28/28 files pass. Not run: nothing - this is host-side only, no rig, no hardware. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
22ca341459 |
ARCH-02: assert the invariant, because a fixture cannot enumerate arrangements
checks / unit (pull_request) Successful in 6m55s
Review approved and then attacked part 5b, the control that stands between B1's
defect and a green suite. It got through in three edits:
A1 delete `END LEG` 5b CATCHES it 2 FAIL
A2 ...and reorder the legs 5b BLIND 1 FAIL
A3 ...and reword the closing summary 0 FAIL, "all checks passed"
with a splitter that closed
ZERO legs
⚠️ TWO OF THOSE THREE ARE ORDINARY REFACTORS - reordering report legs, rewording
a summary line - and neither reads as touching a safety check. 5b caught the
unclosed splitter only because the clean leg happened to be printed LAST, and
the backstop that saved A2 was a hardcoded transcription of the tool's own
sentence, so rewording that sentence blinded it too.
FIXED BY ASSERTING THE STRUCTURE RATHER THAN ADDING A FIXTURE: a leg may not
open while one is open, and every leg that opens must be explicitly closed.
Driven - deleting `END LEG` now fails on the FIRST edit, whatever the order or
the wording:
AssertionError: leg 'SAME-STEP' opened while 'STAGED' was still open - the
tool stopped closing its legs, and a splitter that never closes swallows the
closing summary into the last leg.
All three attacks include that edit, so one assertion kills the class. Clean
tree unchanged: 28/28, all checks passed.
⭐ AND THE RULE IN MUTATION.md IS EXTENDED, because review's finding is the
general case of it. It said "a per-thing check that has never seen the things
DISAGREE is a total". Part 5b DOES make them disagree, every run, and was still
blind. So:
...AND ONE THAT HAS ONLY SEEN THEM DISAGREE IN ONE ARRANGEMENT IS A TOTAL
OVER THAT ARRANGEMENT. When a fixture cannot enumerate the arrangements,
assert the invariant they share.
Also recorded, NOT fixed, with the reason: the reference carries `entity_id
unique_id device` and grades all three, but never the discovery TOPIC. Review
measured `homeassistant/... -> ha/...` exiting 0 with no findings while every
announced entity dies. `discovery_payloads()` already returns the topic and
`announced_rows` discards it, so a fourth column closes it - but the prefix is
HA's fixed contract rather than a name this project chooses, so it is not what a
tidy-up rename reaches. Lower LIKELIHOOD than the N1 gap it resembles, not lower
consequence, and it stops being a note the day the topic becomes configurable.
Two stale figures corrected: the tool claimed five migration mutations where
there are nine, and pointed at a DOCS.md heading that does not exist (the real
one is "Before you commission inverter B", verified present at DOCS.md:1577).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
1448bd909b |
SAFETY-43: the no_account switch was graded by nothing
checks / unit (pull_request) Successful in 6m29s
Review's remaining non-blocking finding, and it is B1's shape one level down -
in the fix for B1.
`wedged_loop`'s `no_account=True` was used in exactly ONE place: the cut-out
fixture, where it PROVABLY cannot change the answer, because a pack at its own
cut-out is never asked and the gate is never reached. Review neutered the switch
so it removed nothing, and the file stayed GREEN while printing
...named=(nobody); with account() DELETED: 0/200
with `account()` not deleted. A line asserting a mechanism that did not happen.
⭐ THE SWITCH DOES BITE, ON A FIXTURE THIS HELPER COULD ALREADY REACH. At the
same cut-out with EQUAL weights, `plan()` hands B a 50/50 share, so the account
gate is the only thing keeping it silent. Measured:
cut-out, OPT-09 weights gate on 0/200 gate DELETED 0/200
cut-out, EQUAL weights gate on 0/200 gate DELETED 200/200
Asserted. Driven: neutering the switch now goes RED on exactly that check, and
nothing else.
Two things this buys beyond closing the finding. It proves the switch removes
the gate, so the printed "with account() DELETED" line is about a run in which
something was. And it gives part 8b the end-to-end account-gate control ON A
POSITIVE SHARE that the block previously had to outsource to test_delivery.py
part 1 - the arm is no longer only true, it is locally demonstrated.
Suite: 27 files, all pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
f76639c7b2 |
TEST-21: two of the argmax's four axes are on an edge, and the doc did not say so
checks / unit (pull_request) Successful in 6m22s
§3.3 already set the standard - "a worst case sitting on an edge of the grid
would be a number about the grid" - and then did not check its own argmax
against it. Review did. Reproduced here independently before writing it down:
stagger 0 -> 1 -> 2 -> 3 -> 4 0.0 15.0 25.0 30.0 35.0 s
weights (A) at stagger 4 0.5 -> 20.0 0.7/0.9/0.99 -> 0.0
0.10 -> 35.0 s
Strictly increasing in stagger, maximal at the largest modelled stagger, and
maximal at the most asymmetric split. Interior only in `gain` and `slew_w`.
⚠️ SO THE HEADLINE IS 0.0 s AT stagger=0. The 35.0 s is ENTIRELY a product of
the modelled serialised-write stagger, which is declared MODELLED in the harness
and is a TERM of the figure rather than a detail of it. A site whose two writes
land in the same cycle does not produce this episode at all.
It does not move `delivery_hold_s`: 185 s must cover the worst the modelled site
can produce, and four cycles of stagger is inside what a real two-inverter write
path can do. What changes is that a reader asking "is 35.0 s about the plant or
about the grid?" now gets the answer instead of the invitation.
⚠️ THE PR BODY HAD CLAIMED THE ARGMAX WAS "interior to the grid, not on an edge
of an axis I had to choose". That was wrong, and it is the claim the doc's own
standard exists to test. The grid is the record's own, fixed by §3.3 before this
rebuild ran, which is the mitigating half and is stated too.
Also: `nl.yaml` wrote "35.0 s" beside "5,29 keer" and "75,0 s" in one sentence.
Dutch takes the comma.
Suite: 26 files, all pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
9099ceb1af |
SAFETY-31: the grid is 42, not 90, and the wrong number was in the failure text
checks / unit (pull_request) Successful in 6m27s
Review reproduced every figure in both PRs except one, and rejected on it. ⚠️ "0 of 90 GAINS x SPLITS combinations produce opposing shares" - the SUBSTANCE is right and review re-measured it, but the DENOMINATOR is invented. sitesim.py declares GAINS with 7 entries and SPLITS with 6: the grid is 42. 90 is not even divisible by 7, so no reading of "GAINS x SPLITS" reaches it. It is almost certainly TEST-20`s RUN count (84 + 6), which is a different quantity that happened to be nearby. ⚠️ AND THE WORST COPY OF IT WAS INSIDE THE `PRECONDITION FAILED` TEXT, so the next engineer to see this scenario go red would have been handed an unreproducible measurement as their diagnosis - the exact defect class this campaign exists to correct, in the failure path where it does the most harm. Corrected in all seven places. At the failure-text site the denominator is now `len(GAINS) * len(SPLITS)`, computed rather than typed, so it cannot drift from the grid again - the same fix test_translations.py already took. Also N3: a missing blank line was going to render the S3 and M19 paragraphs as one in DECISIONS. Re-run after the change: `inject.py two_master_circulation` PASS in 35 s, norig, no rig lock taken. Add-on suite 26 files, all pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
295a2aedb6 |
SAFETY-31 AC 4: the alarm's attribution was graded by nothing, and a mutation proved it
checks / unit (pull_request) Successful in 6m27s
`pos, neg = opposing(...)` -> `([], [])` in main.py's `check_circulating()`
(-102 B) deletes the "Right now X is discharging while Y charges" clause from
the operator notification, and SURVIVED ALL 26 FILES. The commit that shipped
this card claimed "18 mutations, all RED"; the ledger has no entry for the
attribution, and this is that entry.
It is not dead code. Ledger M18 - the whole `await check_circulating()` call
removed - is red on four checks, so the function runs. The three checks on the
notification asserted METER CANNOT SEE, CONTROL HAS BEEN STOPPED and `isolator`,
and never an inverter name. `circulation.py`'s own docstring calls that sentence
the first thing somebody woken by this alarm needs and "impossible to
reconstruct from the number alone".
test_watchdog.py section 6a closes it on a fixture that can tell three failures
apart:
* the roster is ["garage", "shed", "cellar"] - operator names, none of them
`UNIT_A`, for test_maintenance.py section 11's reason: on a fixture whose
names are the module's own constants a check cannot tell the roster from
the constant;
* the site is run TWICE, MIRRORED, so the sentence is not symmetric and a
pos/neg swap is a different red from a deletion;
* a third pack rests at 5 W, so the floor that stops a pack in standby being
named as a participant in a 6 kW loop is graded too.
Mutations, on a tree with no __pycache__ mounted :ro into the checks image:
M20 pos, neg = opposing(...) -> ([], []) -102 B RED (2 checks)
M21 pos and neg exchanged +0 B RED (3 checks)
M22 opposing(..., min_w=cfg.min_w/2.0) -> 0.0 -25 B RED (3 checks)
26/26 files green with the block in place.
AND A DISCLOSURE, WHICH IS THE UNCOMFORTABLE HALF. DECISIONS section 3 records
that the 924-run campaign behind `circulation_w: 700` and `circulation_hold_s:
75` used a harness that is not committed in either repository. Two further
sweeps have now been run and no two of the three agree: peaks of 1700 W, 2750 W
and 1048 W, and worst-continuous figures above 700 W of 5.0 s, 15.0 s and 5.0 s.
That is NOT evidence of fabrication and is not reported as any. The committed
sweep measures why the spread is that wide: on one grid, changing only the
plant model and the write-stagger set, the worst legitimate peak is 1048 W, or
2250 W, or 0 W. The plant and the stagger are TERMS of the number. What the
spread does establish is that the claim cannot be adjudicated while the
instrument is missing.
The window argument survives and is now committed - all three sweeps trip zero
times, on 924 + 48 + 840 non-faulty runs. The threshold CURVE does not survive
as stated: `505.0 s at 100 W`, which is what the "knee at 400 W" argument rests
on, is 10.0 s on the committed grid. The curve still falls with the threshold,
so the knee is real in direction; its depth is established by nothing anybody
can run, and this commit says so rather than repeating it. The overclaiming
comment at test_watchdog.py's 1700 W fixture ("the worst single transient the
rig can produce") is replaced with what the fixture can support.
The committed, re-runnable half is admin/goodwe's `two_master_circulation`,
opened as a separate PR against that repository.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
477911afa1 |
UX-04: the 59 was computed and then graded against "> 15"
checks / unit (pull_request) Successful in 6m22s
Review approved and found four things worth fixing. Three are cosmetic; the fourth is the same defect this card is about, one level up. ⚠️ THE FLOOR WAS PRINTED AS 59 AND ASSERTED AS `> 15`. So a manifest change dropping the required count to 20 would have passed while the sentence beside it still said 59 - a number displayed but not graded, which is exactly what let UX-01's AC 3 survive review in the first place, and exactly the shape `test_translations.py` was already fixed for. Now DERIVED and graded in two separate assertions, because they are two different claims: 1. `_EXPECT_REQ = len(schema) - len(_OPTIONAL)`, where optional is Supervisor's own rule - a `?` suffix, plus `inverters` because `UiOptions` marks a list optional - asserted EQUAL to Supervisor's required count. The printed number is computed, so it cannot go stale. 2. ⚠️ AND THE WITHDRAWAL'S PREMISE, SEPARATELY. The equality alone is not enough and I checked why: both sides read the same `?` convention, so marking a key optional moves BOTH and it stays green - measured, by making `slew_w` optional and watching it pass. That grades Supervisor agreeing with us, NOT the thing AC 3 was withdrawn for. The second assertion grades the premise: the floor is still far above the ~15 AC 3 asked for, and if it ever is not, the withdrawal needs re-arguing rather than re-asserting. Driven, both directions, against the real 2026.08.0 image: clean PASS "floor is DERIVED: 71 schema keys minus 12 optional ones is 59, and Supervisor's own required flag agrees" premise threshold 15 -> 40 FAIL "the floor of 59 is still far above the ~40 AC 3 asked for" Also: a dangling reference to `DECISIONS/UX-04-hiding-and-the-deepmerge.md`, which was never the filename shipped, and `DOCS.md` still saying section F "prints" what it now grades. Not changed, and review agreed both are disclosed rather than defects: the pair-keyed ledger cannot see a SECOND site for a pair it already holds (the site count can grow while the pair set cannot), and the probe's assertions run by hand rather than in CI because they need the Supervisor image - the same status as before this card, not a regression. Suite: 27 files, all pass. Probe: all cases passed on ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0, --network none. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
1ba35f9c99 |
PKG-13: one safe.directory flag, not two, and the tighter of the pair
checks / unit (pull_request) Successful in 6m19s
Review found `-c safe.directory={where}` and `-c safe.directory=*` set on the
same invocation, the specific value redundant once the wildcard is present.
Dropped the WILDCARD rather than the specific path, which is the opposite of
the suggestion and the tighter end of it: `_git()` always runs `-C where`, so
the only repository it ever reads is the one it was pointed at, and naming that
one path grants exactly the trust the tool needs. `*` granted trust to every
repository on the machine for the lifetime of each subprocess - harmless for
read-only `log`/`show` with no tty, as review measured, but broader than the
job.
Verified after the change: the documented container invocation still runs BOTH
halves and reports `no runtime change since the last bump` / `CLEARED for
deploy`, so the fix this branch exists for is intact.
Suite: 26 files, all pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
0b653c35a6 |
PKG-13: the no-op half of the pre-deploy check has never run as documented
checks / unit (pull_request) Successful in 6m19s
⚠️ THE INVOCATION ON PKG-09's CARD AND IN THE TOOL'S OWN DOCSTRING RUNS ONLY HALF THE CHECK. The container runs as uid 0 against a checkout owned by the operator, and git refuses that: fatal: detected dubious ownership in repository at '/w/goodwe-addon' `_git` returned non-zero, `last_version_change()` returned None, and the silent-no-op check - THE REASON PKG-09 EXISTS - reported SKIPPED. Measured, on the documented command against release/1.0: ⚠️ SKIPPED: could not ask git what changed since the last bump; the no-op check is INCONCLUSIVE, not clean NOT CLEARED and with `--user $(id -u):$(id -g)`, unchanged otherwise: no runtime change since the last bump - nothing to deploy CLEARED for deploy. ⚠️ THE TWO HALVES WERE MUTUALLY EXCLUSIVE ACROSS THE TWO DOCUMENTED ENVIRONMENTS. In the container the validator ran and git skipped; on a bare host git ran and the validator skipped on aiohttp. NEITHER DOCUMENTED COMMAND RAN BOTH. The pre-0.9.0 clearance was sound only because it happened to be driven as two separate invocations. ⚠️ AND IT WAS INVISIBLE IN THE ONLY CASE ANYONE HAD RUN. `main()` consults the git result only when `shipped == deployed`; every real run so far compared 0.7.0 against 0.9.0 and printed its verdict without ever needing git. FIXED IN `_git()` WITH `safe.directory`, NOT BY DOCUMENTING `--user`. The tool should be correct however it is invoked rather than requiring every future caller to remember a flag; the read is read-only, on a repository the operator is about to deploy from. ⚠️ WHY THE TESTS COULD NOT SEE IT, WHICH IS THE FINDING BEHIND THE FINDING. `test_version_gate.py` invokes the gate with `sys.executable` against fixtures it creates as its own uid, so no fixture could ever meet an ownership mismatch. MUTATION.md habit 4 in a fourth shape, inside the file written to prevent it - after the pickaxe bug, after the one-bump history, after the filter-only survivor. Two fixtures added: - a directory that is NOT a git repository, so the lookup genuinely fails: the gate must exit 2 saying INCONCLUSIVE and must NOT say CLEARED. - ⚠️ THE EXIT CODES PINNED BY VALUE, NOT BY TRUTHINESS. `returncode != 0` is satisfied by collapsing NOT-CLEARED into REFUSED, which review found the earlier draft could not tell apart. 1 says "this deploy is wrong"; 2 says "nobody checked". Driven: collapsing 2 into 1 now goes red on exactly that check. Suite: 26 files, all pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
8e3734f5ea |
PKG-12: the live options WERE checked, and the changelog said they were not
checks / unit (pull_request) Successful in 6m17s
Review approved the bump and, in doing so, dated one of its own paragraphs.
When this section was written the pre-deploy check had not been run and the
changelog said so, correctly. The owner then exported the reference install's
options and the check cleared, which makes the shipped sentence false in the
release notes an operator reads at the hardware.
Replaced with what was measured, twice:
stored blob as-is exit 0, CLEARED
effective post-upgrade config exit 0, CLEARED
(the 70 stored keys merged OVER 0.9.0's
71 config.yaml defaults)
max_w 5000, slew_w 400, rule-10 ceiling 555.6 W.
⚠️ AND THE SECOND CHECK IS NOT CEREMONY. The stored blob has 70 keys, not 71:
`delivery_hold_s` arrived with SAFETY-42 in 0.8.0, so a box running 0.7.0 has
never stored it, and it is REQUIRED in the schema with no trailing `?` - the
exact shape that becomes exit 78. It resolves to its `options:` default of 185
because the Supervisor sets `App.options = deepmerge(defaults, stored)`, which
`tools/supervisor_selfapi_probe.py` §D pins against a real Supervisor image.
Review re-derived that independently rather than taking the reasoning on trust.
"Your saved settings are valid" and "what the box will actually run is valid"
are different questions, and only the second one is the one being asked before
a deploy.
Suite: 26 files, all pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
86bdf49332 |
PKG-12: bump 0.8.0 -> 0.9.0, so the five merges behind it can actually ship
checks / unit (pull_request) Successful in 6m14s
⚠️ MINOR FOR NEW FUNCTIONALITY, NOT FOR A BREAK, AND THAT DISTINCTION IS
CHECKED RATHER THAN ASSERTED. PKG-07 was minor because a config that started on
0.7.0 could be REFUSED on 0.8.0. Nothing of that kind is true here:
- `options:` and `schema:` hold 71 top-level keys on both
|
||
|
|
04b3330def |
PKG-09: refuse a deploy that would be a no-op, or that the add-on would reject
checks / unit (pull_request) Successful in 6m16s
Two ways a deploy of this add-on has gone wrong, and the Supervisor reports both as success. THE SILENT NO-OP. The Supervisor keys the built image by `version:`, so `ha apps update` against an unchanged version installs nothing and says it worked. PKG-06 recorded this on 2026-09-05 with six merges behind an unbumped version; PKG-07 recorded it again on 2026-09-06 with six. Both times a human noticed. Nothing else was going to. THE REFUSED CONFIG. A config the shipped validator rejects is SystemExit(78) - the add-on does not start. ⚠️ AND THE INGRESS PANEL GOES WITH IT, because the add-on serving it is not running, so recovery means the Supervisor UI. ⚠️ IT CAUGHT A LIVE ONE ON ITS OWN PULL REQUEST. Run against release/1.0 while this branch was in review: version 0.8.0 on both sides with UX-02's and OPT-09's runtime commits already merged behind it. The class this exists to catch had recurred, the same day, unnoticed - which is the argument for PKG-10, a non-required post-merge check, since this script only helps someone who remembers to run it. ⚠️ THE FIRST VERSION WAS BROKEN ON EVERY REAL REPOSITORY AND ITS TESTS COULD NOT SEE IT. The "when did the version last change" lookup was `git log -S`, the pickaxe, which finds commits that change the COUNT of a literal string. A bump edits the line IN PLACE, so the count never moves and it returned only the commit that first INTRODUCED the line. Measured here: -S -> |
||
|
|
cab267c4f3 |
OPT-09: the 6500 W window is a bound the ratings never let the site reach
checks / unit (pull_request) Successful in 6m10s
Round-2 non-blocking findings, both fixed. ⚠️ THE WINDOW FIGURE WAS ARITHMETIC NOBODY HAD RUN. `min_w * (w_A + w_B) / w_B` = 6500 W is correct about `_fill()`'s PROPORTIONAL regime and is not a reachable steady state: the healthy pack's own 5000 W rating binds first. SWEPT on part 8b's own fixture, reproduced rather than transcribed: load equal OPT-09 site target A B 1000 199/200 0/200 1080 997 83 2000 199/200 0/200 2170 2003 167 3000 199/200 0/200 3250 3000 250 4000 199/200 4/200 4340 4006 334 4500 199/200 8/200 4890 4514 376 4900 199/200 41/200 5320 4911 409 <- largest suppressed 5000 199/200 195/200 8000 5000 3000 <- A at its rating 6000 199/200 195/200 8000 5000 3000 So the alarm returns BELOW the formula bound, not above it, and the largest site target reached with the wedged unit still under the 500 W threshold is 5320 W - not 6500. The equal-weights "suppressed below 1000 W" bound is unreachable for the same reason: the equal arm names the wedged unit 199/200 at EVERY load swept, 1000 W included. The formula and its check are kept, because the mechanism they state is right and it is the mechanism that generalises to other ratings. What was wrong was the sentence around them, in four places, implying the site passes through the bound. Each now carries the sweep and says the rating binds first. ⚠️ THIS NUMBER CAME FROM THE REVIEW, NOT FROM THE BRANCH, and was adopted in good faith by the implementer. The reviewer found it in round 2 and said so. That is worth recording: a figure arriving from a trusted reviewer got less scrutiny than one written by the author, and PROC-01's eight fabricated numbers in two days is now nine. The rule that catches it is the same either way - run it before you ship it. Also: the DECISIONS header's base sha, stale for the third time ( |
||
|
|
8d83ef0dfb |
OPT-09: the weights DO move on a wedged unit, and that narrows SAFETY-42
checks / unit (pull_request) Successful in 6m11s
B1, the blocking review finding. §5 of the decision said:
"A wedged unit reads a healthy SoC, so no envelope closes and no weight
moves - OPT-09 cannot see it at all."
The first clause is true and is about the ENVELOPE. The second clause is
FALSE, and it was load-bearing for the claim that OPT-09 and SAFETY-42 meet
without overlapping. A weight is a function of a pack's ENERGY HEADROOM, not
of what the pack delivers, so a wedged unit's weight moves whenever its
headroom differs from its sibling's - the ordinary case this card exists to
exploit. And delivery.delinquent() (app/delivery.py:484, threshold at :531)
names a unit only while its short_by() gap exceeds min_w = Tuning.saturation_w
= 500 W in ABSOLUTE WATTS. Shrinking the share shrinks the gap below the
threshold, and the alarm stops firing on a unit still delivering nothing.
So the seam, stated accurately: OPT-09 stops the ask AND narrows SAFETY-42's
detection window for a low-headroom unit. That is a DETECTION REGRESSION at
N=2. It is disclosed here, not fixed: making delinquent() relative to the ask
rather than absolute is SAFETY-42's code and a design change, filed as its own
card.
1. THE CORRECTION, in the three places that carried the claim
* DECISIONS/OPT-09-soc-aware-weights.md §5 - rewritten. States the seam
accurately, quotes the sentence it is replacing, carries the measured
rows and the window arithmetic, and names the residual in the same voice
as SAFETY-13's "2 of 272" and SAFETY-42's "the discriminator does not
exist". Also drops "they meet and do not overlap" from the opening.
* app/packshare.py - a new section in the module docstring, immediately
after "weights are a comfort choice for stability and not for delivery",
which is where the over-read starts.
* CHANGELOG.md - a new paragraph in the SAFETY-42 entry, ending on the
operator-facing sentence: a wedged inverter with less energy headroom
than its sibling can now go unreported at ordinary house loads.
test_packshare.py part 8's closing check and its "what this file cannot
see" list repeated the same false clause; both now say the ENVELOPE cannot
reach a wedged unit and point at part 8b for what the WEIGHTS do to it.
2. THE GRADED CHECK - test_packshare.py part 8b, 6 named checks
The reviewer's probe, reproduced: real compute(), real plan(), real
packshare.weights(), real delivery.account(), real delivery.delinquent();
the only model is the plant (A obeys within one cycle inside its rating,
B accepts its share and delivers 0 W). Unit B is WEDGED at 15 % against
its own 10 % floor, so envelope() does not close and account() explains
nothing - a SAFETY-42 true positive by that card's own definition, which
a PRECONDITION check asserts before anything is concluded from it.
load 1500 W, B wedged at 15 %, equal weights named on 199/200 cycles,
site target +3000 W, shares a+1500, b+1500, grid +0 W, named=[b]
load 1500 W, B wedged at 15 %, OPT-09 weights named on 0/200 cycles,
site target +1630 W, shares a+1505, b+125, grid -5 W, named=(nobody)
load 6000 W, B wedged at 15 %, equal weights named on 199/200 cycles
load 6000 W, B wedged at 15 %, OPT-09 weights named on 195/200 cycles
suppression window: equal weights, site target < 1000 W; OPT-09
weights, site target < 6500 W of a 9600 W site
THE CHECKS ASSERT THE CURRENT, UNFLATTERING BEHAVIOUR ON PURPOSE - that
the weighted arm names NOBODY where the equal arm names the wedged unit.
A check asserting the alarm still fires would fail, and papering over it
would be worse than the original defect. The 6000 W row is there so the
part is not pinning "the weighted arm never names anybody": past the
healthy pack's own rating, _fill()'s last-resort pass hands the wedged
unit 3000 W and both arms name it. The window is real and it has an edge.
MUTATION - packshare.weights() keeps its shape and forces every weight to
1.0 (M11 in part 11's table, which now has 11 rows). RUN on a purged tree
with python3 -B, not reasoned about:
load 1500 W, B wedged at 15 %, equal weights named on 199/200 cycles,
site target +3000 W, shares a+1500, b+1500, grid +0 W, named=[b]
load 1500 W, B wedged at 15 %, OPT-09 weights named on 199/200 cycles,
site target +3000 W, shares a+1500, b+1500, grid +0 W, named=[b]
load 6000 W, B wedged at 15 %, OPT-09 weights named on 199/200 cycles,
site target +4000 W, shares a+2000, b+2000, grid +4000 W, named=[b]
suppression window: equal weights < 1000 W; OPT-09 weights < 1000 W
FAIL AND WITH OPT-09's WEIGHTS IT IS NAMED ON NONE OF THE 200 [...]
FAIL ...and the arithmetic behind it, stated as a window [...]
FAIL AND IT IS NOT THAT THE WEIGHTED ARM NEVER NAMES ANYBODY [...]
test_packshare.py exits 1. Restored: exits 0.
3. THREE PROC-01 PROSE DEFECTS
* DECISIONS/OPT-09-soc-aware-weights.md:3 said "Base: release/1.0 @
464d398". The branch was rebased; git merge-base HEAD
|
||
|
|
f8e501393f |
OPT-09: the weights parameter nothing ever passed, and what it cannot do
`allocator.plan()` has taken a `weights` argument since ALLOC-01 and NOTHING
had ever passed one - main.py called `resolve()` without it and the arbiter
defaulted it to 1.0 per unit - so the site target was split 50/50 regardless
of what either pack could do. Two things now feed it, and they are different
kinds of thing:
* a `pack` UNIT claim at P_SAFETY that CLOSES a unit's envelope at its own
`bms_soc_floor` or at 100 % - a PERMISSION, obeyed absolutely, so an empty
pack is not asked to discharge and its share goes to the sibling;
* SoC-aware weights by ENERGY headroom in kWh - a PREFERENCE, which
`_fill()`'s last-resort pass will override the moment the sibling saturates.
Measured (test_packshare.py part 8): at a load one pack cannot cover, the
weighted arm and the shipped arm command the IDENTICAL watts into an empty pack
and deliver the IDENTICAL watts. Only the envelope stops the ask, and what it
buys is not delivery - it is a reported `unmet_w` where the record used to read
impeccable. Weights are a comfort choice for STABILITY and NOT for DELIVERY.
Also: an optional per-entry `capacity_kwh` (schema change, all-or-nothing across
the roster, rule 9 refuses a partial one); per-pack arbitrage sizing, with the
sell side's no-op stated rather than hidden; and a 0 W trade refused by name,
because a 0 W arbitrage claim is a park order that outranks the loop.
SAFETY-42 and this card meet at one hazard from opposite sides - OPT-09 stops
the site ASKING a pack whose SoC says it cannot deliver, SAFETY-42 alarms when a
pack that said it could does not - and its end-to-end fixture moved as a result.
Its finding is unchanged and is now stated on the two quantities its rejected
discriminator actually reads.
Not filed at all at a roster of one: at N=1 every source routes to the site, so
the interval would bound the SITE TARGET, and SAFETY-25's decided Option A
stands untouched on the live install.
⚠️ INVERTER B DOES NOT EXIST. Its 4600 W / 20.48 kWh / 10 % are FROM DATASHEET
AND SERIAL, and `sim/plantsim.py`'s INVERTER_B is inverter A copied
(`measured=False`), so every two-inverter figure here is arithmetic or two
copies of A. No hardware was touched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
bcee2356e0 |
UX-02: pin both halves of the max_w fix, because neither was graded
checks / unit (pull_request) Successful in 6m16s
Review of the fix commit found that BOTH halves reverted with the whole 24-file suite still green - including the half that turns a crash into a SILENT ACCEPTANCE, which was my own wrong first attempt at this fix. A fix nothing grades is a fix that comes back. MUTATION.md habit 4. ⚠️ AND THE OBVIOUS PIN HAD THE SAME DEFECT IT WAS WRITTEN TO CATCH. "problems_ and_ranges does not raise" is ALSO satisfied by the backstop swallowing the exception and returning [] - the silent acceptance half two exists to prevent. MEASURED: with both halves reverted, every max_w check reported `ok` and only the forced-boom pins went red. So half one is now pinned on `build_roster` ITSELF, where the backstop cannot answer for it. That is the same defect as the one being fixed - a check whose data cannot reach the failure it names - written into the check for it, and it is the twelfth recorded instance. New section 2b of test_selfconfig.py: - `_flat_max_w` agrees with `_number()` across ten shapes, so composition tolerates exactly what rule 9 tolerates. - ⭐ `build_roster(max_w="")` does not raise - half one, standing alone. - a cleared box answers with the validator's own verdict; an unparseable one answers "max_w 'abc' is not a number". - ⭐ clearing max_w on the LIVE shape (5000 W rail, explicit slew_w 400) is REFUSED by rule 10 "against a site rail of 2000 W". `unit_caps_indexed` reads the flat rail through the same `_number`, so the rail collapses in lockstep and rule 10 catches the collapse. This is what makes the fix safe rather than merely non-crashing: the box cannot be emptied into a quietly halved rail. - ⭐ the backstop never returns an empty list, forced by monkeypatching build_roster to raise; and the patch is shown undone. DRIVEN, all four arms through the real harness: clean rc=0 fails=0 revert half one rc=1 fails=2 (_flat_max_w -> bare float()) revert half two rc=1 fails=2 (backstop returns [] again) revert BOTH rc=1 fails=3 Each half is caught on its own, which is the property the earlier draft lacked. Suite: 24 files, all pass. Not addressed here, filed instead: N3 (`/config/` 404s), N4 (undeclared keys on a hand-crafted POST, which the page cannot produce and Supervisor drops), N5 (saving pins every default as an explicit option), the page reporting "would start" for a blob Supervisor's own schema refuses, and 2000.0 hardcoded in three places that agree today with nothing pinning them together. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
2bb07ccdde |
UX-02: the panel was weaker than the startup gate for one field
checks / unit (pull_request) Successful in 6m9s
Applying review finding N1, which was non-blocking because it fails closed, and
N2, which is a fabricated number.
N1. Clearing the `max_w` box answered HTTP 500 on a non-JSON body, which the
page rendered as "cannot reach the add-on" - untrue, and for the most prominent
limit field's most likely transient state. The cause was a disagreement between
two files that `inverter.py`'s own docstring says to keep in step:
validate.py `_number()` reads "" as "use the default", so rule 9 saw 2000
and was content
inverter.py line 515 did a bare `float(opts.get("max_w", 2000))`, which
raises ValueError on ""
So a shape rule 9 TOLERATED could not be composed at all - the docstring's rule
inverted. The roster path never had this: `_entry_max_w` already guards. Only
the flat single-inverter path did. `_flat_max_w()` now reads empty as the
default exactly as `_number()` does, and unparseable as 0.0 exactly as
`_entry_max_w` does, where rule 9 refuses it by name at line 790.
⚠️ THE FIRST ATTEMPT AT THIS MADE IT WORSE, AND THE PROBE CAUGHT IT. Guarding
`build_roster` in `selfconfig` with `except: return validate(opts), []` turned
the 500 into ZERO PROBLEMS for `max_w: ""` - the validator has nothing to say
about it, so an empty list came back and the page would have ENABLED SAVE. A
crash became a silent acceptance. The guard is kept as a backstop for shapes
neither file anticipates, but it can no longer return an empty list: if the
roster cannot be composed and the validator names no reason, that disagreement
is itself reported.
MEASURED after the fix, `problems_and_ranges` with no HA:
max_w 5000, slew_w 400 0 problems (the live shape)
max_w "", slew_w 400 1 problem rule 10, "against a site rail of
2000 W" - clearing the box FAILS
CLOSED on the live install rather
than silently halving the rail
max_w "", slew_w 0 0 problems accepted at the 2000 default
max_w "abc" 1 problem "max_w 'abc' is not a number"
Before the fix, both "" and "abc" raised ValueError instead.
N2. `translations/en.yaml:7` said "all 71 keys ... so ALL SEVENTY are rendered".
The 70->71 correction had been made in one half of the sentence and not the
other. 59-of-71 required is correct and was re-measured.
Suite: 24 files, all pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
fdaf3fb54f |
UX-02: the validator's refusals, next to the field, before the save
checks / unit (pull_request) Successful in 6m17s
The Configuration tab can only render types and ranges. `validate.py`'s most
useful refusals are CROSS-FIELD - two inverters naming one `setpoint_entity`, a
`maintenance_charge_w` above `max_w`, a flat `setpoint_entity` left set beside a
declared roster - and the Supervisor's UI has no way to express a rule spanning
two keys. Today the operator meets those as exit 78 in a log, after saving and
restarting. This puts them on the screen beside the field they name, before
anything is written.
WHAT IT ADDS
app/configui.py the field model, built from `GET /addons/self/info`'s own
`schema` + `translations`. No option key, no label, no
rule of its own. UX-01's `Group · ` label prefix becomes a
real section heading, in whichever language resolved.
app/selfconfig.py the Supervisor conversation and the write gate.
app/config_page.py the page. Its longest function decides which HTML input a
type deserves; it contains no rule.
app/web.py five routes. Still the only file a reviewer has to read to
answer "does anything here reach the inverter".
THE FOUR THINGS THAT COULD HURT SOMEBODY, AND HOW EACH IS HELD
1. A blob the SHIPPED validator refuses is never written - not warned about,
not written. An invalid blob plus a restart is exit 78, and the panel is
gone with the add-on that serves it.
2. A blob that moved underneath is refused, not clobbered. Read hands out a
sha256 of the canonical blob, save hands it back, and the stored blob is
re-read IMMEDIATELY BEFORE the write. This is SAFETY-28's discipline
applied to configuration - "a blob reaches the Supervisor only if it was
derived from the blob stored right now" - with the honest weakening stated
in the decision record: a second writer is DETECTED, not made
inexpressible.
3. Every message is the validator's own, byte for byte. test_selfconfig.py
part 6 inserts ONE new rule into a COPY of validate.py, hashes every file
under app/ to show validate.py is the only one that differs, and serves the
real /config/validate route out of that copy in a subprocess. The new
sentence arrives, filed under the key it names.
4. Nothing on the surface commands an inverter or clears a latch. Saving does
not restart: applying costs a control interruption - the wind-down commands
0 W on every inverter - so that is a separate, confirmed button that says
so first.
MEASURED, NOT CITED (tools/supervisor_selfapi_probe.py, run against
Supervisor 2026.08.0's own code; it contacts nothing)
- the three self-paths are permitted, and `/addons/self/info` is admitted by
the `default` role too while `options` and `restart` rest on the bypass
ALONE. The probe's first draft claimed the role admitted none of the three
and went red.
- `/addons/self/options/validate` is NOT reachable from inside an add-on, so
the only validator this panel can call is ours - which is the one with the
cross-field rules.
- a POST that omits a key does not preserve it: the key reverts to
config.yaml's default. Hence whole-blob read-modify-write.
- the Configuration tab CAN be removed, and doing so drops 0-of-71 options
through and takes the add-on down. The card said it could not be; it stays
by decision, which is a better reason.
DOES NOT TOUCH config.yaml, main.py or any control path. UX-01 is a dependency,
not something this supersedes: its translations are where every label,
description and section heading on this page comes from, and the Configuration
tab is still the recovery surface. DECISIONS/UX-02-two-config-surfaces.md
records which surface is authoritative (neither: the stored blob is) and what
the second surface costs.
Also corrects a stale "70 options" in three prose spots to the 71 the probe
counts - config.yaml grew a key in SAFETY-42 and the sentences did not follow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
---
⚠️ REBASED ONTO release/1.0 @
|
||
|
|
06661c5e17 |
PKG-07: bump 0.7.0 -> 0.8.0, because 0.7.0's own default is now a refused config
checks / unit (pull_request) Successful in 6m6s
Six merges landed behind an unbumped 0.7.0 - CI-06, SAFETY-38, SAFETY-39,
SAFETY-37, SAFETY-13, SAFETY-42. PKG-06 recorded this exact failure YESTERDAY
(
|
||
|
|
c7eb8c6b97 |
SAFETY-42: close two mutation gaps, and correct what makes N=1 inert
The battery is 12 mutations, one freshly copied tree each, purged, `:ro`,
`python3 -B`, byte deltas measured. First round: 9 red, 3 survivors. All three
are now accounted for and the ledger is in DECISIONS §8.
M5 - `share <= 0` relaxed to `share == 0`, +0 B - WAS A REAL GAP AND THE TWO
OBVIOUS CHECKS DID NOT CLOSE IT. `short_by(-2000, 0) is None` and
`short_by(-2000, -100) is None` both stayed GREEN under the mutant: for a charge
share that is UNDER-delivered `share - measured` is negative and the `gap > 0`
test answers None anyway, so neither input can separate the two spellings. The
one that can is a unit moving MORE than its charge share - -2000 W told,
-3000 W measured, `share - measured` = +1000 W - which the mutant reports as a
shortfall. It is not one; it is something else driving that pack, which is
SAFETY-30's and SAFETY-31's question. That input is now the check, and M5 is RED.
MUTATION.md's third failure mode in miniature: the assertion ran, the sentence
was true, and the data could not reach the failure it named.
M11 - `inv.is_live(now)[0]` replaced by a literal `True`, -15 B - was a WIRING
gap the pure checks structurally could not see. `account(live=False, ...)` was
graded as a FUNCTION; nothing graded that `cycle()` asks the question, because no
fixture had a unit whose writes were being rejected. Such a unit still has its
last ACCEPTED command standing on the wire (`write_setpoint` leaves `last_write`
alone on a rejected write) and still measures nothing, so it looks exactly like a
wedged one to every number this check reads - and reporting it would be the
second, louder alarm about a state the allocator has already handled and logged.
`test_delivery.py` part 6 now builds that unit, with `rejected_since` back-dated
past LINK_STALE_S because the fixture runs in two seconds and the timer is
test_inverter.py's to grade. M11 is RED.
M3 - `len(asked) < 2` relaxed to `< 1`, +0 B - IS STILL A SURVIVOR, and it is an
EQUIVALENT MUTANT. With one asked unit `unaccounted` is a subset of a one-element
set, so it is either empty (`not unaccounted` returns {}) or equal to `asked`
(`len(unaccounted) == len(asked)` returns {}); with zero asked units both
spellings return {} as well. No input separates them, so no check can.
⚠️ AND FINDING IT CORRECTED THE MODULE'S OWN PROSE, WHICH IS THE MORE USEFUL
HALF. `delinquent()`'s docstring claimed `len(asked) < 2` was what makes N=1
inert. It is not - the PROPER-SUBSET rule is, and that line is a fast path and a
signpost. A false attribution of the mechanism in the file's own prose is exactly
what this repository keeps finding, and a mutation that survives is how it
surfaced. The line stays, with the equivalence written down beside it the way
circulation.circulating_w() records its own max(0.0, ...).
Also recorded, rather than left implicit: `account()`'s `parked` branch is graded
as a function but its WIRING is not mutated on its own; part 6's reversed-unit
fixture exercises it end to end, which is weaker, and the ledger says so.
Baseline 23/23 green before and after. app/main.py is byte-identical to the
previous commit - both closures are a test and a comment.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
706e625d99 |
SAFETY-42: a unit that stops delivering, and the discriminator the card asked for does not exist
A unit standing at a non-zero share and producing nothing is seen by NOTHING. SAFETY-30's per-unit sign gate needs OPPOSITION on both sides of 250 W and a unit at 0 W is not opposing, it is ABSENT. The site sum still agrees with the meter in direction. Circulating power is exactly zero when one unit does nothing. The only trace left is `reason == "saturated-freeze"`, which clamp.py:93-96 documents as the alarm an operator learns to ignore. It is HW-01's shape, which ran 6 h 25 m with all three layers spectators. ⚠️ THE CARD'S OWN DISCRIMINATOR WAS BUILT FIRST AND FALSIFIED, AND THAT IS THE MAIN CONTENT. SAFETY-42 asks for "saturation persisting past N while |grid error| > X, both derived". Run on the two-master rig it fails in BOTH directions, and no (N, X) fixes either: FALSE POSITIVE `plantsim.Plant.limit()` returns 0 W for a discharge command into a pack at its own floor, and a wedged unit produces 0 W too. Load 2000 W, equal weights, 2000 W per-unit cap, 120 cycles: THE TWO ARMS ARE IDENTICAL ON ALL 120 in grid, site target, every share and every measured watt. Both saturated-freeze on 116 of them, both hold |grid error| over 300 W on all 120 and settle at 1200 W, both read circulating 0.0 W. The predicate's inputs do not differ, so no threshold on them can differ. And "a cold pack at its floor must not alarm" is the card's own AC 2. FALSE NEGATIVE the standing grid error is what a HEALTHY SIBLING ERASES. Load 800 W, same config, B wedged: over the last 30 of 200 cycles the loop is frozen on 30 of 30 while the worst |grid error| is 30 W, with B standing at +770 W and delivering 0 W. Any X above 30 W is silent for the whole run on the exact fault the card describes. WHAT REPLACES IT. Per unit, for a discharge share, `share - measured`; readable because of a reduction that only exists at N>=2: a unit is named while it is short by more than `saturation_w` with NO ACCOUNT for it, and the units in that state are a PROPER SUBSET of the units asked to discharge. THE ACCOUNT keeps the cold pack out and is the only thing that can: the two cases are identical in the power domain, so the discriminator has to be a non-power fact, and there is exactly one - the pack's own SoC against its own `bms_soc_floor`, asked through `peakguard.discharge_permitted()` rather than transcribed. ⚠️ `bms_soc_floor` ALONE, not `pack_floor_pct()`'s conjunction with `peak_soc_floor`: that floor bounds a money-saving discharge only and using it would explain away a wedged unit anywhere between the two. THE PROPER SUBSET keeps a whole-site cause out - a derate this add-on cannot see hits every unit; one box that stopped listening does not - and it is what makes this INERT AT N=1, by algebra rather than by a roster guard. IT REPORTS. IT DOES NOT ACT, and the strongest argument is for acting. Dropping the wedged unit from `live` genuinely recovers a site the shipped path does not: at load 1500 W, kept in the split the site settles at 800 W with a 700 W error and NEVER comes inside 100 W in 150 cycles (the freeze pins the target, so the sibling's SHARE never grows); dropped, it delivers 1500 W from cycle 14. What refuses it is that the only re-allocation this add-on can express is all-or-nothing: a unit derated to 50 % is short by more than saturation_w with nothing to explain it, so this detector names it too, and at load 3000 W dropping it leaves the site delivering 2000 W against 2400 W kept - 400 W WORSE. A partial re-allocation is what would make acting safe and the allocator cannot express it. That is a card. control.py is NOT MODIFIED. The freeze shapes the loop; this reports on it. NUMBERS, MEASURED. The watts side is `Tuning.saturation_w`, read at runtime, so there is no fourth CFG-03 copy and it cannot drift from the number the control law itself calls "not following". `delivery_hold_s: 185` comes off 2640 closed-loop two-inverter runs in which nothing is faulty (6 gains x 5 slews over the whole schema range, across load reversals, a flapping load and ASYMMETRIC SPLITS - a family SAFETY-30's campaign did not have and the one that dominates here) plus 1890 runs at a roster of ONE as the control: threshold reversals flapping weighted N=1 control * 500 W 20.0 s 0.0 s 30.0 s 0.0 s The N=1 column is 0.0 s at EVERY threshold in all 1890 runs, and the flapping column - the family that defeated SAFETY-30's site window - is zero too, because a common stimulus makes both units short at once and the subset stops being proper. ⚠️ The curve is NOT monotone (55.0 s at 250 W against 40.0 s at 100 W): lowering the threshold can turn a proper subset into the whole roster and BREAK the run. ⚠️ THE MARGIN IS MEASURED, NOT FELT, BECAUSE INVERTER B DOES NOT PHYSICALLY EXIST. `plantsim.INVERTER_B` is A COPIED, `measured=False`, so every two-inverter figure above is two copies of A. Sweeping a different second unit: the worst legitimate episode is insensitive to B's RAMP (30.0 s at half A's) and scales with its LAG - 45.0 s at twice A's measured 10 s, 90.0 s at four times it. 185 s is 6.2x the measured worst, 2.06x the four-times-slower case, 6.2x LINK_STALE_S and 4.9x inside the 900 s quarter. Deliberately not 180, which is SAFETY-02's site hold. SAFETY-34. `unit/<name>/delivery_tripped` in the store, one notification id per unit, re-raised on the first cycle of every process, cleared by the Start press. No flat mirror: a rollback lands on a version that drives one actuator, where this check is inert. ⚠️ SCHEMA CHANGE - `delivery_hold_s` joins options: and schema:, so this needs a version bump before it ships. Additive and defaulted, so an existing options blob stays valid. STATED GAPS: charge shares are not graded (no configured charge ceiling exists, so a constant-voltage taper cannot be explained and arming it would fire on every maintenance charge); every unit wedged at once is not caught (the subset is not proper and no instrument here separates that from a plant that cannot). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
f156fadac0 |
SAFETY-13: the bound came from the wrong half of a two-sided measurement
checks / unit (pull_request) Successful in 5m34s
NOTES.md 5.10 measured the 1.78 kW step BOTH ways - "Up: ~18 s from step to deadband. Down: ~23 s" (NOTES.md:1550). SAFETY-39 takes 18 correctly: its bound is a REFUSAL and the looser number refuses less. This branch reused PLANT_SETTLE_S for the opposite job - how long the plant may still be the EXPLANATION for a divergence - where the conservative half is the longer one. ceil(23/2) = 12, not 9, and being short there does not refuse anything, it accuses. The false positive is real and is measured here. One inverter, one compute(), one plant, no second writer anywhere in the harness: the command falls through zero while the pack is still coasting the old way, unasked_power() calls our own power "unasked", the counter reaches 9 while it is still coasting, and the law emits "freeze-released". At the plant's own measured 18 s/23 s shape, over 272 load-step pairs, the 9-cycle bound fires in 62 and the 12-cycle bound in 2. PLANT_SETTLE_DOWN_S = 23.0 is added beside PLANT_SETTLE_S = 18.0, because the plant's settling time is a PAIR and each bound has to say which half it means and why. SETTLE_CYCLES = ceil(PLANT_SETTLE_DOWN_S / LOOP_TICK_S) = 12. It is no longer the reciprocal of SLEW_RAIL_FRAC; that was a coincidence of both reading the same half, and test_control.py now asserts the inequality. _SAT_COUNT_CAP moves with it, 10 -> 13. config.yaml accepts saturation_cycles as int(1,10), so the whole accepted range is now strictly BELOW the bound and the degenerate configuration this branch used to document no longer exists - pinned against config.yaml's own schema line. Three shipped statements were falsified by that measurement and are rewritten, not renumbered - at 12 the residual is smaller, not zero: unasked_power()'s docstring said anything outside the interval "was put there by something other than this loop". It was our own power: eight cycles after a +1990 W command the command is 0 and the pack still reads +522.3 W. control.py said "SO THE BOUND BELONGS TO (b) AND ONLY TO (b)". It fired in case (a). DOCS.md told an operator "if you see freeze-released, look for the second writer" - on a single-inverter install, hunting something that does not exist. Replaced by a section that gives the innocent causes first (a large load or PV swing; a plant slower than the one measured, 8/320 scenarios at 23 s rising to 268/320 at 50 s) and the second writer only when the state STANDS. Round 1's own results are kept and re-graded, not weakened. The release/1.0 sweep had to widen to reach the new bound (3780 cases): 3267 bit-identical, 432 releases, 27 command changes - all still charge starts from prev_w == 0 - and 81 differing in the saturation counter alone, which nothing downstream can read. The card's case releases on the twelfth saturated cycle instead of the ninth and settles at the same -1190 W against the same frozen integrator. Also corrected, because they were claims and did not reproduce: the coverage table's "output freeze" row was 11, and the mutation form its own note describes gives 6 on this tree, 6 on the first SAFETY-13 commit and 2 on release/1.0; the detector row was left at 11 and is 15; the frozen = False footnote was left at 10 and is 13. And section 6 reported 107 625 as "released states" when it is the number of states SWEPT - 67 500 of them release, and both are counted now. Nothing here reached the inverter. 22/22 test files pass in goodwe-addon-ci, tree rsynced to a plain path, mounted :ro, python3 -B, __pycache__ purged and printed first (0 dirs / 0 .pyc; the worktree carries none). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
8e241a3243 |
SAFETY-37: correct a wall-clock tick and a false size claim
checks / unit (pull_request) Successful in 5m32s
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 |
||
|
|
6864109a01 |
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 |
||
|
|
834e7ca44b |
SAFETY-39: correct the gain figures, which were not the measured ones
checks / unit (pull_request) Successful in 5m15s
Review found validate.py:288-289 carrying numbers that appear nowhere else in the PR's evidence and could not be reproduced: over-absorption "1233 W at slew 1000 and 1344 W at slew 250 ... 1495 W at 0.6, 961 W at 0.3, ~0 W at 0.2". A grep for 1233/1344/1495 found them ONLY in that comment - the DECISIONS record, the PR body, test_control.py's audit table and slew_sim's own output all carried the correct ones. The measured values, from re-running tools/slew_sim.py on the real plant model at the stated conditions (max_w 5000, 2 s tick, 51 export steps 1.0-4.2 kW): slew column 958 (slew 1000) and 1081 (slew 250); gain column 987 (0.6), 876 (0.3), 435 (0.2). ⚠️ "~0 W at 0.2" WAS WRONG IN THE UNSAFE DIRECTION. It invited a future reader to conclude that dropping gain to 0.2 removes over-absorption. It does not: 435 W remains, which is 44 % of the figure at 0.6, not zero. The corrected text says so at the line rather than leaving a round number to be trusted. The conclusion the paragraph draws is unchanged and still holds on the measured figures: over-absorption is flat in slew_w and falls in gain, so `gain` is the constant that would have to move, and SAFETY-39 deliberately does not move it. No behaviour change; comment only. Full suite: 21 passed, 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
d5b9317d57 |
SAFETY-38: correct the survivor count, which was stated four ways
checks / unit (pull_request) Successful in 5m11s
Review found the summary lines miscounted, and one of the miscounts filed
a KILL among the survivors. The authoritative tables in §7e and §7f were
right throughout; only the sentences a reader actually relies on were
wrong.
Arithmetic, from §7f's own ledger:
14 measured = 10 surviving
+ 1 already caught (C3 allocator.py:162, red in test_alloc.py)
+ 3 caught by round 4 (C6 clamp.py:253, C7 peakguard.py:493,
B12 arbiter.py:264)
Survivors by file: TWO in allocator.py (C1 :154, C2 :160), three in
arbiter.py (C4a/C4b :344, C5 :392), two in tariff.py (C8, C9), two in
arbitrage.py (B17, B18), one in main.py (B19). Two, not three, in
allocator.py - C3 is the kill.
Before: DECISIONS §6c said "Nine" while listing eleven; the CHANGELOG said
"11 SURVIVORS" while listing eleven. Both counted C3 as a survivor.
⚠️ KEPT VISIBLE RATHER THAN QUIETLY FIXED. This is this card's own
recurring defect in prose rather than in code - a claim wider than the
measurement beneath it - and it is the second false statement this ticket
has put into its own decision record. The correction says so at the line,
because a document whose whole value is that it can be trusted should
record when it could not be.
No app/ change; no test change. Full suite: 21 passed, 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
564ba11c11 |
SAFETY-38 round 2: assert the ordered pair, and correct the M3 attribution
Two blockers, one of them mine to fix and one a mis-measurement to undo. No
`app/` change in either: the shipped `sum` is correct.
1. A ZERO-BYTE SURVIVOR AT THE CALL SITE ONE LINE BELOW THE PINNED VALUE.
Round 1 pinned `site_standing` and never pinned WHERE IT GOES. Swapping the
detector's first two arguments at main.py:1620 -
`evaluate(site_standing, self.grid, ...)` -> `evaluate(self.grid, site_standing, ...)`
- is a ZERO-BYTE edit and left the round-1 tree fully green. The cost is the
banner: the site stands at -500 W of charge into a +2500 W import and the
LATCHING, SITE-STOPPING alarm reads "commanded +2500 W against a meter
reading -500 W" - which instrument read what, inverted, on the one line
somebody woken by it reads first (watchdog.py:658-665).
Round 1's check could not reach it. It asserted `"-500 W"` appears SOMEWHERE
in the sentence; under the swap it still does, IN THE METER SLOT. A substring
test grades PRESENCE, never ARRANGEMENT. It now asserts the ORDERED PAIR:
`f"commanded {sum:+.0f} W against a meter reading {grid:+.0f} W"`. Measured:
red in test_watchdog.py on that one named check, green in the other twenty.
2. THE M3 ATTRIBUTION WAS FALSE, AND HOW IT BECAME FALSE IS THE USEFUL PART.
Round 1 recorded `list(standing.values())[0]` as closed by SAFETY-32's
package-wide pin and told future authors NOT TO RE-COVER IT. Re-measured on
release/1.0 @
|
||
|
|
d8e02ad3f8 |
PKG-06: bump 0.6.0 -> 0.7.0, because the schema changed and nobody bumped
checks / unit (pull_request) Successful in 5m2s
Six merges landed since 0.6.0 was deployed and the version never moved. Disk and box both read 0.6.0, so `ha apps update` would have been a NO-OP: Supervisor keys the built image by `version:`, and the update path installs `version_latest`, which cannot differ from `version` without a bump. Found while pre-checking the deploy, not after it silently did nothing. ⚠️ MINOR RATHER THAN PATCH, BECAUSE THE SCHEMA CHANGED. SAFETY-32 added `bms_soc_floor: int(0,50)` as a REQUIRED per-entry key on the `inverters` roster - no trailing `?` - so a config declaring a roster and omitting it on any entry is refused at startup. Verified against the deployed |
||
|
|
3527c8a04c |
SAFETY-41: escalate a sustained engagement, so a runaway is never held
checks / unit (pull_request) Successful in 4m59s
Review found the case I asked it to construct, and it is not a corner case. The rate limit alone DELAYS A RUNAWAY: a benign one-cycle clamp that spends the budget, then a continuous §10.3-shaped runaway, held the runaway's first notification for up to 140 s - one REASSERT_S minus a cycle. On the live install, where ordinary railing spends the budget essentially all the time, a runaway arriving during normal operation would be delayed with near-certainty. That is the "benign noise suppresses the bug signal" shape this module's docstring is written against, and the family the first attempt at this card died of. THE ESCALATION. An episode that has not yet spoken because an EARLIER, unrelated episode spent the budget speaks anyway once it has engaged SUSTAINED_CYCLES = 3 times in a row. The discriminator is run length and the two populations genuinely differ: the live flood was runs of length ONE (engage, de-assert, engage), while a runaway engages every cycle. Measured, and it costs nothing it was protecting: live scatter flood (305 cycles, 1-in-6) 11 -> 11 notifications chatter 1-engaged/3-clean over 8000 s 50 -> 50 notifications pinned 900 s quarter (floor is 5) 6 -> 6 alarms runaway delay after a benign episode 140 s -> 10 s ⚠️ MY COUNTERFACTUAL CHECK COULD NOT FAIL FOR THE DEFECT IT NAMED, and review caught that too. It read `ClampWatchdog().note(True, ...) is True` - a FRESH object, which only exercises the `last_alarm is None` branch, the trivially-true one. A fresh watchdog has no earlier episode to be held back BY. It now SPENDS THE BUDGET FIRST: benign clamp, gap past CLEAR_CYCLES, then a continuous runaway, and asserts the delay is well under REASSERT_S. That is the third instance in this file of the family recorded at line 216 and at the 224 s sweep, and it was guarding the most safety-critical property in the change. Also pinned: `last_alarm` SURVIVING the de-assert. Review built the mutation - if the de-assert also refreshed it, chatter collapses from 50 notifications to 1 and the alarm never fires again, with the suite green. That is the "alarm that never fires" class, a different bug from one that fires later. Now caught. Both new checks verified against the mutations they name: removing the escalation -> 1 FAILED; refreshing last_alarm on de-assert -> 1 FAILED. Full suite: 21 passed, 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
8790c4d38f |
SAFETY-41: rate-limit the RISING EDGE, which is where the flood came from
checks / unit (pull_request) Successful in 5m9s
Review falsified the first version of this change, and the correction is the substance of it. Raising REASSERT_S 30 -> 150 removed NONE of the owner's notifications. The re-assert interval governs only the still-engaged path; the rising edge fired unconditionally. At the measured ~5 s cycle the live pattern - 48 engagements in ~25 minutes, about one cycle in six - leaves ~25 s of clean cycles between engagements, past CLEAR_CYCLES x 5 s = 15 s. So the alarm DE-ASSERTED between almost every engagement and each one returned as a fresh rising edge. Measured on the shipped class over that window: 48 episodes, 48 notifications at REASSERT_S = 30, and still 48 at 150. The interval now bounds the NOTIFICATION RATE rather than the repeat rate within one episode: `last_alarm` survives a de-assert, and a new episode speaks only if the interval has elapsed. Same window afterwards: 51 episodes, 51 engagements, 11 notifications. ⚠️ NOTHING IS DROPPED, AND THE TWO PROPERTIES THAT MATTER ARE PINNED: - a PINNED loop still speaks 6 times in the 900 s billing quarter, against a floor of 5 (part 2, unchanged); - a rising edge from a COLD watchdog still fires IMMEDIATELY, so a runaway beginning from quiet is never held back by a budget spent on an earlier, unrelated episode. That check is the counterfactual and it is the half that would go red if the limit ignored elapsed silence. Every engagement is still counted, every episode still increments, and the action-log row is still written on every engaged cycle. The budget is on notifications, never on evidence. The new pin is written in wall-clock seconds and in the shape the field produced, so it does not move when the constant does. Verified by reverting the rising edge to unconditional: 2 checks go RED naming it. Full suite: 21 passed, 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
1d393ced22 |
SAFETY-41: correct the comment, and fix the check it cites
Review found two false statements in the comment and one weak check. All three are fixed here; the constant itself is unchanged at 150 s and was never in question. 1. "That caps this constant at 900/5 = 180 s" was FALSE ABOUT THE CHECK IT CITED. test_clamp.py part 2 sweeps i = 0..899 and the rising edge at i=0 is free, so it measured `1 + floor(899/R)` - one alarm more than a real quarter earns. Measured: 224 s was GREEN across the whole suite. Anyone raising this to 200 s would have had a green board and a requirement quietly unmet - the same defect recorded at test_clamp.py:216, one notch weaker. The check now grades floor(QUARTER_S / REASSERT_S) directly, so it enforces the ceiling the comment claims for it. Verified: 150 PASS, 180 PASS, 181 FAIL, 224 FAIL. 180 is also the true requirement edge - on the worst sliding 900 s window 180 earns 5 alarms and 181 earns 4. 2. The "60 cycles of the §10.3 runaway" note was attributed to this module and truncated. It is main.py:2479 and reads "60 cycles of the §10.3 runaway produce 0 write-path engagements and 2 loop-side ones". Quoted in full, attributed correctly. 3. "48 engagements in 25 minutes" was left implying a pinned rail. It is roughly one cycle in six - real, and far above the 2-in-60 the guard was sized for, but not continuous. Restated, and labelled as a live-log reading rather than a measurement in this repo. Also recorded: the 1500 W tuning derivation is in the OTHER repo (admin/goodwe NOTES.md, "Simulated 2 kW load step (gain 0.6, clamp 1500)"), so this repo cannot corroborate it and a reader should not go looking here; and MIN_PER_QUARTER = 5 is a test-local integer, not a spec figure - the 900 s quarter is system-wide but the 5 is not, so the 180 s ceiling rests on one number a test author chose. Full suite: 20 passed, 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
3516947b5e |
SAFETY-41: raise the clamp alarm's re-assert 30 s -> 150 s
The owner reported the OUTPUT_CLAMP notification as a persistent annoyance on the live install. It re-asserted every 30 s, all day. ⚠️ THIS IS A REGIME CHANGE, NOT A CHANGE OF MIND ABOUT THE ALARM. 30 s was chosen when a loop-side engagement was rare - clamp.py's own note records "60 cycles of the §10.3 runaway produce 2 loop-side engagements". The live install produced 48 IN 25 MINUTES of ordinary operation, because `max_w` is 5000 W where the loop's tuning was derived at a 1500 W clamp (SAFETY-39). The integrator therefore reaches its bound constantly and the alarm narrated it every 30 s. An alarm that speaks that often about the normal case is how an operator learns to ignore the one that matters, which is the exact failure clamp.py's docstring is written against. ⚠️ 150 s IS THE REQUIREMENT'S OWN EDGE WITH ONE STEP OF MARGIN, not a comfort choice. test_clamp.py part 2 pins the cadence in WALL-CLOCK terms rather than against this constant: a loop pinned on the rail must speak at least MIN_PER_QUARTER = 5 times inside the 900 s quarter that bills the capacity tariff. That caps the constant at 900/5 = 180 s. Measured: 180 s -> 5 alarms per quarter (exactly the required 5, no margin) 150 s -> 6 alarms per quarter 300 s -> 3, and the floor check goes RED 180 s would sit on the boundary, so any later change to the firing logic would tip it red for a reason unrelated to that change. The FLOOR is the thing that must not move, and it does not. ⚠️ WHAT THIS DOES NOT DO. It changes how often the alarm repeats. It does NOT make the alarm correct: the message still calls the integrator bound a BUG SIGNAL and still pairs a request from one moment with a `self.target` read at another. That is SAFETY-40. And it does not stop the loop railing in the first place; that is SAFETY-39. This is a stop-gap and says so at the line. Nothing was suppressed: every engagement is still counted, the action-log row is unchanged, and the §10.3 runaway still raises the alarm - an earlier attempt at this that split the alarm so only `clamped` notified was REVERTED because test_clamp.py:398 proved it would have disabled runaway detection entirely (a runaway presents as `i_clamped`, and control.py:78-81 says `clamped` is unreachable on a default install). Full suite: 20 passed, 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
a4c95f83ce |
SAFETY-32 round 3: a fixture of N packs cannot separate reductions that agree on N
Both blockers were the recurring defect sitting inside the fix written to close it: a check whose TEXT claims more than its FIXTURE can observe. Both survived a green 19-file suite on |
||
|
|
ffbabea8ce |
SAFETY-32 fix round: grade the reduction on packs whose floors DIFFER
AC 6 was not met for the reduction main.py actually hands decide(). The
reviewer removed the per-pack grading twice and the 19-file suite stayed green
both times. No shipped behaviour changes here - app/ is untouched. What changes
is what observes it.
WHY BOTH SURVIVED, and it is this repository's recurring defect landing on this
card's own Decision 2: under any rule-8-valid config every pack's
pack_floor_pct() equals peak_soc_floor, so `floors` is a list of IDENTICAL
numbers and max/min/first/last are four spellings of one answer. The only check
using a cut-out ABOVE the EMS floor - 12c's `_n1_deep` - ran on a roster of ONE,
where every reduction agrees. So no check ever evaluated binding_soc_floor() on
two packs whose floors differ. And the degeneracy is not a defence: this PR's own
argument ("unreachable is a claim about today's rule ordering, and
validate_startup_config() is also sim/inject.py's entry point") justifies writing
the reduction and equally obliges grading it.
* test_peakguard.py part 12c2 - NEW. binding_soc_floor() on a roster whose
per-pack floors ALL DIFFER: four SIMULATED cut-outs 30/25/40/35 %, each
strictly above peak_soc_floor, so max (40), min (25), first (30), last (35)
and the mean (32.5) are five different numbers and the extremes sit at
INTERIOR roster positions. Two packs structurally cannot separate those -
there max==last and min==first - and neither can three; four is the smallest
roster that can. Precondition is its own check (habit 1); the counterfactual
raises ONE pack - the last, neither extreme - past the old maximum and the
answer follows (habit 7); peak_soc_floor is then lifted above every cut-out
to grade the other side of the conjunction.
* test_peakguard.py part 12g - REWRITTEN as behaviour (habit 4). The old
assertion was `"self.packs" in <the unparsed call site>`, which `self.packs[:1]`
still satisfies. It now drives REAL Controller.cycle()s on a two-pack roster,
both packs at the same SoC, and reads PeakDecision.soc_floor - the argument,
echoed back by the shipped decide(). The cut-outs are the only thing varied,
and the SAME PAIR is carried in BOTH ORDERS.
* ⚠️ TWO MORE SURVIVORS, found by this round's own battery and recorded rather
than quietly repaired. (a) The first cut of 12g varied only pack B's cut-out,
so `self.packs[-1:]` (+5 bytes) agreed with the roster on both runs and stayed
green across all 19 files; the swapped pair closes it, and between the two
directions no single-position slice passes all three runs. (b)
binding_soc_floor()'s EMPTY-roster branch rewritten to `return None`
(-43 bytes) was green too - a defensive branch nothing in the suite reached.
Both directions of it are pinned now.
* wired_over_limit() gained a prepare(ctl) hook, inside its retry loop so every
attempt grades the same site.
* In-process mutation rows 22, 22b and 23 record all four survivors.
Nine further source mutations went through the harness on the fixed tree
(binding_soc_floor -> floors[0] / floors[-1], pack_floor_pct conjoining with
min, pack_permissions grading against packs[0], `any` -> `all` on the unknown
floor, Controller.packs reversed, decide(soc=) taking inverters[0].soc, and the
two survivors above). Seven were red on first run; the two survivors are now red
as well. Three of the nine have a ZERO byte delta and still went red, which is
the direction stale bytecode cannot fabricate - and the harness purged anyway.
MANDATORY PROOF, all in the CI image over a tree rsynced to a plain path and
bind-mounted :ro, bytecode entry count printed before and after the purge:
each of the four mutations now takes test_peakguard.py RED, and the unmutated
tree is 19/19 green (twice, plus test_peakguard.py five times in isolation -
card CI-06's flake did not reappear). The zero-byte one was proved to have RUN rather than
assumed: dis.get_instructions LOAD_GLOBALs were [float, pack_floor_pct, any,
min] and binding_soc_floor() returned 25.0 on the 12c2 roster where the shipped
code returns 40.0.
No measurement of inverter B appears anywhere. It does not physically exist.
Every second-pack and fourth-pack figure added here is SIMULATED and labelled at
its definition; only inverter A's 10 % is read off hardware. No hardware was
touched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
a9993aeda2 |
SPIKE-04a: inverter B is 4600 W FROM ITS DATASHEET, and the copies are pinned
checks / unit (pull_request) Successful in 4m51s
The desk half of SPIKE-04. No control behaviour changes; this is documentation and the check that stops it drifting again. Three places stated inverter B's rating - config.yaml's commented roster example, DOCS.md's, and tools/supervisor_shape_probe.py's "a blob an operator would POST" - and all three said 3000 W, a number nobody had measured or read off anything. The owner supplied B's model and serial on 2026-09-04: a GoodWe GW5048D-ES, serial `55048ESU22AW0992`, max charging and discharging power 4600 W. Serial characters 6-8 are `ESU`, platform 105 / AA55, so SPIKE-01 does not gate this site. All three copies now read 4600. ⚠️ 4600 W IS FROM A DATASHEET AND IS NOT A MEASUREMENT, and the one ambiguity is recorded rather than guessed. The datasheet prints it under BATTERY INPUT DATA, so it is probably DC, while max_w is graded against 0x0518 - the inverter's own AC power. If it is DC the AC ceiling is nearer 4324 W. Neither reading is written in as fact; SAFETY-20's min(max_w, dev_max) already bounds it correctly under either, once dev_max is read off the unit, so `max_w: 4600` is what an operator should configure today. goodwe/NOTES.md §1.1b carries the full record with the date and the method on every figure. A NEW CHECK, BECAUSE A DOCUMENTATION FIX WITHOUT ONE DRIFTS AGAIN. test_validate.py now pins all three documented rosters: findable, in agreement, and equal to 4600 - with an explicit len(...) == 3 guard so that a regex which stopped matching cannot leave "they all agree" green on the two sites still read. It also grades the PROVENANCE beside the number (the datasheet, the 0x0518 it is graded against, and the 4324 W the DC reading would make it) WITHIN 2500 characters of the roster rather than file-wide. ⚠️ The first version of that provenance check searched whole files and was ALREADY VACUOUS on two of them: 0x0518 appeared once in config.yaml and once in DOCS.md before this card, for unrelated reasons, so `"0x0518" in src` was green no matter what happened next to B's rating. That is a check whose data cannot reach the failure it names, in the file that exists to complain about them. It is fixed, and the window is what fixes it - deleting the caveat beside the number now goes red even where the same token survives elsewhere in the file. The windowed version immediately found that the DOCS.md caveat did not name 0x0518 at all; DOCS.md now says it. Four mutations, bytecode purged and counted at 0 before and after each, all RED (two of them 0-byte deltas that change a printed answer): M1 probe's operator blob 4600 -> 3000 0 B RED (value + agreement) M2 DOCS.md's DC/AC caveat deleted -157 B RED (provenance) M3 config.yaml alone 4600 -> 4324 0 B RED (agreement) M4 probe entity renamed, regex finds nothing RED (the vacuity guard) Restored, green again. The probe itself was re-run against Supervisor 2026.08.0's own AppOptions validator with the 4600: all cases passed, exit 0. ⚠️ THE TEST FIXTURES KEEP 3000 W ON PURPOSE AND NOW SAY WHY AT THE LINE. test_inverter.py, test_maintenance.py, test_peakguard.py and test_watchdog.py build a second inverter at 3000 W against A's 5000 W. The only property those checks consume is that B's number is unmistakably not A's, and the real pair is a 400 W gap - narrower than slew_w, inside the range where an off-by-one-unit bug and ordinary control arithmetic produce indistinguishable numbers. Collapsing them onto the real values would buy realism those checks do not use and cost them their separating power, which is exactly what test_peakguard.py's own §10g PRECONDITION exists to prevent. Their comments no longer call 3000 W a hypothesis about B's rating; they call it illustrative, and not a placeholder either - the placeholder era ended on 2026-09-04. Also recorded in the changelog for the cards that own them: 94 % battery-to-AC => ~88 % round trip => ~360 W burned continuously to circulate 3 kW, which is the number SAFETY-31 argues its threshold from without having one; < 13 W night self-consumption per unit, which nothing accounts for; and the A/B asymmetry in opposite directions (~33 % more energy, ~8 % less power), which means MAINT-05's 12 h drain timeout was derived on A and should be re-checked against B. ⚠️ Honest survivors: - goodwe/NOTES.md §1.1b, where all of this is actually recorded, is graded by nothing here. Rewriting its 16 occurrences of 4600 to 3000 and turning every FROM DATASHEET label into MEASURED ON THE UNIT leaves all 19 files GREEN. This CI cannot fetch admin/goodwe - private, and GITHUB_TOKEN is minted for this repository only, as .gitea/workflows/checks.yml already states - so the cross-repo check is not buildable here. - No check can see whether 4600 is the RIGHT number. Everything above pins a datasheet reading against itself. Only the bench half of SPIKE-04 can say whether the unit agrees, and the DC-vs-AC ambiguity is the live example. - The constant is still named B_RATING_HYPOTHESIS in four test files, which reads as a hypothesis about B's rating when it is now an illustrative fixture. It is left alone deliberately: renaming ~20 sites across test_peakguard.py and test_inverter.py would collide hard with the open SAFETY-32 pull request, which rewrites 1255 lines of the first. The comment directly above each definition says what the value is and is not. Suite: 19/19 green in goodwe-addon-ci, mounted :ro, bytecode purged and verified 0 before and after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
65cddf35f1 |
MAINT-05 fix round: grade the migration against the ROSTER, not against the name it was handed
checks / unit (pull_request) Successful in 4m47s
REJECTED because a survivor undoes AC 5, and it survived for the recurring
reason: two fixture values that must be distinguishable were EQUAL.
THE SURVIVOR. app/main.py:282 mutated from `self.inverters[0].name` to `UNIT_A`
left the full 19-file suite green. `inverter.UNIT_A == "inverter-a"`, and every
two-pack fixture in test_maintenance.py named its first roster entry
"inverter-a" - so the two expressions were the same string in every check that
existed. main.py's own comment made the distinction load-bearing and nothing in
the repository could see whether that comment was true.
THE FAILURE IS REAL. `build_roster()` names a declared roster's entries from the
config and numbers a nameless one POSITIONALLY (`inverter-1`), so "inverter-a"
is not even the fallback. On ["garage","shed"] with a flat last_completed of
2026-08-07 the mutant migrates into unit/inverter-a/, pack 0 reads back None,
and both packs come up believing they have never been recalibrated.
AND IT FAILED SILENTLY, which was the worse half: `_verify_moves()` read back
the keys it had just WRITTEN, under the namespace it had just been HANDED, was
satisfied, and MAINT_SCHEMA_KEY was stamped - so the next start skipped the
migration too. A postcondition that reads back the namespace it was handed
cannot detect being handed the wrong one.
WHAT CHANGED
1. THE POSTCONDITION IS GRADED AGAINST THE ROSTER. `_verify_moves()` takes the
names the MACHINES read and reports a namespace that is not among them as
`misdirected` - a new, differently-named failure from `lost`:
* `lost` is "the store took the write and dropped it" - a storage fault,
with a harmless-default case (a dropped `last_completed: None` loses
nothing, because absent and never-recalibrated are the same statement);
* `misdirected` is "the store did exactly as it was told, by a caller that
was wrong". There is no harmless-default version of it - a write nobody
reads is not information whatever its value - so it fails `ok` alone.
`ok` gates the marker, so a misdirected migration is NOT stamped and the next
start re-runs it. Asymmetric on purpose: a re-run is recoverable (rule 1
makes it a no-op, rule 2 left the flat keys to recover from), a silent skip
is not. Part 9b MEASURES the recovery rather than asserting it.
2. `migrate_maint_keys()` TAKES THE ROSTER IN PLACE OF AN `n_units` COUNT. The
count was `len(self.inverters)` at the only call site, so the roster carries
it, and a count beside the names is a second opinion about how many packs
there are.
3. THE FIXTURES CAN NOW REACH THE FAILURE THEY NAME. Parts 9 and 11 run on
["garage","shed"], each behind a PRECONDITION check that the first name is
not UNIT_A - so an edit that makes them equal again goes red instead of
making the claims vacuous. Part 11's fixture date is relative to `now`, not
a literal, so "history preserved" and "history lost" cannot converge as the
file ages past `interval_days`.
MUTATION LEDGER - all red, purged tree, none at +0 B, full detail on the PR:
M1 main.py inverters[0].name -> UNIT_A -16 B 4 checks red
M2 store.py drop `roster=names` from _verify_moves -14 B 5 checks red
M3 store.py drop the misdirected.append -37 B 5 checks red
M4 store.py ok: drop `and not self.misdirected` -25 B 3 checks red
M5 fixture garage -> inverter-a (the trap itself) +4 B 5 checks red
M6 store.py parts[1] -> parts[-1] +1 B 1 check red
FOUND AND NOT FIXED - `migrate_unit_keys()` HAS THE SAME HOLE AND IT IS A LATCH.
It runs BEFORE `build_roster()` (each Inverter restores its latch in its
constructor), so it is handed the constant UNIT_A with no roster to grade
against. On a declared roster a pre-DEV-02 `sign_tripped: true` lands under
unit/inverter-a/sign_tripped, which no unit reads - and
`_grade_store_migration()`, whose whole job is to re-arm exactly this, is
DEFEATED BY THE KEY THE DEFECT WROTE: its "is anything namespaced latched" test
is satisfied by the misdirected value itself. Measured, with the flat-config
counterfactual, and pinned as a running check (test_maintenance.py part 13)
whose assertions state the WRONG behaviour on purpose. It wants its own card:
the remedy is in inverter.py and SAFETY-02's semantics, and a silent expansion
into a safety latch is how a rejected PR becomes a broken one.
Suite: 19/19 PASS, purged tree, bind-mounted :ro in goodwe-addon-ci.
No hardware was touched; every fixture here is SIMULATED and pack B's 3000 W
remains a marked hypothesis.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
ae21b71824 |
Merge release/1.0 into SAFETY-30
checks / unit (pull_request) Successful in 4m39s
CFG-04 (#45) merged first and moved the base. Two conflicts, both resolved as the union of the two sides: - app/main.py: an import-block collision. CFG-04 added validate_unit_ranges to the .validate import; SAFETY-30 added AGAINST_METER, AGAINST_OWN_POWER, SIGN_OPTION_DEFAULTS and SignWatchdog to the .watchdog import. Neither side dropped anything, so the resolution keeps both lists whole. - CHANGELOG.md: two adjacent new entries under ## Unreleased. Both kept. Both sides also independently rewrote large parts of test_peakguard.py (60 and 47 insertions against the merge base) and git merged them textually, so the full suite was run on the MERGE RESULT rather than inferred from the two parents: 19 passed, 0 failed, twice. One caveat recorded rather than hidden: test_peakguard.py failed once during the first sequential full-suite run and then passed 10/10 in isolation and in two further full runs on this same tree. It passes on both parents too, so this is the pre-existing flakiness CFG-04 already disclosed, not a merge defect - it is timing- or load-sensitive. It sits on the required CI context and can therefore block an unrelated merge at random, which wants a card of its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
d5c1577212 |
SAFETY-30: pin WHICH thresholds each sign detector is armed with, not just what the numbers are
checks / unit (pull_request) Successful in 4m48s
REVIEW FOUND A MUTATION SURVIVOR THAT UNDOES THE WHOLE PRODUCT OF AC 5, and it
is a coverage defect rather than a behaviour one - the shipped code arms the
detectors correctly today. In `_arm_sign_watchdogs()`, collapsing
inv.sign_wd.arm(self.sign_cfg if one else self.unit_sign_cfg, ...)
to `arm(self.sign_cfg, ...)` throws the re-derived 250 W / 85 s per-unit pair
away and arms every unit with SAFETY-02's meter-facing 300 W / 180 s - and left
ALL 19 FILES GREEN. Reproduced here before fixing anything: fresh per-mutation
tree, no __pycache__, mounted :ro, graded through tools/mutation_run.sh; -31 B;
19 pass, 0 fail.
WHY NOTHING CAUGHT IT. `grep -rn "sign_wd.cfg" test_*.py` found exactly ONE
threshold assertion, part 7's N=1 arm at (300.0, 1.0) - which the mutation
PRESERVES. Nothing at a roster of two read `inv.sign_wd.cfg` at all. The ledger
mutates the VALUES in SIGN_OPTION_DEFAULTS (M10 250->300, M11 325->180, both
correctly red through the config pins) and never the WIRING that delivers them.
Part 3's behavioural checks cannot close the gap either, and the comment now
says why: B is reversed at ~2000 W with short holds, so it trips under EITHER
pair, and a fault big enough and long enough to trip under both thresholds is
evidence about neither.
THE FIX - test_watchdog.py part 3, on the real two-inverter Controller. Every
`inv.sign_wd.cfg` equals the per-unit pair, `site_sign_wd.cfg` equals the site
pair, and each detector's `against` is its own instrument. Graded against
LITERALS, because `== self.unit_sign_cfg` cannot tell "the per-unit pair" from
"whatever this Controller was handed". Both pairs are printed.
⚠️ AND THE FIXTURE HAD TO CHANGE FOR THE PIN TO MEAN ANYTHING - the recursion
worth naming, because it is the same defect class as the one being closed.
`_sign_site` set `sign_disagree_s`, `unit_sign_disagree_s` AND
`site_sign_disagree_s` all to 1 s, with two of the three magnitude gates at
300 W. A bare equality there discriminates on `min_w` alone: a mis-wiring that
delivered the right magnitude with the WRONG HOLD compares EQUAL to the pair it
was supposed to have. A check comparing two things that happen to be equal
cannot see the difference it names (MUTATION.md's third failure mode). The
three pairs are now pairwise distinct IN BOTH COORDINATES - 250 W / 1 s per
unit, 320 W / 2 s site, 300 W / 3 s shipped - a PRECONDITION asserts that
distinctness so a later edit collapsing them goes red rather than quietly
vacuous, and a second PRECONDITION asserts the literals are the pairs this
Controller actually holds.
VERIFIED BY BREAKING IT. Three mutations, each on its own freshly copied tree
with no __pycache__, mounted :ro, through tools/mutation_run.sh:
M14 arm() handed self.sign_cfg at every roster -31 B RED (was the
survivor; the AC 5 WIRING check names the wiring)
M15 right min_w, SHIPPED hold - hold-only mis-wire +78 B RED, same pin
M16 right min_w, SITE hold - hold-only, and it still
trips inside the drive +83 B RED, and the
ONLY red in test_watchdog.py
M16 is the one that says the hold half of the equality bites on its own.
Unmutated tree: 19 pass, 0 fail, purged and fingerprinted.
Ledger rows M14-M16 and the write-up in DECISIONS/SAFETY-30-per-unit-sign.md
section 5; a CHANGELOG bullet.
NOT IN THIS PR, so it gets a card rather than a quiet fix: a stuck per-unit
power sensor would false-trip the new per-unit check at N>=2. Already-merged
SAFETY-31 has the identical exposure on the same fixture. Pre-existing N>=2
class, unreachable at the deployed roster of one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
22c016fadd |
CFG-04 fix round: the probe now measures the key it is cited for
checks / unit (pull_request) Successful in 4m33s
Review blocked this on ONE finding, and it was in the EVIDENCE, not the code.
The design and the shipped behaviour are unchanged.
THE INSTRUMENT DID NOT MEASURE WHAT FOUR DOCUMENTS SAID IT MEASURED
tools/supervisor_shape_probe.py section B validated the RECONSTRUCTED LIVE
options blob - which is missing many required keys that have nothing to do with
this card. Its refusal therefore named whichever of THOSE the validator reached
first: the committed transcript recorded `meter_freeze_s`, the reviewer's run
printed `p1_phase_export_entities`, and a run today printed `sign_disagree_w`.
The string those documents quoted - `Missing option 'inverters' in root` - was
never produced by the instrument.
And section B asserted a bare `not ok`. Any refusal at all satisfied it, so
deleting `inverters: []` from config.yaml left it printing PASS: the probe could
not detect the one regression it is the committed evidence for.
THE UNDERLYING CLAIM IS TRUE AND IS NOW MEASURED PROPERLY
Section B is an ISOLATED COUNTERFACTUAL now: the full shipped `options:` block
(expect accept), and the SAME block with `inverters` removed and nothing else
varying (expect refusal). One key differs, so the refusal has nowhere else to
come from - and the assertion is on the KEY NAME IN THE REFUSAL STRING, so the
line goes red if the refusal is ever about a different key. The probe also exits
non-zero when any case fails; without that a transcript is only evidence to
someone who reads it.
The old live-blob case survives as section B2, relabelled for what it is: an
incomplete reconstruction whose RAW half is refused for unrelated missing keys,
kept because its MERGED half genuinely demonstrates what App.options is.
VERIFIED BY BREAKING IT, WHICH IS THE WHOLE POINT
`inverters: []` deleted from config.yaml, probe re-run against
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0:
== B. THE LOAD-BEARING ONE: an options blob with no `inverters` key ==
FAIL the shipped `options:` block validates AS IT STANDS - ...
Missing option 'inverters' in root in GoodWe RS485 Controller
...
== 2 CASE(S) FAILED ==
exit 1. Key restored, same image, same command: `== all cases passed ==`, exit
0. The transcript in DECISIONS is that restored run, re-run today, not the old
one.
THE FOUR MIS-CITATIONS
DECISIONS/CFG-04-inverter-roster.md section 4's "section B shows both halves"
sentence and its line-103 transcript; CHANGELOG.md; test_inverter.py's part-9
comment and its part-12 mutation row; and the PR description. All now attribute
the string to section B and say plainly what section B2 does not show. The
part-12 row additionally records that the probe was blind to that mutation until
review, because a mutation table that lists a defeat nothing catches is the
failure this repository keeps finding.
MUTATION.md gains it as the sixth instance of "the check's data cannot reach the
failure it names" - the first found OUTSIDE the suite, in a committed evidence
artifact, because nothing in that document had ever been pointed at tools/. Three
habits with it: assert on the CONTENT of a refusal rather than that one happened;
vary ONE thing, from an ACCEPTED baseline; and treat a committed transcript as a
check subject to the mutation battery.
TWO NON-BLOCKING FINDINGS, ALSO FIXED
validate.py rules 5 and 6 NAMED THE WRONG INVERTER. `unit_caps()` SKIPS an
entry with no usable `max_w`, so caps position i stops being roster position i
the moment one entry is malformed - and rule 5 fed `caps.index(weakest)` to
`_unit_label()`, which indexes the roster, while rule 6 paired `caps[0]` with a
hard-wired `_unit_label(opts, 0)`. Right number, another inverter's name: an
operator sent to the wrong device by the message that exists to stop that. New
`unit_caps_indexed()` carries the roster index with the cap. It is latent -
rule 9(d) refuses such a config in the same pass - but that was a property of
today's rule ordering, not of the code, and validate_startup_config() is also
sim/inject.py's entry point. Part 10 gains a check that asserts the fixture
really does desynchronise the two lists, then that both messages name the right
unit, then the counterfactual: the OLD expressions, on that same config, still
evaluate to the wrong names.
DOCS.md called a downgrade "safe". True of the SCHEMA - the older schema drops
the key with a warning - and incomplete about the ADD-ON: rule 9(e) requires the
flat `setpoint_entity` be cleared when a roster is declared, so a config that
downgrades cleanly is by construction one whose flat actuator is blank, and the
older version has nothing to write to. Confirmed by running release/1.0's own
validate_startup_config() on that blob: one refusal, naming setpoint_entity.
Recoverable and loud, but an outage until a field is refilled. The note says so
now, and tells the operator to write the entity down before downgrading.
19/19 green in the goodwe-addon-ci image.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
753f700539 |
DEV-02: merge release/1.0, and close the two review blockers
checks / unit (pull_request) Successful in 4m15s
PR #43 was rejected on two findings and had gone unmergeable behind SAFETY-28 and the ALLOC-01 fix. This is the merge commit; the conflict resolution and both fixes are below, because a merge that also changes behaviour has to say so. THE MERGE, AND WHAT IT COST =========================== Two conflicts in main.py, both where SAFETY-28 and this card touched the same method: * `write_setpoint()` split into a site entry point plus a per-unit `_write_unit()` here, while SAFETY-28 put its record gate in the old single-unit body. `write_refusal()` now takes the INVERTER it is about. Asking it with `self.unit` - as release/1.0 does - would authorise A's share on B's wire, and would also trip test_inverter.py part 1's AST scan, which goes red on any read of a Controller N=1 view inside Controller. * the SAFETY-28 gate sits above the clamp and below the non-finite coercion, exactly where release/1.0 put it, and is now asked about `inv`. Fallout the merge produced in the checks, re-aimed rather than relaxed: * every fixture that calls `write_setpoint()` DIRECTLY - the clamp probe, the hung-heartbeat experiment and its serialised counterfactual, the split deadline probe - now arms a real allocation record first. test_inverter.py grows an `arm()` built through the shipped `resolve()`, the same shape test_clamp.py's is: the per-unit envelopes are filed as real `supervised` claims and the site target is their sum, so the water-fill has exactly one answer and the fixture cannot quietly become its own allocator. * test_clamp.py's two-unit failsafe fixture assigned `c.units`, which DEV-02 made a derived property with no setter. It appends a real `Inverter`. BLOCKER 1 - AC 4's ARBITER-BOUND EVIDENCE WAS VACUOUS ===================================================== test_peakguard.py claimed "each unit's `supervised` bound is ITS OWN `max_w` - give inverter B a different rating and the claim filed on inverter B moves with it", and compared B's bound against `_b2.max_w`. `second_inverter()` DEFAULTED B's rating to `LIVE_MAX_W`, which is A's. IT COMPARED (-5000, 5000) AGAINST (-5000, 5000): a number against itself, unable to observe the failure it named. Reproduced: filing the per-unit `device`/`supervised` claims from `self.inverters[0]`'s numbers while leaving `actuator=self.actuator_of(..., inv)` alone left ALL 19 FILES GREEN. At a roster of two with A=5000/B=3000 that makes inverter B's planned envelope (-5000, 5000) instead of (-3000, 3000) - a 2000 W over-command the arbiter believes is supervised. It is a DIFFERENT defect from the actuator variant the static routing pin catches, and only the actuator variant was in the mutation table. * `second_inverter()` gives B 3000 W - a hypothesis, marked as one - for every fixture in the file, so no check there can compare B against A by accident. * the claim is asserted against LITERAL numbers rather than against an expression that can read the same object twice, for `supervised` AND for `device` (whose `dev` had the same defect), plus the envelope the ARBITER RESOLVED for B - a claim filed right and resolved wrong is the same defect one layer down. * a PRECONDITION check fails if the two ratings are ever made equal again. * the BOUND mutation is in the table, beside the actuator one, and was observed RED (3 checks in test_peakguard.py) via tools/mutation_run.sh. ⚠️ THIRD INSTANCE THIS SESSION of a check whose DATA cannot reach the failure it names - ARB-03's call-site scan saw 3 of 8 appends, ALLOC-01's replay generator saw 0 escapes where a widened one saw 39,991. MUTATION.md gains a section for it beside the stale-bytecode and masking ones, and the rule is written at the line: ANY CHECK ASSERTING "X DIFFERS FROM Y" MUST BE RUN ONCE WITH X AND Y ACTUALLY MADE DIFFERENT. BLOCKER 2 - THE LATCH WAS SILENTLY DISARMED BY THE ROLLBACK ROUND TRIP ====================================================================== store.py's rule 2 keeps the flat keys "so a rollback still finds its latch". The operator path that rule contemplates is a ROUND TRIP, and this card lost the latch on it: 1. install migrates -> schema_version=1, unit/inverter-a/... = False 2. operator DOWNGRADES; SAFETY-02 trips inside the downgrade window and the old add-on writes only the flat `sign_tripped: true` 3. operator upgrades again -> `rep.ran` is False, so `_grade_store_migration()` returned early, and `Inverter.__init__` read the namespaced key: False Observed: the controller came up with `sign_wd.tripped = False` while /data/state.json said `sign_tripped: true`. That is store.py's own header sentence - "the file still says sign_tripped: true, the banner is green, and the controller resumes driving the wrong way" - rebuilt by the version marker. A firmware version change with a rollback path was performed on this site on 2026-09-02, so this is not hypothetical. Three changes, each graded by a mutation observed RED: * THE OPERATOR'S CLEAR CLEARS BOTH SPELLINGS. That is what removes the "re-arm forever" objection the `rep.ran` gate existed to answer: with the fossil cleared, `flat True + namespaced False` is unambiguously a downgrade-window latch. * A TRIP MIRRORS THE FLAT PAIR. Rule 2 only ever covered the latch present AT MIGRATION TIME - nothing flat was written for a trip taken afterwards, so an operator who downgraded found `sign_tripped: false` and came up DRIVING. store.py claimed the staleness could only fail toward a SPURIOUS latch. It could not; that sentence is corrected rather than left standing. * THE GUARD RUNS ON EVERY START, and asks the STORE - not the roster - whether anything namespaced is latched. A mirrored trip on B must not re-arm a healthy A, and the store is the right place to ask because it holds every `unit/*/sign_tripped` this install has written, including one belonging to a unit that is not in today's roster. Graded by the three-step scenario itself, run end to end in test_inverter.py part 2, with all three preconditions asserted - including that the catching start did NOT migrate anything, which is what used to skip the guard. Part 2's idempotence check now PRESSES the button instead of hand-writing the store, because the state it used to construct is not one the shipped clear path can produce. Part 3 grades the mirror and the clear on a real roster of two. test_watchdog.py's "the flat key is NOT written" assertion is re-aimed rather than deleted: its argument was right about the READ and wrong about the WRITE. Nothing at runtime reads that key, and the one thing that does ignores it whenever any namespaced latch is set - which a sibling's trip always leaves. What its absence actually cost was the rollback. HANDOVER.md and DOCS.md said the flat key "is never read again", which was the instruction an operator clearing a trip by hand would follow. Both corrected: it is a maintained rollback view, it IS read on every start, and clearing a trip by hand means clearing both spellings. MUTATIONS THIS ROUND, each applied on a purged tree via tools/mutation_run.sh and observed RED: * per-unit `device`/`supervised` BOUNDS from `self.inverters[0]`, actuator left correct -> test_peakguard.py 10g * `_grade_store_migration()` gated on `rep.ran` -> test_inverter.py part 2 * the operator's clear leaving the flat keys set -> test_inverter.py part 2/3 * a trip not mirroring the flat pair -> test_inverter.py part 3 * the guard asking only `self.inverters[0]` -> test_inverter.py part 3 19/19 files green on the merged tree. No hardware was touched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
d48c6b971d |
DEV-02: correct the operator instruction the latch rename made wrong
checks / unit (pull_request) Successful in 4m13s
HANDOVER.md told an operator to "delete `sign_tripped` from /data/state.json" to clear a sign-disagreement trip by hand. After this card that key is a FOSSIL: the live latch is `unit/<inverter>/sign_tripped`, and the flat one is left in the file only so a rollback finds its latch and is never read again. Following the old instruction would delete a key that clears nothing, on the one path an operator reaches while staring at a FAILSAFE banner - so the instruction would look like it had failed. DOCS.md's §SAFETY-02 section named the file but not the key; it now names both, and says why there is one per inverter. No code change; the suite is unaffected and green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
e941bf7f62 |
DEV-02: zero every unit's target when the operator stops, not just the site's
checks / unit (pull_request) Successful in 4m12s
A hazard THIS CARD CREATED, found by re-reading the operator paths rather than by a failing check - so a check now exists for it. Before DEV-02 the heartbeat re-emitted `self.target`, so the stop button zeroing that one scalar was enough: the next refresh carried the zero. The heartbeat now re-emits `inv.target` - it has to, or it would put the SITE number on every inverter, which is the ARCH-01 broadcast fault arriving once every `heartbeat_s` on a path with no allocator in it. So a stop that zeroed only `self.target` left each unit's LAST SHARE standing. No cycle has run yet at that moment, so nothing else would have cleared it, and the very next heartbeat - up to `heartbeat_s` later - would put it back on the wire. The operator presses stop, watches the inverter go to zero, and sees it resume seconds later. `command_site_zero()` moves both together and carries the reason. It is one method rather than two lines at three call sites precisely because the two lines have to stay together: shutdown(), the stop button and maint_abort all mean the same thing, and 0 W is the one value that may legitimately be written site-wide. test_inverter.py part 6 presses stop with 995/995 standing and then runs the SHIPPED `send_heartbeats()` before any cycle, which is the racing refresh. The mutation that zeroes the site target alone takes it red. 19 files green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
e306c74cb8 |
DEV-02: per-instance inverter objects, so nothing is graded against a sibling
checks / unit (pull_request) Successful in 4m15s
Every piece of per-device state in the Controller was a scalar, and several of
them are safety inputs. app/inverter.py introduces an `Inverter` that owns its
entities, its rating, its device range, its SignWatchdog, its last_write and
its last_beat, under store keys namespaced by unit name. The Controller holds a
list of them and keeps only what is genuinely site-level: the P1 reading, the
site integrator, and the tariff quarter.
WHAT THE SCALARS WERE SILENTLY ASSERTING
dev_min/dev_max one pair, read from one setpoint_entity. A second
inverter's rating was NEVER READ AT ALL, so its clamp, its
freeze rail and the peak guard's achievable_w were the
first one's numbers.
soc one reading into the maintenance FSM, SAFETY-24's pack
floor and arbitrage's bounds. An average lets the emptier
pack be driven below its own floor.
sign_wd ONE SignWatchdog, whose own docstring says "one instance
per actuator", latched under FLAT store keys - so a trip
caused by B latched A across restarts and one operator
press cleared both.
last_beat one deadline and one serialised hass.call(), so a hung
write to A ate B's 30 s layer-1 budget.
actual_w BECAME SITE LEVEL WHILE THE READINGS BECAME PER-INSTANCE, and those
are opposite directions on purpose. compute()'s anti-windup test is
`abs(prev_w - actual_w) > saturation_w` with prev_w the SITE target, so feeding
it one inverter's power compares a site number against a unit number, trips the
saturation freeze permanently and leaves a standing grid error. Measured on
admin/goodwe's two-master rig (tools/alloc_sim.py --splits, 2 kW load, 40
cycles), worst |delta| between splits at the meter:
gain one unit's power the site sum
0.2 510 W 0 W
0.4 250 W 0 W
0.6 50 W 0 W
That was ALLOC-01's disclosed finding; test_alloc.py part 8 kept its stimulus
and now grades the other row.
THE STORE MIGRATION IS GRADED, NOT MERELY PERFORMED. sign_tripped/sign_reason
move to unit/<name>/... once, verified by READING BACK what was written, and
the version marker is set only when nothing that carried state was lost. The
flat keys stay in the file so a rollback still finds its latch. And __init__
compares the pre-migration flat latch against what unit A actually came up
with: if the latch did not survive it is RE-ARMED from the legacy value and
shouted about. A latch that silently fails to migrate is a disarmed safety
guard on an install whose state file says it is protected - which is strictly
worse than never persisting it, because now there is evidence saying it is
fine. test_inverter.py part 2 breaks the migration and watches the guard fire.
HEARTBEATS ARE INDEPENDENT AND THE BUDGET IS DERIVED. Each inverter owns its
deadline, the refreshes go out concurrently, and each write is bounded by
WRITE_BUDGET_S = 4 s. The inequality is graded rather than asserted: 4 +
heartbeat_s's schema maximum of 25 < 30, validate.FIRMWARE_INGEST_VETO_S.
Verified by injecting a transport that accepts and never answers.
live_units() ANSWERS HONESTLY. A unit whose every write has been rejected for a
whole layer-1 window is not commandable; the allocator drops it, the survivors
carry its share, the action log names it, and the dropped unit is commanded
0 W rather than left standing on its last share. A roster entry with NO
setpoint_entity is deliberately not handled there - that is a configuration
that cannot be delivered, and it reaches main.py's refusal branch, which is how
that branch keeps a live trigger after this card removed its old one.
AT N=1 NOTHING MOVES. The removed scalars survive as read/write properties of
the sole inverter, so every existing replay runs unmodified - and they REFUSE
at N>=2 rather than answering for the first inverter, which is the tripwire on
a consumer that was never made per-instance.
EVIDENCE. 19 files green. test_inverter.py is new (82 checks). Eighteen
mutations were applied to the shipped modules on a purged tree via
tools/mutation_run.sh and each was observed RED. Two of them SURVIVED first
time and the checks were changed rather than the table: the serialised dispatch
and the shared last_beat both passed while part 6 gathered its own writes and
computed its own `due` list. send_heartbeats() was extracted from run_control()
so the shipped dispatch is reachable by a check at all.
INVERTER B STILL DOES NOT EXIST. build_roster() returns exactly one inverter
from the flat options and is the one function CFG-04 changes; every two-unit
figure here is architecture, and the second rating in the checks is marked a
hypothesis.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
3fe3c7acb0 |
ALLOC-01: clamp each envelope end on its own, and widen the replay that missed it
checks / unit (pull_request) Successful in 4m9s
Two blocking findings from the review of
|
||
|
|
186e13ceca |
ALLOC-01: allocate the site grid target, sum-invariant, never broadcast
checks / unit (pull_request) Successful in 4m6s
ARB-03 left `allocate()` as a documented seam that passed through at N<=1 and
DECLINED at N>=2, with the reasoning recorded at the line: the obvious
placeholder - hand the site target to each inverter - IS the ARCH-01 fault, and
"a placeholder that cannot be told apart from the real thing by any guard we
have is worse than no placeholder". This fills the seam.
WHY A BROADCAST IS A FAULT AND NOT AN INEFFICIENCY. The control primitive is
meter emulation: the master reports a FABRICATED grid reading and the inverter
regulates itself to it, so one number means "the battery moves X W" to this
add-on and "the grid is at X W" to the inverter - an identity that holds only at
N=1. Hand two inverters the same target and each supplies it IN FULL, so the
loop gain at the meter becomes N times the per-unit gain. Reproduced on
admin/goodwe's two-master rig against this tree: -400 W export overshoot at
gain 0.6, and at gain 1.0 - which config.yaml accepts as its documented maximum
- a permanent 4000 W peak-to-peak limit cycle, commands alternating
1000/2000/1000/0 W, with NOT ONE GUARD FIRING. The sign watchdog is evaluated on
every cycle and never trips, because the disagreement flips on alternate ticks
and never holds its 180 s window.
WHAT WAS BUILT. `app/allocator.py`, host-side and strictly above the driver
seam. It consumes intervals and a number and nothing else - no claims, no
priorities, and no measurement, because an allocator that read P1 would be a
SECOND integrator on the site measurement.
* ENVELOPE CLAMP, from ARB-03 stage 1's `UnitBounds`, read PER TICK. `plan()`
is a pure function and the module holds no state, so there is nowhere for a
construction-time envelope to be cached.
* WATER-FILL by weight, with the surplus of a full unit moving to the others.
Weights express PREFERENCE, envelopes express PERMISSION: a zero weight is
honoured while another unit has headroom and overridden when it does not,
because the SUM is the safety property and stranding capacity behind a
comfort choice manufactures a standing error at the meter.
* LARGEST-REMAINDER ROUNDING. The target is rounded ONCE, at the top; every
sum below that line is integer arithmetic, so it cannot drift. Measured: a
per-unit round() drifts -125 W over 50 000 ticks at three inverters.
* THE ALLOCATION RECORD, with `unmet_w`, `dropped` and a per-unit reason.
`ok` is one boolean for SAFETY-28 to gate on, and `unmet_w` is deliberately
NOT fused into it - a saturated inverter must not park the whole site.
THE STABILITY PROPERTY IS STRUCTURAL. With sum(a_i) = 1 the gain at the meter is
sum(a_i * g_i), a convex combination of the per-unit gains, so it lies in
[min g_i, max g_i] FOR ANY SPLIT. That is what makes the weights a comfort
choice rather than a tuning parameter, and it is why a bad split cannot
destabilise the site. Graded directly, and measured end to end: 90/10, 50/50 and
100/0 give a bit-identical aggregate trace at the meter, and a gain sweep to 1.0
at every split has a flat tail at all 84 combinations.
THE TWO REFUSALS main.py NOW MAKES, AND WHY THERE ARE TWO. `allocated=False` no
longer means "no allocator"; it means no safe split EXISTS - a mis-routed claim,
an empty envelope set, or a split that could only be made by commanding one pack
to charge while another discharges. Separately, and this guard is the price of
making allocate() succeed rather than something inherited, main.py refuses to
act on a valid split it cannot DELIVER: there is one `setpoint_entity`, so
writing the site target to the one inverter it can reach would over-command that
inverter by exactly the other's share. Commanding a partial site is a worse
instruction than commanding nothing. DEV-02 gives it the second writer.
scope_violations() IS NOW CONSUMED, AND AS A REFUSAL. ARB-03 left it logging
without changing the resolution. A mis-routed claim means the number about to be
divided may itself be wrong, and dividing a number that may be wrong is worse
than not dividing it - the same answer clause 3 already gives a contradiction.
It is NOT promoted into an envelope: a unit-scoped claim that landed on the site
names no unit, so promoting it would mean guessing one, or applying one
inverter's rating to a sibling that does not have it.
AT N<=1 IT IS A PASS-THROUGH, BYTE-IDENTICAL: the share is the site target, as a
float, unrounded and unclamped. All 17 existing suites replay unedited.
test_peakguard.py part 10h and test_arbiter.py part 7 were RE-AIMED, not
deleted. PR #40's reviewer proved that refusal branch was unreachable and
untested once; removing its trigger without giving it a new one would have
rebuilt the hole. Part 10h now drives three real cycles - a deliverable split, a
dead sibling, and every master stale - and grades all three.
KNOWN AND NOT CLOSED HERE: at a roster of two the loop's `actual_w` is one
inverter's power used as a site measurement, because the add-on binds a single
`inverter_power` sensor. That is the SENSING half of the same N=1 identity this
card removes from the COMMANDING half, and it makes the aggregate trace
split-dependent below gain 1.0. Measured with its counterfactual in test_alloc.py
part 8 and tools/alloc_sim.py --splits. It belongs to DEV-02.
Verified: all 18 suites green in the CI image. 16 mutations run through
tools/mutation_run.sh on a purged tree; 13 red, and the 3 survivors are recorded
in test_alloc.py part 12 with the reason each cannot be caught.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
38f6680639 |
ARB-03: pin all eight actuator call sites statically
checks / unit (pull_request) Successful in 3m45s
The independent review of PR #40 found AC 6 met at three of the eight claim construction sites while the check claimed to cover the class - this repository's named failure mode, a check observing something other than what it claimed, reproduced inside the check written to prevent it. WHAT WAS WRONG. Part 10g's only call-site check was a real two-inverter cycle. That cycle is auto, P1-healthy, no maintenance procedure, no tripped sign watchdog, the guard idle and arbitrage off, so it reaches `device`, `supervised` and `loop` and nothing else - three of the eight direct appends in `Controller.cycle()`. Its own check text nevertheless read "the line a mis-typed `actuator_of(...)` at a call site goes red on", and the PR body's mutation table presented it as covering the class. The reviewer proved the gap on the shipped tree: `actuator_of("maintenance")` -> `actuator_of("peak")` at the maintenance-shaping append left test_arbiter.py, test_peakguard.py and test_arbitrage.py ALL GREEN, while at a roster of two it built a site loop of -1500 W against the guard's site `lo` of 1500 W and the now-misrouted charge-only `hi = 0` - `lo > hi`, arbiter clause 3, 0 W IN THE BILLED QUARTER, SAFETY-25 §0a's fatal case from a one-word typo. The same mutation at the `failsafe` append survived too. The runtime detector does fire - `scope_violations()` names it - but it only logs; nothing graded it. THE FIX, over machinery already present. Part 10f's AST scan already reads each `claims.append(...)` in `Controller.cycle()`. All eight direct appends expose their actuator statically as `actuator=self.actuator_of('<literal>')`, so 10g now requires that literal to EQUAL the source the claim is filed under, read as SOURCE TEXT rather than evaluated - `actuator_of("peak")` at the maintenance append is the same VALUE at the shipped roster of one and a different string here. That pins all eight routing sites, on the line the typo is written, in every configuration and at every roster, exactly the way 10f pins the source set. Two further lines keep the scan from going vacuous: every direct append must be readable in that shape (a computed or hand-written actuator fails there), and the ten appends must still account for 10f's eight sources. The two INDIRECT appends carry no `actuator=` at the call site to mis-type. `peak_claim` and `arb_claim` are routed where their claim is built, and are pinned there - `peakguard.claim()` by the SWEEP_SITE sweep over every load x elapsed x battery x SoC x floor combination, `arbitrage.claim()` by test_arbitrage.py. 10g now says so at the line, and says which three of the eight its dynamic cycle actually sees, rather than rounding up. MEASURED, NOT ARGUED. Each of the eight direct appends mutated in turn to a wrong actuator - device->loop, supervised->peak, failsafe->safety, safety(stopped)->device, safety(inputs missing)->maintenance, maintenance(SET)->loop, maintenance(shaping)->peak, loop->supervised - each on a purged tree via tools/mutation_run.sh over all 17 files. All eight RED, every one of them on the new line. TWO SURVIVING MUTATIONS OF THE SAME CLASS, ALSO CLOSED. * `resolve()` did not have to honour the roster it was given: `if units is None:` -> `if True:` left the whole suite green. Ignoring the argument makes a unit that filed nothing this cycle cease to exist - a site of two reads as a site of one and stage 3 passes the whole grid target through to the inverter that spoke, which is ARCH-01's fault with its own evidence removed. test_arbiter.py part 7 now grades it on the one case that separates the two, a SILENT unit. Mutation RED in test_arbiter.py and test_peakguard.py. * main.py's N>=2 refusal was unreachable and ungraded: `if not resolution.allocated:` -> `if False:` left the whole suite green, so the right instinct was one refactor away from being deleted as dead code. The guard is KEPT - the alternative default is the ARCH-01 fault - and new part 10h grades it on the two-inverter cycle that already existed: the 0 W, the `failsafe` winner, the log line naming the roster size, and the write that follows. Mutation RED. AND THE WORDING CORRECTED. main.py does not "refuse to write" on `allocated=False`. It SUBSTITUTES a 0 W failsafe and then calls `write_setpoint(0.0)` like any other cycle. 0 W is the correct fail-toward-inaction direction and the log text is accurate about what it refuses - to BROADCAST the site target - but "refuses to write" overstates the code. Said as what it does, in the CHANGELOG, at the branch in main.py, and in 10h's own check text. `resolve()`'s docstring now says the roster argument has to be HONOURED, which is not the same as existing. 17/17 green on a purged tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ab6fbe2f9e |
SAFETY-25: record the owner's decision - option a, with alloc-01 as the revisit trigger
checks / unit (pull_request) Successful in 3m43s
The product owner answered SAFETY-25's §1 on 2026-09-02: OPTION A. The BMS cut-out is the DELIBERATE and only backstop for demand-driven discharge on the grid-following loop, and Option B - a loop-side reserve floor - is DEFERRED rather than rejected. Option A is the shipped behaviour, so this is a recording, not a change. `app/` is touched in exactly two places and both are comments that claimed the question was still open (freeze.py's finding, peakguard.py's point 2). No setpoint, no claim, no option, no default moved. The 17-suite run is green on the same numbers as before. WHY DEFERRING IS THE SAFE DIRECTION. Option B's proof - bms_soc_floor < loop_soc_floor <= peak_soc_floor, run through the real arbiter in test_peakguard.py part 10d - has an unstated premise: the reserve predicate and discharge_permitted() read the SAME state of charge. With two packs they read different numbers. The reserve binds on the emptier pack and files hi = 0; the peak guard, reading the other pack or an average, files lo = excess > 0; lo > hi is arbiter clause 3 and the answer is 0 W - the battery parked in the billed quarter, which is the failure the memo itself marks fatal. And no config validator can catch it: it is a runtime divergence between two readings, not a config ordering, so CFG-01's shape of check passes on the exact tree that parks the battery. Option A's cost is energy; Option B's cost built too early is the one quarter of the month that sets the bill. THE REVISIT TRIGGER IS ALLOC-01, recorded in §0c. Under ARCH-01's host-side allocator the two claims stop sharing a list: the loop floor becomes a per-inverter `hi` consumed as an INTERVAL at stage A, while the peak floor stays a site `lo` at stage B. Different claim lists, different stages, structurally unable to cross - a stronger argument than the ordering rule, because it makes the crossing unreachable rather than harmless. That is the shape in which Option B becomes safe to build. WHAT HAS TO BE RE-EARNED AT N=2 is stated plainly in §0d rather than left for someone to discover. SAFETY-27 now names five floors across two packs, so "the SoC" stops being a scalar the config can bound. And part 10f - the pin that made SAFETY-25 mergeable - CANNOT EXPRESS TWO PACKS: its dynamic half is a scalar 0-100 % SoC sweep against a single-series capture, and a two-pack exposure lives in the DIFFERENCE between two readings, which that sweep visits at no resolution. Its static half, the AST scan pinning the eight claim sources, still bites at N=2; the dynamic half goes silent. Merging a two-pack reserve on 10f's current green would be merging on a check that cannot see the failure. The options analysis is kept intact on purpose - §1-§7 stand exactly as written, including §5's "(recommended)" on Option B and §6's recommendation, with a new §0 saying what was decided and §6 marked as answered. A memo that recorded the verdict and deleted the reasoning is the failure this DECISIONS/ directory exists to prevent. Part 10e's check moved with the memo: it used to pin the honest "Status: OPEN" line and now pins that the memo names the option, the date and the trigger AND still carries both options and the recommendation. The tripwire itself stays - a ninth claim source in Controller.cycle() still takes 10f red on the line it is written, and "deferred" is precisely the state in which someone builds Option B by accident. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
acbdf41248 |
TEST-13: grade record_block()'s policy argument, and the two import spellings SAFETY-20's check could not see
checks / unit (pull_request) Successful in 3m55s
Two survivors from TARIFF-01's review, closed in test_tariff.py only. app/ is
untouched and no shipped behaviour changes. Two further items on the card were
already closed in shipped code and are struck rather than re-implemented.
ITEM 1 - the per-phase reduction's PLUMBING was unobservable.
Mutating PeakStore.record_block()'s `billed_w(block, policy)` to
`billed_w(block, POLICY_NET)` (+4 B) leaves all 17 suites green. Measured
three times now: by TARIFF-01's reviewer, re-run through TEST-15's purge
harness this session, and re-run again here on a clean uncontended tree.
The product code is correct - tariff.py:363 really is
`def record_block(self, block, policy: str = POLICY_NET)` and it really does
forward the argument. What was missing is any check that could tell that
apart from a hardcoded call, and the reason is the third instance of one
shape: A STIMULUS EVALUATED WHERE THE QUANTITY IT GRADES IS DEGENERATE, after
WITNESS-02's span normalisation and TARIFF-01's own DST window.
billed_w() short-circuits to block.offtake_avg_w whenever
per_phase_offtake_avg_w is absent. Every block reaching a store anywhere in
the suite was single-phase or built as _CB(..., None) - so NET, SUM and MAX
collapsed onto one number and the argument could not be observed at the call
site. Section 7 grades the FUNCTION on an unbalanced fixture and says nothing
about its CALLER.
So section 7's own (3000, 500, -200) W block - whose three reductions are
3300 / 3500 / 3000 W, asserted distinct three lines above - is now
round-tripped THROUGH the store under each policy and read back from both
tables (peak() and recompute_month(), so both writes in that single
transaction are graded). record_block() with no policy is checked to bank the
NET figure. An anti-degeneracy pin is stated over the three BANKED numbers,
not over vals, because it is the banked ones this triple is made of.
Direction, which is why this is worth plumbing-testing: NET is the SMALLEST
of the three here, because the exporting phase nets off. A store that quietly
billed NET while asked for MAX would UNDER-record the month's peak, and an
under-recorded peak is maximum headroom for SAFETY-07 - the same
under-guarding direction as the getattr(..., 0.0) default SAFETY-07 already
removed from project().
ITEM 2 - the SAFETY-20 import grader claimed more than it checked.
It collected `m.module for m in walk(tree) if isinstance(m, ImportFrom)` and
matched a literal list of six spellings. That is blind to two of Python's
three import forms:
* `from . import arbiter as _ab` is an ImportFrom whose .module is None -
the dots are in .level and the module name is in .names - so it collected
"" and matched nothing;
* `import app.arbiter` is an ast.Import node the walk filtered out entirely.
Both were added to app/tariff.py and the shipped suite stayed GREEN, with the
_names half not firing either (an alias import creates no Name node). Low
severity: any USE of either alias still produces an Attribute the _names half
catches. But the printed sentence "it imports nothing from arbiter, clamp or
control" was false for two forms out of three, and a check that overclaims is
worse than one that claims less, because the next reader stops looking.
_imported_modules() now walks Import as well as ImportFrom and joins
`from . import X` onto its base. _forbidden_imports() matches per dotted
SEGMENT, not by substring: `app.arbiter` must not hide behind a package
prefix, and `clamps` / `arbiters` / `controller` must not be falsely flagged -
`controller` is not hypothetical, this package is goodwe_controller. Five
evasion spellings and eight innocent ones grade the grader itself, and both
the name half and the import half are shown non-vacuous against main.py,
which carries all three import forms and really does import all three modules.
ITEMS 3 AND 4 - ALREADY CLOSED. STRUCK, NOT RE-IMPLEMENTED.
Item 3: app/tariff.py:266-267 is `def is_observed(self): return self.state ==
OBSERVED`, already false for NO_RECORD. Nothing to do.
Item 4: app/tariff.py:672-680 reads `held = max(averager.held_net_w, 0.0)`
with NO getattr default, and carries a comment naming TEST-13 item 4. Fixed
by SAFETY-07 in 616b62d; held_net_w is a documented public property on
QuarterAverager (p1.py:290), so a rename raises AttributeError on the first
cycle instead of crediting 0 W held. A projection crediting 0 W held reads
LOW, which is the under-guarding direction for SAFETY-07's floor. Restoring
a default would re-open a money guard.
And the recorded NON-finding stays a non-finding: removing clear_partial()
is still green and that is a PASS. quarter_start is the PRIMARY KEY and the
upsert carries `ON CONFLICT ... WHERE excluded.offtake_avg_w >
quarter_blocks.offtake_avg_w`, so a replay is a structural no-op. That
demonstrates TARIFF-01's "double counting is answered structurally, not by
care" claim rather than contradicting it.
EVIDENCE - all through tools/mutation_run.sh (recursive __pycache__ purge,
python3 -B, tree re-read from disk, mutated line grepped off disk, byte delta
recorded), in the goodwe-addon-ci image against a :ro mount.
baseline, 17 suites, before any edit ............................ 17 PASS
baseline, 17 suites, final tree ................................. 17 PASS
A1 billed_w(block, policy) -> billed_w(block, POLICY_NET) +4 B 5 RED
the 'sum' and 'max' round-trips, both tiers, and the distinctness pin
A2 billed_w(block, policy) -> billed_w(block, POLICY_MAX) +4 B 6 RED
the 'net' and 'sum' round-trips, both tiers, the default, and the pin
B1 add `from . import arbiter as _ab` to app/tariff.py +29 B 1 RED
the import sentence. SHIPPED suite on the same tree: GREEN - the hole.
B2 add `import app.arbiter` to app/tariff.py +19 B 1 RED
the import sentence. SHIPPED suite on the same tree: GREEN - the hole.
B3 add `from .control import compute` (the form the OLD grader DID
catch, as a regression control) 1 RED
C1 delete the ast.Import branch from _imported_modules() 1 RED
names the two spellings it MISSED, so the grader-grader is live
C2 segment match -> substring match (`any(f in m for f in ...)`) 1 RED
names clamps / arbiters / controller as falsely flagged
CONTROL, and the point of the whole ticket: A1 graded by the SHIPPED
test_tariff.py is green, and A1 graded by all 17 SHIPPED suites on a clean
uncontended run is 17 PASS, rc=0. The survivor is real and it is now closed.
ONE THING THE REVIEWER SHOULD KNOW. During the first (contended - two docker
containers at once) run of that 17-suite survivor sweep, test_peakguard.py
reported FAIL once. It has passed 8 times since on the identical tree,
including three concurrent runs and one clean uncontended full sweep, and its
tail was lost to a grep so I cannot say which check it was. I believe it is a
timing-sensitive check under CPU contention and NOT related to this change -
this commit touches no product code and no peakguard fixture - but I could not
reproduce it, so I am recording it rather than asserting it away.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
6ea84a46f6 |
Merge remote-tracking branch 'origin/release/1.0' into TEST-16
checks / unit (pull_request) Successful in 3m41s
# Conflicts: # goodwe_controller/CHANGELOG.md |
||
|
|
20f9f4969d |
TEST-16: pin the two one-sided arbitrage rungs, and un-mask them from each other
checks / unit (pull_request) Successful in 3m51s
SAFETY-26's AC4 gave the same tolerance mutation to every price and quantity
boundary in arbitrage.py and static_tariff.py. Everything in arbitrage.py went
red - `price < 0`, `soc <= floor_pct`, `soc >= ceiling_pct` are each red under
+/-5. Two survived, both in static_tariff.py, and this card closes them.
D `spread == 0.0` (:912) weakened to `spread <= 0.001` SURVIVED at +2 B.
F `len(firm) < 2` (:901) weakened to `len(firm) < 1` SURVIVED at +0 B - the
delta MUTATION.md names as the risky one.
THE DEFECT IS NOT EITHER ROW, IT IS THE INTERACTION. D and F MASK EACH OTHER. A
single firm hour also has a spread of 0.0, so with F weakened the flat rung
refuses the stimulus anyway; with D weakened the small-but-real spread is still
refused, only for a lie. Each rung's failure is absorbed by the other, so BOTH
can be weakened together and a suite grading `kind == "refused"` reports
nothing. That is structurally invisible to single-mutation testing, which is how
this pair came through four rounds of review on TARIFF-03 and FCST-03 - and it
is the arrangement in which the next edit to either rung opens a real hole with
nothing to report it.
D is not cosmetic either. With the tolerance in place a series priced 0.20000 /
0.20050 - a REAL 0.0005 EUR/kWh spread - flips ALLOWED to REFUSED at
min_spread_eur_kwh=0.0, a value arbitrage()'s own docstring documents ("pass 0.0
to mean any positive spread, explicitly") and its validator at :861 accepts on
purpose. At a shipped threshold the outcome does not move, but the operator
reads the false sentence "the tariff is flat - every one of the 2 firm hours
costs 0.2000 EUR/kWh" about a tariff that is not flat.
WHAT WAS ADDED. test_static_tariff.py section 9, and the shape of it is the
argument:
* THE REASON IS GRADED, NOT THE VERDICT. The masked case refuses CORRECTLY
while reporting the wrong sentence, so a check asserting only "refused"
passes on a broken tree. A `rung9()` helper names which refusal answered,
from the sentence the operator would read; an unrecognised refusal reports
itself as UNATTRIBUTED rather than quietly matching nothing.
* EACH RUNG IS GIVEN A STIMULUS THE OTHER CANNOT ABSORB, or the pinning is
circular. The flat rung gets a THREE-firm-hour flat series, which no
`len(firm) < N` a person would write can reach. The firm-pair rung gets a
ONE-FIRM-HOUR series, which the flat rung CAN reach - so its attribution is
the whole check, and that is why it is written as a rung name rather than a
verdict.
* AC1 is a sweep of small-but-real spreads (0.0005 down to 0.000001) all
ALLOWED at min_spread_eur_kwh=0.0, with the exactly flat series on the other
side still refused BY THE FLAT RUNG - without that mirror the sweep would
also pass for a rung deleted outright. 1e-6 is the floor because the shipped
line rounds the spread to 6 decimals; that quantisation is graded in the
open as its own row rather than left unstated.
* AC3 is a five-row table graded on rung attribution, which denies the masking
from both sides at once.
* The firm-pair count is also pinned from ABOVE (`< 3`) and BELOW (`< 0`),
which the criteria do not ask for but which is what stops this section being
one-sided in exactly the way it was written to fix.
EVIDENCE. Eight rounds through TEST-15's tools/mutation_run.sh, used UNCHANGED:
recursive __pycache__ purge before each run, python3 -B, the tree re-copied FROM
DISK between rounds, the mutated line grepped out of the file before the run,
byte delta printed, test_static_tariff.py and test_arbitrage.py graded each
time. Baseline green.
baseline, shipped +0 B PASS
D1 `spread == 0.0` -> `<= 0.001` +2 B RED, 3 checks
D2 `spread == 0.0` -> `<= 0.000001` +5 B RED, AC1 alone
D3 `spread == 0.0` -> `< 0.0` (rung gone) +0 B RED (section 2 as well)
F1 `len(firm) < 2` -> `< 1` +0 B RED, 3 checks
F2 `len(firm) < 2` -> `< 3` +0 B RED, 6 checks
F3 `len(firm) < 2` -> `< 0` (rung gone) +0 B RED (ValueError)
D1+F1 BOTH AT ONCE - THE TICKET +2 B RED, 5 checks
Four of the seven are +0 B, the delta MUTATION.md names as the one stale
bytecode can hide. All are REDS, the direction that defect cannot fabricate -
but they were run under the purge anyway, because "it went red so the cache
cannot have lied" is a conclusion and not a procedure.
NO SHIPPED BEHAVIOUR CHANGED. app/ is untouched; both rungs were correct all
along, and the card is explicit that a rung found actually wrong is a stop-and-
report rather than an edit. Also updated: MUTATION.md gains a short section on
masking as a SECOND way a survivor lies, with the two rules that fall out of it
(grade the reason, and mutate suspected pairs together); and test_arbitrage.py's
SAFETY-26 AC4 note is marked closed rather than left reading as an open finding.
UNSURE ABOUT. The 5e-7 row asserts the shipped `round(spread, 6)` quantisation.
That is a real property of the line and worth stating, but it does pin a
rounding the ticket never named - if a reviewer would rather that stayed
unpinned, it is one check to drop and nothing else depends on it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
d0b053b150 |
TEST-10: pin max_w beside the meter_freeze_s key TEST-06 already pinned
checks / unit (pull_request) Successful in 3m43s
max_w and the LIVE_MAX_W literal beside it were the two survivors left
after TEST-06 pinned meter_freeze_s: changing max_w 2000 -> 4500 in
config.yaml passed every check in test_freeze.py and test_tariff.py
GREEN, and LIVE_MAX_W = 5000.0 was a bare literal that silently
disagreed with the shipped max_w (2000) with nothing in the file
saying so. For contrast, deadband_w 15 -> 5 already went RED 2 + 1,
so the pinning technique was known to work - it just was not applied
to the key either side of it.
Three changes, each measured:
1. test_freeze.py gains a check comparing Tuning()'s code default
(control.py, 2000.0) against SHIPPED_MAX_W, read from config.yaml
by extending the file's existing _cfg_default() regex reader
rather than reimplementing test_arbitrage.py's _shipped. Broken
and confirmed red: max_w: 2000 -> 4500 in config.yaml now fails
exactly "the code default and the shipped default agree on max_w
too" and nothing else in either file; reverting returns both files
to all-green. (AC 1, AC 4)
2. LIVE_MAX_W is renamed LIVE_INSTALL_MAX_W in both test_freeze.py
and test_tariff.py, each with a comment stating plainly that 5000
is the live install's own override, not config.yaml's shipped
max_w (2000) - the defect was a literal that could be misread as
the shipped default, not the number itself. (AC 2)
3. test_freeze.py states PINNED_KEYS = ("meter_freeze_s", "deadband_w",
"max_w") in one place as the complete set of config keys this
suite pins. Deliberately excludes maintenance_* - CFG-03 (merged
into release/1.0 since this branch started, merged in here) gave
those keys a separate single source of truth in app/maintenance.py
and this ticket has no reason to duplicate that pin. Not every key
in config.yaml is pinned here on purpose: meter_freeze_s,
deadband_w and max_w are the three this file's mutual-exclusion
arithmetic (section 2b) actually reasons about together. (AC 3)
Full 17-file suite run green under goodwe-addon-ci after the merge
with release/1.0 (
|
||
|
|
d5b1ef19b3 |
CFG-03: give maintenance option defaults one source of truth
checks / unit (pull_request) Successful in 3m44s
Option defaults for the maintenance cycle were declared in three places that
could disagree, and MAINT-04 proved they already did. config.yaml shipped
`maintenance_charge_w: 2000`; main.py carried TWO `opts.get(..., 2500)`
fallbacks; MaintConfig's dataclass carried a third `2500.0`. So an options dict
with the key ABSENT was ACCEPTED by validate.py's rule 6 - which read 2000 -
and then built by main.py at 2500, against a 2000 W ceiling:
options dict with maintenance_charge_w ABSENT, max_w=2000
validate_startup_config -> [] (accepted, no problems)
MaintConfig().charge_w -> 2500.0
arbiter.resolve() -> 0.0 W, contradiction=True
That is the bug rule 6 exists to refuse, passing rule 6. It is unreachable on a
Supervisor install, which always supplies every key from config.yaml, and fully
reachable from sim/inject.py and from the test suite - which is where anyone
debugging maintenance actually works.
MAINT-04 shipped a TRIPWIRE and said so: test_validate.py pinned all three
literals and asserted the consequence through the real arbiter, so the
divergence could not silently widen or be half-closed. Its PASSING CONDITION
WAS THE BUG. Both it and validate.py's comment said to delete each other when
the divergence was closed. It is closed, so both are deleted.
THE DIVERGENCE WAS WIDER THAN ONE KEY. `maintenance_discharge_w` had the
identical three-way split (config.yaml 2000, MaintConfig 2500) and the card
named only the charge side. Closing one and leaving its twin would have
satisfied the numbers and left three sources of truth one line down, which is
the state that produced the bug. Both are closed, and so is the whole family:
all eight `maintenance_*` keys go through the same mechanism.
WHAT THE SINGLE SOURCE IS. config.yaml's `options:` block - it is what the
Supervisor reads and what the operator edits, so it wins by definition. There
is now exactly one in-code copy, `maintenance.MAINT_OPTION_DEFAULTS`, which is
TARIFF-02's `PRICE_OPTION_DEFAULTS` and SAFETY-24's `PEAK_OPTION_DEFAULTS`
pattern applied to the family that named the card - validate.py's rule 8 comment
already called that "CFG-03 read as a rule rather than as a complaint", so this
makes the rule hold where it was first broken rather than inventing a fourth
mechanism for one option family.
A MIRROR RATHER THAN A RUNTIME READ, and the reason is mechanical: the
Dockerfile copies `app/` and `run.sh` into the image and NOT config.yaml. The
running add-on cannot open the file it is configured by, so there is nothing to
read at 03:00 on a client's Pi, and a YAML-subset parser that could fail at
import is a new way for the add-on not to start. What makes the copy safe is
that test_maintenance.py pins every entry by READING the shipped file - drift is
a CI failure, not a comment.
EVERY OTHER SITE NOW DERIVES FROM IT OR HAS CEASED TO EXIST:
* MaintConfig's eight dataclass defaults are indexed out of the mirror. No
numeric literal survives in the class that held the third opinion.
* `MaintConfig.from_options()` is the ONLY place in the add-on that reads a
`maintenance_*` option. main.py's eight inline `opts.get(key, <literal>)`
calls are gone: moving the read next to the mirror means the fallback cannot
be anything but the mirror, because there is no literal in sight of it to
disagree with. It uses `key in opts` rather than truthiness, so a shipped
`false` or `0` is honoured instead of being quietly replaced by the default
- this card's own bug in a different costume.
* main.py's SECOND fallback site - the cheap-window charge floor at the
P_MAINT_SHAPE claim - reads `self.maint.cfg.charge_w` instead of re-opening
the options dict with its own literal. The Controller had already parsed
this once in __init__; asking the object that owns it cannot drift from it,
and the rate the shaping asks for can no longer be a different number from
the rate the state machine drains at on the same install.
* validate.py's rule 6 and its `maintenance_discharge_w` warning take their
fallbacks from the mirror.
WHERE THE RANGE LIVES, STATED AND CHECKED. config.yaml's `schema:` block carries
`maintenance_charge_w: int(500,5000)` - a fourth declaration site of a different
kind. It is a BOUND, not a default, so it is not this bug; but leaving the
question open is how a fifth site arrives. The decision recorded in config.yaml
and in maintenance.py is: bounds live in the schema block ONLY, because the
Supervisor is what enforces them and the Supervisor reads only that file, so a
Python copy of `int(500,5000)` would be a place to disagree and nothing else.
test_maintenance.py checks both halves of that: every mirrored key must have a
schema entry - without one the Supervisor drops the option and the add-on
silently runs on the mirror instead of the operator's value - and every shipped
default must lie INSIDE its own range, since a default the schema rejects is a
config that cannot be re-entered once it is edited. What the schema grammar
cannot express is a relation BETWEEN keys (`maintenance_charge_w` against
`max_w`), which is exactly why rule 6 is written in Python and why it does not
belong in the mirror either.
THE REPLACEMENT CHECKS, AND HOW THEY WERE VERIFIED. test_validate.py's new
section exercises the same four consumers the defect ran through, the same way:
the validator ACCEPTS a dict with every maintenance key missing; a REAL
Controller built from that same dict holds 2000 W (through main.py, not through
a regex over its source); `MaintConfig()` answers 2000 W with no dict at all;
and the real arbiter emits `-2000 W, limited by supervised(max_w)` instead of
`0 W, contradiction=True`. test_maintenance.py pins the mirror to config.yaml in
both directions - equal values, and full coverage of every `maintenance_*` key
the file ships, so a ninth key cannot arrive unmirrored - and asserts the
consumers move as a RELATION rather than against numbers, reading MaintConfig's
defaults back out of `dataclasses.fields()`.
Each new check was verified by BREAKING the thing it watches and observing red,
this project's rule:
1. config.yaml -> 1500, mirror left at 2000: test_maintenance 1 FAILED,
test_validate 4 FAILED.
2. config.yaml AND the mirror -> 1500: green again, and every consumer moved
together - MaintConfig() 1500 W, the Controller 1500 W, and the arbiter
`-1500 W, limited by maintenance(cheap-window charge)` where it had said
`-2000 W, limited by supervised(max_w)`. That is AC4: change the single
source and watch the consumers follow.
3. `charge_w: float = 2500.0` retyped into the dataclass: 2 FAILED in each of
the two files.
4. `opts.get("maintenance_charge_w", 2500)` restored in main.py: 1 FAILED,
naming the file and the literal it found.
5. `maintenance_hold_min` deleted from config.yaml's options block: 3 FAILED.
Full suite 17/17 in goodwe-addon-ci, twice, once under parallel load.
Stale prose that asserted the now-closed divergence as current is corrected in
app/watchdog.py, test_watchdog.py and test_peakguard.py - watchdog.py's example
still uses 1500 W, but because the point of the example is a charge strictly
inside the ceiling, not because 2500 is what ships.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
e498dd4c1c |
SAFETY-25: pin the claim-source set, so the tripwire is one
checks / unit (pull_request) Successful in 3m53s
The durability claim this PR shipped was FALSE, and the reviewer of #31 disproved it by building the fix. Part 10 said, in four committed places, that it "goes RED the day a loop-side floor is built, so the exposure cannot change size under a green suite". The reviewer implemented the memo's own recommended Option B in main.py - a `Claim.limit("reserve", P_LOOP, hi=0.0)` filed under a `loop_soc_floor` - and the ENTIRE SUITE STAYED GREEN. Part 10 green. Mutation 13 went on printing "NOTHING PREVENTS IT, ON PURPOSE" and the same 21 % -> 10.0 %, on a tree where something DID prevent it. Pushed to an absurd always-binding 95 % reserve it was caught only by SAFETY-24's pre-existing part 7 wiring checks; part 10 was still green. THE CAUSE IS STRUCTURAL, not a missing assertion. `replay_loop()` hand-builds main.py's claim list as a literal `[supervised, loop, peakguard.claim(...)]`, so it is blind to any claim main.py ADDS - which is exactly where AC 2 would be built. That is this repository's named failure mode: a check that observed something other than what it claimed to observe, shipped as a guarantee inside the document that recommends the change. THE FIX IS 10f: THE CLAIM-SOURCE SET, PINNED, two ways because the two fail differently. * STATICALLY, over `Controller.cycle()`'s own AST. Every `claims.append(...)` in it must be one the scan can read - an unreadable append FAILS rather than counting as nothing, which is how a scan like this goes quietly vacuous - and the sources it can file must be exactly {device, supervised, failsafe, safety, peak, arbitrage, maintenance, loop}. A ninth takes part 10 red on the line it is written, in every configuration, whether or not any fixture here reaches the state that files it. * DYNAMICALLY, through REAL `Controller.cycle()`s with main.py's OWN `resolve` spied - not a list this file built and hoped matched - swept over the WHOLE 0..100 % SoC range in the two states 10a-10c replay. Idle: exactly {device, supervised, loop}. Floor-limited: exactly {device, supervised, loop, peak}. A claim conditioned on a low pack, which is the shape a loop-side floor has, cannot hide from a sweep that visits 0 %. And the fold is checked rather than assumed: main.py's two P_SAFETY rating limits are both symmetric LIMITs, so replay_loop() modelling them as one `supervised` claim at the tighter bound is exact. MEASURED AGAINST THE COUNTEREXAMPLE. Reproduced first on the unfixed tree (green, as reported). On this tree the reviewer's Option B at its recommended default takes part 10 RED on 3 checks, and the 95 % variant on 6. Nine mutations were run in all, each against a freshly copied tree with no `__pycache__` present, mounted `:ro`; all nine red, no survivors, no `+0 B` delta. MUTATION.md records why a second round was needed: the first round's mutations were all aimed at DISARMING the guard, while the claim part 10 actually made was about the guard being FIXED. The four false sentences are replaced with what is now true, and each says WHICH half catches WHICH shape - 10f for an arbiter claim (Options B and C), 10a-10c for a clamp inside control.compute() or the guard (Option D), which the replay drives directly. AC 2 IS STILL OPEN AND NOTHING HERE BUILDS IT. The loop-side floor was a test mutant, reverted before commit. The memo still says "Status: OPEN. This is a memo, not a decision." Also, non-blocking, from the same review: the NOT READY banner had inherited the new scope sentence and become a ~550-character paragraph that the row three lines below repeated verbatim. A checks() row may now carry an optional short `banner` form; the floor-limited row does, and `status_payload()` prefers it. THE ROW IS UNCHANGED - shortening that would have been weakening AC 3 - and rows without the key are unaffected. Graded, and mutated: dropping the short form goes red. Merged origin/release/1.0 first (CI-02), keeping both tickets' CHANGELOG entries. Full suite green: 17/17 in goodwe-addon-ci (test_ci_gate.py needs `git`, which CI-02 added to .gitea/ci/Dockerfile but the local image lacks; run with it installed and it passes). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3cb9d3b7d9 |
Merge origin/release/1.0 into SAFETY-25
CI-02 landed on release/1.0 after this branch was opened and both tickets add a block under `## Unreleased`, so CHANGELOG.md collided. Both entries are kept, newest-first: SAFETY-25's, then CI-02's, then SAFETY-20's. Nothing else conflicted. |
||
|
|
29fa39eda7 |
Merge origin/release/1.0 into SAFETY-20
checks / unit (pull_request) Successful in 3m36s
# Conflicts: # goodwe_controller/CHANGELOG.md |
||
|
|
7b4877d56d |
TEST-15: purge the bytecode cache before a mutation run, not after
checks / unit (pull_request) Successful in 3m35s
FCST-03's developer reverted `self.provisional` to `here.provisional`, re-ran, and reported the mutation as a survivor. It was not one. CPython validates a cached `.pyc` on (source size, source mtime truncated to whole seconds), and that edit moved neither number - same length, same second - so the interpreter loaded the bytecode it already had. Disassembly settled it at the time: the source read `here`, the running code loaded `self`. A mutation run had graded code that was not there, with no error and exit status 0. This does not make the results already on the record wrong. It makes them UNVERIFIED, and that distinction is the whole card - so nothing here re-runs a campaign and nothing here quietly assumes the old numbers hold. REPRODUCED, THEN CLOSED. test_bytecode.py builds a real package whose two return values are the same length, imports it in a real subprocess, rewrites it inside the same second (retrying until both writes land in one whole second - milliseconds, no sleep) and reads back which value ran. Part 1 shows the interpreter running SELF off a file that says HERE. AND THE TWO OBVIOUS MITIGATIONS DO NOT WORK. Measured, not read off documentation: `python3 -B` and PYTHONDONTWRITEBYTECODE=1 are write-side only. Neither stops an EXISTING stale .pyc being read, and a mutation run always has one, because the previous run wrote it. Part 2 grades both still running the old code. The advice in test_control.py's coverage audit led with `-B`; it is corrected here - the purge is the half that works, and `-B` earns its place by keeping THIS run from leaving a cache for the NEXT edit to inherit. NO SLEEP ANYWHERE. One-second mtime resolution is the mechanism, not the enemy. Waiting it out would slow every run down and would still only cover the edit somebody remembered to slow down. THE HARNESS. tools/mutation_run.sh purges, prints a sha256/byte fingerprint of every app/*.py it is about to grade - so a mutation report can say WHICH source produced it, which is the reviewer's addition to this card - runs each suite under `python3 -B`, refuses a tree with no test_*.py (CI-01's rule, for CI-01's reason), and fails if a cache appears during the run. It takes the tree as an argument so the infra repo's replay harnesses can call it without being moved. WHERE IT IS SAID (AC 2). MUTATION.md carries the mechanism, the measurement, the reporting rules and the survey, and every one of the 16 suites now points at it from its docstring. That list is DERIVED FROM THE TREE by test_bytecode.py part 5, so a new suite that joins this directory without the pointer is red - the same failure shape CI-01's file glob exists to prevent. CI WAS ALREADY IMMUNE, AND IS DESCRIBED THAT WAY (AC 4). Verified rather than reasoned: with the checkout bind mounted `-v "$WS:/w:ro"` the interpreter writes no __pycache__ into the tree at all, and it fails SILENTLY - no error, exit status 0 - while the same probe on a writable mount produces one. Each job also rm -rf's its workspace and clones afresh, so nothing crosses between jobs on the shared runner. PYTHONDONTWRITEBYTECODE=1 is added as belt and braces only: the `:ro` is one character wide and the next person debugging a write will be tempted by it. Part 6 grades both ON THE DOCKER COMMAND LINE rather than on the file - the first draft searched the whole workflow and stayed green after the flag was deleted, because the comment explaining it still named it. THE SHIPPED ADD-ON IS NOT AFFECTED and the question is closed: goodwe_controller/Dockerfile has set PYTHONDONTWRITEBYTECODE=1 since it was written. There is no deploy-side finding. THE SURVEY (AC 3), in MUTATION.md, scoped BY THE PROPERTY. The defect is one-directional - a mutation that never took leaves the suite green, so stale bytecode manufactures SURVIVORS and cannot manufacture a red - which puts every "all red, no survivors" claim in the window out of scope, along with mutations executed in-process (SAFETY-07's B1/B2/B3, test_peakguard parts 7 and 10, where no source file is edited) and everything run after the purge discipline landed on 2026-08-31 (SAFETY-24, SAFETY-26, OPT-04, FCST-03's re-run). What is left is the recorded SURVIVORS from source-edit runs before that date. Two were re-run here under the harness and both still stand: TARIFF-01's record_block() ignoring its policy argument, and SAFETY-03's abs() on the clamp limit - 15 suites green each, on the tree as it stood. The rest are labelled still-unverified rather than assumed or re-run; each is one harness run to close, and the moment to spend it is when somebody next touches that module. OUT OF SCOPE AND RECORDED: the infra repo's sim/ replay harnesses import the add-on and write bytecode into both trees (goodwe/sim/__pycache__ exists today), so SAFETY-02's headline replay carries the same exposure. mutation_run.sh takes a tree argument for exactly that, but the change belongs in that repo and some of those harnesses want the rig. RED AND GREEN. Every new check was broken and watched fail: the purge commented out of the harness turns part 4 red (and its non-vacuity probe already proves the fixture would mis-grade without it); one docstring pointer removed turns part 5 red naming the file; `:ro` and the `-e` flag removed from the workflow turn part 6 red. Full suite green, 16 files, in the CI image on a read-only mount - the shape the runner uses. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
504e8daac3 |
TEST-17: grade the status page's peak row at a stated month, not at today's
checks / unit (pull_request) Successful in 3m35s
release/1.0 went red at 00:00 on 2026-09-01 on a commit nobody had touched. `Controller.checks()` read the capacity-tariff peak with `self.peak.peak()` - no argument - so tariff.py resolved the month from `datetime.now()`, while test_tariff.py section 10 banks its quarter at the fixed fixture 2026-08-30. From 1 September the page was correctly asked for September, correctly answered NO_RECORD, and the row read "no completed quarter recorded yet" - a string containing no "peak", which the section's own filter required. Four checks failed. ISOLATED BY MOVING ONLY THE CLOCK. Same commit, same shipped-deps image, libfaketime: green at 2026-08-30, four failures at 2026-09-01. Identical at |
||
|
|
04292c4a08 |
DATA-01: thirty days of the real meter, and what AC 3 actually measures
checks / unit (pull_request) Successful in 3m36s
TARIFF-01 and SAFETY-07 both merged with AC 3 - "monthly peak held under
the soft limit in >= 95 % of intervals over a 30-day simulation replay" -
honestly unmet, because two days of capture existed and it was believed
nothing longer could be produced. That belief was a true statement about
the past read as a claim about the future: HA's recorder still held the
whole month, and the reason nobody found out is that the 2026-08-25
export was a one-off script that left no artefact.
So the exporter is committed and runnable now.
scenarios/p1-net-30d-2026-08/ is 2026-08-01 to 2026-08-30, 485,841
samples, 30/30 days read, 0 failed, 4.13 MB gzipped, in the schema the
two committed days already fixed.
VERIFIED AGAINST THE DAYS THAT EXIST. `--verify` re-fetches 2026-08-20
and 2026-08-23 by this exact route and reproduces both committed files
BYTE FOR BYTE after decompression (865,024 B / 15,600 samples and
894,387 B / 16,351 samples). The gzip containers differ by design: this
exporter writes mtime 0 so its output is reproducible, where the one-off
wrote a wall clock.
A FAILED READ IS NOT AN OBSERVATION. A day whose read fails writes no
file, is recorded "read": "FAILED", and the run exits non-zero; the
entity is proved to exist through /api/states before any empty day is
believed, because a typo would otherwise answer 200 [] thirty times and
produce a consistent dataset of nothing. Nothing is interpolated, so the
month's 13 gaps (1291 s, 99.95 % covered) and its 3 `unavailable`
samples stay visible as holes.
SIZE: COMMITTED, DELIBERATELY, AND MEASURED. A git bundle of the whole
history goes from 1.58 MB (release/1.0 @
|
||
|
|
d57122ec74 |
DATA-02: a real Belgian price year, so AC4 stops being unmeetable
checks / unit (pull_request) Successful in 3m36s
OPT-04 reported AC4 - "net-positive across 12 months of replayed prices" - as NOT MET and replayed a synthetic year, because scenarios/ held only P1 meter captures. Right call, wrong diagnosis: the criterion was being carried beside a 30-day METER criterion as though both needed waiting. EPEX BE day-ahead is public data, not a capture. It needed a fetch. 8760 hourly points, 2025-09-01 to 2026-09-01 Brussels-local, canonical three fields. Licence checked BEFORE committing: Energy-Charts publishes the BE zone under CC BY 4.0 from Bundesnetzagentur | SMARD.de and other zones under private-use-only terms, so this one is redistributable with attribution. The fetcher refuses to write a file whose per-response license_info stops saying so. Cross-checked against SMARD's own 4996/DE: 96 of 96 quarter-hours, max difference 0.00 EUR/MWh. Raw wholesale, and the file says so. all_in: false, with the markup/tax assumption carried as numbers to APPLY rather than numbers applied - TARIFF-02's trap in data form. Section 7B applies it in one visible place and asserts arithmetically that its additive terms cancel out of every daily spread, so the trade count turns on the VAT multiplier alone; the raw replay is run too, so how much rests on the guess is reported (110 vs 88). DST by construction: iterate in UTC, render through price.brussels(). 2025-10-26 is 25 points, 2026-03-29 is 23, verified by counting per Brussels day rather than asserting 24, and by checking October's 02:00 appears twice at two offsets with two prices while March's never appears. Both bugs the point-and-gap accounting caught were real: a UTC-bounded window made the first day 22 hours, and an inclusive-end off-by-one dropped the last day silently. A day nobody asked for is not a day with no prices. THE RESULT, AT THE SHIPPED DEFAULT, WHATEVER IT IS. At 0.10 widened 1.5x to 0.1500: 110 round trips, +24.88 EUR net of the cycle cost. Unwidened, 173. Nothing tuned - the threshold and the 2.5/1.5 factors are untouched. And what the owner's decision now has under it: the four reports that a static ~0.0840 spread does not clear 0.10 all STAND. What was missing is that a dynamic site clears the widened requirement on 121 of 365 days, median all-in spread 0.1130, so a median day would clear at 0.0754 - 156 trips, +35.42 EUR. Reported, not applied. The synthetic year stays, because the real one cannot grade section 17: not one of 365 days has a negative DEAREST hour, so a zero there counts something that could not happen. 7B asserts that degeneracy rather than reporting the zero. Size measured, not assumed: 893 KB plain but 74.6 KB as a git object against 56.5 KB gzipped. Git already compresses; 18 KB is not worth making the provenance block - the licence claim a reviewer must check - unreadable in a diff. Nothing wired into the add-on; generic_dayahead is still the live path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC |
||
|
|
9234016419 |
Merge release/1.0 (OPT-04) into SAFETY-24, and fix one grader the merge broke
checks / unit (pull_request) Successful in 3m36s
OPT-04 landed after the previous rebase. Conflicts were CHANGELOG.md and one IMPORT LINE in validate.py; both refusal blocks and both option sets survive untouched, and DOCS.md, main.py and config.yaml auto-merged. ⚠️ AND THE MERGE BROKE A CHECK THAT NEITHER CARD COULD HAVE SEEN ALONE. test_peakguard.py graded "the first `.decide(` call in main.py" and asserted there was exactly one. That was true when SAFETY-24 was written; OPT-04 added `arbitrage.decide(...)`, so the merged tree had two and the assertion failed. It failed loudly rather than grading the wrong call - the guard did its job - but it cannot ship red. Fixed by selecting the call by QUALIFIED NAME (`peakguard.decide`) instead of by position, and requiring exactly one of THAT. Other modules may now call their own decide(); this grader can no longer be moved by one of them. Verified still discriminating: a decoy `peakguard.decide` added to main.py turns it red on 7 assertions, and the `maintenance_soc_floor` swap still turns it red on 6. Also folded in the review's wording finding. An assertion read "a CONTROLLER whose SoC entity does not answer hands decide() None" while driving no Controller. The wiring property is stronger than the one claimed, and is now asserted on the SERVICE CALL: a Controller with no SoC never reaches the guard at all, because `inputs_ok` is False and cycle() stops at the safety claim first - so a `float(soc or 100)` defaulting line above the call site cannot disarm the floor. The original decide()-level check is kept, reworded to say what it actually grades. Full suite green on the merged tree: 15 files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC |
||
|
|
2b8b8cb97f |
SAFETY-26: pin the negative-price refusal from below, not only from above
checks / unit (pull_request) Successful in 3m15s
OPT-04's reviewer weakened `if price < 0` to `if price < -0.001` and every suite stayed green while a -0.0005 EUR/kWh sell hour discharged. The shipped code was right the whole time; the CHECK could not tell correct from nearly-correct. The only probe near the boundary sat at 0.0000, which catches a `<=` and says nothing about a tolerance invented on the other side - and a tolerance on the other side is §17's named regression walking through with the suite passing. A dearest hour at -0.0005 is an ordinary Belgian day-ahead print, not a fuzz value. `app/` IS NOT TOUCHED. `if price < 0` is already correct and changing it would be the defect; this card closes the check, not the behaviour. The 0.0000 probe stays and is now labelled as the half that pins the boundary from above. The new probe is a SWEEP rather than a point, because one probe at -0.0005 closes `-0.001` and nothing else - a mutation reaching for `-0.01` would be the identical defect one decimal further out. Five prices span four orders of magnitude down to -0.000001, the last place the canonical six-decimal format can express; each is asserted to survive that rounding as strictly negative, to still be the DEAREST hour, and to still clear the widened threshold, so §17 is the only rung that can refuse. The same five magnitudes with the sign flipped all discharge, so this is a boundary at exactly zero rather than a refusal of small numbers. AC4, the look: every price/quantity boundary in arbitrage.py and static_tariff.py was given the same tolerance mutation, both directions where both exist. Two survive, both in static_tariff.py and both NAMED RATHER THAN FIXED - they are TARIFF-03's rungs and want probes in its own file. `spread == 0.0` loosened to `<= 0.001` flips a real 0.0005 spread from allowed to refused at min_spread_eur_kwh=0.0, a documented supported value, and otherwise prints a false reason about a tariff that is not flat. `len(firm) < 2` loosened to `< 1` survives at +0 bytes - TEST-15's risky delta - and is masked only by the flat rung, so the two mask each other and a later edit to either opens a hole nothing reports. Mutation verified under `python -B` with a recursive __pycache__ purge, byte delta printed (+5 B), the mutated line asserted changed, and - per the reviewer's addition to TEST-15 - the tree re-read from disk and asserted to be the tree that was mutated before each run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC |
||
|
|
bdd7f18a54 |
OPT-04: charge cheap, discharge expensive - and only when it pays
checks / unit (pull_request) Successful in 3m16s
Four cards built the price stack and nothing traded on it. app/arbitrage.py is §10.1's priority 3, and it consumes all four rather than re-deciding anything: whether a trade is permitted at all, on which two hours, against which widened threshold is TariffPlan.arbitrage()'s answer, and that answer cannot be obtained without first saying whether the peak guard is holding. §9.1's "peak shaving outranks arbitrage" is therefore honoured structurally - there is no path to a spread that skips the question - and again in the arbiter, where P_PEAK (50) clamps a claim filed at P_ARBITRAGE (30). A guard that never ran is not a guard that is clear. On meter_source off the peak guard is never evaluated; that third state is carried as None and refuses, because charging at the rail creates the billed quantity while nothing measures the quarter. validate.py refuses that configuration too, along with arbitrage armed with no price source and a SoC floor at or above the ceiling. §17: never export into a negative price. The guard is on the hour being sold into, not on the spread - a day priced -0.30 .. -0.05 clears any threshold and its dearest hour is still negative. Buying in a negative hour stays allowed. resolve_tariff() may open an HTTPS connection and the tick feeds the ESP32 heartbeat, so the fetch runs in its own task at a 30 min cadence and the tick reads an attribute and an age bound. test_arbitrage.py section 8 asserts that structurally on the shipped AST: a fetch put back on cycle() is red. AC4 is NOT met and is not restated - this repository holds no price history. Section 7 replays a synthetic year and asserts the property that does not need real prices: every executed round trip cleared its own widened requirement, and zero discharges landed in the 522 negative-priced hours. At the shipped 0.10 EUR/kWh threshold a realistic Belgian day/night site does not trade at all. Reported, not tuned: that number and FCST-03's widening factors are one product decision and it is the owner's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC |
||
|
|
a1376e44e4 |
Merge release/1.0 (FCST-03) into SAFETY-24 - CHANGELOG only
checks / unit (pull_request) Successful in 3m28s
FCST-03 landed while this was in review, so both branches had prepended an Unreleased stanza. Only CHANGELOG.md conflicted, exactly as predicted; app/, config.yaml, validate.py and DOCS.md all auto-merged, and this card touches neither price_forecast.py nor static_tariff.py. Both stanzas kept verbatim, SAFETY-24's above FCST-03's - no text from either author edited. Full suite green on the merged tree: 14 files. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC |
||
|
|
e709554857 |
SAFETY-24: an energy floor under the peak guard's forced discharge
checks / unit (pull_request) Successful in 3m29s
SAFETY-07 can command a discharge to hold a billed quarter down, and nothing below it consulted the state of charge. On the 2026-08-20 replay it asks for 878 Wh in a day. Relying on the BMS is not absurd - it is a real floor and it works - but it is the pack's protection rather than the operator's, and a month of aggressive quarters leaves the battery repeatedly at that cut-out with no EMS-side record of why. The first symptom is a pack never available when wanted, which is indistinguishable from the SoC drift MAINT-01 exists to correct. decide() now takes soc and soc_floor, both required and neither defaulted, and discharge_permitted() is the one place the question is answered. Above the floor nothing changes. At or below it the guard files a 0 W floor instead of a sized one - one-directional, so charging stays blocked and no discharge is capped - and reports floor_limited with the reason it bound. It is not a trip: every stop in 11.2's table still outranks the claim, and the floor is never negative, so 9.1's asymmetry survives as a property of the object (0 negative floors over 1296 swept combinations, SoC included). The SoC is graded only after `wanted > 0` is established, so it is never evaluated where it governs nothing. An unreadable SoC is floor-limited, not a full battery - a full battery is exactly the value that would disarm the floor, and this project shipped that defaulting once already. Failing to shave is not silent: one notification per episode (goodwe_peak_soc_floor), distinct from the saturation alarm because the two ask for opposite operator actions, plus its own status row and an action-log line. Verified by replay. The 2026-08-20 capture is driven against a modelled pack sized from the day's own measured appetite, so it genuinely exhausts: without the floor it reaches the BMS cut-out and holds there for 1227 guard cycles; with it the pack stops at 20.0 % and never touches the BMS. The energy withheld equals the energy between the two floors to within one cycle at the rail. The honest half is reported too - the worst quarter goes 4338 W -> 5030 W, because a floor-limited guard shaves less on purpose. Two new options, mirrored once in peakguard.PEAK_OPTION_DEFAULTS (TARIFF-02's pattern), imported by validate.py and main.py rather than typed as literals and pinned to config.yaml by a check that reads the file - so CFG-03 still tracks three places. validate.py rule 8 refuses peak_soc_floor <= bms_soc_floor at startup, in CFG-01/MAINT-04's manner. maintenance_soc_floor is deliberately not reused and not renamed: it is a drain target, not a spend limit, and the rename is recorded as a decision to revisit if a third floor appears. Eleven mutations of the shipped source were run against the checks; all eleven go red and there are no survivors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC |
||
|
|
71cd7849e4 |
Merge release/1.0 (SAFETY-07) into FCST-03, and close two degenerate stimuli
checks / unit (pull_request) Successful in 3m13s
CHANGELOG.md was the entire collision - SAFETY-07 lands in peakguard.py, main.py and the arbiter, none of which this branch touches. Both entries kept. X1. `_firm_or_die` guarding only `cheapest` survived, because the check passed the SAME provisional point as both cheapest and dearest: a guard on either role alone raises, so the stimulus could not tell "the buy hour is guarded" from "both are". Now one call per role with a firm point in the other seat, plus the positive arm so the two refusals are discriminating. X6. `firm_points()` returning the carried hours too survived, because its two consumers - the §11.4 refusal and cheap_now - guard provenance independently and both keep answering correctly. The accessor is now graded directly: exactly the published points, by identity and in order. Both are the degeneracy rule this file states at the top, in a new dress - a stimulus that cannot distinguish the two things it grades. ⚠️ AND THE REASON THE M9 MUTATION "SURVIVED" IN MY EARLIER REPORT: a stale __pycache__. Reverting `self.provisional` to `here.provisional` changes neither the file size nor, at one-second resolution, the mtime, so CPython kept the MUTATED bytecode and a green run afterwards proved nothing about that line. Every mutation here now clears __pycache__ first; re-run that way, M9 is caught. The committed source was always correct - the checks were not being run against it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC |
||
|
|
dd40c56ab4 |
FCST-03: yesterday's prices, flagged as yesterday's, and unable to move anything
checks / unit (pull_request) Successful in 2m43s
§8.3 fills the hours a day-ahead feed has not published yet with yesterday's profile and flags them provisional, and forbids any irreversible action on such a price. The whole risk of this ticket is one line - a carried point and a published point are three fields and a bool, so `provisional=y.provisional` copies yesterday's False onto today's guess and produces stale data presented as current. That is SAFETY-18's frozen-but-fresh meter one layer up. So the flag is never passed through. Carried points come from a constructor with no provisional parameter; published points pass through BY IDENTITY, so no copy exists that could lose one; an audit re-derives the expected flag for every emitted point and raises rather than repairing; and the check walks the module's own AST so the pass-through idiom cannot come back looking ordinary. A published point may itself be provisional (eneco_eddy's imbalance print is), so the rule for a published hour is "whatever the feed said", never False. The fill keys on the Brussels wall clock rather than t-24h, so the 23- and 25-hour days are right; a late publication rebuilds the series, moving the number and the flag together. AC2 and AC3 landed on TARIFF-03's existing verdict surface rather than beside it. Refusal 3 became "fewer than two FIRM hours" - refusing on "any point is provisional" would have switched arbitrage off every morning on every dynamic site - and refusal 5 is new: the spread must clear a threshold widened by the widest active uncertainty source (max, never a product). The permitting variant now refuses to be constructed around a provisional point at all, and the cheap-window flag became per-hour for the same reason. The irreversible action is a value carrying the hours it commits, derived from maintenance.py's own DRAIN_TIMEOUT_MIN; the gate blocks a maintenance drain whose window reaches into a carried hour, and the phase machine stays idle. It is the price-justified entry point only - a gate that blocked on no_data would stop the monthly cycle on every default install. Zero new config options; TEST-14's signature scan ported to cover both Gate's and Arbitrage's constructors, closing _Refused.reason's open hole. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC |
||
|
|
c2ed509565 |
Merge release/1.0 (TARIFF-03) into SAFETY-07
checks / unit (pull_request) Successful in 3m12s
The anticipated conflict again, and only it: both cards prepend to CHANGELOG.md's Unreleased section. Both entries kept, SAFETY-07 first. app/main.py and DOCS.md auto-merged, so the overlap claim on #18 still holds - config.yaml and validate.py remain untouched by this card even after TARIFF-02 and TARIFF-03 widened both. Full suite green on the merge result, test_price.py and test_static_tariff.py included. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC |
||
|
|
2d570d84ce |
SAFETY-07 review: defend the three wiring mutations that survived
The reviewer's finding was correct and the shape of it matters: everything the
first cut graded was in peakguard.py, and grading the module is not the same as
grading the guard. Three one-line changes to main.py's CALL SITE left all twelve
suites green while a default install parked at 0 W in the quarter that sets the
month's bill.
B2 was the serious one. My own comment described that defect - "that entity is
optional and empty on a default install, so until now the suspension could not
fire on the site that needed it" - and nothing could regress it. A comment that
documents a fix nobody can test is the same shape as a corrected comment nobody
tests, which this project has already been caught by once.
WHAT CHANGED
The suppression conjunction moved out of main.py and into
peakguard.shaping_suspended(), for the reason watchdog.loop_is_following() and
freeze.looks_frozen() live where they do: the check that grades a safety
predicate must assert on THE SHIPPED EXPRESSION, not on a transcription that can
drift apart from it in silence. Its docstring carries both terms and why neither
is sufficient alone. main.py's call site now says READ THAT FUNCTION FIRST, and
records that a reviewer mutated it back to its historical broken form with every
suite staying green.
test_peakguard.py part 7 gains a section that EXECUTES the three mutations
rather than describing them. Each one is put in place on the shipped module and
the REAL Controller.cycle() is driven with it, on the dangerous fixture: a
default install (no peak_forecast_entity, so peak_fc is None and peak_at_risk is
False forever) with maintenance in its CHARGE phase, so a P_MAINT_SHAPE `hi <= 0`
limit is live, and the guard genuinely engaged on the measurement. That the
mutations are monkeypatched onto the shipped module is itself load-bearing: if
main.py inlined either expression again the patch would do nothing and these
checks would pass vacuously.
SHIPPED suspended=True loop -> 1498 W, limited by peak +1497.8 W
B1 suspended=False CONTRADICTION between limits 0.0 W
B2 suspended=False CONTRADICTION between limits 0.0 W
B2b (forecast term dropped instead) - caught by its own check
B3 is graded three ways, because the single-cycle form is only INFLATED and the
failure is what happens on the next cycle:
* the value main.py actually hands decide() is recorded off the real call, with
the measured battery at 0 W and the command standing at +3000 W;
* the one-cycle difference is asserted to be exactly the standing command - the
whole defect in one line, watts that were asked for and never arrived counted
as if they had;
* both forms are then run forward against the REAL controller's own projection
with an inverter that never follows:
intent-sized floor 4482 5000 5000 5000 ... SATURATED at cycle 2
delivered-power floor 1482 1482 1482 1482 ... stationary
* and the call site is graded on parsed syntax, so a rename cannot quietly
reintroduce it: `decide(batt_w=...)` must unparse to `self.batt`.
SAFETY-24 is now named in peakguard.py, in its own section, with the number that
makes it real - 878 Wh of forced discharge on the 2026-08-20 replay - and the
statement that this must not go to a client install before that ticket lands. A
ticket nobody can find from the code is a ticket nobody reads before shipping.
RED AND GREEN
Fifteen mutations run against the shipped modules, each reverted after: the six
new wiring ones (B1, B2, B2b, B3, B3b, and deleting the SAFETY-24 pointer) and
the eight from the first round, re-run to confirm they still bite. All fifteen
turn test_peakguard.py red. Full suite green, test_price.py and TARIFF-03's
included.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
|
||
|
|
9f8b721a4d |
Merge release/1.0 (TARIFF-02) into SAFETY-07
checks / unit (pull_request) Successful in 3m9s
Conflict was the one anticipated: both cards prepend to CHANGELOG.md's Unreleased section. Both entries kept, SAFETY-07 first. Nothing else of TARIFF-02's surface (app/price.py, validate.py rule 7, config.yaml, DOCS.md) overlaps this card - config.yaml and validate.py are untouched here, so the CFG-03 check still reads a config.yaml that only TARIFF-02 has widened. Full suite green on the merge result, test_price.py included. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC |
||
|
|
303525cfc5 |
TARIFF-03: static day/night and fully-fixed tariffs, first-class
checks / unit (pull_request) Successful in 2m43s
§9.2 puts a large share of the Belgian installed base on a static profile, and
an EMS that only works under dynamic pricing is an EMS for a minority. A new
`static_profile` option describes a two-period day/night tariff - or a single
fixed price - and app/static_tariff.py turns it into the same canonical
{start, eur_per_kwh, provisional} series the fetched providers emit.
Not a sixth provider plugin, for two mechanical reasons. capabilities() is a
classmethod, so a plugin's PriceProduct is fixed when the class is written -
but §9.2 makes STATIC and FIXED two different products and which one a site is
on is a property of the operator's data; StaticProfile.product derives it from
the number of distinct prices. And the plugin framework exists to stop a
FETCHED figure being marked up twice, which a number typed as all-in cannot be.
So the profile sits where §11.4 already says it does - under the providers, as
the fallback. Configured product and feed-is-down are one generator differing
in one flag, so the degradation path cannot rot unused.
"No arbitrage" is a type. TariffPlan.arbitrage() returns a variant whose
ALLOWED arm carries the spread and the cheapest/dearest hours and whose REFUSED
arm carries a reason and nothing else - .spread_eur_kwh on a refusal raises
rather than answering 0.0, so an optimiser cannot compute a trade from one.
Four refusals, all derived: peak guard holding, no price data, plan
provisional, or the series is FLAT. The last is AC2: a fixed tariff disables
arbitrage by arithmetic, not by matching on an enum. peak_guard_active is a
required keyword argument, so §10.1's priority 2 over 3 cannot be skipped.
Time-window shifting reaches the one shiftable load that exists: main.py's
cheap-window flag falls back to the static profile, and the UI gained a Tariff
row. Cheap is STRICTLY below the seven-day mean - `<=` would call every hour of
a flat tariff cheap, and 48 h taken on a Friday is two-thirds weekend.
A profile must tile every day type exactly once, checked at startup; a gap is
refused with the range named rather than filled in. `22:00-07:00 mon-fri` does
not cover Saturday morning, and that trap is what the check catches.
validate.py rule 7's stated reason expired exactly as TARIFF-02 predicted: the
test_price.py section 12 tripwire went red naming static_tariff.py. The message
now says what is true instead, and the tripwire is re-aimed rather than removed
- consumer set pinned, expired phrase asserted gone. Rule 7 also grew a second
refusal for static_profile alongside the HA price pair, the same silent-loser
conflict and a live one.
CFG-03 is not widened: static_profile went into price.py's PRICE_OPTION_DEFAULTS
- the one in-code mirror pinned to config.yaml by a check - and the new module
carries no defaults dict of its own.
Measured on the two committed days of this house's real P1 meter. 2026-08-20
(Thursday): 15.220 kWh imported, 9.606 day / 5.614 night, EUR 4.7185 under
day/night vs EUR 4.6725 flat, shifting saves EUR 0.8069 = 9.606 kWh x the
0.0840 spread, derived twice. 2026-08-23 (Sunday): 0.728 kWh, all of it
night-priced, which is the weekend row proving itself. §17 names six Belgian
scenarios; this is the seventh, and it is two days rather than a season - the
file says so rather than implying an annual figure.
All 12 suites green. Eleven mutations of static_tariff.py were each caught by
at least one check.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
|
||
|
|
616b62d3e9 |
SAFETY-07: cap the loop before it sets the month's billed capacity peak
checks / unit (pull_request) Successful in 3m7s
TARIFF-01 measured the quarter and said the consumer would be this card. It is
one new module, app/peakguard.py, and one new claim.
THE MECHANISM. Every cycle the guard projects the open 900 s block to its
end-of-interval average - from P1 telegrams only, never from the command - and
compares it to a ceiling. Over the ceiling it files ONE arbiter claim: a lower
bound on the setpoint at P_PEAK. That single shape is both behaviours 10.1 asks
for, a hard clamp on charging and pre-emptive discharge, and it cannot cap
discharge or export, because a floor cannot. 9.1's asymmetry becomes a property
of the object instead of a rule to remember.
excess = 900 x (projected - ceiling) / seconds remaining
floor = MEASURED battery power + excess
The 900/remaining gain is 1.0 at the top of the quarter and grows, so the guard
is gentlest where its projection rests on the least evidence. The floor is sized
from delivered battery power, not the standing command: a floor built on intent
re-adds its own unmet ask every cycle and winds to the rail whenever the inverter
fails to follow, and test_peakguard.py part 1 shows the two diverging.
IT TRIPS, AND THE CAPTURES DECIDED THAT. On the healthy reference day it engages
on zero of 95 quarters. The action is level-triggered, unlatched and re-derived
every cycle, so a false engagement costs one cycle of discharge rather than a
parked battery - the cost SAFETY-18 could not bound and this one can. Replayed on
2026-08-20 the billed quarter goes from 5029.8 W, above the inverter's own 5000 W
rating, to 3485 W, under the soft limit and without saturating. Quarters under
the limit across both committed days: 189/190 -> 190/190. Two days is not thirty
and AC 3 remains TARIFF-01's open item.
BLINDNESS IS CHECKED BEFORE THE PROJECTION IS GRADED, and it is a different
action. A fresh averager projects a confident 0 W, which is under every ceiling
there is; a meter gap longer than meter_max_age_s makes the projection read LOW;
a frozen meter reading export projects 0 W at full coverage. All three become a
floor of 0 W - no importing while the billed quantity is unobservable - which is
one-directional and cannot cost pack energy. That is the backstop SAFETY-18 could
not be: its alarm arrives at 300 s of a 900 s quarter and commands nothing. The
frozen term is freeze.looks_frozen() itself, on this cycle's standing command.
THE CEILING IS peak_cap_w, RAISED ONLY BY AN OBSERVED READING. NO_RECORD and
NOT_OBSERVED both leave it at the cap, so an unreadable peak store makes the
guard stricter and never looser. The gate is the reading's STATE, not whether a
number happens to be attached: today a `peak_w is not None` test behaves
identically, and depending on that coincidence is how a future last-known-peak
field would raise the ceiling off a read that failed. headroom_w() is
deliberately not consumed - cap minus peak turns a zero peak into FULL freedom.
TWO DEFECTS FOUND BUILDING IT, both in the interaction rather than the
arithmetic, and both caught by running the thing rather than by reading it:
* A LIMIT does not out-vote another LIMIT. arbiter.resolve() applies every
limit at or above the winner's priority and takes the most restrictive, so
the peak floor (lo >= 0) and the maintenance charge-only shaping (hi <= 0)
resolved to a CONTRADICTION and 0 W - the battery parked in the exact quarter
it should shave. Fixed by suppression, not priority: main.py drops the
maintenance shaping while the guard is engaged, which is what that branch
already did for the optional peak_forecast_entity - a key empty on a default
install, so until now the suspension could not fire on the site that needed
it.
* Thresholding `excess > 0` released the claim the instant it began working,
producing seventeen engage/release cycles in one 900 s quarter and leaving
that quarter 49 W over the limit it had spent fifteen minutes holding. That
is 10.2's oscillation. The threshold is now the floor itself, which is the
same arithmetic read as a level rather than as a switch.
PeakTracker.project() no longer reads a private name with a default.
getattr(averager, "_last_net", 0.0) failed quiet AND disarming - a rename credits
0 W held, the projection reads low, the money guard does not engage.
QuarterAverager.held_net_w is a documented public property now, read without a
default, and Projection carries it. TEST-13 item 4.
NO NEW OPTION AND NO FOURTH DEFAULTS SITE. The ceiling is peak_cap_w, the
coverage threshold is meter_max_age_s, the freeze predicate is meter_freeze_s,
and the achievable discharge comes from max_w and the device rating. config.yaml
and validate.py are untouched; 14's capacity_peak_limit_kw stays declined. On
meter_source: off there are no telegrams, so the guard does not run at all and
the status page says NOT COVERED - running it there blinded it permanently and
stopped every legacy install from charging, which is what the first cut did until
test_clamp.py's export case caught it.
test_peakguard.py: eight parts, all figures derived from the shipped config.yaml
and the committed captures. Part 8 runs seven one-line defeats of the guard and
reports which check bites; six do, and the seventh is reported as NOT caught,
because max(cap, peak) makes a zero peak the strictest ceiling there is and there
is nothing left to catch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
|
||
|
|
f4914a174a |
TARIFF-01: measure the month's billed peak, and make it survive a restart
checks / unit (pull_request) Successful in 2m38s
The 15-minute block average was already built and unwired. p1.QuarterAverager computes clock-aligned 900 s blocks, time-weighted over irregular telegram spacing, offtake-only and per-phase; P1Ingest.blocks was a 96-entry list in memory that a restart erased. So this is the persistence and the monthly reduction, not a second accumulator - a second one would be a second source of truth for the exact quantity the money depends on. BLOCKS, NOT A SLIDING WINDOW. The card says "rolling"; 9.1 bills "the highest 15-minute average offtake" and states its own worked case per QUARTER - one 4998 W quarter moving a monthly peak from 4832 to 5013 W. A trailing window reports a peak the meter never bills, and reports it HIGH: a burst straddling :15 fills the window completely while contributing to two block averages at partial weight. DELIVERED, NOT INTENDED. tariff.py only ever sees P1Sample - the meter's own reading - and never a setpoint. SAFETY-20's open question does not arise for a capacity tariff: the utility integrates the current that flowed. AN UNREADABLE STORE IS NOT A ZERO PEAK. peak() answers OBSERVED / NO_RECORD / NOT_OBSERVED with peak_w None unless observed, which is riglock.holder()'s fix applied to something that spends money. A peak read as 0 W hands SAFETY-07 maximum headroom and disarms the guard at the moment its own storage breaks. Restart mid-block resumes the accumulator through a new snapshot()/restore() pair, checkpointed every 15 s and cleared in the same breath as a block close. The held sample keeps its ORIGINAL timestamp, so an outage is integrated under the same max_hold_s rule a running process would apply. quarter_start is the primary key and a re-bank may only ever RAISE a figure. Calendar months are LOCAL: a quarter starting 22:15 UTC on 31 August bills in September. No new option (the limit is the existing peak_cap_w), so config.yaml and validate.py are untouched and CFG-03 is not widened. No second peak entity: peak_forecast_entity stays the input to maintenance_charge_floor(), because repointing that consumer changes what moves energy and belongs to SAFETY-07. AC 3 is NOT met. It asks for 30 days of replay; two days of this house's real meter exist and are committed here. test_tariff.py part 9 replays them, reports the percentage next to the sample size in days, and asserts nothing about 95 %. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC |
||
|
|
4b521b2df0 |
ci: trigger a run on the rebased head
checks / unit (pull_request) Successful in 2m33s
No content change. The gate never created a run for |
||
|
|
0942228d65 |
Merge release/1.0 (SAFETY-03) into TEST-06
checks / unit (pull_request) Canceled after 0s
Two conflicts, both predicted, both mine as the later merger. app/main.py - #12 inserted `await self.check_output_clamp()` on the line immediately above `await self.check_meter_frozen()`, so its hunk abuts my comment correction. Resolved so EACH CALL CARRIES ITS OWN BOUNDARY, because the two guards do not share one: - check_output_clamp() reads `self.clamp_last`, which is None until write_setpoint() has run, so its boundary IS the write; - check_meter_frozen() grades `self.target`, assigned further up, so its boundary is the assignment. Letting my correction generalise across both would have been worse than the vague comment it replaces, because it would read as deliberate. The AST of app/main.py is identical to merged release/1.0 (+1251 bytes, all comment). test_freeze.py - #12 independently added `frozen_notes()` keyed on the same id. `notes_of()` subsumes it: same id, plus the title fallback that keeps the id's own check independently falsifiable, plus `all_notes_of()` for claims about the whole surface. THE THREE SURFACE-WIDE ASSERTIONS, DECIDED ONE AT A TIME. Measured on the merged tree: scenario surface ids freeze frozen meter, railed command 2 frozen + clamp 1 TEL-05 converged, -14.2 W 0 - 0 moving meter, a real 8 kW load 1 clamp 0 uncovered transport, no unchanged_s 1 clamp 0 - the wiring scenario STAYS SURFACE-WIDE and now asserts exactly TWO alarms with BOTH IDS NAMED. The answer to "is this still meant to raise one alarm" is no, it raises two, and a third guard has to come back to this line. That is the one assertion in the file that accounts for the controller's entire alarm output; - the CONVERGED case stays surface-wide and stays silent: at the operating point the whole add-on should have nothing to say, that is still true, and narrowing it would give away a real property; - the other two NARROW to notes_of(). The clamp alarm there is correct and is SAFETY-03's subject: a real 8 kW load does drive the loop past the rating, and an uncovered transport does not stop it. Asserting whole-surface silence would make this file fail whenever another guard correctly does its job. Each still owns the load-bearing half - THE FREEZE DETECTOR SAID NOTHING - and the surface is PRINTED so a reader sees what else fired without this file grading it. Re-verified on the merged tree: M1 RED 4, M2 RED 5, M3 RED 3 + RED 2, N6 RED 1, N3 green (correctly), N8 RED 4, R1 RED 2, R2 RED 1. All nine suites green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC |
||
|
|
d31a12b7c6 |
TEST-06: the id-selection note said 'the only assertion'; there are two
checks / unit (pull_request) Successful in 2m32s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC |
||
|
|
2983d7c3b0 |
Merge remote-tracking branch 'origin/release/1.0' into TEST-06
checks / unit (pull_request) Canceled after 0s
|
||
|
|
611d3dbcdf |
TEST-06 review follow-ups: two notification surfaces, the real ordering boundary, the schema bounds
1. notes_of() could not tell this guard's alarm from anybody else's. It selected by SERVICE, so against #12's app/ (which adds goodwe_output_clamp) eight assertions went red, AC1's core check among them. Blanket id-keying is not the repair and was measured, not guessed: selector vs #12's app/ id renamed by service (as committed) 8 red 1 red by id alone 3 red 6 red by id or title (shipped) 3 red 1 red all_notes_of() is the whole surface; notes_of() is this guard's, recognised by the id it should carry OR the title it writes - the `or` is what keeps the one assertion whose subject IS the id able to fail on its own. The three surface-wide claims stay surface-wide, each with a comment naming which surface it is about and why the next guard's author must decide rather than narrow them. They are the three that go red against #12, which is the correct signal. 2. The ordering comment named the wrong boundary. N3 - moving the call to immediately before write_setpoint() - is green and correctly so: self.target is assigned two lines earlier, write_setpoint() clamps only a local and can skip the write entirely. So the invariant is "after `self.target = resolution.target_w`", and the detector grades pre-clamp INTENT, not the wire. Corrected in test_freeze.py and in main.py's own note above the call (comment-only; I merge second and own the conflict). N8 - moving the call above the assignment - is RED 4. 3. The schema range's own bounds are now pinned. int(60,3600) -> int(1,86400) survived green: an operator could set meter_freeze_s: 86400 and the guard is dead with nothing red. Both bounds are re-derived rather than restated. 4. find_hwsim() crashed the whole suite on a checkout one level below the root (HERE.parents[1] -> IndexError, reproduced at C:/goodwe_controller). Guarded. And part 3 now names its transport in the summary: the rig hwsim and the stub are not equivalent - the rig path failed 1 run in 3 - so a green log has to say which one backed it. Also: the "revived transport re-rails" check is labelled a PRECONDITION like its sibling; freeze_rows() records that flipping p1_enabled exercises the row's content and never its gate; and the note about the deleted assertion is corrected - it was REDUNDANT, not undecidable. The reviewer falsified it in one line (move the hass.call above `if not fired: return`, RED 9). "I could not construct a falsifier, therefore it cannot fail" was exactly the inference this file exists to distrust. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC |
||
|
|
46383c5ae5 |
Merge release/1.0 (CI-01) into SAFETY-03 so the required check can run
checks / unit (pull_request) Successful in 2m32s
Branch protection on release/1.0 now requires the context `checks / unit (pull_request)`, and CI-01's workflow arrived after this branch was cut - so no run had ever been triggered for this PR and the required context would have sat pending forever. Clean merge, no conflicts: .gitattributes, .gitea/ci/Dockerfile and .gitea/workflows/checks.yml only. No add-on source or check is touched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC |
||
|
|
4e66583a52 |
SAFETY-03 review fixes: both clamp stages always run, and two checks that could not fail
1. BEHAVIOURAL, and a regression this PR introduced. clamp_to_rating() returned early from the max_w stage, so the device bound was applied only when the rating bound had NOT fired - the bigger the overshoot, the less the guard did. At max_w 5000 against a +-4000 device, +9999 emitted +5000 W, outside the device range, while +4500 was correctly held to +4000. It undid the pre-PR guarantee "never send a value the device will reject outright" and undercut this module's own case for max_w-not-min(), which rests on that stage catching a validation that did not run. Both stages now always run; the result names both bounds when both bite (max_w+device). Asserted over a 492-point sweep, because the defect was invisible to any single-value check sitting inside one stage. 2. The hysteresis check issued range(CLEAR_CYCLES - 1) clean cycles, which at CLEAR_CYCLES = 1 issues zero, passes, and prints "0 clean cycles do NOT clear it" - green for the chatter setting the constant exists to prevent. Now: ONE clean cycle must not clear the alarm, a literal that does not move with the constant, and the de-assert count is measured by looping until it clears. 3. The re-assert check stimulated at REASSERT_S + 9, green for any value including 3600 - the hour of silence the module promises not to produce. Now stated in wall-clock terms: a loop pinned across the 900 s capacity-tariff quarter must speak at least 5 times and on no more than 20% of cycles. Pinned in both directions. Red confirmed for each: the restored early return (3 checks), CLEAR_CYCLES = 1 (1), REASSERT_S = 3600 (1) and REASSERT_S = 0 (3). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC |
||
|
|
c34b5a2922 |
TEST-06: close the three gaps that survived SAFETY-18's suite green
None of the three is a bug in the shipped code. They are gaps in what the
suite could detect, and the next person to touch freeze.py had less
protection than the passing count implied.
1. WHERE check_meter_frozen() runs in cycle(). Moving it to the top left
the whole file green. Pinned by CONSEQUENCE, not by source order, so a
refactor that keeps the semantics stays green:
- the alarm must land on the SAME cycle the command reaches the rail
(at the top it grades the previous command against this cycle's
meter and arrives a cycle late);
- the dead-poll-task composition: poll task dies, stale_input_s is
crossed, the arbiter commands 0 W, and on THAT cycle the alarm is
already clear - because the detector ran after the 0 W write.
wired() now returns a per-cycle trace, which is what makes "on which
cycle" an observation rather than a hope.
2. The NOT COVERED status row. The old check only asserted that an
uncovered transport is SILENT, which a page claiming `armed` on a guard
that cannot run also satisfies. The string is now asserted in both
directions: the uncovered row must say NOT COVERED, name the
meter_source and homewizard_local, and warn; the covered row must still
say armed, or "does not say armed" would be satisfied by deleting the
armed branch.
3. meter_freeze_s. Pinned in test_validate.py by MAINT-04's regex
technique (no PyYAML in the image), and in test_freeze.py by its
consequences: the shipped value drives the replays, must stay inside
the 900 s billing quarter, and 3600/hold_s must stay inside the shipped
deadband. A Controller built with the key ABSENT must land on the same
number, which pins main.py's fallback without touching CFG-03.
All three demonstrated red before being accepted green, and every new
check was mutated to confirm it can fail; one candidate assertion ("no new
notification while parked at 0 W") was DELETED because no mutation could
break it, and replaced by the exact notification count across a revived
transport, which can.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
|
||
|
|
9c1a23328a |
CI-01: this repository's first automated check
checks / unit (pull_request) Successful in 2m39s
Before this commit `admin/goodwe-addon` had no `.gitea/workflows/` directory at all. Eight runnable test files - `test_loop.py` alone drives the real `run_control()` for 93 s of wall clock - and not one of them had ever been run by a machine. Every merge here was a human reading a diff, and the product owner's requirement that a green CI gate a merge could not be met on a repository with no CI to be green. One job, `unit`, with no `paths:` filter, so it reports on every PR and can therefore be a REQUIRED status check: Gitea leaves a required context it never receives pending forever, which is the failure mode on the other side of this ticket. Concurrency is scoped per PR from the start rather than to a bare group name - the bare name is what let the infra repo's run 521 be superseded while still pending, so its four jobs concluded `cancelled` with `started_at` at the Unix epoch and its PR merged on no evidence at all. The tests run in `.gitea/ci/Dockerfile`: `alpine:3` plus `py3-aiohttp` and `py3-paho-mqtt` from apk, matching goodwe_controller/Dockerfile exactly, because pip would install glibc wheels of a library the shipped image gets from Alpine. The runner itself cannot host them - bare python3, no aiohttp, no pip - and mutating a shared long-lived runner with `apk add` is invisible to anyone reading this repo. Two things measured while building it and recorded in the file rather than learned again: the checkout must sit two levels below the mount root or test_freeze.py's `find_hwsim()` dies on `IndexError: 1` before its first assertion, and test_freeze's hwsim section is silently absent here because admin/goodwe is private - so its PASS is a weaker claim than it looks, and the file says so. Verified green on release/1.0 content before this workflow was written: 8 of 8 files pass, ~4 minutes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC |
||
|
|
c040aac4b8 |
SAFETY-03: alarm when a computed setpoint exceeds the rating, do not just clip it
The clamp was never the missing half. specs.md 10.3 records an unclamped integrator climbing ~130 W per 4 s past 10 kW and reporting 14 768 W pinned at the 5 kW rail; clipped silently that is a flat, legal-looking 5000 W hold and nothing says the loop asked for three times it. write_setpoint() - the one path every write takes - now clamps to the VALIDATED max_w and reports whether the raw value differed; check_output_clamp() raises OUTPUT_CLAMP as a notification, an ERROR, an event row and a red status banner. Not a trip: the clamped value is written and the loop keeps running. The rating is max_w and nothing is min()ed at runtime - CFG-01 already refuses a max_w outside the device range at startup. The device range is applied second as defence in depth; it cannot bite after a successful validation, so if it ever does it alarms too and names the bound. No new config key. Decision grew i_clamped / clamped booleans, and that is the load-bearing part: `reason` is one string that slew-limiting and the saturation freeze overwrite, so an alarm keyed on it is silent on exactly the cycles a winding loop produces. Mutating main.py back onto the string drops the 10.3 replay from 2 engagements to 0. Distinct from saturation by construction - no shared reason code, notification id, status row or counter - and checked in the negative, after first asserting the saturation freeze really did engage. Two things found by running it rather than by reading it: a row per engagement flushed the 40-row event log and evicted SAFETY-18's own METER FROZEN? row (now coalesced per episode with an exact count), and a naive clamp turns a nan into a command at the full rating because every comparison against nan is False. test_clamp.py is new: assert-based, no framework. Six author mutations reproduced red, including "clamp kept, alarm deleted". Two of the check's own hypotheses about the freeze/clamp interaction were refused by its sweep and rewritten to what it measured. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC |
||
|
|
7cde5d862a |
Merge release/1.0 into SAFETY-18 - CHANGELOG only
MAINT-04 (#10) landed on release/1.0 mid-review, so both branches had prepended a stanza. config.yaml and DOCS.md auto-merged clean; only the CHANGELOG needed a hand. Both stanzas are kept verbatim, MAINT-04's Unreleased above the new 0.5.2 - no text from either author edited. MAINT-04 is unreleased and 0.5.2 is being cut here, so a release manager may want to fold the two; that is an editorial call, not a reviewer's. Full suite green on the merged tree: arbiter, control, loop, maintenance, p1, validate, watchdog, freeze. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC |
||
|
|
ffb8ec6373 |
MAINT-04 review: pin the default divergence with a check, not a comment
validate.py's comment said "if config.yaml's value moves, move this and main.py's together" and nothing enforced it - a comment where a check belonged, in a PR that elsewhere proves it knows the difference. test_validate.py now reads main.py and maintenance.py directly and asserts the divergence EXACTLY AS IT STANDS: config.yaml/validate.py at 2000, main.py's two fallbacks and MaintConfig's dataclass default at 2500. It records the mismatch rather than demanding it be closed, so it passes today and goes red if either side moves - including when somebody closes it in main.py, at which point the check and validate.py's block are deleted together. Nothing in a file another ticket owns is touched. The consequence is asserted through the real code as well as the literals: with the key absent the validator accepts, MaintConfig builds 2500 W against a 2000 W ceiling, and the arbiter resolves it to 0 W as a contradiction - the bug rule 6 exists to catch, passing rule 6. Production-unreachable (the Supervisor always supplies every key) but fully reachable from sim/inject.py and the tests, which is where anyone debugging maintenance works. Also: a CHANGELOG section for the upgrade path, covering both the case where the Supervisor re-derives the default and the case where it persisted the old one, since which applies was not verifiable without probing the live install. And maintenance.py's timeout comment said 13.5 kWh where everything else says 13.8 (300 Ah x 51.2 V, DoD 90); SAFETY-18 does not touch that file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC |
||
|
|
0ba3a200fe |
SAFETY-18: verify on the real rig, and record what it showed
AC3 says "verified on the rig". The previous commit drove hwsim in-process because `riglock status` reported UNKNOWN - which is the tool declining to observe, not an observation. riglock shells out to the local docker CLI; on a Windows host without DOCKER_HOST that resolves to a Docker Desktop npipe which is not running, and the result is indistinguishable from a genuinely absent rig. The rig was up the whole time. With DOCKER_HOST=ssh://homelab@192.168.2.147 it reads FREE. Re-run under sim/riglock.py against the real goodwe-sim compose project, with the shipped modules staged into goodwe-sim-plant so they poll that container's own hwsim while plantsim drives it. Both phases passed, lock released cleanly. The rig produced a result the in-process stub could not, and it strengthens the design rather than merely confirming it: in the NEGATIVE twin - +8000 W of house load, healthy meter, correctly railed command - `active_power_w` did not move at all, sitting at exactly 10130.0 W for 26 consecutive polls. The only thing keeping `unchanged_s` at 0.0 s was the kWh registers advancing. So the register terms in measurement_fingerprint() are not belt-and-braces for this detector, they ARE the negative case: fingerprinted on the power figure alone it would have raised a false alarm on a healthy meter under a real load, which is precisely how a safety guard gets switched off. Recorded in freeze.py so nobody simplifies the fingerprint without meeting that run first. No behaviour change - documentation and the harness note only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC |
||
|
|
7aa1bd052e |
SAFETY-18: a meter frozen at a wrong-but-fresh value now raises an alarm
A meter that answers 200 OK with a stale document forever is ARRIVING on every
poll, so every freshness mechanism in the stack reads it as healthy while the
loop integrates a constant error and rails: 360/135/75 s at the live install's
max_w: 5000 for a meter frozen at 350/1000/>=3000 W. Fifteen minutes of a 5 kW
unattended import sets the month's billed capaciteitstarief peak.
The detector is a conjunction, not the bare unchanged_s threshold TEL-05
measured and rejected:
unchanged_s >= meter_freeze_s AND |command| within 5 % of max_w
Both terms already existed and were already on the status page, and they fail
for independent reasons: at the converged point the command is nowhere near the
rail, and at the rail against a real load the kWh registers move in seconds. At
the shipped 300 s the relation is structural rather than a margin - the slowest
a healthy 1 Wh register can tick and still look unchanged is 12 W, which is
inside the 15 W deadband, so the command there is parked at 0 W.
Notification only, deliberately: an HA persistent notification with a fixed
notification_id, an ERROR log line, a UI event and a red status row. No claim,
no latch, no 0 W. A false trip parks the battery; a false notification costs an
alert. Only meter_source: homewizard_local is covered - the status page says
NOT COVERED elsewhere rather than pretending the guard is armed.
DOCS.md states the capacity-tariff exposure this does NOT close: peak_cap_w caps
maintenance charging and nothing caps the loop. That cap is SAFETY-07.
test_freeze.py grades both directions and was verified by breaking what it
watches (six mutations, all red): silent over the committed 40-minute converged
capture, silent over an hour of TEL-05's low-power case where the rejected bare
threshold is shown to fire, alarming on a genuinely frozen meter driven over
real HTTP through the real HomeWizardLocalSource (the rig's own hwsim freeze
fault when reachable), silent on the same railed command against a meter that is
still moving, and asserting on the service call that leaves Controller.cycle()
rather than on the watchdog's own flag.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
|
||
|
|
878a6ce452 |
MAINT-04: refuse a maintenance charge the ceiling cannot deliver
config.yaml shipped max_w 2000 against maintenance_charge_w 2500. The cheap-window shaping files Claim.limit(hi = -maintenance_charge_w) against the supervised SAFETY limit's lo = -max_w, so lo > hi - the arbiter's clause 3, correctly resolved to 0 W and logged as a CONTRADICTION. Maintenance silently never charged in a cheap window on a default install, and the only evidence read like a bug in the arbiter rather than like the two numbers the operator set. validate_startup_config() now refuses maintenance_charge_w > max_w, the same shape as CFG-01's sign_disagree_w >= max_w. Equality is reachable (-max_w is a single valid point) and is not refused. Demonstrated by starting the add-on on the pre-fix pair: exit 78 and the refusal banner. maintenance_discharge_w was checked rather than assumed and is NOT the same case: the drain phase OWNS the actuator (Claim.set at P_MAINTENANCE), so the ceiling clamps it to +2000 W and the arbiter's explain() names the clamp every cycle. Loud, and it still moves energy - warned, not refused. The shipped maintenance rates drop to 2000 so the defaults pass the rule; max_w was deliberately not raised, because it is the supervised safety ceiling. test_validate.py now reads config.yaml itself, so a default the validator refuses cannot ship again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC |
||
|
|
e40eb0768d |
SAFETY-02: the P_PEAK assertion was pinning the test's copy of the gate
Review finding 2, and it is the same duplication `disagrees()` just removed, so
it gets the same fix rather than a follow-up card.
`following_from()` in test_watchdog.py was a TRANSCRIPTION of the gate in
main.py, carrying "keep the two in step" as an instruction - which is exactly the
arrangement that does not keep two things in step. Measured: editing the shipped
gate alone to `winner in ("loop", "peak")` left every suite green while the
watchdog went blind for the duration of every peak event. The row whose whole job
is to force whoever lands peak shaving to come and change an expectation did not
do that.
The expression is now `watchdog.loop_is_following()`, called by main.py and by
the test. One definition, two callers, same reasoning as `disagrees()`: the copy
that drifts is always the one in the harness doing the grading.
⚠️ AND EXTRACTING IT WAS NOT SUFFICIENT - the drift proof still came out green
after the extraction, which is why it was worth running rather than assuming. The
realistic P_PEAK row has peak shaving asking for MORE than the loop, so the
magnitude term rejects it whatever `winner` says and masks the term under test. A
second row with peak asking for LESS makes `winner` the only deciding term. With
that row, widening the shipped gate fails two checks; without it, none.
Also enforcing the write ordering that was documented and not asserted: swapping
the two `store.set` calls passed everything. A store that fails the second write
now proves the property the ordering exists for - a crash mid-trip leaves a
reason with no latch, never a latch with no explanation. Swapping them now turns
that check red.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
|
||
|
|
cc4e88490b |
SAFETY-02 review: a latch that a restart clears is not a latch
BLOCKER. `auto` was persisted and `sign_wd.tripped` was not, so reconstructing the Controller against the same store - which is what a container restart does - brought it back ARMED, into the same fault, unattended: tripped=False, banner back to NOT READY, and it resumed commanding -1000 then -2000 W before re-tripping 180 s later. "Restart the add-on" is the first thing an operator looking at a FAILSAFE banner and a 0 W inverter will try, and under a supervisor that restarts a crashed add-on it is a loop of three-minute wrong-direction bursts. DOCS.md's "it does not clear itself" was false across a restart. `sign_tripped` and `sign_reason` are Store keys now, beside the maintenance state and for the reason store.py's own docstring gives. Written the moment the trip happens - reason first, flag second, so a crash between the two can never persist a latch with no explanation - restored in Controller.__init__, cleared only by the operator's start. The regression is covered: reverting the restore fails seven checks in test_watchdog.py. Review follow-ups, no behaviour change: - The arming gate is tested against the REAL arbiter. It is the decision the ticket turns on and it was asserted in three comment blocks and verified by nothing - every case passed `following` in as a literal. Twelve claim configurations now derive it from resolve(), including the redirect case `winner == "loop"` alone would have got wrong and the `lo` limit that raises magnitude without flipping sign. - The MQTT `status: "failsafe"` value is tested. The check that claimed to cover it restated the line above it and never touched the payload. - The worked example described a config that does not ship: at the defaults maintenance_charge_w 2500 against max_w 2000 is an arbiter contradiction and 0 W, not a -2500 W charge. Rewritten at 1500 W. The contradiction is a real pre-existing bug - a default cheap-window maintenance charge silently never happens - filed separately and deliberately not fixed here. - Forward hazard recorded at the gate: `winner == "loop"` disarms the watchdog under any higher-priority SET, and a peak-shaving strategy at P_PEAK IS grid-following in sign, so it would blind this for every peak event. - disagrees() is public so the replay harness stops carrying its own copy of the predicate it grades. - held_s takes the clock instead of reading one. - The two limits are now each stated in both places rather than one each. - The no-false-positive argument is tied to control.py's freeze permitting the command to cross zero; under the shrink-only encoding control.py records as rejected, this would false-trip on a load step. - Measured figures corrected: 93.4 % / 4212 of 17280, per-cluster 0.3/0.9/10.5/0.0, and the near-circularity of those figures is now stated rather than leaned on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa |
||
|
|
9aed43990b |
SAFETY-02: a loop can be fresh, tracking and inside its rating and still push the wrong way
The stale-input watchdog asks whether the meter is still talking. The saturation freeze asks whether the inverter can follow. The output clamp asks whether the number is too big. A loop can pass all three while charging the battery out of the grid, because the only thing wrong is the sign. On 2026-08-20 a commercial controller did exactly that four times in one day, for up to 77 minutes at a stretch, reaching a commanded -14 768 W against a ±5000 W inverter. app/watchdog.py compares the emitted setpoint against the authoritative P1 figure and, when they oppose each other with both over 300 W for 180 s continuously, files a failsafe SET claim at P_SAFETY, commands 0 W and latches there until an operator starts control again. Validated against that day rather than against an argument: sim/replay_sign.py in the infra repo drives this module over the committed captures of both 2026-08-20 and the healthy 2026-08-23 and reaches 18 trips on the fault day - every one inside a window NOTES.md §4.7 accounts for - and 0 on the healthy day. The four episodes do share one signature: 0.3 / 0.9 / 10.5 / 0.0 % sign agreement inside them against 92.9 % healthy. Three decisions the ticket could not make: - It is DISARMED whenever the grid-following loop is not what is driving. Sign agreement is a property of a loop that follows the meter, and a maintenance grid charge opposes the meter by design - 2500 W of charge into a house drawing 500 W is +3000 W on the meter against a -2500 W command, for hours. The gate tests the emitted number, not the winning claim's name, because a limit claim can let the loop "win" and still replace its value. - The trip LATCHES. Commanding 0 W removes the command side of the comparison, so the disagreement always clears the moment the trip works. - sign_disagree_w at or above max_w is refused at startup. The arbiter never lets a command exceed max_w, so such a threshold leaves the watchdog incapable of firing while the status page still reports it armed. What it does not catch, said out loud: an inverter that ACTS opposite to its command moves the meter further the same way, leaving command and meter in agreement. That is the saturation freeze's job. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa |
||
|
|
aae74d7571 |
SAFETY-12 review: name the safety claim that could not fire, and fix two figures
Review fixes. A. test_loop.py takes 73 s, not the ~35 s claimed in three places. That number is the whole defence against a future developer deleting a slow test, so it was understated by 2x in exactly the place it matters. Corrected - and it is now ~95 s (measured 93 s), because this commit adds the scenario below rather than trimming windows to make an inconvenient number smaller. Both call sites carry an instruction to re-measure if the windows change. B. "242 checks" was test_p1's own count quoted as the total for all five suites. The real figure is 395 across the five, plus 14 here. And the finding this PR should have led with. stale_input_s and the `inputs missing -> 0 W` claim both live INSIDE cycle(), so pre-fix, on a meter whose value happened to repeat, that claim could not fire at all. Constant meter, SoC entity dying at t=4 s, one binary: pre-fix final target 220.0 W, reason 'loop -> 220 W' post-fix final target 0.0 W, reason 'safety -> 0 W' The old controller held 220 W through the whole input outage with its status line still reading loop -> 220 W, and layer 1 does not cover that on a `meter_source: off` install - no P1 age sensor is published, so the firmware has nothing to veto on, while the heartbeat keeps its watchdog fed. The pre-fix behaviour was not "holds a stale command safely"; it was a failsafe a repeating meter switched off. Asserted in test_loop.py, both halves. Three points of reasoning tightened, none of them wrong before: - the re-tune is unreachable by construction, not merely by the reset: the combined cycle rate is max(arrival rate, 1/floor), never the sum, and 1/15 s is strictly slower than the ~1/5 s arrival rate. That holds only while the floor exceeds the meter's update interval - and the schema minimum of 5 s is exactly the reference meter's interval. Flagged for validate.py, not fixed here. - the ~11 s lower bracket is not only a dropped poll: a 5.0 s poller against a 4.97 s meter beats, so about every 14 minutes the poller steps over an update entirely. Reaching 15 s takes a beat-skip AND a failed poll. - the "freshness is decided upstream" guarantee is transport-conditional. On `meter_source: off` the grid figure comes straight from hass.number() with no age gate. Running the full law is still right; the claim is narrower than it was stated. The 15 s default is not independently justified - it rests on the firmware's 30 s ingest veto and moves if that ever does. Said beside the constant. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa |