checks / unit (pull_request) Successful in 7m13s
Second base move while this card was open:559f0eb->a6e64fb. Only CHANGELOG.md conflicted; test_arbitrage.py auto-merged (TARIFF-12 used section 8b, this card uses 10, so the two do not collide). CHANGELOG: both entries under the single ## Unreleased in MERGE ORDER - TARIFF-12's row and section precede this branch's, because it landed first. No version number is picked; PKG-06/07's note stands untouched. WHAT TARIFF-12 CHANGES FOR THIS CARD - written out in decision note §8 rather than left silent, because the question is a fair one. A served plan can now be up to TARIFF_MAX_AGE_S = 3 * TARIFF_REFRESH_S = 5400 s old, where before it was at most one refresh interval, and at() is what answers "what does it cost right now" out of that older plan. * PER-POINT SPAN: unaffected, structurally. Every window is derived from the neighbours' ABSOLUTE start instants - datetimes in a frozen dataclass in a tuple, which cannot move - and _cover_table() runs once at construction over data that cannot change. A plan's AGE is a property of fetched_at/tariff_at, never of a window. The index cannot go stale, which is a small positive under TARIFF-12: a kept plan now lives longer and there is nothing in it that needed recomputing. * ONE-HOUR CAP: unaffected. It bites only at a hole; walking further into a series crosses more windows, each resolved on its own neighbours. * THE TAIL: reachable for the first time, and §2's choice survives it. At the shipped 48 h horizon the tail is ~46.5 h beyond anything a 90-minute-stale plan can be asked about. But price_horizon_h is int(1,72), so a 1 h horizon is a SUPPORTED configuration, and a 90-minute serve-stale window against a 1 h series puts `now` past the end. Under the chosen rule a quarter-hourly 1 h series ends at :45, its window closes on the hour, at() raises Absence, _act() idles and §11.4 sends the site to the static profile. Under the REJECTED flat one-hour tail the same stale plan would have traded against the :45 print for up to 45 minutes after the interval it describes had ended. So TARIFF-12 does not weaken the decision - it is the first thing that makes it reachable, and it selects the same answer. The rule should be re-read if TARIFF_MAX_AGE_S is ever widened again. * TariffPlan.from_feed is a pure predicate on `origin` and TARIFF-12 keeps or replaces a WHOLE PLAN OBJECT. It never splices two reads into one series, so it cannot hand at() an unsorted or duplicated point list. RE-GRADED ON THIS BASE, not carried over: all 29 test files green; app/price.py byte-identical to the TARIFF-10 merge (90d8ca31f7c0109d, 85953 B) because TARIFF-12 does not touch it; M1 re-driven at +68 B, red on the same 24 named checks across both files; M3 re-driven at ZERO BYTES, red on the same 4. Each purged before the run and reverted with the fingerprint re-checked, from a card-specific harness at /home/homelab/.cache/t14-mut/ with a mktemp backup inside it. This branch contributes zero diff to app/main.py and app/static_tariff.py. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
350 lines
18 KiB
Markdown
350 lines
18 KiB
Markdown
# TARIFF-14 — how long one price point covers, and what the last one does
|
||
|
||
One defect and one genuinely open decision. The defect had no defensible
|
||
alternative once it was measured; the decision — the **last** point's window —
|
||
did, and the alternative is argued below rather than dismissed.
|
||
|
||
---
|
||
|
||
## 0. The measurement this card exists for
|
||
|
||
`_Series.at()` asked `s <= u < s + 1 h` of every point **in list order** and
|
||
returned the **first** match. Run against the shipped code at `5059e28` on a
|
||
quarter-hourly series:
|
||
|
||
```
|
||
series: 02:00=0.10 02:15=0.11 02:30=0.12 02:45=0.13 03:00=0.14 ...
|
||
|
||
ask + 0min -> 02:00 0.10
|
||
ask +15min -> 02:00 0.10 <-- WRONG (wanted 02:15)
|
||
ask +30min -> 02:00 0.10 <-- WRONG
|
||
ask +45min -> 02:00 0.10 <-- WRONG
|
||
ask +60min -> 02:15 0.11 <-- WRONG (wanted 03:00)
|
||
ask +75min -> 02:30 0.12 <-- WRONG (wanted 03:15)
|
||
```
|
||
|
||
**Two failure modes, not one.** Inside the first hour the `:00` point wins
|
||
because its hour has not run out. From the second hour on the answer is not
|
||
even the top of the hour — the first point still inside its own hour wins — so
|
||
**the error compounds with depth into the series**.
|
||
|
||
Same code, same run, after the fix:
|
||
|
||
```
|
||
ask + 0min -> 02:00 0.10 ask +60min -> 03:00 0.14
|
||
ask +15min -> 02:15 0.11 ask +75min -> 03:15 0.15
|
||
ask +30min -> 02:30 0.12 ask +90min -> 03:30 0.16
|
||
ask +45min -> 02:45 0.13 ask+120min -> 04:00 0.18
|
||
```
|
||
|
||
An hourly series is **unchanged at every instant**, before and after — which is
|
||
the whole reason nothing caught this. Every price fixture in this repository
|
||
before TARIFF-08 was the invented hourly row shape, `static_tariff.py` emits
|
||
hourly points, and the DST checks count points on the 23- and 25-hour days
|
||
without ever asking `at()` for a quarter-hour. **The suite had been exercising
|
||
the one resolution on which the broken rule and the right one agree.**
|
||
|
||
Belgium moved to a 15-minute MTU on 2025-10-01 and the live day-ahead feed
|
||
returns **96 points at 900 s**, so this is reachable only down the dynamic path
|
||
TARIFF-08/09 just made work. Static sites are immune.
|
||
|
||
## 0.1 What it cost, which is not "a slightly wrong price"
|
||
|
||
`arbitrage._act()` calls `_point_at(plan, now)` → `series.at(now)` and then
|
||
tests `here.start == v.cheapest.start` / `== v.dearest.start` to decide whether
|
||
this interval is the buy one or the sell one. That is an **identity test
|
||
between the matched point and the point the verdict was built from**, and on a
|
||
quarter-hourly series it almost never matched — so the strategy idled through
|
||
exactly the interval it exists to trade, reporting *"this hour is neither the
|
||
buy hour nor the sell hour"*. It also read `here.provisional` (§8.3) and §17's
|
||
`price < 0` off that same wrong point.
|
||
|
||
---
|
||
|
||
## 1. The rule
|
||
|
||
> **A point covers from its own start until the next point's start, and never
|
||
> more than one hour.**
|
||
|
||
* **900 s** → each point covers its own quarter.
|
||
* **3600 s** → each point covers its own hour. Identical to what shipped.
|
||
* **MIXED** → **each point takes its own gap.** FCST-04's carry-forward emits
|
||
hourly carried points beside quarter-hourly firm ones, and the soak measured
|
||
spacing `[900, 3600]` in **70 % of samples** — so a window derived from one
|
||
global spacing would be wrong in the **normal steady state**, not in an edge
|
||
case.
|
||
|
||
### 1.1 Why the one-hour cap is not decoration
|
||
|
||
FCST-04 **leaves holes on purpose**: an hour with no published price and no
|
||
yesterday value is left *out* of the series, and `price_forecast.py`'s module
|
||
note names `at()` raising `Absence` for it as the behaviour that sends the
|
||
caller to the static profile (§11.4 for that hour).
|
||
|
||
Uncapped, "until the next point's start" stretches the point **before** a
|
||
three-hour hole across the whole hole and answers every instant in it with a
|
||
stale price. Filling holes is the defect this module exists to refuse, and it
|
||
would have been introduced **by the fix for a different one**. Graded in
|
||
`test_price.py` 14d; the mutation that removes the cap is red there.
|
||
|
||
---
|
||
|
||
## 2. The decision: what the **last** point covers
|
||
|
||
There is no next start, and §9.3 says nothing about how wide the final interval
|
||
is — a `PricePoint` has **three fields** and must not learn a fourth, because
|
||
that is the wire format every store, replay, fixture and captured body in this
|
||
repository is written against. So the tail is a **choice**, and both candidates
|
||
are defensible.
|
||
|
||
**Chosen: the last point is given the same span as the point before it** (one
|
||
hour when it is the only point).
|
||
|
||
| | flat one hour | the span in front of it (**chosen**) |
|
||
|---|---|---|
|
||
| 96-point 900 s day, `at(23:50)` | answers 23:45 | answers 23:45 |
|
||
| the same day, `at(00:30)` **tomorrow** | **answers with today's 23:45 price** | `Absence` |
|
||
| 24-point hourly day, `at(23:50)` | answers 23:00 | answers 23:00 |
|
||
| mixed series ending at the firm/carried seam | answers for an hour | `Absence` after 15 min |
|
||
|
||
**The second row is why.** A flat one-hour tail reaches across a day boundary
|
||
and prices tomorrow morning with yesterday's last print — which is
|
||
"reaching for a neighbouring one", the single thing the `Absence` message has
|
||
always refused to do. The chosen rule never extends a point past a span the
|
||
series itself demonstrates.
|
||
|
||
### 2.1 The cost of the choice, stated rather than buried
|
||
|
||
On a **mixed** series whose last point is a carried **hourly** one sitting
|
||
behind quarter-hourly firm ones, the tail is **900 s**, and the last 45 minutes
|
||
of the horizon raise `Absence` for an hour something did carry a value for.
|
||
|
||
That is accepted, not tolerated, and the direction is the safe one:
|
||
|
||
* the caller falls back to the static profile (§11.4) instead of trading on a
|
||
span the series never demonstrated;
|
||
* the point is **carried**, so §8.3 forbids any irreversible action resting on
|
||
it regardless;
|
||
* it is at the far end of a 48 h horizon, where nothing is decided at 30 s.
|
||
|
||
Under-covering costs a missed decision. Over-covering prices a real interval
|
||
with a number that does not describe it. Those are not symmetric.
|
||
|
||
### 2.2 The degenerate case
|
||
|
||
A series of **one** point has no gap in front of it to copy and gets **one
|
||
hour** — the shipped behaviour, kept deliberately. `test_price.py` 14e states
|
||
it as its own check so it cannot decay into an accident.
|
||
|
||
---
|
||
|
||
## 3. Does `at()` now depend on sortedness? Yes — and it establishes it itself
|
||
|
||
The old rule read one point at a time and did not care about order. The new one
|
||
reads a point's **neighbours**, so it *would* depend on the series being
|
||
ascending.
|
||
|
||
It does not, because **the lookup index is sorted by UTC start at construction**
|
||
(`_cover_table()`, called once from `_Series.__init__`). No caller's list order
|
||
can reach `at()`.
|
||
|
||
That matters because the three things that "guarantee" order today each
|
||
guarantee it somewhere else:
|
||
|
||
| | what it guarantees | where |
|
||
|---|---|---|
|
||
| `_validate_raw()` | strictly increasing starts | the `load_prices()` path only |
|
||
| `price_history._parse_points()` | re-sorts by instant | the store read path only |
|
||
| FCST-04's fill | emits ascending | that one producer only |
|
||
| `PriceRead.series()` | **nothing** | every other caller, including tests |
|
||
|
||
Three producers agreeing today is not an invariant; it is a coincidence with
|
||
good references. `test_price.py` 14f drives a series handed its 96 points in
|
||
**reverse** order and asserts `at()` answers identically at every one of them —
|
||
red the moment the sort is removed.
|
||
|
||
**Duplicated starts** (refused by `_validate_raw()`, constructible by hand) give
|
||
the earlier list entry a zero-width window and the later one the real span. One
|
||
of the pair becomes unreachable through `at()`; **nothing raises**, at
|
||
construction or on the 30 s control path. Stated as a check rather than left to
|
||
be discovered.
|
||
|
||
---
|
||
|
||
## 4. `covers()` shared it. `window()` did not
|
||
|
||
* **`covers()`** has no window rule of its own — it calls `at()` — so it shared
|
||
the defect exactly and the same change fixes it. It is kept as a delegation
|
||
for that reason: a second copy of the rule is a second place for it to be
|
||
wrong.
|
||
* **`window(since, until)`** grades a point's **start** against a half-open
|
||
range and never asks how long a point lasts, so it carries no resolution
|
||
assumption at all. It is **not** a coverage query — a point that starts before
|
||
`since` and runs into it is absent from its result by design. That is now
|
||
said in its docstring and pinned by a check, because the next reader will ask.
|
||
It has **no callers** in `app/`.
|
||
|
||
---
|
||
|
||
## 5. What this card does NOT fix, with its shape
|
||
|
||
`price_forecast._walk()` — §8.3's irreversible-action gate — iterates
|
||
`committed_hours()`, which steps in **whole hours**, and asks `series.at(u)` at
|
||
each. On a quarter-hourly series that now reliably returns the hour's `:00`
|
||
point (it previously returned something earlier and arbitrary), so the gate
|
||
reads **one point per hour** and never looks at the `:15`/`:30`/`:45` flags.
|
||
|
||
A partially-provisional hour would therefore be gated on its first quarter
|
||
alone. **Two things keep it off every shipped install, and both are
|
||
circumstantial rather than structural:** `price_permits()`/`begin_phase()` have
|
||
**no caller in `app/main.py`** — the §12 price-justified entry point is built
|
||
and not yet wired — and FCST-04 carries **whole** hours while
|
||
`generic_dayahead` publishes whole days, so the four quarters of an hour are
|
||
firm or carried together anyway. Both are facts about the *callers and
|
||
producers*, not properties of the *gate*, so this is written down here rather
|
||
than left for the first sub-hourly feed or the first wiring-up to find. Fixing it means teaching
|
||
`committed_hours()` about resolution, which is a data-model question this card
|
||
deliberately does not open.
|
||
|
||
---
|
||
|
||
## 6. Mutations
|
||
|
||
Eight, each purged before the run (TEST-15), each reverted with the tree
|
||
fingerprint checked afterwards (MUTATION.md habit 18), backup at a `mktemp`
|
||
path per invocation.
|
||
|
||
⚠️ **Re-driven in full on the MERGED tree** after TARIFF-10 landed on
|
||
`release/1.0` (base `88c59a9` -> `559f0eb`). All eight give the same verdicts
|
||
and the same red-check counts as before the merge, which is the answer that
|
||
matters: TARIFF-10 added 650 lines of checks to `test_price.py` and none of
|
||
them moves under any of these edits, because they grade `resolve_tariff()`'s
|
||
*reachability from a configuration* and not the lookup. Clean `app/price.py` on
|
||
the merged tree = **`90d8ca31f7c0109d`, 85953 B** (pre-merge it was
|
||
`30d1a56f0117e571`, 83742 B; the difference is TARIFF-10's `provider_id()` /
|
||
`provider_configured()`, which this card does not touch).
|
||
|
||
| | mutation | Δ | red in |
|
||
|---|---|---|---|
|
||
| M1 | `at()` back to the shipped fixed one-hour scan over `self.points` | +68 B | **24 named checks**: `test_price.py` 14b ×4, 14c ×3, 14d, 14e ×3, 14f, 14g; `test_arbitrage.py` 10b ×6, 10c ×3, 10d ×2 |
|
||
| M2 | drop the one-hour cap | −68 B | `test_price.py` 14d |
|
||
| M3 | flat one-hour tail instead of the span in front | **+0 B** | `test_price.py` 14e ×3, 14g — 4 checks |
|
||
| M4 | trust the caller's list order — drop the sort | −61 B | `test_price.py` 14f |
|
||
| M5 | `covers()` grows its own hour-wide rule | +95 B | `test_price.py` 14e, 14g |
|
||
| M6 | `window()` becomes a coverage query | −37 B | `test_price.py` 14g |
|
||
| M7 | the window's end becomes inclusive | +1 B | `test_price.py` 14b ×4, 14c ×4, 14d, 14e ×4, 14f ×2, 14g — 16 checks |
|
||
| M8 | the window's start becomes exclusive | −1 B | `test_price.py` 14b ×4, 14c ×4, 14d, 14f ×2, 14g, 14h, plus the pre-existing section 6 check — 14 checks |
|
||
|
||
**M3 is the zero-byte one and it is the tail decision itself** — the case
|
||
MUTATION.md names as the risky size, caught by the check written for it.
|
||
|
||
**What the arbitrage file does NOT see, with sizes.** M2, M4, M5, M6 and M8
|
||
leave `test_arbitrage.py` green, and that is honest rather than a gap to round
|
||
off: they are `at()`-level properties (holes, caller order, `covers()`,
|
||
`window()`, the start boundary) that no arbitrage fixture reaches, because the
|
||
probes are seven minutes into a slot and the plans have no holes. The one that
|
||
matters end to end — M1, the shipped rule — is red in **both** files, on the
|
||
named check `THE PROPERTY THE DEFECT BROKE`.
|
||
|
||
---
|
||
|
||
## 7. TARIFF-10 landed on the base while this was open — what it changes here
|
||
|
||
Base moved `88c59a9` → `559f0eb` mid-card. The question is not whether the
|
||
branches merge; it is whether TARIFF-10 makes any sentence above **false**.
|
||
|
||
**In `app/price.py` the two do not meet.** TARIFF-10 added `provider_id()` and
|
||
`provider_configured()` beside `price_option()` and routed `load_prices()`
|
||
through them. That is the option-reading layer; it touches nothing in
|
||
`_Series`, `at()`, `covers()`, `window()` or `_cover_table()`. Git merged the
|
||
file without a conflict, and the merged `at()` is byte-identical to the one the
|
||
mutations above were driven against.
|
||
|
||
**Behaviourally it widens who has a series at all, and that cuts one way only.**
|
||
`run_tariff_refresh()` now runs on installs with `arbitrage_enabled` **off**, so
|
||
a provider-configured site has a standing plan — and therefore a `_Series` —
|
||
where before it had none. **It does not add a new `at()` consumer to the 30 s
|
||
tick**, which is the claim worth checking rather than assuming:
|
||
|
||
| caller of `at()`/`covers()` | reached by a non-trading install? |
|
||
|---|---|
|
||
| `arbitrage._point_at()` (`arbitrage.py:300`) | no — trading only |
|
||
| `static_tariff.TariffPlan.price_at()` / `.words` `here` (`:750`, `:819`) | on the tick these are reached through `static_status()`, which builds a **`static_plan`** — hourly points, immune. `main.py` calls only `plan.words` on the fetched plan, which counts points and never looks one up |
|
||
| `price_forecast._walk()` (`:562`, `:568`) | **`price_permits()` and `begin_phase()` have no caller in `app/main.py` at all** — §12's price-justified entry point is built and not yet wired |
|
||
|
||
So TARIFF-10 neither creates nor closes any exposure this card is about. What it
|
||
does do is make the defect's reach larger the moment such a site arms trading,
|
||
because the series it would trade against now exists on every configured
|
||
install rather than only on armed ones. That strengthens the case for the fix
|
||
and changes nothing in its reasoning.
|
||
|
||
**In `test_price.py` both cards appended a section 13.** Nothing is dropped:
|
||
TARIFF-10's stays **13**, this card's is renumbered **14** (which is also this
|
||
card's number), and the temp-directory teardown TARIFF-10 put at the end of the
|
||
file stays at the end of the file. The two sections share no fixture, no option
|
||
dict and no helper name; TARIFF-10's grades `resolve_tariff()`'s *reachability
|
||
from a configuration* and drives real fetches with a counting transport, and
|
||
**none of its checks moves under any of the eight mutations above** — measured,
|
||
not assumed.
|
||
|
||
---
|
||
|
||
## 8. TARIFF-12 landed too — a plan may now be 90 minutes old when it is asked
|
||
|
||
Base moved `559f0eb` → `a6e64fb` while this was open. TARIFF-12 stops a
|
||
returned §11.4 degradation from overwriting a standing plan that is still
|
||
inside `TARIFF_MAX_AGE_S`, so **a served plan can now be up to
|
||
`3 × TARIFF_REFRESH_S = 5400 s` old** where before it was at most one refresh
|
||
interval. `at()` is what answers *"what does it cost right now"* out of that
|
||
older plan, so the question is fair and the answer is written out rather than
|
||
assumed.
|
||
|
||
**Per-point span: unaffected, structurally.** Every window is derived from the
|
||
neighbouring points' **absolute `start` instants**. Those are `datetime`s inside
|
||
a frozen dataclass inside a tuple — they cannot move, and `_cover_table()` runs
|
||
once at construction over data that cannot change afterwards. A plan's *age* is
|
||
a property of `fetched_at` / `tariff_at`, never of a window. **The index cannot
|
||
go stale**, which is a small positive under TARIFF-12 rather than a risk: a kept
|
||
plan now lives longer, and there is nothing in it that needed recomputing.
|
||
|
||
**The one-hour cap: unaffected.** It bites only where a gap exceeds an hour,
|
||
i.e. at a hole FCST-04 left. Walking further into a series crosses more windows;
|
||
each is still resolved on its own neighbours.
|
||
|
||
**The tail: reachable for the first time, and the choice made in §2 is the one
|
||
that survives it.** This is the half worth spelling out.
|
||
|
||
* At the **shipped 48 h horizon** the tail is unreachable by staleness. A plan
|
||
is served for at most 90 minutes, so `now` is at most 90 minutes into a
|
||
~48 h series — the last point is still about 46½ hours away.
|
||
* But `price_horizon_h` is `int(1,72)` in `config.yaml`, so an operator **can**
|
||
configure a **1 h** horizon. A 90-minute serve-stale window against a 1 h
|
||
series puts `now` genuinely past the end. That is not hypothetical; it is a
|
||
supported configuration meeting a supported outage.
|
||
* Under the **chosen** rule a quarter-hourly 1 h series ends at `:45` and that
|
||
point's window closes at the top of the next hour: `at()` raises `Absence`,
|
||
`_point_at()` returns None, `_act()` idles with its "no price for this
|
||
instant" reason, and §11.4 sends the site to the static profile.
|
||
* Under the **rejected** flat one-hour tail the same stale plan would have gone
|
||
on trading against the `:45` print for **up to 45 minutes after the interval
|
||
it describes had ended** — pricing an outage with a number for a quarter that
|
||
is over.
|
||
|
||
So TARIFF-12 does not weaken the tail decision. It is the first thing that makes
|
||
it reachable on a shipped configuration, and it independently selects the same
|
||
answer. ⚠️ **Note which direction that runs**: the more stale a plan is allowed
|
||
to be, the more the tail rule is doing real work, so it should be re-read if
|
||
`TARIFF_MAX_AGE_S` is ever widened again.
|
||
|
||
**`TariffPlan.from_feed` cannot produce a series this code has not seen.** It is
|
||
a pure predicate on `origin` (`== "provider"`), and TARIFF-12's decision keeps or
|
||
replaces a **whole plan object**. It never splices two reads into one series, so
|
||
it cannot hand `at()` an unsorted or duplicated point list — the case §3 covers
|
||
defensively and that nothing shipped produces.
|
||
|
||
**Re-graded on this base.** All 29 files green. `app/price.py` is byte-identical
|
||
to the TARIFF-10 merge (`90d8ca31f7c0109d`, 85953 B) because TARIFF-12 does not
|
||
touch it. **M1** re-driven: +68 B, red on the same **24 named checks** across
|
||
both files. **M3** re-driven: **+0 B**, red on the same 4. This branch
|
||
contributes **zero diff** to `app/main.py` and `app/static_tariff.py`.
|