SAFETY-47: what a shadow run can show, and the watts on its status row #90

Merged
admin merged 1 commits from SAFETY-47 into release/1.0 2026-09-15 01:36:42 +02:00
Owner

⚠️ Not a leak, and not a write-path defect. Shadow mode's safety is intact — the substitution in _write_unit(), the re-seed, SAFETY-28's gate and SAFETY-03's clamp are untouched. What was wrong is what three merged documents say a shadow run can show you, and a number on the status row that can be 6.61× what a live controller would command.

Shadow mode cannot surface a closed-loop defect. Not partially — not at all.

The battery is held at 0 W and the carried command is re-seeded to it, so every measured input — the meter, the billed-quarter accumulator behind it, the battery power, the SoC — describes a plant that did not respond. A closed-loop defect is one whose stimulus is that response.

This is the same property that makes shadow mode safe, so it is not a gap to close later: feeding the guards a simulated plant instead is exactly what SAFETY-10 §4 refuses for the sign watchdogs. The closed-loop answer comes from the closed-loop rig or from a live run, and there is no third place. SAFETY-10 §5 already conceded this for the grid-following loop ("one slew step, not a trajectory") — that was never the loop leg's limitation, it is the general case, and the loop leg is merely where it had already been noticed.

The exemption was true until SAFETY-45 merged, one commit earlier

All three documents carried a version of "the strategies are unaffected — they ask for a fixed power from inputs that do not depend on what the battery did". SAFETY-45 (#88) makes the buy leg min(asked, headroom) with headroom = (900 / remaining) × (ceiling − projected), and projected reads the open quarter's accumulator — the meter — which is a function of what the battery did earlier in that same quarter.

Measured on TEST-23 stage 3, peak_cap_w 1500 / house 1200 / max_w 2000, one hour at a 5 s cycle, the same hour run twice:

visible from a shadow run? measured
SAFETY-45's limit cycle no 28 charge onsets live, 1 in shadow; grid −455.5 … +3200.0 W live, flat at the 1200 W house; guard engaged 410/720 cycles live, 0/720 in shadow
SAFETY-46's SoC churn no the SoC moves 0.000000000 pp. No SoC limit can be reached in a shadow run
TARIFF-14's wrong quarter-hour yes, and it is the only one which hour, at what price, clearing what spread — identical on 720 of 720 charging cycles, at a binding cap

The exemption narrows to exactly what survives, and it is now graded rather than asserted:

Which hour a strategy picks, and why, are unaffected — the clock and the price series are not functions of the battery. How hard it trades is not.

The peak guard is not in the surviving half either: every term of wanted = batt_w + excess is measured. Its ceiling and its reasons are worth reading from a shadow run; its engagements are not evidence of anything.

The number is caveated, not re-computed — and that is the decision

cap / house live commanded shadow recorded over by
1500 / 1200 −302.4 W −2000.0 W 6.61×
3500 / 3000 −502.6 W −2000.0 W 3.98×
3500 / 2000 (shipped cap) −1502.6 W −2000.0 W 1.33×
3500 / 1800 −1702.6 W −2000.0 W 1.17×
15000 / 350 −2000.0 W −2000.0 W 1.00×

The arithmetic is not wrong: 900/remaining is correct for a meter genuinely under the ceiling, and in a shadow run the meter genuinely is under it. It is the right answer to a question nobody asked. The last row is the fixture SAFETY-10's own evidence was taken on, which is why nothing reported this.

Shipped: the watts are introduced as what would have been commanded ON THIS CYCLE — unconditionally, in every shadow state, because no number a shadow run records is a forecast of a live run — and both the status row and the action log carry shadow.SIZED_FROM_METER whenever a measured billed quarter sized them.

Rejected, and recorded in DECISIONS/SAFETY-47-what-a-shadow-run-can-show.md §3:

  • Re-compute it so it does not amplify. The worst option. It would be a shadow-only sizing: the recorded decision would no longer be the decision the shipped path reached, while the arbiter, the clamp, SAFETY-28's gate, the log and MQTT all ran on the real one. SAFETY-10's whole safety argument is that shadow mode is a substitution at the wire, not a second controller; this makes it a second controller everywhere except the wire.
  • Record nothing where the sizing depends on measured state. Blanks the row exactly when arbitrage is trading; the action log's edge key is the rounded watts, so blanking them either floods or silences the log; and a surface that flickers between a number and a dash is harder to read than a number with a standing caveat. Closest runner-up.
  • Record the unsized ask alongside the sized one. Adds a number without adding a warning — the unsized ask is max_w, and on the shipped 3500/2000 row both figures read 2000 W.

⚠️ "Upper bound", not "smaller", and the difference was measured. Charging only adds offtake, so the shadow size is ≥ the live size with equality at the start of every quarter. Pairwise over 3600 charging cycles in five configurations: 0 below, 730 exactly equal. The first draft of the sentence said "smaller", which would have been false on every one of those 730.

⚠️ And the predicate is NOT keyed on whether the trade was capped. arbitrage's own sentence says "SIZED BY THE CAPACITY-TARIFF GUARD" only when the headroom came out below the ask — in a shadow run, the start of the quarter, where the two runs still agree to the float. By the end, 900/remaining has multiplied the headroom past the ask, the trade is not capped, the sentence is silent, and the recorded number is at its most wrong. A caveat keyed on it would be quietest exactly where it is needed loudest. Part 3d-8 drives the inversion.

SAFETY-10 §6's committed evidence could never have failed

Two independent reasons, and closing either one alone closes nothing:

  1. the fixture is peak_cap_w: 15000 against a 350 W house, so the guard never engages and SAFETY-45's sizing is in the run and inert. ⚠️ The harness's own comment says so — "at this scenario's peak_cap_w: 15000 it is not binding anyway";
  2. every identity row reads the first charging sample. At elapsed = 0 the projection is the held level, so the two runs agree to the float even at a cap that does bind. Measured: widening the fixture alone leaves that comparison passing.

⚠️ The second is a new shape for MUTATION.md's habit-4 list — an INDEX, not a fixture, and the one a reviewer cannot see from a diff. Recorded there as the twelfth instance, and the second inside a committed evidence artifact.

§6 is re-taken at a binding cap across the whole run: 716 of 720 charging cycles differ; headroom 300.0 … 54 000.0 W in shadow against 299.93 … 307.80 W live under a 1500 W ceiling; recorded −2000.0 W against a live −302.4 W.

The grader — test_arbitrage.py part 3d

Placed beside part 3c, SAFETY-45's own closed-loop regression, and built on the same s45_loop() harness so the runs share every line of shipped code and differ in one argument. Three lines stand in for _write_unit()'s substitution and _shadow_settle()'s record and re-seed — marked as a transcription; test_clamp.py part 8 remains the check on the shipped placement. Everything that produces a number is the shipped module.

⚠️ The section carries its own precondition into every check. A mutation that stopped either run trading made it raise on its first min(), kill the file before part 4, and report zero reds — which reads exactly like a survivor. That is FCST-06's shape, and it was driven, not imagined.

⚠️ And the fix's own first draft carried the same defect it is about. Deleting the caveat from the action-log line left all 29 files green — the sentence was an f-string inside _shadow_settle(), and the only suite that drives that method uses a meter_source: off fixture where no arbitrage decision, and therefore no caveat, exists. It is shadow.log_line() now, for the reason words() is a function.

Mutations — 14, no survivors

shadow.py ×4 (the row's caveat, the ON THIS CYCLE narrowing, the log sentence, a quiet False default), arbitrage.py ×4 (each rung of sized_from_meter(), plus the capped-keying trap), main.py ×5 (both call sites, the winner= argument, the headroom hoisted to a literal, the hoist moved out of cycle()), peakguard.py ×1.

⚠️ M13 and M14 are +0 B — MUTATION.md's risky delta — confirmed on disk by sha256 before each run, every run purged. ⚠️ M14's first form was aimed at the wrong direction and correctly survived: min → max in the buy leg makes the shadow record larger, which is still an upper bound. The mutation that actually inverts the bound is the sign of the excess term in wanted.

All 29 check files green. Nothing was deployed: no ha addons update, no writes to /addons/, no contact with 192.168.2.158 or the inverter at 192.168.2.221.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU

⚠️ **Not a leak, and not a write-path defect.** Shadow mode's safety is intact — the substitution in `_write_unit()`, the re-seed, SAFETY-28's gate and SAFETY-03's clamp are untouched. What was wrong is what **three merged documents say** a shadow run can show you, and a number on the status row that can be **6.61×** what a live controller would command. ## Shadow mode cannot surface a closed-loop defect. Not partially — not at all. The battery is held at 0 W and the carried command is re-seeded to it, so every **measured** input — the meter, the billed-quarter accumulator behind it, the battery power, the SoC — describes a plant that **did not respond**. A closed-loop defect is one whose stimulus **is** that response. This is the same property that makes shadow mode safe, so it is not a gap to close later: feeding the guards a simulated plant instead is exactly what SAFETY-10 §4 refuses for the sign watchdogs. **The closed-loop answer comes from the closed-loop rig or from a live run, and there is no third place.** SAFETY-10 §5 already conceded this for the grid-following loop ("one slew step, not a trajectory") — that was never the loop leg's limitation, it is the general case, and the loop leg is merely where it had already been noticed. ## The exemption was true until SAFETY-45 merged, one commit earlier All three documents carried a version of *"the **strategies** are unaffected — they ask for a fixed power from inputs that do not depend on what the battery did"*. SAFETY-45 (#88) makes the buy leg `min(asked, headroom)` with `headroom = (900 / remaining) × (ceiling − projected)`, and `projected` reads the **open quarter's accumulator** — the meter — which is a function of what the battery did earlier in that same quarter. Measured on TEST-23 stage 3, `peak_cap_w` 1500 / house 1200 / `max_w` 2000, one hour at a 5 s cycle, the same hour run twice: | | visible from a shadow run? | measured | |---|---|---| | SAFETY-45's limit cycle | **no** | 28 charge onsets live, **1** in shadow; grid −455.5 … +3200.0 W live, **flat at the 1200 W house**; guard engaged **410/720** cycles live, **0/720** in shadow | | SAFETY-46's SoC churn | **no** | the SoC moves **0.000000000 pp**. No SoC limit can be *reached* in a shadow run | | TARIFF-14's wrong quarter-hour | **yes**, and it is the only one | which hour, at what price, clearing what spread — **identical on 720 of 720** charging cycles, at a binding cap | The exemption narrows to exactly what survives, and it is now graded rather than asserted: > **Which** hour a strategy picks, and **why**, are unaffected — the clock and the price series are not functions of the battery. **How hard** it trades is not. The peak guard is not in the surviving half either: every term of `wanted = batt_w + excess` is measured. Its **ceiling** and its **reasons** are worth reading from a shadow run; its **engagements** are not evidence of anything. ## The number is caveated, not re-computed — and that is the decision | cap / house | live commanded | shadow recorded | over by | |---|---|---|---| | 1500 / 1200 | −302.4 W | **−2000.0 W** | **6.61×** | | 3500 / 3000 | −502.6 W | −2000.0 W | 3.98× | | 3500 / 2000 *(shipped cap)* | −1502.6 W | −2000.0 W | 1.33× | | 3500 / 1800 | −1702.6 W | −2000.0 W | 1.17× | | 15000 / 350 | −2000.0 W | −2000.0 W | 1.00× | The arithmetic is **not wrong**: `900/remaining` is correct for a meter genuinely under the ceiling, and in a shadow run the meter genuinely *is* under it. It is the right answer to a question nobody asked. The last row is the fixture SAFETY-10's own evidence was taken on, which is why nothing reported this. **Shipped:** the watts are introduced as what would have been commanded **ON THIS CYCLE** — unconditionally, in every shadow state, because no number a shadow run records is a forecast of a live run — and both the status row and the action log carry `shadow.SIZED_FROM_METER` whenever a **measured** billed quarter sized them. **Rejected, and recorded in `DECISIONS/SAFETY-47-what-a-shadow-run-can-show.md` §3:** - **Re-compute it so it does not amplify.** The worst option. It would be a *shadow-only sizing*: the recorded decision would no longer be the decision the shipped path reached, while the arbiter, the clamp, SAFETY-28's gate, the log and MQTT all ran on the real one. SAFETY-10's whole safety argument is that shadow mode is a **substitution at the wire**, not a second controller; this makes it a second controller everywhere except the wire. - **Record nothing where the sizing depends on measured state.** Blanks the row exactly when arbitrage is trading; the action log's edge key is the rounded watts, so blanking them either floods or silences the log; and a surface that flickers between a number and a dash is harder to read than a number with a standing caveat. Closest runner-up. - **Record the unsized ask alongside the sized one.** Adds a number without adding a warning — the unsized ask is `max_w`, and on the shipped 3500/2000 row both figures read 2000 W. ⚠️ **"Upper bound", not "smaller", and the difference was measured.** Charging only *adds* offtake, so the shadow size is ≥ the live size with **equality at the start of every quarter**. Pairwise over **3600 charging cycles in five configurations: 0 below, 730 exactly equal.** The first draft of the sentence said "smaller", which would have been false on every one of those 730. ⚠️ **And the predicate is NOT keyed on whether the trade was capped.** `arbitrage`'s own sentence says *"SIZED BY THE CAPACITY-TARIFF GUARD"* only when the headroom came out **below** the ask — in a shadow run, the **start** of the quarter, where the two runs still agree to the float. By the end, `900/remaining` has multiplied the headroom **past** the ask, the trade is **not** capped, the sentence is silent, and the recorded number is at its **most** wrong. A caveat keyed on it would be quietest exactly where it is needed loudest. Part 3d-8 drives the inversion. ## SAFETY-10 §6's committed evidence could never have failed Two independent reasons, and **closing either one alone closes nothing**: 1. the fixture is `peak_cap_w: 15000` against a 350 W house, so the guard never engages and SAFETY-45's sizing is in the run and **inert**. ⚠️ The harness's own comment says so — *"at this scenario's `peak_cap_w: 15000` it is not binding anyway"*; 2. every identity row reads **the first charging sample**. At `elapsed = 0` the projection **is** the held level, so the two runs agree to the float **even at a cap that does bind**. Measured: widening the fixture alone leaves that comparison passing. ⚠️ The second is a **new shape for MUTATION.md's habit-4 list — an INDEX, not a fixture**, and the one a reviewer cannot see from a diff. Recorded there as the twelfth instance, and the second inside a committed evidence artifact. §6 is re-taken at a binding cap **across the whole run**: 716 of 720 charging cycles differ; headroom **300.0 … 54 000.0 W** in shadow against **299.93 … 307.80 W** live under a 1500 W ceiling; recorded −2000.0 W against a live −302.4 W. ## The grader — `test_arbitrage.py` part 3d Placed beside part 3c, SAFETY-45's own closed-loop regression, and built on the **same** `s45_loop()` harness so the runs share every line of shipped code and differ in **one argument**. Three lines stand in for `_write_unit()`'s substitution and `_shadow_settle()`'s record and re-seed — marked as a **transcription**; `test_clamp.py` part 8 remains the check on the shipped placement. Everything that produces a number is the shipped module. ⚠️ **The section carries its own precondition into every check.** A mutation that stopped either run trading made it raise on its first `min()`, kill the file before part 4, and report **zero reds — which reads exactly like a survivor**. That is FCST-06's shape, and it was **driven**, not imagined. ⚠️ **And the fix's own first draft carried the same defect it is about.** Deleting the caveat from the action-log line left **all 29 files green** — the sentence was an f-string inside `_shadow_settle()`, and the only suite that drives that method uses a `meter_source: off` fixture where no arbitrage decision, and therefore no caveat, exists. It is `shadow.log_line()` now, for the reason `words()` is a function. ## Mutations — 14, no survivors `shadow.py` ×4 (the row's caveat, the `ON THIS CYCLE` narrowing, the log sentence, a quiet `False` default), `arbitrage.py` ×4 (each rung of `sized_from_meter()`, plus the `capped`-keying trap), `main.py` ×5 (both call sites, the `winner=` argument, the headroom hoisted to a literal, the hoist moved out of `cycle()`), `peakguard.py` ×1. ⚠️ **M13 and M14 are `+0 B`** — MUTATION.md's risky delta — confirmed on disk by `sha256` before each run, every run purged. ⚠️ **M14's first form was aimed at the wrong direction and correctly survived**: `min` → `max` in the buy leg makes the shadow record *larger*, which is still an upper bound. The mutation that actually inverts the bound is the sign of the excess term in `wanted`. **All 29 check files green.** Nothing was deployed: no `ha addons update`, no writes to `/addons/`, no contact with 192.168.2.158 or the inverter at 192.168.2.221. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
admin added 1 commit 2026-09-15 01:27:49 +02:00
Not a leak and not a write-path defect. Shadow mode's safety is intact -
the substitution, the re-seed, the authorisation gate and the clamp are
untouched. What was wrong is what three merged documents SAY a shadow run
can show you, and a number on the status row that can be 6.61x what a live
controller would command.

SHADOW MODE CANNOT SURFACE A CLOSED-LOOP DEFECT, AND THE DOCUMENTS NOW SAY
SO. The battery is held at 0 W and the carried command is re-seeded to it,
so every measured input - the meter, the billed-quarter accumulator behind
it, the battery power, the SoC - describes a plant that did not respond. A
closed-loop defect is one whose stimulus IS that response. This is the same
property that makes shadow mode safe, so it is not a gap to close later:
the closed-loop answer comes from the closed-loop rig or from a live run.
SAFETY-10 §5 already conceded this for the grid-following loop. It was
never the loop leg's limitation; it is the general case.

THE "STRATEGIES ARE UNAFFECTED" EXEMPTION WAS TRUE UNTIL SAFETY-45 MERGED,
ONE COMMIT EARLIER. That card sizes the buy leg as min(asked, headroom)
with headroom = (900/remaining) x (ceiling - projected), and `projected`
reads the open quarter's accumulator - the meter - which is a function of
what the battery did earlier in that quarter. Measured on TEST-23 stage 3
at peak_cap_w 1500 / house 1200 / max_w 2000, one hour at 5 s:

  SAFETY-45's limit cycle  NOT visible. 28 onsets live vs 1; grid
                           -455.5..+3200.0 W vs flat at the 1200 W house;
                           guard engaged 410/720 cycles vs 0/720.
  SAFETY-46's SoC churn    NOT visible. The SoC moves 0.000000000 pp.
  TARIFF-14's wrong hour   VISIBLE, and it is the only one: which hour, at
                           what price, clearing what spread - identical on
                           720 of 720 charging cycles at a binding cap.

The exemption narrows to exactly what survives: WHICH hour a strategy picks
and WHY are unaffected; HOW HARD it trades is not. The peak guard's ceiling
and reasons are worth reading from a shadow run; its ENGAGEMENTS are not.

THE NUMBER IS CAVEATED, NOT RE-COMPUTED, AND THAT IS THE DECISION. The
arithmetic is not wrong - 900/remaining is correct for a meter genuinely
under the ceiling, and in shadow the meter genuinely is. It is the right
answer to a question nobody asked. Both surfaces now introduce the watts as
what would have been commanded ON THIS CYCLE, in every state, and carry
shadow.SIZED_FROM_METER whenever a measured billed quarter sized them.
Rejected, and recorded in the decision doc: re-computing it (the recorded
decision would no longer be the decision the shipped path reached, while the
arbiter, clamp, SAFETY-28 gate, log and MQTT all ran on the real one - a
second controller everywhere except the wire); recording nothing (blanks the
row exactly when arbitrage is trading, and the log's edge key is the watts);
and recording the unsized ask beside it (a second number, no warning, and at
the shipped 3500/2000 both figures read 2000 W).

"UPPER BOUND" AND NOT "SMALLER", AND THE DIFFERENCE WAS MEASURED. Charging
only adds offtake, so the shadow size is >= the live size with EQUALITY at
the start of every quarter. Pairwise over 3600 charging cycles in five
configurations: 0 below, 730 exactly equal. The first draft said "smaller",
which would have been false on every one of those 730.

AND THE PREDICATE IS NOT KEYED ON WHETHER THE TRADE WAS CAPPED. arbitrage's
own sentence says "SIZED BY THE CAPACITY-TARIFF GUARD" only when the
headroom came out below the ask - in a shadow run, the START of the quarter,
where the two runs agree to the float. By the end, 900/remaining has
multiplied the headroom past the ask, the trade is not capped, the sentence
is silent, and the record is at its most wrong. A caveat keyed on it would
be quietest exactly where it is needed loudest.

SAFETY-10 §6's COMMITTED EVIDENCE COULD NEVER HAVE FAILED, FOR TWO
INDEPENDENT REASONS, AND CLOSING EITHER ALONE CLOSES NOTHING. Its fixture is
peak_cap_w 15000 against a 350 W house, so the guard never engages and the
sizing is inert - the harness's own comment says so. AND every identity row
reads the FIRST charging sample, where at elapsed=0 the projection IS the
held level and the two runs agree to the float even at a cap that does bind.
Measured: widening the fixture alone leaves that comparison passing. That
second reason is a new shape for MUTATION.md's habit-4 list - an INDEX, not
a fixture - and it is recorded there as the twelfth instance. §6 is re-taken
at a binding cap across the whole run: 716 of 720 charging cycles differ,
headroom 300.0..54000.0 W in shadow against 299.9..307.8 W live, recorded
-2000.0 W against a live -302.4 W.

THE GRADER IS test_arbitrage.py PART 3d, beside SAFETY-45's own closed-loop
regression and built on the same s45_loop() harness, so the runs differ in
one argument. Three lines stand in for _write_unit()'s substitution and
_shadow_settle()'s record and re-seed, marked as a transcription;
test_clamp.py part 8 remains the check on the shipped placement. The section
carries its own precondition into every check: a mutation that stopped
either run trading made it raise on its first min(), kill the file and
report ZERO reds - FCST-06's shape, driven rather than imagined.

AND THE FIX'S OWN FIRST DRAFT CARRIED THE SAME DEFECT. Deleting the caveat
from the action-log line left all 29 files green, because the sentence was
an f-string inside _shadow_settle() and the only suite that drives it uses a
meter_source:off fixture where no arbitrage decision exists. It is
shadow.log_line() now, for the reason words() is a function.

14 mutations, no survivors; M13 and M14 are +0 B and were confirmed on disk
by sha256 with every run purged. M14's first form was aimed at the wrong
direction and correctly survived - min->max makes the shadow record larger,
which is still an upper bound. All 29 check files green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
admin merged commit 587495e143 into release/1.0 2026-09-15 01:36:42 +02:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: admin/goodwe-addon#90