Merge release/1.0 (OPT-04) into SAFETY-24, and fix one grader the merge broke
checks / unit (pull_request) Successful in 3m36s

OPT-04 landed after the previous rebase. Conflicts were CHANGELOG.md and one
IMPORT LINE in validate.py; both refusal blocks and both option sets survive
untouched, and DOCS.md, main.py and config.yaml auto-merged.

⚠️ AND THE MERGE BROKE A CHECK THAT NEITHER CARD COULD HAVE SEEN ALONE.
test_peakguard.py graded "the first `.decide(` call in main.py" and asserted
there was exactly one. That was true when SAFETY-24 was written; OPT-04 added
`arbitrage.decide(...)`, so the merged tree had two and the assertion failed.
It failed loudly rather than grading the wrong call - the guard did its job -
but it cannot ship red.

Fixed by selecting the call by QUALIFIED NAME (`peakguard.decide`) instead of
by position, and requiring exactly one of THAT. Other modules may now call
their own decide(); this grader can no longer be moved by one of them. Verified
still discriminating: a decoy `peakguard.decide` added to main.py turns it red
on 7 assertions, and the `maintenance_soc_floor` swap still turns it red on 6.

Also folded in the review's wording finding. An assertion read "a CONTROLLER
whose SoC entity does not answer hands decide() None" while driving no
Controller. The wiring property is stronger than the one claimed, and is now
asserted on the SERVICE CALL: a Controller with no SoC never reaches the guard
at all, because `inputs_ok` is False and cycle() stops at the safety claim
first - so a `float(soc or 100)` defaulting line above the call site cannot
disarm the floor. The original decide()-level check is kept, reworded to say
what it actually grades.

Full suite green on the merged tree: 15 files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
This commit is contained in:
glenn schrooyen
2026-08-31 07:39:40 +02:00
co-authored by Claude Opus 5
12 changed files with 1947 additions and 38 deletions
+61 -12
View File
@@ -1205,9 +1205,19 @@ check("...and the consequence is the wind-up part 1 demonstrates, now on the "
and not b3.peak_guard.saturated)
# Graded on the parsed call syntax too, so a rename cannot quietly reintroduce
# it: the keyword main.py passes must be the measured attribute.
_call = next(n for n in ast.walk(ast.parse(_mainsrc))
if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute)
and n.func.attr == "decide")
# ⚠️ SELECTED BY QUALIFIED NAME, NOT BY BEING FIRST. This took the first
# `.decide(` in main.py and asserted there was only one - which was true when
# SAFETY-24 was written and STOPPED BEING TRUE when OPT-04 added
# `arbitrage.decide(...)`. Grading "whichever .decide() the AST walk reaches
# first" is exactly the fragility a reviewer flagged on SAFETY-07: it grades a
# position, not the call. `peakguard.decide` is what this file is about, so name
# it, and require exactly one of THAT.
_peak_calls = [n for n in ast.walk(ast.parse(_mainsrc))
if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute)
and n.func.attr == "decide"
and isinstance(n.func.value, ast.Name)
and n.func.value.id == "peakguard"]
_call = _peak_calls[0]
_batt_src = ast.unparse(next(k.value for k in _call.keywords if k.arg == "batt_w"))
_soc_src = ast.unparse(next(k.value for k in _call.keywords if k.arg == "soc"))
print(f" main.py's decide(batt_w=...) is `{_batt_src}`, decide(soc=...) is "
@@ -1224,11 +1234,12 @@ check("...and `soc` is handed over as the MEASURED reading, bare. Not "
"a full battery is exactly the value that disarms SAFETY-24's floor, and "
"NOTES.md 4.4b records this project shipping that exact defaulting once",
_soc_src == "self.soc")
check("...and main.py still has exactly ONE `.decide(` call site, so grading "
"the first one grades the shipped one",
sum(1 for n in ast.walk(ast.parse(_mainsrc))
if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute)
and n.func.attr == "decide") == 1)
check(f"...and main.py has exactly ONE `peakguard.decide(` call site (it has "
f"{len(_peak_calls)}), so the call graded above is unambiguously the "
f"shipped one. Other modules may call their own decide() - OPT-04's "
f"arbitrage.decide() does - and that must not silently move which call "
f"this grader reads",
len(_peak_calls) == 1)
# ⚠️ AND WHICH KEY THE FLOOR COMES FROM, GRADED ON THE SOURCE. A mutation that
# swapped `peak_soc_floor` for `maintenance_soc_floor` SURVIVED every
# behavioural check in this file, because the two defaults are close enough that
@@ -1649,11 +1660,49 @@ check("...and it reaches the action log too", any("FLOOR-LIMITED" in e
d_nosoc = decide(p_hungry, batt_w=0.0, cap_w=CAP_W, reading=None,
achievable_w=LIVE_MAX_W, max_hold_s=MAX_HOLD_S,
soc=None, soc_floor=SOC_FLOOR)
check("and a Controller whose SoC entity does not answer hands decide() None, "
"which is floor-limited rather than a full battery - the whole "
"'a failed read is not an observation' rule, on the one input where the "
"disarming value is the plausible one",
check("decide() handed soc=None answers floor-limited rather than a full "
"battery - the whole 'a failed read is not an observation' rule, on the "
"one input where the disarming value is the plausible one",
d_nosoc.floor_limited and not d_nosoc.saturated)
# ⚠️ REVIEW-FOUND, AND THE CLAIM ABOVE USED TO BE WIDER THAN ITS CHECK. It read
# "a CONTROLLER whose SoC entity does not answer hands decide() None" while
# calling decide() directly and driving no Controller - so a reviewer could
# insert `self.soc = 100.0 if self.soc is None else self.soc` immediately above
# main.py's decide() call, which is the exact `float(soc or 100)` defaulting
# discharge_permitted()'s own docstring forbids, and every check here stayed
# green. The wiring property is STRONGER than the one that was claimed, and this
# is it: a Controller with no SoC never reaches the guard at all, because
# `inputs_ok` is False and cycle() returns at the safety stop first. Graded on
# the SERVICE CALL - decide() is spied - rather than on its arguments, so a
# defaulted value cannot satisfy it.
_nosoc_ctl, _nosoc_hass = built(TMP / "nosoc.db")
_nosoc_ctl.auto, _nosoc_ctl.soc, _nosoc_ctl.batt = True, None, 0.0
_nosoc_ctl.dev_min, _nosoc_ctl.dev_max = -LIVE_MAX_W, LIVE_MAX_W
_nosoc_now = datetime.now(timezone.utc)
_nosoc_blk = datetime.fromtimestamp(
int(_nosoc_now.timestamp() // QUARTER_S) * QUARTER_S, timezone.utc)
for _i in range(0, int((_nosoc_now - _nosoc_blk).total_seconds()) + 1, 5):
_nosoc_ctl.p1.submit(sample(_nosoc_blk + timedelta(seconds=_i), CAP_W * 2))
_nosoc_ctl.grid = CAP_W * 2
_nosoc_seen = []
_nosoc_real = peakguard.decide
try:
peakguard.decide = lambda *a, **k: (_nosoc_seen.append(k.get("soc")),
_nosoc_real(*a, **k))[1]
asyncio.get_event_loop_policy().new_event_loop().run_until_complete(
_nosoc_ctl.cycle())
finally:
peakguard.decide = _nosoc_real
print(f" a Controller with no SoC: inputs_ok={_nosoc_ctl.inputs_ok}, "
f"decide() called {len(_nosoc_seen)} time(s), target "
f"{_nosoc_ctl.target:+.0f} W")
check("and a Controller whose SoC entity does not answer never reaches the "
"guard AT ALL - `inputs_ok` is False, cycle() stops at the safety claim, "
"and decide() is not called. A defaulting line above the call site "
"therefore cannot disarm this floor, which is a stronger guarantee than "
"handing decide() a None would be",
not _nosoc_ctl.inputs_ok and not _nosoc_seen
and abs(_nosoc_ctl.target) < 1e-9 and _nosoc_ctl.peak_guard is None)
# --- the startup refusal, in CFG-01 / MAINT-04's manner ----------------------
def opts24(**kw):