TARIFF-14: the point whose own interval covers the instant #80

Merged
admin merged 3 commits from TARIFF-14 into release/1.0 2026-09-13 01:56:05 +02:00
Owner

The defect, reproduced rather than theorised

_Series.at() asked s <= u < s + 1 h of every point in list order and
returned the first match. On the hourly shape every price fixture in this
repository carried before TARIFF-08 that is right by accident. Belgium moved to
a 15-minute MTU on 2025-10-01; the live day-ahead feed returns 96 points
at 900 s
, where it is wrong twice over. 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 + 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)

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.

What it cost is not "a slightly wrong price". 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 matched the
extremum, 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.

After, same probes, all three resolutions

ask 900 s 3600 s mixed [900, 3600]
+0 min 02:00 0.10 02:00 0.10 02:00 0.10
+15 min 02:15 0.11 02:00 0.10 02:15 0.11
+45 min 02:45 0.13 02:00 0.10 02:45 0.13
+60 min 03:00 0.14 03:00 0.11 03:00 0.14
+75 min 03:15 0.15 03:00 0.11 03:00 0.14 (hourly point)

The hourly column is byte-for-byte what shipped. That is exactly why nothing
caught this: the suite had been exercising the one resolution on which the
broken rule and the right one agree.

The rule

A point covers from its own start until the next point's start, and never
more than one hour.

Each point takes its own gap, which is what the mixed case needs: 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 — the normal
steady state, not an edge case.

The one-hour cap is load-bearing. FCST-04 leaves an hour out of the
series when nothing published it and yesterday has no value either, and at()
raising Absence for that hour 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 — filling holes, introduced by the fix for a different
defect.

The last point's window — decided, not inherited

There is no next start and §9.3 says nothing about the final interval's width.
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
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. The cost of the choice is stated rather than buried:
a mixed series whose last point is a carried hourly one behind quarter-hourly
firm ones loses its last 45 minutes to Absence. That direction is the safe
one — the caller falls back to the static profile, the point is carried so §8.3
forbids irreversible action on it anyway, and it sits at the far end of a 48 h
horizon. Under-covering costs a missed decision; over-covering prices a real
interval with a number that does not describe it. Argued in
DECISIONS/TARIFF-14-the-window-a-point-covers.md §2.

Sortedness: at() now depends on it, and 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: the lookup index is sorted by UTC start once, at
construction
, so no caller's list order can reach at().

That matters because the three things that "guarantee" order today each
guarantee it somewhere else — _validate_raw() on the load_prices() path,
price_history._parse_points() on the store read path, FCST-04's fill in that
one producer — while PriceRead.series() itself has never enforced anything.
Three producers agreeing today is a coincidence with good references, not an
invariant. A series handed its 96 points in reverse answers identically, and
that is a check (red the moment the sort is removed).

Duplicate 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. Said as a check, not left to be found.

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. Kept as a delegation: 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. It is not a coverage query, and its docstring now says so. No
    callers in app/.

The arbitrage-level check — the property the defect actually broke

test_arbitrage.py section 10 drives the shipped decide() over a
96-point quarter-hourly day whose cheapest quarter is a :45 and whose
dearest is a :15 — neither on a whole hour, asserted as its own check,
without which the section would grade nothing an hourly fixture does not
already grade. It also states, as a check, that the shipped rule and this one
return different points at both probes, so the fixture can reach the failure
it names.

It grades: the verdict's extremes are the quarters; _point_at() matches them
by identity, which is what makes _act()'s here.start == v.cheapest.start
true; decide() charges and discharges in those quarters at their own
prices
, with those prices in the reason strings; a non-extremum quarter idles
for the right named reason and reports its own price; §17 refuses a negative
sell quarter and the same series with that one sign flipped discharges; and
the whole thing again on a mixed [900, 3600] plan, including an instant
inside a carried hour.

Mutations — eight, all red

Purged before every run (TEST-15), reverted with the tree fingerprint checked
afterwards (MUTATION.md habit 18), backup at a mktemp path per invocation.
Clean app/price.py = 90d8ca31f7c0109d, 85953 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 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
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 16 checks across 14b–14g
M8 the window's start becomes exclusive −1 B 14 checks, including the pre-existing section 6 one

M3 is the zero-byte one and it is the tail decision itself — the size
MUTATION.md names as the risky one, 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. That is honest rather than a gap rounded off:
they are at()-level properties (holes, caller order, covers(), window(),
the start boundary) that no arbitrage fixture reaches, because its probes sit
seven minutes into a slot and its 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.

What this 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 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 — but that is an assumption about the
producers, not a property of the gate, so it is written down in the decision
note §5 rather than left for the next sub-hourly feed to find. Fixing it means
teaching committed_hours() about resolution, a data-model question this card
deliberately does not open.

Scope

app/price.py only, plus its two suites, the changelog and the decision note.
No change to a point's start semantics or to the canonical §9.3 format —
PricePoint still has three fields and the span is derived from the neighbours,
never stored. at() is a linear scan over the index (192 entries on the shipped
48 h horizon) on the 30 s control path; a bisect would be correct and is not
worth the second way to be wrong at that size.

All 29 test files green. ⚠️ Not deployed: no ha addons update, no writes to
/addons/, no contact with the Supervisor or the inverter.


Rebased twice while open — what moved, and what was re-graded

Base moved 88c59a9 → 559f0eb (TARIFF-10) → a6e64fb (TARIFF-12).

The earlier CI failure on head 7f2c4e2 was NOT a test failure. The
workflow's grade the merge, not the branch step refused the PR because it did
not apply, and said so itself: REFUSED: refs/pull/80/head does not merge into release/1.0. NOTHING HAS BEEN TESTED. This is not a test failure - no test ran.
Conflicts were CHANGELOG.md and test_price.py; app/price.py auto-merged.
The job died at the merge step and no suite executed.

Conflicts resolved. CHANGELOG.md both times: every entry under the single
## Unreleased in merge order, TARIFF-10 then TARIFF-12 then this card, no
version number picked
. test_price.py once: both TARIFF-10 and this card had
appended a section "13" — nothing is dropped, TARIFF-10's stays 13, this
card's is renumbered 14, and TARIFF-10's temp-directory teardown stays at
the end of the file.

Neither base change touches this fix. TARIFF-10 added provider_id() /
provider_configured() beside price_option() — the option-reading layer,
nothing in _Series, at(), covers(), window() or _cover_table().
TARIFF-12 does not touch app/price.py at all. The merged at() is
byte-identical to the one every mutation below was driven against.

TARIFF-10 widens who has a series, not who looks one up.
run_tariff_refresh() now runs with arbitrage off, so a provider-configured
site has a standing plan where it had none — but it adds no at() consumer to
the 30 s tick: static_status() builds a static plan (hourly, immune),
main.py calls only plan.words on the fetched plan, and
price_forecast.price_permits()/begin_phase() have no caller in
app/main.py
. Decision note §7.

TARIFF-12 makes the tail decision reachable for the first time — and picks the
same answer.
A served plan may now be up to 3 × TARIFF_REFRESH_S = 5400 s
old. Spans and the one-hour cap are unaffected structurally: every window comes
from the neighbours' absolute start instants, frozen in a tuple, computed
once at construction, so the index cannot go stale. The tail is ~46½ h away at
the shipped 48 h horizon — but price_horizon_h is int(1,72), so a 1 h
horizon is supported, and a 90-minute serve-stale window against a 1 h series
puts now past the end. Under the chosen rule at() raises Absence 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 that quarter had ended. Decision note §8.

Re-graded on the final base a6e64fb, not carried over: all 29 files
green
; M1 (the shipped rule, +68 B) red on the same 24 named checks
across both files; M3 (the tail rule, 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.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU

## The defect, reproduced rather than theorised `_Series.at()` asked `s <= u < s + 1 h` of every point **in list order** and returned the **first** match. On the hourly shape every price fixture in this repository carried before TARIFF-08 that is right by accident. Belgium moved to a **15-minute MTU on 2025-10-01**; the live day-ahead feed returns **96 points at 900 s**, where it is wrong twice over. 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 + 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) ``` 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. **What it cost is not "a slightly wrong price".** `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 matched the extremum, 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. ## After, same probes, all three resolutions | ask | 900 s | 3600 s | mixed `[900, 3600]` | |---|---|---|---| | +0 min | 02:00 `0.10` | 02:00 `0.10` | 02:00 `0.10` | | +15 min | **02:15 `0.11`** | 02:00 `0.10` | **02:15 `0.11`** | | +45 min | **02:45 `0.13`** | 02:00 `0.10` | **02:45 `0.13`** | | +60 min | **03:00 `0.14`** | 03:00 `0.11` | **03:00 `0.14`** | | +75 min | **03:15 `0.15`** | 03:00 `0.11` | 03:00 `0.14` (hourly point) | The hourly column is byte-for-byte what shipped. That is exactly why nothing caught this: **the suite had been exercising the one resolution on which the broken rule and the right one agree.** ## The rule > **A point covers from its own start until the next point's start, and never > more than one hour.** Each point takes **its own** gap, which is what the mixed case needs: 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** — the normal steady state, not an edge case. **The one-hour cap is load-bearing.** FCST-04 leaves an hour *out* of the series when nothing published it and yesterday has no value either, and `at()` raising `Absence` for that hour 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 — **filling holes, introduced by the fix for a different defect.** ## The last point's window — decided, not inherited There is no next start and §9.3 says nothing about the final interval's width. **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` | | 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. **The cost of the choice is stated rather than buried**: a mixed series whose last point is a carried hourly one behind quarter-hourly firm ones loses its last 45 minutes to `Absence`. That direction is the safe one — the caller falls back to the static profile, the point is carried so §8.3 forbids irreversible action on it anyway, and it sits at the far end of a 48 h horizon. Under-covering costs a missed decision; over-covering prices a real interval with a number that does not describe it. Argued in `DECISIONS/TARIFF-14-the-window-a-point-covers.md` §2. ## Sortedness: `at()` now depends on it, and 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: **the lookup index is sorted by UTC start once, at construction**, so no caller's list order can reach `at()`. That matters because the three things that "guarantee" order today each guarantee it somewhere else — `_validate_raw()` on the `load_prices()` path, `price_history._parse_points()` on the store read path, FCST-04's fill in that one producer — while **`PriceRead.series()` itself has never enforced anything**. Three producers agreeing today is a coincidence with good references, not an invariant. A series handed its 96 points in **reverse** answers identically, and that is a check (red the moment the sort is removed). **Duplicate 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. Said as a check, not left to be found. ## `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. Kept as a delegation: 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. It is **not** a coverage query, and its docstring now says so. No callers in `app/`. ## The arbitrage-level check — the property the defect actually broke `test_arbitrage.py` **section 10** drives the shipped `decide()` over a **96-point quarter-hourly day** whose cheapest quarter is a `:45` and whose dearest is a `:15` — **neither on a whole hour**, asserted as its own check, without which the section would grade nothing an hourly fixture does not already grade. It also states, as a check, that the shipped rule and this one return **different points** at both probes, so the fixture can reach the failure it names. It grades: the verdict's extremes are the quarters; `_point_at()` matches them **by identity**, which is what makes `_act()`'s `here.start == v.cheapest.start` true; `decide()` charges and discharges in those quarters **at their own prices**, with those prices in the reason strings; a non-extremum quarter idles for the right named reason and reports **its own** price; §17 refuses a negative sell **quarter** and the same series with that one sign flipped discharges; and the whole thing again on a **mixed** `[900, 3600]` plan, including an instant inside a carried hour. ## Mutations — eight, all red Purged before every run (TEST-15), reverted with the tree fingerprint checked afterwards (MUTATION.md habit 18), backup at a `mktemp` path per invocation. Clean `app/price.py` = `90d8ca31f7c0109d`, 85953 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` 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 | | 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 | 16 checks across 14b–14g | | M8 | the window's start becomes exclusive | −1 B | 14 checks, including the pre-existing section 6 one | **M3 is the zero-byte one and it is the tail decision itself** — the size MUTATION.md names as the risky one, 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. That is honest rather than a gap rounded off: they are `at()`-level properties (holes, caller order, `covers()`, `window()`, the start boundary) that no arbitrage fixture reaches, because its probes sit seven minutes into a slot and its 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`. ## What this 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 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 — but that is an assumption about the *producers*, not a property of the *gate*, so it is written down in the decision note §5 rather than left for the next sub-hourly feed to find. Fixing it means teaching `committed_hours()` about resolution, a data-model question this card deliberately does not open. ## Scope `app/price.py` only, plus its two suites, the changelog and the decision note. **No change to a point's `start` semantics or to the canonical §9.3 format** — `PricePoint` still has three fields and the span is derived from the neighbours, never stored. `at()` is a linear scan over the index (192 entries on the shipped 48 h horizon) on the 30 s control path; a bisect would be correct and is not worth the second way to be wrong at that size. **All 29 test files green.** ⚠️ Not deployed: no `ha addons update`, no writes to `/addons/`, no contact with the Supervisor or the inverter. --- ## Rebased twice while open — what moved, and what was re-graded Base moved `88c59a9` → `559f0eb` (**TARIFF-10**) → `a6e64fb` (**TARIFF-12**). **The earlier CI `failure` on head `7f2c4e2` was NOT a test failure.** The workflow's *grade the merge, not the branch* step refused the PR because it did not apply, and said so itself: `REFUSED: refs/pull/80/head does not merge into release/1.0. NOTHING HAS BEEN TESTED. This is not a test failure - no test ran.` Conflicts were `CHANGELOG.md` and `test_price.py`; `app/price.py` auto-merged. The job died at the merge step and no suite executed. **Conflicts resolved.** `CHANGELOG.md` both times: every entry under the single `## Unreleased` in merge order, TARIFF-10 then TARIFF-12 then this card, **no version number picked**. `test_price.py` once: both TARIFF-10 and this card had appended a section "13" — nothing is dropped, TARIFF-10's stays **13**, this card's is renumbered **14**, and TARIFF-10's temp-directory teardown stays at the end of the file. **Neither base change touches this fix.** TARIFF-10 added `provider_id()` / `provider_configured()` beside `price_option()` — the option-reading layer, nothing in `_Series`, `at()`, `covers()`, `window()` or `_cover_table()`. TARIFF-12 does not touch `app/price.py` at all. The merged `at()` is byte-identical to the one every mutation below was driven against. **TARIFF-10 widens who has a series, not who looks one up.** `run_tariff_refresh()` now runs with arbitrage off, so a provider-configured site has a standing plan where it had none — but it adds no `at()` consumer to the 30 s tick: `static_status()` builds a **static** plan (hourly, immune), `main.py` calls only `plan.words` on the fetched plan, and `price_forecast.price_permits()`/`begin_phase()` have **no caller in `app/main.py`**. Decision note §7. **TARIFF-12 makes the tail decision reachable for the first time — and picks the same answer.** A served plan may now be up to `3 × TARIFF_REFRESH_S = 5400 s` old. Spans and the one-hour cap are unaffected structurally: every window comes from the neighbours' **absolute** `start` instants, frozen in a tuple, computed once at construction, so the index cannot go stale. The tail is ~46½ h away at the shipped 48 h horizon — but `price_horizon_h` is `int(1,72)`, so a **1 h** horizon is supported, and a 90-minute serve-stale window against a 1 h series puts `now` past the end. Under the chosen rule `at()` raises `Absence` 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 that quarter had ended. Decision note §8. **Re-graded on the final base `a6e64fb`, not carried over:** all **29 files green**; **M1** (the shipped rule, +68 B) red on the same **24 named checks** across both files; **M3** (the tail rule, **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`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
admin added 1 commit 2026-09-12 21:23:29 +02:00
TARIFF-14: the point whose own interval covers the instant
checks / unit (pull_request) Failing after 2s
7f2c4e20e1
`_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
admin added 1 commit 2026-09-12 21:38:43 +02:00
Merge release/1.0 (TARIFF-10) into TARIFF-14
checks / unit (pull_request) Successful in 7m10s
435e1b6f7c
CI on PR #80 was NOT a test failure. The workflow's "grade the merge, not
the branch" step refused the PR because it does not apply, and said so
itself: "REFUSED: refs/pull/80/head does not merge into release/1.0.
NOTHING HAS BEEN TESTED. This is not a test failure - no test ran."
Conflicts were CHANGELOG.md and test_price.py; app/price.py auto-merged.

CONFLICTS RESOLVED

* CHANGELOG.md - both entries belong under ## Unreleased. TARIFF-10's
  table row and section come first because it landed first. No version
  number is picked here; PKG-06/07's note stands untouched.

* test_price.py - BOTH cards appended a section "13". Nothing is
  dropped: TARIFF-10's stays 13, this card's is renumbered 14, and
  TARIFF-10's temp-directory teardown stays at the end of the file. The
  two share no fixture, option dict or helper name.

WHAT TARIFF-10 CHANGES FOR THIS CARD - nothing in the reasoning, and the
check is recorded rather than asserted (decision note §7).

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 - the option-reading layer, touching nothing in _Series,
at(), covers(), window() or _cover_table(). The merged at() is
byte-identical to the one the mutations were driven against.

Behaviourally TARIFF-10 makes run_tariff_refresh() run with arbitrage
OFF, so a provider-configured site now has a standing plan - and
therefore a _Series - where before it had none. It does NOT add an at()
consumer to the 30 s tick: static_status() builds a STATIC plan (hourly,
immune), main.py calls only plan.words on the fetched plan, and
price_forecast.price_permits()/begin_phase() have no caller in main.py
at all. So TARIFF-10 neither creates nor closes any exposure this card
is about - it enlarges the defect's reach the moment such a site arms
trading, which strengthens the fix without changing it.

RE-GRADED ON THE MERGED TREE, not carried over

* all 29 test files green;
* all EIGHT mutations re-driven, purged before each run, each reverted
  with the fingerprint re-checked. Same verdicts and same red-check
  counts as before the merge: M1 (the shipped one-hour scan, +68 B) red
  on 24 named checks across both files; M3 (the ZERO-BYTE tail rule) red
  on 4; M2/M4/M6 on 1 each, M5 on 2, M7 on 16, M8 on 14.
* Clean app/price.py on the merged tree: 90d8ca31f7c0109d, 85953 B.
* None of TARIFF-10's 650 new lines of checks moves under any of the
  eight - they grade resolve_tariff()'s reachability from a
  configuration, not the lookup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
admin added 1 commit 2026-09-13 01:47:25 +02:00
Merge release/1.0 (TARIFF-12) into TARIFF-14
checks / unit (pull_request) Successful in 7m13s
028c3e53fb
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
admin merged commit 8d09b30f16 into release/1.0 2026-09-13 01:56:05 +02:00
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#80