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
Owner

Two defects found by QA on merged FCST-04 (release/1.0 tip c2787da), plus two false claims in its prose. Nothing deployed; no option added; config.yaml untouched.

HIGH — a 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. open(..., encoding="utf-8") decodes before json sees a character, so the exception escaped _load() → _open() → PriceHistory.__init__, which Controller.__init__ does not guard. That breaks _load()'s own stated contract, verbatim: "A corrupt cache must not stop the controller."

Before

=== HIGH: non-UTF-8 byte in /data/price_history.json ===
  RAISED UnicodeDecodeError: 'utf-8' codec can't decode byte 0xff in position 39
  issubclass(UnicodeDecodeError, OSError)             = False
  issubclass(UnicodeDecodeError, json.JSONDecodeError) = False

After

  loaded ok=True last_error="unreadable ('utf-8' codec can't decode byte 0xff …)"

The fix names the ValueError family — the file's CONTENT — beside OSError, the file's BYTES, rather than the two members known today: a third subclass nobody has thought of is exactly how this arrived the first time. Verified that all four content shapes now degrade identically and none raises:

file last_error
UTF-8 BOM + valid JSON unreadable (Unexpected UTF-8 BOM …)
valid UTF-8, bad JSON unreadable (Expecting property name …)
one 0xff byte unreadable ('utf-8' codec can't decode byte 0xff …)
a UTF-16 file unreadable ('utf-8' codec can't decode byte 0xff at position 0)

Why the suite missed it. The "unreadable file" fixture writes "{ this is not json" — valid UTF-8, refused one step later by json. The new fixture writes bytes through open(..., "wb"), and the difference between the two is asserted as a precondition, so the pair cannot silently collapse into one fixture again.

MEDIUM — a malformed start under the day the feed is writing to killed the tariff plan permanently

_remember() merged with {q["start"]: q for q in raw if isinstance(q, dict) and "start" in q} and sorted on q["start"], trusting a value read off /data to be a hashable, sortable string. _usable_days() required rec["points"] to be a list and never looked at the elements. _parse_points() — the read path — was properly defensive, which is why yesterday() survived and the existing _p8 fixture passed: it files its malformed rows under a day the feed is not writing to, so _remember() never opened that record.

Before — the row moved under today's own record:

start=7          RAISED TypeError: '<' not supported between 'str' and 'int'   | words='2026-01-15 (1 h)'
start={'a': 1}   RAISED TypeError: cannot use 'dict' as a dict key             | words='2026-01-15 (1 h)'
start=None       RAISED TypeError: '<' not supported between 'str','NoneType'  | words='2026-01-15 (1 h)'
start=[1, 2]     RAISED TypeError: cannot use 'list' as a dict key             | words='2026-01-15 (1 h)'

carry() → resolve_tariff() → run_tariff_refresh, which catches Exception: the add-on stays up while every refresh raises, forever. The plan retires after TARIFF_MAX_AGE_S and arbitrage and the cheap window are off permanently. No self-heal — _remember() raised before reaching _save().

After

start=7          carry OK  words='2026-01-15 (1 point over 1 h) [1 malformed point row(s) dropped from 2026-01-15 (1)]'
start={'a': 1}   carry OK  …
start=None       carry OK  …
start=[1, 2]     carry OK  …

Both paths now share one validator, _point_of(row) -> PricePoint | None. _parse_points() was that validator inline; the write path had its own weaker standard, and there is now no second standard to re-open. A row that is not a point is dropped, reported, and _save() writes the record back clean — the self-heal the raising version could never reach. The shipped resolve_tariff() path is driven twice over the same store (MUTATION.md habit 9: a guard that refuses once and admits the same caller again is a delay, not a guard).

…and the status row must not report health it does not have

words said '2026-01-15 (1 h)' about the record that was killing every refresh. _usable_days() 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 two notes carry different facts and are graded apart:

  • at open — 2026-01-15 (1 point over 1 h) [1 malformed point row(s) **in** 2026-01-15 (1)]
  • after the refresh — 2026-01-15 (24 points over 24 h) [1 malformed point row(s) **dropped from** 2026-01-15 (1)]
  • reopened from that file — 2026-01-15 (24 points over 24 h), last_error is None

…and the merge key is the validated INSTANT, not its spelling

Rule 4 already says "MERGE by instant"; the key was q["start"], the string. Aware datetimes hash and compare by instant, so the validated start is now the key. The lexicographic sort beside the string key also put the second 02:00 of the 25-hour October day (+01:00) before the first (+02:00) — stored order only, since _parse_points() re-sorts by instant on the way out.

LOW — the status row labelled points as hours

f"{d} ({_n_points(rec)} h)" counts points. On the quarter-hourly feed this add-on already supports (_published_by_hour keeps a tuple per hour precisely for it) the row read '2026-01-13 (96 h)' — 96 "h" for a 24-hour day. Same points/hours conflation the sub-hourly defect was about, on the row that ticket shipped.

Before '2026-01-14 (92 points…)' → printed as '2026-01-14 (92 h)'
After '2026-01-14 (96 points over 24 h)'

Both facts, each under its own unit. DOCS.md's description of the row moved with it.

LOW — two stated reasons were false

I re-ran the differential independently rather than taking the finding on trust. FCST-03's _audit (from dd40c56) vs the shipped one, over every published mask at 3 hours crossed with every output series of length 0–3 drawn from {published, published-provisional, carried, carried-unflagged, copy-of-published}:

cases compared: 11990
OLD raised, NEW accepted (WEAKER):   0
OLD accepted, NEW raised (STRONGER): 0

  same published point emitted twice:      old=raise  new=raise
  carried point sharing a published hour:  old=raise  new=raise
  four distinct published points in one hour:          new=accept

On the case count. QA reported 28,928; that is the nominal cross-product. Three of the five kinds — published, published-provisional, copy-of-published — do not exist for an hour the feed did not publish, and I skip those combinations rather than fabricate a "published point" for an unpublished hour, which is in neither audit's domain. 11,990 realisable comparisons, same verdict in both directions.

So the reworked _audit is exactly EQUAL to FCST-03's, not stronger. Corrected:

  • "so does the same published point emitted twice" — the old seen count already raised: one point twice is one hour twice.
  • "which the old count never looked at" (a carried point sharing a published hour) — true of the count; the old identity rule (p is not src) raised on it anyway, with a message about a copy rather than a carry. That wording is the only thing that moved.

The intended relaxation — several distinct published points in one hour — is real, is not expressible to the old audit, and stays.

The differential is now a committed check, not prose (MUTATION.md habit 8): FCST-03's audit is reproduced beside it with its rules verbatim, as a frozen literal rather than a call, so the two sides of the comparison cannot move together (habit 2). The precondition — that the old audit really does raise on both disputed cases — is asserted before the totals are graded (habit 1).

Also — the audit's real limit is named

_audit does not check completeness:

ACCEPTS  only the last point of the hour (the decimation defect)
ACCEPTS  two of four, dropping two
ACCEPTS  empty output
RAISES   same point twice / carried point sharing a published hour

So _audit is not what stops that recurring; the point count and pass-through identity checks are, and the module docstring now says so and points at them. Not a regression — FCST-03's audit was equally blind.

I did not make it complete, and the reason is measured rather than asserted. published is _published_by_hour(read) over the whole read while fill_from_yesterday() emits only [since, until), so "every published point appears" is not a true post-condition of those two arguments. I built the rule anyway (+162 B) and ran it: it takes the 1 h, 12 h and 48 h horizon checks red on the shipped fill and a healthy feed, because at an off-the-hour instant the feed's already-elapsed prints are correctly dropped. The rule as stated is not merely unimplemented — it is wrong. Making it right needs the window passed in, which is a different signature and a different card.

Mutation table

Every mutation ran on a fresh copy of the tree — no __pycache__ ever exists in it, and it is thrown away afterwards — so neither TEST-15 (stale .pyc) nor habit 18 (an unverified revert) is available. Each run fingerprints app/price_history.py and app/price_forecast.py against the clean tree before grading.

# mutation Δ result
M1 except (ValueError, OSError) → except (json.JSONDecodeError, OSError) +10 B RED ×4 — the store raises out of __init__ again
M2 _remember()'s merge restored to the string-keyed comprehension + string sort −172 B RED ×26
M3 _usable_days() stops counting malformed rows inside a kept record −90 B RED ×10
M4 _remember()'s "dropped" note deleted (if junked: → if False:) −1 B RED ×10 — see below
M5 _row_of() → f"{day} ({points} h)" (the shipped FCST-04 row) −65 B RED ×13
M6 _row_of()'s hour slot fed the point count +1 B RED ×1
M7 merge key reverted to q["start"], sort to lexicographic −40 B RED ×1
M8 _held() counts every row, not only rows that are points +0 B RED ×5
M9 M3 and M4 together (the masked pair) −91 B RED ×15
M10 _audit() drops the "same published point twice" rule −11 B RED ×2 (incl. NOT WEAKER: 96 cases)
M11 _audit() gains the completeness rule +162 B RED ×5 (incl. the three horizon checks)
M12 _audit() drops the published-identity rule −23 B RED ×3 (incl. NOT WEAKER: 6960 cases)

No survivors. M8 is at +0 B — the delta MUTATION.md flags as risky — but it is a red, which is the direction stale bytecode cannot fabricate, and it ran on a tree with no cache at all.

⚠️ M4 was a survivor in the first draft, and it is TEST-16 masking in a check I had just written. price_history.py reports a malformed row from two places into one field: _usable_days() records what it FOUND at open, _remember() records what it DROPPED on the refresh, and words prints last_error. My first check asked "malformed point row" in words — satisfied by whichever rung is still standing — 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> vs … dropped from <day>), the two are asserted to differ, and the paired mutation M9 is red on 15 checks. Recorded in MUTATION.md beside TEST-16.

Suites

All 29 test files green (0 failing). New checks: test_price_history.py §5 (the undecodable byte, incl. the precondition separating it from the bad-JSON fixture), §5b (points/hours, quarter-hourly), §5c (the write path — 5 malformed start shapes × 5 assertions each, an accepted baseline at the harmless placement, the shipped path driven twice, merge-by-instant); test_price_forecast.py (the accepted-decimation statement and the committed differential).

What I did not fix

  • _audit is still not a completeness check. Reason and measurement above. Left as a named limit, not a silent one.
  • A RecursionError from deeply nested JSON is still uncaught in _load(). It is not a ValueError or an OSError. The file is written only by this module, three days deep at most, so no shipped path reaches it — but it is the same shape as the defect above and is worth a look if /data ever takes a foreign writer.
  • float(True) is 1.0, so a eur_per_kwh of true survives _point_of() as a price of 1.0. Pre-existing in _parse_points(), unchanged here, and out of this card's scope.
  • No version bump. Per the existing ## Unreleased convention: the right number depends on whether 0.9.0's image has already been built and pulled, which this branch cannot observe. Whoever deploys decides.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU

Two defects found by QA on merged FCST-04 (`release/1.0` tip `c2787da`), plus two false claims in its prose. Nothing deployed; no option added; `config.yaml` untouched. ## HIGH — a 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`. `open(..., encoding="utf-8")` decodes *before* `json` sees a character, so the exception escaped `_load()` → `_open()` → `PriceHistory.__init__`, which `Controller.__init__` does not guard. That breaks `_load()`'s own stated contract, verbatim: *"A corrupt cache must not stop the controller."* **Before** ``` === HIGH: non-UTF-8 byte in /data/price_history.json === RAISED UnicodeDecodeError: 'utf-8' codec can't decode byte 0xff in position 39 issubclass(UnicodeDecodeError, OSError) = False issubclass(UnicodeDecodeError, json.JSONDecodeError) = False ``` **After** ``` loaded ok=True last_error="unreadable ('utf-8' codec can't decode byte 0xff …)" ``` The fix names the `ValueError` **family** — the file's CONTENT — beside `OSError`, the file's BYTES, rather than the two members known today: a third subclass nobody has thought of is exactly how this arrived the first time. Verified that all four content shapes now degrade identically and none raises: | file | `last_error` | |---|---| | UTF-8 BOM + valid JSON | `unreadable (Unexpected UTF-8 BOM …)` | | valid UTF-8, bad JSON | `unreadable (Expecting property name …)` | | one `0xff` byte | `unreadable ('utf-8' codec can't decode byte 0xff …)` | | a UTF-16 file | `unreadable ('utf-8' codec can't decode byte 0xff at position 0)` | **Why the suite missed it.** The "unreadable file" fixture writes `"{ this is not json"` — **valid UTF-8**, refused one step later by `json`. The new fixture writes bytes through `open(..., "wb")`, and the difference between the two is asserted as a precondition, so the pair cannot silently collapse into one fixture again. ## MEDIUM — a malformed `start` under the day the feed is writing to killed the tariff plan permanently `_remember()` merged with `{q["start"]: q for q in raw if isinstance(q, dict) and "start" in q}` and sorted on `q["start"]`, trusting a value read off `/data` to be a hashable, sortable string. `_usable_days()` required `rec["points"]` to be a *list* and never looked at the elements. `_parse_points()` — the read path — was properly defensive, which is why `yesterday()` survived and the existing `_p8` fixture passed: **it files its malformed rows under a day the feed is not writing to**, so `_remember()` never opened that record. **Before** — the row moved under today's own record: ``` start=7 RAISED TypeError: '<' not supported between 'str' and 'int' | words='2026-01-15 (1 h)' start={'a': 1} RAISED TypeError: cannot use 'dict' as a dict key | words='2026-01-15 (1 h)' start=None RAISED TypeError: '<' not supported between 'str','NoneType' | words='2026-01-15 (1 h)' start=[1, 2] RAISED TypeError: cannot use 'list' as a dict key | words='2026-01-15 (1 h)' ``` `carry()` → `resolve_tariff()` → `run_tariff_refresh`, which catches `Exception`: the add-on stays **up** while **every refresh raises, forever**. The plan retires after `TARIFF_MAX_AGE_S` and arbitrage and the cheap window are off permanently. No self-heal — `_remember()` raised before reaching `_save()`. **After** ``` start=7 carry OK words='2026-01-15 (1 point over 1 h) [1 malformed point row(s) dropped from 2026-01-15 (1)]' start={'a': 1} carry OK … start=None carry OK … start=[1, 2] carry OK … ``` **Both paths now share one validator**, `_point_of(row) -> PricePoint | None`. `_parse_points()` *was* that validator inline; the write path had its own weaker standard, and there is now no second standard to re-open. A row that is not a point is dropped, reported, and `_save()` writes the record back clean — the self-heal the raising version could never reach. The shipped `resolve_tariff()` path is driven **twice** over the same store (MUTATION.md habit 9: a guard that refuses once and admits the same caller again is a delay, not a guard). ### …and the status row must not report health it does not have `words` said `'2026-01-15 (1 h)'` about the record that was killing every refresh. `_usable_days()` 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 two notes carry different facts and are graded apart: * at open — `2026-01-15 (1 point over 1 h) [1 malformed point row(s) **in** 2026-01-15 (1)]` * after the refresh — `2026-01-15 (24 points over 24 h) [1 malformed point row(s) **dropped from** 2026-01-15 (1)]` * reopened from that file — `2026-01-15 (24 points over 24 h)`, `last_error is None` ### …and the merge key is the validated INSTANT, not its spelling Rule 4 already says *"MERGE by instant"*; the key was `q["start"]`, the string. Aware datetimes hash and compare by instant, so the validated `start` is now the key. The lexicographic sort beside the string key also put the second 02:00 of the 25-hour October day (`+01:00`) **before** the first (`+02:00`) — stored order only, since `_parse_points()` re-sorts by instant on the way out. ## LOW — the status row labelled points as hours `f"{d} ({_n_points(rec)} h)"` counts *points*. On the quarter-hourly feed this add-on already supports (`_published_by_hour` keeps a tuple per hour precisely for it) the row read `'2026-01-13 (96 h)'` — 96 "h" for a 24-hour day. Same points/hours conflation the sub-hourly defect was about, on the row that ticket shipped. **Before** `'2026-01-14 (92 points…)' → printed as '2026-01-14 (92 h)'` **After** `'2026-01-14 (96 points over 24 h)'` Both facts, each under its own unit. `DOCS.md`'s description of the row moved with it. ## LOW — two stated reasons were false I re-ran the differential independently rather than taking the finding on trust. FCST-03's `_audit` (from `dd40c56`) vs the shipped one, over every published mask at 3 hours crossed with every output series of length 0–3 drawn from {published, published-provisional, carried, carried-unflagged, copy-of-published}: ``` cases compared: 11990 OLD raised, NEW accepted (WEAKER): 0 OLD accepted, NEW raised (STRONGER): 0 same published point emitted twice: old=raise new=raise carried point sharing a published hour: old=raise new=raise four distinct published points in one hour: new=accept ``` > **On the case count.** QA reported 28,928; that is the nominal cross-product. Three of the five kinds — published, published-provisional, copy-of-published — **do not exist** for an hour the feed did not publish, and I skip those combinations rather than fabricate a "published point" for an unpublished hour, which is in neither audit's domain. 11,990 realisable comparisons, same verdict in both directions. So the reworked `_audit` is **exactly EQUAL** to FCST-03's, not stronger. Corrected: * *"so does the same published point emitted twice"* — the old `seen` count already raised: one point twice is one **hour** twice. * *"which the old count never looked at"* (a carried point sharing a published hour) — true of the **count**; the old **identity** rule (`p is not src`) raised on it anyway, with a message about a copy rather than a carry. That wording is the only thing that moved. The intended **relaxation** — several distinct published points in one hour — is real, is not expressible to the old audit, and stays. **The differential is now a committed check**, not prose (MUTATION.md habit 8): FCST-03's audit is reproduced beside it with its rules verbatim, as a frozen literal rather than a call, so the two sides of the comparison cannot move together (habit 2). The precondition — that the old audit really does raise on both disputed cases — is asserted before the totals are graded (habit 1). ## Also — the audit's real limit is named `_audit` does **not** check completeness: ``` ACCEPTS only the last point of the hour (the decimation defect) ACCEPTS two of four, dropping two ACCEPTS empty output RAISES same point twice / carried point sharing a published hour ``` So `_audit` is **not** what stops that recurring; the point **count** and pass-through **identity** checks are, and the module docstring now says so and points at them. Not a regression — FCST-03's audit was equally blind. **I did not make it complete, and the reason is measured rather than asserted.** `published` is `_published_by_hour(read)` over the **whole** read while `fill_from_yesterday()` emits only `[since, until)`, so "every published point appears" is not a true post-condition of those two arguments. I built the rule anyway (+162 B) and ran it: it takes the **1 h, 12 h and 48 h horizon checks red on the shipped fill and a healthy feed**, because at an off-the-hour instant the feed's already-elapsed prints are correctly dropped. The rule as stated is not merely unimplemented — it is wrong. Making it right needs the window passed in, which is a different signature and a different card. ## Mutation table Every mutation ran on a **fresh copy** of the tree — no `__pycache__` ever exists in it, and it is thrown away afterwards — so neither TEST-15 (stale `.pyc`) nor habit 18 (an unverified revert) is available. Each run fingerprints `app/price_history.py` and `app/price_forecast.py` against the clean tree before grading. | # | mutation | Δ | result | |---|---|---|---| | M1 | `except (ValueError, OSError)` → `except (json.JSONDecodeError, OSError)` | +10 B | **RED** ×4 — the store raises out of `__init__` again | | M2 | `_remember()`'s merge restored to the string-keyed comprehension + string sort | −172 B | **RED** ×26 | | M3 | `_usable_days()` stops counting malformed rows inside a kept record | −90 B | **RED** ×10 | | M4 | `_remember()`'s "dropped" note deleted (`if junked:` → `if False:`) | −1 B | **RED** ×10 — *see below* | | M5 | `_row_of()` → `f"{day} ({points} h)"` (the shipped FCST-04 row) | −65 B | **RED** ×13 | | M6 | `_row_of()`'s hour slot fed the **point** count | +1 B | **RED** ×1 | | M7 | merge key reverted to `q["start"]`, sort to lexicographic | −40 B | **RED** ×1 | | M8 | `_held()` counts every row, not only rows that are points | **+0 B** | **RED** ×5 | | M9 | M3 **and** M4 together (the masked pair) | −91 B | **RED** ×15 | | M10 | `_audit()` drops the "same published point twice" rule | −11 B | **RED** ×2 (incl. `NOT WEAKER: 96 cases`) | | M11 | `_audit()` gains the completeness rule | +162 B | **RED** ×5 (incl. the three horizon checks) | | M12 | `_audit()` drops the published-identity rule | −23 B | **RED** ×3 (incl. `NOT WEAKER: 6960 cases`) | **No survivors.** M8 is at `+0 B` — the delta MUTATION.md flags as risky — but it is a **red**, which is the direction stale bytecode cannot fabricate, and it ran on a tree with no cache at all. ⚠️ **M4 was a survivor in the first draft, and it is TEST-16 masking in a check I had just written.** `price_history.py` reports a malformed row from **two** places into **one** field: `_usable_days()` records what it FOUND at open, `_remember()` records what it DROPPED on the refresh, and `words` prints `last_error`. My first check asked `"malformed point row" in words` — satisfied by whichever rung is still standing — 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>` vs `… dropped from <day>`), the two are asserted to differ, and the paired mutation M9 is red on 15 checks. Recorded in `MUTATION.md` beside TEST-16. ## Suites All **29** test files green (`0 failing`). New checks: `test_price_history.py` §5 (the undecodable byte, incl. the precondition separating it from the bad-JSON fixture), §5b (points/hours, quarter-hourly), **§5c** (the write path — 5 malformed `start` shapes × 5 assertions each, an accepted baseline at the harmless placement, the shipped path driven twice, merge-by-instant); `test_price_forecast.py` (the accepted-decimation statement and the committed differential). ## What I did not fix * **`_audit` is still not a completeness check.** Reason and measurement above. Left as a named limit, not a silent one. * **A `RecursionError` from deeply nested JSON** is still uncaught in `_load()`. It is not a `ValueError` or an `OSError`. The file is written only by this module, three days deep at most, so no shipped path reaches it — but it is the same shape as the defect above and is worth a look if `/data` ever takes a foreign writer. * **`float(True)` is `1.0`**, so a `eur_per_kwh` of `true` survives `_point_of()` as a price of 1.0. Pre-existing in `_parse_points()`, unchanged here, and out of this card's scope. * **No version bump.** Per the existing `## Unreleased` convention: the right number depends on whether 0.9.0's image has already been built and pulled, which this branch cannot observe. Whoever deploys decides. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
admin added 1 commit 2026-09-10 01:05:50 +02:00
HIGH - a 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 - breaking `_load()`'s own stated
contract, "a corrupt cache must not stop the controller". The handler now
names the ValueError FAMILY (the file's content) beside OSError (its bytes),
because a third subclass nobody has thought of is how this arrived the first
time. The suite could not see it: its "unreadable file" fixture writes
"{ this is not json", which is valid UTF-8 and fails one step later, in
`json`. A fixture holding a genuinely undecodable byte is now beside it,
with the difference between the two asserted as a precondition.

MEDIUM - a malformed `start` under the day the feed is writing to killed the
tariff plan permanently. `_remember()` merged 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 raised TypeError out of
`carry()` -> `resolve_tariff()` -> `run_tariff_refresh`, which catches
Exception - so the add-on stayed UP while every refresh raised forever, the
plan retiring after TARIFF_MAX_AGE_S with arbitrage and the cheap window off
for good, and no self-heal because `_remember()` raised before `_save()`.
The read path 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. Both paths now share one validator,
`_point_of()`. A row that is not a point is dropped, reported, and written
back out clean.

...and the status row said the store was healthy while the plan was dead:
`words` read '2026-01-15 (1 h)'. `_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 once the file is rewritten.

...and the merge key is the validated INSTANT rather than its spelling,
which is what rule 4 already said. The lexicographic sort beside the string
key also put the second 02:00 of the 25-hour October day (+01:00) before the
first (+02:00); stored order only, since `_parse_points()` re-sorts.

LOW - the status row counted POINTS and printed them under the HOUR's name:
'2026-01-13 (96 h)' for a 24-hour day on the quarter-hourly feed this add-on
already supports. It is now '96 points over 24 h' - two facts, each under
its own unit.

LOW - two stated reasons were false. `_audit`'s "WHY IT IS STRICTER" claimed
the rework refused the same published point emitted twice and a carried
point sharing a published hour, "which the old count never looked at". A
differential over the whole domain the audit can express finds ZERO
divergence in both directions: the old `seen` count already refused the
first (one point twice is one hour twice) and the old IDENTITY rule already
refused the second. The reworked audit is EQUAL to FCST-03's, not stronger;
the only behavioural change is the intended relaxation, and it stays. The
differential is committed as a check rather than left as prose.

...and the audit's real limit is named: it does not check COMPLETENESS. Fed
the decimation defect FCST-04 was raised for it ACCEPTS, and it accepts an
empty output. So `_audit` is not what stops that recurring - the count and
identity checks are, and the text now points at them. Not made complete:
`published` is the whole read while the fill emits only [since, until), so
the rule as stated is wrong, and building it takes the 1/12/48 h horizon
checks red on a healthy feed. Measured, not assumed.

12 mutations, purged tree, every one RED, no survivors. All 29 test files
green. Nothing deployed; no option added; config.yaml untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
admin merged commit 5059e28860 into release/1.0 2026-09-10 01:13:41 +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#74