checks / unit (pull_request) Successful in 3m51s
SAFETY-26's AC4 gave the same tolerance mutation to every price and quantity
boundary in arbitrage.py and static_tariff.py. Everything in arbitrage.py went
red - `price < 0`, `soc <= floor_pct`, `soc >= ceiling_pct` are each red under
+/-5. Two survived, both in static_tariff.py, and this card closes them.
D `spread == 0.0` (:912) weakened to `spread <= 0.001` SURVIVED at +2 B.
F `len(firm) < 2` (:901) weakened to `len(firm) < 1` SURVIVED at +0 B - the
delta MUTATION.md names as the risky one.
THE DEFECT IS NOT EITHER ROW, IT IS THE INTERACTION. D and F MASK EACH OTHER. A
single firm hour also has a spread of 0.0, so with F weakened the flat rung
refuses the stimulus anyway; with D weakened the small-but-real spread is still
refused, only for a lie. Each rung's failure is absorbed by the other, so BOTH
can be weakened together and a suite grading `kind == "refused"` reports
nothing. That is structurally invisible to single-mutation testing, which is how
this pair came through four rounds of review on TARIFF-03 and FCST-03 - and it
is the arrangement in which the next edit to either rung opens a real hole with
nothing to report it.
D is not cosmetic either. With the tolerance in place a series priced 0.20000 /
0.20050 - a REAL 0.0005 EUR/kWh spread - flips ALLOWED to REFUSED at
min_spread_eur_kwh=0.0, a value arbitrage()'s own docstring documents ("pass 0.0
to mean any positive spread, explicitly") and its validator at :861 accepts on
purpose. At a shipped threshold the outcome does not move, but the operator
reads the false sentence "the tariff is flat - every one of the 2 firm hours
costs 0.2000 EUR/kWh" about a tariff that is not flat.
WHAT WAS ADDED. test_static_tariff.py section 9, and the shape of it is the
argument:
* THE REASON IS GRADED, NOT THE VERDICT. The masked case refuses CORRECTLY
while reporting the wrong sentence, so a check asserting only "refused"
passes on a broken tree. A `rung9()` helper names which refusal answered,
from the sentence the operator would read; an unrecognised refusal reports
itself as UNATTRIBUTED rather than quietly matching nothing.
* EACH RUNG IS GIVEN A STIMULUS THE OTHER CANNOT ABSORB, or the pinning is
circular. The flat rung gets a THREE-firm-hour flat series, which no
`len(firm) < N` a person would write can reach. The firm-pair rung gets a
ONE-FIRM-HOUR series, which the flat rung CAN reach - so its attribution is
the whole check, and that is why it is written as a rung name rather than a
verdict.
* AC1 is a sweep of small-but-real spreads (0.0005 down to 0.000001) all
ALLOWED at min_spread_eur_kwh=0.0, with the exactly flat series on the other
side still refused BY THE FLAT RUNG - without that mirror the sweep would
also pass for a rung deleted outright. 1e-6 is the floor because the shipped
line rounds the spread to 6 decimals; that quantisation is graded in the
open as its own row rather than left unstated.
* AC3 is a five-row table graded on rung attribution, which denies the masking
from both sides at once.
* The firm-pair count is also pinned from ABOVE (`< 3`) and BELOW (`< 0`),
which the criteria do not ask for but which is what stops this section being
one-sided in exactly the way it was written to fix.
EVIDENCE. Eight rounds through TEST-15's tools/mutation_run.sh, used UNCHANGED:
recursive __pycache__ purge before each run, python3 -B, the tree re-copied FROM
DISK between rounds, the mutated line grepped out of the file before the run,
byte delta printed, test_static_tariff.py and test_arbitrage.py graded each
time. Baseline green.
baseline, shipped +0 B PASS
D1 `spread == 0.0` -> `<= 0.001` +2 B RED, 3 checks
D2 `spread == 0.0` -> `<= 0.000001` +5 B RED, AC1 alone
D3 `spread == 0.0` -> `< 0.0` (rung gone) +0 B RED (section 2 as well)
F1 `len(firm) < 2` -> `< 1` +0 B RED, 3 checks
F2 `len(firm) < 2` -> `< 3` +0 B RED, 6 checks
F3 `len(firm) < 2` -> `< 0` (rung gone) +0 B RED (ValueError)
D1+F1 BOTH AT ONCE - THE TICKET +2 B RED, 5 checks
Four of the seven are +0 B, the delta MUTATION.md names as the one stale
bytecode can hide. All are REDS, the direction that defect cannot fabricate -
but they were run under the purge anyway, because "it went red so the cache
cannot have lied" is a conclusion and not a procedure.
NO SHIPPED BEHAVIOUR CHANGED. app/ is untouched; both rungs were correct all
along, and the card is explicit that a rung found actually wrong is a stop-and-
report rather than an edit. Also updated: MUTATION.md gains a short section on
masking as a SECOND way a survivor lies, with the two rules that fall out of it
(grade the reason, and mutate suspected pairs together); and test_arbitrage.py's
SAFETY-26 AC4 note is marked closed rather than left reading as an open finding.
UNSURE ABOUT. The 5e-7 row asserts the shipped `round(spread, 6)` quantisation.
That is a real property of the line and worth stating, but it does pin a
rounding the ticket never named - if a reviewer would rather that stayed
unpinned, it is one check to drop and nothing else depends on it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
185 lines
12 KiB
Markdown
185 lines
12 KiB
Markdown
# Mutation runs in this repository — and the one way they lie
|
|
|
|
Nearly every safety claim in this add-on was established the same way: change
|
|
the shipped module, run the suites, watch one go **red**, put the module back,
|
|
watch it go **green**. There is no coverage tool here and there is not meant to
|
|
be — the check that matters is whether a *check* notices when the behaviour it
|
|
names is removed.
|
|
|
|
That method has exactly one silent failure mode, and TEST-15 exists because we
|
|
walked into it.
|
|
|
|
## The defect
|
|
|
|
FCST-03's developer reverted `self.provisional` to `here.provisional`, re-ran,
|
|
and reported the mutation as a **survivor**. It was not. CPython decides a
|
|
cached `.pyc` is still valid from two numbers:
|
|
|
|
* the source file's **size in bytes**, and
|
|
* the source file's **mtime truncated to whole seconds**.
|
|
|
|
`self` and `here` are the same length, and the revert happened in the same
|
|
second as the edit. Neither number moved, so the interpreter loaded the
|
|
bytecode it already had. Confirmed at the time by disassembly: **the source read
|
|
`here`, the running code loaded `self`.** The suite had graded code that was not
|
|
there — no error, no warning, exit status 0.
|
|
|
|
The same collision, in the other direction, can leave a **red** standing on
|
|
correct source after a revert that never took.
|
|
|
|
### Which way it lies, and why that scopes everything
|
|
|
|
A mutation that never took effect leaves the suite **green**, and a green run of
|
|
a mutated tree is reported as a survivor. So stale bytecode **manufactures
|
|
survivors**; it cannot manufacture a red. The false-red case exists but
|
|
announces itself, because a developer chases a failure they cannot explain.
|
|
|
|
Two consequences, and both are used below:
|
|
|
|
* a recorded **"all red, no survivors"** result is not at risk from this defect;
|
|
* a recorded **survivor** — and a recorded *zero*, such as a coverage-audit
|
|
count of 0 — is exactly what this defect produces.
|
|
|
|
`test_control.py`'s coverage-audit table says the same thing in its own terms:
|
|
the error is one-directional, so every figure there is a lower bound at worst.
|
|
|
|
## The rule
|
|
|
|
**Purge `__pycache__` before every mutation run.** Not afterwards, not usually,
|
|
not when the edit looks big enough.
|
|
|
|
```sh
|
|
cd goodwe_controller
|
|
tools/mutation_run.sh # every test_*.py in this directory
|
|
tools/mutation_run.sh . test_tariff.py # or just the ones you are watching
|
|
tools/mutation_run.sh "$GOODWE_ADDON_REPO/goodwe_controller" # from elsewhere
|
|
```
|
|
|
|
The harness purges, prints a `sha256`/byte-count fingerprint of every `app/*.py`
|
|
it is about to grade, runs each suite under `python3 -B`, and fails if a cache
|
|
appeared during the run. Diff two fingerprints and the file you mutated is the
|
|
line that changed — which is also how a mutation report proves *which source*
|
|
produced it.
|
|
|
|
### What does **not** fix this
|
|
|
|
| | writes bytecode | reads a stale `.pyc` |
|
|
|---|---|---|
|
|
| `python3 -B` | no | **yes** |
|
|
| `PYTHONDONTWRITEBYTECODE=1` | no | **yes** |
|
|
| purging `__pycache__` | (unchanged) | **no — nothing to read** |
|
|
|
|
Both flags are *write-side only*. A mutation run always has a cache already,
|
|
because the previous run wrote it, so a harness that only adds `-B` closes
|
|
nothing. This is measured, not read off documentation: `test_bytecode.py`
|
|
part 2 arranges the collision and shows both flags still running the old code.
|
|
|
|
`-B` is still worth passing — it stops **this** run leaving a cache for the
|
|
**next** edit to inherit, which is the one nobody is thinking about three
|
|
mutations into a batch. Purge for this run, `-B` for the next one.
|
|
|
|
### And not a `sleep`
|
|
|
|
One-second mtime resolution is the *mechanism*, not the enemy. Waiting out the
|
|
clock would slow every run down, and would still only cover the edit somebody
|
|
remembered to slow down. Stop trusting mtime instead.
|
|
|
|
## Reporting a mutation
|
|
|
|
State the things a reviewer cannot reconstruct afterwards:
|
|
|
|
1. **the byte delta** of the mutated file (`+5 B`, `+0 B`). `+0 B` is the risky
|
|
one — say so;
|
|
2. **that the mutated line was on disk** when the run started (the harness
|
|
fingerprint, or a `grep` of the line);
|
|
3. **which suite went red, and on which named check** — "the suite is red" and
|
|
"the check that watches this is red" are different claims;
|
|
4. for a **survivor**, that the run was purged. An unpurged survivor is not a
|
|
result.
|
|
|
|
## The other thing a mutation sweep cannot see: masking (TEST-16)
|
|
|
|
Stale bytecode is not the only way a survivor lies. **Two guards can mask each
|
|
other**, and a sweep that mutates one line at a time is structurally blind to it.
|
|
|
|
TEST-16 is the worked example. `static_tariff.arbitrage()` refuses a
|
|
one-firm-hour series at `len(firm) < 2`, and refuses a flat series at
|
|
`spread == 0.0`. A single firm hour *also* has a spread of zero. So:
|
|
|
|
* weaken the firm-pair rung alone → the flat rung refuses the stimulus anyway;
|
|
* weaken the flat rung alone → the tiny-spread stimulus is still refused, just
|
|
for the wrong reason;
|
|
* and the suite, grading `kind == "refused"`, reports **nothing either way**.
|
|
|
|
Both rungs could then be weakened *together* and still nothing would report —
|
|
which is precisely the state in which the next edit to either one opens a real
|
|
hole. It survived four rounds of review on TARIFF-03 and FCST-03.
|
|
|
|
Two rules come out of it, and both are cheap:
|
|
|
|
1. **Grade the reason, not just the verdict.** "Refused" and "refused for the
|
|
right reason" are different claims, and a masked rung is the case where the
|
|
first is true and the second is false. A refusal sentence is what the
|
|
operator reads; it is also the only observable that moves when one rung
|
|
quietly absorbs another's work.
|
|
2. **When two rungs can answer the same stimulus, mutate them together.** One
|
|
extra run per suspected pair. If a sweep finds two survivors in the same
|
|
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.
|
|
|
|
## Where each surface stands
|
|
|
|
* **Local and dev-harness runs — the exposed one.** This is where FCST-03 hit
|
|
it. `goodwe_controller/__pycache__` and `app/__pycache__` are gitignored and
|
|
are sitting in most working trees right now. Use the harness.
|
|
* **CI — structurally immune, and it was before this card.** `.gitea/workflows/
|
|
checks.yml` bind mounts the checkout `-v "$WS:/w:ro"`, so the interpreter
|
|
cannot write a `__pycache__` into the tree under test at all; measured for
|
|
TEST-15, and it fails *silently* — no error, exit status 0. Each job also
|
|
`rm -rf`s its workspace and clones afresh, so nothing crosses between jobs on
|
|
the shared runner. `PYTHONDONTWRITEBYTECODE=1` is now set there too, as belt
|
|
and braces: the `:ro` is one character wide and the next person debugging a
|
|
write will be tempted by it.
|
|
* **The shipped add-on — not affected, and this is closed.**
|
|
`goodwe_controller/Dockerfile` has carried `PYTHONDONTWRITEBYTECODE=1` since
|
|
it was written. There is no deploy-side finding here; do not re-open it.
|
|
* **The infra repo's `sim/` harnesses — exposed, and NOT fixed by this card.**
|
|
`sim/replay_sign.py` and friends import the add-on out of
|
|
`$GOODWE_ADDON_REPO` and write `__pycache__` into *both* trees
|
|
(`goodwe/sim/__pycache__` exists today). SAFETY-02's headline validation is a
|
|
replay, not a suite, so a mutation graded through it carries the same
|
|
exposure. `tools/mutation_run.sh` takes the tree to purge as its first
|
|
argument precisely so that repo can call it, but the change belongs in that
|
|
repo and some of those harnesses want the rig. **Anyone mutating a module
|
|
graded by a replay must purge both trees by hand until then.**
|
|
|
|
## Survey — mutation claims of 2026-08-25 … 2026-09-01
|
|
|
|
Scoped **by the property, not by re-running everything**. A claim is at risk
|
|
only if a mutation could have preserved the file's byte count *and* been
|
|
reverted inside the same second, *and* the claim is of the kind stale bytecode
|
|
can fabricate (see "which way it lies"). Sources: `git log release/1.0` and the
|
|
commit and PR bodies.
|
|
|
|
| claim | verdict | why |
|
|
|---|---|---|
|
|
| **FCST-03** M9 `self.provisional`→`here.provisional` (dd40c56, 71cd784) | **re-verified** | this is the original false survivor. Re-run with the cache cleared, M9 is caught. The shipped source was correct throughout. |
|
|
| **FCST-03** X1, X6 degenerate stimuli (71cd784) | **not at risk** | run after the purge discipline landed in that branch. |
|
|
| **SAFETY-24** 11 author + 9 reviewer mutations (e709554, 4601682) | **not at risk** | the commit records them run under `python -B` with a purged `__pycache__` and real byte deltas. Its one survivor is an equivalent mutant, argued structurally rather than by a green run. |
|
|
| **SAFETY-26** tolerance sweep over `arbitrage.py` and `static_tariff.py`, 2 survivors (2b8b8cb) | **not at risk** | purge, printed `+5 B` delta, mutated line asserted present, tree re-read before each run. One survivor is explicitly recorded at `+0 B` — the risky delta — and was produced *under* the purge. **Both survivors are now closed by TEST-16** (`test_static_tariff.py` section 9), which also found that the two of them *masked each other* — each was individually non-live only because the other rung caught its stimulus, an arrangement single-mutation testing cannot see. Its own ledger is 7 mutations, 4 of them at `+0 B`, all red. |
|
|
| **OPT-04** mutations reported on PR #22 (bdd7f18) | **not at risk** | `test_arbitrage.py`'s header records the exact command: purge, then `python3 -B`. |
|
|
| **TEST-17** `checks()` dropping `when` (504e8da) | **not at risk** | a *red* claim, and dropping an argument changes the file length. Neither half of the collision is available. |
|
|
| **SAFETY-07** wiring mutations B1/B2/B3 and `test_peakguard.py` parts 7 and 10 (2d570d8, e7c95bf) | **not at risk** | those mutations are **executed in-process**, monkeypatched onto the imported module. No source file is edited, so no `.pyc` is consulted. |
|
|
| every **"all red, no survivors"** claim in the window — TARIFF-02 (24/0), TARIFF-03 (11), MAINT-04 (10), SAFETY-18 (6+4), SAFETY-03 (6), TEL-04 (10), TEL-05 (6) | **not at risk** | direction. Stale bytecode turns a red into a green survivor, never the reverse. A red is evidence that *something* changed under the check. |
|
|
| **TEST-06**'s deleted assertion, "no new notification while parked at 0 W" (c34b5a2) | **not at risk / superseded** | it was recorded as undecidable and then *falsified* — `test_freeze.py` records the one-line mutation that takes the file red 9 times. The stronger later claim is a red one. |
|
|
| **TARIFF-01** reviewer survivor: `record_block()` ignoring its `policy` argument (0dbd485) | **re-verified — still a survivor** | re-run here through `tools/mutation_run.sh` against the 15 suites that were on the record, all green with `billed_w(block, policy)` mutated to `billed_w(block, POLICY_NET)`. The recorded diagnosis holds: every block reaching the store is single-phase, where NET, SUM and MAX collapse. |
|
|
| **SAFETY-25** 8 mutations against `test_peakguard.py` part 10, then 9 more after review, all red | **not at risk** | each run against a tree copied fresh with `__pycache__` excluded and mounted `:ro`, so no `.pyc` existed to go stale - the same structural immunity recorded for CI above. Byte deltas recorded on the PR; none is `+0 B`. All are **reds**, which is the direction this defect cannot fabricate. ⚠️ The second round exists because the first round's mutations were all aimed at DISARMING the guard, and the claim part 10 actually made was about the guard being FIXED - the reviewer built the fix and the suite stayed green. Bytecode was never the issue there; an unaimed battery was. |
|
|
| **SAFETY-03** reviewer survivor: `abs()` dropped from `clamp_to_rating`'s limit (6aabb83) | **re-verified — still a survivor** | re-run the same way, all 15 green. Also as recorded: unreachable under the `int(100, 5000)` schema. |
|
|
| **TARIFF-01**'s other three survivors, **SAFETY-03**'s `describe()` survivor, **TARIFF-02**'s M3/M5/M7, **SAFETY-18**'s three | **still unverified** | recorded before the purge discipline, with no byte delta on the record, so a `+0 B` same-second revert cannot be excluded. All are survivors, which is the direction this defect fabricates. TARIFF-02's and SAFETY-18's were **closed by later commits whose claims are reds**, so what is unverified is the *diagnosis*, not the current state of the code. |
|
|
|
|
**Nothing here says a past result is wrong.** The two survivors re-run above
|
|
both stood. What changed is that they are now on the record as measured rather
|
|
than assumed, and the rest are labelled honestly. The still-unverified rows are
|
|
cheap to close — one harness run each — and the right moment is when somebody
|
|
next touches that module, not in a re-run campaign nobody will read.
|