d2ad080e45680cfe2dd3cb2fa494ca85c4a5c0f5
65
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 |
||
|
|
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
|
||
|
|
8fa4febfef |
SAFETY-10: the whole decision path runs and nothing reaches the inverter
checks / unit (pull_request) Successful in 7m58s
Every optimiser defect this project has found was invisible from outside while it was happening, and each was paid for at the meter first: SAFETY-45's 28 charge onsets an hour against a binding peak_cap_w, TARIFF-14's wrong quarter-hour for 45 minutes in every 60, SAFETY-46's 598 Wh spent to gain 310 Wh. There was no way to watch what the optimiser WOULD do without letting it do it. `shadow_mode` runs strategies, arbiter, clamps and guards, records the decision with its reason, and commands nothing. ⚠️ IT WRITES. It writes 0 W, on the ordinary heartbeat, through the ordinary single writer. The inverter holds its last command forever - it has no meter timeout - so "commands nothing" implemented as "stops writing" would leave whatever was standing when shadow mode came on standing for as long as it was left on, under a page saying nothing is being commanded. That is the failure this repository keeps finding. Refreshing 0 W is also what firmware/goodwe-master.yaml's 30 s stale-input watchdog requires of us; going quiet would keep that board tripped for the whole run, which is an alarm being used as a mode. ⚠️ IT IS NOT A SECOND WRITE PATH. No claim, no transport, no arithmetic of its own. It is a substitution inside Controller._write_unit() - the single choke point every write already reaches - in the same form as the three above it, and it is the LAST of them, so SAFETY-28's gate and SAFETY-03's clamp still grade the number the decision path produced. ⚠️ THE DEFAULT IS `auto`, NOT `true`, AND THAT IS THE CARD'S REAL CONTENT. The Supervisor composes options as {**config.yaml defaults, **stored user options}, so a new key with a new default reaches EVERY EXISTING INSTALL on the next update - including the one regulating a real battery. A boolean default-on would have stopped it silently. There is no mechanism by which one key can default differently for a new install and an existing one, so "default-on for new" is a value the add-on RESOLVES: `auto` is shadow while control is STOPPED and live once it is STARTED. A fresh install ships `auto_start: false` and comes up observing; an install that is controlling is started and does not change by one watt. THE FAILSAFES. Sign watchdogs (SAFETY-02/30): INERT - they grade Inverter.standing_w(), which shadow mode holds at 0 W. Fed the shadow decision they would disagree with a meter that never saw it and LATCH A TRIP THAT SURVIVES INTO LIVE OPERATION AND A RESTART. Clamp (SAFETY-03), write gate (SAFETY-28), meter freeze (SAFETY-18), peak guard: OBSERVE. Circulation (SAFETY-31) and delivery (SAFETY-42): observe and may legitimately latch, because their evidence is measured power. The operator stop is the one deliberate change: enforced at the wire rather than as a P_SAFETY claim, which would otherwise make every recorded decision read "stopped". ⚠️ RECORDED LIMITATION: _shadow_settle() re-seeds the carried command to what stands on the wire, which makes the exit safe by construction - and therefore the loop leg's shadow decision is ONE SLEW STEP, not a trajectory. A shadow run answers "what would it command now", not "what would it have done next". The strategies are unaffected; all three defects above are visible from one. Graded on the writes and on the rendered surface, never on the flag: 22 mutations, no survivors, including one that lets a write escape and one that goes silent instead of commanding 0 W. Closed loop on TEST-23 stage 3's rig, same hour twice: live drives the pack to -2000 W and SoC 50.000 -> 62.331 %; shadow commands 0 W throughout, SoC stays 50.0 exactly, and the arbitrage decision is identical in action, instant, signed power, reason and arbiter winner. 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 |
||
|
|
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
|
||
|
|
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
|
||
|
|
ac9854886b |
TARIFF-15: your own tariff card, as two coefficients the card itself grades
checks / unit (pull_request) Successful in 7m14s
A Belgian dynamic contract prices energy as `B x index + A`, and the two
coefficients exist on exactly one artefact: the customer's own tariff card.
There is no machine-readable source for them anywhere - CREG's consumer
tariff-card pages 404, CREG Scan is a JS wizard, the annual report has the
supplier list without per-product coefficients, VREG was unreachable, and
every coefficient found in research came off a supplier's own PDF, one
image-only. So nothing can be scraped and a per-supplier plugin would have no
source for its numbers, which is why TARIFF-07 withdrew three of them. The
customer is the only party who can see their own coefficients; they type them.
New provider `custom_formula`, one new option `price_formula` (a JSON string,
the `static_profile` precedent exactly), and a new module
`app/custom_formula.py`. The index path is `generic_dayahead`'s, not a second
one: the URL/override decision is now one shared `dayahead_feed_url()` both
plugins call, same feed, same Brussels-calendar range, same columnar parser,
same declared egress hosts.
It declares {ENERGY, MARKUP}, NOT ALL_IN. A card's Tariefformule line is the
energy component alone - Eneco tabulates network tariffs and levies
separately, ENGIE states the formula excludes network, distribution,
transport, levies and excise - so ALL_IN would lie for every operator who
typed what their card says and would drop the taxes from every hour
(0.2125 -> 0.1555 EUR/kWh, measured).
THE CARD IS THE GRADER, and that is why this is safe to ship. The framework
already mechanises that a PLUGIN cannot double-count. This introduces a risk
none of those guards can see because it is config, not code: the OPERATOR
double-counting, by folding network charges into `offset` while
price_taxes_eur_kwh carries them too. So validate.py recomputes the card's own
printed price from the operator's coefficients at startup and refuses to start
unless the two agree to within 0.01, naming the discrepancy. An inflated
offset cannot pass: 15.55 becomes 28.27. The same check catches VAT omitted,
VAT doubled, a transposed digit and a unit mismatch between the two sides.
And the limit, in the same sentence as the claim: it grades the coefficients
against the card, NOT the card against reality. A consistent-but-wrong pair
passes. A second refusal covers the one unit error the first cannot see - the
one applied to both sides at once - by bounding the card's own point to
0.02-1.00 EUR/kWh. Its FLOOR is applied to the energy figure rather than the
all-in one because the shipped 0.06 of taxes is three times that floor and
would carry a hundredfold error over it alone; found by writing the mutation,
not by review (M10, +0 B).
The market's own arithmetic, reproduced through the shipped code:
Eneco (0.102 x 133.9905 + 1) x 1.06 = 15.5471 card prints 15,55
ENGIE (0.1 x 61.96 + 0.9) x 1.06 = 7.5218 card prints 7,522
NO EXPRESSION EVALUATION OF ANY KIND. The string is settable over HTTP through
the add-on's own ingress page, so eval on it is RCE in a root container
holding SUPERVISOR_TOKEN and driving an inverter over RS485 - but the deciding
argument is that an expression is UNVERIFIABLE: two coefficients can be graded
against the card, an arbitrary expression cannot be graded against anything,
so it would make the invisible-wrong-coefficient problem strictly worse. An
unknown field is REFUSED rather than ignored, so `"expr"` is told it does not
exist. Future shapes become named fields, never a grammar. test_price.py 15f
walks the module's AST for eval/exec/compile/__import__/literal_eval and
exercises the scanner's red direction on every run.
Refused at startup: card absent or out by >0.01; `units` absent (no default);
factor == 0 (every spread 0, arbitrage silently never fires - the message
points at static_profile); factor < 0 (inverts the market); vat_pct outside
0-30 (0 stays legitimate); non-finite or non-numeric; unknown field, index or
resolution; the plausibility band. A card older than ~6 months WARNS and does
not refuse - ENGIE freezes coefficients for a contract's duration. Negative
output prices are not refused anywhere.
A startup preview logs the next 24 h as `index EUR/MWh -> all-in EUR/kWh` with
min/median/max and the day's spread, through a new default-silent
`PriceProvider.startup_preview()` hook - only the plugin knows what went in to
its own arithmetic. Emitted once per process, and a preview that raises cannot
cost the site its prices.
`custom_formula` joins the config.yaml enum through the DERIVED set
PROVIDERS | RETIRED_PROVIDERS | {"off"}, not a hardcoded list. Both
translations describe the option and the new id. DOCS.md replaces the
"planned" sentence with the shape, a worked example from a real card, a real
startup transcript and the limit of the check stated beside the claim.
22 mutations, one per check added, no survivors - MUTATION.md carries the
ledger. M13 (the preview's index column) was a -13 B survivor of the whole
suite on the first attempt because the check graded the HEADING and not the
row; it is graded on a rendered row now. All 29 suites green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
f620f7cf80 |
TARIFF-07: three plugins that never fetched a price are withdrawn, and a site configured on one is told so
checks / unit (pull_request) Successful in 7m12s
engie_dynamic, totalenergies_dynamic and eneco_eddy are removed from PROVIDERS and from every surface that listed them. They did not stop working - they never worked. Re-verified by hand on 2026-09-13 before deleting anything: 404 with Kong's "no Route matched with those values"; a TLS handshake that fails because api.totalenergies.be is absent from the certificate's SAN list; 404 with an empty body. All three are also structurally impossible - the coefficients are per-contract and behind a customer login, there is no regulator index, and "Eddy" is not an Eneco product at all. ⚠️ BACKWARD-INCOMPATIBLE. A config legal on the current version is refused on the next. That is the point of the change: §11.4 turned every failed fetch into a silent fall back, so a site on one of these ids has been running on static_profile - or on nothing - since the day it was set, with nothing on its status page ever differing from a working provider that never found a cheap hour. validate.py now refuses a retired id with a message naming the id, the endpoint, what that endpoint answers, which tariff has really been in force, and what to set instead. No version number is picked here; that depends on a fact about the live box. ⚠️ config.yaml's price_provider enum deliberately KEEPS all three ids. The Supervisor validates the options file against that enum before the add-on starts, so removing them would make an existing config schema-invalid and the refusal above would never be reached on exactly the sites that need it. PROVIDERS is two; the enum stays six; test_price.py asserts the schema offers exactly PROVIDERS | RETIRED_PROVIDERS | {"off"}, derived rather than typed, and register() refuses to re-register a retired id. PriceProduct.REALTIME, _WIDE, Uncertainty.REALTIME_PRODUCT and WIDEN_FACTOR's 2.5 are KEPT with no shipped declarer - it is the AC2 seam, and a reviewer finding a mechanism with zero callers would reasonably delete it. Recorded in DECISIONS/TARIFF-07-withdrawing-three-plugins.md and pinned by a check. False claims corrected: every "Belgian imbalance product" claim about eneco_eddy (DOCS.md, price_forecast.py, price_history.py, static_tariff.py, FCST-04, CHANGELOG - the two released entries annotated in place rather than rewritten); the quarter-hourly examples now cite Belgian day-ahead, which really does settle per quarter since the 15-minute MTU; and export_price_eur_kwh's "no shipped supplier publishes a dynamic injection curve", which is false - Eneco publishes 0,1 x BELPEX-H - 1,188 and ENGIE (B x eSpot_15) - A, same two-coefficient shape with its own coefficients and its own VAT treatment, so the scalar is a simplification and not the market. DOCS.md's provider table is two rows and now says honestly what a Belgian dynamic customer should do today, including that generic_dayahead cannot express B x spot + A and that price_markup_eur_kwh: float(0,1) could not hold a negative offset. The formula provider is referenced as planned, not built. _points_from_json() went with the plugins it served: it read a shape no feed anybody here has ever reached publishes. The one place it survives is a negative stimulus, named row_shape_body(). Nine mutations driven, all red; table in the decision record. 29/29 suites green. 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 |
||
|
|
f5ea39784d |
Merge release/1.0 (TARIFF-10) into TARIFF-12
checks / unit (pull_request) Successful in 7m16s
CHANGELOG.md was the only conflict: both entries belong under the same
`## Unreleased` heading, TARIFF-10's first because it merged first. No
version number picked.
app/main.py auto-merged, and was re-read because of it. TARIFF-10 changed
WHICH installs run `run_tariff_refresh()` (`arbitrage.enabled(opts)` ->
`tariff_refresh_wanted(opts)`); this card changed what that task does on
a failed fetch. Nothing in the keep/replace reasoning changed, and the
two newly reachable configurations are now DRIVEN in test_arbitrage.py
8b rather than reasoned about:
* a provider configured with trading DISARMED is the case the guard
was designed for, and needs no second fixture because nothing on
this path reads `arbitrage_enabled` at all - asserted over the AST's
symbols and non-docstring constants, after two drafts of that check
went red on a CORRECT tree for matching the word "arbitrage" in a
docstring and in a log line;
* `price_provider: off` + a `static_profile` resolves to the
CONFIGURED product (`origin: "static"`), so `standing.from_feed` is
false on every refresh and the guard never latches - driven twice in
a row, because a guard that latched there would freeze a static
site's plan at its first window for the life of the install.
Also: the kept-plan log line said "arbitrage stops with it", which after
TARIFF-10 is false on a site that fetches without trading; it now says
"arbitrage, where it is armed, stops with it". The keep decision is
strictly downstream of `resolve_tariff()`, so the §8.3 carry into
price_history - TARIFF-10's whole point - is unaffected either way.
All 29 suites green on the merged tree, including TARIFF-10's new
test_price.py section 13. All eleven mutations RE-DRIVEN on the merged
tree rather than carried over: eleven red, no survivors, every revert
fingerprinted. M11 is new - making the guard conditional on
`arbitrage.enabled(self.o)` (+86 B) is red on five named checks.
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
|
||
|
|
c81289527f |
TARIFF-12: a returned degradation is not a refresh
checks / unit (pull_request) Successful in 7m12s
`run_tariff_refresh()` promised in its own docstring that "a failure does not clear the plan", and enforced it with `except Exception` - a branch a supplier outage never reaches. `load_prices()` states the reason in its own words: it "RETURNS, NEVER RAISES, for every failure a site can actually have", because a degradation arriving as a traceback into the planning tick would stop the controller regulating over an expired certificate. So a dead feed RETURNS a §11.4 plan, the `try` succeeds, and the assignment below it overwrote the good plan unconditionally. With `static_profile` unset - the shipped default, and the normal state of a dynamic-tariff site - `_degrade()` returns `TariffPlan(no_data, None, "none")`: a complete 48-hour plan replaced by nothing, at the FIRST failed fetch, ~27 minutes after the last good one. `TARIFF_MAX_AGE_S` (5400 s) never governed anything and there was no serve-stale window at all. Fourth card in one family: FCST-04's M4 (`not ok` only), FCST-05's HIGH (`(JSONDecodeError, OSError)` only), FCST-06 (`not self.ok` only), and now `except Exception` only, for a path that never raises. The question is now asked once, after the attempt, from the evidence that exists - FCST-06's answer, one module over. The evidence is `TariffPlan.from_feed` (did this resolution observe the configured feed) and the shipped `fresh_tariff_plan()`, reused rather than transcribed. A degradation replaces a standing observation only once that observation has aged out, so `TARIFF_MAX_AGE_S` is now the serve-stale window it reads like. A §11.4 static fallback does not replace a fresh provider plan either, although it carries real prices: every point `_degrade()` builds is `provisional=True`, so `firm_points()` is empty, `arbitrage()` refuses and `cheap_now()` is False - everything a consumer may act on is already off - while a day-ahead price for a given hour does not change once published. The rule is deliberately not symmetrical: a standing plan that is itself a degradation IS replaced, or an outage would freeze the operator's static profile at the window it was first generated for. `tariff_at` does not move on a kept plan. It is the moment of the fetch that produced the plan standing, `fresh_tariff_plan()` judges the age from it, and an unmoved timestamp is the only thing that tells a kept plan from a freshly fetched one. test_arbitrage.py section 8b drives the whole chain with nothing stubbed but the socket: the transport raises the way aiohttp does, load_prices() returns no_data, _degrade() builds the §11.4 plan, and the shipped run_tariff_refresh() decides. A check that patched resolve_tariff to RAISE would have passed against the defect. Ten mutations, ten reds, no survivors; 29/29 suites green. Out of scope and unchanged: the persisted price store still does not back the plan during an outage (FCST-03), and the status banner and the Arbitrage row's lag are separately carded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
e6c45a61e7 |
TARIFF-10: configuring a price provider is what fetches prices
checks / unit (pull_request) Successful in 7m19s
amain() started the background tariff refresh only when arbitrage_enabled was
on, and that option ships false. run_tariff_refresh is the only caller of
resolve_tariff(), which is the only caller of price_history.carry() - so on a
shipped install with a price provider configured nothing was ever fetched: no
plan resolved, /data/price_history.json never created, the Price history status
row reading `empty` for the life of the install, and TARIFF-09's short-horizon
warning unable to fire because there was no fetch to warn about. Measured over
a 40 h two-instance soak on one feed: 0 requests and `present: false` across all
802 samples, against a resolved 132-point plan on the instance with arbitrage
armed.
git log -S puts the refresh task, its arbitrage.enabled() guard AND the DOCS.md
sentence saying a configured provider still fetches in the same commit - OPT-04,
bdd7f18; config.yaml has carried the claim since TARIFF-02,
|
||
|
|
17b7b447c4 |
TARIFF-09: ask the day-ahead feed for the horizon it was handed
checks / unit (pull_request) Successful in 7m10s
`generic_dayahead.fetch()` accepted `since` and `until` and requested the
bare `?bzn=BE`, which answers ONE Brussels day. Everything past today was
then filled from yesterday's profile by FCST-04, flagged provisional, and
§8.3 drops provisional hours from the trade set -- so the optimiser never
saw tomorrow as firm, at any hour of any day, and nothing reported it.
Measured on
|
||
|
|
c45893ffad |
FCST-06: "cannot persist" becomes one question, asked once, about the outcome
checks / unit (pull_request) Successful in 7m14s
price_history.py's docstring states its rule unconditionally - "A store that
cannot persist now records nothing, and both of its answers say the same
thing" - and FCST-04 enforced it against `not self.ok` only, which is one
SYMPTOM: the durable volume is missing. `ok` is about the DIRECTORY, and a
read-only remount and a full disk both leave it True. Reproduced on
|
||
|
|
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 |
||
|
|
37dd0f00cc | Merge pull request 'SAFETY-43: delinquency measured against the ask, not against a flat 500 W' (#69) from SAFETY-43 into release/1.0 | ||
|
|
a0d981731c | Merge pull request 'SAFETY-31 AC 4: the alarm's attribution was graded by nothing, and a mutation proved it' (#68) from SAFETY-31-attribution into release/1.0 | ||
|
|
b21e229adf |
SAFETY-43 round 2: the cut-out "positive control" was vacuous, and the record
checks / unit (pull_request) Successful in 6m29s
stated the wrong mechanism ⚠️ REVIEW FINDING, BLOCKING, AND IT IS THE EVIDENCE THAT WAS WRONG RATHER THAN THE BEHAVIOUR. Part 8b's cut-out arm claimed an "identical power trace" to the wedged arm and that "what separates them is `account()`". EVERY PART OF THAT WAS FALSE, and the check survived deleting the gate it named. Measured: cut-out arm, account() live named 0/200, distinct B shares: [0] cut-out arm, account() FORCED None named 0/200, distinct B shares: [0] wedged arm, account() live named 200/200, shares 77/125/133/154 W At its own cut-out the pack's usable energy ABOVE that cut-out is zero, so `weights()` answers 0.0, `_fill()`'s preferred pass gives it nothing, the healthy sibling covers the load alone so the last-resort pass never runs, and its share is 0 W ON ALL 200 CYCLES. A unit with a 0 W share is not in `asked`, so `len(asked) < 2` returns {} BEFORE EITHER ARM IS EVALUATED and `account()` is never consulted. Nothing was identical, "100 % of the ask" was 0/0, and the arm never reached the comparison it was written to grade. WHAT REPLACES IT IS A BETTER FACT, NOT A WEAKER ONE: at its own cut-out OPT-09 STOPS ASKING THE PACK AT ALL, so SAFETY-43's relative arm is never reached. That is a SECOND, INDEPENDENT protection sitting UPSTREAM of `account()` - the allocator declines to ask before the detector has to decline to alarm. So the arm is kept and re-pointed, not deleted: * the 0 W share is now ASSERTED (`_sat_last[1][Bee] == 0`), not inferred from the silence - a control that cannot say WHY it is quiet cannot fail; * the arm runs a SECOND time with `account()` DELETED (`no_account=True`) and must still be silent, which is what proves which mechanism does the work; * the account gate's claim is re-pointed at test_delivery.py part 1, where it is graded on a REAL positive 125 W share. ⚠️ AND IT WAS INVISIBLE FOR ONE REASON WORTH KEEPING: that was the ONLY line in part 8b that did not print its shares. `inverter-b+0` would have been on screen from the first run. Every line in the part prints them now. The derated control's vacuity guard also now asserts ITS OWN unit is asked, rather than re-asserting a different run's 200/200 - which is exactly why the old guard could not see this. ALSO, from the same review: N1 app/delivery.py said 0.05 was "twelve times more sensitive" than 0.85. 0.85/0.05 = 17 and no reading of the pair gives 12. An unsupported number in shipped source; replaced with what was actually measured. N2 M14's recorded count was 4 named checks in test_delivery.py and it is 5 - part 1's pre-existing "a shortfall AT the threshold is not one" also goes red. It under-claimed, so it never inflated the evidence, but a recorded figure that does not reproduce is still one. N3 test_delivery.py part 1 printed the alarm sentence with a bare `delinquent(...)["b"]`, which raises KeyError under M12/M13 and ABORTED THE FILE AT PART 1, leaving parts 2-8 ungraded. The direction is safe (an abort is a red; MUTATION.md's defect fabricates only greens) but the recorded coverage read smaller than the truth. It is a `.get` now, and M12/M13's counts are re-run: 7 and 7 named checks, not 5 and 6. CFG-07: the assessment is unchanged (worse by one control cycle, 35.0 -> 40.0 s, deliberately not fixed here) and now says WHEN it has to close - BEFORE INVERTER B IS COMMISSIONED (~2026-09-12), because the band is unreachable at N=1 and commissioning B is when it first becomes reachable - and that CFG-07 HAS NO DECISION DOCUMENT IN THIS TREE, so whoever takes it writes one first. No behaviour change. `delinquent()`'s two arms, the fraction, `min_w` and the N=1 path are all untouched by this commit. 27/27 suites green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
2271e147c6 |
SAFETY-43: delinquency measured against the ask, not against a flat 500 W
checks / unit (pull_request) Successful in 6m20s
`delivery.delinquent()` named a unit whose `short_by()` gap exceeded an
ABSOLUTE `min_w = Tuning.saturation_w = 500 W`. OPT-09's SoC-aware weights
shrink a low-headroom unit's share, and shrinking the share shrinks the gap, so
a unit standing at +125 W and delivering 0 W - 100 % delinquent - was never
named. Measured end to end on the real loop (test_packshare.py part 8b):
load 1500 W, B wedged at 15 %, equal weights named on 199/200 cycles
load 1500 W, B wedged at 15 %, OPT-09 weights named on 0/200 cycles
⚠️ The suppressed regime is the quiet one: at a load the healthy sibling covers,
the meter is satisfied, `unmet_w` is 0 and no action-log line fires, so this
alarm is the ONLY instrument that would have told the operator.
WHAT CHANGED, in one line:
named if short_by() > min_w OR short_by() > frac * share
⚠️ `min_w` WAS NOT LOWERED. It exists to stop normal saturation being reported
as delinquency, and lowering it trades a missed alarm for a false one on every
unit at once - SAFETY-41's four-attempt regime. This is a SECOND way to be
named, not a looser first one: all seven of test_delivery.py part 1's existing
fixtures answer identically under both rules, and the absolute arm's suppression
window is still exactly 1000 W / 6500 W, still asserted in part 8b.
THE FRACTION IS DERIVED, 0.85. admin/goodwe:sim/delivery_window.py gained a
`--fractions` axis and grades the constant over the SAME 2640 no-fault runs that
produced `delivery_hold_s`'s 35.0 s floor. Worst continuous legitimate episode,
worst of three families, twenty fractions:
frac 0.05 0.10 0.15 0.20 0.25 0.30 0.35 0.40 0.45 0.50
worst 90.0 90.0 70.0 70.0 60.0 55.0 55.0 60.0 45.0 45.0 s
frac 0.55 0.60 0.65 0.70 0.75 0.80 *0.85 0.90 0.95 0.99
worst 40.0 40.0 45.0 45.0 45.0 45.0 40.0 40.0 40.0 40.0 s
absolute arm alone: 35.0 s (SAFETY-42 §3.3, reproduced in the same runs)
⚠️ NO CLIFF ANYWHERE: 0.45-0.99 is flat within one control cycle, and it is not
monotone - §3.3's own mechanism (c). ⚠️ "Zero false namings" is NOT achievable
at any fraction and the record says so; what is zero is false ALARMS - 90.0 s
worst against a 185 s hold, so nothing comes within 95 s of notifying anybody.
N=1 control 0.0 s at every fraction in all 2640 one-inverter runs; flapping
0.0 s in all 840.
DRIVEN. Part 8b pinned the SUPPRESSED behaviour and said "if it ever goes red
because the alarm fired, read the card that made it fire". It now grades BOTH
rules on one run - `share_frac=None` is SAFETY-42's rule exactly:
OPT-09 weights, absolute arm alone named on 0/200
OPT-09 weights, the shipped rule named on 200/200
POSITIVE CONTROL. Same fixture, same weights, same 125 W share, one legitimate
fact changed: a pack at its own 10 % cut-out is named on 0/200 (account() still
gates it), and a unit delivering HALF its share is named on 0/200.
N=1 BYTE-IDENTICAL, asserted: `plan()` handed a `weights` mapping that raises on
any read still answers identically at a roster of one, and the same mapping
raises at a roster of two; `delinquent()` answers {} on all 84 single-unit
(share, measured, rule) combinations including 0 of 125 W.
MUTATION, run on fresh :ro trees: M12 (drop the relative arm), M13 (frac -> 1.0)
and M14 (frac -> 0.05) are all red on named checks. The constant cannot move in
either direction unseen.
⚠️ CFG-07 gets ONE CONTROL CYCLE WORSE and is deliberately not fixed here: the
worst legitimate episode goes 35.0 -> 40.0 s, so the schema's `int(30,900)`
false-tripping band widens from [30,35] to [30,40]. CFG-07 should be re-derived
against 40.0 s.
No option value, no schema bound and no default changed. 27/27 suites green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
f76639c7b2 |
TEST-21: two of the argmax's four axes are on an edge, and the doc did not say so
checks / unit (pull_request) Successful in 6m22s
§3.3 already set the standard - "a worst case sitting on an edge of the grid
would be a number about the grid" - and then did not check its own argmax
against it. Review did. Reproduced here independently before writing it down:
stagger 0 -> 1 -> 2 -> 3 -> 4 0.0 15.0 25.0 30.0 35.0 s
weights (A) at stagger 4 0.5 -> 20.0 0.7/0.9/0.99 -> 0.0
0.10 -> 35.0 s
Strictly increasing in stagger, maximal at the largest modelled stagger, and
maximal at the most asymmetric split. Interior only in `gain` and `slew_w`.
⚠️ SO THE HEADLINE IS 0.0 s AT stagger=0. The 35.0 s is ENTIRELY a product of
the modelled serialised-write stagger, which is declared MODELLED in the harness
and is a TERM of the figure rather than a detail of it. A site whose two writes
land in the same cycle does not produce this episode at all.
It does not move `delivery_hold_s`: 185 s must cover the worst the modelled site
can produce, and four cycles of stagger is inside what a real two-inverter write
path can do. What changes is that a reader asking "is 35.0 s about the plant or
about the grid?" now gets the answer instead of the invitation.
⚠️ THE PR BODY HAD CLAIMED THE ARGMAX WAS "interior to the grid, not on an edge
of an axis I had to choose". That was wrong, and it is the claim the doc's own
standard exists to test. The grid is the record's own, fixed by §3.3 before this
rebuild ran, which is the mitigating half and is stated too.
Also: `nl.yaml` wrote "35.0 s" beside "5,29 keer" and "75,0 s" in one sentence.
Dutch takes the comma.
Suite: 26 files, all pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
57775d4e06 |
TEST-21: delivery_hold_s's floor re-derived, and 30.0 s did not reproduce
No code and no option changes. What changes is a number this repository claimed
about a shipped safety constant.
`delivery_hold_s: 185` shipped resting on "30.0 s, the worst continuous
legitimate episode at the shipped threshold across all 2640 runs", plus a lag
sweep giving 45.0 s and 90.0 s - from a harness `DECISIONS/SAFETY-42` §1
deliberately did NOT commit, because it imports `plantsim` and `frame_check` out
of admin/goodwe. So the floor under a safety constant could not be re-run. That
is PROC-01's shape, with eleven instances in three days.
The harness is committed now, in admin/goodwe as `sim/delivery_window.py`, and
NOT here. `plantsim` and `frame_check` are that repo's; restating them in this
tree is CI-08's coupling, which has broken three times, and the alternative
fate - skipped for want of its imports - is a green that establishes nothing.
There the dependency runs one way: THIS repo's `control`, `arbiter` and the
shipped `delivery.delinquent()` are imported live out of whichever checkout is
under test. Its `--selftest` is now a step in that repo's `checks.yml`, so a
change here that moves `saturation_w`, `max_w`, `delivery_hold_s` or the shape
of `delinquent()` goes red there instead of leaving a stale number here.
cd goodwe/sim && GOODWE_ADDON_REPO=/path/to/goodwe-addon \
python3 -B delivery_window.py --selftest --campaign --blag
⚠️ THE HEADLINE DID NOT REPRODUCE: 35.0 s, not 30.0 s. Recorded as measured. The
harness was not adjusted until it agreed - the one correction made during the
rebuild was made AGAINST the doc's number (§1 records it) and made every figure
LARGER.
WHAT REPRODUCED - and it is the part the compatibility promise rests on:
* the N=1 control arm is 0.0 s at EVERY threshold in EVERY run. §3.2's algebra
measured, not argued.
* the flapping family is 0.0 s everywhere in all 840 runs - the family that
defeated SAFETY-30's site window still cannot touch this check.
* 45.0 s at twice A's lag, to the cycle.
WHAT DID NOT:
* the headline, by one control cycle (30.0 -> 35.0 s); the reversal family by
two (20.0 -> 30.0 s).
* the NON-MONOTONE curve. §3.3 finding 3 read 55.0 s at 250 W against 40.0 s
at 100 W as a property of the reduction. This rebuild's curve is monotone.
The mechanism is real and is still the reason not to read either table as a
sensitivity curve, but it is an ARGUMENT now, not a measurement, and it is
written down as weaker.
* the low-threshold rows, badly (40.0 -> 120.0 s at 100 W). Nothing ships
there, but that gap is the honest measure of how much of the original table
was a property of axes nobody wrote down. §3.3 fixes every axis COUNT and
RANGE and no individual value; the rebuild prints its choices above its own
table so the caveat travels with the number.
* the four-times-slower row moved the OTHER way: 75.0 s, not 90.0 s.
185 s IS NOT CHANGED. It is 5.29x the re-measured worst (the record claimed
6.2x), 2.47x the four-times-slower case, 6.2x the firmware's LINK_STALE_S veto
and 4.9x inside the billing quarter. All four bounds clear and none narrowly,
and the ordering of the argument is unchanged. Moving a shipped safety constant
because a margin went from 6.2x to 5.3x while every bound holds would re-open
SAFETY-41's four-attempt false-trip regime for nothing.
⚠️ ONE CONSEQUENCE AN OPERATOR CAN HIT. At 30.0 s the rig's floor coincided with
the firmware's veto and a reader could not tell which was binding. At 35.0 s the
rig's is strictly the binding one - and it is ABOVE the schema's own
`int(30,900)` minimum. `delivery_hold_s: 30` is therefore below a MEASURED
legitimate episode and would report on ordinary saturation. Both translations
now say so. Raising the schema floor is a change to a shipped option's contract
and is left to its own card rather than smuggled in here.
⚠️ THE B-LAG ROWS ARE RELABELLED THROUGHOUT. Inverter B does not physically
exist (~2026-09-12); `plantsim.INVERTER_B` is A COPIED, `measured=False`. Every
two-inverter figure in this record is two copies of A, and the lag sweep is a
SENSITIVITY STUDY ON A HYPOTHESIS ABOUT B, not a measurement of it. The old
wording - "MEASURED, not felt" - overstated exactly that, and "the RAMP does not
move it at all" was too strong: halving it is worth 0 s at A's lag and +5 s at
twice it.
Touched: DECISIONS/SAFETY-42 §1 and §3.3, app/delivery.py's provenance comment,
config.yaml's `delivery_hold_s` block, both translations, CHANGELOG Unreleased.
26/26 suites pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
9099ceb1af |
SAFETY-31: the grid is 42, not 90, and the wrong number was in the failure text
checks / unit (pull_request) Successful in 6m27s
Review reproduced every figure in both PRs except one, and rejected on it. ⚠️ "0 of 90 GAINS x SPLITS combinations produce opposing shares" - the SUBSTANCE is right and review re-measured it, but the DENOMINATOR is invented. sitesim.py declares GAINS with 7 entries and SPLITS with 6: the grid is 42. 90 is not even divisible by 7, so no reading of "GAINS x SPLITS" reaches it. It is almost certainly TEST-20`s RUN count (84 + 6), which is a different quantity that happened to be nearby. ⚠️ AND THE WORST COPY OF IT WAS INSIDE THE `PRECONDITION FAILED` TEXT, so the next engineer to see this scenario go red would have been handed an unreproducible measurement as their diagnosis - the exact defect class this campaign exists to correct, in the failure path where it does the most harm. Corrected in all seven places. At the failure-text site the denominator is now `len(GAINS) * len(SPLITS)`, computed rather than typed, so it cannot drift from the grid again - the same fix test_translations.py already took. Also N3: a missing blank line was going to render the S3 and M19 paragraphs as one in DECISIONS. Re-run after the change: `inject.py two_master_circulation` PASS in 35 s, norig, no rig lock taken. Add-on suite 26 files, all pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
295a2aedb6 |
SAFETY-31 AC 4: the alarm's attribution was graded by nothing, and a mutation proved it
checks / unit (pull_request) Successful in 6m27s
`pos, neg = opposing(...)` -> `([], [])` in main.py's `check_circulating()`
(-102 B) deletes the "Right now X is discharging while Y charges" clause from
the operator notification, and SURVIVED ALL 26 FILES. The commit that shipped
this card claimed "18 mutations, all RED"; the ledger has no entry for the
attribution, and this is that entry.
It is not dead code. Ledger M18 - the whole `await check_circulating()` call
removed - is red on four checks, so the function runs. The three checks on the
notification asserted METER CANNOT SEE, CONTROL HAS BEEN STOPPED and `isolator`,
and never an inverter name. `circulation.py`'s own docstring calls that sentence
the first thing somebody woken by this alarm needs and "impossible to
reconstruct from the number alone".
test_watchdog.py section 6a closes it on a fixture that can tell three failures
apart:
* the roster is ["garage", "shed", "cellar"] - operator names, none of them
`UNIT_A`, for test_maintenance.py section 11's reason: on a fixture whose
names are the module's own constants a check cannot tell the roster from
the constant;
* the site is run TWICE, MIRRORED, so the sentence is not symmetric and a
pos/neg swap is a different red from a deletion;
* a third pack rests at 5 W, so the floor that stops a pack in standby being
named as a participant in a 6 kW loop is graded too.
Mutations, on a tree with no __pycache__ mounted :ro into the checks image:
M20 pos, neg = opposing(...) -> ([], []) -102 B RED (2 checks)
M21 pos and neg exchanged +0 B RED (3 checks)
M22 opposing(..., min_w=cfg.min_w/2.0) -> 0.0 -25 B RED (3 checks)
26/26 files green with the block in place.
AND A DISCLOSURE, WHICH IS THE UNCOMFORTABLE HALF. DECISIONS section 3 records
that the 924-run campaign behind `circulation_w: 700` and `circulation_hold_s:
75` used a harness that is not committed in either repository. Two further
sweeps have now been run and no two of the three agree: peaks of 1700 W, 2750 W
and 1048 W, and worst-continuous figures above 700 W of 5.0 s, 15.0 s and 5.0 s.
That is NOT evidence of fabrication and is not reported as any. The committed
sweep measures why the spread is that wide: on one grid, changing only the
plant model and the write-stagger set, the worst legitimate peak is 1048 W, or
2250 W, or 0 W. The plant and the stagger are TERMS of the number. What the
spread does establish is that the claim cannot be adjudicated while the
instrument is missing.
The window argument survives and is now committed - all three sweeps trip zero
times, on 924 + 48 + 840 non-faulty runs. The threshold CURVE does not survive
as stated: `505.0 s at 100 W`, which is what the "knee at 400 W" argument rests
on, is 10.0 s on the committed grid. The curve still falls with the threshold,
so the knee is real in direction; its depth is established by nothing anybody
can run, and this commit says so rather than repeating it. The overclaiming
comment at test_watchdog.py's 1700 W fixture ("the worst single transient the
rig can produce") is replaced with what the fixture can support.
The committed, re-runnable half is admin/goodwe's `two_master_circulation`,
opened as a separate PR against that repository.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
21f22b393a |
UX-04: nothing was hidden, the defaults pin was never built, and CFG-04 was closed by UX-02
checks / unit (pull_request) Successful in 6m20s
UX-01's three unmet acceptance criteria, closed out. It shipped in 0.7.0 and is LIVE, so all three describe the running install. No behaviour, no schema and no default moves here: config.yaml, translations/ and every module under app/ are byte-identical to |
||
|
|
cab267c4f3 |
OPT-09: the 6500 W window is a bound the ratings never let the site reach
checks / unit (pull_request) Successful in 6m10s
Round-2 non-blocking findings, both fixed. ⚠️ THE WINDOW FIGURE WAS ARITHMETIC NOBODY HAD RUN. `min_w * (w_A + w_B) / w_B` = 6500 W is correct about `_fill()`'s PROPORTIONAL regime and is not a reachable steady state: the healthy pack's own 5000 W rating binds first. SWEPT on part 8b's own fixture, reproduced rather than transcribed: load equal OPT-09 site target A B 1000 199/200 0/200 1080 997 83 2000 199/200 0/200 2170 2003 167 3000 199/200 0/200 3250 3000 250 4000 199/200 4/200 4340 4006 334 4500 199/200 8/200 4890 4514 376 4900 199/200 41/200 5320 4911 409 <- largest suppressed 5000 199/200 195/200 8000 5000 3000 <- A at its rating 6000 199/200 195/200 8000 5000 3000 So the alarm returns BELOW the formula bound, not above it, and the largest site target reached with the wedged unit still under the 500 W threshold is 5320 W - not 6500. The equal-weights "suppressed below 1000 W" bound is unreachable for the same reason: the equal arm names the wedged unit 199/200 at EVERY load swept, 1000 W included. The formula and its check are kept, because the mechanism they state is right and it is the mechanism that generalises to other ratings. What was wrong was the sentence around them, in four places, implying the site passes through the bound. Each now carries the sweep and says the rating binds first. ⚠️ THIS NUMBER CAME FROM THE REVIEW, NOT FROM THE BRANCH, and was adopted in good faith by the implementer. The reviewer found it in round 2 and said so. That is worth recording: a figure arriving from a trusted reviewer got less scrutiny than one written by the author, and PROC-01's eight fabricated numbers in two days is now nine. The rule that catches it is the same either way - run it before you ship it. Also: the DECISIONS header's base sha, stale for the third time ( |
||
|
|
8d83ef0dfb |
OPT-09: the weights DO move on a wedged unit, and that narrows SAFETY-42
checks / unit (pull_request) Successful in 6m11s
B1, the blocking review finding. §5 of the decision said:
"A wedged unit reads a healthy SoC, so no envelope closes and no weight
moves - OPT-09 cannot see it at all."
The first clause is true and is about the ENVELOPE. The second clause is
FALSE, and it was load-bearing for the claim that OPT-09 and SAFETY-42 meet
without overlapping. A weight is a function of a pack's ENERGY HEADROOM, not
of what the pack delivers, so a wedged unit's weight moves whenever its
headroom differs from its sibling's - the ordinary case this card exists to
exploit. And delivery.delinquent() (app/delivery.py:484, threshold at :531)
names a unit only while its short_by() gap exceeds min_w = Tuning.saturation_w
= 500 W in ABSOLUTE WATTS. Shrinking the share shrinks the gap below the
threshold, and the alarm stops firing on a unit still delivering nothing.
So the seam, stated accurately: OPT-09 stops the ask AND narrows SAFETY-42's
detection window for a low-headroom unit. That is a DETECTION REGRESSION at
N=2. It is disclosed here, not fixed: making delinquent() relative to the ask
rather than absolute is SAFETY-42's code and a design change, filed as its own
card.
1. THE CORRECTION, in the three places that carried the claim
* DECISIONS/OPT-09-soc-aware-weights.md §5 - rewritten. States the seam
accurately, quotes the sentence it is replacing, carries the measured
rows and the window arithmetic, and names the residual in the same voice
as SAFETY-13's "2 of 272" and SAFETY-42's "the discriminator does not
exist". Also drops "they meet and do not overlap" from the opening.
* app/packshare.py - a new section in the module docstring, immediately
after "weights are a comfort choice for stability and not for delivery",
which is where the over-read starts.
* CHANGELOG.md - a new paragraph in the SAFETY-42 entry, ending on the
operator-facing sentence: a wedged inverter with less energy headroom
than its sibling can now go unreported at ordinary house loads.
test_packshare.py part 8's closing check and its "what this file cannot
see" list repeated the same false clause; both now say the ENVELOPE cannot
reach a wedged unit and point at part 8b for what the WEIGHTS do to it.
2. THE GRADED CHECK - test_packshare.py part 8b, 6 named checks
The reviewer's probe, reproduced: real compute(), real plan(), real
packshare.weights(), real delivery.account(), real delivery.delinquent();
the only model is the plant (A obeys within one cycle inside its rating,
B accepts its share and delivers 0 W). Unit B is WEDGED at 15 % against
its own 10 % floor, so envelope() does not close and account() explains
nothing - a SAFETY-42 true positive by that card's own definition, which
a PRECONDITION check asserts before anything is concluded from it.
load 1500 W, B wedged at 15 %, equal weights named on 199/200 cycles,
site target +3000 W, shares a+1500, b+1500, grid +0 W, named=[b]
load 1500 W, B wedged at 15 %, OPT-09 weights named on 0/200 cycles,
site target +1630 W, shares a+1505, b+125, grid -5 W, named=(nobody)
load 6000 W, B wedged at 15 %, equal weights named on 199/200 cycles
load 6000 W, B wedged at 15 %, OPT-09 weights named on 195/200 cycles
suppression window: equal weights, site target < 1000 W; OPT-09
weights, site target < 6500 W of a 9600 W site
THE CHECKS ASSERT THE CURRENT, UNFLATTERING BEHAVIOUR ON PURPOSE - that
the weighted arm names NOBODY where the equal arm names the wedged unit.
A check asserting the alarm still fires would fail, and papering over it
would be worse than the original defect. The 6000 W row is there so the
part is not pinning "the weighted arm never names anybody": past the
healthy pack's own rating, _fill()'s last-resort pass hands the wedged
unit 3000 W and both arms name it. The window is real and it has an edge.
MUTATION - packshare.weights() keeps its shape and forces every weight to
1.0 (M11 in part 11's table, which now has 11 rows). RUN on a purged tree
with python3 -B, not reasoned about:
load 1500 W, B wedged at 15 %, equal weights named on 199/200 cycles,
site target +3000 W, shares a+1500, b+1500, grid +0 W, named=[b]
load 1500 W, B wedged at 15 %, OPT-09 weights named on 199/200 cycles,
site target +3000 W, shares a+1500, b+1500, grid +0 W, named=[b]
load 6000 W, B wedged at 15 %, OPT-09 weights named on 199/200 cycles,
site target +4000 W, shares a+2000, b+2000, grid +4000 W, named=[b]
suppression window: equal weights < 1000 W; OPT-09 weights < 1000 W
FAIL AND WITH OPT-09's WEIGHTS IT IS NAMED ON NONE OF THE 200 [...]
FAIL ...and the arithmetic behind it, stated as a window [...]
FAIL AND IT IS NOT THAT THE WEIGHTED ARM NEVER NAMES ANYBODY [...]
test_packshare.py exits 1. Restored: exits 0.
3. THREE PROC-01 PROSE DEFECTS
* DECISIONS/OPT-09-soc-aware-weights.md:3 said "Base: release/1.0 @
464d398". The branch was rebased; git merge-base HEAD
|
||
|
|
f8e501393f |
OPT-09: the weights parameter nothing ever passed, and what it cannot do
`allocator.plan()` has taken a `weights` argument since ALLOC-01 and NOTHING
had ever passed one - main.py called `resolve()` without it and the arbiter
defaulted it to 1.0 per unit - so the site target was split 50/50 regardless
of what either pack could do. Two things now feed it, and they are different
kinds of thing:
* a `pack` UNIT claim at P_SAFETY that CLOSES a unit's envelope at its own
`bms_soc_floor` or at 100 % - a PERMISSION, obeyed absolutely, so an empty
pack is not asked to discharge and its share goes to the sibling;
* SoC-aware weights by ENERGY headroom in kWh - a PREFERENCE, which
`_fill()`'s last-resort pass will override the moment the sibling saturates.
Measured (test_packshare.py part 8): at a load one pack cannot cover, the
weighted arm and the shipped arm command the IDENTICAL watts into an empty pack
and deliver the IDENTICAL watts. Only the envelope stops the ask, and what it
buys is not delivery - it is a reported `unmet_w` where the record used to read
impeccable. Weights are a comfort choice for STABILITY and NOT for DELIVERY.
Also: an optional per-entry `capacity_kwh` (schema change, all-or-nothing across
the roster, rule 9 refuses a partial one); per-pack arbitrage sizing, with the
sell side's no-op stated rather than hidden; and a 0 W trade refused by name,
because a 0 W arbitrage claim is a park order that outranks the loop.
SAFETY-42 and this card meet at one hazard from opposite sides - OPT-09 stops
the site ASKING a pack whose SoC says it cannot deliver, SAFETY-42 alarms when a
pack that said it could does not - and its end-to-end fixture moved as a result.
Its finding is unchanged and is now stated on the two quantities its rejected
discriminator actually reads.
Not filed at all at a roster of one: at N=1 every source routes to the site, so
the interval would bound the SITE TARGET, and SAFETY-25's decided Option A
stands untouched on the live install.
⚠️ INVERTER B DOES NOT EXIST. Its 4600 W / 20.48 kWh / 10 % are FROM DATASHEET
AND SERIAL, and `sim/plantsim.py`'s INVERTER_B is inverter A copied
(`measured=False`), so every two-inverter figure here is arithmetic or two
copies of A. No hardware was touched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
fdaf3fb54f |
UX-02: the validator's refusals, next to the field, before the save
checks / unit (pull_request) Successful in 6m17s
The Configuration tab can only render types and ranges. `validate.py`'s most
useful refusals are CROSS-FIELD - two inverters naming one `setpoint_entity`, a
`maintenance_charge_w` above `max_w`, a flat `setpoint_entity` left set beside a
declared roster - and the Supervisor's UI has no way to express a rule spanning
two keys. Today the operator meets those as exit 78 in a log, after saving and
restarting. This puts them on the screen beside the field they name, before
anything is written.
WHAT IT ADDS
app/configui.py the field model, built from `GET /addons/self/info`'s own
`schema` + `translations`. No option key, no label, no
rule of its own. UX-01's `Group · ` label prefix becomes a
real section heading, in whichever language resolved.
app/selfconfig.py the Supervisor conversation and the write gate.
app/config_page.py the page. Its longest function decides which HTML input a
type deserves; it contains no rule.
app/web.py five routes. Still the only file a reviewer has to read to
answer "does anything here reach the inverter".
THE FOUR THINGS THAT COULD HURT SOMEBODY, AND HOW EACH IS HELD
1. A blob the SHIPPED validator refuses is never written - not warned about,
not written. An invalid blob plus a restart is exit 78, and the panel is
gone with the add-on that serves it.
2. A blob that moved underneath is refused, not clobbered. Read hands out a
sha256 of the canonical blob, save hands it back, and the stored blob is
re-read IMMEDIATELY BEFORE the write. This is SAFETY-28's discipline
applied to configuration - "a blob reaches the Supervisor only if it was
derived from the blob stored right now" - with the honest weakening stated
in the decision record: a second writer is DETECTED, not made
inexpressible.
3. Every message is the validator's own, byte for byte. test_selfconfig.py
part 6 inserts ONE new rule into a COPY of validate.py, hashes every file
under app/ to show validate.py is the only one that differs, and serves the
real /config/validate route out of that copy in a subprocess. The new
sentence arrives, filed under the key it names.
4. Nothing on the surface commands an inverter or clears a latch. Saving does
not restart: applying costs a control interruption - the wind-down commands
0 W on every inverter - so that is a separate, confirmed button that says
so first.
MEASURED, NOT CITED (tools/supervisor_selfapi_probe.py, run against
Supervisor 2026.08.0's own code; it contacts nothing)
- the three self-paths are permitted, and `/addons/self/info` is admitted by
the `default` role too while `options` and `restart` rest on the bypass
ALONE. The probe's first draft claimed the role admitted none of the three
and went red.
- `/addons/self/options/validate` is NOT reachable from inside an add-on, so
the only validator this panel can call is ours - which is the one with the
cross-field rules.
- a POST that omits a key does not preserve it: the key reverts to
config.yaml's default. Hence whole-blob read-modify-write.
- the Configuration tab CAN be removed, and doing so drops 0-of-71 options
through and takes the add-on down. The card said it could not be; it stays
by decision, which is a better reason.
DOES NOT TOUCH config.yaml, main.py or any control path. UX-01 is a dependency,
not something this supersedes: its translations are where every label,
description and section heading on this page comes from, and the Configuration
tab is still the recovery surface. DECISIONS/UX-02-two-config-surfaces.md
records which surface is authoritative (neither: the stored blob is) and what
the second surface costs.
Also corrects a stale "70 options" in three prose spots to the 71 the probe
counts - config.yaml grew a key in SAFETY-42 and the sentences did not follow.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
---
⚠️ REBASED ONTO release/1.0 @
|
||
|
|
090a8ecc35 |
SAFETY-42 round 2: pin the two thresholds at a config that is not the shipped one
checks / unit (pull_request) Successful in 6m7s
THE BLOCKER. At the `delinquent()` call site, `self.tuning.saturation_w` -> `250.0` (-19 B) was a survivor of all 23 suites. It is not an equivalent mutant: two copies of inverter A, B wedged, `saturation_w: 1800` (inside its own schema int(100,2000)) and a load of 1600 W put B's shortfall strictly between the literal and the configured value - head names nobody, the mutant names B. At the shipped 500 both name it, which is exactly why every fixture in part 4 was blind: their gaps are all above both thresholds. It matters beyond a mutation score. `delivery.py`'s MIN_W_IS_SATURATION_W note and the `delivery_hold_s` block in config.yaml both promise an operator, in shipped prose, that there is no fourth copy of a threshold to keep in step and no way for the two to drift - raise `saturation_w` and this check moves with you. A literal at the call site removes exactly that promise, silently, and the promise is why the design was chosen over a `delivery_w` key. ASKING THE SAME QUESTION OF EVERY OTHER NUMBER THIS CHECK READS FOUND A SECOND SURVIVOR OF ALL 23 SUITES: `bms_floor=inv.bms_soc_floor` -> `10.0` (-13 B), invisible because config.yaml's own example states 10 % and every fixture in this file used it. Two arms bracket it: a pack at 15 % against its own 25 % cut-out is cold and must stay unnamed (the literal names an empty pack), and a pack at 8 % against its own 5 % cut-out is wedged and must be named (the literal explains the fault away). One arm on each side leaves no constant at all. `delivery_hold_s` and `is_live()`'s window were ALREADY pinned - the fixtures had to move them to run - and `LINK_STALE_S` has no option behind it, so "pinned across a changed config" is not available for it and is not claimed. DECISIONS section 9 enumerates all of it with the deltas and which check kills which. Verified in the CI image, tree mounted :ro, `python3 -B`, `__pycache__` purged and counted before every run (TEST-15): R5 saturation_w -> 250.0 -19 B killed by part 4b(a) R6 bms_soc_floor -> 10.0 -13 B killed by part 4b(b), both arms R7 arm(self.delivery_cfg) -> DeliveryConfig() -1 B killed (already) R8 is_live(now) -> is_live(now, 3600.0) +8 B killed (already) R9 pack cut-out -> conjoined site floor +105 B killed twice head: 23/23 test files PASS, no bytecode written during the run. No shipped behaviour changes: the only edits to app/ are comments. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
c7eb8c6b97 |
SAFETY-42: close two mutation gaps, and correct what makes N=1 inert
The battery is 12 mutations, one freshly copied tree each, purged, `:ro`,
`python3 -B`, byte deltas measured. First round: 9 red, 3 survivors. All three
are now accounted for and the ledger is in DECISIONS §8.
M5 - `share <= 0` relaxed to `share == 0`, +0 B - WAS A REAL GAP AND THE TWO
OBVIOUS CHECKS DID NOT CLOSE IT. `short_by(-2000, 0) is None` and
`short_by(-2000, -100) is None` both stayed GREEN under the mutant: for a charge
share that is UNDER-delivered `share - measured` is negative and the `gap > 0`
test answers None anyway, so neither input can separate the two spellings. The
one that can is a unit moving MORE than its charge share - -2000 W told,
-3000 W measured, `share - measured` = +1000 W - which the mutant reports as a
shortfall. It is not one; it is something else driving that pack, which is
SAFETY-30's and SAFETY-31's question. That input is now the check, and M5 is RED.
MUTATION.md's third failure mode in miniature: the assertion ran, the sentence
was true, and the data could not reach the failure it named.
M11 - `inv.is_live(now)[0]` replaced by a literal `True`, -15 B - was a WIRING
gap the pure checks structurally could not see. `account(live=False, ...)` was
graded as a FUNCTION; nothing graded that `cycle()` asks the question, because no
fixture had a unit whose writes were being rejected. Such a unit still has its
last ACCEPTED command standing on the wire (`write_setpoint` leaves `last_write`
alone on a rejected write) and still measures nothing, so it looks exactly like a
wedged one to every number this check reads - and reporting it would be the
second, louder alarm about a state the allocator has already handled and logged.
`test_delivery.py` part 6 now builds that unit, with `rejected_since` back-dated
past LINK_STALE_S because the fixture runs in two seconds and the timer is
test_inverter.py's to grade. M11 is RED.
M3 - `len(asked) < 2` relaxed to `< 1`, +0 B - IS STILL A SURVIVOR, and it is an
EQUIVALENT MUTANT. With one asked unit `unaccounted` is a subset of a one-element
set, so it is either empty (`not unaccounted` returns {}) or equal to `asked`
(`len(unaccounted) == len(asked)` returns {}); with zero asked units both
spellings return {} as well. No input separates them, so no check can.
⚠️ AND FINDING IT CORRECTED THE MODULE'S OWN PROSE, WHICH IS THE MORE USEFUL
HALF. `delinquent()`'s docstring claimed `len(asked) < 2` was what makes N=1
inert. It is not - the PROPER-SUBSET rule is, and that line is a fast path and a
signpost. A false attribution of the mechanism in the file's own prose is exactly
what this repository keeps finding, and a mutation that survives is how it
surfaced. The line stays, with the equivalence written down beside it the way
circulation.circulating_w() records its own max(0.0, ...).
Also recorded, rather than left implicit: `account()`'s `parked` branch is graded
as a function but its WIRING is not mutated on its own; part 6's reversed-unit
fixture exercises it end to end, which is weaker, and the ledger says so.
Baseline 23/23 green before and after. app/main.py is byte-identical to the
previous commit - both closures are a test and a comment.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
706e625d99 |
SAFETY-42: a unit that stops delivering, and the discriminator the card asked for does not exist
A unit standing at a non-zero share and producing nothing is seen by NOTHING. SAFETY-30's per-unit sign gate needs OPPOSITION on both sides of 250 W and a unit at 0 W is not opposing, it is ABSENT. The site sum still agrees with the meter in direction. Circulating power is exactly zero when one unit does nothing. The only trace left is `reason == "saturated-freeze"`, which clamp.py:93-96 documents as the alarm an operator learns to ignore. It is HW-01's shape, which ran 6 h 25 m with all three layers spectators. ⚠️ THE CARD'S OWN DISCRIMINATOR WAS BUILT FIRST AND FALSIFIED, AND THAT IS THE MAIN CONTENT. SAFETY-42 asks for "saturation persisting past N while |grid error| > X, both derived". Run on the two-master rig it fails in BOTH directions, and no (N, X) fixes either: FALSE POSITIVE `plantsim.Plant.limit()` returns 0 W for a discharge command into a pack at its own floor, and a wedged unit produces 0 W too. Load 2000 W, equal weights, 2000 W per-unit cap, 120 cycles: THE TWO ARMS ARE IDENTICAL ON ALL 120 in grid, site target, every share and every measured watt. Both saturated-freeze on 116 of them, both hold |grid error| over 300 W on all 120 and settle at 1200 W, both read circulating 0.0 W. The predicate's inputs do not differ, so no threshold on them can differ. And "a cold pack at its floor must not alarm" is the card's own AC 2. FALSE NEGATIVE the standing grid error is what a HEALTHY SIBLING ERASES. Load 800 W, same config, B wedged: over the last 30 of 200 cycles the loop is frozen on 30 of 30 while the worst |grid error| is 30 W, with B standing at +770 W and delivering 0 W. Any X above 30 W is silent for the whole run on the exact fault the card describes. WHAT REPLACES IT. Per unit, for a discharge share, `share - measured`; readable because of a reduction that only exists at N>=2: a unit is named while it is short by more than `saturation_w` with NO ACCOUNT for it, and the units in that state are a PROPER SUBSET of the units asked to discharge. THE ACCOUNT keeps the cold pack out and is the only thing that can: the two cases are identical in the power domain, so the discriminator has to be a non-power fact, and there is exactly one - the pack's own SoC against its own `bms_soc_floor`, asked through `peakguard.discharge_permitted()` rather than transcribed. ⚠️ `bms_soc_floor` ALONE, not `pack_floor_pct()`'s conjunction with `peak_soc_floor`: that floor bounds a money-saving discharge only and using it would explain away a wedged unit anywhere between the two. THE PROPER SUBSET keeps a whole-site cause out - a derate this add-on cannot see hits every unit; one box that stopped listening does not - and it is what makes this INERT AT N=1, by algebra rather than by a roster guard. IT REPORTS. IT DOES NOT ACT, and the strongest argument is for acting. Dropping the wedged unit from `live` genuinely recovers a site the shipped path does not: at load 1500 W, kept in the split the site settles at 800 W with a 700 W error and NEVER comes inside 100 W in 150 cycles (the freeze pins the target, so the sibling's SHARE never grows); dropped, it delivers 1500 W from cycle 14. What refuses it is that the only re-allocation this add-on can express is all-or-nothing: a unit derated to 50 % is short by more than saturation_w with nothing to explain it, so this detector names it too, and at load 3000 W dropping it leaves the site delivering 2000 W against 2400 W kept - 400 W WORSE. A partial re-allocation is what would make acting safe and the allocator cannot express it. That is a card. control.py is NOT MODIFIED. The freeze shapes the loop; this reports on it. NUMBERS, MEASURED. The watts side is `Tuning.saturation_w`, read at runtime, so there is no fourth CFG-03 copy and it cannot drift from the number the control law itself calls "not following". `delivery_hold_s: 185` comes off 2640 closed-loop two-inverter runs in which nothing is faulty (6 gains x 5 slews over the whole schema range, across load reversals, a flapping load and ASYMMETRIC SPLITS - a family SAFETY-30's campaign did not have and the one that dominates here) plus 1890 runs at a roster of ONE as the control: threshold reversals flapping weighted N=1 control * 500 W 20.0 s 0.0 s 30.0 s 0.0 s The N=1 column is 0.0 s at EVERY threshold in all 1890 runs, and the flapping column - the family that defeated SAFETY-30's site window - is zero too, because a common stimulus makes both units short at once and the subset stops being proper. ⚠️ The curve is NOT monotone (55.0 s at 250 W against 40.0 s at 100 W): lowering the threshold can turn a proper subset into the whole roster and BREAK the run. ⚠️ THE MARGIN IS MEASURED, NOT FELT, BECAUSE INVERTER B DOES NOT PHYSICALLY EXIST. `plantsim.INVERTER_B` is A COPIED, `measured=False`, so every two-inverter figure above is two copies of A. Sweeping a different second unit: the worst legitimate episode is insensitive to B's RAMP (30.0 s at half A's) and scales with its LAG - 45.0 s at twice A's measured 10 s, 90.0 s at four times it. 185 s is 6.2x the measured worst, 2.06x the four-times-slower case, 6.2x LINK_STALE_S and 4.9x inside the 900 s quarter. Deliberately not 180, which is SAFETY-02's site hold. SAFETY-34. `unit/<name>/delivery_tripped` in the store, one notification id per unit, re-raised on the first cycle of every process, cleared by the Start press. No flat mirror: a rollback lands on a version that drives one actuator, where this check is inert. ⚠️ SCHEMA CHANGE - `delivery_hold_s` joins options: and schema:, so this needs a version bump before it ships. Additive and defaulted, so an existing options blob stays valid. STATED GAPS: charge shares are not graded (no configured charge ceiling exists, so a constant-voltage taper cannot be explained and arming it would fire on every maintenance charge); every unit wedged at once is not caught (the subset is not proper and no instrument here separates that from a plant that cannot). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
f156fadac0 |
SAFETY-13: the bound came from the wrong half of a two-sided measurement
checks / unit (pull_request) Successful in 5m34s
NOTES.md 5.10 measured the 1.78 kW step BOTH ways - "Up: ~18 s from step to deadband. Down: ~23 s" (NOTES.md:1550). SAFETY-39 takes 18 correctly: its bound is a REFUSAL and the looser number refuses less. This branch reused PLANT_SETTLE_S for the opposite job - how long the plant may still be the EXPLANATION for a divergence - where the conservative half is the longer one. ceil(23/2) = 12, not 9, and being short there does not refuse anything, it accuses. The false positive is real and is measured here. One inverter, one compute(), one plant, no second writer anywhere in the harness: the command falls through zero while the pack is still coasting the old way, unasked_power() calls our own power "unasked", the counter reaches 9 while it is still coasting, and the law emits "freeze-released". At the plant's own measured 18 s/23 s shape, over 272 load-step pairs, the 9-cycle bound fires in 62 and the 12-cycle bound in 2. PLANT_SETTLE_DOWN_S = 23.0 is added beside PLANT_SETTLE_S = 18.0, because the plant's settling time is a PAIR and each bound has to say which half it means and why. SETTLE_CYCLES = ceil(PLANT_SETTLE_DOWN_S / LOOP_TICK_S) = 12. It is no longer the reciprocal of SLEW_RAIL_FRAC; that was a coincidence of both reading the same half, and test_control.py now asserts the inequality. _SAT_COUNT_CAP moves with it, 10 -> 13. config.yaml accepts saturation_cycles as int(1,10), so the whole accepted range is now strictly BELOW the bound and the degenerate configuration this branch used to document no longer exists - pinned against config.yaml's own schema line. Three shipped statements were falsified by that measurement and are rewritten, not renumbered - at 12 the residual is smaller, not zero: unasked_power()'s docstring said anything outside the interval "was put there by something other than this loop". It was our own power: eight cycles after a +1990 W command the command is 0 and the pack still reads +522.3 W. control.py said "SO THE BOUND BELONGS TO (b) AND ONLY TO (b)". It fired in case (a). DOCS.md told an operator "if you see freeze-released, look for the second writer" - on a single-inverter install, hunting something that does not exist. Replaced by a section that gives the innocent causes first (a large load or PV swing; a plant slower than the one measured, 8/320 scenarios at 23 s rising to 268/320 at 50 s) and the second writer only when the state STANDS. Round 1's own results are kept and re-graded, not weakened. The release/1.0 sweep had to widen to reach the new bound (3780 cases): 3267 bit-identical, 432 releases, 27 command changes - all still charge starts from prev_w == 0 - and 81 differing in the saturation counter alone, which nothing downstream can read. The card's case releases on the twelfth saturated cycle instead of the ninth and settles at the same -1190 W against the same frozen integrator. Also corrected, because they were claims and did not reproduce: the coverage table's "output freeze" row was 11, and the mutation form its own note describes gives 6 on this tree, 6 on the first SAFETY-13 commit and 2 on release/1.0; the detector row was left at 11 and is 15; the frozen = False footnote was left at 10 and is 13. And section 6 reported 107 625 as "released states" when it is the number of states SWEPT - 67 500 of them release, and both are counted now. Nothing here reached the inverter. 22/22 test files pass in goodwe-addon-ci, tree rsynced to a plain path, mounted :ro, python3 -B, __pycache__ purged and printed first (0 dirs / 0 .pyc; the worktree carries none). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
6c4565659b |
SAFETY-13: the output freeze gets an exit, and only where it needs one
checks / unit (pull_request) Successful in 5m30s
While the loop is frozen the command's magnitude may fall but never rise. At prev_w == 0 there is no magnitude left to fall, so a charge cannot START. That refusal is deliberate and it is kept: the inverter reporting power we did not command means another writer is on the actuator, which is the §1.5 non-goal. What it did not have was an END - nothing cleared it but the other writer leaving. Reproduced on release/1.0 @ |
||
|
|
8e241a3243 |
SAFETY-37: correct a wall-clock tick and a false size claim
checks / unit (pull_request) Successful in 5m32s
Two blocking findings from review, both in prose, both corrected at every site. 1. "trips on tick 7" was not what the fixture prints. It prints 11, five runs out of five, and the developer's own report says 11. ⚠️ But the deeper problem is that the number should never have been quoted: the tick is counted on the WALL CLOCK inside cycle() (hold_s 2 s, ticks 0.20 s apart), so it is load-dependent - 7 is what a box slow enough to stretch each tick to ~0.29 s would print - and nothing pins it. The DECISIONS transcript now says 11 because it is a transcript of a run; the CHANGELOG stops quoting a tick at all and states the checkable thing instead: it trips without the gate and never with it, which part 3 grades. 2. ⚠️ SURVIVOR 2's SIZE CLAIM WAS FALSE FOR ONE OF THE TWO CONSUMERS THIS CARD EXISTS TO PROTECT. It read "one contributing at most 250 W to a loop whose reporting floor is 700 W" at three sites. That bounds the COMMAND. Once the register is frozen its REPORTED value is unrelated to the command - which is exactly HW-01's shape: commanded ~0 W, register stuck on the last large number. Measured in review: a unit frozen at -1200 W beside a healthy +900 W sibling yields circulating_w = 1800 W against the 700 W floor, 2.6x OVER, with no gate armed. So the scoping is TRUE for the per-unit sign check and FALSE for circulation. Corrected to say exactly that, and the circulation consumer's exposure below MOVE_W is now recorded as a KNOWN GAP rather than a covered one. It survived because part 9's S2 check asserts only `not stale` and part 4 pairs the frozen unit with a RESTING sibling (circulating_w([A_FROZEN_W, 0.0]) == 0.0) - neither grades the size sentence. No behaviour change. Full suite: 22 passed, 0 failed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
6864109a01 |
SAFETY-37: a stale reading is None, and arrival cannot be what says so
checks / unit (pull_request) Successful in 5m34s
The architect pass made SAFETY-37, TEL-06 and TEL-08 one systemic problem - "a stale reading holds its last value and reads healthy" - and this builds the single policy. Both consumers the card names are gated by one change, and neither consumer needed a line: `SignWatchdog.evaluate` already CLEARS its window on a None measurement and `circulating_w` already returns None for a roster with a missing term. Neither could ever receive one. The gate is the piece that was missing BETWEEN the shipped read and the shipped checks. ⚠️ ONE CRITERION IN THE CARD COULD NOT BE IMPLEMENTED AS WRITTEN, AND SAYING SO IS THE MAIN CONTENT. The card says "only arrival separates them". At this layer arrival cannot: `read_inputs()` polls Home Assistant's state cache about once a second and HA answers 200 with a number whether or not the publisher behind the entity is alive, so both members of the pair arrive identically for ever. test_freshness.py part 1a BUILDS the arrival-age gate the card asks for and grades it failing - the age is 0.0 s at every instant of both runs, a threshold that can never fire, which is TEL-06's own defect one layer up. The half of the rule that survives is honoured absolutely: nothing here reads a Home Assistant timestamp, and the check asserts the module contains no `last_updated`. So the criterion became: a reading is stale when it has not moved through a change it was OBLIGED to follow. STALE <=> unchanged >= 35 s AND |standing| >= 250 W AND |standing - standing when the reading last changed| >= 250 W Latched until a different value arrives. `Inverter.batt` becomes None - never a held number - which is a missing input, which §11.1 rule 1 answers with 0 W. WHY A CONJUNCTION AND NOT A THRESHOLD, MEASURED 2026-09-06. Replaying the committed 30-day capture of this house's own 1 W power entity (485,841 samples out of HA's recorder, where a row exists only on a CHANGE, so the gap between rows IS the unchanged duration every consumer saw): the longest legitimate unchanged run is 263.2 s, held at -899.0 W. A false-positive-free threshold would have to exceed that, and 263 s is 3.5x too slow for a consumer that trips at 75 s. There is no admissible threshold. Every one of those long runs is under an UNMOVING load, which is what the travel term keys on. THE BOUND, derived at both ends and graded against the shipped config.yaml. Floor 17.0 s = 6.0 plant settle (NOTES §5.9, measured 2026-08-22) + 10.0 ESPHome publish (firmware/goodwe-master.yaml:181) + 1.0 read period (run_control's asyncio.sleep(1)). Ceiling 75.0 s = min(circulation_hold_s 75, unit_sign_disagree_s 85). Band [34.0, 37.5]; 35 is a CHOICE inside a DERIVED band and the check says so - the first draft claimed the band held one integer and went red on its own first run. MOVE_W is the LARGEST admissible value, unit_sign_disagree_w, because below it the consumer cannot speak at all. SoC is STATED, NOT WIRED, and BOUNDS carries it with wired=False and the reason. Its obligation term is energy through the pack, not command travel - a rested pack holds a percent for hours under a 0 W command - and K needs a usable capacity nobody has measured. That is TEL-08's; the record says so. ⚠️ BEHAVIOUR CHANGES AT THE SHIPPED ROSTER OF ONE, and it is named rather than hidden: a FROZEN register now stops the site where it used to read as a healthy measurement for ever. That is HW-01, which was a one-inverter site. A healthy install is unchanged, asserted over sixty reads of a dithering register under a reversing command. A false verdict fails toward 0 W behind the existing stale_input_s hold and clears itself on the register's next move. ⚠️ BOTH UNITS IN EVERY TWO-INVERTER FIXTURE ARE INVERTER A with different numbers passed in; inverter B does not physically exist (~2026-09-12). MUTATION: 16 mutations against a purged, :ro, python3 -B tree, fingerprinted before and after every revert. One survivor, and it is the finding - dropping the magnitude term left every check green because no fixture separated "the command travelled 250 W" from "the command ended up above 250 W". Closed, and recorded as MUTATION.md habit 17. A second process defect is habit 18: a mutation run that timed out between the edit and the revert left the module mutated on disk and nothing announced it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
5d7e275df4 |
SAFETY-39: slew_w is a ratio to the rail, and gain is what is still wrong
checks / unit (pull_request) Successful in 5m16s
`slew_w: 1000` was derived on 2026-08-22 against a clamp of 1500 W, where "capping how far the loop can jump ahead of the inverter" was true because the rail itself bounded the excursion. The live install went to max_w 5000 and the constant did not move, so the loop crosses its whole output range in 10 s against a plant NOTES.md §5.10 measured at ~18 s. Measured on the owner's system 2026-09-05 11:43-11:46: a 3.4 kW export drove the setpoint 380 -> 5000 W in five 2 s ticks, the battery to -5154 W past a 5000 VA rating, and the grid from 3.4 kW of EXPORT to 2.8 kW of IMPORT. Nothing was broken; the rail moved. REPRODUCED FIRST, and one half of it is not reproducible at all. On plantsim.Plant and on an independent in-repo model: the setpoint stepping a full 1000 W per tick to the rail (48 of 51 export steps), and the meter crossing from export into import. The -5154 W figure is NOT reproducible in either model, because both clamp the plant at its rating - so the card's AC "battery no longer exceeds max_w in simulation" is unfalsifiable and was rewritten rather than met. THE DELIVERABLE IS THE RELATIONSHIP. SLEW_RAIL_FRAC = LOOP_TICK_S / PLANT_SETTLE_S = 2 s / 18 s = 1/9 slew_w = 0 now means FOLLOW THE RAIL - SLEW_RAIL_FRAC * max_w - exactly as integrator_max_w = 0 already means follow max_w. It is the shipped default: 222 W at max_w 2000, 556 W at 5000, 1067 W at a ~9600 W two-inverter site. validate.py rule 10 refuses an explicit value faster than that, graded against sum(unit_caps(opts)) - the roster sum, i.e. Controller.site_max_w - so a second inverter moves the bound with no operator action. VERIFIED BY BREAKING IT: restoring slew_w 1000 at max_w 5000 is refused, and the refusal names the rail, the crossing time, the measurement and what to do. Equality, one watt of slack, the live interim 400 and slew_w 0 at every rating all still start. GAIN IS THE CONSTANT THAT IS STILL WRONG, and it is NOT changed here. Over 51 export steps the over-absorption is FLAT in slew (958 W at 1000, 1081 W at 250) and monotone in gain (987 W at 0.6, 876 at 0.3, 435 at 0.2): slew_w bounds the OUTPUT, while the integrator moves gain*error per cycle and reaches a 5000 W rail in under three ticks whatever the slew is. Two control-law constants moving in one release on a running house makes the next measurement uninterpretable. ⚠️ N=2: the site rail becomes sum(max_w) ~ 9600 W (inverter B FROM DATASHEET AND SERIAL, not measured, and sim/plantsim.py's INVERTER_B is A copied), and the enforced ratio produces 1067 W there automatically. But a growing rail LOOSENS this bound, and the >= 900 W symptom goes stale at N=2 the same way the constant did - both stated in the record rather than left to be discovered. Nothing reached the inverter. The live install still runs the interim slew_w 400 applied by hand on 2026-09-05, which rule 10 accepts unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
d5b9317d57 |
SAFETY-38: correct the survivor count, which was stated four ways
checks / unit (pull_request) Successful in 5m11s
Review found the summary lines miscounted, and one of the miscounts filed
a KILL among the survivors. The authoritative tables in §7e and §7f were
right throughout; only the sentences a reader actually relies on were
wrong.
Arithmetic, from §7f's own ledger:
14 measured = 10 surviving
+ 1 already caught (C3 allocator.py:162, red in test_alloc.py)
+ 3 caught by round 4 (C6 clamp.py:253, C7 peakguard.py:493,
B12 arbiter.py:264)
Survivors by file: TWO in allocator.py (C1 :154, C2 :160), three in
arbiter.py (C4a/C4b :344, C5 :392), two in tariff.py (C8, C9), two in
arbitrage.py (B17, B18), one in main.py (B19). Two, not three, in
allocator.py - C3 is the kill.
Before: DECISIONS §6c said "Nine" while listing eleven; the CHANGELOG said
"11 SURVIVORS" while listing eleven. Both counted C3 as a survivor.
⚠️ KEPT VISIBLE RATHER THAN QUIETLY FIXED. This is this card's own
recurring defect in prose rather than in code - a claim wider than the
measurement beneath it - and it is the second false statement this ticket
has put into its own decision record. The correction says so at the line,
because a document whose whole value is that it can be trusted should
record when it could not be.
No app/ change; no test change. Full suite: 21 passed, 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
e2efc765af |
SAFETY-38 round 4: the enumeration covered one sink, and it was not the one this codebase uses
checks / unit (pull_request) Successful in 5m12s
The scanner's two censuses between them scored ONE syntactic sink and called it
the family. Every one of census B's 16 members was a sentence that is APPENDED,
ASSIGNED to a `reason`-shaped name, or passed as a LOG ARGUMENT. Not one was
RETURNED - and census A's return bucket only ever held a callee formatting its
own PARAMETERS, so a method formatting its own ATTRIBUTES and handing the string
back was invisible to both. That is the whole describe() / explain() /
reason-builder family, which is where this repository actually keeps its
operator sentences. Worse than the gap: it was in NONE of the five blind spots
the tool printed about itself.
THE SCANNER, WIDENED - and it rediscovered both members without being told:
* CENSUS C, the return sink, resolved through the returning method's CALL
SITES so a returned KEY (store.unit_key, mqtt.unit_state_key - MAINT-05's
card) is not mistaken for a returned SENTENCE. 53 returned pairs, 39
reaching an operator, 16 of those sharing a format spec.
* A sentence handed to a constructor as `reason=` or placed under a "text"
dict key is a MESSAGE. That one line of the sink walker was hiding SIX more
caller-built members, census B 16 -> 22.
* Every census B and C row now prints whether the two interpolations share a
FORMAT SPEC - the difference between a transposition that is silent and one
that merely looks broken.
* The blind-spot list is now TWO lists: what it scans (printed from the
constants, so they cannot drift) and WHAT IT DOES NOT SCAN AT ALL, largest
hole first - `%s`-style lazy logging, MEASURED at 31 calls across 8 modules
and each one listed, not asserted.
CLOSED, both zero-byte survivors of a green 21-file suite:
* clamp.py:253 ClampResult.describe(), requested_w <-> applied_w. No
test_*.py mentioned describe() at all. It is the SS15 action-log row
(main.py:1366) AND the ClampWatchdog notification reason (main.py:2452) -
the alarm SAFETY-40 and SAFETY-41 are about. Under the swap the guard
reports a setpoint clamped UPWARD, to +9999 W by a bound whose rating the
same sentence gives as 2000 W. Pinned on the ORDERED PAIR on the contiguous
literal at BOTH consumers, operands read off ctl.target and
hass.writes[-1] - what the loop asked and what the device was sent - not
off the ClampResult the swap lives in. 4 checks red.
* peakguard.py:493 PeakDecision.explain(), projected_w <-> ceiling_w. "peak
guard idle" was in no test_*.py. The row then reads "projects 5013 W under
a 2300 W ceiling" - 2713 W OVER - on a row whose own words say idle and
whose own flag says ok. Pinned on the decision and on the shipped status
row. 3 checks red.
* test_arbiter.py's scope-violation check, whose CLAIM was wider than its
ASSERTION. Measured this round rather than left at source level: 0 bytes,
21/21 green before, red now. 2 checks.
DISCLOSED, NOT CLOSED - 11 measured zero-byte survivors, each with its file
fingerprint and its 21/21 verdict, in the decision doc SS7e-SS7g. Three in
allocator.py's UnitPlan.explain(), three across arbiter.py's two explain()s,
two in tariff.py, two in arbitrage.py's trade reasons, one in main.py's
SoC-floor notification. An enumerated list with honest gaps beats a fifth round
finding a fifth instance.
CORRECTIONS THIS ROUND OWES:
* The idle sentence does NOT reach main.py:3299/3302 - those are the
floor-limited branch, where explain() returns self.reason before the idle
literal is built. It reaches main.py:3313 and, on the release edge, 1951.
1951 is disclosed unpinned.
* SS4's "Every one of the ten is red" sat under a twelve-row table: it is the
ten site_standing entries.
* SS6b said "weaker evidence than A1-A3"; A4 was measured, so SS7d's
"A1-A4 and B1" is the right list and SS6b now says it.
* AND THE PROBE THAT GRADES THESE MUTATIONS WAS THE DEFECT, ONCE. Its first
cut numbered the operands BY ORDER OF APPEARANCE, so after a transposition
the other leaf took the same number and all thirteen mutant renders came
back byte-identical - which reads exactly like "the edit is inert". Keying
the value to the NAME fixed it. Habit 4, in the measuring instrument:
MUTATION.md habit 16 is the new entry, and the count there is corrected to
eleven rather than inflated.
14 mutations measured: fresh full-repo copy each, mounted :ro, bytecode purged
and the purge COUNT printed, the edit re-read from disk, length and sha256
printed on both sides, and every one required to change the EMITTED SENTENCE
and not merely the bytes. 12 survived the pre-round suite, 1 was already
caught, 3 are caught now. Full 21-file suite green on the final tree.
NOT ONE BYTE OF app/ MOVES. This card changes coverage only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
|
||
|
|
357cb4f07f |
SAFETY-38 round 3: pin the banner the SHIPPED install actually prints
checks / unit (pull_request) Successful in 5m12s
Round 2 pinned the operand order of `SignWatchdog.evaluate()` at main.py:1620 -
the SITE detector. Part 9c asserts that detector is never armed at a roster of
one, so on every install this add-on has, the PER-UNIT banner is the only
sign-disagreement sentence that can ever be printed, and its call site is a
different line. main.py:1560 was left unpinned.
1. THE SAME ZERO-BYTE DEFECT ONE CALL SITE OVER.
`evaluate(stood, against, ...)` -> `evaluate(against, stood, ...)`:
app/main.py 234 542 bytes before and after, delta 0, fingerprint
61aa413f... -> e57e6026..., bytecode purged, tree mounted :ro, line re-read
from disk. On the round-2 tree the FULL 21-FILE SUITE PASSES.
Nothing behavioural could ever have caught it. `watchdog.disagrees()` is
SYMMETRIC in its first two arguments - `commanded_w * against_w < 0` with an
abs() gate on both - so the mutant takes identical actions on every input.
Not "no fixture happens to separate them": no fixture CAN.
What moves is the sentence. Measured by diffing the suite's own printed
banners between the round-3 tree and the same tree carrying only this edit:
9 emitted banner lines invert, in 3 distinct sentences.
shipped commanded -2000 W against a meter reading +2000 W
swapped commanded +2000 W against a meter reading -2000 W
shipped commanded +1000 W against its own measured power -1000 W
swapped commanded -1000 W against its own measured power +1000 W
shipped commanded -2000 W against a meter reading +4000 W (the N=1 arm)
swapped commanded +4000 W against a meter reading -2000 W
Persisted to `sign_reason` and re-read after the restart the latch exists to
survive; also the action log, `_LOG.error` and `status_payload()`'s headline.
CLOSED by the ORDERED PAIR on both per-unit banners, both operands read off
the OBSERVED CYCLE - `inv.last_write` and `inv.batt` off the objects the
plant moved, never off the arguments the detector was handed, which would
compare the swap against itself:
- part 3, the N>=2 trip: "commanded {stood:+.0f} W against its own
measured power {meas:+.0f} W", plus the persisted namespaced copy;
- part 7, the N=1 controller-driven trip: "commanded {stood:+.0f} W
against a meter reading {grid:+.0f} W", plus the namespaced key AND the
flat rollback mirror;
- a PRECONDITION on each that the two operands are different numbers of
opposite sign - without it a transposition produces the same string and
the check is decoration.
Measured after the fix: RED on four named checks in test_watchdog.py, GREEN
in the other twenty files. M11 (the site swap) re-measured on this tree:
still red. Round 1's and round 2's kills are intact.
2. A FALSE STATEMENT ROUND 2 INTRODUCED, AND IT WAS WORSE THAN REPORTED.
Section 4 said "three of these left the whole suite green - M1, M2 and M3 -
and the remaining seven were caught by SAFETY-30's own N>=2 checks". Round
1's vaguer wording was harmless; naming three made it specific and wrong.
Re-measured mutation by mutation on clean release/1.0 @
|
||
|
|
564ba11c11 |
SAFETY-38 round 2: assert the ordered pair, and correct the M3 attribution
Two blockers, one of them mine to fix and one a mis-measurement to undo. No
`app/` change in either: the shipped `sum` is correct.
1. A ZERO-BYTE SURVIVOR AT THE CALL SITE ONE LINE BELOW THE PINNED VALUE.
Round 1 pinned `site_standing` and never pinned WHERE IT GOES. Swapping the
detector's first two arguments at main.py:1620 -
`evaluate(site_standing, self.grid, ...)` -> `evaluate(self.grid, site_standing, ...)`
- is a ZERO-BYTE edit and left the round-1 tree fully green. The cost is the
banner: the site stands at -500 W of charge into a +2500 W import and the
LATCHING, SITE-STOPPING alarm reads "commanded +2500 W against a meter
reading -500 W" - which instrument read what, inverted, on the one line
somebody woken by it reads first (watchdog.py:658-665).
Round 1's check could not reach it. It asserted `"-500 W"` appears SOMEWHERE
in the sentence; under the swap it still does, IN THE METER SLOT. A substring
test grades PRESENCE, never ARRANGEMENT. It now asserts the ORDERED PAIR:
`f"commanded {sum:+.0f} W against a meter reading {grid:+.0f} W"`. Measured:
red in test_watchdog.py on that one named check, green in the other twenty.
2. THE M3 ATTRIBUTION WAS FALSE, AND HOW IT BECAME FALSE IS THE USEFUL PART.
Round 1 recorded `list(standing.values())[0]` as closed by SAFETY-32's
package-wide pin and told future authors NOT TO RE-COVER IT. Re-measured on
release/1.0 @
|
||
|
|
3033420dd7 |
SAFETY-38: record the CI-06 false red, and where each mutation lands
All ten mutations of `site_standing` are red in test_watchdog.py and in nothing else - the other 19 files are green under every one of them, which is the measurement of how unpinned the line was. One exception, recorded rather than dropped: in the batch run M10 (`sorted(standing.values())[-1]`) also reported FAIL test_peakguard.py. Re-run in isolation three times and once more through tools/mutation_run.sh - four clean passes. The batch script had discarded the failure detail so the check cannot be named; what can be said is that it did not reproduce in four attempts and that no claim in this card rests on it. That is CI-06's file behaving the way CI-06 records. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
a8bd2b9330 |
SAFETY-38: pin site_standing with shares that cannot answer for the site
`app/main.py:1598`'s `site_standing = float(sum(standing.values()))` is the
first argument of SAFETY-30's latching, site-stopping sign detector AND the
`commanded_w` handed to SAFETY-18's `looks_frozen()`. It was pinned by nothing.
Coverage only: not one byte of `app/` moves - the shipped `sum` is correct.
WHY THE SUITE COULD NOT SEE IT. `_sign_site`, the only N>=2 site fixture in the
repository, runs shares of about -2500 W EACH - every one individually over its
320 W gate - so sum, mean and max(key=abs) all agree on it. Its values happen to
be equal, and a check comparing two things that happen to be equal cannot see
the difference it names. MUTATION.md habit 4, ninth instance, inside the site
half of the card that names the rule in its own text. At N=1 it is arithmetic
rather than a fixture defect: sum == mean == max == the one share, identically.
THE FIXTURE (test_watchdog.py part 9). Two SIMULATED units rated 220 W and
280 W, both obeying exactly, inverted gain, house importing 2000 W: the site
commands -500 W against a +2500 W meter and NO SINGLE SHARE clears the shipped
300 W gate. Four candidate reductions, four distinct answers, one over the gate
- sum -500, mean -250, max(key=abs) -280, [0] -220 - asserted as a precondition.
The asymmetry is load-bearing, and the smaller unit is first so [0] stays
distinct from max(key=abs). Gate shipped; only the hold is shortened.
BOTH CONSUMERS. 9b drives the same discrimination at SAFETY-18's threshold: the
site rail is 0.95 x 500 = 475 W, so the sum rails it while 250, 280 and 220 do
not. Shipped: BLIND_FROZEN, floor 0 W. Under both surviving mutations: not blind
at all, no floor filed - the battery may keep importing through a quarter whose
billed average is unobservable. The open quarter is backfilled with the stuck
reading so the mutant cannot reach the same action by the coverage route and
mask the difference. 9c asserts the N=1 identity on a real one-inverter tree.
SAFETY-32 already closes `list(standing.values())[0]` structurally and this card
does not claim it. Its disclosed blind spot - semantics are invisible to a shape
pin, permanently - is why the mean and max(key=abs) needed a behavioural set.
AND THE SEARCH FOR THE NEXT SURVIVOR FOUND ONE. `float(self.target)` - the
arbiter's site target instead of the sum of what stands on the wire - is -11 B
and survives all 20 files on untouched release/1.0 @
|
||
|
|
26c5ee8adc |
TEL-07: a second MQTT device for inverter B, and A keeps the one it has
checks / unit (pull_request) Successful in 4m58s
MQTT discovery announced ONE device and hung every entity off one unique_id prefix, so at two inverters B's per-unit telemetry had nowhere to go. It has somewhere now, and not one entity that already exists moved. ⚠️ THE ASYMMETRY IS DELIBERATE. The first roster entry's per-unit rows are announced on THE DEVICE IT ALREADY HAS; every later entry gets its own device, via_device the controller. "One device per inverter" is tidier and is the change that renames every entity on the live install - and because HA keeps the id it composed at FIRST discovery, a rename produces a NEW entity beside a DEAD one: the history stays behind and every dashboard, template and automation naming it goes on pointing at a sensor that quietly stopped updating, including the three live watchdog automations. The composed ids come out symmetric anyway (sensor.goodwe_inverter_a_battery_power / ..._inverter_b_...), so the tidy design's only real benefit is bought here without its cost. * four per-unit rows - setpoint, battery power, SoC, SAFETY-02 sign latch - announced only from a roster of two. At N=1 nothing is announced and nothing is published: no second device, no renamed entity, no added topic. * grid and p1_sample_age_s stay SITE-scoped and are announced ONCE, at every roster length 1-6. One P1, one grid connection, and p1_sample_age_s is a watchdog input - a second copy is a second answer to "is the meter fresh?". * inverter.roster_names() is the one answer to what the units are called, read by build_roster() and by the publisher, which must know the roster before a Controller exists because announcing happens on the CONNACK. * DECISIONS/TEL-07-second-mqtt-device.md records the two designs side by side. VERIFIED BY DIFFING THE ANNOUNCED PAYLOADS, not by reading the code. The release/1.0 baseline in test_mqtt.py was generated by running that commit's module, not transcribed. Roster of one: 9 topics, 0 missing, 0 changed, 0 added. Roster of two: 0 missing, 0 changed, 8 added. MEASURED, NOT ASSERTED. Sixteen mutations through tools/mutation_run.sh; twelve are in the file as a running table, each named with the property it breaks. Three of those properties exist because the table found their ABSENCE - dropping the unit from the state topic, moving `grid` into the per-unit set, and `units=()` in main()'s publisher call were all GREEN on the first run, as was a roster fallback naming two nameless entries `inverter-a`. Part 9 enumerates what the file still cannot see, with sizes. ⚠️ INVERTER B DOES NOT PHYSICALLY EXIST (~2026-09-12). Every roster of two here is SIMULATED, from the datasheet and serial in NOTES.md §1.1b. Nothing about B has been measured and no entity described here has been seen on a broker. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU |
||
|
|
19decf433f |
SAFETY-32 round 5: stop closing sites, build an instrument that finds its own scope
checks / unit (pull_request) Successful in 4m59s
Rounds 2, 3 and 4 each closed exactly the survivor they were handed and each time
the reviewer found the same shape one step over: the reduction, the call site, the
sibling argument, and now the sibling MODULE. Round 4's enumeration stopped at the
`Controller` class boundary; round 5's four blockers were reductions in validate.py,
over a list DERIVED from the roster three calls away from it. Closing four more sites
by hand would have produced a fifth rejection, so this round widens the instrument
instead and lets it enumerate.
⚠️ THE BLOCKERS ARE NOT EQUIVALENT MUTANTS - a safety refusal disappears, verified by
calling the shipped validator both ways on the real tree:
roster max_w 5000 (inverter-a) beside 2000 (inverter-b), sign_disagree_w 3000
clean -> 1 problem, "...max_w (inverter-b) is 2000 W, so the
SAFETY-02 sign-disagreement watchdog can never fire"
min(caps[:1]) +4 B -> 0 problems. The site STARTS.
caps_ix[:1] +4 B -> 0 problems.
the SAME roster with the small cap FIRST (2000, 5000)
clean -> 1 problem, naming inverter-a
min(caps[-1:]) +5 B -> 0 problems.
⚠️ AND THE ONE-ROSTER FIXTURE COULD NOT HAVE SEEN THAT. On [5000, 2000] the `[-1:]`
mutant still refuses, because last IS min there. A fixture of N members cannot separate
two reductions that agree on N members - which is why the behavioural half below is a
SET and not a roster.
THE INSTRUMENT (test_peakguard.py part 12j, widened past `Controller`)
Every `.py` under app/ is read off disk and parsed - NO MODULE IS NAMED IN THE CHECK -
and the roster is found by transitive closure from ONE seed, the `inverters` option key:
through returns, through lists ACCUMULATED in a loop over a roster (which is exactly
what `caps`, `roster` and `problems` are, and what round 4's rule could not express),
through attributes and properties, and THROUGH CALL ARGUMENTS, which is how it walks
into peakguard, arbiter, allocator, circulation, maintenance and store without being
told they exist. A slice of a roster stays a roster so a mutation cannot cut the taint;
an index is one unit and does not.
183 roster reductions across 9 modules, out of 23 files parsed:
main 96, allocator 24, validate 19, arbiter 14, circulation 10, maintenance 10,
peakguard 4, inverter 3, store 3. 14 modules reduce over no roster at all.
⚠️ THE HAND-WRITTEN MODULE LIST WOULD HAVE BEEN WRONG IN BOTH DIRECTIONS. It named
watchdog.py, which reduces over no roster at all (SAFETY-30's sign watchdogs are
strictly per-unit), and omitted maintenance.py and store.py, which both do. A vacuous
claim about one module and no claim at all about two others - which is the argument for
computing the scope, made with data.
The pin rediscovers ALL FOUR blockers without being told where to look, and finds a
FIFTH beside them (validate.py:783, rule 5's own message) that no review round named.
Its counterfactual runs at two depths: every enumerated site's expression mutated with
each of the four cuts this card has been defeated by (183 x 4 = 732 spellings, all
reported), and `[:1]` written into app/validate.py at each blocker position with the
WHOLE analysis re-run over the mutated source.
THE SWEEP, AND THE NUMBER THAT MUST NOT BE QUOTED ALONE
All 183 sites mutated to `[:1]`, +4 B each, against the full 19-file suite in
goodwe-addon-ci (:ro, -B, fresh rsync per mutant, 0 .pyc before and after - a read-only
mount cannot write one): 183/183 RED. ⚠️ That is TAUTOLOGICAL on its own, because the
pin makes every slice red. So the 51 whose first red was test_peakguard.py were re-run
with the pin NEUTRALISED:
145 of 183 a behavioural fixture separates the site from a slice of itself
38 of 183 ONLY the structural claim. Behaviourally UNGRADED.
All 38 are listed by site with what a `[:1]` there would cost, in the DECISIONS record
and in part 12j itself: 15 are rows and counts on the operator's page, 3 are
`len(targets)` arithmetic beside a write path that IS graded, 6 are allocator internals
ALLOC-01 reaches but not with enough units to separate. NONE of the 38 is closed
behaviourally by this card and none is counted as covered.
THE BEHAVIOURAL HALF, because a shape pin grades an identity and never a meaning
test_validate.py grows a ROSTER SET for the three rules that reduce over
`unit_caps_indexed()`: widths 2, 3, 4, 5 with the one small cap at EVERY position,
14 rosters x 3 rules = 42 runs of the real validator, each asserting the refusal fires
AND names the unit the number came off. Beside it a COMPUTED family table of twelve
candidates - max, first, last, the mean, [:1], [-1:], [:2], [:3], [1:], [:-1], [::2] -
each compared against what the shipped validator actually answered on the same roster.
Only `min` agrees everywhere; a member no roster kills is printed as a NAMED SURVIVOR.
⚠️ A THIRD RULE WAS READING THE FLAT `max_w`, AND IT IS SAFETY-30's
Found by reading the three branches side by side, NOT by the pin - and said that way
because it is the shape of what the pin cannot see. `unit_sign_disagree_w` was graded
against `_number(opts, "max_w", 2000.0)`, three lines under a branch that had already
stopped doing that. Both directions were measured before the fix:
two 2000 W units + a leftover `max_w: 5000` + unit_sign_disagree_w 3000 -> 0 problems.
The site started, the status page said `armed`, and the per-inverter watchdog could
not trip on EITHER unit. Identical sentence to the blocker, one branch over.
two 5000 W units with NO flat key -> REFUSED against a 2000 W default the operator
never wrote, for a threshold both units can reach.
It takes the same `weakest` the branch above takes and names the same unit. At a roster
of one `unit_caps()` is `[max_w]`, so the shipped single-inverter refusal is the same
comparison, the same boundary and the same sentence.
MUTATION PROOF (each: bytecode purged and counted 0 before and 0 after, the edit
re-read FROM DISK with the new text present and the old absent, byte delta printed)
B1 validate min(caps) -> min(caps[:1]) +4 B RED test_validate.py
B2 validate min(caps) -> min(caps[-1:]) +5 B RED test_validate.py
B3 validate caps_ix -> caps_ix[:1] +4 B RED test_validate.py
B4 validate rule 6 min -> min(caps_ix[-1:]) +5 B RED test_validate.py
B5 validate rule 5 msg -> min(caps_ix[:1]) +4 B RED test_validate.py
B6 validate rule 5b msg -> min(caps_ix[-1:]) +5 B RED test_validate.py
B7 CONTROL: weakest -> max(0.0, weakest) +10 B GREEN, as intended
R3a peakguard max(floors) -> min(floors) 0 B RED test_peakguard.py
R3b peakguard max(floors) -> max(floors[-2:]) +5 B RED test_peakguard.py
R3c main call site packs -> self.packs[:2] +4 B RED test_peakguard.py
R4a main pack_floor_soc -> vals[:2] +4 B RED test_peakguard.py
R4b main pack_floor_soc -> vals[-2:] +5 B RED test_peakguard.py
R4c main pack_floor_soc -> vals[:3] +4 B RED test_peakguard.py
R4d main cycle() any(...) -> self.inverters[:1] +4 B RED test_watchdog.py
B1-B6 are caught by the BEHAVIOURAL set in test_validate.py, before the pin is reached.
B7 is a deliberate no-op control: `max(0.0, weakest)` is `weakest` for weakest > 0, and
it stays green, so the battery is not merely red for everything.
R3a is a ZERO-byte delta, so the shipped function was called both ways as well:
packs floors [15.0, 30.0, 20.0], peak_soc_floor 15
binding_soc_floor(...) = 30.0 the 0-byte mutant would answer 15.0 (+15.0 pp)
-> the site would discharge to 15 % while pack-b's own BMS cuts out at 30 %.
⚠️ PRODUCTION IS DEGENERATE ON THE FLOOR AXIS. SPIKE-04: pack A and pack B BOTH floor
at 10 %, so a differing floor exists ONLY in the fixtures. That is the argument for
keeping them, not for simplifying them - collapsing them deletes the only data in this
repository that can see the reduction at all. Restated in config.yaml's example, in the
probe and in the fixtures.
⚠️ INVERTER B DOES NOT PHYSICALLY EXIST (~2026-09-12). Every second-unit figure here -
4600 W FROM ITS DATASHEET, 15 % illustrative, the whole roster SET - is SIMULATED and
labelled. Only inverter A's 10 % is read off hardware.
REBASED onto
|
||
|
|
8beba0a3e7 |
SAFETY-32 round 4: the same family at the SIBLING ARGUMENT, and the family enumerated
Rounds 2 and 3 hardened the FLOOR axis - the max inside binding_soc_floor(), the roster handed to it, Controller.packs, pack_floor_pct()'s conjunction. peakguard.decide() is handed a SECOND reduction over the same roster beside it, soc=self.pack_floor_soc, and peakguard.py's own docstring names the two as a pair. Three rewrites of Controller.pack_floor_soc were green across all 19 files on |