checks / unit (pull_request) Successful in 7m16s
CHANGELOG.md was the only conflict: both entries belong under the same
`## Unreleased` heading, TARIFF-10's first because it merged first. No
version number picked.
app/main.py auto-merged, and was re-read because of it. TARIFF-10 changed
WHICH installs run `run_tariff_refresh()` (`arbitrage.enabled(opts)` ->
`tariff_refresh_wanted(opts)`); this card changed what that task does on
a failed fetch. Nothing in the keep/replace reasoning changed, and the
two newly reachable configurations are now DRIVEN in test_arbitrage.py
8b rather than reasoned about:
* a provider configured with trading DISARMED is the case the guard
was designed for, and needs no second fixture because nothing on
this path reads `arbitrage_enabled` at all - asserted over the AST's
symbols and non-docstring constants, after two drafts of that check
went red on a CORRECT tree for matching the word "arbitrage" in a
docstring and in a log line;
* `price_provider: off` + a `static_profile` resolves to the
CONFIGURED product (`origin: "static"`), so `standing.from_feed` is
false on every refresh and the guard never latches - driven twice in
a row, because a guard that latched there would freeze a static
site's plan at its first window for the life of the install.
Also: the kept-plan log line said "arbitrage stops with it", which after
TARIFF-10 is false on a site that fetches without trading; it now says
"arbitrage, where it is armed, stops with it". The keep decision is
strictly downstream of `resolve_tariff()`, so the §8.3 carry into
price_history - TARIFF-10's whole point - is unaffected either way.
All 29 suites green on the merged tree, including TARIFF-10's new
test_price.py section 13. All eleven mutations RE-DRIVEN on the merged
tree rather than carried over: eleven red, no survivors, every revert
fingerprinted. M11 is new - making the guard conditional on
`arbitrage.enabled(self.o)` (+86 B) is red on five named checks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
253 lines
13 KiB
Markdown
253 lines
13 KiB
Markdown
# TARIFF-12 — a returned degradation is not a refresh
|
|
|
|
Three decisions. The first is what the ticket put up for judgement; the second
|
|
and third are what fall out of it and would otherwise be decided by accident.
|
|
|
|
---
|
|
|
|
## 0. What was actually wrong, stated once
|
|
|
|
`run_tariff_refresh()`'s docstring:
|
|
|
|
> ⚠️ **AND A FAILURE DOES NOT CLEAR THE PLAN.** … Zeroing the plan here would
|
|
> replace a coarse tariff with no tariff, which is the opposite of what §11.4
|
|
> asks for.
|
|
|
|
The guarantee was real. The condition it was enforced on was not:
|
|
|
|
```python
|
|
try:
|
|
plan = await resolve_tariff(...)
|
|
self.tariff_plan, self.tariff_at = plan, now # unconditional
|
|
except Exception as err:
|
|
_LOG.warning("tariff refresh failed (%s); keeping the plan ...")
|
|
```
|
|
|
|
`load_prices()` says in its own docstring that it **"RETURNS, NEVER RAISES, for
|
|
every failure a site can actually have"**, because a degradation arriving as a
|
|
traceback into the planning tick would stop the controller regulating over an
|
|
expired certificate. So a supplier outage — the case the docstring names first —
|
|
is a **return**: the `try` succeeds, `_degrade()` hands back a §11.4 plan, and
|
|
the good plan is overwritten. The `except` is reachable only by something
|
|
nobody expected, and nothing in the shipped path produces it.
|
|
|
|
This is the fourth card in one family: FCST-04's M4 (`not ok` only), FCST-05's
|
|
HIGH (`(JSONDecodeError, OSError)` only), FCST-06 (`not self.ok` only), and now
|
|
`except Exception` only, for a path that never raises. **A guarantee enforced on
|
|
the condition someone thought of, rather than the condition that occurs.**
|
|
|
|
Reproduced before the fix, driving the shipped chain with nothing stubbed but
|
|
the socket — 48 h horizon, `static_profile` empty, one good fetch then a
|
|
`ClientConnectorError` 1800 s later, `TARIFF_MAX_AGE_S` = 5400 s:
|
|
|
|
```
|
|
after a good fetch origin='provider' n_points=48 'dynamic_dayahead from generic_dayahead, 48 h'
|
|
after the failed fetch origin='none' n_points=0 'no tariff - generic_dayahead: ClientConnectorError: ...'
|
|
tariff_at moved: True
|
|
```
|
|
|
|
and after it:
|
|
|
|
```
|
|
after the failed fetch origin='provider' n_points=48 'dynamic_dayahead from generic_dayahead, 48 h'
|
|
tariff_at moved: False
|
|
```
|
|
|
|
---
|
|
|
|
## 1. The question is asked ONCE, after the attempt, from the evidence that exists
|
|
|
|
FCST-06's answer, one module over, and for the same reason: **the enumeration
|
|
of failure conditions cannot be completed.** There is no pre-check that a feed
|
|
will answer which is not itself a guess, and adding one would be a second
|
|
symptom test beside the `except`. So the predicate is an **outcome**, read off
|
|
the object the attempt produced:
|
|
|
|
```python
|
|
standing = self.fresh_tariff_plan(now)
|
|
keep = (not plan.from_feed
|
|
and standing is not None and standing.from_feed)
|
|
```
|
|
|
|
Two facts, both of which exist by the time the question is asked:
|
|
|
|
* **`plan.from_feed`** — did *this* resolution observe the configured provider?
|
|
`TariffPlan.origin` already names the four ways a plan is obtained; this
|
|
collapses them to the one question a caller holding a previous plan has to
|
|
ask. It lives on `TariffPlan` rather than as an `origin == "provider"` at the
|
|
call site for `fresh_tariff_plan()`'s own stated reason — *a check has to be
|
|
able to assert on the shipped expression rather than on a transcription that
|
|
drifts* — and because `origin`'s vocabulary belongs to `static_tariff.py`.
|
|
* **`fresh_tariff_plan(now)`** — is a standing observation still current. It
|
|
already existed, it is already the one place the age is judged, and it is
|
|
**reused, not transcribed**. `test_arbitrage.py` 8b pins that reuse on the
|
|
AST; a copy of the comparison behaves identically until the day the two
|
|
drift, which is a claim about the code's shape, so a shape check is what
|
|
grades it (FCST-06's precedent for the one source check among driven ones).
|
|
|
|
### What was NOT done
|
|
|
|
* **Not `except Exception` widened, or a second `if` beside it.** That is the
|
|
defect's own shape — a place where the rule is spelt twice, so a fifth cause
|
|
needs a fifth branch. The `except` stays, relabelled *"not the outage path"*,
|
|
keeps the plan on the same terms, and is now honestly the
|
|
something-nobody-expected branch.
|
|
* **Not the persisted price store as a cache.** Out of scope by the product
|
|
owner's call, and it is the right call: FCST-03 exists precisely to stop
|
|
stale prices being acted on, and "recent" is not "current". Keeping the last
|
|
**fetched** plan until it ages out is a different promise from serving the
|
|
store, and that one needs its own ticket.
|
|
* **Not a `no_data`-only guard.** `not plan.has_prices` is the tempting
|
|
spelling and it is wrong — see decision 2. It leaves the §11.4 fallback free
|
|
to replace a fresh observation, and it is a 1-byte edit from the shipped
|
|
line, which is why it is in the mutation table rather than in a comment.
|
|
|
|
---
|
|
|
|
## 2. A §11.4 static fallback does NOT replace a fresh provider plan
|
|
|
|
The ticket asked this directly, because the fallback is *not* nothing: it is
|
|
the operator's own configured profile, with real prices, for the requested
|
|
window. The answer is still no, and the reason is not that it is worse data —
|
|
it is that **nothing may be acted on it, and it is no more current.**
|
|
|
|
* Every point `_degrade()` builds carries `provisional=True`. So
|
|
`firm_points()` is empty, `arbitrage()` finds fewer than two firm hours and
|
|
refuses, and `cheap_now()` is False. Both of the two things `TariffPlan`
|
|
exposes for a consumer to act on are already off.
|
|
* A day-ahead price for a given hour **does not change once published**. A plan
|
|
fetched 30 minutes ago is exactly as true about 19:00 as one fetched now; at
|
|
most `TARIFF_MAX_AGE_S` of its horizon has elapsed. The observation is not
|
|
stale in any sense the fallback is fresher than.
|
|
|
|
So replacing the observation with the fallback trades a plan with firm hours
|
|
for a plan with none, and buys no currency with it. The fallback takes over
|
|
when the observation **retires** — which is exactly the row §11.4 describes,
|
|
arrived at when there is no longer an observation to prefer.
|
|
|
|
### And the rule is deliberately NOT symmetrical
|
|
|
|
`standing.from_feed` is a term in its own right. A standing plan that is itself
|
|
a degradation **is** replaced by the next degradation, and its timestamp moves.
|
|
Without that term:
|
|
|
|
* an outage on a static-profile site would freeze the operator's profile at the
|
|
window it was first generated for, and the guard would be *"never overwrite"*
|
|
wearing a bound's name;
|
|
* and on a `price_provider: off` install — the shipped default — the plan would
|
|
flip between kept and re-derived every `TARIFF_MAX_AGE_S`, for no behavioural
|
|
gain, changing which reason the status page prints on a two-hour cycle.
|
|
|
|
Each of the three terms is the only false one in some check in 8b, which is
|
|
MUTATION.md habit 17's test for whether a suite can tell an `AND` from an `OR`.
|
|
|
|
---
|
|
|
|
## 3. `tariff_at` does not move on a kept plan
|
|
|
|
The docstring's other half is that a plan must not *"freeze at its last value
|
|
and look current forever"*. `tariff_at` is what makes that work: it is the
|
|
moment of the fetch that produced the plan standing, and `fresh_tariff_plan()`
|
|
judges the age from it.
|
|
|
|
Re-stamping it on a kept plan would be this task asserting the feed answered
|
|
when it did not. The plan would be re-certified as current at every refresh, an
|
|
outage of any length would never retire it, and `TARIFF_MAX_AGE_S` would be
|
|
**exactly as dead as it was before this card** — reachable only by the refresh
|
|
task itself dying. Leaving it alone is what makes the bound govern: the plan is
|
|
served for the age bound after its own fetch, and no longer.
|
|
|
|
It is also the whole of **"distinguishable from a freshly-fetched one"**. A
|
|
kept plan and a fetched plan are the same object shape; what separates them is
|
|
that one's timestamp advanced and the other's did not, and every consumer that
|
|
asks how old the plan is already reads that number. The kept case additionally
|
|
logs at WARNING, naming the degradation's own reason, the fetch it is keeping,
|
|
its age, and the deadline it retires at.
|
|
|
|
⚠️ **What this does not give the operator, and why it is not here.** The status
|
|
page has no row for "the last refresh failed" — the banner/level not moving
|
|
when the plan is lost is separately carded, and the ticket rules it out of
|
|
scope. This card's change makes that surface *more* truthful, not less: during
|
|
the first `TARIFF_MAX_AGE_S` of an outage there is now a real plan behind the
|
|
Arbitrage row instead of a no-plan refusal, so the row says something true for
|
|
longer. The no-plan state is entered later by up to 5400 s; it is not removed,
|
|
and the 30-minute lag reported on that row is untouched by anything here.
|
|
|
|
---
|
|
|
|
## 4. Merged after TARIFF-10 — what the wider gate changed, and what it did not
|
|
|
|
TARIFF-10 landed on `release/1.0` while this card was open. It changed **which
|
|
installs run this task** — `if arbitrage.enabled(opts)` became
|
|
`tariff_refresh_wanted(opts)`, i.e. `provider_configured(opts) or
|
|
arbitrage.enabled(opts)` — while this card changed **what the task does on a
|
|
failed fetch**. Git auto-merged the two hunks, which is exactly when the
|
|
interaction needs reading rather than trusting.
|
|
|
|
**Nothing in the decisions above changed.** Two configurations are newly
|
|
reachable, and both are now driven in `test_arbitrage.py` 8b rather than
|
|
reasoned about:
|
|
|
|
* **A provider configured with trading DISARMED** — TARIFF-10's headline case.
|
|
This is precisely the configuration the guard was designed for: the plans are
|
|
`origin == "provider"`, and a dead feed must not delete one that is still
|
|
inside `TARIFF_MAX_AGE_S`. It needs no second fixture, because **nothing on
|
|
this path reads `arbitrage_enabled` at all**, and that is asserted rather
|
|
than assumed — over the AST's symbols and the non-docstring string constants,
|
|
because two earlier drafts of that check were **red against a correct tree**:
|
|
the first matched the docstring paragraph that *says* `arbitrage_enabled` is
|
|
not read, the second matched the log line's "arbitrage, where it is armed".
|
|
A substring test over a function grades its prose as much as its behaviour,
|
|
which is habit 4 in a third costume. A fixture that set the option and
|
|
asserted no difference would have been the other defect — comparing a thing
|
|
against itself.
|
|
* **`price_provider: off` + a `static_profile`** — the configuration TARIFF-10's
|
|
`or` exists to protect, and the one where a latching guard would do real harm.
|
|
`resolve_tariff()` returns `static_plan(provisional=False)`, whose origin is
|
|
`"static"`: the CONFIGURED product, not a §11.4 row. So `standing.from_feed`
|
|
is false on every refresh, `keep` is false on every refresh, and the plan is
|
|
regenerated for the current window every 30 minutes exactly as before this
|
|
card. **Driven, twice in a row**, because a guard that latched here would
|
|
freeze a static site's plan at its first window for the life of the install —
|
|
and mutation M3, which drops the `standing.from_feed` term, is red on that
|
|
check as well as on the fallback one.
|
|
|
|
Two further facts settle the rest of the interaction:
|
|
|
|
* **This decision is strictly downstream of every side effect.** The §8.3 carry
|
|
into `price_history` happens inside `resolve_tariff()`, on the `series`
|
|
branch, before `keep` is computed — so which of two plans is kept can neither
|
|
add nor lose a recorded day, and TARIFF-10's *Price history* row is
|
|
unaffected either way.
|
|
* **The kept-plan log line was reworded.** It said "arbitrage stops with it",
|
|
which after TARIFF-10 is false on a site that fetches without trading; it now
|
|
says "arbitrage, **where it is armed**, stops with it".
|
|
|
|
All eleven mutations were **re-driven on the merged tree** rather than carried
|
|
over — the surrounding code changed, so a red against the old base is not
|
|
evidence about the new one. Eleven red, no survivors, every revert
|
|
fingerprinted. The added member is M11: making the guard conditional on
|
|
`arbitrage.enabled(self.o)` (+86 B), red on five named checks.
|
|
|
|
⚠️ **And one process note that belongs in this file.** This card's mutation
|
|
harness was written to a shared scratchpad path and was **overwritten mid-run
|
|
by another agent's harness for a different card**, pointing at a different
|
|
worktree. That is MUTATION.md habit 18's second bullet almost verbatim. It was
|
|
caught because the harness fingerprints the tree after every revert and the
|
|
runs are what proved this worktree was never left mutated; the harness now
|
|
lives at a card-specific path. **A harness that edits source must not share a
|
|
name, a directory or a backup path with another one.**
|
|
|
|
---
|
|
|
|
## What this does not change
|
|
|
|
* **Nothing on the 30 s tick.** `cycle()` still reads two attributes and awaits
|
|
no fetch; the AST pin in `test_arbitrage.py` section 8 is unchanged and green.
|
|
* **Nothing about the prices themselves.** No series is invented, extended,
|
|
re-stamped or carried. The only question this card answers is *which of two
|
|
plans the controller holds*.
|
|
* **A `price_provider: off` install is bit-for-bit unaffected.** Its standing
|
|
plan is never `origin == "provider"`, so `keep` is false on every refresh it
|
|
will ever run, and the assignment happens exactly as it did before.
|