# 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`.