FCST-03: provisional-price flagging, and what may not rest on one #20

Merged
admin merged 2 commits from FCST-03 into release/1.0 2026-08-31 00:23:41 +02:00
Owner

What this is

§8.3: until the day-ahead print lands (~14–16 h CET) the unknown hours use yesterday's profile and are flagged provisional: true, and no irreversible action may be taken on a provisional price. Real-time / imbalance products get widened arbitrage thresholds.

Built in goodwe_controller/app/, per the PM decision of 2026-08-30 — the card's functional notes name ems/ingest/prices/, which does not exist. Behaviour authoritative, paths not.

The defect this is shaped by, and why it is the SAFETY-18 shape

A carried-forward point and a published one are the same shape — three fields and a bool. The natural fill is:

PricePoint(start=t, eur_per_kwh=y.eur_per_kwh, provisional=y.provisional)

which copies yesterday's False onto today's guess. It reads fine, type-checks, round-trips through §9.3's JSON, and every price on the status page looks like a price. That is SAFETY-18's frozen-but-fresh meter one layer up: the reading arrives on time, looks fresh, and is wrong — and a freshness check that only asks "did something arrive" cannot see it. A stale-but-plausible price is exactly the input that turns an arbitrage decision into a loss with nothing in the log to say so.

So the flag is not passed through anywhere:

  • carried points are built by a constructor with no provisional parameter — the literal True exists in one place, so there is nothing to forget, default, or copy;
  • published points are never copied: they pass through by identity, so no copy exists that could lose or gain a flag (filled.points[i] is fetched.points[j] is checked);
  • _audit() re-derives the expected flag for every emitted point from the set of hours the feed published, and raises — it never repairs, because a module that quietly corrects its own provenance has no provenance;
  • the check walks the module's own AST and fails if any PricePoint(...) in it takes provisional from an expression, so the pass-through idiom cannot return in a later edit by looking ordinary. (AST, not a substring: the docstring quotes the defect.)

⚠️ "Published" is not "firm." eneco_eddy's current-quarter imbalance print is genuinely provisional and says so, so the audit's rule for a published hour is "whatever the feed said", never "False". A check that forced published points to False would be a bug wearing a guarantee.

The fill keys on the Brussels wall clock, not t − 24 h: a 23-hour day has no 02:00 and a 25-hour day has two. Both 2026 transitions are driven, and the check first asserts that the two mappings disagree there before grading the right one. A late publication rebuilds the series, so the number and the flag move together (§5.4).

AC2 and AC3 landed on TARIFF-03's existing surface, not beside it

  • Refusal 3 rewritten, not added to: "any point is provisional" → "fewer than two firm hours". The old rule would have switched arbitrage off every morning on every dynamic site, since a part-firm series is the normal pre-print state. §8.3 forbids acting on a provisional price, so the provisional hours are dropped and spread / cheapest / dearest come from published hours only. A wholly provisional series (the §11.4 fallback) still has no firm pair and is still refused — the previous behaviour by a truer route. The permitting variant now refuses to be constructed around a provisional point at all.
  • Refusal 5, new: the spread must clear a widened threshold. One widening path driven by a set of uncertainty sources — the product's own (§9.2 row 2, from the declared PriceProduct, never a plugin id) and §11.4's NO_WEATHER, the same mechanism reached by a different trigger. Combination is max, not a product: two independent doubts must not compound into a threshold nothing can clear.
  • arbitrage() now takes min_spread_eur_kwh and uncertainty as further required keyword arguments, so §10.1's priority 2 > 3 property is preserved and joined by two more questions that cannot be skipped. OPT-04 still owns what the base number is.
  • AC2's named example is actually rejected: begin_phase() runs a maintenance drain start through the gate; on a series whose 12-hour window reaches into carried hours it is blocked and the phase machine is still idle, and on a fully published one it is permitted and moves to drain. The commitment window is derived from maintenance.DRAIN_TIMEOUT_MIN, not typed twice.
  • ⚠️ It is the price-justified entry point only. The calendar path main.py runs today is untouched: a gate that blocked on no_data would stop the monthly cycle on every default install (price_provider: off), which is §12's opening sentence.

"Measurably wider", measured

Not a constant read back out of the module that defines it. The check bisects the accept/refuse boundary of the real verdict to find the smallest spread each configuration will trade on: day-ahead 0.104000, real-time 0.260000 — a measured factor of 2.500, compared afterwards with the declared one. With both sources active the measured threshold matches max and is shown not to match the product (3.75) or the sum (4.0). The two factors are deliberately different, or those three rules would be one number. The widening is also shown to follow the declared product and not the provider id, in both directions.

Housekeeping

  • CFG-03 not widened: zero new options. No config.yaml key, no second defaults dict, no o.get(k, X) in main.py — the new module reads no options at all. This follows TARIFF-02's pattern (one in-code mirror, PRICE_OPTION_DEFAULTS, imported by validate.py) by adding nothing to it; when OPT-04 lands cycle_cost_threshold_eur, that is where it goes. Checked.
  • validate.py rule 7's tripwire is NOT tripped. The new module deliberately does not fetch — it transforms a read somebody else obtained — so the pinned load_prices consumer list is still ["static_tariff.py"] and rule 7's wording is still accurate. A check in the new file asserts that, so the reason is on the record rather than incidental.
  • TEST-14's hole not inherited. The signature scan is ported and widened to cover Gate's constructors and Arbitrage's — _Refused.reason gaining a default is now red, where it survived every check in that file before.
  • Fixtures are six-decimal with distinct 3-, 4- and 6-dp roundings, not TARIFF-03's degenerate 3-dp pair.

Checks

test_price_forecast.py, 66 checks, plus the existing suite (all 14 files pass; test_static_tariff.py's 10 arbitrage() call sites updated for the new required arguments, nothing weakened). Mutations run against my own checks — carried flag flipped to False, published point copied instead of passed by identity, firm filter removed, max → product, uncertainty_of never widening, the gate ignoring the flag, cheap_now back to whole-plan, a default added to Gate.block, the fill keyed on t − 24 h, the _Allowed firm guard removed — all ten caught.

Not in scope

The optimizer (OPT-04): this supplies the flag, the refusals and the widened threshold, and decides no trades. Nothing wires the fill or the gate into main.py's tick — a provider fetch is network I/O on the heartbeat path, and that background refresh is OPT-04's, as resolve_tariff's docstring already says.

🤖 Generated with Claude Code

https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC

## What this is §8.3: until the day-ahead print lands (~14–16 h CET) the unknown hours use yesterday's profile and are flagged `provisional: true`, and **no irreversible action may be taken on a provisional price**. Real-time / imbalance products get widened arbitrage thresholds. Built in `goodwe_controller/app/`, per the PM decision of 2026-08-30 — the card's functional notes name `ems/ingest/prices/`, which does not exist. Behaviour authoritative, paths not. ## The defect this is shaped by, and why it is the SAFETY-18 shape A carried-forward point and a published one are the **same shape** — three fields and a bool. The natural fill is: ```python PricePoint(start=t, eur_per_kwh=y.eur_per_kwh, provisional=y.provisional) ``` which copies yesterday's `False` onto today's guess. It reads fine, type-checks, round-trips through §9.3's JSON, and every price on the status page looks like a price. **That is SAFETY-18's frozen-but-fresh meter one layer up**: the reading arrives on time, looks fresh, and is wrong — and a freshness check that only asks "did something arrive" cannot see it. A stale-but-plausible price is exactly the input that turns an arbitrage decision into a loss with nothing in the log to say so. So the flag is not passed through anywhere: * **carried points are built by a constructor with no `provisional` parameter** — the literal `True` exists in one place, so there is nothing to forget, default, or copy; * **published points are never copied**: they pass through **by identity**, so no copy exists that could lose or gain a flag (`filled.points[i] is fetched.points[j]` is checked); * **`_audit()` re-derives** the expected flag for every emitted point from the set of hours the feed published, and **raises** — it never repairs, because a module that quietly corrects its own provenance has no provenance; * the check **walks the module's own AST** and fails if any `PricePoint(...)` in it takes `provisional` from an expression, so the pass-through idiom cannot return in a later edit by looking ordinary. (AST, not a substring: the docstring quotes the defect.) ⚠️ **"Published" is not "firm."** `eneco_eddy`'s current-quarter imbalance print is genuinely provisional and says so, so the audit's rule for a published hour is "whatever the feed said", never "False". A check that forced published points to `False` would be a bug wearing a guarantee. The fill keys on the **Brussels wall clock**, not `t − 24 h`: a 23-hour day has no 02:00 and a 25-hour day has two. Both 2026 transitions are driven, and the check first asserts that the two mappings *disagree* there before grading the right one. A late publication rebuilds the series, so the number and the flag move together (§5.4). ## AC2 and AC3 landed on TARIFF-03's existing surface, not beside it * **Refusal 3 rewritten**, not added to: "any point is provisional" → "fewer than two **firm** hours". The old rule would have switched arbitrage off every morning on every dynamic site, since a part-firm series is the *normal* pre-print state. §8.3 forbids acting **on** a provisional price, so the provisional hours are dropped and spread / cheapest / dearest come from published hours only. A wholly provisional series (the §11.4 fallback) still has no firm pair and is still refused — the previous behaviour by a truer route. The permitting variant now refuses to be **constructed** around a provisional point at all. * **Refusal 5, new**: the spread must clear a widened threshold. One widening path driven by a set of uncertainty sources — the product's own (§9.2 row 2, from the declared `PriceProduct`, never a plugin id) and §11.4's `NO_WEATHER`, the same mechanism reached by a different trigger. **Combination is `max`, not a product**: two independent doubts must not compound into a threshold nothing can clear. * `arbitrage()` now takes `min_spread_eur_kwh` and `uncertainty` as further **required keyword arguments**, so §10.1's priority 2 > 3 property is preserved and joined by two more questions that cannot be skipped. OPT-04 still owns what the base number *is*. * **AC2's named example is actually rejected**: `begin_phase()` runs a maintenance drain start through the gate; on a series whose 12-hour window reaches into carried hours it is blocked and **the phase machine is still `idle`**, and on a fully published one it is permitted and moves to `drain`. The commitment window is derived from `maintenance.DRAIN_TIMEOUT_MIN`, not typed twice. * ⚠️ **It is the price-justified entry point only.** The calendar path main.py runs today is untouched: a gate that blocked on `no_data` would stop the monthly cycle on every default install (`price_provider: off`), which is §12's opening sentence. ## "Measurably wider", measured Not a constant read back out of the module that defines it. The check **bisects the accept/refuse boundary of the real verdict** to find the smallest spread each configuration will trade on: day-ahead 0.104000, real-time 0.260000 — a measured factor of 2.500, compared afterwards with the declared one. With both sources active the measured threshold matches `max` and is shown *not* to match the product (3.75) or the sum (4.0). The two factors are deliberately different, or those three rules would be one number. The widening is also shown to follow the **declared product** and not the provider id, in both directions. ## Housekeeping * **CFG-03 not widened: zero new options.** No `config.yaml` key, no second defaults dict, no `o.get(k, X)` in main.py — the new module reads no options at all. This follows TARIFF-02's pattern (one in-code mirror, `PRICE_OPTION_DEFAULTS`, imported by `validate.py`) by adding nothing to it; when OPT-04 lands `cycle_cost_threshold_eur`, that is where it goes. Checked. * **validate.py rule 7's tripwire is NOT tripped.** The new module deliberately does not fetch — it transforms a read somebody else obtained — so the pinned `load_prices` consumer list is still `["static_tariff.py"]` and rule 7's wording is still accurate. A check in the new file asserts that, so the reason is on the record rather than incidental. * **TEST-14's hole not inherited.** The signature scan is ported and widened to cover `Gate`'s constructors **and `Arbitrage`'s** — `_Refused.reason` gaining a default is now red, where it survived every check in that file before. * Fixtures are six-decimal with distinct 3-, 4- and 6-dp roundings, not TARIFF-03's degenerate 3-dp pair. ## Checks `test_price_forecast.py`, 66 checks, plus the existing suite (all 14 files pass; `test_static_tariff.py`'s 10 `arbitrage()` call sites updated for the new required arguments, nothing weakened). Mutations run against my own checks — carried flag flipped to `False`, published point copied instead of passed by identity, firm filter removed, `max` → product, `uncertainty_of` never widening, the gate ignoring the flag, `cheap_now` back to whole-plan, a default added to `Gate.block`, the fill keyed on `t − 24 h`, the `_Allowed` firm guard removed — **all ten caught**. ## Not in scope The optimizer (OPT-04): this supplies the flag, the refusals and the widened threshold, and decides no trades. Nothing wires the fill or the gate into main.py's tick — a provider fetch is network I/O on the heartbeat path, and that background refresh is OPT-04's, as `resolve_tariff`'s docstring already says. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
admin added 1 commit 2026-08-30 23:55:40 +02:00
§8.3 fills the hours a day-ahead feed has not published yet with yesterday's
profile and flags them provisional, and forbids any irreversible action on such
a price. The whole risk of this ticket is one line - a carried point and a
published point are three fields and a bool, so `provisional=y.provisional`
copies yesterday's False onto today's guess and produces stale data presented as
current. That is SAFETY-18's frozen-but-fresh meter one layer up.

So the flag is never passed through. Carried points come from a constructor with
no provisional parameter; published points pass through BY IDENTITY, so no copy
exists that could lose one; an audit re-derives the expected flag for every
emitted point and raises rather than repairing; and the check walks the module's
own AST so the pass-through idiom cannot come back looking ordinary. A published
point may itself be provisional (eneco_eddy's imbalance print is), so the rule
for a published hour is "whatever the feed said", never False.

The fill keys on the Brussels wall clock rather than t-24h, so the 23- and
25-hour days are right; a late publication rebuilds the series, moving the
number and the flag together.

AC2 and AC3 landed on TARIFF-03's existing verdict surface rather than beside
it. Refusal 3 became "fewer than two FIRM hours" - refusing on "any point is
provisional" would have switched arbitrage off every morning on every dynamic
site - and refusal 5 is new: the spread must clear a threshold widened by the
widest active uncertainty source (max, never a product). The permitting variant
now refuses to be constructed around a provisional point at all, and the
cheap-window flag became per-hour for the same reason.

The irreversible action is a value carrying the hours it commits, derived from
maintenance.py's own DRAIN_TIMEOUT_MIN; the gate blocks a maintenance drain
whose window reaches into a carried hour, and the phase machine stays idle. It
is the price-justified entry point only - a gate that blocked on no_data would
stop the monthly cycle on every default install.

Zero new config options; TEST-14's signature scan ported to cover both Gate's
and Arbitrage's constructors, closing _Refused.reason's open hole.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
Author
Owner

Content with the code — but I cannot merge it: SAFETY-07 landed while this was in review, so mergeable: false on a CHANGELOG.md conflict. Everything I was asked to verify holds, including the behavioural change to a file I merged. Eight mutations of my own: two real gaps, both one-liners, one benign survivor I am recording rather than dressing up.

Blocked, and on what

PR #20 head dd40c56  mergeable: false   base e7c95bf
release/1.0          e7c95bf SAFETY-07 (#18)
conflict             goodwe_controller/CHANGELOG.md   (only that file)
checks / unit        success - "Successful in 2m43s"  (run 544, on dd40c56)

The green is real but it is on a head that predates SAFETY-07, so it will need to run again after you integrate. Integrate rather than rebase (force-push is blocked and the reviewed SHAs stay addressable), and I will merge on the next pass. Your predicted surface was exactly right — config.yaml and validate.py untouched, and SAFETY-07 touched peakguard.py / cycle() / the arbiter, none of which you go near, so CHANGELOG.md is the whole of it.

The four provisional mechanisms

The AST scan discriminates and carries its own falsifier, which is what I most wanted to see:

provisional=y.provisional  (the named defect)  FLAGGED
provisional omitted entirely                   FLAGGED
positional third arg                           FLAGGED
provisional=bool(x)                            FLAGGED
provisional=True  (correct)                    missed  <- as intended

and the in-file check that the scan fails on its own named defect is the right habit.

One scope note, not a request: it matches ast.Call with func.id == "PricePoint", so an alias (PP(...)) or an attribute form (price.PricePoint(...)) slips past. The module imports from .price import PricePoint and all 3 call sites use the bare name, so nothing is uncovered today; adding ast.Attribute with attr == "PricePoint" would close the realistic half of that if you are ever in there.

Identity pass-through, _carried() having no provisional parameter, and _audit() raising rather than repairing all check out, and I like that "published" is deliberately not "firm" — eneco_eddy keeping the feed's own flag is the right call and it is asserted.

Refusal 3: the §11.4 path really does still refuse

This is the change to a file I merged, so I drove the real path rather than reading the new checks — resolve_tariff() with a dead transport, against the shipped static profile:

origin      fallback        has_prices True     provisional True
spread      0.084           firm points 0
arbitrage   refused - "§8.3: only 0 of 24 hours carry a published price..."

contrast: the CONFIGURED static product (not provisional)
origin      static          firm points 24      arbitrage allowed

So the fallback still refuses, and by a truer route than the rule it replaced: it has a real 0.084 spread and 24 points, and what stops it is having no firm pair — with the reason counting them. The old "any point is provisional" would have refused this for a reason that was about to become wrong for every dynamic site every morning. I am satisfied the rewrite preserved the guarantee rather than trading it away.

The ten updated call sites in test_static_tariff.py are all min_spread_eur_kwh=0.0, uncertainty=frozenset() — the neutral choice, which makes the new rung 5 unable to fire and leaves every pre-existing assertion testing exactly what it tested before. No expectation flipped, none deleted. That is how to update call sites.

The interlock survived, and got stronger

arbitrage() no args / one / two          TypeError, naming the missing ones
all three positionally                   TypeError
peak_guard_active="no"                   TypeError - "A truthy 'unknown' is how..."
min_spread_eur_kwh=-1                    TypeError
uncertainty={"weather"} (strings)        TypeError
peak guard ON, everything else valid     refused
Arbitrage.allow(cheapest=PROVISIONAL)    ValueError
Arbitrage.allow(dearest=PROVISIONAL)     ValueError

§10.1's priority 2 > 3 still cannot be skipped, and _Allowed genuinely refuses construction on either role.

AC3: the degeneracy discipline holds on the policy rule

Section 0 asserts the factors differ so max / sum / product are three distinguishable answers, and the measurement then lands on 2.500 with 3.75 and 4.0 explicitly excluded. max -> min goes RED on exactly that check; dropping the widening or emptying uncertainty_of goes RED on the measured-factor pair. Widening follows the declared PriceProduct in both directions and not the provider id. That is the discipline applied to a policy rule and it works.

I am also glad to see section 0 carrying my TARIFF-03 finding forward — a fixture price that rounds to three different numbers at 3, 4 and 6 decimals. That is the fix I asked for, generalised.

AC2 is real, not incidental

The blocked drain leaves the phase machine still idle, the same transition on a fully published series moves to drain, and the permitting arm carries one firm point per committed hour. The rejection is of the action, not of a verdict object.

Two real gaps, one benign survivor

mutation result
X1 _firm_or_die checks only cheapest, not dearest SURVIVED
X6 firm_points() falls back to all points when none are firm SURVIVED
X5 MAINTENANCE_DRAIN window hard-coded 12.0 instead of derived survived, benign
S1 static_tariff._Refused.reason gains a default RED — the ported scan bites
X2/X3/X4 widening max->min, no widening, empty uncertainty_of RED
X7 peak_guard_active bool guard dropped RED

X1 is the one worth fixing. The check says "an ALLOWED verdict cannot be constructed around a provisional hour" but passes _prov_pt as both cheapest and dearest, so it cannot tell which role is guarded. The shipped code guards both — I proved that directly above — so this is coverage, not a defect. Two calls, (firm, prov) and (prov, firm), close it.

X6 is real but its blast radius is nil, and I checked rather than assumed. With firm_points() over-reporting 24 on a wholly-provisional fallback, the §11.4 refusal still fires (the ladder counts firm hours by its own expression) and cheap_now still says no hour is cheap (it guards per hour on here.provisional). So the two things that could have moved energy both hold independently. One assert — firm_points() on a wholly-provisional series is empty — closes it.

X5 I am reporting as not a finding. It survives because DRAIN_TIMEOUT_MIN / 60.0 is 12.0 today, so a literal is indistinguishable from the derivation while they coincide. But the check does catch the case it names: change the timeout and a hard-coded window goes red. A value-preserving refactor passing is the correct outcome, so I am not asking for anything.

The module-scope aborts: acceptable, and here is why

I forced one to see what it looks like rather than reasoning about it:

exit code 1     traceback naming the exception     named FAIL lines: 0

That is a failure signal CI cannot miss — it gates on exit code, so an abort can never be read as a pass. This is not the "verdict that is not a verdict" this project keeps hitting; that defect is a check that passes while observing nothing, and this is the opposite. The cost is diagnostic: you learn something raised rather than which three properties broke, and every section after it stops running. The file already owns the fix — derived() turns a raise into a named FAIL and is used elsewhere — so building the module-scope fixture through it, or lazily, would upgrade the signal. Worth doing, not worth blocking.

TEST-14 can be narrowed to riglock alone

I enumerated every _Outcome subclass across app/ rather than taking the coverage on trust:

app.price          PriceRead / _Series / _NoData        scanned by test_price.py
app.static_tariff  Arbitrage / _Allowed / _Refused      scanned here (new)
app.price_forecast Gate / _Permitted / _Blocked         scanned here (new)

Three families, all three scanned, no fourth. So yes — with this merged, the only unscanned outcome type left is riglock.py's _Unread in admin/goodwe, and TEST-14 can be narrowed to that. My two conditions from last time still stand for the port: the AST form, and re-run riglock's own mutations against the ported version rather than assuming the pattern travels.

The two widening factors

I would send them to the owner together with the finding already surfaced — that a realistic 0.08 static spread sits below the shipped 0.10 cycle_cost_threshold_eur. They are the same decision wearing two hats: what should this system actually do on a static tariff, and how much wider should a real-time product have to be before it trades. Both are policy, both are currently unwired, and answering one without the other risks a default that only makes sense against the other's default. Not a blocker here — nothing consumes either number yet, and the module says plainly that the spec supplies none.

What I could not verify

Nothing here reaches a real supplier feed, so the provider-backed half is fixture-only — unchanged from TARIFF-02 and TARIFF-03 and stated in the file. And the widening factors are policy defaults, so "measurably wider" is verified but "wider by the right amount" is not verifiable from anything in the repo.

Integrate the CHANGELOG, let CI report on the new head, and I will merge. Good work — the §11.4 rewrite is a genuine improvement on what I merged, not just a compatible one.

**Content with the code — but I cannot merge it: SAFETY-07 landed while this was in review, so `mergeable: false` on a `CHANGELOG.md` conflict.** Everything I was asked to verify holds, including the behavioural change to a file I merged. Eight mutations of my own: two real gaps, both one-liners, one benign survivor I am recording rather than dressing up. ## Blocked, and on what ``` PR #20 head dd40c56 mergeable: false base e7c95bf release/1.0 e7c95bf SAFETY-07 (#18) conflict goodwe_controller/CHANGELOG.md (only that file) checks / unit success - "Successful in 2m43s" (run 544, on dd40c56) ``` The green is real but it is on a head that predates SAFETY-07, so it will need to run again after you integrate. Integrate rather than rebase (force-push is blocked and the reviewed SHAs stay addressable), and I will merge on the next pass. **Your predicted surface was exactly right** — `config.yaml` and `validate.py` untouched, and SAFETY-07 touched `peakguard.py` / `cycle()` / the arbiter, none of which you go near, so `CHANGELOG.md` is the whole of it. ## The four provisional mechanisms **The AST scan discriminates and carries its own falsifier**, which is what I most wanted to see: ``` provisional=y.provisional (the named defect) FLAGGED provisional omitted entirely FLAGGED positional third arg FLAGGED provisional=bool(x) FLAGGED provisional=True (correct) missed <- as intended ``` and the in-file check that the scan fails on its own named defect is the right habit. One scope note, not a request: it matches `ast.Call` with `func.id == "PricePoint"`, so an alias (`PP(...)`) or an attribute form (`price.PricePoint(...)`) slips past. The module imports `from .price import PricePoint` and all **3** call sites use the bare name, so nothing is uncovered today; adding `ast.Attribute` with `attr == "PricePoint"` would close the realistic half of that if you are ever in there. Identity pass-through, `_carried()` having no `provisional` parameter, and `_audit()` raising rather than repairing all check out, and I like that "published" is deliberately not "firm" — `eneco_eddy` keeping the feed's own flag is the right call and it is asserted. ## Refusal 3: the §11.4 path really does still refuse This is the change to a file I merged, so I drove the real path rather than reading the new checks — `resolve_tariff()` with a dead transport, against the shipped static profile: ``` origin fallback has_prices True provisional True spread 0.084 firm points 0 arbitrage refused - "§8.3: only 0 of 24 hours carry a published price..." contrast: the CONFIGURED static product (not provisional) origin static firm points 24 arbitrage allowed ``` So the fallback still refuses, and by a **truer route than the rule it replaced**: it has a real 0.084 spread and 24 points, and what stops it is having no firm pair — with the reason counting them. The old "any point is provisional" would have refused this for a reason that was about to become wrong for every dynamic site every morning. I am satisfied the rewrite preserved the guarantee rather than trading it away. The ten updated call sites in `test_static_tariff.py` are all `min_spread_eur_kwh=0.0, uncertainty=frozenset()` — the neutral choice, which makes the new rung 5 unable to fire and leaves every pre-existing assertion testing exactly what it tested before. No expectation flipped, none deleted. That is how to update call sites. ## The interlock survived, and got stronger ``` arbitrage() no args / one / two TypeError, naming the missing ones all three positionally TypeError peak_guard_active="no" TypeError - "A truthy 'unknown' is how..." min_spread_eur_kwh=-1 TypeError uncertainty={"weather"} (strings) TypeError peak guard ON, everything else valid refused Arbitrage.allow(cheapest=PROVISIONAL) ValueError Arbitrage.allow(dearest=PROVISIONAL) ValueError ``` §10.1's priority 2 > 3 still cannot be skipped, and `_Allowed` genuinely refuses construction on either role. ## AC3: the degeneracy discipline holds on the policy rule Section 0 asserts the factors differ so `max` / `sum` / `product` are three distinguishable answers, and the measurement then lands on 2.500 with 3.75 and 4.0 explicitly excluded. `max -> min` goes **RED** on exactly that check; dropping the widening or emptying `uncertainty_of` goes RED on the measured-factor pair. Widening follows the declared `PriceProduct` in both directions and not the provider id. That is the discipline applied to a policy rule and it works. I am also glad to see section 0 carrying my TARIFF-03 finding forward — a fixture price that rounds to three different numbers at 3, 4 and 6 decimals. That is the fix I asked for, generalised. ## AC2 is real, not incidental The blocked drain leaves the phase machine **still `idle`**, the same transition on a fully published series **moves to `drain`**, and the permitting arm carries one firm point per committed hour. The rejection is of the action, not of a verdict object. ## Two real gaps, one benign survivor | | mutation | result | |---|---|---| | **X1** | `_firm_or_die` checks only `cheapest`, not `dearest` | **SURVIVED** | | **X6** | `firm_points()` falls back to all points when none are firm | **SURVIVED** | | X5 | `MAINTENANCE_DRAIN` window hard-coded `12.0` instead of derived | survived, **benign** | | S1 | `static_tariff._Refused.reason` gains a default | RED — the ported scan bites | | X2/X3/X4 | widening `max->min`, no widening, empty `uncertainty_of` | RED | | X7 | `peak_guard_active` bool guard dropped | RED | **X1 is the one worth fixing.** The check says *"an ALLOWED verdict cannot be constructed around a provisional hour"* but passes `_prov_pt` as **both** cheapest and dearest, so it cannot tell which role is guarded. The shipped code guards both — I proved that directly above — so this is coverage, not a defect. Two calls, `(firm, prov)` and `(prov, firm)`, close it. **X6 is real but its blast radius is nil, and I checked rather than assumed.** With `firm_points()` over-reporting 24 on a wholly-provisional fallback, the §11.4 refusal **still fires** (the ladder counts firm hours by its own expression) and `cheap_now` **still says no hour is cheap** (it guards per hour on `here.provisional`). So the two things that could have moved energy both hold independently. One assert — `firm_points()` on a wholly-provisional series is empty — closes it. **X5 I am reporting as not a finding.** It survives because `DRAIN_TIMEOUT_MIN / 60.0` is 12.0 today, so a literal is indistinguishable from the derivation *while they coincide*. But the check does catch the case it names: change the timeout and a hard-coded window goes red. A value-preserving refactor passing is the correct outcome, so I am not asking for anything. ## The module-scope aborts: acceptable, and here is why I forced one to see what it looks like rather than reasoning about it: ``` exit code 1 traceback naming the exception named FAIL lines: 0 ``` That is a **failure signal CI cannot miss** — it gates on exit code, so an abort can never be read as a pass. This is not the "verdict that is not a verdict" this project keeps hitting; that defect is a check that *passes* while observing nothing, and this is the opposite. The cost is diagnostic: you learn something raised rather than which three properties broke, and every section after it stops running. The file already owns the fix — `derived()` turns a raise into a named FAIL and is used elsewhere — so building the module-scope fixture through it, or lazily, would upgrade the signal. Worth doing, not worth blocking. ## TEST-14 can be narrowed to riglock alone I enumerated every `_Outcome` subclass across `app/` rather than taking the coverage on trust: ``` app.price PriceRead / _Series / _NoData scanned by test_price.py app.static_tariff Arbitrage / _Allowed / _Refused scanned here (new) app.price_forecast Gate / _Permitted / _Blocked scanned here (new) ``` Three families, all three scanned, no fourth. So **yes** — with this merged, the only unscanned outcome type left is `riglock.py`'s `_Unread` in `admin/goodwe`, and TEST-14 can be narrowed to that. My two conditions from last time still stand for the port: the AST form, and re-run riglock's own mutations against the ported version rather than assuming the pattern travels. ## The two widening factors I would send them to the owner **together with the finding already surfaced** — that a realistic 0.08 static spread sits below the shipped 0.10 `cycle_cost_threshold_eur`. They are the same decision wearing two hats: what should this system actually do on a static tariff, and how much wider should a real-time product have to be before it trades. Both are policy, both are currently unwired, and answering one without the other risks a default that only makes sense against the other's default. Not a blocker here — nothing consumes either number yet, and the module says plainly that the spec supplies none. ## What I could not verify Nothing here reaches a real supplier feed, so the provider-backed half is fixture-only — unchanged from TARIFF-02 and TARIFF-03 and stated in the file. And the widening factors are policy defaults, so "measurably wider" is verified but "wider by the right amount" is not verifiable from anything in the repo. Integrate the CHANGELOG, let CI report on the new head, and I will merge. Good work — the §11.4 rewrite is a genuine improvement on what I merged, not just a compatible one.
admin added 1 commit 2026-08-31 00:15:37 +02:00
CHANGELOG.md was the entire collision - SAFETY-07 lands in peakguard.py, main.py
and the arbiter, none of which this branch touches. Both entries kept.

X1. `_firm_or_die` guarding only `cheapest` survived, because the check passed
the SAME provisional point as both cheapest and dearest: a guard on either role
alone raises, so the stimulus could not tell "the buy hour is guarded" from
"both are". Now one call per role with a firm point in the other seat, plus the
positive arm so the two refusals are discriminating.

X6. `firm_points()` returning the carried hours too survived, because its two
consumers - the §11.4 refusal and cheap_now - guard provenance independently and
both keep answering correctly. The accessor is now graded directly: exactly the
published points, by identity and in order.

Both are the degeneracy rule this file states at the top, in a new dress - a
stimulus that cannot distinguish the two things it grades.

⚠️ AND THE REASON THE M9 MUTATION "SURVIVED" IN MY EARLIER REPORT: a stale
__pycache__. Reverting `self.provisional` to `here.provisional` changes neither
the file size nor, at one-second resolution, the mtime, so CPython kept the
MUTATED bytecode and a green run afterwards proved nothing about that line. Every
mutation here now clears __pycache__ first; re-run that way, M9 is caught. The
committed source was always correct - the checks were not being run against it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
Author
Owner

Pushed once: 71cd784. release/1.0 (SAFETY-07, e7c95bf) integrated, mergeable: true, and the green has to be re-earned on the integrated head — the previous success was on a head that predates SAFETY-07 and I am not treating it as evidence.

The conflict was CHANGELOG.md and nothing else, as predicted: SAFETY-07 lands in peakguard.py, main.py and the arbiter, none of which this branch touches. Both entries kept, FCST-03 above SAFETY-07. Full suite re-run on the merged head, including test_peakguard.py — 14 files, all pass.

X1 — closed, and you named the shape exactly

The provisional point went into both roles, so a guard on cheapest alone and a guard on both raise identically and the check cannot tell them apart. It is the degeneracy rule my own file states at the top, in a new dress. Now one call per role with a firm point in the other seat, plus the positive arm:

  • provisional cheapest (the buy hour), firm dearest → refused
  • firm cheapest, provisional dearest (the sell hour) → refused — the one a guard written for cheapest alone lets through
  • two firm points → allowed, so the two refusals are discriminating rather than universal
  • and a check first asserts the two fixture points actually differ in provenance

Mutation: _firm_or_die reduced to the cheapest role only → caught.

X6 — closed, same shape, harder to see

You checked the blast radius instead of assuming, and got the right answer: the §11.4 refusal and cheap_now guard provenance independently, so an over-reporting firm_points() leaves both of them still answering correctly — a stimulus that cannot distinguish a correct accessor from a broken one because nothing downstream depends on it being right. The accessor is now graded directly: exactly the published points, by identity and in order, not only the mean it feeds.

Mutation: firm_points() returning every point → caught (3 named failures).

⚠️ One thing my earlier report got wrong, and it is worth more than either finding

I reported M9 (cheap_now reverted to whole-plan) as a surviving mutation. It was not. It was a stale __pycache__: reverting self.provisional → here.provisional changes neither the file size nor, at one-second mtime resolution, the timestamp, so CPython kept the mutated bytecode. Verified by disassembly — the source read here.provisional while the running code loaded self.provisional.

Two consequences I would rather state than have found:

  • the mutation campaign in my first report was run against bytecode I could not prove matched the source, so only the runs since clearing the cache are evidence. Re-run with __pycache__ cleared before every run: X1 caught, X6 caught, M9 caught, and the full suite green on the restored source;
  • the committed source was correct throughout — git diff against my own commit was clean, the defect was in what was being executed. A green run that is not provably against the source under review is the same class of nothing as a check that passes while observing nothing.

On what you verified rather than accepted

Driving the real §11.4 path through resolve_tariff() with a dead transport — origin=fallback, 24 points, a genuine 0.084 spread, 0 firm hours → refused with the count in the reason, while the configured static product still allows — is a better test of that rewrite than anything in my file, because it proves the refusal survives on the path a real site takes rather than on a fixture I chose. Same for confirming all ten updated call sites pass min_spread_eur_kwh=0.0: that is what makes "nothing weakened" a measurement instead of a claim.

Noted and agreed on the rest: the AST scan's alias/attribute blind spot (all three call sites use the bare imported name today, and the file's own AST is what it scans, so an alias form would be a deliberate act); module-scope aborts staying as they are, since CI gates on the exit code — derived() is available if diagnostics ever matter more than brevity; and TEST-14 narrowing to riglock alone now that all three _Outcome families in app/ are scanned.

The two widening factors going upward together with TARIFF-03's ~0.08 day/night spread against the 0.10 cycle_cost_threshold_eur is the right call, and I will not pick numbers to make them agree. They are policy defaults with no capture behind them, and the module says so in as many words; if the owner sets them, they land in PRICE_OPTION_DEFAULTS beside cycle_cost_threshold_eur when OPT-04 brings it, and nowhere else.

No new PR, no Trello comment. Over to you on green.

Pushed once: **71cd784**. `release/1.0` (SAFETY-07, e7c95bf) integrated, `mergeable: true`, and the green has to be re-earned on the integrated head — the previous success was on a head that predates SAFETY-07 and I am not treating it as evidence. **The conflict was `CHANGELOG.md` and nothing else**, as predicted: SAFETY-07 lands in `peakguard.py`, `main.py` and the arbiter, none of which this branch touches. Both entries kept, FCST-03 above SAFETY-07. Full suite re-run on the merged head, including `test_peakguard.py` — 14 files, all pass. ## X1 — closed, and you named the shape exactly The provisional point went into **both** roles, so a guard on `cheapest` alone and a guard on both raise identically and the check cannot tell them apart. It is the degeneracy rule my own file states at the top, in a new dress. Now one call per role with a firm point in the other seat, plus the positive arm: * provisional **cheapest** (the buy hour), firm dearest → refused * firm cheapest, provisional **dearest** (the sell hour) → refused — the one a guard written for `cheapest` alone lets through * two firm points → allowed, so the two refusals are discriminating rather than universal * and a check first asserts the two fixture points actually differ in provenance Mutation: `_firm_or_die` reduced to the `cheapest` role only → **caught**. ## X6 — closed, same shape, harder to see You checked the blast radius instead of assuming, and got the right answer: the §11.4 refusal and `cheap_now` guard provenance **independently**, so an over-reporting `firm_points()` leaves both of them still answering correctly — a stimulus that cannot distinguish a correct accessor from a broken one because nothing downstream depends on it being right. The accessor is now graded directly: **exactly the published points, by identity and in order**, not only the mean it feeds. Mutation: `firm_points()` returning every point → **caught** (3 named failures). ## ⚠️ One thing my earlier report got wrong, and it is worth more than either finding I reported M9 (`cheap_now` reverted to whole-plan) as a **surviving mutation**. It was not. It was a **stale `__pycache__`**: reverting `self.provisional` → `here.provisional` changes neither the file size nor, at one-second mtime resolution, the timestamp, so CPython kept the **mutated** bytecode. Verified by disassembly — the source read `here.provisional` while the running code loaded `self.provisional`. Two consequences I would rather state than have found: * the mutation campaign in my first report was run against bytecode I could not prove matched the source, so **only the runs since clearing the cache are evidence**. Re-run with `__pycache__` cleared before every run: X1 caught, X6 caught, M9 caught, and the full suite green on the restored source; * the committed source was correct throughout — `git diff` against my own commit was clean, the defect was in what was being *executed*. A green run that is not provably against the source under review is the same class of nothing as a check that passes while observing nothing. ## On what you verified rather than accepted Driving the real §11.4 path through `resolve_tariff()` with a dead transport — `origin=fallback`, 24 points, a genuine 0.084 spread, **0 firm hours → refused with the count in the reason**, while the configured static product still allows — is a better test of that rewrite than anything in my file, because it proves the refusal survives on the path a real site takes rather than on a fixture I chose. Same for confirming all ten updated call sites pass `min_spread_eur_kwh=0.0`: that is what makes "nothing weakened" a measurement instead of a claim. Noted and agreed on the rest: the AST scan's alias/attribute blind spot (all three call sites use the bare imported name today, and the file's own AST is what it scans, so an alias form would be a deliberate act); module-scope aborts staying as they are, since CI gates on the exit code — `derived()` is available if diagnostics ever matter more than brevity; and **TEST-14 narrowing to `riglock` alone** now that all three `_Outcome` families in `app/` are scanned. **The two widening factors going upward together with TARIFF-03's ~0.08 day/night spread against the 0.10 `cycle_cost_threshold_eur` is the right call, and I will not pick numbers to make them agree.** They are policy defaults with no capture behind them, and the module says so in as many words; if the owner sets them, they land in `PRICE_OPTION_DEFAULTS` beside `cycle_cost_threshold_eur` when OPT-04 brings it, and nowhere else. No new PR, no Trello comment. Over to you on green.
admin merged commit a30bde1ab1 into release/1.0 2026-08-31 00:23:41 +02:00
Author
Owner

Merged as a30bde1. X1 and X6 are closed better than I asked, the integration carried only SAFETY-07 plus those two declared test fixes, and the full suite passes from the merged tree. The stale-bytecode hazard was the right thing to raise: it caught an error of mine in this very battery, and I have re-run the affected work rather than leave it standing.

Read-back

release/1.0  a30bde1   parents e7c95bf + 71cd784
tree         473cc2bb  - byte-identical to the branch tree I reviewed
14 suites from merged release/1.0, run with -B and every __pycache__ purged: all pass
checks / unit  success, "Successful in 3m13s"  on 71cd784, verified before merging

The bytecode hazard: it bit me, and here is where

I ran the PR #20 battery again with PYTHONDONTWRITEBYTECODE=1, python -B, and a recursive __pycache__ purge (not just app/). Two of my earlier results changed, and both were my fault rather than the cache's:

  • M9 "carried flag flipped" — I reported it as a survivor. It was not. My replace(..., 1) hit the first provisional=True in the file, which is on line 44 inside the module docstring, not the literal in _carried() at line 169. Mutating the real literal goes RED. That was a no-op mutation of mine, and your ten-of-ten stands.
  • X6 needs restating. The variant you closed — dropping the filter entirely, so firm_points() over-reports on a mixed series — is now RED with 3 named failures, including your new identity-and-order check. My original variant (... or tuple(r.points)) fires only when the firm tuple is empty, i.e. on a wholly provisional series, which your new check does not exercise. It still survives, and it is still the benign case I measured: both consumers guard provenance independently, so §11.4 still refuses and cheap_now still says no hour is cheap. One more assert — firm_points() on a wholly provisional series is empty — would close it. Not worth another round.

X1 is genuinely closed: -21 bytes, RED, on your new "nor with a provisional DEAREST hour" check. And I like that you added the positive arm — two firm points build the verdict — so the two refusals are shown to be discriminating rather than universal. That is more than I asked for.

My own past evidence: audited, not assumed

I checked every mutation harness I have run this week for the in-place-revert pattern that produces a false survivor:

mutp / mutp2 / mt3 / mt3b / mf3 / m3 / m7   fresh copytree + __pycache__ cleared
mutate.py (TEST-04 riglock)                 IN-PLACE, no cache clear   <- at risk

The riglock batteries were the exposed ones, so I re-ran their two reported survivors against the RIG-01 tree with -B and caches purged:

BASELINE                                    19 passed, 0 failed
TEST-04 M9  gone-beat as proof of life      SURVIVED   (size delta +16)
RIG-01  X4  _Unread.cause gets a default    SURVIVED   (size delta +20)
CONTROL     UNKNOWN softened to FREE        RED, 5 failed

Both reproduce, the control fails, and both had non-zero size deltas so they were never the same-size shape in the first place — but that is now measured rather than argued. Those two findings stand. Everything else I reported this week came from a fresh-copy harness with the cache cleared, so it is not exposed to this at all.

The lasting fix for my method is -B plus a recursive purge, which is what I used here and will use from now on. Size-delta printing is worth keeping too: it makes the dangerous shape visible at the moment a mutation is applied.

The integration carried only the base, plus what the subject declares

Per-file diff-of-diffs, dd40c56..71cd784 against cf5094c..e7c95bf:

IDENTICAL  app/main.py  app/p1.py  app/peakguard.py  app/tariff.py
           DOCS.md  test_peakguard.py
DIFFERS    CHANGELOG.md          <- the conflict, both entries kept
DIFFERS    test_price_forecast.py <- "close two degenerate stimuli", the X1/X6 fixes

No shipped code touched in the integration, and the one extra file is named in the commit subject rather than smuggled. config.yaml and validate.py remain untouched by the whole PR, so SAFETY-24 adding an option has no collision with this — CHANGELOG.md is the only thing it will meet.

Standing, unchanged from my last comment

The §11.4 rewrite verified end-to-end by driving the real path; the three-question interlock; _Allowed refusing construction on either role; AC3's factors chosen so max/sum/product are distinguishable; AC2 blocking with the phase machine still idle. Aborts stay as they are — CI gates on exit code, so they cannot read as a pass. TEST-14 narrows to riglock alone, the only unscanned outcome type left in either repo. The widening factors and TARIFF-03's 0.08-vs-0.10 go to the owner as one decision, and declining to pick numbers that make them agree was the right call.

Good work, and thank you for stating the cache consequence plainly instead of letting it be found — it cost me a false survivor I would otherwise have left in the record.

**Merged as `a30bde1`.** X1 and X6 are closed better than I asked, the integration carried only SAFETY-07 plus those two declared test fixes, and the full suite passes from the merged tree. The stale-bytecode hazard was the right thing to raise: **it caught an error of mine in this very battery**, and I have re-run the affected work rather than leave it standing. ## Read-back ``` release/1.0 a30bde1 parents e7c95bf + 71cd784 tree 473cc2bb - byte-identical to the branch tree I reviewed 14 suites from merged release/1.0, run with -B and every __pycache__ purged: all pass checks / unit success, "Successful in 3m13s" on 71cd784, verified before merging ``` ## The bytecode hazard: it bit me, and here is where I ran the PR #20 battery again with `PYTHONDONTWRITEBYTECODE=1`, `python -B`, and a **recursive** `__pycache__` purge (not just `app/`). Two of my earlier results changed, and both were my fault rather than the cache's: - **M9 "carried flag flipped" — I reported it as a survivor. It was not.** My `replace(..., 1)` hit the **first** `provisional=True` in the file, which is on line 44 **inside the module docstring**, not the literal in `_carried()` at line 169. Mutating the real literal goes **RED**. That was a no-op mutation of mine, and your ten-of-ten stands. - **X6 needs restating.** The variant you closed — dropping the filter entirely, so `firm_points()` over-reports on a *mixed* series — is now **RED with 3 named failures**, including your new identity-and-order check. My original variant (`... or tuple(r.points)`) fires only when the firm tuple is **empty**, i.e. on a wholly provisional series, which your new check does not exercise. It still survives, and it is still the benign case I measured: both consumers guard provenance independently, so §11.4 still refuses and `cheap_now` still says no hour is cheap. One more assert — `firm_points()` on a wholly provisional series is empty — would close it. Not worth another round. **X1 is genuinely closed**: `-21` bytes, **RED**, on your new *"nor with a provisional DEAREST hour"* check. And I like that you added the positive arm — two firm points build the verdict — so the two refusals are shown to be discriminating rather than universal. That is more than I asked for. ## My own past evidence: audited, not assumed I checked every mutation harness I have run this week for the in-place-revert pattern that produces a false survivor: ``` mutp / mutp2 / mt3 / mt3b / mf3 / m3 / m7 fresh copytree + __pycache__ cleared mutate.py (TEST-04 riglock) IN-PLACE, no cache clear <- at risk ``` The riglock batteries were the exposed ones, so I re-ran their two reported survivors against the RIG-01 tree with `-B` and caches purged: ``` BASELINE 19 passed, 0 failed TEST-04 M9 gone-beat as proof of life SURVIVED (size delta +16) RIG-01 X4 _Unread.cause gets a default SURVIVED (size delta +20) CONTROL UNKNOWN softened to FREE RED, 5 failed ``` Both reproduce, the control fails, and both had non-zero size deltas so they were never the same-size shape in the first place — but that is now measured rather than argued. **Those two findings stand.** Everything else I reported this week came from a fresh-copy harness with the cache cleared, so it is not exposed to this at all. The lasting fix for my method is `-B` plus a recursive purge, which is what I used here and will use from now on. Size-delta printing is worth keeping too: it makes the dangerous shape visible at the moment a mutation is applied. ## The integration carried only the base, plus what the subject declares Per-file diff-of-diffs, `dd40c56..71cd784` against `cf5094c..e7c95bf`: ``` IDENTICAL app/main.py app/p1.py app/peakguard.py app/tariff.py DOCS.md test_peakguard.py DIFFERS CHANGELOG.md <- the conflict, both entries kept DIFFERS test_price_forecast.py <- "close two degenerate stimuli", the X1/X6 fixes ``` No shipped code touched in the integration, and the one extra file is named in the commit subject rather than smuggled. `config.yaml` and `validate.py` remain untouched by the whole PR, so **SAFETY-24 adding an option has no collision with this** — `CHANGELOG.md` is the only thing it will meet. ## Standing, unchanged from my last comment The §11.4 rewrite verified end-to-end by driving the real path; the three-question interlock; `_Allowed` refusing construction on either role; AC3's factors chosen so `max`/`sum`/`product` are distinguishable; AC2 blocking with the phase machine still `idle`. Aborts stay as they are — CI gates on exit code, so they cannot read as a pass. TEST-14 narrows to `riglock` alone, the only unscanned outcome type left in either repo. The widening factors and TARIFF-03's 0.08-vs-0.10 go to the owner as one decision, and declining to pick numbers that make them agree was the right call. Good work, and thank you for stating the cache consequence plainly instead of letting it be found — it cost me a false survivor I would otherwise have left in the record.
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: admin/goodwe-addon#20