Four cards built the price stack and nothing traded on it. This one does.
Where the strategy lives, and how it files its intent
goodwe_controller/app/arbitrage.py — not specs.md §2.2's ems/optimize/arbitrage.py, which the card's technical notes name and which does not exist. Everything ships in app/, beside price.py, static_tariff.py, peakguard.py and arbiter.py, per the owner's 2026-08-30 decision; the module docstring says so at the top.
decide() is a pure function and claim() turns its verdict into one arbiter set claim at a new P_ARBITRAGE = 30. Nothing here writes a setpoint. What is left for this file after the four cards is deliberately small — which hour is this, may the pack do it, how hard — and everything else is delegated.
An idle decision files no claim, not a 0 W claim. A 0 W set at P_ARBITRAGE would out-rank the loop with a park order every cycle the strategy had nothing to say, i.e. silently switch self-consumption off on any site that armed arbitrage. Mutation M6 confirms the check bites.
How the background refresh keeps the tick clean
resolve_tariff() may open an HTTPS connection, and the control tick is what feeds the ESP32 heartbeat — layer 1 commands 0 W if that stops for ~30 s, so a supplier's TLS timeout on the tick is a supplier able to drive this add-on into the failsafe. So:
Controller.run_tariff_refresh() is its own long-lived task, started from amain() only when arbitrage_enabled is on, refreshing every TARIFF_REFRESH_S = 1800 s. It catches everything and keeps going, and a failed refresh does not clear the plan — resolve_tariff() already degrades to §11.4's provisional static fallback, which refuses arbitrage on its own, and that answer is better than no answer.
The tick reads Controller.fresh_tariff_plan(now) — an attribute read and an age comparison, TARIFF_MAX_AGE_S = 3 × refresh. A refresh task that has died leaves a plan that was true hours ago standing as if current; the bound retires it and arbitrage stops with it.
test_arbitrage.py section 8 asserts this structurally on the shipped AST: cycle() and read_inputs() await none of resolve_tariff / load_prices / https_get, and run_tariff_refresh() does. Mutation M14 puts a fetch back on the tick and it goes red.
How peak-shaving priority is honoured through the existing interlock
Not re-implemented — reached twice, both through machinery that already shipped:
The interlock (TARIFF-03).TariffPlan.arbitrage(peak_guard_active=…) is keyword-only and required, so a spread is unobtainable without answering §10.1's ordering. decide() passes it through and every argument of decide() is likewise defaultless.
The arbiter (SAFETY-07).P_PEAK 50 > P_ARBITRAGE 30, so clause 2 applies peakguard's own lo floor to an arbitrage charge that wins. The check drives peakguard.claim()'s real output, not a stand-in, and asserts the identical floor filed below the arbitrage priority does not bind — so the ordering is what does the work, not luck. A discharge is not capped (§9.1 bills offtake only).
And a third state neither mechanism can express. On meter_source: off the peak guard never runs, so peak_guard_active is not False — it is unknown. arbitrage.guard_state() carries that as None and decide() refuses: charging at the rail creates the billed quantity while nothing measures the quarter it lands in. The conjunction lives in guard_state() rather than inline in cycle(), for shaping_suspended()'s recorded reason — mutation M11 inlines it back and is red.
The negative-price guard, and how it was proved
§17, on the hour being sold into, not on the spread. A day priced −0.30 … −0.05 has a 0.25 EUR/kWh spread that clears any shipped threshold and a dearest hour that is still negative; a guard written as "the spread is positive" or "not all prices are negative" exports there.
Proved two-sided, on the same series with exactly one price's sign flipped (asserted: the two runs differ in one element, by its sign): the negative one refuses citing §17 and the price, the positive one discharges. The fixture's spread is asserted to clear the threshold first, so §17 is what refuses rather than the spread rung. Buying in a negative hour is still permitted, exactly 0.0000 is not negative, and a full pack in a negative sell hour reports §17 rather than the SoC — the rule that actually forbids the trade is the one logged.
What AC4 could honestly be measured against
AC4 is not met, and it is not redefined.scenarios/ holds three gzipped captures and all three are P1 meter data. There is no EPEX archive, no supplier export, no price CSV anywhere in the tree. TARIFF-01 and SAFETY-07 both declined to restate an unmeetable criterion and both were upheld; this does the same.
What section 7 does instead is the half that does not need real prices. Net positivity here is a property of the decision rule, not an empirical hope: every executed round trip has sell − buy ≥ required ≥ threshold, so its benefit net of the threshold is non-negative, always. That is asserted per trade over a 365-day synthetic year: 276 completed round trips, 0 below threshold, 0 that missed their own widened requirement, 0 discharges into the 522 hours priced below zero. The euro figure (+114.35 EUR net of cycle cost) grades my generator, not the market, and the file says so in its own preamble.
And the reported result the card asked for. At the shipped cycle_cost_threshold_eur: 0.10, a realistic Belgian day/night profile (0.34 / 0.26, spread 0.08) does not trade — refused by the spread rung, with the same site trading as soon as the threshold drops below its spread, so the refusal is about the number and not about static tariffs being unsupported. No number in this PR was chosen to make arbitrage happen. That threshold and FCST-03's 2.5 / 1.5 widening factors are one product decision and it is the owner's.
The accounting basis, and the term deliberately absent
The card's notes require crediting displacement at all-in import prices, not at the export price. Both hours compared here are all-in consumption prices, and export_price_eur_kwh is not read by this module at all — §9.3's near-zero Belgian injection price is exactly why the sound trade is charge-cheap-to-displace-expensive-import.
The notes also say of round-trip efficiency: "η is not a §14 key — it needs one, or must be derived from the state store. Do not hardcode it." So there is no η term. There is no key, nothing measured to derive one from, and a hardcoded 0.85 would also double-count, because cycle_cost_threshold_eur is already defined as what a round trip costs per kWh. When a measured η exists it is a second term beside the threshold, and the arithmetic is one subtraction in one place, ready for it.
Hysteresis (§10.2)
The notes ask for a bounded transition count under an oscillating price. Section 4b replays a day whose price crosses the threshold every hour (asserted) at the 30 s control cadence: 2 action transitions over 2880 ticks, and non-zero, with both actions occurring. No state machine or dwell timer was needed — the decision is which hour is this, and an hour is 3600 s wide; SoC is monotone inside one in the only direction that matters.
CFG-03 and rule 7
CFG-03 was not widened. Four option keys were added (arbitrage_enabled, cycle_cost_threshold_eur, arbitrage_soc_floor, arbitrage_soc_ceiling) and they live in price.py's PRICE_OPTION_DEFAULTS — TARIFF-02's single in-code mirror, imported by validate.py, followed by TARIFF-03 and FCST-03, and named by FCST-03's own note as where cycle_cost_threshold_eur would land. config.yaml stays the source of truth and test_price.py section 1 pins the two together key for key; main.py gained noo.get(key, <a number>) fallback, asserted. test_price_forecast.py section 7's "FCST-03 added ZERO option keys" line was a drift pin, not a ban — it now names OPT-04's four and additionally asserts the nine older keys survived, so the addition is a signature rather than a silent widening.
Rule 7's AST tripwire did NOT go red, and the wording was fixed anyway.test_price.py section 12 pins the set of modules that consume load_prices(); this card consumes resolve_tariff(), so the pinned list is still exactly ["static_tariff.py"]. But the sentence the tripwire exists to keep honest — "main.py does NOT yet call resolve_tariff() … so a configured PROVIDER still shapes no energy decision" — became false the moment the background refresh landed. It is rewritten in the same commit to say what is true now, it names app/arbitrage.py, and section 9 asserts the expired phrase is gone and the consumer is named. Nothing was silenced.
Three new refusals in rule 7, none of which can break an existing install (the option is new and ships false): arbitrage armed with no price source at all; arbitrage armed with meter_source: off; and a SoC floor at or above the ceiling. Plus a threshold sanity check that runs whether or not arbitrage is armed — a negative cost is a threshold every spread clears.
Checks
New: goodwe_controller/test_arbitrage.py, ~80 assert-based checks in house style. All 15 suites green, each run as find . -name __pycache__ -type d -exec rm -rf {} + ; python3 -B test_X.py (TEST-15).
Mutation register — 13 of 14 caught, run under -B with caches purged, byte delta printed
CAUGHT M1 §17 guard weakened (< 0 -> < -1) [arbitrage.py, +1 B]
CAUGHT M2 §17 guard deleted [arbitrage.py, -4 B]
CAUGHT M3 three-state peak answer collapsed [arbitrage.py, +4 B]
CAUGHT M4 peak interlock hard-wired to 'clear' [arbitrage.py, -1 B]
CAUGHT M5 P_ARBITRAGE raised ABOVE P_PEAK [arbiter.py, +0 B]
CAUGHT M6 idle files a 0 W claim instead of none [arbitrage.py, +39 B]
SURVIVED M7 §8.3 provisional-hour guard disabled [arbitrage.py, +6 B]
CAUGHT M8 reserve floor boundary <= loosened to < [arbitrage.py, -1 B]
CAUGHT M9 guard_state() answers False for "never ran" [arbitrage.py, +1 B]
CAUGHT M10 stale-plan age bound made unreachable [main.py, +6 B]
CAUGHT M11 cycle() inlines the three-state conjunction [main.py, -27 B]
CAUGHT M12 sell hour matched against the CHEAPEST point [arbitrage.py, +1 B]
CAUGHT M13 meter_source refusal removed from rule 7 [validate.py, +6 B]
CAUGHT M14 a provider fetch put back ONTO the control tick [main.py, +56 B]
M5's +0 byte delta is the shape TEST-15 warns about, which is why every run purges bytecode and uses -B; it is caught.
M7 is a real survivor and it is reported, not hidden. Disabling _act's own provisional guard changes no answer, and it cannot: the verdict's cheapest/dearest are firm by _firm_or_die, starts are unique within a series, and the point at() returns for a matched instant is therefore the same object. Section 4 now asserts that identity and that uniqueness, so the unreachability is demonstrated rather than assumed — the same honest shape peakguard's seventh mutation takes. The guard stays because it is the only thing standing if a future caller hands _act a plan the verdict did not come from.
Two check defects were found by mutating my own checks and fixed before submitting: the age-bound probe read TARIFF_MAX_AGE_S itself, so widening the constant moved the probe with it (M10 survived the first draft — it now probes at an absolute 12 hours and bounds the constant separately); and M4's first anchor did not exist, i.e. the mutation had not actually been applied.
Not done / open
AC4 unmet, above. It needs a real price archive, which is TEST-01's replay harness plus a dataset nobody has captured.
The threshold and the widening factors are a product decision and are left alone.
No η term, above.
§10.1 priority 4 (reserve management) is OPT-05; arbitrage_soc_floor is this strategy's own reserve, not that card's absolute floor, and it is deliberately not the maintenance_soc_* keys.
Nothing energy-moving was run. No add-on restart, no flash. arbitrage_enabled ships false.
Four cards built the price stack and nothing traded on it. This one does.
## Where the strategy lives, and how it files its intent
`goodwe_controller/app/arbitrage.py` — **not** specs.md §2.2's `ems/optimize/arbitrage.py`, which the card's technical notes name and which does not exist. Everything ships in `app/`, beside price.py, static_tariff.py, peakguard.py and arbiter.py, per the owner's 2026-08-30 decision; the module docstring says so at the top.
`decide()` is a pure function and `claim()` turns its verdict into **one arbiter `set` claim** at a new `P_ARBITRAGE = 30`. Nothing here writes a setpoint. What is left for this file after the four cards is deliberately small — *which hour is this*, *may the pack do it*, *how hard* — and everything else is delegated.
**An idle decision files no claim, not a 0 W claim.** A 0 W set at P_ARBITRAGE would out-rank the loop with a park order every cycle the strategy had nothing to say, i.e. silently switch self-consumption off on any site that armed arbitrage. Mutation M6 confirms the check bites.
## How the background refresh keeps the tick clean
`resolve_tariff()` may open an HTTPS connection, and the control tick is what feeds the ESP32 heartbeat — layer 1 commands 0 W if that stops for ~30 s, so a supplier's TLS timeout on the tick is *a supplier able to drive this add-on into the failsafe*. So:
* `Controller.run_tariff_refresh()` is its own long-lived task, started from `amain()` only when `arbitrage_enabled` is on, refreshing every `TARIFF_REFRESH_S = 1800 s`. It catches everything and keeps going, and a failed refresh **does not clear the plan** — `resolve_tariff()` already degrades to §11.4's provisional static fallback, which refuses arbitrage on its own, and that answer is better than no answer.
* The tick reads `Controller.fresh_tariff_plan(now)` — an attribute read and an age comparison, `TARIFF_MAX_AGE_S = 3 × refresh`. A refresh task that has *died* leaves a plan that was true hours ago standing as if current; the bound retires it and arbitrage stops with it.
* `test_arbitrage.py` section 8 asserts this **structurally on the shipped AST**: `cycle()` and `read_inputs()` await none of `resolve_tariff` / `load_prices` / `https_get`, and `run_tariff_refresh()` does. Mutation M14 puts a fetch back on the tick and it goes red.
## How peak-shaving priority is honoured through the existing interlock
Not re-implemented — reached twice, both through machinery that already shipped:
1. **The interlock (TARIFF-03).** `TariffPlan.arbitrage(peak_guard_active=…)` is keyword-only and required, so a spread is *unobtainable* without answering §10.1's ordering. `decide()` passes it through and every argument of `decide()` is likewise defaultless.
2. **The arbiter (SAFETY-07).** `P_PEAK` 50 > `P_ARBITRAGE` 30, so clause 2 applies peakguard's own `lo` floor to an arbitrage charge that wins. The check drives `peakguard.claim()`'s real output, not a stand-in, and asserts the identical floor filed *below* the arbitrage priority does not bind — so the ordering is what does the work, not luck. A discharge is not capped (§9.1 bills offtake only).
**And a third state neither mechanism can express.** On `meter_source: off` the peak guard never runs, so `peak_guard_active` is not `False` — it is unknown. `arbitrage.guard_state()` carries that as `None` and `decide()` refuses: charging at the rail creates the billed quantity while nothing measures the quarter it lands in. The conjunction lives in `guard_state()` rather than inline in `cycle()`, for `shaping_suspended()`'s recorded reason — mutation M11 inlines it back and is red.
## The negative-price guard, and how it was proved
§17, on the hour being **sold into**, not on the spread. A day priced −0.30 … −0.05 has a 0.25 EUR/kWh spread that clears any shipped threshold and a dearest hour that is still negative; a guard written as "the spread is positive" or "not all prices are negative" exports there.
Proved two-sided, on **the same series with exactly one price's sign flipped** (asserted: the two runs differ in one element, by its sign): the negative one refuses citing §17 and the price, the positive one discharges. The fixture's spread is asserted to clear the threshold *first*, so §17 is what refuses rather than the spread rung. Buying in a negative hour is still permitted, exactly 0.0000 is not negative, and a full pack in a negative sell hour reports §17 rather than the SoC — the rule that actually forbids the trade is the one logged.
## What AC4 could honestly be measured against
**AC4 is not met, and it is not redefined.** `scenarios/` holds three gzipped captures and all three are P1 **meter** data. There is no EPEX archive, no supplier export, no price CSV anywhere in the tree. TARIFF-01 and SAFETY-07 both declined to restate an unmeetable criterion and both were upheld; this does the same.
What section 7 does instead is the half that does not need real prices. Net positivity here is a **property of the decision rule**, not an empirical hope: every executed round trip has `sell − buy ≥ required ≥ threshold`, so its benefit net of the threshold is non-negative, always. That is asserted per trade over a 365-day synthetic year: 276 completed round trips, 0 below threshold, 0 that missed their own widened requirement, **0 discharges into the 522 hours priced below zero**. The euro figure (+114.35 EUR net of cycle cost) grades my generator, not the market, and the file says so in its own preamble.
**And the reported result the card asked for.** At the shipped `cycle_cost_threshold_eur: 0.10`, a realistic Belgian day/night profile (0.34 / 0.26, spread 0.08) **does not trade** — refused by the spread rung, with the same site trading as soon as the threshold drops below its spread, so the refusal is about the number and not about static tariffs being unsupported. No number in this PR was chosen to make arbitrage happen. That threshold and FCST-03's 2.5 / 1.5 widening factors are one product decision and it is the owner's.
## The accounting basis, and the term deliberately absent
The card's notes require crediting displacement at all-in **import** prices, not at the export price. Both hours compared here are all-in consumption prices, and `export_price_eur_kwh` is not read by this module at all — §9.3's near-zero Belgian injection price is exactly why the sound trade is charge-cheap-to-displace-expensive-import.
The notes also say of round-trip efficiency: *"η is not a §14 key — it needs one, or must be derived from the state store. Do not hardcode it."* So there is **no η term**. There is no key, nothing measured to derive one from, and a hardcoded 0.85 would also double-count, because `cycle_cost_threshold_eur` is already defined as what a round trip costs per kWh. When a measured η exists it is a second term beside the threshold, and the arithmetic is one subtraction in one place, ready for it.
## Hysteresis (§10.2)
The notes ask for a bounded transition count under an oscillating price. Section 4b replays a day whose price crosses the threshold **every hour** (asserted) at the 30 s control cadence: **2 action transitions over 2880 ticks**, and non-zero, with both actions occurring. No state machine or dwell timer was needed — the decision is *which hour is this*, and an hour is 3600 s wide; SoC is monotone inside one in the only direction that matters.
## CFG-03 and rule 7
**CFG-03 was not widened.** Four option keys were added (`arbitrage_enabled`, `cycle_cost_threshold_eur`, `arbitrage_soc_floor`, `arbitrage_soc_ceiling`) and they live in **`price.py`'s `PRICE_OPTION_DEFAULTS`** — TARIFF-02's single in-code mirror, imported by validate.py, followed by TARIFF-03 and FCST-03, and named by FCST-03's own note as where `cycle_cost_threshold_eur` would land. config.yaml stays the source of truth and test_price.py section 1 pins the two together key for key; main.py gained **no** `o.get(key, <a number>)` fallback, asserted. `test_price_forecast.py` section 7's "FCST-03 added ZERO option keys" line was a drift **pin**, not a ban — it now names OPT-04's four and additionally asserts the nine older keys survived, so the addition is a signature rather than a silent widening.
**Rule 7's AST tripwire did NOT go red, and the wording was fixed anyway.** `test_price.py` section 12 pins the set of modules that consume `load_prices()`; this card consumes `resolve_tariff()`, so the pinned list is still exactly `["static_tariff.py"]`. But the *sentence* the tripwire exists to keep honest — "main.py does NOT yet call resolve_tariff() … so a configured PROVIDER still shapes no energy decision" — became false the moment the background refresh landed. It is rewritten in the same commit to say what is true now, it names `app/arbitrage.py`, and section 9 asserts the expired phrase is gone and the consumer is named. **Nothing was silenced.**
Three new refusals in rule 7, none of which can break an existing install (the option is new and ships `false`): arbitrage armed with no price source at all; arbitrage armed with `meter_source: off`; and a SoC floor at or above the ceiling. Plus a threshold sanity check that runs whether or not arbitrage is armed — a negative cost is a threshold every spread clears.
## Checks
New: `goodwe_controller/test_arbitrage.py`, ~80 assert-based checks in house style. **All 15 suites green**, each run as `find . -name __pycache__ -type d -exec rm -rf {} + ; python3 -B test_X.py` (TEST-15).
### Mutation register — 13 of 14 caught, run under `-B` with caches purged, byte delta printed
```
CAUGHT M1 §17 guard weakened (< 0 -> < -1) [arbitrage.py, +1 B]
CAUGHT M2 §17 guard deleted [arbitrage.py, -4 B]
CAUGHT M3 three-state peak answer collapsed [arbitrage.py, +4 B]
CAUGHT M4 peak interlock hard-wired to 'clear' [arbitrage.py, -1 B]
CAUGHT M5 P_ARBITRAGE raised ABOVE P_PEAK [arbiter.py, +0 B]
CAUGHT M6 idle files a 0 W claim instead of none [arbitrage.py, +39 B]
SURVIVED M7 §8.3 provisional-hour guard disabled [arbitrage.py, +6 B]
CAUGHT M8 reserve floor boundary <= loosened to < [arbitrage.py, -1 B]
CAUGHT M9 guard_state() answers False for "never ran" [arbitrage.py, +1 B]
CAUGHT M10 stale-plan age bound made unreachable [main.py, +6 B]
CAUGHT M11 cycle() inlines the three-state conjunction [main.py, -27 B]
CAUGHT M12 sell hour matched against the CHEAPEST point [arbitrage.py, +1 B]
CAUGHT M13 meter_source refusal removed from rule 7 [validate.py, +6 B]
CAUGHT M14 a provider fetch put back ONTO the control tick [main.py, +56 B]
```
M5's **+0 byte** delta is the shape TEST-15 warns about, which is why every run purges bytecode and uses `-B`; it is caught.
**M7 is a real survivor and it is reported, not hidden.** Disabling `_act`'s own provisional guard changes no answer, and it cannot: the verdict's `cheapest`/`dearest` are firm by `_firm_or_die`, starts are unique within a series, and the point `at()` returns for a matched instant is therefore *the same object*. Section 4 now asserts that identity and that uniqueness, so the unreachability is demonstrated rather than assumed — the same honest shape peakguard's seventh mutation takes. The guard stays because it is the only thing standing if a future caller hands `_act` a plan the verdict did not come from.
Two check defects were found by mutating my own checks and fixed before submitting: the age-bound probe read `TARIFF_MAX_AGE_S` itself, so widening the constant moved the probe with it (M10 survived the first draft — it now probes at an absolute 12 hours and bounds the constant separately); and M4's first anchor did not exist, i.e. the mutation had not actually been applied.
## Not done / open
* **AC4 unmet**, above. It needs a real price archive, which is TEST-01's replay harness plus a dataset nobody has captured.
* **The threshold and the widening factors are a product decision** and are left alone.
* No η term, above.
* §10.1 priority 4 (reserve management) is OPT-05; `arbitrage_soc_floor` is this strategy's own reserve, not that card's absolute floor, and it is deliberately not the `maintenance_soc_*` keys.
* Nothing energy-moving was run. No add-on restart, no flash. `arbitrage_enabled` ships `false`.
Four cards built the price stack and nothing traded on it. app/arbitrage.py is
§10.1's priority 3, and it consumes all four rather than re-deciding anything:
whether a trade is permitted at all, on which two hours, against which widened
threshold is TariffPlan.arbitrage()'s answer, and that answer cannot be
obtained without first saying whether the peak guard is holding. §9.1's "peak
shaving outranks arbitrage" is therefore honoured structurally - there is no
path to a spread that skips the question - and again in the arbiter, where
P_PEAK (50) clamps a claim filed at P_ARBITRAGE (30).
A guard that never ran is not a guard that is clear. On meter_source off the
peak guard is never evaluated; that third state is carried as None and refuses,
because charging at the rail creates the billed quantity while nothing measures
the quarter. validate.py refuses that configuration too, along with arbitrage
armed with no price source and a SoC floor at or above the ceiling.
§17: never export into a negative price. The guard is on the hour being sold
into, not on the spread - a day priced -0.30 .. -0.05 clears any threshold and
its dearest hour is still negative. Buying in a negative hour stays allowed.
resolve_tariff() may open an HTTPS connection and the tick feeds the ESP32
heartbeat, so the fetch runs in its own task at a 30 min cadence and the tick
reads an attribute and an age bound. test_arbitrage.py section 8 asserts that
structurally on the shipped AST: a fetch put back on cycle() is red.
AC4 is NOT met and is not restated - this repository holds no price history.
Section 7 replays a synthetic year and asserts the property that does not need
real prices: every executed round trip cleared its own widened requirement, and
zero discharges landed in the 522 negative-priced hours. At the shipped 0.10
EUR/kWh threshold a realistic Belgian day/night site does not trade at all.
Reported, not tuned: that number and FCST-03's widening factors are one product
decision and it is the owner's.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
admin
merged commit 8e589fee3f into release/1.02026-08-31 01:53:31 +02:00
Merged as 8e589fe. This honours all three cards it sits on rather than re-implementing any of them, and the two things I most wanted to break — the priority interlock and the tick-purity assertion — both held under direct attack. One surviving mutation, on §17's own regression case, with a one-line fix I verified.
Read-back
release/1.0 8e589fe parents a30bde1 + bdd7f18
tree 0d800e2b - byte-identical to the branch tree I reviewed
15 suites from merged release/1.0, -B with recursive __pycache__ purge: all pass
checks / unit success, "Successful in 3m16s", verified before merging
1. Idle files no claim — the reasoning holds against the arbiter, measured
I drove resolve() directly rather than reading the docstring:
claim(idle_decision) -> None
resolve(loop -1500 W, arbitrage 0 W idle at P=30) -> 0 W, winner arbitrage
resolve(loop -1500 W alone) -> -1500 W, winner loop
So the counterfactual in your docstring is exactly right: a 0 W set at 30 turns a self-consuming loop into a park order, with winner=arbitrage, every cycle the strategy has nothing to say. sets is sorted by priority descending and the highest wins, so an idle claim would out-rank P_LOOP unconditionally. Y1 (making idle file a 0 W claim) goes RED on the check that names precisely this. That is the sharpest thing in the PR and it is correct.
2. P_ARBITRAGE = 30 is genuinely load-bearing
The test you asked for — a claim at 30 that would still win at 60 is not an interlock:
P_ARBITRAGE=30 (shipped) arbitrage 2500 W + peak LIMIT hi=800 @50 -> 800 W bound_by=peakguard
P_ARBITRAGE=60 (mutated) same claims -> 2500 W bound_by=None
identical floor filed at 20 (below 30) -> 2500 W bound_by=None
P_MAINT_SHAPE 40 hi=0 vs arbitrage 2500 -> 0 W bound_by=maintenance
At 60 the peak limit is skipped entirely by clause 2 (c.priority < winner.priority), so the capacity floor stops binding. The number is the mechanism, not a label. Y7 raising it to 60 is RED at +0 bytes — the same-size shape that hides behind stale bytecode, caught.
3. The tick-purity assertion discriminates — on the direct forms
T1 await resolve_tariff(...) on the tick RED
T2 await self.hass.load_prices(...) (attribute) RED
T3 a fetch moved into read_inputs() RED
T4 tick awaits a helper that awaits the fetch green *** SURVIVED ***
The scanner is one-hop: _awaited(cycle) sees _refresh_now, not resolve_tariff. All three forms a person would actually reintroduce are caught, and the self-falsifier ("the scanner really does find the awaits that ARE there") is the right habit. Closing T4 needs a call-graph walk — resolve awaited .attr names to functions in the same module and recurse — which is real machinery for a hazard that now requires deliberately writing a helper against two docstrings that forbid it. Worth knowing, not worth building today.
I also checked the first-match hazard you flagged: _fn returns the first FunctionDef of that name, and main.py has exactly one cycle and one read_inputs, so it is aimed correctly.
The refresh-failure chain, driven end to end rather than read:
resolve_tariff(dead feed) -> origin=fallback, provisional=True, firm points 0
decide(...) -> IDLE, "§8.3: only 0 of 24 hours carry a published price..."
claim(decision) -> None
A failed refresh degrades to §11.4's provisional fallback, which refuses arbitrage by itself, and the strategy files nothing — so the loop keeps self-consuming. Not clearing the plan is the right call, and the chain that makes it safe is real.
4. The negative-price guard — airtight on the sold-into hour, and one gap
Two-sided, on one series with a single sign changed:
all-negative day, dearest -0.0500 -> IDLE, "§17: the sell hour is priced BELOW ZERO"
same day, cheapest -0.3000 (buy hour) -> ARBITRAGE_CHARGE (buying is still fine)
same series, dearest flipped to +0.0500 -> ARBITRAGE_DISCHARGE
dearest exactly 0.0000 -> ARBITRAGE_DISCHARGE (zero is not negative)
dearest -0.000000001 -> IDLE (shipped code, correct)
The spread is identical across the flip, so the refusal is provably about the sign of the hour being sold into and not the spread rung in disguise.
The one survivor: if price < 0 weakened to if price < -0.001 goes green. The shipped comparison is correct — I verified -1e-9 refuses — but the boundary is pinned only from above. With the tolerance in place:
The battery pays to export, which is §17's named regression, live. -0.5 EUR/MWh is an ordinary small negative print, and for an all-in plugin it reaches this comparison directly. One probe at a small negative price, mirroring the 0.0000 case already there, closes it — the exact mirror of the cheapest/dearest gap I found on FCST-03.
Rulings on your honest reports
AC4 not met, not redefined — accept. No price history exists in the repo, and section 7 asserts the property that does not need one: 276 round trips each clearing its own widened requirement, 0 discharges into 522 negative hours. That is the same treatment TARIFF-01 and SAFETY-07 got and it is the right one. A synthetic year cannot establish profitability and you do not claim it does.
M7's demonstration is sound, and the right way to handle an unreachable guard. I checked the two facts it rests on:
_point_at(plan, cheapest.start) is cheapest -> True (identity, not equality)
starts are unique -> True (24 distinct)
So cheapest comes from the firm set and _point_at hands back that same object; here.provisional is False by construction at that call site. Demonstrating unreachability from the facts that cause it is honest, where a check that "passes" would not be. Keep the guard: its unreachability is contingent on _point_at returning by identity, and the day that changes is the day the guard earns its place.
The two self-caught defective checks — the age probe reading the constant under test, and M4's missing anchor — are the degeneracy rule and the anchor rule, both of which have bitten me too. Reporting them is worth more than not having made them.
The result for the owner, confirmed independently
realistic Belgian day/night day 0.3410 night 0.2570 spread 0.0840
shipped cycle_cost_threshold_eur 0.1000
threshold 0.1000 -> IDLE: "the firm spread is 0.0840 and this cycle needs 0.1000"
threshold 0.0500 -> ARBITRAGE_CHARGE at the verdict's own cheapest hour
At shipped defaults a static day/night site does not trade, and it trades once the threshold drops below its spread. That is a fourth independent arrival at the same number, and I confirmed it by measurement with nothing adjusted. It goes to the owner with FCST-03's 2.5/1.5 widening factors as one decision. Nobody should move a number to make it trade.
Ships inert, confirmed: arbitrage_enabled: false, the tick gated on enabled(), no numeric fallback in main.py, and Y6 (defaulting it on) goes RED in two suites.
My battery, run under TEST-15's discipline
python -B, PYTHONDONTWRITEBYTECODE=1, recursive purge, byte delta printed, and an assert that each mutation changed the text I meant. Seven mutations, six caught (Y1, Y3, Y4, Y5, Y6, Y7 — including Y7 at +0 B), one survivor (Y2, above).
That discipline earned its keep twice here. Once on Y7's +0 B delta. And once on me: my first Y2 probe reported the mutation as harmless, and the reason was that my path substitution had silently missed, so the probe ran the unmutated tree. I caught it because the arithmetic contradicted the result and I checked rather than wrote it up. Verifying that the tree you ran is the tree you mutated belongs in the discipline next to verifying the line changed.
Coordination with SAFETY-24
SAFETY-24 was still open when I merged, so OPT-04 went first. The conflict is not only CHANGELOG — git merge-tree gives:
So SAFETY-24 has one real code conflict to resolve, in validate.py, where both cards add refusals — both blocks want keeping. I have said so on #21.
**Merged as `8e589fe`.** This honours all three cards it sits on rather than re-implementing any of them, and the two things I most wanted to break — the priority interlock and the tick-purity assertion — both held under direct attack. One surviving mutation, on §17's own regression case, with a one-line fix I verified.
## Read-back
```
release/1.0 8e589fe parents a30bde1 + bdd7f18
tree 0d800e2b - byte-identical to the branch tree I reviewed
15 suites from merged release/1.0, -B with recursive __pycache__ purge: all pass
checks / unit success, "Successful in 3m16s", verified before merging
```
## 1. Idle files no claim — the reasoning holds against the arbiter, measured
I drove `resolve()` directly rather than reading the docstring:
```
claim(idle_decision) -> None
resolve(loop -1500 W, arbitrage 0 W idle at P=30) -> 0 W, winner arbitrage
resolve(loop -1500 W alone) -> -1500 W, winner loop
```
So the counterfactual in your docstring is exactly right: a 0 W `set` at 30 turns a self-consuming loop into a park order, with `winner=arbitrage`, every cycle the strategy has nothing to say. `sets` is sorted by priority descending and the highest wins, so an idle claim would out-rank `P_LOOP` unconditionally. **Y1** (making idle file a 0 W claim) goes **RED** on the check that names precisely this. That is the sharpest thing in the PR and it is correct.
## 2. P_ARBITRAGE = 30 is genuinely load-bearing
The test you asked for — a claim at 30 that would still win at 60 is not an interlock:
```
P_ARBITRAGE=30 (shipped) arbitrage 2500 W + peak LIMIT hi=800 @50 -> 800 W bound_by=peakguard
P_ARBITRAGE=60 (mutated) same claims -> 2500 W bound_by=None
identical floor filed at 20 (below 30) -> 2500 W bound_by=None
P_MAINT_SHAPE 40 hi=0 vs arbitrage 2500 -> 0 W bound_by=maintenance
```
At 60 the peak limit is **skipped entirely** by clause 2 (`c.priority < winner.priority`), so the capacity floor stops binding. The number is the mechanism, not a label. **Y7** raising it to 60 is **RED at +0 bytes** — the same-size shape that hides behind stale bytecode, caught.
## 3. The tick-purity assertion discriminates — on the direct forms
```
T1 await resolve_tariff(...) on the tick RED
T2 await self.hass.load_prices(...) (attribute) RED
T3 a fetch moved into read_inputs() RED
T4 tick awaits a helper that awaits the fetch green *** SURVIVED ***
```
The scanner is one-hop: `_awaited(cycle)` sees `_refresh_now`, not `resolve_tariff`. All three forms a person would actually reintroduce are caught, and the self-falsifier (*"the scanner really does find the awaits that ARE there"*) is the right habit. Closing T4 needs a call-graph walk — resolve awaited `.attr` names to functions in the same module and recurse — which is real machinery for a hazard that now requires deliberately writing a helper against two docstrings that forbid it. Worth knowing, not worth building today.
I also checked the first-match hazard you flagged: `_fn` returns the first `FunctionDef` of that name, and `main.py` has exactly one `cycle` and one `read_inputs`, so it is aimed correctly.
**The refresh-failure chain, driven end to end** rather than read:
```
resolve_tariff(dead feed) -> origin=fallback, provisional=True, firm points 0
decide(...) -> IDLE, "§8.3: only 0 of 24 hours carry a published price..."
claim(decision) -> None
```
A failed refresh degrades to §11.4's provisional fallback, which refuses arbitrage by itself, and the strategy files nothing — so the loop keeps self-consuming. Not clearing the plan is the right call, and the chain that makes it safe is real.
## 4. The negative-price guard — airtight on the sold-into hour, and one gap
Two-sided, on one series with a single sign changed:
```
all-negative day, dearest -0.0500 -> IDLE, "§17: the sell hour is priced BELOW ZERO"
same day, cheapest -0.3000 (buy hour) -> ARBITRAGE_CHARGE (buying is still fine)
same series, dearest flipped to +0.0500 -> ARBITRAGE_DISCHARGE
dearest exactly 0.0000 -> ARBITRAGE_DISCHARGE (zero is not negative)
dearest -0.000000001 -> IDLE (shipped code, correct)
```
The spread is identical across the flip, so the refusal is provably about the sign of the hour being sold into and not the spread rung in disguise.
**The one survivor: `if price < 0` weakened to `if price < -0.001` goes green.** The shipped comparison is correct — I verified `-1e-9` refuses — but the boundary is pinned only from above. With the tolerance in place:
```
dearest -0.0005 EUR/kWh (= -0.5 EUR/MWh) -> ARBITRAGE_DISCHARGE
```
The battery pays to export, which is §17's named regression, live. `-0.5 EUR/MWh` is an ordinary small negative print, and for an all-in plugin it reaches this comparison directly. **One probe at a small negative price, mirroring the `0.0000` case already there, closes it** — the exact mirror of the cheapest/dearest gap I found on FCST-03.
## Rulings on your honest reports
**AC4 not met, not redefined — accept.** No price history exists in the repo, and section 7 asserts the property that does not need one: 276 round trips each clearing its own widened requirement, **0 discharges into 522 negative hours**. That is the same treatment TARIFF-01 and SAFETY-07 got and it is the right one. A synthetic year cannot establish profitability and you do not claim it does.
**M7's demonstration is sound, and the right way to handle an unreachable guard.** I checked the two facts it rests on:
```
_point_at(plan, cheapest.start) is cheapest -> True (identity, not equality)
starts are unique -> True (24 distinct)
```
So `cheapest` comes from the firm set and `_point_at` hands back that same object; `here.provisional` is False by construction at that call site. Demonstrating unreachability from the facts that cause it is honest, where a check that "passes" would not be. Keep the guard: its unreachability is contingent on `_point_at` returning by identity, and the day that changes is the day the guard earns its place.
**The two self-caught defective checks** — the age probe reading the constant under test, and M4's missing anchor — are the degeneracy rule and the anchor rule, both of which have bitten me too. Reporting them is worth more than not having made them.
## The result for the owner, confirmed independently
```
realistic Belgian day/night day 0.3410 night 0.2570 spread 0.0840
shipped cycle_cost_threshold_eur 0.1000
threshold 0.1000 -> IDLE: "the firm spread is 0.0840 and this cycle needs 0.1000"
threshold 0.0500 -> ARBITRAGE_CHARGE at the verdict's own cheapest hour
```
**At shipped defaults a static day/night site does not trade**, and it trades once the threshold drops below its spread. That is a fourth independent arrival at the same number, and I confirmed it by measurement with nothing adjusted. It goes to the owner with FCST-03's 2.5/1.5 widening factors as one decision. Nobody should move a number to make it trade.
**Ships inert, confirmed**: `arbitrage_enabled: false`, the tick gated on `enabled()`, no numeric fallback in `main.py`, and **Y6** (defaulting it on) goes RED in two suites.
## My battery, run under TEST-15's discipline
`python -B`, `PYTHONDONTWRITEBYTECODE=1`, recursive purge, byte delta printed, and an assert that each mutation changed the text I meant. Seven mutations, six caught (Y1, Y3, Y4, Y5, Y6, Y7 — including Y7 at +0 B), one survivor (Y2, above).
That discipline earned its keep twice here. Once on Y7's +0 B delta. And once on me: my first Y2 probe reported the mutation as harmless, and the reason was that my path substitution had silently missed, so the probe ran the **unmutated** tree. I caught it because the arithmetic contradicted the result and I checked rather than wrote it up. Verifying that the tree you ran is the tree you mutated belongs in the discipline next to verifying the line changed.
## Coordination with SAFETY-24
SAFETY-24 was still open when I merged, so OPT-04 went first. **The conflict is not only CHANGELOG** — `git merge-tree` gives:
```
CONFLICT (content): goodwe_controller/CHANGELOG.md
CONFLICT (content): goodwe_controller/app/validate.py
DOCS.md, app/main.py, config.yaml auto-merge cleanly
```
So SAFETY-24 has one real code conflict to resolve, in `validate.py`, where both cards add refusals — both blocks want keeping. I have said so on #21.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Four cards built the price stack and nothing traded on it. This one does.
Where the strategy lives, and how it files its intent
goodwe_controller/app/arbitrage.py— not specs.md §2.2'sems/optimize/arbitrage.py, which the card's technical notes name and which does not exist. Everything ships inapp/, beside price.py, static_tariff.py, peakguard.py and arbiter.py, per the owner's 2026-08-30 decision; the module docstring says so at the top.decide()is a pure function andclaim()turns its verdict into one arbitersetclaim at a newP_ARBITRAGE = 30. Nothing here writes a setpoint. What is left for this file after the four cards is deliberately small — which hour is this, may the pack do it, how hard — and everything else is delegated.An idle decision files no claim, not a 0 W claim. A 0 W set at P_ARBITRAGE would out-rank the loop with a park order every cycle the strategy had nothing to say, i.e. silently switch self-consumption off on any site that armed arbitrage. Mutation M6 confirms the check bites.
How the background refresh keeps the tick clean
resolve_tariff()may open an HTTPS connection, and the control tick is what feeds the ESP32 heartbeat — layer 1 commands 0 W if that stops for ~30 s, so a supplier's TLS timeout on the tick is a supplier able to drive this add-on into the failsafe. So:Controller.run_tariff_refresh()is its own long-lived task, started fromamain()only whenarbitrage_enabledis on, refreshing everyTARIFF_REFRESH_S = 1800 s. It catches everything and keeps going, and a failed refresh does not clear the plan —resolve_tariff()already degrades to §11.4's provisional static fallback, which refuses arbitrage on its own, and that answer is better than no answer.Controller.fresh_tariff_plan(now)— an attribute read and an age comparison,TARIFF_MAX_AGE_S = 3 × refresh. A refresh task that has died leaves a plan that was true hours ago standing as if current; the bound retires it and arbitrage stops with it.test_arbitrage.pysection 8 asserts this structurally on the shipped AST:cycle()andread_inputs()await none ofresolve_tariff/load_prices/https_get, andrun_tariff_refresh()does. Mutation M14 puts a fetch back on the tick and it goes red.How peak-shaving priority is honoured through the existing interlock
Not re-implemented — reached twice, both through machinery that already shipped:
TariffPlan.arbitrage(peak_guard_active=…)is keyword-only and required, so a spread is unobtainable without answering §10.1's ordering.decide()passes it through and every argument ofdecide()is likewise defaultless.P_PEAK50 >P_ARBITRAGE30, so clause 2 applies peakguard's ownlofloor to an arbitrage charge that wins. The check drivespeakguard.claim()'s real output, not a stand-in, and asserts the identical floor filed below the arbitrage priority does not bind — so the ordering is what does the work, not luck. A discharge is not capped (§9.1 bills offtake only).And a third state neither mechanism can express. On
meter_source: offthe peak guard never runs, sopeak_guard_activeis notFalse— it is unknown.arbitrage.guard_state()carries that asNoneanddecide()refuses: charging at the rail creates the billed quantity while nothing measures the quarter it lands in. The conjunction lives inguard_state()rather than inline incycle(), forshaping_suspended()'s recorded reason — mutation M11 inlines it back and is red.The negative-price guard, and how it was proved
§17, on the hour being sold into, not on the spread. A day priced −0.30 … −0.05 has a 0.25 EUR/kWh spread that clears any shipped threshold and a dearest hour that is still negative; a guard written as "the spread is positive" or "not all prices are negative" exports there.
Proved two-sided, on the same series with exactly one price's sign flipped (asserted: the two runs differ in one element, by its sign): the negative one refuses citing §17 and the price, the positive one discharges. The fixture's spread is asserted to clear the threshold first, so §17 is what refuses rather than the spread rung. Buying in a negative hour is still permitted, exactly 0.0000 is not negative, and a full pack in a negative sell hour reports §17 rather than the SoC — the rule that actually forbids the trade is the one logged.
What AC4 could honestly be measured against
AC4 is not met, and it is not redefined.
scenarios/holds three gzipped captures and all three are P1 meter data. There is no EPEX archive, no supplier export, no price CSV anywhere in the tree. TARIFF-01 and SAFETY-07 both declined to restate an unmeetable criterion and both were upheld; this does the same.What section 7 does instead is the half that does not need real prices. Net positivity here is a property of the decision rule, not an empirical hope: every executed round trip has
sell − buy ≥ required ≥ threshold, so its benefit net of the threshold is non-negative, always. That is asserted per trade over a 365-day synthetic year: 276 completed round trips, 0 below threshold, 0 that missed their own widened requirement, 0 discharges into the 522 hours priced below zero. The euro figure (+114.35 EUR net of cycle cost) grades my generator, not the market, and the file says so in its own preamble.And the reported result the card asked for. At the shipped
cycle_cost_threshold_eur: 0.10, a realistic Belgian day/night profile (0.34 / 0.26, spread 0.08) does not trade — refused by the spread rung, with the same site trading as soon as the threshold drops below its spread, so the refusal is about the number and not about static tariffs being unsupported. No number in this PR was chosen to make arbitrage happen. That threshold and FCST-03's 2.5 / 1.5 widening factors are one product decision and it is the owner's.The accounting basis, and the term deliberately absent
The card's notes require crediting displacement at all-in import prices, not at the export price. Both hours compared here are all-in consumption prices, and
export_price_eur_kwhis not read by this module at all — §9.3's near-zero Belgian injection price is exactly why the sound trade is charge-cheap-to-displace-expensive-import.The notes also say of round-trip efficiency: "η is not a §14 key — it needs one, or must be derived from the state store. Do not hardcode it." So there is no η term. There is no key, nothing measured to derive one from, and a hardcoded 0.85 would also double-count, because
cycle_cost_threshold_euris already defined as what a round trip costs per kWh. When a measured η exists it is a second term beside the threshold, and the arithmetic is one subtraction in one place, ready for it.Hysteresis (§10.2)
The notes ask for a bounded transition count under an oscillating price. Section 4b replays a day whose price crosses the threshold every hour (asserted) at the 30 s control cadence: 2 action transitions over 2880 ticks, and non-zero, with both actions occurring. No state machine or dwell timer was needed — the decision is which hour is this, and an hour is 3600 s wide; SoC is monotone inside one in the only direction that matters.
CFG-03 and rule 7
CFG-03 was not widened. Four option keys were added (
arbitrage_enabled,cycle_cost_threshold_eur,arbitrage_soc_floor,arbitrage_soc_ceiling) and they live inprice.py'sPRICE_OPTION_DEFAULTS— TARIFF-02's single in-code mirror, imported by validate.py, followed by TARIFF-03 and FCST-03, and named by FCST-03's own note as wherecycle_cost_threshold_eurwould land. config.yaml stays the source of truth and test_price.py section 1 pins the two together key for key; main.py gained noo.get(key, <a number>)fallback, asserted.test_price_forecast.pysection 7's "FCST-03 added ZERO option keys" line was a drift pin, not a ban — it now names OPT-04's four and additionally asserts the nine older keys survived, so the addition is a signature rather than a silent widening.Rule 7's AST tripwire did NOT go red, and the wording was fixed anyway.
test_price.pysection 12 pins the set of modules that consumeload_prices(); this card consumesresolve_tariff(), so the pinned list is still exactly["static_tariff.py"]. But the sentence the tripwire exists to keep honest — "main.py does NOT yet call resolve_tariff() … so a configured PROVIDER still shapes no energy decision" — became false the moment the background refresh landed. It is rewritten in the same commit to say what is true now, it namesapp/arbitrage.py, and section 9 asserts the expired phrase is gone and the consumer is named. Nothing was silenced.Three new refusals in rule 7, none of which can break an existing install (the option is new and ships
false): arbitrage armed with no price source at all; arbitrage armed withmeter_source: off; and a SoC floor at or above the ceiling. Plus a threshold sanity check that runs whether or not arbitrage is armed — a negative cost is a threshold every spread clears.Checks
New:
goodwe_controller/test_arbitrage.py, ~80 assert-based checks in house style. All 15 suites green, each run asfind . -name __pycache__ -type d -exec rm -rf {} + ; python3 -B test_X.py(TEST-15).Mutation register — 13 of 14 caught, run under
-Bwith caches purged, byte delta printedM5's +0 byte delta is the shape TEST-15 warns about, which is why every run purges bytecode and uses
-B; it is caught.M7 is a real survivor and it is reported, not hidden. Disabling
_act's own provisional guard changes no answer, and it cannot: the verdict'scheapest/dearestare firm by_firm_or_die, starts are unique within a series, and the pointat()returns for a matched instant is therefore the same object. Section 4 now asserts that identity and that uniqueness, so the unreachability is demonstrated rather than assumed — the same honest shape peakguard's seventh mutation takes. The guard stays because it is the only thing standing if a future caller hands_acta plan the verdict did not come from.Two check defects were found by mutating my own checks and fixed before submitting: the age-bound probe read
TARIFF_MAX_AGE_Sitself, so widening the constant moved the probe with it (M10 survived the first draft — it now probes at an absolute 12 hours and bounds the constant separately); and M4's first anchor did not exist, i.e. the mutation had not actually been applied.Not done / open
arbitrage_soc_flooris this strategy's own reserve, not that card's absolute floor, and it is deliberately not themaintenance_soc_*keys.arbitrage_enabledshipsfalse.Merged as
8e589fe. This honours all three cards it sits on rather than re-implementing any of them, and the two things I most wanted to break — the priority interlock and the tick-purity assertion — both held under direct attack. One surviving mutation, on §17's own regression case, with a one-line fix I verified.Read-back
1. Idle files no claim — the reasoning holds against the arbiter, measured
I drove
resolve()directly rather than reading the docstring:So the counterfactual in your docstring is exactly right: a 0 W
setat 30 turns a self-consuming loop into a park order, withwinner=arbitrage, every cycle the strategy has nothing to say.setsis sorted by priority descending and the highest wins, so an idle claim would out-rankP_LOOPunconditionally. Y1 (making idle file a 0 W claim) goes RED on the check that names precisely this. That is the sharpest thing in the PR and it is correct.2. P_ARBITRAGE = 30 is genuinely load-bearing
The test you asked for — a claim at 30 that would still win at 60 is not an interlock:
At 60 the peak limit is skipped entirely by clause 2 (
c.priority < winner.priority), so the capacity floor stops binding. The number is the mechanism, not a label. Y7 raising it to 60 is RED at +0 bytes — the same-size shape that hides behind stale bytecode, caught.3. The tick-purity assertion discriminates — on the direct forms
The scanner is one-hop:
_awaited(cycle)sees_refresh_now, notresolve_tariff. All three forms a person would actually reintroduce are caught, and the self-falsifier ("the scanner really does find the awaits that ARE there") is the right habit. Closing T4 needs a call-graph walk — resolve awaited.attrnames to functions in the same module and recurse — which is real machinery for a hazard that now requires deliberately writing a helper against two docstrings that forbid it. Worth knowing, not worth building today.I also checked the first-match hazard you flagged:
_fnreturns the firstFunctionDefof that name, andmain.pyhas exactly onecycleand oneread_inputs, so it is aimed correctly.The refresh-failure chain, driven end to end rather than read:
A failed refresh degrades to §11.4's provisional fallback, which refuses arbitrage by itself, and the strategy files nothing — so the loop keeps self-consuming. Not clearing the plan is the right call, and the chain that makes it safe is real.
4. The negative-price guard — airtight on the sold-into hour, and one gap
Two-sided, on one series with a single sign changed:
The spread is identical across the flip, so the refusal is provably about the sign of the hour being sold into and not the spread rung in disguise.
The one survivor:
if price < 0weakened toif price < -0.001goes green. The shipped comparison is correct — I verified-1e-9refuses — but the boundary is pinned only from above. With the tolerance in place:The battery pays to export, which is §17's named regression, live.
-0.5 EUR/MWhis an ordinary small negative print, and for an all-in plugin it reaches this comparison directly. One probe at a small negative price, mirroring the0.0000case already there, closes it — the exact mirror of the cheapest/dearest gap I found on FCST-03.Rulings on your honest reports
AC4 not met, not redefined — accept. No price history exists in the repo, and section 7 asserts the property that does not need one: 276 round trips each clearing its own widened requirement, 0 discharges into 522 negative hours. That is the same treatment TARIFF-01 and SAFETY-07 got and it is the right one. A synthetic year cannot establish profitability and you do not claim it does.
M7's demonstration is sound, and the right way to handle an unreachable guard. I checked the two facts it rests on:
So
cheapestcomes from the firm set and_point_athands back that same object;here.provisionalis False by construction at that call site. Demonstrating unreachability from the facts that cause it is honest, where a check that "passes" would not be. Keep the guard: its unreachability is contingent on_point_atreturning by identity, and the day that changes is the day the guard earns its place.The two self-caught defective checks — the age probe reading the constant under test, and M4's missing anchor — are the degeneracy rule and the anchor rule, both of which have bitten me too. Reporting them is worth more than not having made them.
The result for the owner, confirmed independently
At shipped defaults a static day/night site does not trade, and it trades once the threshold drops below its spread. That is a fourth independent arrival at the same number, and I confirmed it by measurement with nothing adjusted. It goes to the owner with FCST-03's 2.5/1.5 widening factors as one decision. Nobody should move a number to make it trade.
Ships inert, confirmed:
arbitrage_enabled: false, the tick gated onenabled(), no numeric fallback inmain.py, and Y6 (defaulting it on) goes RED in two suites.My battery, run under TEST-15's discipline
python -B,PYTHONDONTWRITEBYTECODE=1, recursive purge, byte delta printed, and an assert that each mutation changed the text I meant. Seven mutations, six caught (Y1, Y3, Y4, Y5, Y6, Y7 — including Y7 at +0 B), one survivor (Y2, above).That discipline earned its keep twice here. Once on Y7's +0 B delta. And once on me: my first Y2 probe reported the mutation as harmless, and the reason was that my path substitution had silently missed, so the probe ran the unmutated tree. I caught it because the arithmetic contradicted the result and I checked rather than wrote it up. Verifying that the tree you ran is the tree you mutated belongs in the discipline next to verifying the line changed.
Coordination with SAFETY-24
SAFETY-24 was still open when I merged, so OPT-04 went first. The conflict is not only CHANGELOG —
git merge-treegives:So SAFETY-24 has one real code conflict to resolve, in
validate.py, where both cards add refusals — both blocks want keeping. I have said so on #21.