Closes SAFETY-04 (P0). Body rewritten after review round 1 — the original described the rejected first cut.
The structural finding.control.py was in velocity form: want = prev_w + gain*error, with prev_w fed back post-clamp. The accumulator was the commanded power, so "clamp the integrator, not only the output" (§10.3) was not expressible. The accumulator is now explicit (i_w, carried by main.py) with its own bound, integrator_max_w.
The bound. Default is "follow max_w" (integrator_max_w: 0 = unset). It is a separate key because the useful direction is below the rail, where it binds first and caps unwind latency tighter than max_w does. Above the rail it only adds windup that release/1.0 did not have — measured on the 4000 W load-drop sim, the command on the first cycle after the drop is 1000 W at the default and 1800 W at 3000 W. DOCS.md says so, and a test asserts both halves of it.
The freeze. While saturated the integrator may not wind further in the direction it is already pushing; it may fall, cross zero, or reverse. This is the same asymmetric rule the output freeze has always used. It is deliberately not encoded as "only corrections that shrink |i_w|" — that is unsatisfiable for both signs of error whenever the correction exceeds twice the integrator, so near zero the loop deadlocks and the freeze can never clear (clearing needs the inverter to track, and not-tracking is the definition of saturation). That was blocker S-1 and it now has two regression tests.
Entering failsafe.main.py re-seeds i_w from the arbiter's applied target whenever the loop did not get what it asked for. Every layer-1 stop resolves to 0 W, so entering failsafe zeroes the integrator and the first cycle after release does not dump the stale period as power.
Seconds vs samples. Corrected, and it is worse than the spec's ambiguity: cycle() runs only when the meter value changes, so saturation_cycles counts distinct meter readings and has no wall-clock bound at all. That is detection latency rather than a windup hazard — the same stall freezes the whole loop — but the comment and DOCS no longer claim "~15 s". A guaranteed-seconds window would have to live in Controller, which has a clock; compute() deliberately does not.
Tests.python test_control.py: 24 (release/1.0) → 41, all passing. test_arbiter.py / test_maintenance.py unchanged and passing. Includes the §10.3 runaway replay (130 W per 4 s = a standing 217 W error at gain 0.6, inverter reporting 0 W, 150 cycles) asserted on the emitted command; the deadlock repro at i_w=0 and i_w=+100; a carried-i_w convergence/overshoot sim (the shipped configuration, previously uncovered); the post-saturation unwind figure; and a 3024-case exhaustive equivalence check proving the i_w=None path still reproduces release/1.0 exactly, freeze branch included.
Non-vacuity, all verified by mutation. Restore the rejected freeze gate → 3 red (including the equivalence check, which is independent confirmation of S-3). Delete the integrator clamp → 4 red, accumulator reaches 19 500 W. Put the default bound back above max_w → 4 red.
Coordination.main.py and config.yaml changes stay additive in the options wiring and schema. TEL-01 has already stacked on this branch; they do not touch control.py, so their rebase is the two option lines.
Closes SAFETY-04 (P0). *Body rewritten after review round 1 — the original described the rejected first cut.*
**The structural finding.** `control.py` was in velocity form: `want = prev_w + gain*error`, with `prev_w` fed back post-clamp. The accumulator *was* the commanded power, so "clamp the integrator, not only the output" (§10.3) was not expressible. The accumulator is now explicit (`i_w`, carried by `main.py`) with its own bound, `integrator_max_w`.
**The bound.** Default is "follow `max_w`" (`integrator_max_w: 0` = unset). It is a separate key because the useful direction is *below* the rail, where it binds first and caps unwind latency tighter than `max_w` does. Above the rail it only adds windup that `release/1.0` did not have — measured on the 4000 W load-drop sim, the command on the first cycle after the drop is 1000 W at the default and 1800 W at 3000 W. `DOCS.md` says so, and a test asserts both halves of it.
**The freeze.** While saturated the integrator may not wind further *in the direction it is already pushing*; it may fall, cross zero, or reverse. This is the same asymmetric rule the output freeze has always used. It is deliberately **not** encoded as "only corrections that shrink |i_w|" — that is unsatisfiable for both signs of error whenever the correction exceeds twice the integrator, so near zero the loop deadlocks and the freeze can never clear (clearing needs the inverter to track, and not-tracking is the definition of saturation). That was blocker S-1 and it now has two regression tests.
**Entering failsafe.** `main.py` re-seeds `i_w` from the arbiter's applied target whenever the loop did not get what it asked for. Every layer-1 stop resolves to 0 W, so entering failsafe zeroes the integrator and the first cycle after release does not dump the stale period as power.
**Seconds vs samples.** Corrected, and it is worse than the spec's ambiguity: `cycle()` runs only when the meter *value changes*, so `saturation_cycles` counts distinct meter readings and has no wall-clock bound at all. That is detection latency rather than a windup hazard — the same stall freezes the whole loop — but the comment and DOCS no longer claim "~15 s". A guaranteed-seconds window would have to live in `Controller`, which has a clock; `compute()` deliberately does not.
**Tests.** `python test_control.py`: 24 (`release/1.0`) → 41, all passing. `test_arbiter.py` / `test_maintenance.py` unchanged and passing. Includes the §10.3 runaway replay (130 W per 4 s = a standing 217 W error at gain 0.6, inverter reporting 0 W, 150 cycles) asserted on the **emitted** command; the deadlock repro at `i_w=0` and `i_w=+100`; a carried-`i_w` convergence/overshoot sim (the shipped configuration, previously uncovered); the post-saturation unwind figure; and a **3024-case exhaustive equivalence check** proving the `i_w=None` path still reproduces `release/1.0` exactly, freeze branch included.
**Non-vacuity, all verified by mutation.** Restore the rejected freeze gate → 3 red (including the equivalence check, which is independent confirmation of S-3). Delete the integrator clamp → 4 red, accumulator reaches 19 500 W. Put the default bound back above `max_w` → 4 red.
**Coordination.** `main.py` and `config.yaml` changes stay additive in the options wiring and schema. TEL-01 has already stacked on this branch; they do not touch `control.py`, so their rebase is the two option lines.
The loop was in velocity form: the accumulator WAS the commanded power, so
"clamp the integrator" and "clamp the output" were the same line of code and
could not be set apart. This makes the accumulator an explicit carried value
(`i_w`), bounds it with its own `integrator_max_w`, and keeps the output clamp
where it was. Killing either mechanism now still leaves the other holding -
which is the point of the ticket, and what the new regression test asserts.
Freeze semantics: while saturated the integrator may unwind but not wind
further. A strict freeze would strand the command at whatever it reached,
because the condition that releases it is the inverter tracking again, and not
tracking is exactly what saturation means.
The integrator is re-seeded from the arbiter's actual output whenever the loop
did not get what it asked for, so entering any failsafe (all of which resolve
to 0 W) zeroes it, and the first cycle after release does not dump the stale
period as power.
test_control.py: 24 -> 33 checks, all passing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
S-1. The frozen branch admitted a correction only if it shrank |i_w|. That is
unsatisfiable for BOTH signs of error whenever |correction| > 2*|i_w|, i.e.
whenever the integrator is near zero, so the loop stopped moving and the freeze
could never clear - it clears when the inverter tracks, and not tracking is
what saturation means. Measured: 0 W held into a 2 kW import indefinitely,
where release/1.0 recovers on the next cycle. Re-encoded as the same asymmetric
rule the output freeze has always used: may not wind further in the direction
it is already pushing, may fall, cross zero or reverse. Same interpretation,
an encoding that cannot deadlock.
S-2. integrator_max_w defaulted to 1.5x max_w, which ADDED windup: in
release/1.0 the accumulator was the post-clamp command and could never pass the
rail. Default is now "follow max_w" (config 0 = unset). Measured on the 4000 W
load-drop sim, first cycle after the drop: 1000 W at the new default, 1800 W at
3000. DOCS row inverted - the useful direction is below max_w, and the 14 768 W
anecdote is a vendor controller, not evidence about this code.
S-3. The claim that i_w=None preserved release/1.0 exactly was false, because
the S-1 gate ran regardless of seeding. It is true again, and now asserted
rather than asserted-about: 3024-case exhaustive comparison against a
transcription of the old law, over both freeze states, both signs and either
side of the deadband. Added the carried-i_w convergence/overshoot sim that the
shipped configuration was missing.
S-4. Cycles are distinct meter values, not seconds: cycle() runs only when the
meter reading changes, so the window has no wall-clock bound. Comment and DOCS
corrected; the stall is detection latency, not a windup hazard, because the
same condition stalls the whole loop.
test_control.py: 33 -> 41 checks, all passing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
Review round 1 addressed in e461755, pushed to the same branch. Body above rewritten; it described the rejected cut.
S-1 freeze gate re-encoded as the asymmetric rule from the output freeze. i_w=0 against a 2 kW import now commands 1000 W and the freeze clears the next cycle. Two regression tests, both go red against the old gate.
S-2integrator_max_w default is now "follow max_w" (0 = unset), schema int(0,15000), DOCS row inverted. Post-saturation unwind: 1000 W at the default vs 1800 W at 3000, asserted in a test so the DOCS advice is checkable.
S-3 the equivalence claim is now a 3024-case exhaustive comparison against a transcription of the release/1.0 law, plus the carried-i_w convergence/overshoot sim that was missing. Confirming your diagnosis: the old gate breaks that equivalence test, which is how it would have been caught.
S-4 comments and DOCS corrected — cycles count distinct meter values with no wall-clock bound. Left in compute() rather than moved to Controller: the stall is detection latency, not windup, since the same condition stalls the loop. Say the word if you want the real seconds window and it goes in Controller.
Review round 1 addressed in e461755, pushed to the same branch. Body above rewritten; it described the rejected cut.
- **S-1** freeze gate re-encoded as the asymmetric rule from the output freeze. `i_w=0` against a 2 kW import now commands 1000 W and the freeze clears the next cycle. Two regression tests, both go red against the old gate.
- **S-2** `integrator_max_w` default is now "follow `max_w`" (`0` = unset), schema `int(0,15000)`, DOCS row inverted. Post-saturation unwind: 1000 W at the default vs 1800 W at 3000, asserted in a test so the DOCS advice is checkable.
- **S-3** the equivalence claim is now a 3024-case exhaustive comparison against a transcription of the `release/1.0` law, plus the carried-`i_w` convergence/overshoot sim that was missing. Confirming your diagnosis: the old gate breaks that equivalence test, which is how it would have been caught.
- **S-4** comments and DOCS corrected — cycles count distinct meter values with no wall-clock bound. Left in `compute()` rather than moved to `Controller`: the stall is detection latency, not windup, since the same condition stalls the loop. Say the word if you want the real seconds window and it goes in `Controller`.
24 -> 41 checks. Mutation results: old gate 3 red, clamp deleted 4 red (19 500 W), default above `max_w` 4 red.
`want = i_w` after the integrator bound, so at the default limit == max_w the
output clamp can never fire and `reason == "clamped"` had become unreachable.
Observability only today - nothing gates on the string - but SAFETY-03 exists
to alarm on exactly that engagement, so its hook was dead before it was built.
The integrator bound now reports "i-clamped", and that is the signal SAFETY-03
must watch: it is the one that fires on a default install. "clamped" stays
reachable for a configuration that lets the integrator run above the rail,
where both fire and the output clamp - which describes the value actually
emitted - is the one reported. Two names because the two events want different
alarms: the loop winding, versus a command that came out over the rating.
The real fix is the second half. The equivalence sweep compared
(target_w, sat_count), which is how a dead reason survived 3024 cases. It now
compares (target_w, sat_count, frozen, reason) and it catches this defect:
dropping the emit turns it red. Deliberate rename aliased explicitly, so any
OTHER reason divergence still fails.
Result of adding reason to the tuple: 105 of 3024 cases differ, and every one
of them is the i-clamped/clamped rename. Zero value divergences, `frozen`
included. Nothing else surfaced.
test_control.py: 41 -> 43 checks, all passing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
The integrator bound now emits i-clamped. Contract for SAFETY-03's owner: that is the signal to alarm on, not clamped - at the default limit == max_w the integrator is truncated first and the command derived from it can then never exceed max_w, so clamped cannot fire on a default install. It stays reachable for a config that deliberately lets the integrator run above the rail, where both fire and clamped wins because it describes the value actually emitted. Both are pinned by tests.
Sweep tuple widened to (target_w, sat_count, frozen, reason) - frozen was missing too. The deliberate rename is aliased explicitly rather than papered over, so any other reason divergence still goes red.
Answering the question about the 189 cases: on my 3024-case sweep it is 105, and every single one is the i-clamped/clamped rename - one distinct pair, nothing else. Value divergences remain zero with frozen now compared. New reason set {tracking, deadband, i-clamped, slew-limited, saturated-freeze}, same cardinality as the old five.
Mutations: drop the emit -> 2 red including the sweep (i.e. the widened tuple catches exactly the defect you found); emit it unconditionally -> 5 red; delete the clamped line -> 1 red. 41 -> 43 checks. test_arbiter.py / test_maintenance.py unchanged and passing. Grepped for consumers of the reason strings: none outside control.py, so today's impact really is observability only.
Dead-reason defect fixed in 7123aa0, same branch.
- The integrator bound now emits **`i-clamped`**. Contract for SAFETY-03's owner: **that** is the signal to alarm on, not `clamped` - at the default `limit == max_w` the integrator is truncated first and the command derived from it can then never exceed `max_w`, so `clamped` cannot fire on a default install. It stays reachable for a config that deliberately lets the integrator run above the rail, where both fire and `clamped` wins because it describes the value actually emitted. Both are pinned by tests.
- Sweep tuple widened to `(target_w, sat_count, frozen, reason)` - `frozen` was missing too. The deliberate rename is aliased explicitly rather than papered over, so any other reason divergence still goes red.
**Answering the question about the 189 cases:** on my 3024-case sweep it is 105, and every single one is the `i-clamped`/`clamped` rename - one distinct pair, nothing else. Value divergences remain zero with `frozen` now compared. New reason set `{tracking, deadband, i-clamped, slew-limited, saturated-freeze}`, same cardinality as the old five.
Mutations: drop the emit -> 2 red including the sweep (i.e. the widened tuple catches exactly the defect you found); emit it unconditionally -> 5 red; delete the `clamped` line -> 1 red. 41 -> 43 checks. `test_arbiter.py` / `test_maintenance.py` unchanged and passing. Grepped for consumers of the reason strings: none outside `control.py`, so today's impact really is observability only.
`min(moved, i_w) if i_w > 0 else max(moved, i_w)` files i_w == 0.0 under
rising-only, so the first push toward charging from exactly zero was blocked
permanently - the S-1 deadlock again, mirrored in sign. main.py resets i_w to
exactly 0.0 on every stop and every reseed, so it is a normal state.
Zero is now handled explicitly and both directions are allowed: nothing is
wound, so "may not wind further" has no referent, and a first step from zero is
bounded by the gain, the output clamp and the slew limit like any other.
Measured before the fix, at i_w == 0.0 and frozen: 12 800 of 25 920 ticks held
the integrator and 8 304 of those changed the emitted command, worst case
abandoning a 2 kW charge into a 4 kW export. Note this is NOT the same as the
reported symptom: at prev_w == 0 the command holds at 0 W either way, because
the output freeze forbids starting a charge while saturated, and that rule is
release/1.0's and unchanged. There is now a test asserting it deliberately.
Tests. The durable part is a property rather than more points: over 13 041
frozen states the integrator may be held ONLY by a correction pushing it
further from zero on the side it already sits, and any other hold fails. Both
signs at exactly 0.0. Mirrors added everywhere the suite tested one direction
of two - freeze wind/unwind while charging, i_w=-100, the export-direction
runaway, the negative clamp and slew.
DOCS: the cycles-vs-seconds deviation is now written down as a deviation - the
"> 10 s" criterion is not met as literally written, a cycle is one CHANGED
meter reading, and there is no guaranteed wall-clock window.
test_control.py: 43 -> 55 checks, all passing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
Zero-boundary defect fixed in 53d301b, same branch.
i_w == 0.0 is now its own branch and both directions are allowed - nothing is wound, so "may not wind further" has no referent, and a first step from zero is bounded by the gain, the output clamp and the slew limit like any other step.
One correction to the report, and it matters for re-test. The tie-break was NOT what made the command stick at 0 W in the quoted scenario. At prev_w == 0 with the inverter reporting -600 W, release/1.0 holds 0 W forever too - verified side by side, same values cycle for cycle - because the output freeze deliberately forbids starting a charge while saturated ("commanding 0 while the inverter reports hundreds of watts means something else is driving the bus"). sat_count pinning at 10 is likewise the counter saturating by design. After this fix the integrator moves to -1200 W but the command still holds at 0 W in that exact case, and there is now a test asserting that on purpose so nobody removes it by accident. If the intent is that the command should move there, that is a change to release/1.0's output-freeze rule and wants its own ticket - it is a real safety rule, not a leftover.
The defect is real regardless, and it does reach the wire where prev_w is already charging: measured at i_w == 0.0 and frozen, 12 800 of 25 920 ticks held the integrator, 8 304 of them changing the emitted command, worst case abandoning a 2 kW charge into a 4 kW export (-1000 W where release/1.0 commands -2000 W).
On "three of us missed the same half": the durable fix is a property, not more points. Over 13 041 frozen states the suite now asserts the integrator may be held ONLY by a correction pushing it further from zero on the side it already sits; any other hold fails, whichever side it is on. It catches the reported defect, its sign-mirror, and a freeze-everything-at-zero variant. Points would not have.
I also swept the suite for one-sided tests, per your note, and mirrored all of them: freeze wind/unwind while charging, i_w=-100 under import, the export-direction runaway, the negative clamp and the negative slew.
Nothing else surfaced at that boundary: 128 160 frozen ticks swept across i_w and grid, zero illegitimate holds remaining.
DOCS now carries the cycles-vs-seconds deviation explicitly - the "> 10 s" criterion is not met as literally written, a cycle is one changed meter reading, no guaranteed wall-clock window, and why that is acceptable.
43 -> 55 checks, all passing; test_arbiter.py 18 and test_maintenance.py 21 unchanged. Mutations: reported defect restored -> 3 red; sign-mirrored (>= 0) -> 4 red including the equivalence sweep; freeze-everything-at-zero -> 6 red.
Noted on the i_clamped: bool correction - agreed, last-writer-wins makes the string unusable as an alarm hook, and SAFETY-03 owning the boolean is the right split.
Zero-boundary defect fixed in 53d301b, same branch.
`i_w == 0.0` is now its own branch and both directions are allowed - nothing is wound, so "may not wind further" has no referent, and a first step from zero is bounded by the gain, the output clamp and the slew limit like any other step.
**One correction to the report, and it matters for re-test.** The tie-break was NOT what made the command stick at 0 W in the quoted scenario. At `prev_w == 0` with the inverter reporting -600 W, `release/1.0` holds 0 W forever too - verified side by side, same values cycle for cycle - because the output freeze deliberately forbids *starting* a charge while saturated ("commanding 0 while the inverter reports hundreds of watts means something else is driving the bus"). `sat_count` pinning at 10 is likewise the counter saturating by design. After this fix the integrator moves to -1200 W but the command still holds at 0 W in that exact case, and there is now a test asserting that on purpose so nobody removes it by accident. **If the intent is that the command should move there, that is a change to `release/1.0`'s output-freeze rule and wants its own ticket** - it is a real safety rule, not a leftover.
The defect is real regardless, and it does reach the wire where `prev_w` is already charging: measured at `i_w == 0.0` and frozen, 12 800 of 25 920 ticks held the integrator, 8 304 of them changing the emitted command, worst case abandoning a 2 kW charge into a 4 kW export (`-1000 W` where `release/1.0` commands `-2000 W`).
**On "three of us missed the same half":** the durable fix is a property, not more points. Over 13 041 frozen states the suite now asserts the integrator may be held ONLY by a correction pushing it further from zero on the side it already sits; any other hold fails, whichever side it is on. It catches the reported defect, its sign-mirror, and a freeze-everything-at-zero variant. Points would not have.
I also swept the suite for one-sided tests, per your note, and mirrored all of them: freeze wind/unwind while charging, `i_w=-100` under import, the export-direction runaway, the negative clamp and the negative slew.
**Nothing else surfaced at that boundary:** 128 160 frozen ticks swept across `i_w` and grid, zero illegitimate holds remaining.
DOCS now carries the cycles-vs-seconds deviation explicitly - the "> 10 s" criterion is not met as literally written, a cycle is one *changed* meter reading, no guaranteed wall-clock window, and why that is acceptable.
43 -> 55 checks, all passing; `test_arbiter.py` 18 and `test_maintenance.py` 21 unchanged. Mutations: reported defect restored -> 3 red; sign-mirrored (`>= 0`) -> 4 red including the equivalence sweep; freeze-everything-at-zero -> 6 red.
Noted on the `i_clamped: bool` correction - agreed, last-writer-wins makes the string unusable as an alarm hook, and SAFETY-03 owning the boolean is the right split.
AC 3 - "integration freezes while saturated" - had no non-vacuous test.
Deleting the integrator freeze outright failed 0 of 55 checks: the two checks
that name it used a fixture at max_w 2000 with the integrator bound following
it, so a wound value was truncated back to exactly 2000 and the assertion
passed on the clamp instead. Fixture lifted to max_w 5000, clear of every rail.
Deleting the freeze now fails 2.
Then swept the whole function for the same pattern, one mechanism at a time:
delete it, count which checks notice. It found a second instance - the OUTPUT
clamp. `clamped to max_w` and `clamped to -max_w` were both satisfied by the
integrator bound truncating first, so removing the output clamp failed only the
reason-string check. Those two fixtures now set integrator_max_w above max_w so
the mechanism they name is the binding one; the output clamp goes from 1 failure
to 3.
Every mechanism in compute() is now caught by a check that names it: freeze 2,
integrator clamp 6, bound-follows-max_w 4, output clamp 3, slew 4, output freeze
2, deadband 5, quantise 2, detector 10, duration 2, counter reset 6, grid bias 3,
None-seeding 6. No mechanism at zero.
Method note: the audit disables bytecode caching. Rewriting control.py inside
one second leaves a stale app/__pycache__ entry and silently under-reports -
it under-reported one mutation as 2 failures where the true figure is 6.
test_control.py stays at 55 checks, all passing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
AC 3 now has a non-vacuous test - 389d9ec, same branch. The lead's diagnosis and their fix were both exactly right: TF lifted to max_w=5000, all 55 stay green, and deleting the integrator freeze outright now fails 2 where it failed 0.
I then swept the rest of the function for the same pattern rather than eyeballing it - delete one mechanism, count which checks notice - and it found one more instance: the output clamp. clamped to max_w and clamped to -max_w were both satisfied by the integrator bound truncating first, so deleting the output clamp failed only the reason-string check. Those two fixtures now set integrator_max_w above max_w, so the mechanism each one names is the binding one. Output clamp: 1 failure -> 3.
Final state, every mechanism in compute() caught by a check that names it:
mechanism
fails
mechanism
fails
integrator freeze (AC 3)
2
deadband
5
integrator clamp (AC 1)
6
quantisation
2
bound follows max_w
4
detector never fires
10
output clamp
3
duration (AC 2)
2
slew limit
4
counter reset
6
output freeze
2
target_grid_w bias
3
i_w=None seeding
6
Nothing at zero.
A method warning that affects how you read any mutation figure from me, including earlier rounds. Rewriting control.py repeatedly inside one second can leave a stale app/__pycache__ entry, and the suite then runs the OLD bytecode and under-reports. It bit me here: one mutation reported 2 failures where the true figure is 6. The audit now runs with PYTHONDONTWRITEBYTECODE=1 and clears the cache first. The earlier rounds' numbers were taken from separate sequential runs with enough elapsed time between them, and you and the lead reproduced all of them independently, so they stand - but the hazard is real for anyone re-running this quickly in a loop.
The audit script is not committed: it hardcodes exact source lines from control.py, so it would go stale on the next edit and generate noise. It is a throwaway tool, ~60 lines, and I can commit it or hand it over if you would rather it lived in the repo.
55 checks (unchanged - this round fixed what the existing checks actually test, it did not add any), test_arbiter.py 18, test_maintenance.py 21, all passing.
AC 3 now has a non-vacuous test - 389d9ec, same branch. The lead's diagnosis and their fix were both exactly right: `TF` lifted to `max_w=5000`, all 55 stay green, and deleting the integrator freeze outright now fails **2** where it failed 0.
**I then swept the rest of the function for the same pattern** rather than eyeballing it - delete one mechanism, count which checks notice - and it found **one more instance**: the **output clamp**. `clamped to max_w` and `clamped to -max_w` were both satisfied by the integrator bound truncating first, so deleting the output clamp failed only the reason-string check. Those two fixtures now set `integrator_max_w` above `max_w`, so the mechanism each one names is the binding one. Output clamp: 1 failure -> 3.
Final state, every mechanism in `compute()` caught by a check that names it:
| mechanism | fails | mechanism | fails |
|---|---|---|---|
| integrator freeze (AC 3) | 2 | deadband | 5 |
| integrator clamp (AC 1) | 6 | quantisation | 2 |
| bound follows `max_w` | 4 | detector never fires | 10 |
| output clamp | 3 | duration (AC 2) | 2 |
| slew limit | 4 | counter reset | 6 |
| output freeze | 2 | `target_grid_w` bias | 3 |
| `i_w=None` seeding | 6 | | |
Nothing at zero.
**A method warning that affects how you read any mutation figure from me, including earlier rounds.** Rewriting `control.py` repeatedly inside one second can leave a stale `app/__pycache__` entry, and the suite then runs the OLD bytecode and under-reports. It bit me here: one mutation reported 2 failures where the true figure is 6. The audit now runs with `PYTHONDONTWRITEBYTECODE=1` and clears the cache first. The earlier rounds' numbers were taken from separate sequential runs with enough elapsed time between them, and you and the lead reproduced all of them independently, so they stand - but the hazard is real for anyone re-running this quickly in a loop.
The audit script is not committed: it hardcodes exact source lines from `control.py`, so it would go stale on the next edit and generate noise. It is a throwaway tool, ~60 lines, and I can commit it or hand it over if you would rather it lived in the repo.
55 checks (unchanged - this round fixed what the existing checks actually test, it did not add any), `test_arbiter.py` 18, `test_maintenance.py` 21, all passing.
The audit found a dead mechanism twice, both times a clamp standing in for the
mechanism under test, so the technique has to survive this ticket. Not as a
script: the only cheap way to automate it is to key on source lines, that goes
stale silently, and a green audit that has quietly stopped testing anything is
this ticket's own failure mode one level up. Automating it properly would mean
decomposing compute() to make its statements separately addressable, which is a
refactor of the most safety-critical function in the repo for the benefit of
test tooling.
So it goes in as a comment block next to the checks it describes, carrying the
commit it was measured at, the thirteen figures, and the invariant with the
teeth in it: every mechanism must be noticed by at least two checks when it is
deleted, and adding a mechanism means re-running the audit. A comment cannot go
stale-green, because it never claims to be running.
Also recorded: fixtures must sit clear of every rail they are not testing,
which is the rule both misses violated; and the `python -B` / clear-pycache
discipline, with the reason (CPython invalidates on source mtime-in-seconds
plus size, so a same-second same-size rewrite reuses stale bytecode) and the
reason it casts no doubt on the figures (the error is one-directional, so every
number is a lower bound).
Figures are the lead's independent reproduction. I re-measured the one that
differed: the detector is 11 for `saturated_now = False` and 10 for the weaker
`frozen = False` form, so the table names the form.
test_control.py stays at 55 checks, all passing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
Audit recorded as a comment block in test_control.py, next to the checks it describes - 680461c. Script stays in my scratchpad; agreed on all three grounds, and the decomposition argument is the decisive one.
The block carries the commit it was measured at (389d9ec), the thirteen figures, and the invariant with the teeth in it: every mechanism in compute() must be noticed by at least two checks when deleted; adding a mechanism means re-running the audit. Plus the technique itself in prose, since there is no script to point at, and the two rules the misses produced - a fixture must sit clear of every rail it is not testing, and mutations run under python -B with app/__pycache__ cleared.
I re-measured the figure that differed rather than transcribing it. Both forms reproduce exactly here: saturated_now = False gives 11, the weaker frozen = False gives 10. Your read of the cause was right, and the table names the form so the next person reproduces the same one.
Also wrote in your sharpening of the caching mechanism - invalidation on (source mtime in whole seconds, source size), so a same-second rewrite that preserves file size reuses stale bytecode - along with why it casts no doubt on the numbers: the error is one-directional, so every figure is a lower bound at worst, and the only two zeros ever recorded were both confirmed by fixing them and watching the count rise, which an artefact cannot do.
git diff 389d9ec..680461c is test_control.py only, 57 insertions, no deletions. 55 checks, test_arbiter.py 18, test_maintenance.py 21, all passing.
Nothing pre-empted on the four sweep targets - they are the tester's.
Audit recorded as a comment block in `test_control.py`, next to the checks it describes - 680461c. Script stays in my scratchpad; agreed on all three grounds, and the decomposition argument is the decisive one.
The block carries the commit it was measured at (389d9ec), the thirteen figures, and the invariant with the teeth in it: **every mechanism in `compute()` must be noticed by at least two checks when deleted; adding a mechanism means re-running the audit.** Plus the technique itself in prose, since there is no script to point at, and the two rules the misses produced - a fixture must sit clear of every rail it is not testing, and mutations run under `python -B` with `app/__pycache__` cleared.
**I re-measured the figure that differed rather than transcribing it.** Both forms reproduce exactly here: `saturated_now = False` gives **11**, the weaker `frozen = False` gives **10**. Your read of the cause was right, and the table names the form so the next person reproduces the same one.
Also wrote in your sharpening of the caching mechanism - invalidation on (source mtime in whole seconds, source size), so a same-second rewrite that preserves file size reuses stale bytecode - along with why it casts no doubt on the numbers: the error is one-directional, so every figure is a lower bound at worst, and the only two zeros ever recorded were both confirmed by fixing them and watching the count rise, which an artefact cannot do.
`git diff 389d9ec..680461c` is `test_control.py` only, 57 insertions, no deletions. 55 checks, `test_arbiter.py` 18, `test_maintenance.py` 21, all passing.
Nothing pre-empted on the four sweep targets - they are the tester's.
This branch is already included in the target branch. There is nothing to merge.
This branch is out-of-date with the base branch
View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes SAFETY-04 (P0). Body rewritten after review round 1 — the original described the rejected first cut.
The structural finding.
control.pywas in velocity form:want = prev_w + gain*error, withprev_wfed back post-clamp. The accumulator was the commanded power, so "clamp the integrator, not only the output" (§10.3) was not expressible. The accumulator is now explicit (i_w, carried bymain.py) with its own bound,integrator_max_w.The bound. Default is "follow
max_w" (integrator_max_w: 0= unset). It is a separate key because the useful direction is below the rail, where it binds first and caps unwind latency tighter thanmax_wdoes. Above the rail it only adds windup thatrelease/1.0did not have — measured on the 4000 W load-drop sim, the command on the first cycle after the drop is 1000 W at the default and 1800 W at 3000 W.DOCS.mdsays so, and a test asserts both halves of it.The freeze. While saturated the integrator may not wind further in the direction it is already pushing; it may fall, cross zero, or reverse. This is the same asymmetric rule the output freeze has always used. It is deliberately not encoded as "only corrections that shrink |i_w|" — that is unsatisfiable for both signs of error whenever the correction exceeds twice the integrator, so near zero the loop deadlocks and the freeze can never clear (clearing needs the inverter to track, and not-tracking is the definition of saturation). That was blocker S-1 and it now has two regression tests.
Entering failsafe.
main.pyre-seedsi_wfrom the arbiter's applied target whenever the loop did not get what it asked for. Every layer-1 stop resolves to 0 W, so entering failsafe zeroes the integrator and the first cycle after release does not dump the stale period as power.Seconds vs samples. Corrected, and it is worse than the spec's ambiguity:
cycle()runs only when the meter value changes, sosaturation_cyclescounts distinct meter readings and has no wall-clock bound at all. That is detection latency rather than a windup hazard — the same stall freezes the whole loop — but the comment and DOCS no longer claim "~15 s". A guaranteed-seconds window would have to live inController, which has a clock;compute()deliberately does not.Tests.
python test_control.py: 24 (release/1.0) → 41, all passing.test_arbiter.py/test_maintenance.pyunchanged and passing. Includes the §10.3 runaway replay (130 W per 4 s = a standing 217 W error at gain 0.6, inverter reporting 0 W, 150 cycles) asserted on the emitted command; the deadlock repro ati_w=0andi_w=+100; a carried-i_wconvergence/overshoot sim (the shipped configuration, previously uncovered); the post-saturation unwind figure; and a 3024-case exhaustive equivalence check proving thei_w=Nonepath still reproducesrelease/1.0exactly, freeze branch included.Non-vacuity, all verified by mutation. Restore the rejected freeze gate → 3 red (including the equivalence check, which is independent confirmation of S-3). Delete the integrator clamp → 4 red, accumulator reaches 19 500 W. Put the default bound back above
max_w→ 4 red.Coordination.
main.pyandconfig.yamlchanges stay additive in the options wiring and schema. TEL-01 has already stacked on this branch; they do not touchcontrol.py, so their rebase is the two option lines.Review round 1 addressed in
e461755, pushed to the same branch. Body above rewritten; it described the rejected cut.i_w=0against a 2 kW import now commands 1000 W and the freeze clears the next cycle. Two regression tests, both go red against the old gate.integrator_max_wdefault is now "followmax_w" (0= unset), schemaint(0,15000), DOCS row inverted. Post-saturation unwind: 1000 W at the default vs 1800 W at 3000, asserted in a test so the DOCS advice is checkable.release/1.0law, plus the carried-i_wconvergence/overshoot sim that was missing. Confirming your diagnosis: the old gate breaks that equivalence test, which is how it would have been caught.compute()rather than moved toController: the stall is detection latency, not windup, since the same condition stalls the loop. Say the word if you want the real seconds window and it goes inController.24 -> 41 checks. Mutation results: old gate 3 red, clamp deleted 4 red (19 500 W), default above
max_w4 red.Dead-reason defect fixed in
7123aa0, same branch.i-clamped. Contract for SAFETY-03's owner: that is the signal to alarm on, notclamped- at the defaultlimit == max_wthe integrator is truncated first and the command derived from it can then never exceedmax_w, soclampedcannot fire on a default install. It stays reachable for a config that deliberately lets the integrator run above the rail, where both fire andclampedwins because it describes the value actually emitted. Both are pinned by tests.(target_w, sat_count, frozen, reason)-frozenwas missing too. The deliberate rename is aliased explicitly rather than papered over, so any other reason divergence still goes red.Answering the question about the 189 cases: on my 3024-case sweep it is 105, and every single one is the
i-clamped/clampedrename - one distinct pair, nothing else. Value divergences remain zero withfrozennow compared. New reason set{tracking, deadband, i-clamped, slew-limited, saturated-freeze}, same cardinality as the old five.Mutations: drop the emit -> 2 red including the sweep (i.e. the widened tuple catches exactly the defect you found); emit it unconditionally -> 5 red; delete the
clampedline -> 1 red. 41 -> 43 checks.test_arbiter.py/test_maintenance.pyunchanged and passing. Grepped for consumers of the reason strings: none outsidecontrol.py, so today's impact really is observability only.Zero-boundary defect fixed in
53d301b, same branch.i_w == 0.0is now its own branch and both directions are allowed - nothing is wound, so "may not wind further" has no referent, and a first step from zero is bounded by the gain, the output clamp and the slew limit like any other step.One correction to the report, and it matters for re-test. The tie-break was NOT what made the command stick at 0 W in the quoted scenario. At
prev_w == 0with the inverter reporting -600 W,release/1.0holds 0 W forever too - verified side by side, same values cycle for cycle - because the output freeze deliberately forbids starting a charge while saturated ("commanding 0 while the inverter reports hundreds of watts means something else is driving the bus").sat_countpinning at 10 is likewise the counter saturating by design. After this fix the integrator moves to -1200 W but the command still holds at 0 W in that exact case, and there is now a test asserting that on purpose so nobody removes it by accident. If the intent is that the command should move there, that is a change torelease/1.0's output-freeze rule and wants its own ticket - it is a real safety rule, not a leftover.The defect is real regardless, and it does reach the wire where
prev_wis already charging: measured ati_w == 0.0and frozen, 12 800 of 25 920 ticks held the integrator, 8 304 of them changing the emitted command, worst case abandoning a 2 kW charge into a 4 kW export (-1000 Wwhererelease/1.0commands-2000 W).On "three of us missed the same half": the durable fix is a property, not more points. Over 13 041 frozen states the suite now asserts the integrator may be held ONLY by a correction pushing it further from zero on the side it already sits; any other hold fails, whichever side it is on. It catches the reported defect, its sign-mirror, and a freeze-everything-at-zero variant. Points would not have.
I also swept the suite for one-sided tests, per your note, and mirrored all of them: freeze wind/unwind while charging,
i_w=-100under import, the export-direction runaway, the negative clamp and the negative slew.Nothing else surfaced at that boundary: 128 160 frozen ticks swept across
i_wand grid, zero illegitimate holds remaining.DOCS now carries the cycles-vs-seconds deviation explicitly - the "> 10 s" criterion is not met as literally written, a cycle is one changed meter reading, no guaranteed wall-clock window, and why that is acceptable.
43 -> 55 checks, all passing;
test_arbiter.py18 andtest_maintenance.py21 unchanged. Mutations: reported defect restored -> 3 red; sign-mirrored (>= 0) -> 4 red including the equivalence sweep; freeze-everything-at-zero -> 6 red.Noted on the
i_clamped: boolcorrection - agreed, last-writer-wins makes the string unusable as an alarm hook, and SAFETY-03 owning the boolean is the right split.AC 3 now has a non-vacuous test -
389d9ec, same branch. The lead's diagnosis and their fix were both exactly right:TFlifted tomax_w=5000, all 55 stay green, and deleting the integrator freeze outright now fails 2 where it failed 0.I then swept the rest of the function for the same pattern rather than eyeballing it - delete one mechanism, count which checks notice - and it found one more instance: the output clamp.
clamped to max_wandclamped to -max_wwere both satisfied by the integrator bound truncating first, so deleting the output clamp failed only the reason-string check. Those two fixtures now setintegrator_max_wabovemax_w, so the mechanism each one names is the binding one. Output clamp: 1 failure -> 3.Final state, every mechanism in
compute()caught by a check that names it:max_wtarget_grid_wbiasi_w=NoneseedingNothing at zero.
A method warning that affects how you read any mutation figure from me, including earlier rounds. Rewriting
control.pyrepeatedly inside one second can leave a staleapp/__pycache__entry, and the suite then runs the OLD bytecode and under-reports. It bit me here: one mutation reported 2 failures where the true figure is 6. The audit now runs withPYTHONDONTWRITEBYTECODE=1and clears the cache first. The earlier rounds' numbers were taken from separate sequential runs with enough elapsed time between them, and you and the lead reproduced all of them independently, so they stand - but the hazard is real for anyone re-running this quickly in a loop.The audit script is not committed: it hardcodes exact source lines from
control.py, so it would go stale on the next edit and generate noise. It is a throwaway tool, ~60 lines, and I can commit it or hand it over if you would rather it lived in the repo.55 checks (unchanged - this round fixed what the existing checks actually test, it did not add any),
test_arbiter.py18,test_maintenance.py21, all passing.Audit recorded as a comment block in
test_control.py, next to the checks it describes -680461c. Script stays in my scratchpad; agreed on all three grounds, and the decomposition argument is the decisive one.The block carries the commit it was measured at (
389d9ec), the thirteen figures, and the invariant with the teeth in it: every mechanism incompute()must be noticed by at least two checks when deleted; adding a mechanism means re-running the audit. Plus the technique itself in prose, since there is no script to point at, and the two rules the misses produced - a fixture must sit clear of every rail it is not testing, and mutations run underpython -Bwithapp/__pycache__cleared.I re-measured the figure that differed rather than transcribing it. Both forms reproduce exactly here:
saturated_now = Falsegives 11, the weakerfrozen = Falsegives 10. Your read of the cause was right, and the table names the form so the next person reproduces the same one.Also wrote in your sharpening of the caching mechanism - invalidation on (source mtime in whole seconds, source size), so a same-second rewrite that preserves file size reuses stale bytecode - along with why it casts no doubt on the numbers: the error is one-directional, so every figure is a lower bound at worst, and the only two zeros ever recorded were both confirmed by fixing them and watching the count rise, which an artefact cannot do.
git diff 389d9ec..680461cistest_control.pyonly, 57 insertions, no deletions. 55 checks,test_arbiter.py18,test_maintenance.py21, all passing.Nothing pre-empted on the four sweep targets - they are the tester's.
View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.