FCST-05: one byte stops stopping the add-on, and the write path gets the read path's standard #74

Merged
admin merged 1 commits from FCST-05 into release/1.0 2026-09-10 01:13:41 +02:00
7 changed files with 847 additions and 50 deletions
+86 -2
View File
@@ -13,6 +13,89 @@ if it has, it needs 0.10.0. Whoever deploys decides.
| | |
|---|---|
| _this branch_ | FCST-04 - the carry-forward fill reaches the plan, and the day it carries survives a restart |
| _this branch_ | FCST-05 - one byte stops stopping the add-on, and the write path gets the read path's standard |
### FCST-05 - what FCST-04's own contract still did not hold
**A single non-UTF-8 byte in `/data/price_history.json` stopped the add-on from
starting.** `_load()` caught `(json.JSONDecodeError, OSError)`.
`UnicodeDecodeError` is `ValueError -> UnicodeError -> UnicodeDecodeError` -
neither of those, and `issubclass(UnicodeDecodeError, OSError)` is **False** -
so it escaped `_load()` -> `_open()` -> `PriceHistory.__init__`, which
`Controller.__init__` does not guard. Reproduced: `UnicodeDecodeError: 'utf-8'
codec can't decode byte 0xff`, and **the controller did not come up**. That is the same class as the day-record defect the store was reworked
for, in the one byte class the rework left uncovered: valid-UTF-8-invalid-JSON,
a UTF-8 BOM and a non-dict day record all degraded correctly. The suite could
not see it because its "unreadable file" fixture writes `"{ this is not json"`,
which is **valid UTF-8**. The handler now names the `ValueError` family - the
file's CONTENT - beside `OSError`, the file's BYTES, and a fixture holding a
genuinely undecodable byte is in `test_price_history.py` section 5.
**A malformed `start` under the day the feed is writing to killed the tariff
plan permanently.** `_remember()` merged a day's points with
`{q["start"]: q ...}` and sorted on `q["start"]`, trusting a value read off
`/data` to be a hashable, sortable string. An `int`, `null`, a `dict` or a
`list` there raised `TypeError`; `run_tariff_refresh` catches `Exception`, so
the add-on stayed **up** while **every refresh raised, forever** - the plan
retiring after `TARIFF_MAX_AGE_S` and arbitrage and the cheap window off for
good, with no self-heal because `_remember()` raised before reaching `_save()`.
The read path (`_parse_points()`) was properly defensive throughout, which is
why `yesterday()` survived and the existing fixture passed: it files its
malformed rows under a day the feed is **not** writing to, so `_remember()`
never opened that record. **The two paths now share one validator**,
`_point_of()`, and neither has a standard of its own. A row that is not a point
is dropped and reported, and `_save()` rewrites the record clean.
⚠️ **AND THE STATUS ROW SAID THE STORE WAS HEALTHY WHILE THE PLAN WAS DEAD.**
`words` read `'2026-01-15 (1 h)'` about the record that was killing every
refresh. `_load()` now counts the rows inside a kept record as well as the
record itself, so the store stops reporting plain health from the moment it is
opened; the note names what was **found** at open and what was **dropped** by
the refresh, and clears itself once the file has been rewritten.
⚠️ **AND THE ROW COUNTED POINTS AND CALLED THEM HOURS.** `f"{d} ({_n_points(d)}
h)"` on the quarter-hourly feed this add-on already supports reads
`'2026-01-13 (96 h)'` for a 24-hour day - the same points/hours conflation the
sub-hourly defect above was about, on the row that defect's own card shipped.
The cell is now `2026-01-13 (96 points over 24 h)`: two facts, each under its
own unit, and the hour figure is the one that says whether tomorrow morning's
fill has a whole day to carry.
⚠️ **AND THE MERGE KEY IS THE INSTANT RATHER THAN ITS SPELLING**, which is what
rule 4 already said. Aware datetimes hash and compare by instant. The string
key merged by spelling, and the lexicographic sort beside it put the second
02:00 of the 25-hour October day (`+01:00`) **before** the first (`+02:00`);
only the stored order was affected, because `_parse_points()` re-sorts by
instant on the way out.
⚠️ **TWO STATED REASONS WERE FALSE, AND THIS REPOSITORY TREATS THAT AS A
DEFECT.** `price_forecast._audit()`'s "WHY IT IS STRICTER" section claimed the
rework refused two things FCST-03's audit did not. A differential over the
whole domain the audit can express - every published mask over 3 hours crossed
with every output series of length 0-3 - found **zero divergence in both
directions**: the reworked audit is exactly **EQUAL** to FCST-03's, not
stronger. The old `seen` count already refused the same published point emitted
twice (one point twice is one hour twice), and the old **identity** rule
already refused a carried point sharing a published hour. The only behavioural
change is the intended **relaxation** - several distinct published points in
one hour - and that stays. The docstring and the two check texts now say what
is true, and the differential is committed as a check in
`test_price_forecast.py` rather than left as prose.
⚠️ **AND `_audit` IS NOT WHAT STOPS THE DECIMATION DEFECT RECURRING.** It does
not check **completeness**: fed the decimation directly - four published points
in an hour, output emitting only the last - it **accepts**, and it accepts an
empty output. That is a limit, not a regression, and the module docstring said
otherwise by presenting the audit as the post-condition guarding provenance.
What actually guards it is the point **count** and the pass-through **identity**
checks in `test_price_forecast.py` and `test_price_history.py`, and the text now
points at them. It is **not** made complete: `published` is the whole read while
the fill emits only `[since, until)`, so "every published point appears" is not
a true post-condition of those two arguments. Measured - the rule as stated
takes the 1 h, 12 h and 48 h horizon checks red on legitimate series.
**No option was added, `config.yaml` is untouched, and nothing was deployed.**
**FCST-03 shipped the fill and no caller.** `price_forecast.fill_from_yesterday()`
was complete and checked, and so were the consumers written for it -
@@ -58,8 +141,9 @@ spread it would have created was never compared against anything.
no durable volume passes the explicit `NoHistory()`. A default of `None`
meaning "skip the fill" would hand a caller who forgot the argument exactly
the truncated horizon this card removes, silently.
* A new status row, **Price history**, names the days held and their hour
counts.
* A new status row, **Price history**, names the days held, how many points
each holds and how many whole hours they cover (see FCST-05: it shipped
counting points and printing them under the hour's name).
⚠️ **ONE CHANGE HERE MOVES ENERGY, AND IT IS NOT THE ONE THE CARD IS NAMED FOR.**
Carried hours are provisional, so §8.3 keeps them out of the trade set: the fill
+7 -2
View File
@@ -1474,8 +1474,13 @@ a cheap night that has already gone used to sit in the series as the cheapest
hour of the day, and a cycle can never buy in an hour that is past — so the
trade was quietly never taken. The spread is now taken over hours still ahead.
A new status row, **Price history**, says which days are held and how many hours
each has. If it reads `empty` or `unavailable`, nothing is carried, the horizon
A new status row, **Price history**, says which days are held, how many price
points each holds and how many whole hours those cover — `2026-01-13 (96 points
over 24 h)`. ⚠️ **Those are two different numbers and the row used to print one
of them under the other's name**: it counted points and labelled them `h`, so a
quarter-hourly feed read `96 h` for a 24-hour day. The figure that says whether
tomorrow morning's fill has a whole day to carry is the **hour** one. If it
reads `empty` or `unavailable`, nothing is carried, the horizon
is as short as the feed makes it, and every other guarantee is unchanged — a
cache that cannot answer is not allowed to be a controller that cannot regulate.
The store is rebuilt by the next successful fetch; on a brand-new install the
+15
View File
@@ -127,6 +127,21 @@ Two rules come out of it, and both are cheap:
function, that pair is the first thing to try — TEST-16's two survivors were
adjacent lines in one function and neither was individually live.
⚠️ **AND FCST-05 WALKED INTO IT AGAIN, IN ITS OWN NEW CHECK, WHICH IS WHY THE
RULE IS “mutate every check you add”.** `price_history.py` reports a malformed
point row from **two** places into **one** field: `_usable_days()` records what
it FOUND when the file was opened, and `_remember()` records what it DROPPED on
the next refresh. Both land in `last_error`, and `words` prints `last_error`.
The first draft of the new check asked `"malformed point row" in words` — which
whichever rung is still standing satisfies — so deleting `_remember()`'s note
entirely was a **−1 B SURVIVOR of the whole suite**, on the half of the fix that
tells an operator the cache **self-healed**. It is graded on the TENSE now
(`… in <day>` at open, `… dropped from <day>` after the refresh), the two are
asserted to DIFFER, and the paired mutation — both notes deleted, −91 B — is
red on 15 named checks. Same shape as TEST-16 and the same fix: **grade the
reason, not the verdict**, and when two rungs answer one stimulus, mutate them
together.
## The third way a check lies: its DATA cannot reach the failure it names
Stale bytecode manufactures survivors from a mutation that never ran. Masking
+72 -8
View File
@@ -55,6 +55,17 @@ So the flag is not passed through anywhere in this file:
It is a post-condition, not a filter: it never fixes a flag, because a
module that quietly corrects its own provenance is one whose provenance
cannot be trusted.
⚠️ IT GRADES THE POINTS THAT ARE EMITTED AND NOTHING ABOUT THE ONES THAT
ARE NOT (FCST-05). `_audit()` is a provenance check, NOT a completeness
check: fed FCST-04's own decimation defect - four published points in an
hour, only the last emitted - it ACCEPTS, and it accepts an empty output
too. Measured, and the FCST-03 audit it replaced was equally blind, so
this is a limit rather than a regression. What actually guards against a
decimated series is the point COUNT and the pass-through IDENTITY checks
in `test_price_forecast.py` (section on the quarter-hourly feed) and
`test_price_history.py`; see `_audit()`'s own docstring for why the
completeness rule is not expressible from its two arguments.
* and test_price_forecast.py walks THIS FILE'S AST and fails if any
`PricePoint(...)` in it passes anything but a literal for `provisional`, so
the pass-through idiom cannot come back in a later edit and pass review by
@@ -225,14 +236,67 @@ def _audit(points, published) -> None:
* CARRIED - the hour must not appear in `published` AT ALL, the flag must
be exactly True, and that hour may hold no second point.
⚠️ WHAT REPLACED "AT MOST ONE POINT PER HOUR", AND WHY IT IS STRICTER. This
audit began as a `seen` set: any repeated `_utc_hour` raised, because a
duplicated hour is how a DST day gets mispriced. A bare hour count cannot
express a quarter-hourly feed, where four distinct published points share
one hour legitimately - so the count was replaced by the two rules above,
which subsume it. A repeated CARRIED hour still raises; so does the same
published point emitted twice; and a carried point may no longer share an
hour with a published one, which the count never checked at all.
⚠️ WHAT REPLACED "AT MOST ONE POINT PER HOUR", AND WHAT THAT DID AND DID
NOT CHANGE. This audit began as FCST-03's `seen` set: any repeated
`_utc_hour` raised, beside an identity rule `p is not src`. A bare hour
count cannot express a quarter-hourly feed, where four distinct published
points share one hour legitimately - so the count was replaced by the two
rules above, which subsume it.
⚠️ AND IT IS NOT STRICTER. FCST-04's version of this paragraph claimed two
strengthenings and BOTH WERE FALSE; FCST-05 measured it. A differential
over the whole domain this audit can express - every published mask over 3
hours crossed with every output series of length 0-3 drawn from
{published, published-provisional, carried, carried-unflagged,
copy-of-published} - found ZERO divergence in EITHER direction:
OLD raised, NEW accepted (WEAKER): 0
OLD accepted, NEW raised (STRONGER): 0
The two specific claims, one at a time:
* "so does the same published point emitted twice" - the OLD `seen`
count already raised on that, because emitting one point twice is one
HOUR twice. Preserved by the identity/`emitted` rules, not added.
* "a carried point may no longer share an hour with a published one,
which the count never checked at all" - true of the COUNT, and the old
IDENTITY rule raised on it anyway: a carried point in a published hour
took the `else` branch and failed `p is not src`. The message was about
a copy rather than about a carry, which is the only thing that moved.
The one real behavioural change is the intended RELAXATION - several
distinct published points in one hour, which the old audit could not
express at all and refused. That relaxation is why this was rewritten, and
it stays. A right decision with a fabricated reason is still a defect here.
⚠️ AND THE REAL LIMIT, WHICH IS NOT A REGRESSION AND IS NOT SMALL: THIS
AUDIT DOES NOT CHECK COMPLETENESS. It grades every point that IS emitted
and says nothing about the ones that are not. Fed the H1 decimation
directly - four published points in an hour, output emitting only the last
- it ACCEPTS; so does two of four, and so does an EMPTY output. So `_audit`
is NOT what stops H1 recurring. What stops it is the point COUNT and the
IDENTITY checks in `test_price_history.py` and `test_price_forecast.py`,
which compare the emitted series against the feed's own prints.
It is not made complete here, and the reason is structural rather than
effort: `published` is `_published_by_hour(read)` over the WHOLE read,
unwindowed, while `fill_from_yesterday()` emits only [since, until). A
published point outside the window is legitimately absent, so "every
published point appears" is not a true post-condition of these two
arguments - it would need the window passed in, which is a different
signature and a different check.
⚠️ AND THAT IS MEASURED, NOT ASSUMED. FCST-05 built the rule - raise unless
every point of every `published[u]` is emitted, +162 B - and ran it. It
takes the 1 h, 12 h and 48 h HORIZON checks in test_price_forecast.py RED,
on the shipped fill and a healthy feed: at an off-the-hour instant the
feed's already-elapsed prints are outside [since, until) and are correctly
dropped, and the rule calls that a provenance failure. (The differential
beside those checks scores the same rule as 49 STRONGER cases, so it is a
real change of behaviour and not a no-op.) The rule as stated is therefore
not merely unimplemented, it is WRONG - and stating a false post-condition
is MUTATION.md habit 16's defect: an instrument that asserts a completeness
it does not have is worse than no instrument.
⚠️ RAISES, NEVER REPAIRS. A module that silently corrects its own
provenance is one whose provenance means nothing; the only safe response to
+207 -28
View File
@@ -208,8 +208,65 @@ def _yesterday_of(instant: datetime) -> str:
return (brussels(instant).date() - timedelta(days=1)).strftime("%Y-%m-%d")
def _n_points(rec) -> int:
"""How many points a day record holds. 0 for anything that is not one.
def _point_of(row):
"""One stored row -> a `PricePoint`, or `None` if the row is not one.
THE ONE VALIDATOR, AND THE ASYMMETRY IT CLOSES (FCST-05's MEDIUM). This
file used to hold TWO standards for what a stored point is. The READ path
(`_parse_points()`) was properly defensive - every field checked, a
malformed row dropped and reported. The WRITE path was
`{q["start"]: q for q in raw if isinstance(q, dict) and "start" in q}`
followed by `sorted(..., key=lambda q: q["start"])`, which trusts
`q["start"]` to be a hashable, sortable string it never checked. Measured,
with the malformed row under the day the feed is currently writing to:
start is an int -> TypeError: '<' not supported between instances
of 'str' and 'int'
start is a dict -> TypeError: cannot use 'dict' as a dict key
start is null -> TypeError: '<' not supported between 'str' and
'NoneType'
start is a list -> TypeError: cannot use 'list' as a dict key
⚠️ AND THE BLAST RADIUS IS PERMANENT, WHICH IS WHY IT IS NOT MERELY UNTIDY.
`_remember()` is reached through `carry()` -> `resolve_tariff()` ->
`run_tariff_refresh`, which catches `Exception` - so the add-on stays UP
and EVERY refresh raises forever. The plan retires after
`TARIFF_MAX_AGE_S`, and arbitrage and the cheap window are off from then
on. There is no self-heal, because `_remember()` raises before it reaches
`_save()`. `_usable_days()` only required `rec["points"]` to be a LIST; the
elements were unchecked on the write path, and the existing fixture put its
malformed row under a day the feed was NOT writing to, so `_remember()`
never touched it.
So both paths call this, and neither of them has a standard of its own.
Returning `None` rather than raising is the contract: a caller decides
whether a bad row is dropped (both of them do) and how it is reported.
"""
if not isinstance(row, dict):
return None
try:
start = datetime.fromisoformat(row["start"])
value = float(row["eur_per_kwh"])
flag = row["provisional"]
except (KeyError, TypeError, ValueError):
return None
if start.tzinfo is None or start.utcoffset() is None \
or not isinstance(flag, bool):
return None
return PricePoint(start=start, eur_per_kwh=value, provisional=flag)
def _held(rec) -> tuple:
"""`(points, whole hours they cover)` for a day record. `(0, 0)` otherwise.
⚠️ POINTS AND HOURS ARE TWO NUMBERS, AND FCST-04's STATUS ROW PRINTED THE
FIRST UNDER THE SECOND'S NAME. The row was `f"{d} ({_n_points(rec)} h)"`,
which counts POINTS: on the quarter-hourly feed this add-on already
supports it read `'2026-01-13 (96 h)'` - 96 "h" for a 24-hour day. That is
the points/hours conflation H1 was raised for, on the row H1's own ticket
shipped. Both numbers are reported now, so an operator can see a day that
is 24 points over 24 h and a day that is 96 points over 24 h and tell them
apart, and neither number is wearing the other's unit.
⚠️ TOLERANT, AND IT IS THE SECOND LINE OF DEFENCE, NOT THE FIRST. `_load()`
drops a malformed day record and says so, so this should never see one. It
@@ -220,11 +277,52 @@ def _n_points(rec) -> int:
"oops"}}` gave `ok=True`, `last_error=None`, and an `AttributeError` out of
`words`, `yesterday()` and `resolve_tariff()` alike, with no self-heal
because `_remember()` raised before it could rewrite the file.
⚠️ AND IT COUNTS ONLY ROWS THAT ARE POINTS. A row `_point_of()` refuses is
not held prices, and a row count that included it would report a store as
healthier than it is - which is the second half of FCST-05's MEDIUM: the
status row said `'2026-01-15 (1 h)'` about a record whose single row was
killing every tariff refresh. What is dropped is named in `last_error`, and
`words` prints that beside the counts.
"""
if not isinstance(rec, dict):
return (0, 0)
raw = rec.get("points")
if not isinstance(raw, (list, tuple)):
return (0, 0)
n, hours = 0, set()
for row in raw:
pt = _point_of(row)
if pt is None:
continue
n += 1
hours.add(pt.start.astimezone(timezone.utc)
.replace(minute=0, second=0, microsecond=0))
return (n, len(hours))
def _row_of(day: str, points: int, hours: int) -> str:
"""One day's cell of the status row: what is held, in BOTH of its units.
⚠️ THE UNIT IS SPELT OUT ON EACH NUMBER. FCST-04's cell was
`f"{day} ({points} h)"` - the point count wearing the hour's name, which on
a quarter-hourly feed reads `'2026-01-13 (96 h)'` for a 24-hour day. What
an operator needs from this row is whether tomorrow morning's fill has a
whole day to carry, and that is the HOUR figure; the point count says how
finely it is priced. Neither answers for the other.
"""
return (f"{day} ({points} point{'' if points == 1 else 's'} "
f"over {hours} h)")
def _bad_rows(rec) -> int:
"""How many rows of a day record are not points. Total; never raises."""
if not isinstance(rec, dict):
return 0
pts = rec.get("points")
return len(pts) if isinstance(pts, (list, tuple)) else 0
raw = rec.get("points")
if not isinstance(raw, (list, tuple)):
return 0
return sum(1 for row in raw if _point_of(row) is None)
class PriceHistory:
@@ -269,11 +367,33 @@ class PriceHistory:
"one, and the fill has nothing to carry until the day "
"after that", self.path)
return
except (json.JSONDecodeError, OSError) as err:
except (ValueError, OSError) as err:
# A corrupt cache must not stop the controller, and must not be
# silently re-created either: the record is discarded, the reason
# is kept, and `yesterday()` reports it rather than answering
# "no record", which would read as a normal first day.
#
# ⚠️ `ValueError`, NOT `json.JSONDecodeError`, AND THAT IS
# FCST-05's HIGH. Reading this file has two failure families: the
# BYTES (`OSError`) and the CONTENT - and the content family has at
# least two members, because `open(..., encoding="utf-8")` decodes
# before `json` ever sees a character. `UnicodeDecodeError` is
# `ValueError -> UnicodeError -> UnicodeDecodeError`; it is NOT a
# `json.JSONDecodeError` and it is NOT an `OSError`
# (`issubclass(UnicodeDecodeError, OSError)` is False). So ONE
# non-UTF-8 byte in /data/price_history.json escaped `_load()` ->
# `_open()` -> `PriceHistory.__init__`, which is unguarded at
# main.py's `Controller.__init__`, AND THE ADD-ON DID NOT START.
# Measured: `UnicodeDecodeError: 'utf-8' codec can't decode byte
# 0xff in position 39`. Valid-UTF-8-invalid-JSON, a UTF-8 BOM and a
# non-dict day record all degraded correctly - it was the one byte
# class left uncovered, which is the same class as the defect the
# `_usable_days()` rework was raised for.
#
# The tuple names the FAMILY rather than the two members known
# today, because "the file's content is not readable as JSON" is
# exactly what this handler says, and a third `ValueError` subclass
# nobody has thought of is how this defect arrived the first time.
self.last_error = f"unreadable ({err})"
_LOG.warning("price history at %s is %s - starting from empty. "
"§8.3 carry-forward has nothing to carry until the "
@@ -318,20 +438,41 @@ class PriceHistory:
Dropped, not repaired: an unusable record has nothing in it to keep, and
the next `_remember()` writes a fresh one under that date.
⚠️ AND THE ROWS INSIDE A KEPT RECORD ARE COUNTED, NOT ONLY THE RECORD
(FCST-05). This checked that `rec["points"]` is a LIST and stopped
there, so a record holding one row with a `start` of `7` loaded as
healthy: `last_error=None`, and `words` said `'2026-01-15 (1 h)'` about
a store whose next `_remember()` would raise on that row and go on
raising forever. The rows are NOT dropped here - `_parse_points()` is
the read path's one filter and stays so - but they are COUNTED, so the
store stops reporting health it does not have from the moment it is
opened. `_remember()` drops them on the next refresh and `_save()`
rewrites the file, after which a restart clears the note.
"""
kept, dropped = {}, []
kept, dropped, malformed = {}, [], {}
for key, rec in days.items():
if not isinstance(key, str) or not isinstance(rec, dict) \
or not isinstance(rec.get("points"), list):
dropped.append(str(key))
continue
bad = _bad_rows(rec)
if bad:
malformed[key] = bad
kept[key] = rec
notes = []
if dropped:
notes.append(f"{len(dropped)} unusable day record(s) dropped "
f"({', '.join(sorted(dropped))})")
if malformed:
notes.append(
f"{sum(malformed.values())} malformed point row(s) in "
+ ", ".join(f"{d} ({n})" for d, n in sorted(malformed.items())))
if notes:
# Recorded, not silent: `yesterday()` and `words` both report
# `last_error`, and a cache that quietly discards half a file reads
# to an operator exactly like a normal first day.
self.last_error = (f"{len(dropped)} unusable day record(s) dropped "
f"({', '.join(sorted(dropped))})")
self.last_error = "; ".join(notes)
_LOG.warning("price history at %s: %s - the controller starts "
"anyway and the horizon is as short as the feed makes "
"it", self.path, self.last_error)
@@ -417,6 +558,7 @@ class PriceHistory:
no_data=lambda _r: None)
touched = set()
junked = {}
for p in points:
day = _bdate(p.start)
rec = self._days.setdefault(
@@ -424,14 +566,52 @@ class PriceHistory:
# Rule 4: MERGE by instant. A later read updates the hours it
# carries and leaves the rest; a revised price for an hour already
# held wins, because it is the newer statement about that hour.
#
# ⚠️ EVERY HELD ROW GOES THROUGH `_point_of()` (FCST-05's MEDIUM).
# This was `{q["start"]: q for q in raw if isinstance(q, dict) and
# "start" in q}` sorted by `q["start"]` - a key and a sort key that
# trusted a value read off disk to be a hashable, sortable string.
# An `int`, `null`, a `dict` or a `list` there raised `TypeError`
# out of `carry()` on the day the feed was writing to, and
# `run_tariff_refresh` caught it, so the add-on stayed up while
# EVERY refresh failed for good. A row that is not a point is now
# dropped and reported here, exactly as the read path drops it, and
# `_save()` below writes the record back clean - which is the
# self-heal the raising version could never reach.
#
# ⚠️ AND THE MERGE KEY IS THE VALIDATED INSTANT, NOT ITS SPELLING.
# Aware datetimes hash and compare by instant, so this is rule 4 as
# rule 4 is written. The old string key merged by SPELLING, and the
# old lexicographic sort put the second 02:00 of the 25-hour
# October day (`+01:00`) BEFORE the first (`+02:00`). Only the
# stored order was affected - `_parse_points()` re-sorts by instant
# on the way out - but the store now holds what it says it holds.
raw = rec.get("points")
held = {q["start"]: q for q in (raw if isinstance(raw, list) else ())
if isinstance(q, dict) and "start" in q}
held[p.start.isoformat()] = p.canonical()
rec["points"] = sorted(held.values(), key=lambda q: q["start"])
held = {}
for q in (raw if isinstance(raw, list) else ()):
kept = _point_of(q)
if kept is None:
junked[day] = junked.get(day, 0) + 1
continue
held[kept.start] = q
held[p.start] = p.canonical()
order = sorted(held, key=lambda t: t.astimezone(timezone.utc))
rec["points"] = [held[t] for t in order]
rec["product"], rec["provider"] = product, provider
touched.add(day)
if junked:
# Said, not swallowed. `words` prints `last_error`, so a store that
# has just discarded rows off disk stops reporting plain health -
# and after `_save()` below the file is clean, so a restart clears
# this. `_save()` overwrites it if the write itself fails, which is
# the more urgent fact of the two.
self.last_error = (
f"{sum(junked.values())} malformed point row(s) dropped from "
+ ", ".join(f"{d} ({n})" for d, n in sorted(junked.items())))
_LOG.warning("price history at %s: %s - the record is rewritten "
"without them and the refresh continues",
self.path, self.last_error)
self._prune(stamp)
self._save()
_LOG.debug("price history: %d point(s) filed under %s; %d day(s) held",
@@ -523,15 +703,21 @@ class PriceHistory:
an exception here is an add-on that does not start. It went out that
way: a day record that was a string instead of a dict raised
`AttributeError` from the `len(...)` below. `_load()` now drops such a
record and `_n_points()` is total, so this has two independent reasons
not to fail on a broken file.
record and `_held()` is total, so this has two independent reasons not
to fail on a broken file.
⚠️ AND IT COUNTED POINTS AND CALLED THEM HOURS (FCST-05's LOW). The row
was `f"{d} ({_n_points(rec)} h)"`; on the quarter-hourly feed this
add-on already supports that reads `'2026-01-13 (96 h)'` for a 24-hour
day. Both numbers are printed now - see `_held()` - because they are
two different facts and only one of them was ever being shown.
"""
if not self.ok:
return f"unavailable - {self.last_error}"
if not self._days:
why = f" - {self.last_error}" if self.last_error else ""
return f"empty{why} - nothing to carry forward yet"
held = "; ".join(f"{d} ({_n_points(self._days[d])} h)"
held = "; ".join(_row_of(d, *_held(self._days[d]))
for d in sorted(self._days, key=str))
# A file that PARTLY loaded says so, or the dropped days read as days
# that were simply never written.
@@ -550,21 +736,14 @@ def _parse_points(raw) -> tuple:
return (), "the `points` field is not a list"
out, dropped = [], 0
for row in raw:
if not isinstance(row, dict):
# ⚠️ `_point_of()` IS THE ONE VALIDATOR, and this loop used to BE it.
# The write path had its own, weaker standard; see `_point_of()` for
# what that cost. One standard, two callers, no asymmetry to re-open.
pt = _point_of(row)
if pt is None:
dropped += 1
continue
try:
start = datetime.fromisoformat(row["start"])
value = float(row["eur_per_kwh"])
flag = row["provisional"]
except (KeyError, TypeError, ValueError):
dropped += 1
continue
if start.tzinfo is None or start.utcoffset() is None \
or not isinstance(flag, bool):
dropped += 1
continue
out.append(PricePoint(start=start, eur_per_kwh=value, provisional=flag))
out.append(pt)
out.sort(key=lambda p: p.start.astimezone(timezone.utc))
return tuple(out), (f"{dropped} malformed point(s)" if dropped else "")
+155 -4
View File
@@ -59,6 +59,7 @@ Read MUTATION.md before you report a survivor.
import ast
import inspect
import itertools
import os
import sys
from datetime import datetime, timedelta, timezone
@@ -354,14 +355,164 @@ check("...and still REFUSES a repeated CARRIED hour, which is the DST "
"mispricing that count existed to catch",
raises(lambda: _fc._audit([_fc._carried(D0, 0.11),
_fc._carried(D0, 0.12)], {}), ValueError))
check("...and refuses the SAME published point emitted twice - the provider's "
"points are strictly increasing, so a repeat is never a second print",
check("...and STILL refuses the SAME published point emitted twice - the "
"provider's points are strictly increasing, so a repeat is never a "
"second print. The old hour COUNT already refused this (one point twice "
"is one hour twice); what is graded here is that the relaxation did not "
"lose it, not that it added it",
raises(lambda: _fc._audit([_QUARTERS.points[0], _QUARTERS.points[0]],
_PUB_Q), ValueError))
check("...and refuses a CARRIED point standing in an hour the feed DID "
"publish, which the old count never looked at",
check("...and STILL refuses a CARRIED point standing in an hour the feed DID "
"publish. The old COUNT never looked at it; the old IDENTITY rule did, "
"and raised - so this is preserved behaviour, not new strictness. "
"FCST-04's text called it new here and in `_audit`'s docstring, and a "
"measured differential says otherwise",
raises(lambda: _fc._audit([_fc._carried(D0, 0.11)], _PUB_Q), ValueError))
# ⚠️ AND WHAT THE AUDIT DOES NOT DO, STATED WHERE A READER OF THESE THREE
# CHECKS WOULD OTHERWISE ASSUME IT (FCST-05, MUTATION.md habit 16). `_audit` is
# a PROVENANCE post-condition, not a COMPLETENESS one: it grades every point
# that IS emitted and says nothing about points that are not. So it accepts the
# very decimation FCST-04 was raised for. That is not a regression - FCST-03's
# audit was equally blind - but it means the audit is NOT the thing that stops
# H1 coming back. The count and identity checks above this line are.
check("the audit ACCEPTS the H1 decimation itself - four published points in "
"one hour, only the last emitted - and accepts an EMPTY output. It is a "
"provenance check, not a completeness one, and the checks that guard "
"provenance completeness are the COUNT and IDENTITY ones above",
derived(lambda: _fc._audit([_QUARTERS.points[3]], _PUB_Q)) is None
and derived(lambda: _fc._audit([], _PUB_Q)) is None)
# --------------------------------------------------------------------------- #
# ⚠️ AND THE CLAIM "STRICTER" IS ITSELF A CHECK NOW (FCST-05).
#
# FCST-04 rewrote `_audit` and wrote, in the module and in the three checks
# above, that the result was STRICTER than FCST-03's. It is not. Two of the
# stated reasons were false - the old `seen` count already refused the same
# point emitted twice, and the old IDENTITY rule already refused a carried
# point in a published hour - and a right decision with a fabricated reason is
# a defect in this repository (MUTATION.md, and the reason DECISIONS files
# exist). So the claim is measured here rather than asserted in prose:
# FCST-03's audit is reproduced below from dd40c56 - its RULES verbatim, its
# message TEXT shortened, and nothing here reads a message - and the two are
# run over the whole domain `_audit` can express, in BOTH directions.
#
# THE DOMAIN, AND ITS SIZE. Every published mask over 3 hours (2**3) crossed
# with every output series of length 0-3 drawn from 3 hours x 5 point kinds.
# The nominal cross-product is 28 928; three of those kinds - published,
# published-provisional, copy-of-published - DO NOT EXIST for an hour the feed
# did not publish, and those combinations are SKIPPED rather than faked, which
# leaves 11 990 realisable comparisons. A fabricated "published point" for an
# unpublished hour is not in either audit's domain, and inventing one would
# grade the fixture rather than the code.
#
# ⚠️ THE OLD COPY IS A LITERAL, NOT A CALL. MUTATION.md habit 2: a differential
# whose two sides both come from the shipped module compares a thing against
# itself and moves with every edit. This side is frozen text.
def _audit_fcst03(points, published):
"""FCST-03's `_audit`, verbatim from dd40c56. Its `published` maps a UTC
hour to ONE point, which is exactly the expressiveness this ticket relaxed.
"""
seen = set()
for p in points:
u = _fc._utc_hour(p.start)
if u in seen:
raise ValueError("the filled series repeats " + u.isoformat())
seen.add(u)
src = published.get(u)
if src is None:
if p.provisional is not True:
raise ValueError(u.isoformat() + " is carried and is flagged "
"provisional=" + repr(p.provisional))
else:
if p is not src:
raise ValueError(u.isoformat() + " is published but the series "
"carries a COPY of that point")
def _verdict(fn, pts, pub):
try:
fn(list(pts), pub)
return "accept"
except ValueError:
return "raise"
_DH = [D0 + timedelta(hours=6 + i) for i in range(3)]
_weaker, _stronger, _compared = [], [], 0
for _mask in itertools.product((0, 1), repeat=3):
_pub = {u: PricePoint(start=u, eur_per_kwh=0.10 + i, provisional=False)
for i, (u, _on) in enumerate(zip(_DH, _mask)) if _on}
_old_pub = dict(_pub)
_new_pub = {u: (q,) for u, q in _pub.items()}
# The five shapes an output point can take at a given hour. Three of them
# do not exist for an hour the feed did not publish, and those combinations
# are skipped rather than faked - a fabricated "published point" for an
# unpublished hour is not in either audit's domain.
_kinds = []
for _u in _DH:
_src = _pub.get(_u)
_kinds.append(("published", _src))
_kinds.append(("published-provisional",
None if _src is None else
PricePoint(start=_src.start,
eur_per_kwh=_src.eur_per_kwh,
provisional=True)))
_kinds.append(("carried", _fc._carried(_u, 0.5)))
_kinds.append(("carried-unflagged",
PricePoint(start=_u, eur_per_kwh=0.5,
provisional=False)))
_kinds.append(("copy-of-published",
None if _src is None else
PricePoint(start=_src.start,
eur_per_kwh=_src.eur_per_kwh,
provisional=_src.provisional)))
for _n in range(4):
for _combo in itertools.product(_kinds, repeat=_n):
if any(k[1] is None for k in _combo):
continue
_pts = [k[1] for k in _combo]
_compared += 1
_o = _verdict(_audit_fcst03, _pts, _old_pub)
_nw = _verdict(_fc._audit, _pts, _new_pub)
if _o == "raise" and _nw == "accept":
_weaker.append((_mask, [k[0] for k in _combo]))
if _o == "accept" and _nw == "raise":
_stronger.append((_mask, [k[0] for k in _combo]))
# ⚠️ THE PRECONDITION FIRST (MUTATION.md habit 1). A differential over a
# domain that cannot reach the two disputed cases would score 0-0 for the wrong
# reason and read exactly like this one, so the two cases are driven against
# the OLD audit by name before the totals are graded.
_probe = PricePoint(start=_DH[0], eur_per_kwh=0.1, provisional=False)
check(f"the differential really covers the two claims it is here to settle, "
f"or it is {_compared} runs of nothing: FCST-03's own audit raises on "
f"BOTH 'the same published point twice' and 'a carried point in a "
f"published hour', which is what FCST-04 said it did not",
_compared > 3000
and _verdict(_audit_fcst03, [_probe, _probe], {_DH[0]: _probe}) == "raise"
and _verdict(_audit_fcst03, [_fc._carried(_DH[0], 0.5)],
{_DH[0]: _probe}) == "raise")
check(f"...and over all {_compared} of them the reworked audit is NOT WEAKER "
f"than FCST-03's: {len(_weaker)} case(s) the old one raised and this one "
f"accepts {_weaker[:2]}",
_weaker == [])
check(f"...and NOT STRONGER either: {len(_stronger)} case(s) the old one "
f"accepted and this one raises {_stronger[:2]}. FCST-04's docstring and "
f"two of these checks said it was stricter and named two reasons, and "
f"BOTH were already refused by the old count and the old identity rule. "
f"The only behavioural change is the RELAXATION below, which is what the "
f"rework was for",
_stronger == [])
_QP = [PricePoint(start=_DH[0] + timedelta(minutes=15 * i),
eur_per_kwh=0.1 + i, provisional=False) for i in range(4)]
check("...and the relaxation is real and is NOT expressible to the old audit: "
"four distinct published points inside one hour, which FCST-03's count "
"refused as a repeated hour and this one accepts",
_verdict(_fc._audit, _QP, {_DH[0]: tuple(_QP)}) == "accept"
and _verdict(_audit_fcst03, _QP, {_DH[0]: _QP[0]}) == "raise")
# ⚠️ THE HORIZON IS WHOLE HOURS AT BOTH ENDS (FCST-04 L8). `since` is floored
# and `until` was not, so a 48 h request made at any instant off the hour came
# back 49 hours long. Graded at an OFF-THE-HOUR instant, because on the hour
+305 -6
View File
@@ -34,6 +34,18 @@ cache cannot decay by being fed its own output.
`PriceHistory(...)` and `.words` are unguarded in `Controller.__init__`
- so the add-on did not start, and never self-healed.
AC5 (FCST-05) THE CACHE'S OWN CONTRACT HOLDS FOR EVERY BYTE AND EVERY ROW.
A file holding one non-UTF-8 byte no longer stops the add-on from
starting - `_load()` caught `(json.JSONDecodeError, OSError)` and
`UnicodeDecodeError` is neither, so it escaped `PriceHistory.__init__`
and `Controller.__init__` does not guard it. And a malformed `start`
under the day the feed is WRITING to no longer kills the tariff plan
for good: the write path shares `_point_of()` with the read path,
drops the row, says so, and `_save()` rewrites the record clean.
⚠️ THE STATUS ROW IS PART OF THIS. It said `'2026-01-15 (1 h)'` about a
store whose every refresh was raising - health it did not have, in a
unit that was not its own (it counts POINTS).
THE RULES THIS FILE HOLDS ITSELF TO, because the reviewer will:
* EVERY VERDICT IS DERIVED. The carried/published split is re-derived here
@@ -262,6 +274,13 @@ YESTERDAY_MWH = {h: 400.0 + 3 * h for h in range(24)}
MARKUP, TAXES = 0.03, 0.06
# ⚠️ FCST-05's HIGH, AS BYTES. A cache that is structurally valid JSON up to
# the point where one byte is not UTF-8 - which is what a truncated write, a
# half-flushed page or a foreign writer produces. Written with `open(..., "wb")`
# and never through a text handle, or the fixture cannot hold the byte it is
# named for. `0xff` is not a valid UTF-8 start byte in any position.
_UTF8_BOMB = b'{"version": 1, "days": {"2026-01-14": "\xff\xfe"}}'
def allin(mwh):
"""EUR/MWh as published -> the all-in EUR/kWh `assemble()` produces."""
@@ -896,6 +915,55 @@ check("an unreadable file: the reason is kept and reported, and the record is "
and _h6.yesterday(NOW).kind == "no_data"
and "unreadable" in why(_h6.yesterday(NOW)))
# ⚠️ AND THE BYTE CLASS THAT FIXTURE CANNOT REACH - FCST-05's HIGH. The row
# above is INVALID JSON but it is PERFECTLY VALID UTF-8, so it is decoded and
# then refused by `json`, which raises `json.JSONDecodeError`. A file holding
# one byte that is not UTF-8 at all fails EARLIER, in the decode `open(...,
# encoding="utf-8")` performs before `json` sees a character, and
# `UnicodeDecodeError` is `ValueError -> UnicodeError -> UnicodeDecodeError`:
# NOT a `JSONDecodeError`, and NOT an `OSError` either. `_load()` caught
# `(json.JSONDecodeError, OSError)`, so it escaped `_load()` -> `_open()` ->
# `PriceHistory.__init__` - unguarded in `Controller.__init__` - AND THE ADD-ON
# DID NOT START, on a cache whose own contract says "a corrupt cache must not
# stop the controller".
#
# The precondition is asserted first, because it is the whole reason the check
# above could not see this: if `"{ this is not json"` were undecodable, the two
# fixtures would be one fixture and this would grade nothing (MUTATION.md
# habit 1).
check("the two unreadable-file fixtures are DIFFERENT FAILURES: the one above "
"is valid UTF-8 that is not JSON, and the one below is not UTF-8 at all. "
"A file that is merely bad JSON cannot reach the decode step, which is "
"why the suite had this hole",
open(_p6, "rb").read().decode("utf-8") == "{ this is not json"
and raises(lambda: _UTF8_BOMB.decode("utf-8"), UnicodeDecodeError))
_d6b = tmpdir()
_p6b = os.path.join(_d6b, "price_history.json")
open(_p6b, "wb").write(_UTF8_BOMB)
_h6b = derived(lambda: PriceHistory(_p6b))
check(f"a single non-UTF-8 byte in the cache does NOT stop the store from "
f"being constructed {_h6b!r}. `PriceHistory(...)` is unguarded in "
f"`Controller.__init__`, so an exception here is an add-on that does not "
f"start - which is what shipped",
isinstance(_h6b, PriceHistory))
check(f"...and it degrades the SAME WAY the bad-JSON file does: the reason is "
f"kept, named and reported "
f"{(_h6b.last_error if isinstance(_h6b, PriceHistory) else _h6b)!r}",
isinstance(_h6b, PriceHistory)
and "unreadable" in (_h6b.last_error or "")
and "codec can't decode byte" in (_h6b.last_error or "")
and _h6b.yesterday(NOW).kind == "no_data"
and "unreadable" in why(_h6b.yesterday(NOW)))
_w6b = derived(lambda: _h6b.words)
_plan6b = derived(lambda: run(resolve_tariff(
CFG, NOW, _until, transport=transport_for(_body_today),
now=NOW, history=_h6b)).origin)
check(f"...and NOTHING downstream raises on it either: not `words` ({_w6b!r}), "
f"not the shipped `resolve_tariff()` path ({_plan6b!r}). §6: absent "
f"machinery must not disable the loop, and a JSON file is machinery",
isinstance(_w6b, str) and _plan6b == "provider")
_p7 = os.path.join(tmpdir(), "price_history.json")
json.dump({"version": SCHEMA + 99, "days": {}}, open(_p7, "w", encoding="utf-8"))
_h7 = PriceHistory(_p7)
@@ -1008,11 +1076,49 @@ check("a healthy but EMPTY store says something different, because it is a "
and words_of(_h_words).startswith("empty")
and "nothing to carry forward" in words_of(_h_words))
_ph.PriceHistory._remember(_h_words, series_of(YDAY_H, YESTERDAY_MWH), NOW)
check(f"...and a store holding a day names the DATE and the HOUR COUNT "
f"{words_of(_h_words)!r} - 'a cache exists' and 'the cache holds "
f"yesterday, all 24 hours of it' are different facts, and only the "
f"second says the horizon will be full tomorrow morning",
words_of(_h_words) == f"{YDAY_D} (24 h)")
check(f"...and a store holding a day names the DATE, the POINT COUNT and the "
f"HOUR COUNT {words_of(_h_words)!r} - 'a cache exists' and 'the cache "
f"holds yesterday, all 24 hours of it' are different facts, and only "
f"the second says the horizon will be full tomorrow morning",
words_of(_h_words) == f"{YDAY_D} (24 points over 24 h)")
# ⚠️ AND THE TWO NUMBERS ARE NOT THE SAME NUMBER - FCST-05's LOW. The row
# shipped as `f"{d} ({_n_points(rec)} h)"`, which counts POINTS and prints them
# under the HOUR's name. On an hourly feed the two are equal and nothing shows;
# on the quarter-hourly feed this add-on already supports (`_published_by_hour`
# keeps a TUPLE per hour precisely for it, and §7 below drives one) the row
# read '2026-01-13 (96 h)' - 96 "h" for a 24-hour day. That is the points/hours
# conflation H1 was raised for, on the row H1's own ticket shipped.
#
# MUTATION.md's rule: this check asserts a DIFFERENCE, so the fixture is run
# with the two numbers actually made different. 96 != 24, asserted first.
_h_q = store()
_QUARTERS = [u + timedelta(minutes=15 * q) for u in YDAY_H for q in range(4)]
_ph.PriceHistory._remember(
_h_q, PriceRead.series(
[PricePoint(start=brussels(u),
eur_per_kwh=allin(YESTERDAY_MWH[brussels(u).hour]
+ brussels(u).minute),
provisional=False) for u in _QUARTERS],
PriceProduct.DAY_AHEAD, "generic_dayahead", NOW),
NOW)
_qw = words_of(_h_q)
check(f"the quarter-hourly fixture really is sub-hourly and its two counts "
f"really differ ({len(_QUARTERS)} points, "
f"{len({brussels(u).hour for u in _QUARTERS})} local hours), or 'points' "
f"and 'hours' are the same observation and the row grades nothing",
len(_QUARTERS) == 96
and len({brussels(u).hour for u in _QUARTERS}) == 24
and len({u.minute for u in _QUARTERS}) == 4)
check(f"...and the row reports BOTH, each under its own unit: {_qw!r}. "
f"A day of 96 quarter-hourly prints is 96 points over 24 h, and the "
f"figure that says whether tomorrow's fill has a whole day to carry is "
f"the HOUR one",
_qw == f"{YDAY_D} (96 points over 24 h)")
check(f"...and specifically the row does NOT say '96 h' {_qw!r} - a point "
f"count wearing the hour's name is H1's defect, and this row is where "
f"an operator would have read it",
isinstance(_qw, str) and "(96 h)" not in _qw and " (96 points " in _qw)
check(f"a file that only PARTLY loaded says so in the row {_w14!r}, or the "
f"days it dropped read as days that were simply never written",
isinstance(_w14, str) and (_h14.last_error or "") in _w14
@@ -1023,7 +1129,7 @@ check("...and `words` never raises, whatever the file holds. It is read once "
"from Controller.__init__ and again on every status render, and neither "
"call site guards it - an exception here is an add-on that does not start",
all(isinstance(words_of(h), str)
for h in (_missing, _h6, _h7, _h14, _h_words)))
for h in (_missing, _h6, _h6b, _h7, _h14, _h_words)))
# ⚠️ AND A FAILED WRITE LEAVES NOTHING BEHIND. `_save()` writes to a `mkstemp`
# and renames - atomic, so a power cut cannot leave a truncated file that
@@ -1067,6 +1173,7 @@ check(f"...and a SUCCESSFUL write leaves exactly the one file "
# provider series with no invented points and no §11.4 degradation.
for _label, _hist in (("no durable volume", _missing),
("unreadable file", _h6),
("a non-UTF-8 byte in the file", _h6b),
("wrong schema", _h7)):
_plan = run(resolve_tariff(CFG, NOW, _until,
transport=transport_for(_body_today), now=NOW,
@@ -1080,6 +1187,198 @@ for _label, _hist in (("no durable volume", _missing),
and not any(p.provisional for p in _plan.read.points))
# --------------------------------------------------------------------------- #
print()
print("5c. FCST-05 - the WRITE path is as defensive as the read path")
# THE DEFECT. `_remember()` merged a day's held points with
#
# held = {q["start"]: q for q in raw
# if isinstance(q, dict) and "start" in q}
# rec["points"] = sorted(held.values(), key=lambda q: q["start"])
#
# which trusts a value read off /data to be a HASHABLE, SORTABLE STRING. It is
# whatever is in the file. `_usable_days()` required `rec["points"]` to be a
# LIST and never looked at the elements, and `_parse_points()` - the READ path -
# was properly defensive, so `yesterday()` survived and the §5 `_p8` fixture
# passed. Two standards for what a stored point is; the weaker one on the path
# that WRITES.
#
# ⚠️ AND THE PLACEMENT IS THE WHOLE FIXTURE. `_p8` files its malformed rows
# under a day the feed is NOT writing to, so `_remember()` never touches that
# record and the defect is invisible. What reaches it is a malformed row under
# TODAY - the record the running feed merges into on every refresh - so that is
# asserted before anything is graded (MUTATION.md habit 1), and the harmless
# placement is run as the accepted baseline it varies from (habit 7).
#
# ⚠️ AND THE BLAST RADIUS IS WHY IT IS NOT MERELY UNTIDY. `carry()` is reached
# from `resolve_tariff()` from `run_tariff_refresh`, which catches `Exception`:
# the add-on stays UP and EVERY refresh raises, forever. The plan retires after
# TARIFF_MAX_AGE_S and arbitrage and the cheap window are off permanently, with
# no self-heal, because `_remember()` raises before it reaches `_save()`.
_BAD_STARTS = (
("an int", 7), # TypeError: '<' between 'str' and 'int'
("a dict", {"a": 1}), # TypeError: unhashable as a dict key
("null", None), # TypeError: '<' between 'str', 'NoneType'
("a list", [1, 2]), # TypeError: unhashable as a dict key
("a string that is not an instant", "nope"), # sorts fine, never a point
)
def _seeded(day, bad_start):
"""A store on disk holding ONE malformed row under `day`, plus a good one."""
d = tmpdir()
path = os.path.join(d, "price_history.json")
good = {"start": brussels(at_local(day, 5)).isoformat(),
"eur_per_kwh": allin(YESTERDAY_MWH[5]), "provisional": False}
json.dump({"version": SCHEMA, "days": {day: {
"product": "dynamic_dayahead", "provider": "generic_dayahead",
"points": [good, {"start": bad_start, "eur_per_kwh": 0.2,
"provisional": False}]}}},
open(path, "w", encoding="utf-8"))
return path, PriceHistory(path)
_TODAY_FEED = series_of(TODAY_H, TODAY_MWH)
check(f"the fixture files its malformed row under {TODAY_D}, which is exactly "
f"the Brussels day the feed being carried writes to "
f"{sorted({_ph._bdate(p.start) for p in _TODAY_FEED.points})} - under any "
f"other day `_remember()` never opens the record and this whole section "
f"grades nothing, which is why the suite already had this hole",
{_ph._bdate(p.start) for p in _TODAY_FEED.points} == {TODAY_D})
# The accepted baseline: the SAME row, one day over. Shipped and broken alike
# pass this, and that is the point of running it (habit 7).
_pB, _hB = _seeded(YDAY_D, 7)
_cB = derived(lambda: _hB.carry(_TODAY_FEED, since=NOW, until=_until, stamp=NOW))
check("baseline: the same malformed row under a day the feed is NOT writing "
"to is carried without incident - one variable moves between this and "
"every check below, and it is the DAY",
not isinstance(_cB, _Raised) and _cB.kind == "series")
for _shape, _bad in _BAD_STARTS:
_pM, _hM = _seeded(TODAY_D, _bad)
_wload = words_of(_hM)
_c1 = derived(lambda h=_hM: h.carry(_TODAY_FEED, since=NOW, until=_until,
stamp=NOW))
_saw = repr(_c1) if isinstance(_c1, _Raised) else _c1.kind
check(f"a `start` that is {_shape} ({_bad!r}) under TODAY's own record "
f"does not take the refresh down: `carry()` returns a series "
f"({_saw}). It raised "
f"`TypeError` out of the shipped path, and `run_tariff_refresh` "
f"catches `Exception`, so the add-on stayed up while every refresh "
f"failed for good",
not isinstance(_c1, _Raised) and _c1.kind == "series")
# ⚠️ THE VERDICT IS NOT THE CLAIM. "It did not raise" is satisfied by a
# store that silently kept the poison row; what this section promises is
# that the row is GONE FROM THE FILE, so the next start is clean.
_disk = json.load(open(_pM, encoding="utf-8"))
_rows = _disk["days"][TODAY_D]["points"]
# DERIVED, not a constant: the surviving rows are exactly the DISTINCT
# instants the feed published for that day plus the fixture's one good
# seeded row - which is 05:00, an hour the feed also prints, so it merges.
_want = len({p.start.astimezone(UTC) for p in _TODAY_FEED.points}
| {at_local(TODAY_D, 5).astimezone(UTC)})
check(f"...and the row is DROPPED FROM /data, not carried: {len(_rows)} "
f"row(s) on disk against {_want} distinct instants, every one of "
f"them a point. `_remember()` used to raise BEFORE `_save()`, so "
f"there was no self-heal at all - the store came back poisoned on "
f"every restart",
all(_ph._point_of(r) is not None for r in _rows)
and len(_rows) == _want)
# ...and the store stops reporting health, at BOTH moments it can be read:
# when it is opened, and after the refresh that cleaned it.
# ⚠️ THE COUNTS ARE ASSERTED TOO, NOT ONLY THE NOTE. The seeded record holds
# ONE point and one row that is not one, so a row printing "2 points" would
# be reporting the poison as held prices - which is the half of this defect
# that reached the operator: `words` said "2026-01-15 (1 h)" while every
# tariff refresh was dying on that record.
check(f"...and the status row does not call that store healthy - at open "
f"{_wload!r}: ONE point held, and the row that is not a point named "
f"rather than counted",
isinstance(_wload, str)
and _wload.startswith(f"{TODAY_D} (1 point over 1 h) [")
and "1 malformed point row(s) in" in _wload)
# ⚠️ AND THE TWO NOTES ARE GRADED APART, BECAUSE THEY MASK EACH OTHER
# OTHERWISE (MUTATION.md TEST-16). `_usable_days()` records what it FOUND
# at open and `_remember()` records what it DROPPED on the refresh; both
# land in `last_error`, so a check asking only "does the row mention a
# malformed point" is answered by whichever rung is still standing - the
# first draft of this check was exactly that, and deleting `_remember()`'s
# note left the whole 29-file suite green at -1 B. The tense is the
# difference an operator reads: "in" is still there, "dropped from" is
# gone, and after the refresh only the second is true.
_wafter = words_of(_hM)
check(f"...nor after the refresh that dropped it {_wafter!r}. It said "
f"'{TODAY_D} (1 h)' - the status row reporting a full cache while "
f"every tariff refresh was dying on it",
isinstance(_wafter, str)
and "malformed point row(s) dropped from" in _wafter
and TODAY_D in _wafter)
check(f"...and the row MOVED from what was found to what was dropped "
f"{_wload.split('[')[-1]!r} -> {_wafter.split('[')[-1]!r}, so the "
f"two notes are not one note read twice",
isinstance(_wload, str) and isinstance(_wafter, str)
and "malformed point row(s) in" in _wload
and "malformed point row(s) in" not in _wafter
and "malformed point row(s) dropped from" not in _wload)
# ...and a SECOND refresh over the same store is clean, which is the state
# a restarted add-on comes up in.
_hM2 = PriceHistory(_pM)
check(f"...and the store reopened from that file is CLEAN: the whole day "
f"held and nothing to report {words_of(_hM2)!r}, so the note clears "
f"itself rather than becoming permanent furniture",
words_of(_hM2) == f"{TODAY_D} ({_want} points over {_want} h)"
and _hM2.last_error is None)
# ...and the shipped path, end to end, through the caller that catches
# `Exception` and would otherwise have hidden this for good.
_pP, _hP = _seeded(TODAY_D, 7)
_o1 = derived(lambda: run(resolve_tariff(
CFG, NOW, _until, transport=transport_for(_body_today), now=NOW,
history=_hP)).origin)
_o2 = derived(lambda: run(resolve_tariff(
CFG, NOW, _until, transport=transport_for(_body_today), now=NOW,
history=_hP)).origin)
check(f"the SHIPPED `resolve_tariff()` path survives it TWICE ({_o1!r}, "
f"{_o2!r}) - once for the refresh that finds the poison and once for "
f"the one after it. A guard that refuses once and dies on the next call "
f"is a delay, not a guard (MUTATION.md habit 9)",
_o1 == "provider" and _o2 == "provider")
# ⚠️ AND THE MERGE KEY IS THE INSTANT, WHICH IS WHAT RULE 4 SAYS. The old key
# was `q["start"]` - the SPELLING - so the same instant written at two offsets
# was held twice, and the lexicographic sort beside it put the second 02:00 of
# the 25-hour October day (`+01:00`) BEFORE the first (`+02:00`). Aware
# datetimes hash and compare by instant, so the validated `start` is the key.
_d15 = tmpdir()
_p15 = os.path.join(_d15, "price_history.json")
_alt = at_local(YDAY_D, 6)
json.dump({"version": SCHEMA, "days": {YDAY_D: {
"product": "dynamic_dayahead", "provider": "generic_dayahead",
"points": [{"start": _alt.astimezone(UTC).isoformat(),
"eur_per_kwh": 0.999, "provisional": False}]}}},
open(_p15, "w", encoding="utf-8"))
_h15 = PriceHistory(_p15)
check(f"the two spellings really are one instant and really are two strings "
f"({_alt.astimezone(UTC).isoformat()!r} vs "
f"{brussels(_alt).isoformat()!r}), or 'merged by instant' and 'merged by "
f"spelling' are the same observation",
_alt.astimezone(UTC).isoformat() != brussels(_alt).isoformat()
and _alt.astimezone(UTC) == brussels(_alt))
_ph.PriceHistory._remember(
_h15, series_of([_alt], YESTERDAY_MWH), NOW)
_y15 = _h15.yesterday(NOW)
_n15 = len(_y15.points) if _y15.kind == "series" else _y15.kind
_v15 = _y15.points[0].eur_per_kwh if _y15.kind == "series" else None
check(f"...and a revision of that hour REPLACES it rather than sitting beside "
f"it: {_n15} point held, priced {_v15} and not 0.999. Rule 4 says "
f"'MERGE BY INSTANT'; the key was the string",
_y15.kind == "series" and len(_y15.points) == 1
and abs(_y15.points[0].eur_per_kwh - allin(YESTERDAY_MWH[6])) < 1e-9)
# --------------------------------------------------------------------------- #
print()
print("6. rule 4 - hours accumulate within a day; they do not replace it")