safety-96-stale-unit-site-total
100
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
956711b801 |
SAFETY-96 review: keep the refusal count over a refused write; test a fast board
F1: resetting the count on each refused write hid a battery that really refuses behind a link refusing 1 write in 3 (never learned; learned at 358 s with the count kept). F2: a board at 0 W before its link is stale learned no refusal untested (mutation R7 survived); now a 1 s-lag case. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
88b9cd470a |
SAFETY-96: a battery the loop cannot move does not freeze it
At N>=2 a battery out of the split (stale link), with refused writes, or settling after a step the loop did not make (a charge end, a rejoin) now counts in the freeze test at its command, not its reading. Its real power still reaches the loop through the meter. At a drop its share leaves the loop's start point (the others take over one slew step a cycle); at a rejoin it enters at its reading. No refusal is learned while a battery's writes are refused. N=1 and the 46 identity digests are byte-identical. test_safety78 part 2: the dropped-drain case now rests at B 1510 W (grid at its -10 W aim) instead of 1500 W; the drop no longer sends B to 2500 W. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
1d28daf8a0 |
MAINT-10 review: warn when HA did not answer and TZ is UTC; test the 3 s budget
HA can take minutes to start (2026-10-05, 502 from the proxy). Then TZ holds, which the Supervisor sets from HA's zone, so the hour stays right; only a TZ of UTC there now logs a WARNING asking for a restart. A test pins the 3 s budget on the zone request (a mutant without it survived). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
0b6b34f563 |
MAINT-10: every pack at N=2, the next DST days, plainer upgrade note
test_maint10.py part 10: at two inverters both packs get the site zone, the real Controller is identical for 11 UTC and 13 Brussels, and two due packs across 24/25 Oct 2026 and 27/28 Mar 2027 start at the local hour on successive days; a pack freed in the repeated hour does not start there. Part 3 adds 28 Mar 2027. DOCS: an existing install keeps its saved hour, now read as local time; an hour set to make up for UTC goes back to 19. DECISIONS: the SAFETY-83 gate is met; section on two inverters. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
08fb1235fc |
Merge release/1.0 (0.15.0 + SAFETY-90) into maint-10-local-start-hour
Conflicts: main.py's maintenance import (base added CHARGE, HOLD; branch added site_zone) - both kept. DOCS maintenance table: base's longer drain-rate row kept, start hour row at 19 / local. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
8c6e5bca2e |
Release 0.15.0: correct the SAFETY-94/97 row, add the ±170 W known issue
Review round 1 (F1-F3) on PR #173. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
64dddd64bf |
CI-14: infra-pin to admin/goodwe 3b4ff84 (WITNESS-09)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
dd5eb6fdc8 |
SAFETY-94 review: tests for three surviving mutants; record two gaps
- test_safety94 part 4: time under 250 W is not counted; a command that swaps direction every cycle is never refusal evidence; a reading moving away from the ask does not restart the evidence. These kill review mutants R1, R3 and R6, which survived the PR's tests. - DECISIONS: at N=1 a battery with B's standby offset no longer latches a refusal (SAFETY-97 now covers N=1); a part-time ask is caught after 35/d s of wall time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
b307db1049 |
Release 0.14.0: version bump and changelog
Two inverters are now supported for maintenance and the peak guard (SAFETY-91, SAFETY-93). No option changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
69dc1f0e7b |
CI: bump infra pin to 0c04488 (admin/goodwe#62 FW-05, #63 WITNESS-08)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
4b81387908 |
SAFETY-85: no false 'not keeping up' while maintenance or the peak guard sets the battery
The loop's integrator bound counts only on a cycle the loop decided. A battery another owner set (maintenance, the peak guard, a safety limit) alarms only when it gives more than saturation_w less than told for saturation_cycles cycles. At N>=2 the loop is bounded while maintenance holds a battery, so it does not wind and control comes back one slew step a cycle. The status row counts episodes, not cycles. Digests re-pinned where behaviour changed on purpose; DECISIONS/SAFETY-85-no-false-not-keeping-up.md has the list. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr |
||
|
|
1c41a1e412 |
SAFETY-86 review: hold the site at the peak floor while a HOLD gives
At N>=2 the holding pack's pin follows the guard's floor while the loop still asks for +5000 W, so the ALLOCATOR "could not be placed" row was re-logged on every 1 W change and filled the 40-row action log. While the pin is raised, cycle() now files a site `hold` ceiling at the floor (P_MAINTENANCE), as SAFETY-84 does for a charge. Filed only when the holding pack gives; hi == the floor, so it never clashes with it. Tests: a noisy N=2 house logs no ALLOCATOR row and at most one clamp row (20 and 20 without the ceiling); the ceiling is the floor itself; a CHARGE is not refiled at N=1. The DECISIONS entry records the owner's 2026-10-01 decision: the HOLD ends on its clock, no top-up. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
ba31e7c8b1 |
SAFETY-86: peak guard outranks the maintenance hold
During a HOLD the holding battery gives what keeps the quarter at the peak aim. N=1: the peak floor is refiled at P_MAINTENANCE, as for a drain (SAFETY-83). N>=2: the hold's pin is raised to the floor minus what the other live batteries may place, so they give first. A site stop still decides alone. The hold ends on its clock even if the level dipped. Below the aim nothing changes (rows identical). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
513183aba3 |
test_peak_margin: read the month peak on the runs' clock, not today's
Part 3 read the simulated September's peak with the wall clock, so from
2026-10-01 it read no peak and crashed on every tree (
|
||
|
|
43a52e7f60 |
SAFETY-84: maintenance charge at N=2 no longer stalls
At N>=2 a cheap-window maintenance charge files a site `hi` (`charge`, P_MAINT_SHAPE) equal to what the charging pack's own `hi` places, so the other pack never has to give against it. The charge is sized to the quarter's headroom under the peak aim so it yields to the peak guard, and to 0 W under a site safety stop. N=1 byte-identical (safety84_identity). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
bc6d744c6c |
SAFETY-83 review: banked month peak and no false alarm during a drain
test_safety83 part 1: under a month peak banked above the limit the drain is lifted to peak - margin, and a drain the guard raises (steady or with 5 s spikes that saturate it) gives no 'Load too high' alarm; the part 3 overload still does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
79b94024cb |
Merge release/1.0 (OPT-11 P4, #156) into safety-83-peak-over-drain
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
b171ada894 |
Merge release/1.0 (SAFETY-78, #154) into opt11-p4-solar-forecast
Conflicts: modetrade.decide() takes both the day plan (targets) and SAFETY-78's draining flag; the Why line keeps SAFETY-78's other_cycle ahead of the plan's word (test added). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
0b96e3191c |
OPT-11 P4 review: records and text
- DECISIONS: the Cheap hours change and why; the no-sun replay (the gain is Leftover solar buying the evening's shortfall; the forecast added 0.14 EUR); the reconstruction bias stated plainly. - DOCS: Cheap hours ignores the forecast; a reading from before midnight is stale. - The forecast line: "Solar still to come today: ..." (it is what is left, and it stays within the 12-word limit). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
a692560960 |
Merge release/1.0 (SAFETY-81, #155) into opt11-p4-solar-forecast
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
fbd884797d |
OPT-11 P4 review: Cheap hours ignores the plan; midnight-stale forecast; stricter ids
- Cheap hours only fills to the ceiling with or without a forecast, as the design (section 1) says. Skipping the sun's share cost 0.31 EUR on real 2026 prices (29.72 against P3's 29.41) and saved nothing on the P3 set. - A forecast reading from before local midnight is stale: until the integration refreshes, "left today" is still yesterday's 0 and "tomorrow" is really today. - Forecast entity ids must be sensor.[a-z0-9_]+ (they go into the /api/states/<id> path). - House profile: a stored day that is not a date, or is after today, is dropped instead of kept forever. - Text: the forecast line says "for the rest of today"; the learning line no longer promises "tomorrow" (a plan needs one full day). - Tests for four surviving review mutants (hour sample floor, the dear run after the cheap run, share by room, daytime use) and the fixes above. - Replay tool: `--fc zero` (a forecast of no sun). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
7104515229 |
OPT-11 P3 option A review: wizard no longer says modes come later
Step 5 said "Choosing a mode for each battery comes in a later update", stale since P1. It now points to battery modes on the Batteries screen (EN/NL); test_setup checks the new line. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
b014fd2e16 |
OPT-11 P3 review: sale shows no money, honest sell text, tests for four surviving mutants
- A sale block on the plan no longer says "Worth up to" at the import price: a sale earns the lower injection price. - The selling switch help (EN/NL) and DOCS say the grid pays less than power costs; DECISIONS records why P3 loses on the replay. - test_modes_p3 part 11: modes on ignore the site trade switch (R13), a battery in maintenance takes no part (R10), a headroom stop latches for the period (R8). All three mutants survived before and are red now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
308adc9277 |
Merge release/1.0 (7995e4d, UX-13 P3) into opt11-p2-ha-entities
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
0951cb227f |
Merge release/1.0 into safety-79-peak-margin
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> # Conflicts: # goodwe_controller/app/uilayout.py |
||
|
|
e5a0ba23d7 |
SAFETY-79: CI fixes - MUTATION.md pointer, no key-taking helper, fixture sized on the aim
test_peakguard's over-limit fixture now sizes its load against cap - margin, so the floor it asks for is exact wherever in the quarter the suite runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UBtqPrG1mDLXzTEBnhEkkV |
||
|
|
7c4c8f5c66 |
SAFETY-79: peak guard keeps a margin below the limit
New option peak_margin_w (default 50 W, 0-500 W). The peak guard, the
forecast checks and the maintenance charge sizing aim at
peak_cap_w - peak_margin_w; the month peak, the bill, headroom, warnings
and every notice keep the real cap. Owner decision 2026-09-28.
peak_margin_w 0 is byte-identical to
|
||
|
|
ffc9a4d721 |
OPT-11 P1 review: a mode never beats the peak floor; SAFETY-03 kept at the rail
- arbiter: a mode now gives way only as far as a site limit above P_MODE
needs (_unit_modes at N>1, _site_mode at N=1). Before, the modes were
released only when the peak floor bound S, so a trade won above the floor
kept them and placed less than the floor (an Off battery at N=1 went to 0 W
under a 2000 W floor), and a capped grid charge released an Off battery.
While released, held batteries weigh 0 and carry only the shortfall.
- control: a side of the loop bound that reaches the rail is left to the
rail, so SAFETY-03's i_clamped still fires with every battery on.
- opt11_identity: add maintenance scenarios and a 6000-case arbiter digest
(peak floor, trades, maintenance, refusals);
|
||
|
|
0a28f71872 |
Merge release/1.0 (28a49cc, SAFETY-73) into UX-13-p2-installer
|
||
|
|
06aa09e28c |
SAFETY-73 review: a refusal needs 22 s of evidence, and the cut-out warning is for giving only
- REFUSED_SPAN_S: the 12 cycles of evidence must also span (SETTLE_CYCLES-1) * LOOP_TICK_S = 22 s. At a 1 s meter, lagged healthy readings refused both units 10-16 times an hour after load steps (site held 0 W). 2 s and slower ticks are unchanged. - REFUSES_ABOVE_CUT_OUT only for a refusal to give power: a full battery that stops taking charge is always above its cut-out level. - test_delivery: its wedged fixture (0.2 s cycles) no longer reaches a refusal; the release/1.0 checks are restored and a span check added. - test_refusal_units: lagged-reading and full-battery plants, two new checks, U14/U15 in the mutation table. Stale "> 500 W" wording fixed. DOCS/DECISIONS. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
1a9c7523c9 |
Merge release/1.0 (a36dca6) into SAFETY-73-per-unit-refusal
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
5a8d832eba |
Merge release/1.0 (afd6af0) into SAFETY-73-per-unit-refusal
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
016ed5a5a3 |
SAFETY-73: a battery that refuses, per inverter, with two or more
SAFETY-52's hold applied per unit at N>1 (owner, 2026-09-27, option R). A unit that gives < 100 W for 12 cycles in one direction while asked > 200 W, or trips its stale-reading gate on a ~0 W reading, gets a P_SAFETY `refused` unit limit closing that direction, so the allocator hands its share to the others within their own limits. Probe: a +-200 W pin every 15 min, one unit at a time, only while the site asks 200 W that way. All units refusing: the site law holds 0 W. Exits reseed the site target from the wire. INFO plus one WARNING (REFUSES_ABOVE_CUT_OUT) above the configured cut-out; SAFETY-42/43 no longer report a refusing unit. N=1 unchanged. test_refusal_units.py: cases a/b/c on flat and jitter registers against the old behaviour, probes, recovery, exits; mutations U1-U13 all red. TEST-19 no-fault campaign: 0 engagements in 5280 runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
15627c38a6 |
UX-13 P0: layout table, page<->options derivation, warning tier, entity list
The foundation for the new settings page. No visible UI change. - app/uilayout.py: screen -> group -> part -> field for every option in config.yaml (67 fields, 11 derived, 0 hidden), with control, unit, display scale, step, entity kind, show conditions, Simple/wizard placement and "applies after a restart". Ranges stay in the schema, words in translations and the new app/ui_text/en.json. - app/uimodel.py: options_to_ui()/ui_to_options() for the derived keys (one inverter vs the list, solar rows, static_profile from simple prices, a year-stamped region preset for the levies figure, and an OPT-11 hook for arbitrage_enabled that changes nothing yet). Unchanged derivations write the stored values back, so the round trip is exact. - validate.py: validate.Note, a warn/info tier. Each logged warning is also a note when a list is passed; config_notes() adds checks that need live data (missing entity, a kW sensor read as W, a limit below this month's peak). /config/validate returns them as "notes". No refusal changed: 261 recorded validator calls, 0 differ (tools/ux13_validator_calls.py). - GET ha/entities: Home Assistant's entity list for the picker, read through hass.states(), cached 30 s, 10 s timeout. When HA is down it answers 200 with the old list marked old, or an empty list that says why. - DECISIONS/UX-13-one-layout-table.md amends UX-02's "no option key in UI code": keys may appear in uilayout.py and uimodel.py only, both tested. - Tests: test_uilayout, test_uimodel (round trip over the recorded calls, the TEST-23 option files and config.yaml defaults), test_haentities, a warning-tier part in test_validate; test_messages scans notes and ui_text; test_pv and test_selfconfig part 7 widened on purpose. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p |
||
|
|
1d33265aac |
test_price: grade the Why row of the same render, not a second read of why_words
Since UX-10 the waiting sentence counts its seconds when it is read, so the
second read could cross a second boundary on a loaded runner ("1 second" in
the row, "2 seconds" in the check). CI run 953 on this PR failed that way;
test_price.py passes locally on the same tree.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
1f97c7652f |
SAFETY-52: a battery that refuses is asked for 0 W, with a 200 W probe every 15 min
At the BMS cut-out the saturation freeze held the last request for hours (+5000 W for 11.87 h on 09-06, +1940 W for 3 h 55 m on 09-19) until the grid error changed sign. compute() now carries a refused direction: after SETTLE_CYCLES of a ~0 W reading against a larger ask it holds that direction at 0 W, probes 200 W (below the gate's 250 W) every 15 minutes, and lets go on charge, an answered probe, stop/start or another writer. A stale-reading trip on a frozen ~0 W reading seeds the same hold, which ends SAFETY-59's ~70 s cycle. One inverter only; no SoC in the loop (SAFETY-25 Option A). test_refusal.py drives the real Controller and gate; tools/replay_refusal.py replays the 16-day capture. test_clamp.py and test_delivery.py re-baselined where they asserted the old hold. DECISIONS/SAFETY-52 has the record and what SAFETY-58/59 must take into account. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
c628099e69 |
UX-11: the meter WARNING names the limit only, as 'over 30 seconds'
TEST-23 showed 'No meter reading for 30 seconds, over the limit of 30 seconds': the age is 30.4 s, which rounds to the limit. The limit alone is always true at the moment the line is logged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p |
||
|
|
29837dfaa7 |
UX-11: safety log lines give one plain sentence and the code, the figures go to DEBUG
- Every WARNING/ERROR line that reported a safety reason (direction, delivery, stuck meter, stale reading, peak guard, circulation, over-rating) now ends in the reason's code (messages.reason_code()). The reason itself, figures and limits included, is on a DEBUG line right after it and in the action log. - The reasons and state.json are unchanged, so an old state file loads and shows the same way: every saved reason opens with its code. - test_messages part 6 checks every WARNING+ line at 25 words as logged; nine non-safety families are listed with a reason and capped. The byte pin in test_watchdog now pins the meaning, in order. - The meter logs one WARNING per quiet episode, and INFO when back, so the red row's "the add-on log shows why" holds. The Why line past the hold names the missing reading. Action log: "25 engagements", "Shadow mode off: ...". No trigger, latch, threshold, log level, notification id, cadence, row colour or banner order changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p |
||
|
|
2f1627e287 |
CI: bump infra pin to d637d0b (admin/goodwe#61, UX-10)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
f1a660881c |
UX-10: two INFO lines read better on TEST-23 (hours count, maintenance history)
"11 of 48 hours use yesterday's prices", and the maintenance-history line no longer says "maintenance history" twice. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
23a3a3f5b5 |
UX-10: the last plain-language leftovers: log lines, two table rows, 'Why: Other', red at 0 s
- test_messages.py part 6: every dynamic part of a WARNING+ line is classified as data or as a built text, and every built text is produced by the real code and checked. This is the gap the 2d scan had: it counted `%s` as one word, so reasons built by loop_clamp_note() and friends were never seen. - Quoted reasons rewritten to STYLE.md (red-alarm shape, codes kept): OUTPUT_CLAMP, METER_FROZEN, CIRCULATING_POWER, STALE_READING, the peak guard (codes now PEAK_GUARD*), the migration report, price feed, price history and gate reasons. Plurals via messages.times()/count(). - Top table: Price history "96 prices over 24 hours for 26 Sep", Tariff "one every 15 minutes or every hour", "not final yet" dropped. - Levels and noise: per-cycle shadow line DEBUG, shadow on/off INFO, aiohttp access log only at log level debug, settings warnings once per start, one plain line per Home Assistant outage (hass.Outage). - INFO jargon and error texts: no "§8.3", ISO times, "banked", "keys ARE", "NOT_OBSERVED", "did NOT land", "AND". - Display bugs: WHY_WINNER covers every winner and safety reason (main.why_key(), part 7 finds them in the code); the meter row is amber "waiting for the first reading" inside its limit after a start; the missing-reading age is counted when the page asks. - STYLE.md and DECISIONS/UX-08-short-notifications.md (UX-10 section). No change to safety behaviour, alarm levels, notification ids, triggers, cadence or the banner cascade. One row colour changes (meter at start), justified in DECISIONS. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
b878663b2d |
CI: bump infra pin to ecd3864 (admin/goodwe#60, UX-08)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
2c516a032c |
UX-08 2c: short refusals, error texts and option texts
Validator refusals name the setting by its label and key, say what is wrong and what to change, and keep the rejected value and limit. Option names and descriptions are short in en and nl; citations moved to DOCS "For installers" and the test_translations citation check now pins the instruction on the tab and the record in DOCS. No rule, key, schema, default or exit code changed. Allowlist 167 -> 43 (log lines only). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p |
||
|
|
571b884315 |
UX-08 phase 2b review: keep over-rating evidence, pin latch clearing, plainer rows
- During a bound episode an earlier over-rating count stays on the page (amber, under its own title); test_clamp pins it. - test_messages pins that every latch row says how it clears. - pv_entities description no longer claims the page names the entity (en, nl); allowlist hash updated for that option-desc line. - Plainer texts: multi-inverter direction banner, "fine now" after an episode, and the e-stop, peak-forecast and peak-guard-waiting rows move to messages.py. - TEL-12 DECISIONS points to UX-08 item 3 for the retired row rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
696142bccc |
Merge branch 'release/1.0' into UX-08-status-rows
Brings in the infra pin bump (#132) so infra-pin / fresh runs against it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
24f955cb26 |
CI: bump infra pin to 7081df4 (admin/goodwe#59, UX-08)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
50ccea3dd7 |
UX-08 phase 2a: short, plain notifications, and a ratchet for the rest
Every HA notification the add-on raises now has a title of at most 5 everyday words and a one-sentence body; a red alarm may use three so no action is lost. The texts live in app/messages.py, the rule in STYLE.md, and the detail that moved out in DOCS.md "Notifications", which each body links to. No notification_id, trigger or cadence changed. test_messages.py enforces the rule on every notification, fails any notification worded outside app/messages.py, and ratchets the kinds not yet rewritten (rows, banners, refusals, logs, options) through a shrinking allowlist of 200. Six tests re-pinned from prose to meaning. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p |
||
|
|
55a6d5dcdb |
SAFETY-40 review: integrator-bound WARNING gets its own notification id
A later anti-windup notice could replace a write-path BUG SIGNAL notice
under the shared goodwe_output_clamp id (escalation ignores the budget).
The WARNING now uses goodwe_integrator_bound and points to DOCS; the
cadence sentence in DOCS is corrected. Tests pin both ids (red on
|
||
|
|
fd14569a71 |
SAFETY-40: the integrator bound is not a BUG SIGNAL; numbers from the clamp tick
The clamp alarm gave `i_clamped` (normal anti-windup) the over-rating BUG SIGNAL text, printed `Decision.i_w` as "the loop asked for", and printed `self.target` (after slew/freeze) as "was clamped to". - clamp.loop_clamp_note(): i_clamped says "the loop's integrator reached its N W anti-windup bound" with this tick's command; clamped keeps BUG SIGNAL with the requested command and the clamp's own result. - Decision carries i_bound_w / requested_w / clamped_w, set at the clamp. - i_clamped still counts and notifies (same id, cadence, SAFETY-41 escalation) but as a WARNING, amber row, plain title. A bug has its own notification budget and its text is not overwritten within an episode. - test_clamp.py part 9: red on the old code per defect; 5 mutations killed. - DOCS.md alarm section (and its stale 30 s cadence), DECISIONS/SAFETY-40. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p |
||
|
|
f37268f3e4 |
CI: bump infra pin to 7689816 (admin/goodwe#58, SAFETY-34)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
6028e21298 |
SAFETY-34: re-assert latched alarms, because a Core restart keeps the add-on
A Core-only restart wipes persistent_notification and does NOT restart the add-on (measured on TEST-23: the add-on's SAFETY-31 card was gone 4 min after `docker restart test23-ha`, add-on StartedAt unchanged). The latched SAFETY-31 and SAFETY-42 cards were re-raised only on add-on start, so they stayed gone. They are now re-asserted every REANNOUNCE_S = 300 s while tripped (same notification_id, so it replaces). test_watchdog part 6 and test_delivery part 8b grade it with the same process across the "restart"; red on the old code. The docstrings that said the add-on restarts with HA are corrected. Record: goodwe docs/DECISIONS.md, SAFETY-34 D4. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p |
||
|
|
00e9a7b4e1 |
CI: bump infra pin to a1d0618 (admin/goodwe#57, TOOL-9)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
b963153de6 |
Restore .gitea/infra-pin (revert the TOOL-9 proof throwaway)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
a2c571b20e |
THROWAWAY (TOOL-9 proof, reverted next): remove .gitea/infra-pin
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
550bd6fa52 |
TOOL-9: pin checks print their FAIL line under pipefail; job tokens via header
A missing or comment-only .gitea/infra-pin now ends on the workflow's own FAIL line instead of git's `fatal: path ... does not exist` (still red). Every x-access-token remote URL becomes an env-set Authorization header, so no token lands in .git/config or argv; the pin workflows' temp dir is removed by an EXIT trap, also on failure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
8ff42ea85d |
CI-12: .gitea/infra-pin to admin/goodwe release/1.0 HEAD (2d026df, goodwe#56 merged)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p |
||
|
|
841424463a |
CI-12 review: INFRA_READ_TOKEN via an env-set header, not a remote URL
The long-lived token no longer lands in $WS/goodwe/.git/config (mounted into the tests' container, left behind by a failed job) or in git's argv. A missing secret (fork PR) fails with its own message. README: addon-pin is not re-run after a bump. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p |
||
|
|
c63eda0b0f |
CI-12: restore .gitea/infra-pin to admin/goodwe release/1.0 HEAD (56a933e)
This reverts commit
|
||
|
|
54cc5263fb |
THROWAWAY (CI-12 proof 2): pin one release/1.0 merge behind (d84ac01); reverted next
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
c493a6d67a |
CI-12: test_tariff.py no longer says CI cannot see the infra repo
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
d0891a6bca |
CI-12: .gitea/infra-pin to admin/goodwe release/1.0 HEAD (56a933e), restoring the proof pin
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
fe80047235 |
CI-12 round 2: INFRA_READ_TOKEN for admin/goodwe; the pin tracks release/1.0
- checks.yml's infra checkout and infra-pin.yml's admin/goodwe ls-remote use the INFRA_READ_TOKEN secret; the self fetch keeps GITHUB_TOKEN. - infra-pin.yml compares against admin/goodwe refs/heads/release/1.0; STALE message, comments and README follow. - test_control.py / test_alloc.py: drop the claim that CI cannot reach admin/goodwe. - .gitea/infra-pin is DELIBERATELY one release/1.0 merge behind (d84ac01) for the "infra-pin / fresh goes red" proof; the next commit sets it to HEAD. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
d71f7dbd35 |
Merge release/1.0 (43b2740) into DOC-5-watchdog to sync base
checks / unit (pull_request) Successful in 8m45s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
6dbe06e32e |
DOC-5: add the Watchdog step to README's Install section
checks / unit (pull_request) Successful in 8m31s
One line after the install step, linking to the DOCS.md paragraph that gives the reason. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
7559d21eda |
TEL-14: PV sign declared per entity (pv_invert_entities)
checks / unit (pull_request) Successful in 8m39s
Some PV entities (kWh meters, ESPHome clamps in the PV circuit) read positive for consumption and negative for production. `pv_invert_entities` lists the ones to flip; the flip is applied per member inside pv.total(), before the sum, so the MQTT `pv` row and the status page carry the corrected figure. Default [] is byte-identical to 0.12.0. Shape measured in supervisor_shape_probe.py section H (Supervisor 2026.08.0): a parallel list validates the 0.12.0 blob unchanged and renders as its own translated repeatable field; dicts on pv_entities refuse 0.12.0's stored strings; a prefix is invisible syntax; a global bool cannot describe a mixed roster. Status page marks flipped entities and cautions per entity below -50 W, saying ADD or REMOVE. Telemetry only: control documents unchanged. Record: DECISIONS/TEL-14-pv-sign.md (reverses TEL-11 section 2). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
02a822af98 |
DOC-5: tell installers to turn on the Supervisor Watchdog
checks / unit (pull_request) Successful in 8m41s
Since SAFETY-61 a dead control loop writes 0 W and exits 70. The Supervisor restarts a stopped add-on only when the per-install Watchdog toggle is on, and it defaults to off. One step in DOCS.md "Before you start", with a pointer to the DECISIONS file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
770988c902 |
TEST-26: a missing value in a check is a named FAIL, not a traceback
checks / unit (pull_request) Successful in 8m44s
Checks and prints that indexed an empty list, exhausted next(), or formatted None killed the rest of their file (FCST-06). Read them through the new named_absence.py (nth/first/field/given/present) and conjoin a presence term. - every site in the brief, plus the family the new scan and a sweep found - test_named_absence.py: scans every test_*.py for unguarded filtered-list indexing, next() without a default and numeric formats on maybe-None names; proven to bite; prints what it cannot see, counted - test_price.py 16a: the `_T17_CAPS is _T17_CAPS` tautology now grades the two assemble() calls' arguments - test_control.py: coverage table re-measured on runs graded to the end Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
b06f1fad5f |
SAFETY-62 review: DOCS exit 3 at the command block (R1); recognise the auto store key (R2)
checks / unit (pull_request) Successful in 8m35s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
71d2d4a37a |
SAFETY-61: the control task's death ends the process, after the wind-down
checks / unit (pull_request) Successful in 8m22s
A done-callback on run_control()'s task logs the traceback at the death and sets `stop`; amain() runs the ordinary wind-down (shutdown() -> command_site_zero()) and main() exits 70 (EX_SOFTWARE). Normal stops and run_control()'s refusal path are ignored by the callback (still 0 / 78). hass.py: a malformed JSON body, a JSON non-object, and an undecodable error body are an absence / a failed write, not an exception into the loop. checks() gains a first "control loop" row, red past FIRMWARE_INGEST_VETO_S. Why exit rather than restart in-process, and what the Supervisor actually does on exit (restarts only with the per-install Watchdog toggle on, default off): DECISIONS/SAFETY-61-control-task-death.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
f4f72211a9 |
SAFETY-60/62: operator tools refuse input they cannot read
checks / unit (pull_request) Successful in 8m31s
predeploy_check.py and rehearse_roster.py now assert a positive property of their input (a JSON object, most of whose top-level keys are known option or store keys) and refuse anything else with the shape named. The `ha apps info --raw-json` wrapper, a truncated or list-wrapped state file and a wrapped config no longer produce "CLEARED" / "NO FINDINGS" with exit 0. Also: a clean --no-live-entities rehearsal exits 3, not 0; alloc_sim.py exits 1 when split-invariance FAILS; decode_bus_capture.py takes several paths and exits 1 on a file that is mostly not frames. Graded by the new test_tool_inputs.py; DOCS.md says to pass .data.options. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
1ad4d3a5bb |
PKG-18: cut 0.12.0, so TEL-12's pv_entities can reach an installed 0.11.0
checks / unit (pull_request) Successful in 8m5s
One runtime merge since 0.11.0 (
|
||
|
|
07ba3433ed |
TEL-11: PV production stops being invisible, and stays out of the control law
checks / unit (pull_request) Successful in 8m6s
OPT-10 measured that this site's pack reaches 90 % SoC on 2 days of 16 and that
20.5 kWh of surplus left the site while the pack had room - 12.6 kWh of it with
the battery not charging at all - and could not say WHY, because PV production
is recorded NOWHERE. Three causes need three different fixes (no irradiance:
nothing to fix; a charge-power limit; the optimiser declining to charge, which
is the only software defect) and guessing between them is how an optimiser gets
tuned to solve a cloud problem. This card adds the channel. It does not do the
attribution.
AN HA ENTITY, NOT A REGISTER, AND THAT IS A PRODUCT DECISION. This add-on's
RS485 bus carries the BATTERY inverter it drives; a household's PV inverter is
normally a different box from a different vendor reporting into Home Assistant
through its own integration. `pv_entity` takes whatever `sensor.*` the site's
own PV integration provides, any brand HA can read.
POSITIVE IS PRODUCTION, AND THERE IS NO `pv_invert`. The convention is derived
rather than assumed: `meter_entity` is positive when IMPORTING and `batt_entity`
is positive when DISCHARGING (DOCS.md, Sources), both of which are "power
arriving at the house's AC bus", and production arrives there too. Those two
keys carry invert flags because they are BIPOLAR and are READ BY THE CONTROL
PATH - `inv.batt` feeds SAFETY-30's per-unit sign check and SAFETY-31's
circulating-power sensor, so a reversed sensor hands the site a plausible number
it acts on. Production is unipolar and is read by nothing: a reversed sensor
announces itself as -4 kW at noon in the very history this card creates, and a
flag would only let an installer hide it. No negative-value alarm either - a PV
inverter's own standby draw makes a small negative reading at night ordinary, so
any threshold would be a number nobody measured.
ABSENCE IS NEVER ZERO, AND "DROPPED" IS NOT ABSENT. `publish()` SKIPS a None,
which is right for `circulation` and exactly wrong here: a skipped row leaves
Home Assistant holding the last number forever and the recorder draws a FLAT
LINE through the outage, which is indistinguishable from a measurement and
manufactures the "there was no surplus" conclusion this card exists to TEST. So
the absence is published, with HA's own payload: `mqtt/const.py` declares
`PAYLOAD_NONE = "None"` and `mqtt/sensor.py` tests it BEFORE the numeric branch,
so the entity's state becomes `unknown` and the recorder stores a GAP. Read out
of home-assistant/core `dev` on 2026-09-24 and quoted at the constant.
`ABSENT_AS_UNKNOWN` is a one-key set and the suite asserts `circulation: None`
is STILL dropped.
THE UNIT IS READ OFF THE ENTITY. `mW`, `W`, `kW`, `MW`, `GW`, `TW` - HA's own
`UnitOfPower`, every factor an exact power of ten. A sensor with no unit, or a
unit outside that table, is REFUSED by name and the status page says which.
`read_inputs()`'s `abs(raw) < 50` kW-or-W guess is safe for a quarter-hour
demand forecast and is NOT safe here: 30 W of dusk is an ordinary reading and
that heuristic would publish it as 30 kW.
GENUINELY OPTIONAL, AND NOT A SECOND `price_now_entity`. validate.py does not
mention the key - it cannot conflict with anything, because nothing else reads
it. The MQTT row lives in a new `OPTIONAL_SENSORS` list whose `include` argument
DEFAULTS TO EMPTY, so every caller that has never heard of `pv_entity` -
test_mqtt.py's fixtures, tools/rehearse_roster.py, anything written next year -
announces exactly what it announced before. Unset: no state call at all, no
entity, no topic, no status row, no page row. Set and failing: a CAUTION
(`ok=False, warn=True`), never a `bad` row, so a telemetry channel can never put
a battery site into NOT READY.
IT MUST NOT BECOME A CONTROL INPUT, AND THAT IS GRADED TWICE. `test_pv.py`
part 6 drives 40 cycles of the real `cycle()` and asserts the commands are
bit-identical across unset / no reading / 0 W / 8 kW, with a COMPUTED census
(33 modules under app/) showing the material is mentioned in exactly three:
pv.py, main.py, mqtt.py. `tools/tel11_pv_trace.py` is the cross-tree half -
`safety56_behaviour_trace.py`'s pattern with a DIFFERENT control clause, because
this card's claim is that nothing differs rather than that only sentences do: it
REFUSES unless the two `app/` fingerprints differ and unless exactly one side
reacts to the option. Measured on 41 stimulus cells:
control release/1.0 unset 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
control TEL-11 unset 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
control TEL-11 set 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
telemetry release/1.0 unset a443a6ceaffe072b44bcd5f2ad946c1ed6c9e01b69e278eca631f7f4d7892164
telemetry TEL-11 unset a443a6ceaffe072b44bcd5f2ad946c1ed6c9e01b69e278eca631f7f4d7892164
telemetry TEL-11 set 4e6d1cb289a957223ad0205f732a6fec6110ce1f2032c98ad60694e67625274f
UNSET is byte-identical in BOTH documents, which is stronger than "the control
path is untouched": an install that does not set the option sees no change of
any kind, announcement and status rows included.
⚠️ AND THE CARD'S CENTRAL MUTATION WAS A SURVIVOR ON THE FIRST BATTERY.
`self.grid -= self.pv_w` in `read_inputs()` - +106 B, on disk, purged, genuinely
running - came back GREEN, because the fixture drove a 6 kW export over 12
cycles, which puts the loop AGAINST ITS RAIL: a command moving `slew_w` every
cycle produces the same ramp for -6000 W and for -14 000 W of apparent error.
MUTATION.md habit 23 with a saturated ACTUATOR in place of a coinciding value,
and it is in that file now as the habit's second instance. The fixture now
drives a 600 W import the loop SETTLES on, against a responsive meter, and asks
habit 23's own question as its own check: shift the METER by the same 8 kW and
confirm the commands really would have moved.
Evidence: 31/31 suites green in the CI image, checks.yml's exact command, purged
tree, `:ro`. Ten mutations, NO survivors, each on its own `cp -a` of the tree so
habit 18's revert hazard is closed by construction - table in
`DECISIONS/TEL-11-pv-production.md` §7 with byte deltas and sha256s.
⚠️ THIS IS A RUNTIME CHANGE BEHIND AN UNCHANGED VERSION, AND
`tools/predeploy_check.py` WILL REFUSE IT. `app/`, `config.yaml` and
`translations/` are all in its `RUNTIME` tuple and `config.yaml` still reads
0.10.1, so the gate reports a NO-OP deploy until a version is cut. That is PKG's
call and this card does not take it.
No CHANGELOG section: `test_version_gate.py` 11b refuses a newest `## ` heading
that is not config.yaml's `version:`, and opening one would mean taking that
same packaging decision. The operator account is in DOCS.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
8f9c2cad57 |
CI-9 follow-up: the skip direction, written the right way round
checks / unit (pull_request) Successful in 8m37s
`every subsequent pull request matching it is skipped too` is the inverse of the behaviour. A pull request that MATCHES a `branches:` filter still RUNS; the one the filter EXCLUDES is what Gitea sits out, reports `skipped`, and the rollup launders into `success`. The error arrived with the generalisation in the previous commit: `33e17db` said "every subsequent pull request is skipped too", which was unqualified and true for the `[main]` case it described. Naming the wider hazard made a qualifier necessary, and the wrong one went in - contradicting the paragraph directly below it, which describes `[release/1.0]` correctly as leaving every day-to-day PR running and green. Corrected, and the direction is now stated explicitly with the failed draft recorded, because this file already carries one instance of the same shape: "That was wrong, it contradicted the checkout step's own note a few lines below, and the error was not cosmetic." Guard re-verified on the edited bytes: passes here, still refuses `branches: [release/1.0]` added to the `on:` block. Comment-only, 0 non-comment lines changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
628279a68a |
CI-9 follow-up: name the filter that actually gets written, not the one that looks wrong
checks / unit (pull_request) Successful in 7m58s
Three sentences in the blind-spot clause went stale between writing and review. `no pull request in 99 has ever targeted master` is no longer true: SEC-2 made `release/1.0` -> `master` the mandated release path and protected `master` with the same required context, and a PR against that base has run for real. The census it came from also erases its own evidence - PR #102 was itself opened against `master` before retargeting, which the /pulls listing no longer shows, exactly as the caveat 25 lines above this clause warns. The clause also named `branches: [main]` as the hazard. That is the version that looks wrong. The one someone would actually write is `branches: [release/1.0]` - it reads as tightening onto the protected branch, every day-to-day PR keeps running green, and the only thing it switches off is the release PR whose content reaches an installed system. And the prescribed fix named only `release/1.0`'s status_check_contexts. Since SEC-2 `master` carries the same rule, so a reader following it literally would half-close the hole and believe it shut. Guard re-verified against the edited bytes: passes on this file (whose new prose quotes `branches: [release/1.0]` in a comment) and still refuses a real filter added to the `on:` block. Comment-only: 0 non-comment lines changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
9d593429d3 |
SEC-2: write down the release path, now that master refuses a direct push
checks / unit (pull_request) Successful in 7m56s
master is the default branch, which is what the Home Assistant Supervisor clones - so whatever sits on it runs on a real 5 kW inverter. Until today it had no branch protection at all, while every gate this repo built sat on release/1.0, the branch that does not ship. 0.10.1 went straight onto master on 2026-09-22 with no PR, no CI and no reviewer, and it succeeded. master now carries the same rule as release/1.0 - enable_push false, required context `checks / unit (pull_request)`, block_admin_merge_override, required_approvals 0. That is a forge setting and cannot live in a commit, so the part that can is this: the procedure it implies. Without it the next release is attempted the old way and fails confusingly at the push. docs/RELEASE.md records the path (merge to release/1.0, bump version, PR release/1.0 -> master, wait for green, merge, then `ha store reload` before the Supervisor offers anything), the before/after protection state of both branches, and how the gate was proven to bite rather than merely badge green. Documentation only - no code, no workflow change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
eb88646899 |
PKG-16: cut 0.10.1, so SAFETY-57's fix can reach an installed 0.10.0
checks / unit (pull_request) Successful in 8m0s
The Supervisor decides an update exists by comparing config.yaml's version string with the one the box reports. master and release/1.0 both said 0.10.0, so SAFETY-57 - a deadlock that parked the reference battery for ~13 min on 2026-09-21 while the house exported 2.4 kW - had no way of reaching the box that has it. A patch bump is the delivery mechanism, not bookkeeping. - config.yaml: 0.10.0 -> 0.10.1. Nothing else in the option surface moves: no key added or removed, no default changed, no enum widened, no rule added to validate.py, so a blob that starts on 0.10.0 starts on 0.10.1. - CHANGELOG.md: a new `## 0.10.1` section, holding SAFETY-57's entry moved out of `## 0.10.0`. The entry opened by saying it was misfiled and that whoever cut the next version owned moving it; this is that cut, so that paragraph is replaced by what an operator actually needs in order to decide - what the deadlock does (command parked at 0 W, batt reading gated to MISSING, escape condition unreachable from inside the state), how to recognise it (setpoint stuck at exactly 0 W with the battery register flat, a STALE line and no clearing line) and that a restart of the add-on clears it. - PKG-16 proper: `## 0.10.0`'s preamble still claimed 0.10.0 "has never been built or pulled anywhere" and that the reference install was on 0.7.0. Both stopped being true on 2026-09-20 ~22:18 CEST, when 0.10.0 was installed as a repository add-on (slug 68285ac7_goodwe_controller) and began regulating a real battery. The section's own bump argument was explicitly conditional on that; the condition has fired, so the argument is retold as history and the section is closed. This text is what the Supervisor shows beside Update. Also fixes, in this file only, the three non-blocking documentation defects raised on PR #104 - issuecomments 1445, 1446 and 1447: - 1446: the live episode is 790 s, not 710 s. Re-derived rather than rescaled: at >= 790 s the committed 16-day replay gives 16 of 93, not 17. - 1447: the BMS keep-alive pulse was an inference stated as a measurement. No committed capture identifies one, and on this episode 0x0518 never moved at all. The measured claim replaces the firmware story. - 1445: "neither consumer can reach a verdict inside one window" is universal as written and graded on a fixture that puts the obligation back on every release. It now carries the qualifier the reviewer asked for, and names the unbounded case and why the shipped one-inverter roster cannot reach it. The same three defects also sit in app/freshness.py, test_freshness.py, DOCS.md and the DECISIONS note. They are deliberately NOT touched here: this PR is config.yaml and CHANGELOG.md only, and none of them is worth delaying a live safety fix. SAFETY-56 and SAFETY-58 are likewise untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
33e17dba0e |
CI-9 follow-up: the summary said "mechanically", the blind spots say otherwise
checks / unit (pull_request) Successful in 7m57s
The residual-risk paragraph at the top of checks.yml claims the `guard the trigger` step "keeps it that way mechanically rather than by asking the next editor to read this comment". For blind spots 1 and 3 that is not true: the guard lives inside the workflow a filter makes Gitea skip, so for those cases the defence IS the comment. That paragraph is the preamble someone reads at the exact moment they are editing the trigger, so a summary running ahead of its detail is worse here than elsewhere. It now points at the blind spots and states spot 3 plainly: a filter naming a branch nothing targets is itself skipped, lands green and untested, and then skips every subsequent pull request - the gate off for everything, silently. Flagged by the reviewer on PR #95 (issuecomment-1424) as non-blocking. This PR is also the first real test of CI-9's fix on release/1.0 itself. Run 806 was a workflow_dispatch and posts no commit status at all, so it verified content but never the gate. This one has to post `checks / unit (pull_request)` as success rather than skipped. Verified before push: the guard passes against this edited file (whose new prose quotes `branches: [main]` and `master`) and still refuses a genuine filter added to the `on:` block. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
a6af332048 |
SAFETY-48: what the lead cap actually does to a frozen integrator, and what the peak figure depends on
checks / unit (pull_request) Successful in 7m55s
Four review findings, none of them in executable code. The control law, the tests and the instruments' arithmetic are untouched; every number below was re-measured against the committed 16-day capture before it was written down. R1 - app/control.py carried a justification the decision record and MUTATION.md habit 22 had already retracted, and it was false about the code it sits in. It said an unconditional placement "would also pull the integrator toward a command the SATURATION freeze is pinning", priced at 118 of 256 250 ticks. The 118 was measured against a different pair; on the pair the sentence names the two forms are bit-identical - 0 of 256 246 ticks, re-measured. And the narrow form that shipped DOES move a saturation-frozen integrator: it runs before the freeze block and is not gated on `frozen`. Of 79 160 frozen ticks the cap changes `i_w` on 1095, and on 679 of those it moves it further out on the side it was already on - putting back magnitude the freeze had just let fall, which the freeze's own rule forbids. Worst case 2424 W, at prev_w -3400 W, grid +4697 W, actual -4144 W, sat_count 2 -> 3: the freeze leaves the integrator at -176 W and the cap drags it to -2600 W. It never overrides a same-cycle refusal (the freeze clipped on 75 512 ticks, the slew bound on none of them), and that is stated too. It is also where SAFETY-13's settle values went: gating the cap on `not frozen` puts test_control.py part 5 back at -1190 W exactly, while part 4(b) does not come back - that one moved for the winding reason. R3 - the record read its own sensitivity table backwards. `fit_plant()` picks lag 7.5 s on battery-power RMS, which separates it from 2.5 s by 0.3 %, while the tool's own printed fidelity measure (export against the recorded 29.43 kWh) prefers 2.5 s by the whole 35 % gap. Swept properly: at every lag <= 5.0 s release/1.0's worst quarter is ALREADY 3434 W and this card changes it by exactly zero. The 3739 -> 3434 W headline exists at one plant. New section 3a carries the table; the export reduction now leads every summary and the peak is presented as lag-dependent. The form-A rejection is strengthened rather than weakened: form A is worse on the billed quarter at every lag where release/1.0 produces a peak at all, and at every top-N cut of the quarters. R2 - DOCS.md stated modelled watts to the owner as measurement. It now leads with the export reduction, says the figures are simulated, and says plainly that no capacity-tariff improvement is promised. R4 - tools/slew_sim.py --slew narrated "the OVER-ABSORPTION does not move, and at the slowest slews it is slightly WORSE" while printing a monotonically falling column. That sentence is TRUE on release/1.0 (reproduced: 958/1107/1133/1058/987/1029/1150/1143/1081) and false under this branch's law (777/734/663/563/553/507/435/358/321), so SAFETY-39 is not retracted: its record gets a forward reference, and the script now reads SLEW_LEAD_STEPS out of the law it imported and narrates from the column it just printed. Both branches of that narration were driven. R5 - three counts in the L table were decoration and are re-driven in the CI image on a `cp -a`, purged, `:ro`, one file at a time: L5 is 5+5 with the function names exchanged and 5+4 with the branch bodies exchanged (two mutations wearing one name, now listed separately), L6 is 6+3. The separable- state count is grid-dependent and three sweeps disagree, so all three are quoted with their grids and the load-bearing claim is WHERE they separate. R7 - "a large import answered too weakly" is not what the aggregate shows. Form A cuts import by 5.45 kWh and export by 5.18 kWh, and paired per tick delivers 8.56 kWh less offtake energy on the ticks release/1.0 was importing. The harm is a tail redistribution inside quarters that already net to offtake. The conclusion stands; the sentence is narrowed to it. Verified: the full suite in the CI image, purged tree, `:ro` mount, one file at a time, exactly the command in .gitea/workflows/checks.yml - 30/30, exit 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
03ef61fa3b |
SAFETY-48: the slew step ceiling reaches the integrator, and what it bounds is the LEAD
`compute()` freezes the integrator with an asymmetric rule and the comment beside it states the requirement outright - "the requirement is that the INTEGRATOR stop accumulating, not only the command" - but `frozen` is computed from ONE signal, `abs(prev_w - actual_w) > saturation_w`, and slew-limiting never enters it. The integrator moves `gain * error` a cycle and the command moves at most `slew_w`, so the integrator outruns the command whenever `|error| > slew_w / gain`: 667 W at the live shape, against a measured median grid error of 1114 W at the onset of a recorded episode. SAFETY-39's retune made it MORE frequent, not less - a smaller step is a lower threshold. ⚠️ THE FORM THE CARD ASKED FOR RAISES THE BILLED QUANTITY, WHICH IS WHY IT IS NOT WHAT SHIPPED. Reusing the asymmetric freeze on a carried slew flag was built from release/1.0's own source and measured over 16.00 days / 256 252 meter instants of the live install's recorder: export 39.61 -> 34.43 kWh, and the worst quarter-hour average OFFTAKE 3739 -> 3935 W with the mean quarter +2.73 W, in five of six plant and tuning variants. A frozen integrator stops at `prev + gain*error`, which undershoots by (1 - gain); for an export that is free and for an import it is offtake, for as long as the ramp lasts. The card said "do not invent a second rule"; the measurement says to, and both sentences are recorded. WHAT SHIPS BOUNDS THE LEAD AT ONE SLEW STEP, inside the branch where the slew actually bound. Same 16 days, same ticks, same disturbance, same plant, same seed: export 39.61 -> 34.07 kWh, wasted throughput 4.67 -> 3.19 kWh, battery sign reversals 2992 -> 2770, median recovery 232 -> 199 s, p99 849 -> 850 s, max 901 -> 898 s, unrecovered 53 -> 31, worst |i_w - prev_w| 9200 -> 800 W - and the worst 15-minute average offtake 3739 -> 3434 W, i.e. the billed quantity IMPROVES. `i_w = slewed` (a cap of zero) is better again at 32.02 kWh and is refused: it is the velocity form this module's docstring says `i_w=None` collapses to. TWO COMMITTED NUMBERS MOVED, DELIBERATELY, WITH THE REASON BESIDE THEM. SAFETY-13's released command settles at -800 W and -1200 W where release/1.0 settled at -1190 W and -2390 W, because there is less wound integrator to settle onto; AC 4's property - it stops at the frozen integrator, not the rail - is what is asserted and the second is now graded against `i_w` rather than a literal. And SAFETY-39's "the slew does not own the over-absorption" is now FALSE - INVERTED, on the independent first-order plant test_control.py writes itself: 1005/1109/1285/1330 W rising with a slower slew becomes 729/550/424/326 falling. Two plant models that share nothing agree on the sign. Evidence: `tools/replay_recorded_loop.py` plus the read-only capture it made (scenarios/loop-2026-09-02_2026-09-17). The "before" law is the shipped file with the block removed by a named substitution - never a transcription - and a missing anchor is a REFUSAL carrying "NOTHING HAS BEEN MEASURED". The instrument prints its own fidelity first: it reproduces 39.6 kWh of export where the install exported 29.4, i.e. it is ~35 % pessimistic about the loop's tracking, so only the delta is claimed. 30/30 suites green, including test_control.py's exhaustive i_w=None equivalence sweep against the transcribed release/1.0 law, untouched: the cap moves `i_w` and not `target`, and that sweep compares (target_w, sat_count, frozen, reason). Six law mutations, one instrument battery. L4 - the cap applied unconditionally, -92 B - was a SURVIVOR of all thirty files: it is bit-identical on the shipped configuration (0 of 256 246 ticks) and separable on 2050 of 257 400 swept states, all at `integrator_max_w` above `max_w`, which part 4 now drives. L5 and L6 are ZERO BYTES. AND TWO COMMENTS IN THIS CARD WERE INVENTED AND ARE CORRECTED IN PLACE. The one-sided spelling was justified by a two-sided form that would "drag a trailing integrator" - it does not, and the two agree on 205 920 of 205 920 swept states; and a "118 of 256 252 ticks" figure had been measured against a different pair. MUTATION.md gains habits 21 and 22 for both shapes. NOT DEPLOYED, NOT VERSIONED. The live install still runs release/1.0's law. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
b6c6da31d8 |
TEL-10: two sentences that named assertions this file does not make
checks / unit (pull_request) Successful in 7m57s
Both are review findings on the merged-in-spirit branch, and neither is in executable code. R6 - test_mqtt.py part 1's headline said "every one of release/1.0's NINE messages is still byte-identical and still first". The assertion under it is `announce(...)[:len(BASELINE) - 1] == BASELINE[:-1]`, which covers EIGHT: the ninth, `goodwe_ctl/availability`, is byte-identical but deliberately moves to last, so a prefix comparison cannot include it. Nothing was unguarded - the `== SITE_NOW` on the next check pins it in its new position - but the sentence was not the check. Part 3 carried the same mis-statement two lines from a check that IS about all nine, and is corrected the same way. R5 - M7's red count was decoration. Re-driven in the CI image on a `cp -a` of the tree, purged, `:ro`, one file at a time, it is 10 reds in test_mqtt.py and not 8, on this tree and on SAFETY-48's alike. The direction was right; the number was inherited rather than re-measured, which is MUTATION.md's reporting rule 3. A note beside the table now says that a red count is a property of the SUITE as much as of the mutation and therefore has to name a tree. Verified: the full suite in the CI image, purged tree, `:ro` mount, one file at a time, exactly the command in .gitea/workflows/checks.yml - 30/30, exit 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
b548525d10 |
TEL-10: the loop's integrator stops being invisible
`self.i_w` has been a separate quantity from the command since SAFETY-04 and has left the process nowhere at all - no entity, no MQTT attribute, no log line. The investigation behind SAFETY-48 could only RECONSTRUCT it from the grid trace plus a replay; the reconstruction reproduced about a third of the engagements it was checked against and was discarded, so the card it was gathered for had to be argued from a measurement nobody could stand behind. It could not have been made to work either: `i_w` depends on every cycle before it, including the ones where the arbiter overrode the loop and `main.py` re-seeded `self.i_w = self.target`, and that re-seed is invisible in the grid trace. THREE SITE ROWS, APPENDED AFTER release/1.0's EIGHT. `loop_i` is `Decision.i_w` - the accumulator after the cycle, before the output clamp, the slew limit and quantisation. `loop_lead` is `decision.i_w - prev_w` against the command `compute()` was HANDED, which is not a subtraction of the other two published rows: `Setpoint` is the arbiter's output and is this cycle's, so on any cycle the arbiter overrode the loop the two differ - and that is exactly the cycle on which the integrator was re-seeded. `loop_reason` is `compute()`'s own word; the arbiter's `explain()` prints the winning claim's NAME and has never carried the claim's reason, so `slew-limited` appears in neither the `Why` row nor `Controller status`. Measured on the same cycle: arbiter `'loop -> -5000 W'`, loop `'i-clamped'`. NOTHING IN THE CONTROL PATH MOVED. `app/control.py` is not in this diff. `compute()`, its returned `Decision` and every constant it reads are identical to release/1.0, and all nine of release/1.0's retained discovery messages are byte-identical and still FIRST in the announcement. Evidence: 30/30 suites green in the CI image. Nine mutations, ONE survivor, reported as one: `decision.i_w - self.target` for the lead is +2 B and green because `cycle()` has not reassigned `self.target` at that line - the name is kept for the coupling, not for a behaviour it buys, and M2b (+8 B) is the mutation that does change the value and is red. AND TWO OF THIS CARD'S OWN CHECKS COULD NOT REACH THE FAILURE THEY NAMED. "the published integrator reaches its bound" was GREEN under the card's named mutation (publish `prev_w`, -3 B), because the command reaches the bound too - twelve cycles later. It asserts the LEAD TIME now and is red on 5. "a stopped controller publishes not-running" was GREEN under deleting that branch's assignments (-45 B, -67 B), because the fixture STARTED stopped and `__init__` writes the same values; it stops the controller part-way through a run that has already wound the integrator to -5000 W. Both are MUTATION.md habit 4, found by the battery rather than by review. No CHANGELOG section: `test_version_gate.py` 11b refuses a newest `## ` heading that is not config.yaml's `version:`, and opening one would mean taking a packaging decision this card does not get to take. The operator account is in DOCS.md and the release note belongs to the next PKG ticket. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
e3ccd9f6ea |
SAFETY-47: what a shadow run can show, and the watts on its status row
checks / unit (pull_request) Successful in 7m48s
Not a leak and not a write-path defect. Shadow mode's safety is intact -
the substitution, the re-seed, the authorisation gate and the clamp are
untouched. What was wrong is what three merged documents SAY a shadow run
can show you, and a number on the status row that can be 6.61x what a live
controller would command.
SHADOW MODE CANNOT SURFACE A CLOSED-LOOP DEFECT, AND THE DOCUMENTS NOW SAY
SO. The battery is held at 0 W and the carried command is re-seeded to it,
so every measured input - the meter, the billed-quarter accumulator behind
it, the battery power, the SoC - describes a plant that did not respond. A
closed-loop defect is one whose stimulus IS that response. This is the same
property that makes shadow mode safe, so it is not a gap to close later:
the closed-loop answer comes from the closed-loop rig or from a live run.
SAFETY-10 §5 already conceded this for the grid-following loop. It was
never the loop leg's limitation; it is the general case.
THE "STRATEGIES ARE UNAFFECTED" EXEMPTION WAS TRUE UNTIL SAFETY-45 MERGED,
ONE COMMIT EARLIER. That card sizes the buy leg as min(asked, headroom)
with headroom = (900/remaining) x (ceiling - projected), and `projected`
reads the open quarter's accumulator - the meter - which is a function of
what the battery did earlier in that quarter. Measured on TEST-23 stage 3
at peak_cap_w 1500 / house 1200 / max_w 2000, one hour at 5 s:
SAFETY-45's limit cycle NOT visible. 28 onsets live vs 1; grid
-455.5..+3200.0 W vs flat at the 1200 W house;
guard engaged 410/720 cycles vs 0/720.
SAFETY-46's SoC churn NOT visible. The SoC moves 0.000000000 pp.
TARIFF-14's wrong hour VISIBLE, and it is the only one: which hour, at
what price, clearing what spread - identical on
720 of 720 charging cycles at a binding cap.
The exemption narrows to exactly what survives: WHICH hour a strategy picks
and WHY are unaffected; HOW HARD it trades is not. The peak guard's ceiling
and reasons are worth reading from a shadow run; its ENGAGEMENTS are not.
THE NUMBER IS CAVEATED, NOT RE-COMPUTED, AND THAT IS THE DECISION. The
arithmetic is not wrong - 900/remaining is correct for a meter genuinely
under the ceiling, and in shadow the meter genuinely is. It is the right
answer to a question nobody asked. Both surfaces now introduce the watts as
what would have been commanded ON THIS CYCLE, in every state, and carry
shadow.SIZED_FROM_METER whenever a measured billed quarter sized them.
Rejected, and recorded in the decision doc: re-computing it (the recorded
decision would no longer be the decision the shipped path reached, while the
arbiter, clamp, SAFETY-28 gate, log and MQTT all ran on the real one - a
second controller everywhere except the wire); recording nothing (blanks the
row exactly when arbitrage is trading, and the log's edge key is the watts);
and recording the unsized ask beside it (a second number, no warning, and at
the shipped 3500/2000 both figures read 2000 W).
"UPPER BOUND" AND NOT "SMALLER", AND THE DIFFERENCE WAS MEASURED. Charging
only adds offtake, so the shadow size is >= the live size with EQUALITY at
the start of every quarter. Pairwise over 3600 charging cycles in five
configurations: 0 below, 730 exactly equal. The first draft said "smaller",
which would have been false on every one of those 730.
AND THE PREDICATE IS NOT KEYED ON WHETHER THE TRADE WAS CAPPED. arbitrage's
own sentence says "SIZED BY THE CAPACITY-TARIFF GUARD" only when the
headroom came out below the ask - in a shadow run, the START of the quarter,
where the two runs agree to the float. By the end, 900/remaining has
multiplied the headroom past the ask, the trade is not capped, the sentence
is silent, and the record is at its most wrong. A caveat keyed on it would
be quietest exactly where it is needed loudest.
SAFETY-10 §6's COMMITTED EVIDENCE COULD NEVER HAVE FAILED, FOR TWO
INDEPENDENT REASONS, AND CLOSING EITHER ALONE CLOSES NOTHING. Its fixture is
peak_cap_w 15000 against a 350 W house, so the guard never engages and the
sizing is inert - the harness's own comment says so. AND every identity row
reads the FIRST charging sample, where at elapsed=0 the projection IS the
held level and the two runs agree to the float even at a cap that does bind.
Measured: widening the fixture alone leaves that comparison passing. That
second reason is a new shape for MUTATION.md's habit-4 list - an INDEX, not
a fixture - and it is recorded there as the twelfth instance. §6 is re-taken
at a binding cap across the whole run: 716 of 720 charging cycles differ,
headroom 300.0..54000.0 W in shadow against 299.9..307.8 W live, recorded
-2000.0 W against a live -302.4 W.
THE GRADER IS test_arbitrage.py PART 3d, beside SAFETY-45's own closed-loop
regression and built on the same s45_loop() harness, so the runs differ in
one argument. Three lines stand in for _write_unit()'s substitution and
_shadow_settle()'s record and re-seed, marked as a transcription;
test_clamp.py part 8 remains the check on the shipped placement. The section
carries its own precondition into every check: a mutation that stopped
either run trading made it raise on its first min(), kill the file and
report ZERO reds - FCST-06's shape, driven rather than imagined.
AND THE FIX'S OWN FIRST DRAFT CARRIED THE SAME DEFECT. Deleting the caveat
from the action-log line left all 29 files green, because the sentence was
an f-string inside _shadow_settle() and the only suite that drives it uses a
meter_source:off fixture where no arbitrage decision exists. It is
shadow.log_line() now, for the reason words() is a function.
14 mutations, no survivors; M13 and M14 are +0 B and were confirmed on disk
by sha256 with every run purged. M14's first form was aimed at the wrong
direction and correctly survived - min->max makes the shadow record larger,
which is still an upper bound. All 29 check files green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
e1b9254626 |
SAFETY-45: a price-driven charge is SIZED by the capacity guard, not switched by it
checks / unit (pull_request) Successful in 7m42s
With arbitrage armed at the shipped defaults, arbitrage and the capacity-tariff guard drove each other in a sustained limit cycle. Re-measured on TEST-23 stage 3's closed rig against |
||
|
|
995da777ce |
TARIFF-16: the injection leg stops being one number
checks / unit (pull_request) Successful in 7m42s
`price.py` modelled what a site is PAID for export as ONE scalar. The market publishes a line, and the cards say so in their own words: Eneco Zon & Wind Dynamisch `0,1 x BELPEX-H - 1,188` c EUR/kWh - "het injectietarief is dynamisch en kan per uur wijzigen", identical on the January and the September 2026 cards; ENGIE Dynamic `(B x eSpot_15) - A`; Aspiravi `0,07 x Belpex - 2`. `price_export_formula` carries that line with its OWN coefficients. ⚠️ THIS IS NOT A WRONG-TRADE FIX, AND SAYING SO IS PART OF SHIPPING IT. `arbitrage.py` does not read `export_price_eur_kwh` at all - its own comment says so, citing §9.3's near-zero Belgian injection price as the reason the economically sound trade on this market is charge-cheap-to-displace-expensive- IMPORT. Both hours it compares are all-in CONSUMPTION prices. No trade this add-on takes today is decided by an injection price and nothing here changes one. What it changes is what an operator is told, and what a future export-aware strategy rests on: OPT-06 and any export logic would otherwise inherit a scalar that cannot represent the market, and the 6% VAT asymmetry would be invisible in the arithmetic. test_price.py 17h parses every module under app/ and goes red the day something calls an injection reader, so that sentence is graded rather than attested. FOUR PROPERTIES A SCALAR CANNOT HOLD. Its own factor (Eneco 0,102 consumption against 0,100 injection; Aspiravi 0,07 against ~0,10 - not the consumption B with a sign flip). A negative offset, which `price_markup_eur_kwh: float(0,1)` cannot express at all. VAT-exemption - "injectietarieven zijn vrijgesteld van btw" - against 6% on consumption, representable only because `vat_pct` is PER SPEC. And a price that goes below zero with no floor, under 11,88 EUR/MWh of BELPEX-H, which is the middle of a sunny day. ⚠️ A NEGATIVE INJECTION PRICE IS ACCEPTED, AND THAT NEEDED A DIFFERENT BAND RATHER THAN A LOWER FLOOR. `implausible_all_in()` floors the consumption leg at +0.02 EUR/kWh of energy; applied to injection that refuses the real market in the hours it matters most. Measured rather than argued: 17c puts ONE spec through BOTH bands and prints that the consumption band refuses -0.03008 EUR/kWh while the injection band accepts it. The floor was not decoration - it is what catches a card read a hundred times too small, the one unit error the card self-check cannot see because it cancels out of a comparison between the two sides - so the bound moves OFF the price and ONTO the coefficients, where it is index-free and sign-free: a pass-through band (Eneco's injection leg is 1.00x, Aspiravi's 0.70x; the band is 0.10-10x) and an offset magnitude bound (0.20 EUR/kWh against published 0.01188 and 0.02). Two rungs that can both answer a hundredfold error, so each is graded on its REASON, each gets a stimulus the other cannot reach with the precondition asserted as its own check, and they are mutated TOGETHER (TEST-16). ⚠️ THE CARD SELF-CHECK APPLIES WHERE THERE IS A CARD, AND WHERE THERE IS NOT THAT IS STATED RATHER THAN WORKED AROUND. `price_formula` can require a `card` block because a Belgian card prints the consumption formula, the reference index AND the resulting price. Eneco's INJECTION formula is printed with no price beside it. Requiring a block would make the published line inexpressible; inventing the estimate would grade the coefficients against arithmetic done with the same coefficients. So the block is OPTIONAL on this leg and unchanged on the other, the self-check runs at the same tolerance wherever a printed estimate exists, and validate.py says at startup - "its coefficients are UNGRADED" - when it does not, naming the two bounds that ran instead. This is TARIFF-15 demonstrating two tariffs end to end rather than three, one card later. 17e also MEASURES the size of that gap: a factor of 0.070 where the contract says 0.100 - 30% of every export payment - passes both bounds, because 0.70x is a pass-through somebody really publishes. ONE PARSER, NOT TWO. `parse_export_formula()` is `parse_price_formula()` with its OPTION NAME as an argument - TARIFF-17's move on `parse_static_profile()`, exactly - and everything that differs between the legs is a string in one `_LEGS` table. A second copy would be a second unit table, a second NaN rule, a second unknown-key rule and a second `to_resolution()`, diverging first on the 23- and 25-hour days. test_price.py 17b pins it two ways: all 32 grammar names are defined in exactly ONE module under app/, with the scope COMPUTED by parsing every file rather than listed; and twelve refusal sentences for the new option are DERIVED from the old option's own sentence rather than transcribed. The four sentences that deliberately differ are named, with what differs, and asserted to differ. NOTHING CHANGES FOR A SITE THAT DOES NOT SET IT. The option ships ""; `export_price_eur_kwh()`'s body is byte-identical, pinned on the AST rather than on the source text; `export_price_at()` falls through to it for every index value. 17f compares seven option dicts x five index values against the pre-TARIFF-16 expression with `==`. `price_export_formula` joins FRAMEWORK_KEYS, BANNED_IN_PLUGIN_CODE is derived from that set, and `code_leaks()` refuses a plugin that names it with nothing else edited. ALSO CORRECTED HERE: config.yaml's comment beside `export_price_eur_kwh` still read "no shipped supplier publishes a dynamic injection curve". TARIFF-07 struck that claim from app/price.py and DOCS.md and did not reach config.yaml, where it has been shipping since. 20 mutations, every one red on a named check, ledger in test_price.py below section 17 - including a ZERO-byte one (the two legs transposed in the VAT-asymmetry banner: no decision moves, only the sentence) and a ONE-byte one (`card_required=True` on the injection leg). Three run over all 29 files and red on test_price.py alone. 29/29 green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
9c65ac2cf8 |
TARIFF-18: price_taxes_eur_kwh stops shipping at half the real figure
checks / unit (pull_request) Successful in 7m48s
`price_taxes_eur_kwh` shipped at 0.06 EUR/kWh with no recorded provenance -
no card, no date, no component list - and roughly HALF the true Flemish
all-in non-energy figure. It now ships at 0.11736, and the rows it is made
of are in the code rather than in this message:
Fluvius distribution (Antwerpen) 5,35 c EUR/kWh
Bijzondere accijns, "Alle verbruik" 4,876
Bijdrage groene stroom en WKK Vlaanderen 1,51
Energiebijdrage (0 EUR/MWh since 2026-08-01) 0
------
11,736 c EUR/kWh = 0.11736
Eneco residential card for Flanders, September 2026, incl. 6 % VAT.
`app/price.py`'s FLEMISH_TAXES_COMPONENTS_EUR_KWH carries the rows and
test_price.py section 1b re-adds them - and also compares the total against
the card's own figures typed in the test file, so an edit that moved the
rows and the default together is still caught.
WHAT THIS IS NOT. It is not an arbitrage-correctness fix and it cannot be.
This scalar is a LEVEL component: an additive constant cancels exactly in
`max - min` and leaves the argmin where it was. TARIFF-17 shipped that proof
and it is unchanged - test_price.py 16b drives six flat scalars spanning and
exceeding the whole per-DSO range and gets the identical cheapest hour and
the identical spread to the last digit. DOCS and the CHANGELOG say so in
those words.
WHAT IT IS. A display and guard fix:
* every number an operator reads, which at half the true value made the
add-on look considerably better than it is; and
* §17's negative-price guard in arbitrage.py - the one rung that tests an
ABSOLUTE all-in price rather than a difference. It fires when
`spot < -(markup + taxes)`, so its boundary moves -0.09 -> -0.14736
EUR/kWh of spot. test_arbitrage.py section 2c drives both sides through
the shipped decide() and pins the boundary from below as well.
The default is Antwerpen's real row, near the LOW end of the 0.117-0.131
per-DSO band, and that is deliberate: the error directions are not
symmetric. Too high carries a genuinely negative hour above zero and the
add-on exports into paid-for disposal - §17's own case. Too low only
declines a discharge that would have paid.
TARIFF-17's INTERACTION, WHICH HAD TO BE DECIDED. Nothing in that rung
hardcoded 0.06; it read PRICE_OPTION_DEFAULTS, so moving the default moves
what it refuses. The Supervisor sets `App.options = deepmerge(config.yaml
defaults, stored options)`, so a saved blob carries the key whether it was
typed or not - every stored 0.06 became "non-default" with nobody editing
anything, and a working Walloon or Brussels site with a taxes table would
have met SystemExit(78) on the upgrade over an inert number. price.py now
carries SUPERSEDED_TAXES_DEFAULTS_EUR_KWH = (0.06,) and validate.py reads a
superseded default as untouched too. The rung is NOT weakened: 0.1198 beside
a table is still refused by name, and the refusal now names both figures.
config.yaml and PRICE_OPTION_DEFAULTS moved together; CFG-03's tripwire
(test_price.py section 1, test_defaults_pin.py) stayed green by agreeing.
test_price.py section 0's fourth check was REWRITTEN rather than re-pointed:
it used to justify the file's own grading constants by observing that the
shipped pair was degenerate (0.06 is exactly twice 0.03). TARIFF-18 ends
that coincidence, so the sentence is no longer true and is not kept.
29/29 test files green. Mutation ledger on the PR.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
2fac0aaf06 |
TARIFF-17: a price component that varies by hour has somewhere to live
checks / unit (pull_request) Successful in 7m25s
`price.py` modelled the whole non-energy part of a Belgian bill as ONE flat scalar. This adds `price_taxes_profile`, which carries the TAXES component as a period table, and makes `assemble()` evaluate it at each point's own instant. ⚠️ THE FLAT SCALAR WAS NOT A WRONG NUMBER, AND THIS IS NOT A CORRECTION OF ONE. The optimiser reads exactly two quantities off a series - the spread `max - min` over the firm points, and which hour is cheapest - and an additive constant identical in every hour cancels out of the first and leaves the second where it was. It is provably inert. Measured, and printed by test_price.py 16b on every run: with a flat taxes figure of 0.00, 0.0739, 0.1198, 0.1327 or 1.00 EUR/kWh the same 24-hour feed is cheapest at 13:00 in all five cases and its spread is 0.1500 EUR/kWh in all five cases. What was missing is anywhere for an HOUR-VARYING non-energy component to live. This is a missing capability, not a bad constant. Belgium has one. Fluvius distribution is a single rate - its tariff-card row reads `Normaal | Uitsl. nacht`, where exclusive night is a separate physical circuit and not a night register - so nothing changes for the reference site in Flanders. The Walloon DSOs and Sibelga are on the same card with four columns, `Enkelvoudig | Dag | Nacht | Uitsl. nacht`, and the gap is real money (c/kWh): ORES 13,27 / 7,39; AIESH 15,17 / 8,21; Tecteo RESA 12,20 / 7,01; Sibelga 9,39 / 7,10; ORES IMPACT 2026 16,578 / 5,090. ⚠️ AND THE BANDS ARE ANTI-CORRELATED WITH WHAT THE BATTERY WANTS TO DO. The cheap network hours are the cheap spot hours, so flattening them does not shift the curve - it UNDERSTATES the spread and declines cycles that would have paid. Same feed, same markup, the only difference being the taxes argument: flat 0.0000 / 0.0739 / 0.1198 / 0.1327 / 1.0000 -> cheapest hour 13, spread 0.1500 ORES dag 0.1327 / nacht 0.0739 -> cheapest hour 03, spread 0.1788 The regression check grades WHICH HOUR IS CHEAPEST, not the spread: an implementation that reads the table once and adds one number to every point is the LEVEL bucket wearing a period table - right magnitude, wrong shape - and a spread assertion passes on it. Measured: hoisting the lookup out of `assemble()`'s loop (+17 B) is red on six named checks in test_price.py and GREEN in the other 28 files. ONE GRAMMAR, NOT TWO. `static_tariff.py` already shipped the periods, the day selectors, the tiling rule and the DST handling, so `parse_taxes_profile()` is the same parser with the option name and the "what this number means" clause as arguments. A second tiling rule would be a second set of 23- and 25-hour-day bugs. test_static_tariff.py section 10 pins that two ways: by parsing every module under `app/` and asserting the grammar is defined in exactly one of them, and by deriving each of thirteen refusal sentences for the new option from the old option's own sentence rather than transcribing them. `Component.TAXES` semantics, `code_leaks()` and the double-counting guarantee are untouched. The new key joins FRAMEWORK_KEYS, BANNED_IN_PLUGIN_CODE is derived from that set, and a plugin that names it is refused at `register()`. It reaches both plugins that take taxes - `custom_formula` ({ENERGY, MARKUP}) and `generic_dayahead` ({ENERGY}) - and not `manual_csv`, which is all-in: the table is routed through `capabilities().adds`, exactly as the scalar is. ⚠️ A MALFORMED TABLE IS REFUSED AT STARTUP, NOT AT THE NEXT FETCH. It is read on the ~30 s planning path, and a table that first fails at 03:00 is a site that stops pricing at 03:00, which §11.4 turns into a quiet fallback indistinguishable from a healthy fixed-tariff day. validate.py refuses three things: a table that does not tile the day; a table set beside a NON-DEFAULT `price_taxes_eur_kwh`, because the table wins and the scalar would then be a number the operator typed that decides nothing; and a whole card typed in CENTS, which is how these figures are printed. That last rung is not a restatement of the parser's +-10 EUR/kWh bound - ORES's 13,27 trips the parser, but Sibelga's entire card, 9,39 and 7,10, is under it and would otherwise have started as a 9.39 EUR/kWh levy. Both rungs are graded by the REASON each gives, and mutating them together (TEST-16) moves three rows. NOTHING CHANGES FOR A SITE THAT DOES NOT SET IT. The option ships "", and the scalar path is unchanged arithmetically rather than merely in intent: test_price.py 16f reproduces the pre-TARIFF-17 expression and compares 12 capability/scalar combinations point for point with `==`. 13 mutations, every one red on a named check, ledger in test_price.py below section 16k with byte deltas and the one equivalent mutant named. 29/29 green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
a991438e40 |
TARIFF-13: the coverage figure beside the peak stops resetting at a restart
checks / unit (pull_request) Successful in 7m27s
The capacity-tariff status row printed a peak read from SQLite next to a count
of banked quarters read from PROCESS MEMORY - `PeakTracker.recorded`, an `int`
set to 0 in `__init__` and counted up inside `sync()`. A 40-hour soak caught
what that means at a driven restart, on both instances independently:
before: 2026-09: peak 365 W, set by the quarter at 2026-09-11T18:45:00+00:00
- 10 % of the 3500 W soft limit; 120 quarter(s) banked
after : 2026-09: peak 365 W, set by the quarter at 2026-09-11T18:45:00+00:00
- 10 % of the 3500 W soft limit; 0 quarter(s) banked
Reproduced here before anything changed, through the real Controller and a real
restart - a second Controller object against the same file on disk - at 12 -> 0
with 12 rows standing in `quarter_blocks`.
The peak is durable and was never wrong. The COUNT is what says how much of the
billing month that peak covers, and 365 W over 120 quarters and 365 W over 0
quarters are different claims about the same number. Mid-month, after every
restart, the page made the weaker claim while displaying the stronger one's
value. That is TARIFF-01's own argument - an unreadable store must not read as a
zero peak, because a zero peak is disarming - one column over.
`PeakStore.banked_count(month)` asks the database, and `recorded` is a property
over it. Two more defects in the same variable fell out once it was driven, and
neither was in the ticket: it was never scoped to a MONTH (on 1 October the row
still showed September's tally beside October's empty month - measured), and it
missed the block `restore()` banks from the checkpoint, which never passes
through `sync()`.
⚠️ NOT 0, AND NOT None, WHEN THE STORE WILL NOT ANSWER. `banked_count()` returns
None and the row renders `an unreadable number of quarter(s) banked`.
`f"{None} quarter(s) banked"` was rejected because it reads as the same zero.
`0 quarter(s) banked` stays true and reachable - it is what a month nothing has
been written to says, and the two are asserted to be different text.
⚠️ THE DEDUPE IS NOT REMOVED WITH THE COUNTER. `_seen` still stops `cycle()`'s
once-a-second `sync()` re-banking a block `P1Ingest.blocks` keeps for a day. The
check that graded it asserted `recorded == 1` - the dedupe and the figure
reporting it were the same variable - and now grades `sync()`'s RETURN, because
counting rows cannot see a re-bank at all (PRIMARY KEY no-op).
THE TIE RULE, DECIDED RATHER THAN EMERGENT: at equal wattage the EARLIEST
quarter keeps the attribution. No bill and no guard moves - `peak_w` is still
only ever raised, the `>` term is untouched, SAFETY-07 reads the same watts.
The two readers DID disagree: the upsert's strict `>` kept whichever tied block
was WRITTEN first while `recompute_month()` has always kept the earliest, the
same only while blocks arrive in time order, and measured to diverge (09:45
against 09:00) fed newest-first.
⚠️ AND THE SOAK'S DRIFTING QUARTER WAS NOT A TIE. Six exactly equal 365.0 W
quarters re-attributed zero times on the shipped tree - `>` is strict. What
moved it was a real new maximum BELOW THE ROW'S OWN RESOLUTION: the row renders
whole watts, so 365.0000001 W is a new peak to the database and the same 365 W
to the reader. Left alone deliberately, and pinned so it is not re-diagnosed.
test_tariff.py parts 11 and 12. 12 mutations, all red, listed on the PR; M2 -
`banked_count()`'s sqlite3.Error arm returning 0 - was a SURVIVOR at -3 B on the
first battery, because a handle that never opened short-circuits before any SQL
runs and nothing reached the except arm. Closed with an open store whose table
is dropped under it, with a precondition that the same call answered 4 first.
main.py is NOT touched - TARIFF-11 holds its status rows. The row picks this up
without editing because `recorded` is an attribute access and is now a property.
The residual is stated in DECISIONS §3: `recorded` answers for now() while the
peak follows `checks(when=...)`, and the shipped install has exactly one call
site - `self.checks()` with no argument, main.py:4359 - where they are the same
month, so the live page is correct and durable. `recorded_in(when)` is shipped
and the row's move onto it is one token, for whoever holds main.py next; part 10
pins the present behaviour against a row count taken from `quarter_blocks` so
the gap cannot be quietly forgotten.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
028c3e53fb |
Merge release/1.0 (TARIFF-12) into TARIFF-14
checks / unit (pull_request) Successful in 7m13s
Second base move while this card was open: |
||
|
|
435e1b6f7c |
Merge release/1.0 (TARIFF-10) into TARIFF-14
checks / unit (pull_request) Successful in 7m10s
CI on PR #80 was NOT a test failure. The workflow's "grade the merge, not the branch" step refused the PR because it does not apply, and said so itself: "REFUSED: refs/pull/80/head does not merge into release/1.0. NOTHING HAS BEEN TESTED. This is not a test failure - no test ran." Conflicts were CHANGELOG.md and test_price.py; app/price.py auto-merged. CONFLICTS RESOLVED * CHANGELOG.md - both entries belong under ## Unreleased. TARIFF-10's table row and section come first because it landed first. No version number is picked here; PKG-06/07's note stands untouched. * test_price.py - BOTH cards appended a section "13". Nothing is dropped: TARIFF-10's stays 13, this card's is renumbered 14, and TARIFF-10's temp-directory teardown stays at the end of the file. The two share no fixture, option dict or helper name. WHAT TARIFF-10 CHANGES FOR THIS CARD - nothing in the reasoning, and the check is recorded rather than asserted (decision note §7). In app/price.py the two do not meet: TARIFF-10 added provider_id() and provider_configured() beside price_option() and routed load_prices() through them - the option-reading layer, touching nothing in _Series, at(), covers(), window() or _cover_table(). The merged at() is byte-identical to the one the mutations were driven against. Behaviourally TARIFF-10 makes run_tariff_refresh() run with arbitrage OFF, so a provider-configured site now has a standing plan - and therefore a _Series - where before it had none. It does NOT add an at() consumer to the 30 s tick: static_status() builds a STATIC plan (hourly, immune), main.py calls only plan.words on the fetched plan, and price_forecast.price_permits()/begin_phase() have no caller in main.py at all. So TARIFF-10 neither creates nor closes any exposure this card is about - it enlarges the defect's reach the moment such a site arms trading, which strengthens the fix without changing it. RE-GRADED ON THE MERGED TREE, not carried over * all 29 test files green; * all EIGHT mutations re-driven, purged before each run, each reverted with the fingerprint re-checked. Same verdicts and same red-check counts as before the merge: M1 (the shipped one-hour scan, +68 B) red on 24 named checks across both files; M3 (the ZERO-BYTE tail rule) red on 4; M2/M4/M6 on 1 each, M5 on 2, M7 on 16, M8 on 14. * Clean app/price.py on the merged tree: 90d8ca31f7c0109d, 85953 B. * None of TARIFF-10's 650 new lines of checks moves under any of the eight - they grade resolve_tariff()'s reachability from a configuration, not the lookup. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
7f2c4e20e1 |
TARIFF-14: the point whose own interval covers the instant
checks / unit (pull_request) Failing after 2s
`_Series.at()` asked `s <= u < s + 1 h` of every point IN LIST ORDER and
returned the FIRST match. On the hourly shape every fixture in this
repository carried before TARIFF-08 that is right by accident. Belgium
moved to a 15-minute MTU on 2025-10-01 and the live day-ahead feed
returns 96 points at 900 s, where it is wrong twice over - measured
against the shipped code at
|
||
|
|
375a1e29d2 |
FCST-05: one byte stops stopping the add-on, and the write path gets the read path's standard
checks / unit (pull_request) Successful in 7m10s
HIGH - a non-UTF-8 byte in /data/price_history.json stopped the add-on
from starting. `_load()` caught `(json.JSONDecodeError, OSError)`.
`UnicodeDecodeError` is `ValueError -> UnicodeError -> UnicodeDecodeError`:
neither of those, and `issubclass(UnicodeDecodeError, OSError)` is False. So
it escaped `_load()` -> `_open()` -> `PriceHistory.__init__`, which
`Controller.__init__` does not guard - breaking `_load()`'s own stated
contract, "a corrupt cache must not stop the controller". The handler now
names the ValueError FAMILY (the file's content) beside OSError (its bytes),
because a third subclass nobody has thought of is how this arrived the first
time. The suite could not see it: its "unreadable file" fixture writes
"{ this is not json", which is valid UTF-8 and fails one step later, in
`json`. A fixture holding a genuinely undecodable byte is now beside it,
with the difference between the two asserted as a precondition.
MEDIUM - a malformed `start` under the day the feed is writing to killed the
tariff plan permanently. `_remember()` merged with `{q["start"]: q ...}` and
sorted on `q["start"]`, trusting a value read off /data to be a hashable,
sortable string. An int, null, a dict or a list raised TypeError out of
`carry()` -> `resolve_tariff()` -> `run_tariff_refresh`, which catches
Exception - so the add-on stayed UP while every refresh raised forever, the
plan retiring after TARIFF_MAX_AGE_S with arbitrage and the cheap window off
for good, and no self-heal because `_remember()` raised before `_save()`.
The read path was properly defensive throughout, which is why `yesterday()`
survived and the existing fixture passed - it files its malformed rows under
a day the feed is NOT writing to. Both paths now share one validator,
`_point_of()`. A row that is not a point is dropped, reported, and written
back out clean.
...and the status row said the store was healthy while the plan was dead:
`words` read '2026-01-15 (1 h)'. `_load()` now counts the rows inside a kept
record as well as the record itself, so the store stops reporting plain
health from the moment it is opened; the note names what was FOUND at open
and what was DROPPED by the refresh, and clears once the file is rewritten.
...and the merge key is the validated INSTANT rather than its spelling,
which is what rule 4 already said. The lexicographic sort beside the string
key also put the second 02:00 of the 25-hour October day (+01:00) before the
first (+02:00); stored order only, since `_parse_points()` re-sorts.
LOW - the status row counted POINTS and printed them under the HOUR's name:
'2026-01-13 (96 h)' for a 24-hour day on the quarter-hourly feed this add-on
already supports. It is now '96 points over 24 h' - two facts, each under
its own unit.
LOW - two stated reasons were false. `_audit`'s "WHY IT IS STRICTER" claimed
the rework refused the same published point emitted twice and a carried
point sharing a published hour, "which the old count never looked at". A
differential over the whole domain the audit can express finds ZERO
divergence in both directions: the old `seen` count already refused the
first (one point twice is one hour twice) and the old IDENTITY rule already
refused the second. The reworked audit is EQUAL to FCST-03's, not stronger;
the only behavioural change is the intended relaxation, and it stays. The
differential is committed as a check rather than left as prose.
...and the audit's real limit is named: it does not check COMPLETENESS. Fed
the decimation defect FCST-04 was raised for it ACCEPTS, and it accepts an
empty output. So `_audit` is not what stops that recurring - the count and
identity checks are, and the text now points at them. Not made complete:
`published` is the whole read while the fill emits only [since, until), so
the rule as stated is wrong, and building it takes the 1/12/48 h horizon
checks red on a healthy feed. Measured, not assumed.
12 mutations, purged tree, every one RED, no survivors. All 29 test files
green. Nothing deployed; no option added; config.yaml untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
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 |