Compare commits

..
5 Commits
Author SHA1 Message Date
glenn schrooyenandClaude Opus 5 f76639c7b2 TEST-21: two of the argmax's four axes are on an edge, and the doc did not say so
checks / unit (pull_request) Successful in 6m22s
§3.3 already set the standard - "a worst case sitting on an edge of the grid
would be a number about the grid" - and then did not check its own argmax
against it. Review did. Reproduced here independently before writing it down:

    stagger  0 -> 1 -> 2 -> 3 -> 4      0.0  15.0  25.0  30.0  35.0 s
    weights (A) at stagger 4            0.5 -> 20.0   0.7/0.9/0.99 -> 0.0
                                        0.10 -> 35.0 s

Strictly increasing in stagger, maximal at the largest modelled stagger, and
maximal at the most asymmetric split. Interior only in `gain` and `slew_w`.

⚠️ SO THE HEADLINE IS 0.0 s AT stagger=0. The 35.0 s is ENTIRELY a product of
the modelled serialised-write stagger, which is declared MODELLED in the harness
and is a TERM of the figure rather than a detail of it. A site whose two writes
land in the same cycle does not produce this episode at all.

It does not move `delivery_hold_s`: 185 s must cover the worst the modelled site
can produce, and four cycles of stagger is inside what a real two-inverter write
path can do. What changes is that a reader asking "is 35.0 s about the plant or
about the grid?" now gets the answer instead of the invitation.

⚠️ THE PR BODY HAD CLAIMED THE ARGMAX WAS "interior to the grid, not on an edge
of an axis I had to choose". That was wrong, and it is the claim the doc's own
standard exists to test. The grid is the record's own, fixed by §3.3 before this
rebuild ran, which is the mitigating half and is stated too.

Also: `nl.yaml` wrote "35.0 s" beside "5,29 keer" and "75,0 s" in one sentence.
Dutch takes the comma.

Suite: 26 files, all pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-07 21:47:08 +02:00
adminandClaude Opus 5 57775d4e06 TEST-21: delivery_hold_s's floor re-derived, and 30.0 s did not reproduce
No code and no option changes. What changes is a number this repository claimed
about a shipped safety constant.

`delivery_hold_s: 185` shipped resting on "30.0 s, the worst continuous
legitimate episode at the shipped threshold across all 2640 runs", plus a lag
sweep giving 45.0 s and 90.0 s - from a harness `DECISIONS/SAFETY-42` §1
deliberately did NOT commit, because it imports `plantsim` and `frame_check` out
of admin/goodwe. So the floor under a safety constant could not be re-run. That
is PROC-01's shape, with eleven instances in three days.

The harness is committed now, in admin/goodwe as `sim/delivery_window.py`, and
NOT here. `plantsim` and `frame_check` are that repo's; restating them in this
tree is CI-08's coupling, which has broken three times, and the alternative
fate - skipped for want of its imports - is a green that establishes nothing.
There the dependency runs one way: THIS repo's `control`, `arbiter` and the
shipped `delivery.delinquent()` are imported live out of whichever checkout is
under test. Its `--selftest` is now a step in that repo's `checks.yml`, so a
change here that moves `saturation_w`, `max_w`, `delivery_hold_s` or the shape
of `delinquent()` goes red there instead of leaving a stale number here.

    cd goodwe/sim && GOODWE_ADDON_REPO=/path/to/goodwe-addon \
        python3 -B delivery_window.py --selftest --campaign --blag

⚠️ THE HEADLINE DID NOT REPRODUCE: 35.0 s, not 30.0 s. Recorded as measured. The
harness was not adjusted until it agreed - the one correction made during the
rebuild was made AGAINST the doc's number (§1 records it) and made every figure
LARGER.

WHAT REPRODUCED - and it is the part the compatibility promise rests on:
  * the N=1 control arm is 0.0 s at EVERY threshold in EVERY run. §3.2's algebra
    measured, not argued.
  * the flapping family is 0.0 s everywhere in all 840 runs - the family that
    defeated SAFETY-30's site window still cannot touch this check.
  * 45.0 s at twice A's lag, to the cycle.

WHAT DID NOT:
  * the headline, by one control cycle (30.0 -> 35.0 s); the reversal family by
    two (20.0 -> 30.0 s).
  * the NON-MONOTONE curve. §3.3 finding 3 read 55.0 s at 250 W against 40.0 s
    at 100 W as a property of the reduction. This rebuild's curve is monotone.
    The mechanism is real and is still the reason not to read either table as a
    sensitivity curve, but it is an ARGUMENT now, not a measurement, and it is
    written down as weaker.
  * the low-threshold rows, badly (40.0 -> 120.0 s at 100 W). Nothing ships
    there, but that gap is the honest measure of how much of the original table
    was a property of axes nobody wrote down. §3.3 fixes every axis COUNT and
    RANGE and no individual value; the rebuild prints its choices above its own
    table so the caveat travels with the number.
  * the four-times-slower row moved the OTHER way: 75.0 s, not 90.0 s.

185 s IS NOT CHANGED. It is 5.29x the re-measured worst (the record claimed
6.2x), 2.47x the four-times-slower case, 6.2x the firmware's LINK_STALE_S veto
and 4.9x inside the billing quarter. All four bounds clear and none narrowly,
and the ordering of the argument is unchanged. Moving a shipped safety constant
because a margin went from 6.2x to 5.3x while every bound holds would re-open
SAFETY-41's four-attempt false-trip regime for nothing.

⚠️ ONE CONSEQUENCE AN OPERATOR CAN HIT. At 30.0 s the rig's floor coincided with
the firmware's veto and a reader could not tell which was binding. At 35.0 s the
rig's is strictly the binding one - and it is ABOVE the schema's own
`int(30,900)` minimum. `delivery_hold_s: 30` is therefore below a MEASURED
legitimate episode and would report on ordinary saturation. Both translations
now say so. Raising the schema floor is a change to a shipped option's contract
and is left to its own card rather than smuggled in here.

⚠️ THE B-LAG ROWS ARE RELABELLED THROUGHOUT. Inverter B does not physically
exist (~2026-09-12); `plantsim.INVERTER_B` is A COPIED, `measured=False`. Every
two-inverter figure in this record is two copies of A, and the lag sweep is a
SENSITIVITY STUDY ON A HYPOTHESIS ABOUT B, not a measurement of it. The old
wording - "MEASURED, not felt" - overstated exactly that, and "the RAMP does not
move it at all" was too strong: halving it is worth 0 s at A's lag and +5 s at
twice it.

Touched: DECISIONS/SAFETY-42 §1 and §3.3, app/delivery.py's provenance comment,
config.yaml's `delivery_hold_s` block, both translations, CHANGELOG Unreleased.
26/26 suites pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-07 21:47:08 +02:00
admin bb469163a6 Merge pull request 'UX-04: nothing was hidden, the defaults pin was never built, and CFG-04 was closed by UX-02' (#66) from UX-04 into release/1.0 2026-09-07 19:03:35 +02:00
glenn schrooyenandClaude Opus 5 477911afa1 UX-04: the 59 was computed and then graded against "> 15"
checks / unit (pull_request) Successful in 6m22s
Review approved and found four things worth fixing. Three are cosmetic; the
fourth is the same defect this card is about, one level up.

⚠️ THE FLOOR WAS PRINTED AS 59 AND ASSERTED AS `> 15`. So a manifest change
dropping the required count to 20 would have passed while the sentence beside it
still said 59 - a number displayed but not graded, which is exactly what let
UX-01's AC 3 survive review in the first place, and exactly the shape
`test_translations.py` was already fixed for.

Now DERIVED and graded in two separate assertions, because they are two
different claims:

  1. `_EXPECT_REQ = len(schema) - len(_OPTIONAL)`, where optional is Supervisor's
     own rule - a `?` suffix, plus `inverters` because `UiOptions` marks a list
     optional - asserted EQUAL to Supervisor's required count. The printed
     number is computed, so it cannot go stale.
  2. ⚠️ AND THE WITHDRAWAL'S PREMISE, SEPARATELY. The equality alone is not
     enough and I checked why: both sides read the same `?` convention, so
     marking a key optional moves BOTH and it stays green - measured, by making
     `slew_w` optional and watching it pass. That grades Supervisor agreeing
     with us, NOT the thing AC 3 was withdrawn for. The second assertion grades
     the premise: the floor is still far above the ~15 AC 3 asked for, and if it
     ever is not, the withdrawal needs re-arguing rather than re-asserting.

Driven, both directions, against the real 2026.08.0 image:

    clean                         PASS  "floor is DERIVED: 71 schema keys minus
                                         12 optional ones is 59, and
                                         Supervisor's own required flag agrees"
    premise threshold 15 -> 40    FAIL  "the floor of 59 is still far above
                                         the ~40 AC 3 asked for"

Also: a dangling reference to `DECISIONS/UX-04-hiding-and-the-deepmerge.md`,
which was never the filename shipped, and `DOCS.md` still saying section F
"prints" what it now grades.

Not changed, and review agreed both are disclosed rather than defects: the
pair-keyed ledger cannot see a SECOND site for a pair it already holds (the
site count can grow while the pair set cannot), and the probe's assertions run
by hand rather than in CI because they need the Supervisor image - the same
status as before this card, not a regression.

Suite: 27 files, all pass. Probe: all cases passed on
ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0, --network none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-07 18:56:55 +02:00
Claude Opus 5 21f22b393a UX-04: nothing was hidden, the defaults pin was never built, and CFG-04 was closed by UX-02
checks / unit (pull_request) Successful in 6m20s
UX-01's three unmet acceptance criteria, closed out. It shipped in 0.7.0 and is
LIVE, so all three describe the running install. No behaviour, no schema and no
default moves here: config.yaml, translations/ and every module under app/ are
byte-identical to 6e7b233.

⚠️ AC 3 - "the visible count drops to ~15" - IS WITHDRAWN AS UNSATISFIABLE, and
the arithmetic is the reason rather than the effort. options: and schema: both
declare all 71 keys, so the set HA's toggle can hide is EMPTY - it hides a key
only when it is ?-optional AND absent from options:, and nothing is absent. But
the deeper fact is that no manifest could have met it: Supervisor's own
UiOptions marks 59 of the 71 REQUIRED, and the frontend's own filter -
`schema.filter(e => e.name in opts || e.required)`, transcribed out of the
bundle in the release image - shows a required field unconditionally. So 59 is
the floor with options: EMPTIED ALTOGETHER. The target was ~4x below anything
reachable.

⚠️ AND THE 12 HIDES THAT ARE EXPRESSIBLE WOULD BE BOUGHT WITH THE UPGRADE PATH.
A key absent from options: has no default for the Supervisor to deep-merge into
an existing box's stored blob. That is exactly the delivery_hold_s mechanism
this release rests on: a REQUIRED key a 0.7.0 blob has never stored resolves to
its options: default of 185 instead of becoming exit 78. Hiding a key is
precisely the edit that removes that. It would also promote the 27 unpinned
in-code literal fallbacks the new check ledgers into the ACTUAL source of the
defaults on a live install - the CFG-03 defect the same card's AC 4 exists to
police, made load-bearing by its AC 3.

⚠️ THE COUNT WAS PRINTED AND NEVER GRADED, WHICH IS HOW THIS PASSED REVIEW.
supervisor_shape_probe.py section F has printed "the Configuration tab renders
71 fields" since the day it was written and exited 0 through UX-01's whole
review while displaying the number that contradicted the criterion it was
evidence for. Three assertions replace the bare print, every number computed
from the manifest and from Supervisor's own `required` flag rather than typed,
including an ISOLATED COUNTERFACTUAL that empties options: and nothing else.
MEASURED RED: delete one key (price_feed_url) from options: and two of them
fail - `Hideable: ['price_feed_url']`, `70 of 71 fields render` - exit 1.

--- AC 4: the AST check, built --------------------------------------------

AC 4's first clause is VACUOUS over an empty set (see above) and its second -
"the table changed goes red" - already works six times over. The third, the one
with the content, was never written: the only literal scan in the repository is
test_validate.py's regex over eight maintenance_* keys, which is a grep and
predates the card.

test_defaults_pin.py walks ast.Call nodes over app/*.py. It:

  - DISCOVERS the six *_OPTION_DEFAULTS mirrors from the tree rather than
    naming them. Every existing pin names its own table by hand, so a SEVENTH
    table added tomorrow is pinned by nobody and nothing says so;
  - grades all 30 mirrored keys against config.yaml, and requires that no
    module carries a literal fallback for any of them;
  - FREEZES the CFG-03 backlog at exactly today's 27 (module, key) pairs, by
    pair and not by count - a count goes green again the moment one site is
    deleted and another added. Line numbers are deliberately out of the key: a
    ledger that reddens on an unrelated reformat is one people re-bless without
    reading. It can only shrink;
  - grades every remaining literal AGAINST config.yaml, which is the half that
    catches CFG-04's actual failure - two copies of one number, one moves.

⭐ ONE PREVIOUSLY UNREPORTED DIVERGENCE, and it is what that last check is for:
main.py:290 answers a missing target_grid_w with 0 where config.yaml ships -10.
It cannot reach an operator - the deep-merge guarantees the key is present - and
the only paths that hand over a partial dict are sim/inject.py and this repo's
fixtures. Recorded as the single permitted divergence with its reason rather
than corrected; changing it alters the control loop's target and belongs to a
card that grades the loop. A second divergence goes red.

⭐ THE RED DIRECTION WAS RUN, TWICE OVER. Part 5 feeds the scanner a synthetic
module containing a deliberate violation of each rule and REQUIRES it to be
caught, in-process, through the same function the graded parts call - so a
scanner that goes blind stops working there too - and the site total is graded
against a floor. And a literal was planted in the REAL tree:

    main.py:290  float(opts.get("maintenance_charge_w", 2000))
    -> FAIL no literal fallback survives for any of the 30 keys a mirror
       already answers for                                          exit 1

    main.py:290  opts.get("p1_host", "x"), opts.get("slew_w", 999)
    -> FAIL no NEW module/key pair has started carrying a literal fallback
            [('main.py', 'p1_host')]
       FAIL every remaining literal still AGREES with config.yaml
            main.py  slew_w: code 999 vs config.yaml 0               exit 1

Both plants removed.

--- AC 5: CFG-04 is closed, and not by this card's predecessor -------------

fef6ef2 touched config.yaml, translations/, test_translations.py and the probe -
no defaults code at all - and on 0.7.0 the max_w 2000 -> 2500 triple mutation
survives 21 files, 0 RED. CONFIRMED CLOSED on release/1.0 by re-running the same
triple mutation on a throwaway copy of 6e7b233: 26 files, RED at
test_selfconfig.py, exit 1 -

  1 FAILED: ⚠️ CLEARING max_w ON THE LIVE SHAPE IS REFUSED - rule 10 catches
  the rail collapsing to the 2000 default under an explicit 400 W slew

git log -L 310,325:test_selfconfig.py attributes that check to bcee235,
"UX-02: pin both halves of the max_w fix, because neither was graded". The AC is
met BY UX-02 and ships with the pending 0.9.0 deploy. Nothing was re-done.

--- the 8 per-entry translations: they stay --------------------------------

Measured negative and not a defect in them: UiOptions gives inverters type
schema WITH multiple, the frontend sends that case to ha-selector-object, which
labels fields `fields[name].label || name`, and the converter sets neither label
nor translation_key. The Supervisor tab CANNOT be made to render them. They stay
because UX-02's ingress panel is the surface that can, because they cost
nothing, and because deleting them removes the only thing that would catch a
per-entry key added without a translation - which is how SAFETY-32's REQUIRED
bms_soc_floor would have joined the roster unlabelled. A written decision, not a
change; translations/ is untouched.

tools/supervisor_selfapi_probe.py stays red on its hardcoded len(_sub) == 7.
That is TOOL-04, already filed, and it is not re-filed or fixed here.

--- evidence ---------------------------------------------------------------

  positive control  test_defaults_pin.py's mirror floor moved 6 -> 7 inside the
                    harness container: "### RED: test_defaults_pin.py", exit 1
  suite             27 files, ALL GREEN, exit 0 (goodwe-addon-checks:1, the
                    checkout mounted read-only)
  shape probe       all cases passed, exit 0, on
                    ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0

Nothing here touched hardware, the live Home Assistant at 192.168.2.158, or
sim/. Both probe runs were throwaway containers over a read-only mount.

DECISIONS/UX-04-the-configuration-tab-and-the-defaults-pin.md carries all four
decisions, the frontend transcription, and the two counterfactual transcripts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-07 18:29:48 +02:00
5 changed files with 903 additions and 1 deletions
+42
View File
@@ -95,6 +95,7 @@ what happens when nobody does.
| | | | | |
|---|---| |---|---|
| _this branch_ | UX-02 - a configuration page in the ingress panel, where the validator's refusals can be seen | | _this branch_ | UX-02 - a configuration page in the ingress panel, where the validator's refusals can be seen |
| _this branch_ | UX-04 - UX-01's three unmet acceptance criteria: one withdrawn, one built, one re-attributed |
| _this branch_ | TEST-21 - `delivery_hold_s`'s floor re-derived from a harness that is now committed, and the recorded 30.0 s did not reproduce | | _this branch_ | TEST-21 - `delivery_hold_s`'s floor re-derived from a harness that is now committed, and the recorded 30.0 s did not reproduce |
⚠️ **TEST-21 CHANGES NO CODE AND NO OPTION. IT CHANGES A NUMBER THIS RECORD ⚠️ **TEST-21 CHANGES NO CODE AND NO OPTION. IT CHANGES A NUMBER THIS RECORD
@@ -172,6 +173,47 @@ already declared, and the panel was already live.
the Configuration tab **can** be removed - at the cost of all 71 options, which the Configuration tab **can** be removed - at the cost of all 71 options, which
is why it stays. `DECISIONS/UX-02-two-config-surfaces.md` carries both. is why it stays. `DECISIONS/UX-02-two-config-surfaces.md` carries both.
⚠️ **UX-04 CHANGES NO BEHAVIOUR, NO SCHEMA AND NO DEFAULT.** It adds one check
file, three assertions to a probe, and a decisions record; `config.yaml`,
`translations/` and every module under `app/` are byte-identical to `6e7b233`.
It closes out [[UX-01]], which shipped in **0.7.0 and is live**, with three
criteria that were never met:
* **AC 3 - "the visible count drops to ~15" - is WITHDRAWN as unsatisfiable.**
`options:` and `schema:` declare the same 71 keys, so the set HA's *show
unused optional configuration options* toggle can hide is **empty**, and
Supervisor's own `UiOptions` marks **59 of the 71 required** - the frontend
shows those unconditionally, so 59 is the floor even with `options:` emptied
altogether. The target was ~4x below what any manifest can reach. ⚠️ And the
12 hides that *are* expressible would be bought with the deep-merge: a key
absent from `options:` has no default for the Supervisor to merge into a
0.7.0 box's stored blob, which is exactly the `delivery_hold_s` mechanism
that makes this release's upgrade safe rather than exit 78. The count was
**printed** by `tools/supervisor_shape_probe.py` §F from the day it was
written and never graded, which is how this passed review; it is now three
assertions, and removing one key from `options:` turns two of them red.
* **AC 4's third clause - the AST check for stray in-code literal fallbacks -
is BUILT**, as `test_defaults_pin.py`. It discovers the six
`*_OPTION_DEFAULTS` mirrors from the tree rather than naming them, grades all
30 mirrored keys against `config.yaml`, and freezes the CFG-03 backlog at
exactly today's **27 (module, key) pairs** so it can only shrink. It found
one previously unreported divergence: `main.py` answers a missing
`target_grid_w` with **0** where `config.yaml` ships **-10** - unreachable on
a Supervisor install, recorded rather than corrected. Its red direction runs
on every invocation, and a literal planted in the real tree was shown red.
* **AC 5 / the CFG-04 double-literal `max_w` hole is closed, by
[[UX-02]]'s `bcee235` and not by UX-01's `fef6ef2`.** Re-measured on this
branch: the `2000 -> 2500` triple mutation is **RED at `test_selfconfig.py`**,
where it survived 21/21 green on 0.7.0. The attribution is corrected in
`DECISIONS/UX-04-the-configuration-tab-and-the-defaults-pin.md`; nothing was
re-done.
The 8 per-entry `inverters:` translations **stay**. They are shipped, validated
and measured *not* to reach the Supervisor tab - `ha-selector-object` labels
those fields `fields[name].label || name` and the converter sets no `label` -
and UX-02's ingress panel is the surface that can render them. A written
decision, not a change.
## 0.8.0 ## 0.8.0
⚠️ **A CONFIG THAT STARTED ON 0.7.0 CAN BE REFUSED ON 0.8.0, AND THAT IS WHY ⚠️ **A CONFIG THAT STARTED ON 0.7.0 CAN BE REFUSED ON 0.8.0, AND THAT IS WHY
@@ -0,0 +1,379 @@
# UX-04 — what the Configuration tab renders, and the pin that was never built
**Status: DECIDED 2026-09-07.** Three decisions and one confirmation, in the
order [[UX-01]]'s unmet acceptance criteria are numbered:
1. **UX-01 AC 3 (hide the optional fields behind HA's toggle) is WITHDRAWN as
unsatisfiable**, not deferred. The target was arithmetically unreachable, and
the part of it that *was* reachable would be bought with the upgrade path.
The measurement is now **graded** in `tools/supervisor_shape_probe.py` §F
rather than printed, so the withdrawal cannot be quietly reversed.
2. **UX-01 AC 4's third clause is now BUILT**: `test_defaults_pin.py`, an AST
check, with its red direction executed on every run.
3. **UX-01 AC 5 / the CFG-04 double-literal hole is CONFIRMED CLOSED on
`release/1.0`, by [[UX-02]]'s `bcee235`, not by UX-01's `fef6ef2`.** The
attribution is corrected here; no work was re-done.
4. **The 8 per-entry `inverters:` translations STAY.** They do not reach the
Supervisor tab and cannot be made to; [[UX-02]]'s ingress panel is where they
become visible.
Filed with UX-04. Card `6a9ec94617e9c48ed74ca5dd`. Every number below was
measured on `release/1.0` at `6e7b233` on 2026-09-07, against
`ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0` — the version
`ha info` reports on the live install — and against the 26-file check suite in
`goodwe-addon-checks:1`. Nothing here contacted the live Supervisor at
192.168.2.158, posted options, or restarted anything.
---
## 1. Hiding: withdrawn, and why that is the *safe* answer
### 1.1 What AC 3 claimed
> "The visible count drops to ~15, with the rest behind HA's *show unused
> optional configuration options* toggle."
### 1.2 What the tab actually renders
`config.yaml` declares **71 keys under `options:` and the same 71 under
`schema:`**. The set that HA's toggle is *able* to hide — schema-only keys — is
therefore **empty**:
```
hideable = set(schema) - set(options) -> [] (0 of 71)
```
On the deployed 0.7.0 tree the same measurement is 70/70. Nothing was hidden
before UX-01, nothing is hidden after it, and no line of `fef6ef2` moved a key
either way. AC 3 is not partially met; it is unstarted.
### 1.3 The rule that decides it, replayed rather than paraphrased
From the frontend bundle shipped **in this image**,
`api/panel/frontend_latest/5680.*.js` (frontend 20250925.1):
```js
_filteredSchema = (opts, schema) => schema.filter(e => e.name in opts || e.required)
```
`opts` is the add-on's options **as Supervisor serves them** — the operator's
stored blob after `options:` has been deep-merged into it. So a field is hidden
only when it is **both** absent from `options:` **and** not `required`. The
probe now runs that filter on this manifest rather than quoting it.
### 1.4 Reason one: the target was never reachable
Supervisor's own `UiOptions` marks `required` on **59** of the 71 fields; 12 are
optional. The filter above shows a required field unconditionally. So the
**floor** — what the tab renders with `options:` emptied *altogether*, which is
the most hiding that is even expressible — is 59 fields.
| `options:` block | fields rendered |
|---|---|
| as shipped (71 keys) | **71** |
| emptied entirely (0 keys) | **59** |
| AC 3's target | ~15 |
AC 3 asked for a number roughly four times below the floor. No arrangement of
`options:` reaches it. The criterion could not have been met by any
implementation; it was mis-specified, and the honest disposal of a
mis-specified criterion is withdrawal, not a partial gesture toward it.
### 1.5 Reason two: the 12 hides that *are* expressible cost the deep-merge
Moving a key out of `options:` is not free. `App.options` is
`deepmerge(config.yaml defaults, the stored user options)`, so **a key absent
from `options:` has no default for the Supervisor to merge**.
That is exactly the mechanism the 0.9.0 upgrade rests on. From `CHANGELOG.md`,
the pre-deploy check run against this release on 2026-09-07:
> The reference install's stored blob has **70 keys, not 71**.
> `delivery_hold_s` arrived with SAFETY-42 in 0.8.0, so an install running
> 0.7.0 has never stored it — and it is REQUIRED in the schema, no trailing
> `?`, which is the shape that becomes **exit 78**. It resolves to its
> `options:` default of **185** because the Supervisor deep-merges.
Hiding a key is done by taking it *out* of `options:` and marking it `?`. That
is, precisely, the edit that removes a key's merge default — the thing standing
between a 0.7.0 box and exit 78 on upgrade. It would be traded for at most 12
fewer rows on a form, on a site where the add-on is holding a real house's grid
exchange at zero.
### 1.6 Reason three: it would promote 27 unpinned literals to load-bearing
And the cost compounds with §2 of this document. `test_defaults_pin.py` part 4
ledgers **27 (module, key) pairs** in `app/` that still answer a missing option
with an in-code literal. Today those literals are unreachable on a Supervisor
install, because the deep-merge guarantees every `options:` key is present in
the served blob. Remove keys from `options:` to hide them and those literals
stop being dead code: they become **the actual source of the defaults on a live
install** — the CFG-03 defect the same card's AC 4 exists to police, made
load-bearing by the card's own AC 3.
### 1.7 What now grades this
`tools/supervisor_shape_probe.py` §F. The count was printed there from the day
it was written — *"the Configuration tab renders 71 fields"* — and the probe
exited 0 through UX-01's whole review while displaying the number that
contradicted the criterion it was evidence for. **A measurement that is
displayed but not asserted is how this passed review.** Three assertions
replace the bare print:
```
PASS every one of the 71 `schema:` keys is ALSO in `options:`, so schema-only
- the only set HA's toggle can hide - is EMPTY. Hideable: []
PASS ...so replaying the frontend's own _filteredSchema on the served options
hides NOTHING: 71 of 71 fields render
PASS ...and with `options:` EMPTIED ENTIRELY - the maximum hiding that is
expressible - the tab still renders 59 required fields. THAT is the
floor, and AC 3 asked for ~15.
```
Every number in them is computed from the manifest and from Supervisor's own
`required` flag; none is typed. **The red direction was run**: deleting one key
(`price_feed_url`) from `options:` and nothing else turns the first two to FAIL
— `Hideable: ['price_feed_url']`, `70 of 71 fields render` — and the probe
exits 1. The counterfactual is isolated: one key varied, nothing else.
So if a future editor empties `options:` to shorten the form, this goes red and
somebody has to read §1.5 before merging.
---
## 2. The defaults pin: `test_defaults_pin.py`
### 2.1 What AC 4 asked for and what shipped
AC 4 has three clauses.
| clause | state before UX-04 |
|---|---|
| "every `schema:` key absent from `options:` has exactly one defaults-table entry" | **vacuous** — that set is empty (§1.2), so the clause is true of any tree, including one with no defaults at all |
| "the table changed → red" | **works**, six times over, once per mirror in its own suite |
| "no module carries its own literal fallback for a schema default — an AST check, not a grep" | **never built** |
The only literal scan in the repository before this card is
`test_validate.py:1025`: a `re.findall` restricted to eight `maintenance_*`
keys across three named files. It is a grep, it predates UX-01, and it was not
written for this.
### 2.2 What an "in-code literal fallback" is, structurally
A call in `app/` taking a string literal that names a `schema:` key, with a
constant in the next positional slot:
```python
opts.get("max_w", 2000) _number(opts, "max_w", 2000.0)
```
Found by walking `ast.Call` nodes — so a multi-line call and a renamed `opts`
are found, and the same text inside a comment or docstring is not. Two
exclusions, both structural and both argued at the site:
* **`getattr(obj, "max_w", 0.0)`** reads an attribute off an object, not an
option off a blob. Excluded by callee name.
* **the empty string.** `opts.get("static_profile", "")` is Python's "not
stated", and all six such sites in `validate.py` normalise it immediately —
`str(opts.get(k, "") or "").strip()`. It cannot be a stale copy of a *number*,
which is the whole of the CFG-04 defect class. Excluded by value. **`0` is
not excluded**: `slew_w: 0` and `integrator_max_w: 0` are real shipped
defaults with real meanings.
### 2.3 What it grades, measured on `release/1.0` @ `6e7b233`
```
2. the mirrors, DISCOVERED from the tree rather than listed here
6 table(s): circulation.py:CIRC_OPTION_DEFAULTS, delivery.py:DELIVERY_OPTION_DEFAULTS,
maintenance.py:MAINT_OPTION_DEFAULTS, peakguard.py:PEAK_OPTION_DEFAULTS,
price.py:PRICE_OPTION_DEFAULTS, watchdog.py:SIGN_OPTION_DEFAULTS
30 mirrored key(s) of 71 in `schema:`
3. the AST scan
scanned 29 modules, 34 literal fallback site(s): 0 over mirrored keys,
34 over unmirrored ones
4. the CFG-03 ledger
27 (module, key) pair(s) carry a literal for a key no mirror answers for
```
Four properties, none of them vacuous:
1. **The mirrors are discovered, not listed.** Six suites already pin their own
table to `config.yaml` — and every one of those pins names its table by
hand, so a *seventh* table added tomorrow would be pinned by nobody and
nothing would say so. This walks `app/` for module-level `*_OPTION_DEFAULTS`
assignments and grades whatever it finds against `config.yaml`. That is the
property the six hand-written pins cannot have.
2. **No literal survives for a key a mirror already answers for.** 30 keys,
0 violations.
3. **The CFG-03 ledger is FROZEN, by (module, key) pair, not by count.** A
count goes green again the moment somebody deletes one site and adds
another. Line numbers are deliberately *not* in the key: a ledger that goes
red on an unrelated reformat is a ledger people learn to re-bless without
reading. The 27 pairs are the open CFG-03 backlog; they are **not** removed
here, because that is behaviour change across `main.py`, `p1.py`,
`inverter.py` and `validate.py` that no single card should make in passing.
The set can now only shrink.
4. **Every remaining literal is graded against `config.yaml`** — the half that
catches CFG-04's actual failure mode, two copies of one number and one moves.
### 2.4 One divergence found, and it is new
```
main.py target_grid_w: code 0 vs config.yaml -10
```
`main.py:290` reads `target_grid_w=float(opts.get("target_grid_w", 0))` while
`config.yaml` ships **-10**. A dict handed to `Controller` without the key holds
net exchange at 0 W instead of the shipped -10 W bias into import. **It cannot
reach an operator**: on a Supervisor install the deep-merge guarantees the key
is present, and the only paths that hand over a partial dict are
`sim/inject.py` and this repository's own fixtures. It is recorded in the check
as the single permitted divergence, with the reason, rather than corrected —
changing it alters the control loop's target and belongs to a card that grades
the loop. A *second* divergence goes red.
### 2.5 ⭐ The red direction, shown rather than claimed
A check that has never been shown failing is not a check — which is exactly
what clause one of this AC was. Two things follow from that:
**(a) The red direction runs on every invocation.** Part 5 feeds the scanner a
synthetic module containing a deliberate violation of each rule and *requires*
it to be flagged, in-process, through the same `literal_fallbacks()` the graded
parts call. A scanner that goes blind — a renamed helper, an `ast` change, a
`SCHEMA` that comes back empty — stops working here too, and the total site
count is additionally graded against a floor.
**(b) A literal was planted in the real tree and the check went red.** Planted
at `main.py:290`, `float(opts.get("maintenance_charge_w", 2000))` — a mirrored
key:
```
3. the AST scan: no module carries its own literal fallback for a MIRRORED schema default
scanned 29 modules, 35 literal fallback site(s): 1 over mirrored keys, 34 over unmirrored ones
ok the scanner is not blind: it found 35 sites (floor 20)
main.py:290 get('maintenance_charge_w', 2000)
FAIL no literal fallback survives for any of the 30 keys a mirror already
answers for - those sites must index the mirror
1 FAILED (exit 1)
```
And part 4's two directions, planted the same way with
`opts.get("p1_host", "x")` and `opts.get("slew_w", 999)`:
```
4. the CFG-03 ledger
28 (module, key) pair(s) carry a literal for a key no mirror answers for
FAIL no NEW module/key pair has started carrying a literal fallback [('main.py', 'p1_host')]
main.py p1_host: code 'x' vs config.yaml ''
main.py slew_w: code 999 vs config.yaml 0
main.py target_grid_w: code 0 vs config.yaml -10
FAIL every remaining literal still AGREES with config.yaml, except the 1
divergence(s) recorded above it
(exit 1)
```
The second of those is the CFG-04 defect class itself: a literal that has
drifted away from `config.yaml` while the file still says something else. Both
plants were removed; the tree is byte-identical to `6e7b233` apart from this
card's own files.
---
## 3. CFG-04: closed on `release/1.0`, by UX-02, not by UX-01
UX-01's AC 5 claimed the double-literal `max_w` hole. It did not close it.
`fef6ef2` touched `config.yaml`, `translations/`, `test_translations.py` and the
probe — **no defaults code at all** — and on the 0.7.0 tree the triple mutation
survives: 21 files, 0 RED.
**Confirmed on current `release/1.0` @ `6e7b233`, 2026-09-07.** The same triple
mutation, `2000 -> 2500` at the three descendants of 0.7.0's
`inverter.py:438` / `validate.py:383` / `validate.py:684`:
| 0.7.0 | `release/1.0` @ `6e7b233` |
|---|---|
| `inverter.py:438` `max_w=float(opts.get("max_w", 2000))` | `inverter.py:640` `raw = opts.get("max_w", 2000)` |
| `validate.py:383` `cap = _number(opts, "max_w", 2000.0)` | `validate.py:466`, same line |
| `validate.py:684` `max_w = _number(opts, "max_w", 2000.0)` | `validate.py:836`, same line |
Result — 26 files, **RED at `test_selfconfig.py`**, exit 1:
```
1 FAILED: ⚠️ CLEARING max_w ON THE LIVE SHAPE IS REFUSED - rule 10 catches the
rail collapsing to the 2000 default under an explicit 400 W slew, so the box
cannot be emptied into a quietly halved rail
```
`git log -L 310,325:goodwe_controller/test_selfconfig.py` attributes that check
to **`bcee235`, "UX-02: pin both halves of the max_w fix, because neither was
graded", glenn schrooyen, 2026-09-06** — not to UX-01. The hole ships closed
with the pending 0.9.0 deploy. **The AC is met by UX-02.** No work was re-done
here; the mutation was run once, on a throwaway copy, to confirm it.
---
## 4. The 8 per-entry translations: they stay
### 4.1 The measurement, and it is negative
`translations/{en,nl}.yaml` carry a name and a description for all 8 per-entry
keys of the repeatable `inverters:` section. They are loaded by Supervisor's own
loader, they pass `SCHEMA_APP_TRANSLATIONS`, and `test_translations.py` grades
their coverage. **They do not reach the screen**, and this is not a defect in
them. `UiOptions` gives `inverters` `type: "schema"` with `multiple: True`, and
the frontend's `_convertSchemaElement` sends exactly that case to a selector
rather than to a nested `ha-form`:
```js
if ("schema" === t.type && !t.multiple) return {...type:"expandable"...} // not us
if ("schema" === t.type) { const o = {}; for (const a of t.schema)
o[a.name] = {required: a.required, selector: ...}; return {object:{multiple:t.multiple, fields:o}} }
```
and `ha-selector-object` labels those fields `fields[name].label || name`, while
`_convertSchemaElementToSelector` sets **neither `label` nor `translation_key`**.
So an operator sees raw `name`, `setpoint_entity`, `max_w`, `bms_soc_floor`. The
probe already asserts this, negatively and by name.
### 4.2 Decision: keep them. Four reasons.
1. **The Supervisor tab cannot be made to render them.** The gap is in
somebody else's converter, not in this repository. There is no change here
that fixes it; deleting the translations does not fix it either.
2. **[[UX-02]]'s ingress panel is where they become visible**, and it renders
the roster itself rather than handing it to `ha-selector-object`. The
`fields:` block is the shape Supervisor's own schema defines for exactly
this, and it is what a surface that wires `label` through consumes.
3. **They cost nothing.** No runtime path reads them; they are two YAML blocks
in files the Supervisor already parses, and the parse is graded green.
4. **Deleting them would remove the only thing that catches a per-entry key
added without a translation.** `test_translations.py` grades top-level and
per-entry coverage symmetrically; drop the per-entry half and SAFETY-32's
`bms_soc_floor` — a *required* per-entry key added after UX-01 — would have
joined the roster with no label and nothing to say so.
This is a written decision, not a change. The tree is unmodified in
`translations/`.
### 4.3 Not re-filed
`tools/supervisor_selfapi_probe.py` has been known red since OPT-09 — it
hardcodes `len(_sub) == 7` against 8 per-entry keys. That is **TOOL-04**,
already filed, and it is not re-filed or fixed here. It is named only so a
reader of §4.1 knows why the *other* probe is the one quoted.
---
## 5. What this card did not do
* It did not remove a key from `options:`. See §1.5 — the deep-merge default is
what makes the pending 0.9.0 upgrade safe on a 0.7.0 box.
* It did not pay down the 27-pair CFG-03 ledger. See §2.3.
* It did not correct `main.py`'s `target_grid_w` fallback. See §2.4.
* It did not touch `translations/`. See §4.2.
* It did not re-do CFG-04. See §3.
* It did not touch hardware, the live Home Assistant at 192.168.2.158, or
`sim/`. Both probe runs were throwaway containers over a read-only mount.
+2 -1
View File
@@ -110,7 +110,8 @@ so Home Assistant's *Configuration* tab renders human labels and a description
for every option instead of the raw option keys. What that does and does not buy for every option instead of the raw option keys. What that does and does not buy
you is worth stating plainly, because it was **measured against the Supervisor you is worth stating plainly, because it was **measured against the Supervisor
image this site runs** (`ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0`) image this site runs** (`ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0`)
rather than assumed — `tools/supervisor_shape_probe.py` section F prints the rather than assumed — `tools/supervisor_shape_probe.py` section F GRADES (it
used only to print) the
whole rendered list, in both languages. whole rendered list, in both languages.
| | | | | |
+372
View File
@@ -0,0 +1,372 @@
"""UX-04 - the defaults pin UX-01 AC 4 promised and never built.
`python3 -B test_defaults_pin.py`
WHAT UX-01 AC 4 ASKED FOR, IN ITS OWN WORDS: "every `schema:` key absent from
`options:` has exactly one defaults-table entry ... the table changed goes red
... and NO MODULE CARRIES ITS OWN LITERAL FALLBACK FOR A SCHEMA DEFAULT - an AST
check, not a grep."
WHAT SHIPPED. Clause one is VACUOUS: `options:` and `schema:` declare the same
71 keys, so "every schema key absent from options" quantifies over the empty set
and is true of any tree, including one with no defaults at all. Clause two -
"the table changed goes red" - does work, six times over, once per mirror in its
own suite. Clause three, THE ONE WITH THE CONTENT, was never written. The only
literal scan in this repository before this file is
`test_validate.py`'s `re.findall(r'"(maintenance_[a-z_]+)"...')`, which is a
grep, is restricted to eight `maintenance_*` keys and three named files, and
predates UX-01.
⚠️ AND A CHECK THAT CANNOT GO RED IS NOT A CHECK, so this file does not repeat
clause one's mistake. Every part below states the size of the set it quantifies
over and grades that size against a floor derived from the tree, and part 5
plants a literal in a synthetic module and REQUIRES the scanner to catch it, so
the red direction is exercised on every run rather than attested in a commit
message. MUTATION.md habit 4.
--------------------------------------------------------------------------- #
WHAT AN "IN-CODE LITERAL FALLBACK" IS, STRUCTURALLY, so a reader can argue with
the definition rather than guess it. It is a call in `app/` that takes a STRING
LITERAL naming a `schema:` key and, in the next positional slot, a CONSTANT:
opts.get("max_w", 2000) _number(opts, "max_w", 2000.0)
Two shapes, one rule, found by walking `ast.Call` nodes - not by matching text,
so `opts.get(\n "max_w",\n 2000,\n)` and a renamed `opts` are found too and
a `max_w` inside a comment or a docstring is not.
TWO EXCLUSIONS, both structural and both narrow:
* `getattr(obj, "max_w", 0.0)` reads an ATTRIBUTE off an object, not an option
off the operator's blob. Its third argument is an absent-attribute guard and
has no relationship to config.yaml. Excluded by function name.
* THE EMPTY STRING. `opts.get("static_profile", "")` is Python's "this key is
not stated", and every one of the six sites in `validate.py` immediately
normalises it - `str(opts.get(k, "") or "").strip()`. It cannot be a stale
copy of a NUMBER, which is the whole of the CFG-04 defect class, and
rewriting those six to index a mirror would replace a sentence that reads
"absent" with one that reads "the shipped default" at sites where absent is
what is meant. Excluded by value. `0` is NOT excluded: `slew_w: 0` and
`integrator_max_w: 0` are real shipped defaults with real meanings.
--------------------------------------------------------------------------- #
WHAT THIS FILE DOES *NOT* DO, said before the parts rather than after them.
It does not remove the 27 (module, key) pairs in part 4's ledger. That is
CFG-03's open ticket - option defaults living in four places - and it is a body
of behaviour change across `main.py`, `p1.py`, `inverter.py` and `validate.py`
that no single card should make in passing. What this file does instead is
FREEZE the ledger: the set is graded EXACTLY, so the twenty-eighth pair is red
on the commit that introduces it and the ledger can only ever shrink. A backlog
that cannot grow is a backlog being paid down; one that is merely written in a
comment is the state UX-01 left.
MUTATION.md is the method for the red-direction evidence in part 5, and
`tools/mutation_run.sh` is the harness.
"""
import ast
import io
import re
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
APP = HERE / "app"
FAILED = 0
def check(what, ok):
global FAILED
print((" ok " if ok else " FAIL ") + what)
if not ok:
FAILED += 1
# --------------------------------------------------------------------------- #
# config.yaml, parsed by hand.
#
# ⚠️ NO PyYAML, AND NOT AS A PREFERENCE. The add-on image installs py3-aiohttp
# and py3-paho-mqtt only; `import yaml` raises in the checks container and a
# check that skips on ImportError is not a check. `test_maintenance.py` and
# `test_price.py` each carry their own two-line reader for the same reason, and
# this is the third - which is itself an instance of the defect this file
# grades, one level up. It is written down rather than tidied away because
# tidying it into a shared helper is a change to two suites that are green.
CFG_TEXT = io.open(HERE / "config.yaml", encoding="utf-8").read()
_SCHEMA_AT = CFG_TEXT.index("\nschema:")
_OPTS_BLOCK, _SCHEMA_BLOCK = CFG_TEXT[:_SCHEMA_AT], CFG_TEXT[_SCHEMA_AT:]
def _scalar(raw):
"""One `options:` value, as the yaml module this image lacks would read it."""
raw = raw.split("#", 1)[0].strip().strip('"').strip("'")
if raw in ("true", "false"):
return raw == "true"
if raw == "[]":
return []
for cast in (int, float):
try:
return cast(raw)
except ValueError:
pass
return raw
def _block_keys(block):
return [m.group(1) for m in re.finditer(r"^ ([a-z0-9_]+):", block, re.M)]
SHIPPED = {m.group(1): _scalar(m.group(2))
for m in re.finditer(r"^ ([a-z0-9_]+): *(.*)$", _OPTS_BLOCK, re.M)}
SCHEMA = set(_block_keys(_SCHEMA_BLOCK))
print("UX-04 - the defaults pin\n")
print("1. the surface this file quantifies over")
print(f" config.yaml: {len(SHIPPED)} keys under `options:`, {len(SCHEMA)} "
f"under `schema:`")
# The floor, for part 1's own reason: a reader that returned {} would make every
# check below vacuously true. 60 is the count of REQUIRED schema keys and is the
# number `tools/supervisor_shape_probe.py` section F now grades; any floor well
# inside the real size does the job, and this one cannot be reached by accident.
check(f"the hand reader found a real options block ({len(SHIPPED)} keys, floor 60)",
len(SHIPPED) >= 60)
check(f"...and a real schema block ({len(SCHEMA)} keys, floor 60)",
len(SCHEMA) >= 60)
check("every shipped default names a schema key, so a mirror pinned to one is "
"pinned to the other",
sorted(set(SHIPPED) - SCHEMA) == [])
# --------------------------------------------------------------------------- #
print()
print("2. the mirrors, DISCOVERED from the tree rather than listed here")
# ⚠️ WHY DISCOVERED. Six suites already pin their own mirror to config.yaml -
# test_maintenance, test_price, test_watchdog (twice), test_peakguard,
# test_delivery. Every one of those pins names its table by hand, so a SEVENTH
# table added tomorrow is pinned by nobody and nothing says so. This walks
# `app/` for module-level assignments whose name ends in OPTION_DEFAULTS and
# grades whatever it finds, so the seventh is covered on the commit that
# introduces it. That is the property the six hand-written pins cannot have.
MODULES = sorted(p.name for p in APP.glob("*.py"))
TREES = {name: ast.parse(io.open(APP / name, encoding="utf-8").read(), name)
for name in MODULES}
MIRROR = {} # schema key -> (module, table name, value)
TABLES = []
for name in MODULES:
for node in TREES[name].body:
if not (isinstance(node, ast.Assign) and len(node.targets) == 1
and isinstance(node.targets[0], ast.Name)
and node.targets[0].id.endswith("OPTION_DEFAULTS")):
continue
table = node.targets[0].id
TABLES.append((name, table))
for k, v in zip(node.value.keys, node.value.values):
# A literal key with a literal value is the only shape a mirror
# has; anything else is not a table this file can grade, and
# saying so out loud beats skipping it silently.
MIRROR[k.value] = (name, table, ast.literal_eval(v))
print(f" {len(TABLES)} table(s): " + ", ".join(f"{m}:{t}" for m, t in TABLES))
print(f" {len(MIRROR)} mirrored key(s) of {len(SCHEMA)} in `schema:`")
check(f"the walk found the mirrors that exist ({len(TABLES)} tables, floor 6)",
len(TABLES) >= 6)
_unschemad = sorted(k for k in MIRROR if k not in SCHEMA)
check(f"every mirrored key is a real schema key {_unschemad or ''}",
_unschemad == [])
_unshipped = sorted(k for k in MIRROR if k not in SHIPPED)
check(f"...and is shipped in the options block {_unshipped or ''}",
_unshipped == [])
_drift = sorted(k for k, (m, t, v) in MIRROR.items()
if k in SHIPPED and v != SHIPPED[k])
check(f"...and reproduces config.yaml exactly, so ANY mirror - including one no "
f"suite names by hand - goes red on drift {_drift or ''}",
_drift == [])
# --------------------------------------------------------------------------- #
print()
print("3. the AST scan: no module carries its own literal fallback for a "
"MIRRORED schema default")
def literal_fallbacks(tree, name):
"""Every `f(..., "<schema key>", <constant>, ...)` in one module.
Yields (module, lineno, callee, key, value). See the header for the two
exclusions and the argument for each.
"""
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
fn = node.func
callee = fn.attr if isinstance(fn, ast.Attribute) else getattr(fn, "id", "")
if callee == "getattr":
continue
for i, arg in enumerate(node.args[:-1]):
if not (isinstance(arg, ast.Constant) and isinstance(arg.value, str)
and arg.value in SCHEMA):
continue
default = node.args[i + 1]
if not isinstance(default, ast.Constant):
continue
if default.value is None or default.value == "":
continue
yield (name, node.lineno, callee, arg.value, default.value)
ALL_SITES = [s for name in MODULES for s in literal_fallbacks(TREES[name], name)]
MIRRORED_SITES = [s for s in ALL_SITES if s[3] in MIRROR]
UNMIRRORED_SITES = [s for s in ALL_SITES if s[3] not in MIRROR]
print(f" scanned {len(MODULES)} modules, {len(ALL_SITES)} literal fallback "
f"site(s): {len(MIRRORED_SITES)} over mirrored keys, "
f"{len(UNMIRRORED_SITES)} over unmirrored ones")
# ⚠️ THE SCANNER'S OWN FLOOR, and it is the line that stops this whole file
# rotting into clause one. A scanner that silently stopped matching - a renamed
# helper, an ast API change, a `SCHEMA` that came back empty - would report ZERO
# sites and every check below would pass. So the total is graded against a floor
# the tree already exceeds by a wide margin, and part 5 additionally feeds the
# scanner a source it MUST flag.
check(f"the scanner is not blind: it found {len(ALL_SITES)} sites (floor 20)",
len(ALL_SITES) >= 20)
for site in sorted(MIRRORED_SITES):
print(f" {site[0]}:{site[1]} {site[2]}({site[3]!r}, {site[4]!r})")
check(f"no literal fallback survives for any of the {len(MIRROR)} keys a mirror "
f"already answers for - those sites must index the mirror",
MIRRORED_SITES == [])
# --------------------------------------------------------------------------- #
print()
print("4. the CFG-03 ledger: the unmirrored keys that still carry a literal, "
"frozen at exactly today's set")
# ⚠️ WHY A FROZEN SET AND NOT A COUNT. A count goes green again the moment
# somebody deletes one site and adds another, which is the same-size drift this
# repository keeps finding. The pair (module, key) is stable under every edit
# that is not the one being guarded against: reformatting, moving the call
# within the file, renaming the local `opts`. It changes only when a module
# starts or stops carrying a literal for a key - which is exactly the event.
#
# LINE NUMBERS ARE DELIBERATELY NOT IN THE KEY. A ledger that goes red when an
# unrelated edit shifts a line is a ledger people learn to re-bless without
# reading, and a ledger re-blessed without reading grades nothing.
LEDGER = {
("inverter.py", "max_w"),
("main.py", "auto_start"),
("main.py", "cycle_floor_s"),
("main.py", "deadband_w"),
("main.py", "estop_fitted"),
("main.py", "gain"),
("main.py", "heartbeat_s"),
("main.py", "log_level"),
("main.py", "meter_freeze_s"),
("main.py", "meter_max_age_s"),
("main.py", "meter_phases"),
("main.py", "peak_cap_w"),
("main.py", "saturation_cycles"),
("main.py", "saturation_w"),
("main.py", "sign_disagree_s"),
("main.py", "sign_disagree_w"),
("main.py", "slew_w"),
("main.py", "stale_input_s"),
("main.py", "step_w"),
("main.py", "target_grid_w"),
("p1.py", "meter_poll_s"),
("p1.py", "meter_source"),
("validate.py", "max_w"),
("validate.py", "meter_max_age_s"),
("validate.py", "meter_phases"),
("validate.py", "sign_disagree_w"),
("validate.py", "slew_w"),
}
_found = {(s[0], s[3]) for s in UNMIRRORED_SITES}
_new = sorted(_found - LEDGER)
_gone = sorted(LEDGER - _found)
print(f" {len(_found)} (module, key) pair(s) carry a literal for a key no "
f"mirror answers for")
check(f"no NEW module/key pair has started carrying a literal fallback {_new or ''}",
_new == [])
check(f"...and none has been paid off without the ledger being shortened - "
f"shrink LEDGER when it has {_gone or ''}",
_gone == [])
# --- and every one of them is graded AGAINST config.yaml, which is the half
# that catches CFG-04's actual failure: two copies of one number, one moves.
DIVERGENT = {
# main.py:290 `target_grid_w=float(opts.get("target_grid_w", 0))`.
# config.yaml ships -10 W. MEASURED here for the first time, 2026-09-07,
# and it is a real divergence, not a sentinel: a dict handed to
# `Controller` WITHOUT the key holds net exchange at 0 W instead of the
# shipped -10 W bias into import. On a Supervisor install the key is always
# present - the deep-merge of `options:` guarantees it - so this cannot
# reach an operator; the paths that CAN hand over a partial dict are
# `sim/inject.py` and this repository's own fixtures. It is recorded rather
# than corrected because changing it is a behaviour change to the control
# loop's target, which belongs to a card that grades the loop.
("main.py", "target_grid_w"): (0, -10),
}
_diverge = {}
for mod, line, callee, key, value in UNMIRRORED_SITES:
want = SHIPPED.get(key)
same = (isinstance(value, bool) == isinstance(want, bool)) and value == want
if not same:
_diverge[(mod, key)] = (value, want)
for pair, (got, want) in sorted(_diverge.items()):
print(f" {pair[0]} {pair[1]}: code {got!r} vs config.yaml {want!r}")
check(f"every remaining literal still AGREES with config.yaml, except the "
f"{len(DIVERGENT)} divergence(s) recorded above it",
_diverge == DIVERGENT)
# --------------------------------------------------------------------------- #
print()
print("5. the red direction, RUN on every invocation")
# ⚠️ A CHECK THAT HAS NEVER BEEN SHOWN FAILING IS NOT A CHECK. Parts 3 and 4
# both pass on a clean tree, which is exactly what UX-01 AC 4's clause one did
# while grading nothing. So the scanner is fed a source that is a deliberate
# violation of each rule and is REQUIRED to flag it - in-process, against the
# same `literal_fallbacks()` the parts above call, so a scanner that stops
# working stops working here too.
_PLANTED = '''
def build(opts):
a = opts.get("maintenance_charge_w", 2000) # mirrored key -> part 3
b = _number(opts, "cycle_floor_s", 15) # unmirrored, main.py's key
c = opts.get("static_profile", "") # the "" sentinel: NOT a hit
d = getattr(unit, "max_w", 0.0) # attribute guard: NOT a hit
e = opts.get("peak_soc_floor", 20) # mirrored key -> part 3
return a, b, c, d, e
'''
_hits = list(literal_fallbacks(ast.parse(_PLANTED, "<planted>"), "<planted>"))
_keys = sorted(h[3] for h in _hits)
for h in _hits:
print(f" caught {h[2]}({h[3]!r}, {h[4]!r}) at line {h[1]}")
check("a planted literal for a MIRRORED key is caught - this is part 3's red "
"direction, exercised",
"maintenance_charge_w" in _keys and "peak_soc_floor" in _keys)
check("a planted literal for an unmirrored key is caught too - part 4's",
"cycle_floor_s" in _keys)
check('...and the two documented exclusions still hold: the "" sentinel and '
"getattr are not reported",
"static_profile" not in _keys and _keys.count("max_w") == 0)
check(f"exactly the three intended sites, no more {_keys}",
_keys == ["cycle_floor_s", "maintenance_charge_w", "peak_soc_floor"])
# ...and the mirror comparison's red direction, likewise executed rather than
# claimed: part 2 goes red when a table and config.yaml disagree by one.
_drifted = dict(MIRROR)
_pick = sorted(MIRROR)[0]
_mod, _tab, _val = _drifted[_pick]
_drifted[_pick] = (_mod, _tab, "###drifted###")
check(f"part 2's comparison rejects a mirror that has drifted (simulated on "
f"{_pick!r})",
any(v != SHIPPED.get(k) for k, (_, _, v) in _drifted.items()))
print()
if FAILED:
print(f"{FAILED} FAILED")
sys.exit(1)
print("all checks passed")
@@ -315,6 +315,114 @@ print(f" the Configuration tab renders {len(ui)} fields, in `schema:` order "
"(App.schema_ui is UiOptions(...)(data['schema']), so the ORDER IS THE " "(App.schema_ui is UiOptions(...)(data['schema']), so the ORDER IS THE "
"SCHEMA BLOCK'S and not the options block's)") "SCHEMA BLOCK'S and not the options block's)")
# --------------------------------------------------------------------------- #
# ⚠️ UX-04: THAT COUNT IS NOW GRADED, AND IT IS WHY UX-01's AC 3 IS WITHDRAWN.
#
# UX-01 AC 3 said the visible count would drop to ~15, with the rest behind
# HA's "show unused optional configuration options" toggle. The line above has
# printed the refutation since the day it was written - 71, not 15 - and PRINTED
# IS NOT GRADED: this probe exited 0 through the whole of UX-01's review while
# displaying the number that contradicted the criterion it was evidence for.
# That is the defect, not the count.
#
# THE FRONTEND RULE, transcribed from the bundle in THIS image at the top of
# this section and replayed rather than paraphrased:
#
# _filteredSchema = (opts, schema) => schema.filter(e => e.name in opts
# || e.required)
#
# `opts` is the add-on's OPTIONS AS SUPERVISOR SERVES THEM - the operator's
# stored blob after `options:` has been deep-merged into it - so a key is
# hideable only when it is BOTH absent from `options:` AND not required.
#
# WHY THE CRITERION IS WITHDRAWN RATHER THAN IMPLEMENTED, in two measurements:
#
# 1. IT IS ARITHMETICALLY UNREACHABLE. Supervisor's own UiOptions marks
# `required` on every non-`?` key, and the filter above shows a required
# key UNCONDITIONALLY. So the floor - what the tab renders with `options:`
# EMPTIED ALTOGETHER, which is the most hiding that is even expressible -
# is the required count below. It is nowhere near 15. No arrangement of
# `options:` can reach the target; the criterion was never satisfiable.
#
# 2. THE 12 KEYS THAT *COULD* BE HIDDEN COST THE DEEP-MERGE DEFAULT. A key
# absent from `options:` has nothing for the Supervisor to merge into an
# existing box's stored blob. That is exactly the `delivery_hold_s`
# mechanism the 0.9.0 upgrade rests on: a key that 0.7.0's stored blob
# cannot contain resolves to its `options:` default instead of failing
# validation, and moving it out of `options:` is precisely how you get the
# exit 78 that mechanism exists to prevent. Worse, it would promote the 27
# unpinned in-code literal fallbacks that `test_defaults_pin.py` part 4
# ledgers into the LOAD-BEARING source of those defaults on a live install
# - which is the CFG-03 defect the same card's AC 4 exists to police.
#
# So: four fewer clicks, bought with the upgrade path and with the defaults
# pin. The decision is written up in
# DECISIONS/UX-04-the-configuration-tab-and-the-defaults-pin.md
# and the three assertions below are what keeps it a decision rather than an
# omission - if anyone empties `options:` to shorten the form, this goes red.
_hideable = sorted(set(schema) - set(defaults))
_required = [n["name"] for n in ui if n.get("required")]
def _filtered(opts):
"""The frontend's own `_filteredSchema`, replayed on this manifest."""
return [n for n in ui if n["name"] in opts or n.get("required")]
show(f"every one of the {len(schema)} `schema:` keys is ALSO in `options:`, so "
f"schema-only - the only set HA's toggle can hide - is EMPTY. UX-01 AC 3 "
f"is not partially met, it is unstarted. Hideable: {_hideable}",
not _hideable and len(schema) == len(defaults))
show(f"...so replaying the frontend's own _filteredSchema on the served options "
f"hides NOTHING: {len(_filtered(defaults))} of {len(ui)} fields render",
len(_filtered(defaults)) == len(ui))
# ⚠️ DERIVED, NOT TYPED, AND NOT A FLOOR. The assertion above read
# `len(_required) > 15` while the prose said 59 - so a manifest change dropping
# the required count to 20 would have passed while the sentence beside it stayed
# wrong. That is the same shape as the count this repo already fixed in
# `test_translations.py`: a number displayed but not graded.
#
# So the expected value is computed from config.yaml by Supervisor's OWN rule -
# a key is optional iff its schema string ends in `?`, plus `inverters`, which
# `UiOptions` marks optional because it is a list - and the assertion is an
# EQUALITY against that. Typing 59 here would rot the first time a key is added.
_OPTIONAL = {k for k, v in schema.items()
if isinstance(v, str) and v.rstrip().endswith("?")} | {"inverters"}
_EXPECT_REQ = len(schema) - len(_OPTIONAL)
show(f"...and that floor is DERIVED: {len(schema)} schema keys minus "
f"{len(_OPTIONAL)} optional ones (the `?` suffix, plus `inverters`) is "
f"{_EXPECT_REQ}, and Supervisor's own required flag agrees",
_EXPECT_REQ == len(_required))
# ⚠️ AND THE WITHDRAWAL'S OWN PREMISE, GRADED SEPARATELY. The equality above is
# deliberately NOT enough on its own: both sides read the same `?` convention,
# so marking a key optional moves BOTH and it stays green. That is correct - it
# makes the printed number impossible to leave stale - but it grades Supervisor
# agreeing with us, not the thing AC 3 was withdrawn for.
#
# AC 3 was withdrawn because the floor is FAR above the ~15 it asked for. If
# enough keys ever became optional that the floor approached 15, the withdrawal
# stops being true and this file should say so rather than keep printing a
# sentence that has quietly become wrong.
_AC3_ASKED = 15
show(f"...and the floor of {_EXPECT_REQ} is still far above the ~{_AC3_ASKED} "
f"AC 3 asked for, which is the whole reason it was withdrawn. If this ever "
f"goes red the withdrawal needs re-arguing, not re-asserting",
_EXPECT_REQ > 2 * _AC3_ASKED)
# ⚠️ AN ISOLATED COUNTERFACTUAL, and the number is Supervisor's own `required`
# flag rather than a count typed here. `options:` is emptied and NOTHING else
# varies, so the answer cannot be about anything but the merge.
show(f"...and with `options:` EMPTIED ENTIRELY - the maximum hiding that is "
f"expressible - the tab still renders {len(_filtered({}))} required "
f"fields. THAT is the floor, and AC 3 asked for ~15. The criterion is "
f"withdrawn as unsatisfiable, not deferred.",
len(_filtered({})) == len(_required) and len(_required) == _EXPECT_REQ)
def _entry(lang, name, path=()): def _entry(lang, name, path=()):
node = TRANSLATIONS.get(lang, {}).get("configuration") node = TRANSLATIONS.get(lang, {}).get("configuration")