TARIFF-14: the point whose own interval covers the instant
checks / unit (pull_request) Failing after 2s

`_Series.at()` asked `s <= u < s + 1 h` of every point IN LIST ORDER and
returned the FIRST match. On the hourly shape every fixture in this
repository carried before TARIFF-08 that is right by accident. Belgium
moved to a 15-minute MTU on 2025-10-01 and the live day-ahead feed
returns 96 points at 900 s, where it is wrong twice over - measured
against the shipped code at 5059e28:

    series: 02:00=0.10  02:15=0.11  02:30=0.12  02:45=0.13  03:00=0.14

    ask +15min -> 02:00  0.10   <-- WRONG (wanted 02:15)
    ask +60min -> 02:15  0.11   <-- WRONG (wanted 03:00)
    ask +75min -> 02:30  0.12   <-- WRONG (wanted 03:15)

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.

`arbitrage._act()` tests `here.start == v.cheapest.start` /
`== v.dearest.start` to decide whether this interval is the buy one or
the sell one. That identity almost never held, so the strategy idled
through exactly the interval it exists to trade, and read §8.3's
`here.provisional` and §17's `price < 0` off the same wrong point.
`static_tariff.py` emits hourly points, so static sites were never
affected; this is reachable only down the dynamic path TARIFF-08/09
made work.

THE RULE: a point covers from its own start until the next point's
start, and never more than one hour. Quarter-hourly takes 900 s,
hourly is unchanged, and a MIXED series - FCST-04's carry-forward puts
hourly carried points beside quarter-hourly firm ones, spacing
[900, 3600] in 70 % of soak samples - takes each point's OWN gap.

The one-hour cap is load-bearing: FCST-04 leaves an hour OUT when
nothing published it and yesterday has no value, and `at()` raising
Absence for it is what sends the caller to the static profile (§11.4).
Uncapped, "until the next point's start" would stretch the point before
a three-hour hole across the whole hole and answer every instant in it
with a stale price.

THE LAST POINT is given the same span as the point before it (one hour
when it is the only point). A flat one-hour tail would answer 00:30
TOMORROW with today's 23:45 price. The cost - a mixed series ending at
the firm/carried seam loses its last 45 minutes to Absence - is argued
in DECISIONS/TARIFF-14-the-window-a-point-covers.md §2 rather than
buried; the direction is the safe one.

`at()` now reads a point's NEIGHBOURS, so it would depend on
sortedness. It establishes that itself: the lookup index is sorted by
UTC start once, at construction, so no caller's list order reaches it.
A series handed its 96 points in reverse answers identically, and that
is a check. `covers()` shared the defect exactly - it has no rule of
its own, it asks `at()` - and is fixed by the same change; `window()`
did not, and its docstring now says why.

Nothing about §9.3 or a point's `start` changed. PricePoint still has
three fields; the span is derived from the neighbours, never stored.

Graded by test_price.py section 13 (900 s, 3600 s, mixed, holes, the
tail, caller order, duplicate starts, covers()/window(), the no-data
contract) and end to end by test_arbitrage.py section 10, which drives
decide() over a 96-point quarter-hourly day whose cheapest and dearest
quarters are deliberately NOT on a whole hour, plus §17, §8.3 and the
mixed series. Eight mutations, all red, including a ZERO-BYTE one on
the tail rule; byte deltas and what the arbitrage file could not see
are in the decision note §6. All 29 test files green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
glenn schrooyen
2026-09-12 21:22:28 +02:00
co-authored by Claude Opus 5
parent 88c59a9918
commit 7f2c4e20e1
5 changed files with 1142 additions and 9 deletions
@@ -0,0 +1,234 @@
# 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` 13d; 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` 13e 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` 13f 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. It is not reachable from anything shipped — FCST-04 carries **whole**
hours and `generic_dayahead` publishes whole days, so the four quarters of an
hour are firm or carried together — but it is an assumption about the
*producers*, not a property of the *gate*, and it is written down here rather
than left for the next sub-hourly feed 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. Clean `app/price.py` = `30d1a56f0117e571`, 83742 B.
| | mutation | Δ | red in |
|---|---|---|---|
| M1 | `at()` back to the shipped fixed one-hour scan over `self.points` | +68 B | **24 named checks**: `test_price.py` 13b ×4, 13c ×3, 13d, 13e ×3, 13f, 13g; `test_arbitrage.py` 10b ×6, 10c ×3, 10d ×2 |
| M2 | drop the one-hour cap | −68 B | `test_price.py` 13d |
| M3 | flat one-hour tail instead of the span in front | **+0 B** | `test_price.py` 13e ×3, 13g — 4 checks |
| M4 | trust the caller's list order — drop the sort | −61 B | `test_price.py` 13f |
| M5 | `covers()` grows its own hour-wide rule | +95 B | `test_price.py` 13e, 13g |
| M6 | `window()` becomes a coverage query | −37 B | `test_price.py` 13g |
| M7 | the window's end becomes inclusive | +1 B | `test_price.py` 13b ×4, 13c ×4, 13d, 13e ×4, 13f ×2, 13g — 16 checks |
| M8 | the window's start becomes exclusive | −1 B | `test_price.py` 13b ×4, 13c ×4, 13d, 13f ×2, 13g, 13h, 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`.