Commit Graph
6 Commits
Author SHA1 Message Date
glenn schrooyenandClaude Opus 5 acbdf41248 TEST-13: grade record_block()'s policy argument, and the two import spellings SAFETY-20's check could not see
checks / unit (pull_request) Successful in 3m55s
Two survivors from TARIFF-01's review, closed in test_tariff.py only. app/ is
untouched and no shipped behaviour changes. Two further items on the card were
already closed in shipped code and are struck rather than re-implemented.

ITEM 1 - the per-phase reduction's PLUMBING was unobservable.

  Mutating PeakStore.record_block()'s `billed_w(block, policy)` to
  `billed_w(block, POLICY_NET)` (+4 B) leaves all 17 suites green. Measured
  three times now: by TARIFF-01's reviewer, re-run through TEST-15's purge
  harness this session, and re-run again here on a clean uncontended tree.

  The product code is correct - tariff.py:363 really is
  `def record_block(self, block, policy: str = POLICY_NET)` and it really does
  forward the argument. What was missing is any check that could tell that
  apart from a hardcoded call, and the reason is the third instance of one
  shape: A STIMULUS EVALUATED WHERE THE QUANTITY IT GRADES IS DEGENERATE, after
  WITNESS-02's span normalisation and TARIFF-01's own DST window.

  billed_w() short-circuits to block.offtake_avg_w whenever
  per_phase_offtake_avg_w is absent. Every block reaching a store anywhere in
  the suite was single-phase or built as _CB(..., None) - so NET, SUM and MAX
  collapsed onto one number and the argument could not be observed at the call
  site. Section 7 grades the FUNCTION on an unbalanced fixture and says nothing
  about its CALLER.

  So section 7's own (3000, 500, -200) W block - whose three reductions are
  3300 / 3500 / 3000 W, asserted distinct three lines above - is now
  round-tripped THROUGH the store under each policy and read back from both
  tables (peak() and recompute_month(), so both writes in that single
  transaction are graded). record_block() with no policy is checked to bank the
  NET figure. An anti-degeneracy pin is stated over the three BANKED numbers,
  not over vals, because it is the banked ones this triple is made of.

  Direction, which is why this is worth plumbing-testing: NET is the SMALLEST
  of the three here, because the exporting phase nets off. A store that quietly
  billed NET while asked for MAX would UNDER-record the month's peak, and an
  under-recorded peak is maximum headroom for SAFETY-07 - the same
  under-guarding direction as the getattr(..., 0.0) default SAFETY-07 already
  removed from project().

ITEM 2 - the SAFETY-20 import grader claimed more than it checked.

  It collected `m.module for m in walk(tree) if isinstance(m, ImportFrom)` and
  matched a literal list of six spellings. That is blind to two of Python's
  three import forms:

    * `from . import arbiter as _ab` is an ImportFrom whose .module is None -
      the dots are in .level and the module name is in .names - so it collected
      "" and matched nothing;
    * `import app.arbiter` is an ast.Import node the walk filtered out entirely.

  Both were added to app/tariff.py and the shipped suite stayed GREEN, with the
  _names half not firing either (an alias import creates no Name node). Low
  severity: any USE of either alias still produces an Attribute the _names half
  catches. But the printed sentence "it imports nothing from arbiter, clamp or
  control" was false for two forms out of three, and a check that overclaims is
  worse than one that claims less, because the next reader stops looking.

  _imported_modules() now walks Import as well as ImportFrom and joins
  `from . import X` onto its base. _forbidden_imports() matches per dotted
  SEGMENT, not by substring: `app.arbiter` must not hide behind a package
  prefix, and `clamps` / `arbiters` / `controller` must not be falsely flagged -
  `controller` is not hypothetical, this package is goodwe_controller. Five
  evasion spellings and eight innocent ones grade the grader itself, and both
  the name half and the import half are shown non-vacuous against main.py,
  which carries all three import forms and really does import all three modules.

ITEMS 3 AND 4 - ALREADY CLOSED. STRUCK, NOT RE-IMPLEMENTED.

  Item 3: app/tariff.py:266-267 is `def is_observed(self): return self.state ==
  OBSERVED`, already false for NO_RECORD. Nothing to do.

  Item 4: app/tariff.py:672-680 reads `held = max(averager.held_net_w, 0.0)`
  with NO getattr default, and carries a comment naming TEST-13 item 4. Fixed
  by SAFETY-07 in 616b62d; held_net_w is a documented public property on
  QuarterAverager (p1.py:290), so a rename raises AttributeError on the first
  cycle instead of crediting 0 W held. A projection crediting 0 W held reads
  LOW, which is the under-guarding direction for SAFETY-07's floor. Restoring
  a default would re-open a money guard.

  And the recorded NON-finding stays a non-finding: removing clear_partial()
  is still green and that is a PASS. quarter_start is the PRIMARY KEY and the
  upsert carries `ON CONFLICT ... WHERE excluded.offtake_avg_w >
  quarter_blocks.offtake_avg_w`, so a replay is a structural no-op. That
  demonstrates TARIFF-01's "double counting is answered structurally, not by
  care" claim rather than contradicting it.

EVIDENCE - all through tools/mutation_run.sh (recursive __pycache__ purge,
python3 -B, tree re-read from disk, mutated line grepped off disk, byte delta
recorded), in the goodwe-addon-ci image against a :ro mount.

  baseline, 17 suites, before any edit ............................ 17 PASS
  baseline, 17 suites, final tree ................................. 17 PASS

  A1  billed_w(block, policy) -> billed_w(block, POLICY_NET)   +4 B  5 RED
      the 'sum' and 'max' round-trips, both tiers, and the distinctness pin
  A2  billed_w(block, policy) -> billed_w(block, POLICY_MAX)   +4 B  6 RED
      the 'net' and 'sum' round-trips, both tiers, the default, and the pin
  B1  add `from . import arbiter as _ab` to app/tariff.py     +29 B  1 RED
      the import sentence. SHIPPED suite on the same tree: GREEN - the hole.
  B2  add `import app.arbiter` to app/tariff.py               +19 B  1 RED
      the import sentence. SHIPPED suite on the same tree: GREEN - the hole.
  B3  add `from .control import compute` (the form the OLD grader DID
      catch, as a regression control)                                1 RED
  C1  delete the ast.Import branch from _imported_modules()          1 RED
      names the two spellings it MISSED, so the grader-grader is live
  C2  segment match -> substring match (`any(f in m for f in ...)`)  1 RED
      names clamps / arbiters / controller as falsely flagged

  CONTROL, and the point of the whole ticket: A1 graded by the SHIPPED
  test_tariff.py is green, and A1 graded by all 17 SHIPPED suites on a clean
  uncontended run is 17 PASS, rc=0. The survivor is real and it is now closed.

ONE THING THE REVIEWER SHOULD KNOW. During the first (contended - two docker
containers at once) run of that 17-suite survivor sweep, test_peakguard.py
reported FAIL once. It has passed 8 times since on the identical tree,
including three concurrent runs and one clean uncontended full sweep, and its
tail was lost to a grep so I cannot say which check it was. I believe it is a
timing-sensitive check under CPU contention and NOT related to this change -
this commit touches no product code and no peakguard fixture - but I could not
reproduce it, so I am recording it rather than asserting it away.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 08:40:09 +02:00
glenn schrooyenandClaude Opus 5 d0b053b150 TEST-10: pin max_w beside the meter_freeze_s key TEST-06 already pinned
checks / unit (pull_request) Successful in 3m43s
max_w and the LIVE_MAX_W literal beside it were the two survivors left
after TEST-06 pinned meter_freeze_s: changing max_w 2000 -> 4500 in
config.yaml passed every check in test_freeze.py and test_tariff.py
GREEN, and LIVE_MAX_W = 5000.0 was a bare literal that silently
disagreed with the shipped max_w (2000) with nothing in the file
saying so. For contrast, deadband_w 15 -> 5 already went RED 2 + 1,
so the pinning technique was known to work - it just was not applied
to the key either side of it.

Three changes, each measured:

1. test_freeze.py gains a check comparing Tuning()'s code default
   (control.py, 2000.0) against SHIPPED_MAX_W, read from config.yaml
   by extending the file's existing _cfg_default() regex reader
   rather than reimplementing test_arbitrage.py's _shipped. Broken
   and confirmed red: max_w: 2000 -> 4500 in config.yaml now fails
   exactly "the code default and the shipped default agree on max_w
   too" and nothing else in either file; reverting returns both files
   to all-green. (AC 1, AC 4)

2. LIVE_MAX_W is renamed LIVE_INSTALL_MAX_W in both test_freeze.py
   and test_tariff.py, each with a comment stating plainly that 5000
   is the live install's own override, not config.yaml's shipped
   max_w (2000) - the defect was a literal that could be misread as
   the shipped default, not the number itself. (AC 2)

3. test_freeze.py states PINNED_KEYS = ("meter_freeze_s", "deadband_w",
   "max_w") in one place as the complete set of config keys this
   suite pins. Deliberately excludes maintenance_* - CFG-03 (merged
   into release/1.0 since this branch started, merged in here) gave
   those keys a separate single source of truth in app/maintenance.py
   and this ticket has no reason to duplicate that pin. Not every key
   in config.yaml is pinned here on purpose: meter_freeze_s,
   deadband_w and max_w are the three this file's mutual-exclusion
   arithmetic (section 2b) actually reasons about together. (AC 3)

Full 17-file suite run green under goodwe-addon-ci after the merge
with release/1.0 (f630ed9, CFG-03). CHANGELOG.md updated under
Unreleased.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 06:48:05 +02:00
glenn schrooyenandClaude Opus 5 7b4877d56d TEST-15: purge the bytecode cache before a mutation run, not after
checks / unit (pull_request) Successful in 3m35s
FCST-03's developer reverted `self.provisional` to `here.provisional`, re-ran,
and reported the mutation as a survivor. It was not one. CPython validates a
cached `.pyc` on (source size, source mtime truncated to whole seconds), and
that edit moved neither number - same length, same second - so the interpreter
loaded the bytecode it already had. Disassembly settled it at the time: the
source read `here`, the running code loaded `self`. A mutation run had graded
code that was not there, with no error and exit status 0.

This does not make the results already on the record wrong. It makes them
UNVERIFIED, and that distinction is the whole card - so nothing here re-runs a
campaign and nothing here quietly assumes the old numbers hold.

REPRODUCED, THEN CLOSED. test_bytecode.py builds a real package whose two
return values are the same length, imports it in a real subprocess, rewrites it
inside the same second (retrying until both writes land in one whole second -
milliseconds, no sleep) and reads back which value ran. Part 1 shows the
interpreter running SELF off a file that says HERE.

AND THE TWO OBVIOUS MITIGATIONS DO NOT WORK. Measured, not read off
documentation: `python3 -B` and PYTHONDONTWRITEBYTECODE=1 are write-side only.
Neither stops an EXISTING stale .pyc being read, and a mutation run always has
one, because the previous run wrote it. Part 2 grades both still running the old
code. The advice in test_control.py's coverage audit led with `-B`; it is
corrected here - the purge is the half that works, and `-B` earns its place by
keeping THIS run from leaving a cache for the NEXT edit to inherit.

NO SLEEP ANYWHERE. One-second mtime resolution is the mechanism, not the enemy.
Waiting it out would slow every run down and would still only cover the edit
somebody remembered to slow down.

THE HARNESS. tools/mutation_run.sh purges, prints a sha256/byte fingerprint of
every app/*.py it is about to grade - so a mutation report can say WHICH source
produced it, which is the reviewer's addition to this card - runs each suite
under `python3 -B`, refuses a tree with no test_*.py (CI-01's rule, for CI-01's
reason), and fails if a cache appears during the run. It takes the tree as an
argument so the infra repo's replay harnesses can call it without being moved.

WHERE IT IS SAID (AC 2). MUTATION.md carries the mechanism, the measurement,
the reporting rules and the survey, and every one of the 16 suites now points at
it from its docstring. That list is DERIVED FROM THE TREE by test_bytecode.py
part 5, so a new suite that joins this directory without the pointer is red -
the same failure shape CI-01's file glob exists to prevent.

CI WAS ALREADY IMMUNE, AND IS DESCRIBED THAT WAY (AC 4). Verified rather than
reasoned: with the checkout bind mounted `-v "$WS:/w:ro"` the interpreter writes
no __pycache__ into the tree at all, and it fails SILENTLY - no error, exit
status 0 - while the same probe on a writable mount produces one. Each job also
rm -rf's its workspace and clones afresh, so nothing crosses between jobs on the
shared runner. PYTHONDONTWRITEBYTECODE=1 is added as belt and braces only: the
`:ro` is one character wide and the next person debugging a write will be
tempted by it. Part 6 grades both ON THE DOCKER COMMAND LINE rather than on the
file - the first draft searched the whole workflow and stayed green after the
flag was deleted, because the comment explaining it still named it.

THE SHIPPED ADD-ON IS NOT AFFECTED and the question is closed:
goodwe_controller/Dockerfile has set PYTHONDONTWRITEBYTECODE=1 since it was
written. There is no deploy-side finding.

THE SURVEY (AC 3), in MUTATION.md, scoped BY THE PROPERTY. The defect is
one-directional - a mutation that never took leaves the suite green, so stale
bytecode manufactures SURVIVORS and cannot manufacture a red - which puts every
"all red, no survivors" claim in the window out of scope, along with mutations
executed in-process (SAFETY-07's B1/B2/B3, test_peakguard parts 7 and 10, where
no source file is edited) and everything run after the purge discipline landed
on 2026-08-31 (SAFETY-24, SAFETY-26, OPT-04, FCST-03's re-run). What is left is
the recorded SURVIVORS from source-edit runs before that date. Two were re-run
here under the harness and both still stand: TARIFF-01's record_block() ignoring
its policy argument, and SAFETY-03's abs() on the clamp limit - 15 suites green
each, on the tree as it stood. The rest are labelled still-unverified rather
than assumed or re-run; each is one harness run to close, and the moment to
spend it is when somebody next touches that module.

OUT OF SCOPE AND RECORDED: the infra repo's sim/ replay harnesses import the
add-on and write bytecode into both trees (goodwe/sim/__pycache__ exists today),
so SAFETY-02's headline replay carries the same exposure. mutation_run.sh takes
a tree argument for exactly that, but the change belongs in that repo and some
of those harnesses want the rig.

RED AND GREEN. Every new check was broken and watched fail: the purge commented
out of the harness turns part 4 red (and its non-vacuity probe already proves
the fixture would mis-grade without it); one docstring pointer removed turns
part 5 red naming the file; `:ro` and the `-e` flag removed from the workflow
turn part 6 red. Full suite green, 16 files, in the CI image on a read-only
mount - the shape the runner uses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 02:07:09 +02:00
glenn schrooyenandClaude Opus 5 504e8daac3 TEST-17: grade the status page's peak row at a stated month, not at today's
checks / unit (pull_request) Successful in 3m35s
release/1.0 went red at 00:00 on 2026-09-01 on a commit nobody had
touched. `Controller.checks()` read the capacity-tariff peak with
`self.peak.peak()` - no argument - so tariff.py resolved the month from
`datetime.now()`, while test_tariff.py section 10 banks its quarter at
the fixed fixture 2026-08-30. From 1 September the page was correctly
asked for September, correctly answered NO_RECORD, and the row read "no
completed quarter recorded yet" - a string containing no "peak", which
the section's own filter required. Four checks failed.

ISOLATED BY MOVING ONLY THE CLOCK. Same commit, same shipped-deps image,
libfaketime: green at 2026-08-30, four failures at 2026-09-01. Identical
at f4914a1, 04292c4~1 and 04292c4, so this is the calendar and not a
regression. It has been latent since TARIFF-01 merged on 2026-08-30, and
every PR that ever ran this suite ran it in August.

THE PRODUCT IS RIGHT AND THE TEST WAS WRONG, so the live page is
unchanged. Section 5 of this same file calls NO_RECORD "a normal state
on the 1st of the month" and a capacity tariff is billed monthly, so a
page showing the current month is correct. `checks()` now takes
`when: datetime | None = None` and threads it into peak() and
warning_frac() - the convention PeakStore.peak(), PeakTracker.peak(),
headroom_w() and warning_frac() all already follow. The one production
caller passes nothing and still means today.

AND THE DEFECT IS NOW DETECTABLE RATHER THAN SEASONAL. Three checks:
the same controller read at two different reference months must produce
two different rows, which no clock can satisfy; the later month must
read NO_RECORD and stay distinguishable from both an observed peak and
an unreadable store; and checks() with no argument must still mean
today. Verified by mutation - a checks() that drops `when` is red at
every clock tried, where the old section went green in August. The first
draft of these checks was itself wrong in the same way and reproduced
the bug: it selected rows on "peak", which the NO_RECORD row does not
contain, so it dropped the row and then graded its absence. They select
on "quarter(s) banked" instead.

Full suite green, 15 files, at the real clock and at faked clocks in
2026-08, 2026-12, 2027-02 and 2027-03.

Left alone deliberately: only the UNREADABLE branch prefixes its row
with "capacity-tariff peak:". The NO_RECORD row carries neither that
phrase nor the word "peak". That asymmetry is what made the original
filter brittle and what broke the first draft above; it is product text
with other suites reading it, so it is reported rather than changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-01 00:58:20 +02:00
glenn schrooyenandClaude Opus 5 04292c4a08 DATA-01: thirty days of the real meter, and what AC 3 actually measures
checks / unit (pull_request) Successful in 3m36s
TARIFF-01 and SAFETY-07 both merged with AC 3 - "monthly peak held under
the soft limit in >= 95 % of intervals over a 30-day simulation replay" -
honestly unmet, because two days of capture existed and it was believed
nothing longer could be produced. That belief was a true statement about
the past read as a claim about the future: HA's recorder still held the
whole month, and the reason nobody found out is that the 2026-08-25
export was a one-off script that left no artefact.

So the exporter is committed and runnable now.
scenarios/p1-net-30d-2026-08/ is 2026-08-01 to 2026-08-30, 485,841
samples, 30/30 days read, 0 failed, 4.13 MB gzipped, in the schema the
two committed days already fixed.

VERIFIED AGAINST THE DAYS THAT EXIST. `--verify` re-fetches 2026-08-20
and 2026-08-23 by this exact route and reproduces both committed files
BYTE FOR BYTE after decompression (865,024 B / 15,600 samples and
894,387 B / 16,351 samples). The gzip containers differ by design: this
exporter writes mtime 0 so its output is reproducible, where the one-off
wrote a wall clock.

A FAILED READ IS NOT AN OBSERVATION. A day whose read fails writes no
file, is recorded "read": "FAILED", and the run exits non-zero; the
entity is proved to exist through /api/states before any empty day is
believed, because a typo would otherwise answer 200 [] thirty times and
produce a consistent dataset of nothing. Nothing is interpolated, so the
month's 13 gaps (1291 s, 99.95 % covered) and its 3 `unavailable`
samples stay visible as holes.

SIZE: COMMITTED, DELIBERATELY, AND MEASURED. A git bundle of the whole
history goes from 1.58 MB (release/1.0 @ 86c43eb) to 5.28 MB - +3.70 MB,
x3.3 - and a Home Assistant supervisor clones that on every install.
Downsampling was not on the table: meter_max_age_s is 30 s, so any
coarser resample turns real coverage into modelled meter-gap and moves
the number being measured. "Outside the repo with a pointer" is what
already happened and is why this card exists. The files sit in a
SUBDIRECTORY so the non-recursive globs in test_tariff.py part 9 and
test_peakguard.py part 6 do not pick them up - CI keeps replaying the two
small days instead of growing ~50 s per PR.

AC 3, MEASURED OVER 30 DAYS (tools/replay_ac3.py, which must reproduce
test_peakguard part 6's 189/190 to be believed):

  uncontrolled  2847/2879 = 98.89 % of quarters at or under 3500 W
  guarded       2850/2879 = 98.99 %
  month peak    5029.8 W -> 4107.3 W

Met on the ">= 95 % of intervals" reading - and the uncontrolled figure
is the finding, because a month with no controller at all passes it. Not
met on the other reading of the same sentence: 4107.3 W is still over the
soft limit. The guard pulls 31 of the 32 over-cap quarters down but lands
only 3 under the cap, because CEILING_MONTH_PEAK defends the running
month peak rather than the cap once one is set. Both suites' honesty
notes are corrected to point at the month instead of at its absence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-31 11:15:02 +02:00
glenn schrooyenandClaude Opus 5 f4914a174a TARIFF-01: measure the month's billed peak, and make it survive a restart
checks / unit (pull_request) Successful in 2m38s
The 15-minute block average was already built and unwired. p1.QuarterAverager
computes clock-aligned 900 s blocks, time-weighted over irregular telegram
spacing, offtake-only and per-phase; P1Ingest.blocks was a 96-entry list in
memory that a restart erased. So this is the persistence and the monthly
reduction, not a second accumulator - a second one would be a second source of
truth for the exact quantity the money depends on.

BLOCKS, NOT A SLIDING WINDOW. The card says "rolling"; 9.1 bills "the highest
15-minute average offtake" and states its own worked case per QUARTER - one
4998 W quarter moving a monthly peak from 4832 to 5013 W. A trailing window
reports a peak the meter never bills, and reports it HIGH: a burst straddling
:15 fills the window completely while contributing to two block averages at
partial weight.

DELIVERED, NOT INTENDED. tariff.py only ever sees P1Sample - the meter's own
reading - and never a setpoint. SAFETY-20's open question does not arise for a
capacity tariff: the utility integrates the current that flowed.

AN UNREADABLE STORE IS NOT A ZERO PEAK. peak() answers OBSERVED / NO_RECORD /
NOT_OBSERVED with peak_w None unless observed, which is riglock.holder()'s fix
applied to something that spends money. A peak read as 0 W hands SAFETY-07
maximum headroom and disarms the guard at the moment its own storage breaks.

Restart mid-block resumes the accumulator through a new snapshot()/restore()
pair, checkpointed every 15 s and cleared in the same breath as a block close.
The held sample keeps its ORIGINAL timestamp, so an outage is integrated under
the same max_hold_s rule a running process would apply. quarter_start is the
primary key and a re-bank may only ever RAISE a figure.

Calendar months are LOCAL: a quarter starting 22:15 UTC on 31 August bills in
September.

No new option (the limit is the existing peak_cap_w), so config.yaml and
validate.py are untouched and CFG-03 is not widened. No second peak entity:
peak_forecast_entity stays the input to maintenance_charge_floor(), because
repointing that consumer changes what moves energy and belongs to SAFETY-07.

AC 3 is NOT met. It asks for 30 days of replay; two days of this house's real
meter exist and are committed here. test_tariff.py part 9 replays them, reports
the percentage next to the sample size in days, and asserts nothing about 95 %.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
2026-08-30 19:13:58 +02:00