ARB-03: pin all eight actuator call sites statically
checks / unit (pull_request) Successful in 3m45s
checks / unit (pull_request) Successful in 3m45s
The independent review of PR #40 found AC 6 met at three of the eight claim construction sites while the check claimed to cover the class - this repository's named failure mode, a check observing something other than what it claimed, reproduced inside the check written to prevent it. WHAT WAS WRONG. Part 10g's only call-site check was a real two-inverter cycle. That cycle is auto, P1-healthy, no maintenance procedure, no tripped sign watchdog, the guard idle and arbitrage off, so it reaches `device`, `supervised` and `loop` and nothing else - three of the eight direct appends in `Controller.cycle()`. Its own check text nevertheless read "the line a mis-typed `actuator_of(...)` at a call site goes red on", and the PR body's mutation table presented it as covering the class. The reviewer proved the gap on the shipped tree: `actuator_of("maintenance")` -> `actuator_of("peak")` at the maintenance-shaping append left test_arbiter.py, test_peakguard.py and test_arbitrage.py ALL GREEN, while at a roster of two it built a site loop of -1500 W against the guard's site `lo` of 1500 W and the now-misrouted charge-only `hi = 0` - `lo > hi`, arbiter clause 3, 0 W IN THE BILLED QUARTER, SAFETY-25 §0a's fatal case from a one-word typo. The same mutation at the `failsafe` append survived too. The runtime detector does fire - `scope_violations()` names it - but it only logs; nothing graded it. THE FIX, over machinery already present. Part 10f's AST scan already reads each `claims.append(...)` in `Controller.cycle()`. All eight direct appends expose their actuator statically as `actuator=self.actuator_of('<literal>')`, so 10g now requires that literal to EQUAL the source the claim is filed under, read as SOURCE TEXT rather than evaluated - `actuator_of("peak")` at the maintenance append is the same VALUE at the shipped roster of one and a different string here. That pins all eight routing sites, on the line the typo is written, in every configuration and at every roster, exactly the way 10f pins the source set. Two further lines keep the scan from going vacuous: every direct append must be readable in that shape (a computed or hand-written actuator fails there), and the ten appends must still account for 10f's eight sources. The two INDIRECT appends carry no `actuator=` at the call site to mis-type. `peak_claim` and `arb_claim` are routed where their claim is built, and are pinned there - `peakguard.claim()` by the SWEEP_SITE sweep over every load x elapsed x battery x SoC x floor combination, `arbitrage.claim()` by test_arbitrage.py. 10g now says so at the line, and says which three of the eight its dynamic cycle actually sees, rather than rounding up. MEASURED, NOT ARGUED. Each of the eight direct appends mutated in turn to a wrong actuator - device->loop, supervised->peak, failsafe->safety, safety(stopped)->device, safety(inputs missing)->maintenance, maintenance(SET)->loop, maintenance(shaping)->peak, loop->supervised - each on a purged tree via tools/mutation_run.sh over all 17 files. All eight RED, every one of them on the new line. TWO SURVIVING MUTATIONS OF THE SAME CLASS, ALSO CLOSED. * `resolve()` did not have to honour the roster it was given: `if units is None:` -> `if True:` left the whole suite green. Ignoring the argument makes a unit that filed nothing this cycle cease to exist - a site of two reads as a site of one and stage 3 passes the whole grid target through to the inverter that spoke, which is ARCH-01's fault with its own evidence removed. test_arbiter.py part 7 now grades it on the one case that separates the two, a SILENT unit. Mutation RED in test_arbiter.py and test_peakguard.py. * main.py's N>=2 refusal was unreachable and ungraded: `if not resolution.allocated:` -> `if False:` left the whole suite green, so the right instinct was one refactor away from being deleted as dead code. The guard is KEPT - the alternative default is the ARCH-01 fault - and new part 10h grades it on the two-inverter cycle that already existed: the 0 W, the `failsafe` winner, the log line naming the roster size, and the write that follows. Mutation RED. AND THE WORDING CORRECTED. main.py does not "refuse to write" on `allocated=False`. It SUBSTITUTES a 0 W failsafe and then calls `write_setpoint(0.0)` like any other cycle. 0 W is the correct fail-toward-inaction direction and the log text is accurate about what it refuses - to BROADCAST the site target - but "refuses to write" overstates the code. Said as what it does, in the CHANGELOG, at the branch in main.py, and in 10h's own check text. `resolve()`'s docstring now says the roster argument has to be HONOURED, which is not the same as existing. 17/17 green on a purged tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
06f95e0a64
commit
38f6680639
@@ -2563,27 +2563,169 @@ check("...and the roster it was restored to is the shipped one, so nothing "
|
||||
# IT RIGHT. `actuator_of("maintenance")` written at the `device` append routes a
|
||||
# rating limit to whatever `maintenance` routes to, and at the shipped
|
||||
# one-inverter roster BOTH answers are the site, so nothing above can see it.
|
||||
# The only place it becomes observable is a cycle at a roster of two - so take
|
||||
# one, on real claims, spied at main.py's own resolve.
|
||||
# ⚠️ AND THE DYNAMIC CYCLE BELOW SEES ONLY THREE OF THE EIGHT DIRECT APPENDS.
|
||||
# That was THIS PART committing this repository's own named failure - a check
|
||||
# observing something other than what it claimed - inside the check written to
|
||||
# prevent it. The first cut of 10g called the two-inverter cycle "the line a
|
||||
# mis-typed `actuator_of(...)` at a call site goes red on", full stop. IT IS
|
||||
# NOT. That cycle is auto, P1-healthy, no maintenance procedure, no tripped sign
|
||||
# watchdog, the guard idle and arbitrage off, so it reaches `device`,
|
||||
# `supervised` and `loop` AND NOTHING ELSE. PR #40's reviewer mutated the other
|
||||
# five appends one at a time and every suite in this repository stayed GREEN,
|
||||
# this part included. At the maintenance-shaping append,
|
||||
# `actuator_of("maintenance")` -> `actuator_of("peak")` then produced, at a
|
||||
# roster of two, a site loop of -1500 W against the guard's site `lo` of 1500 W
|
||||
# and the now-misrouted charge-only `hi = 0`: `lo > hi`, arbiter clause 3, 0 W
|
||||
# IN THE BILLED QUARTER - SAFETY-25 §0a's fatal case, built by a one-word typo
|
||||
# under a green suite. The same mutation at the `failsafe` append survived too.
|
||||
#
|
||||
# So THE CALL SITES ARE PINNED STATICALLY, over 10f's own AST scan of
|
||||
# `Controller.cycle()` and for 10f's reason: a static pin sees every append on
|
||||
# the line it is written, in every configuration, whether or not any fixture
|
||||
# here reaches the state that files it. All eight direct appends expose their
|
||||
# actuator as a LITERAL - `actuator=self.actuator_of('<source>')` - so the
|
||||
# literal is simply required to EQUAL the source the claim is filed under. That
|
||||
# is what makes "a mis-typed `actuator_of(...)` goes red" a true sentence about
|
||||
# all eight rather than about the three a fixture happens to reach.
|
||||
|
||||
|
||||
def append_actuator(node):
|
||||
"""The actuator expression a DIRECT append routes its claim through.
|
||||
|
||||
⚠️ RETURNED AS SOURCE TEXT, NOT EVALUATED, and that is the whole point.
|
||||
`self.actuator_of('device')` and `self.actuator_of('maintenance')` are the
|
||||
SAME VALUE at the shipped roster and two different routings; only the text
|
||||
written at the call site tells them apart, which is exactly why every
|
||||
dynamic check in this file is blind to the swap at N=1.
|
||||
"""
|
||||
kw = [k for k in node.keywords if k.arg == "actuator"]
|
||||
if len(kw) != 1:
|
||||
return None
|
||||
return ast.unparse(kw[0].value)
|
||||
|
||||
|
||||
# The appends that BUILD their claim in place - the eight whose routing is
|
||||
# chosen by hand, and the only ones where a typo is possible - against the two
|
||||
# that append a claim built elsewhere in the method and carry no `actuator=`
|
||||
# here at all.
|
||||
_direct = [n.args[0] for n in _appends if isinstance(n.args[0], ast.Call)]
|
||||
_indirect = [n.args[0].id for n in _appends if isinstance(n.args[0], ast.Name)]
|
||||
_routing = sorted(((c.args[0].value, append_actuator(c), c.lineno)
|
||||
for c in _direct), key=lambda r: r[2])
|
||||
_misrouted = [r for r in _routing if r[1] != f"self.actuator_of({r[0]!r})"]
|
||||
print(f" {len(_direct)} direct appends, each read at its own line: "
|
||||
+ ", ".join(f"main.py:{ln} {s} -> {a}" for s, a, ln in _routing))
|
||||
check("all eight of `Controller.cycle()`'s DIRECT appends route through "
|
||||
"`self.actuator_of(<string literal>)`, so this scan can read the routing "
|
||||
"of EVERY one of them. An append that computed its actuator, or omitted "
|
||||
"the argument, or hand-wrote a string, fails HERE rather than being "
|
||||
"silently skipped - which is how a scan like this goes quietly vacuous, "
|
||||
"and 10f is in this file because that already happened once",
|
||||
len(_direct) == 8
|
||||
and all(a is not None and a.startswith("self.actuator_of('")
|
||||
for _s, a, _ln in _routing))
|
||||
check("⚠️ AND AT EVERY ONE OF THE EIGHT THE LITERAL IT ROUTES THROUGH IS THE "
|
||||
"SOURCE THE CLAIM IS FILED UNDER. THIS is the line a mis-typed "
|
||||
"`actuator_of(...)` at a call site goes red on - all eight of them, on "
|
||||
"the line the typo is written, at every roster, INCLUDING the five "
|
||||
"appends no fixture in this repository reaches. "
|
||||
"`actuator_of(\"peak\")` at the maintenance append is a different string "
|
||||
"here even though it is the same VALUE at the shipped roster of one",
|
||||
_misrouted == [])
|
||||
check("...and the only appends this pin does NOT reach are the two that file a "
|
||||
"claim BUILT ELSEWHERE - `peak_claim` and `arb_claim` - which have no "
|
||||
"`actuator=` at the call site to mis-type. Their routing is pinned where "
|
||||
"it is CHOSEN instead: peakguard.claim() by the SWEEP_SITE sweep above, "
|
||||
"over every load x elapsed x battery x SoC x floor combination, and "
|
||||
"arbitrage.claim() by test_arbitrage.py",
|
||||
sorted(_indirect) == sorted(CLAIM_BUILDERS)
|
||||
and len(_direct) + len(_indirect) == len(_appends))
|
||||
check("...and between them the ten appends account for exactly the eight "
|
||||
"sources 10f pins, so the routing pin and the source set are one scan "
|
||||
"and neither can drift from the other",
|
||||
{s for s, _a, _ln in _routing} | set(CLAIM_BUILDERS.values())
|
||||
== CLAIM_SOURCES)
|
||||
|
||||
# THE DYNAMIC HALF, kept, and now stated for what it actually observes: a real
|
||||
# cycle proving the routing reaches the arbiter THROUGH main.py rather than only
|
||||
# through this file's reading of main.py. It grades three of the eight appends.
|
||||
# The static pin above is what covers the other five.
|
||||
c_src.units = ("inverter-a", "inverter-b")
|
||||
_n2_writes_before = len(c_src.hass.writes)
|
||||
try:
|
||||
_, _cl2 = cycle_claims(lambda: run_cycle(c_src))
|
||||
_n2_target, _n2_reason = c_src.target, c_src.reason
|
||||
_n2_events = list(c_src.events)
|
||||
_n2_writes = c_src.hass.writes[_n2_writes_before:]
|
||||
_n2_last = c_src.last_write
|
||||
finally:
|
||||
c_src.units = (app_main.UNIT_A,)
|
||||
print(" a REAL two-inverter cycle files: "
|
||||
+ ", ".join(f"{c.source}@{c.actuator}" for c in _cl2))
|
||||
check("a real cycle at a roster of TWO partitions its own claims correctly - "
|
||||
"every claim main.py filed is on an actuator its source's scope permits. "
|
||||
"This is the line a mis-typed `actuator_of(...)` at a call site goes red "
|
||||
"on, and it is the only place one can be seen: at N=1 every source "
|
||||
"legitimately routes to the site, so a swap there is invisible by "
|
||||
"construction", len(_cl2) >= 3 and scope_violations(_cl2) == [])
|
||||
"every claim main.py filed is on an actuator its source's scope permits, "
|
||||
"observed on the list main.py's own `resolve` was handed. ⚠️ AND THIS "
|
||||
"SEES THREE OF THE EIGHT APPENDS, NOT THE CLASS: this state files "
|
||||
"`device`, `supervised` and `loop`, so it is the static pin above and not "
|
||||
"this line that covers `failsafe`, `safety` and the two `maintenance` "
|
||||
"appends", len(_cl2) >= 3 and scope_violations(_cl2) == [])
|
||||
check("...and the partition is the expected one on the sources this state "
|
||||
"reaches: the two rating limits bind the unit, the loop claim stays on "
|
||||
"the grid connection",
|
||||
{c.source: c.actuator for c in _cl2}
|
||||
== {"device": "inverter-a", "supervised": "inverter-a",
|
||||
"loop": ACTUATOR_SITE})
|
||||
check("...and naming those three is not an accident of this fixture but the "
|
||||
"reason the static pin exists - five of the eight sources need a state "
|
||||
"no check in this file reaches",
|
||||
{c.source for c in _cl2}
|
||||
== CLAIM_SOURCES - {"failsafe", "safety", "maintenance", "peak",
|
||||
"arbitrage"})
|
||||
|
||||
|
||||
# --- 10h. ARB-03: AND WHAT MAIN.PY DOES WITH `allocated=False` --------------
|
||||
# ⚠️ AN UNREACHABLE BRANCH THAT NOTHING GRADED. The roster ships as one unit, so
|
||||
# `allocate()` never declines in production and no fixture went looking: PR
|
||||
# #40's reviewer replaced main.py's `if not resolution.allocated:` with
|
||||
# `if False:` and the whole 17-file suite stayed GREEN. The branch is the right
|
||||
# instinct - the alternative default is the fault ARCH-01 was filed about, N
|
||||
# inverters each supplying the site target IN FULL, a permanent limit cycle at
|
||||
# the meter with every §11.2 watchdog reporting healthy - and an ungraded right
|
||||
# instinct is one refactor away from being deleted as dead code. The
|
||||
# two-inverter cycle above IS that state, so it is graded on that cycle rather
|
||||
# than with a fixture of its own.
|
||||
#
|
||||
# ⚠️ AND WHAT IT DOES IS SUBSTITUTE, NOT ABSTAIN. main.py does not skip the
|
||||
# write. It replaces the resolution with a 0 W `failsafe` and then calls
|
||||
# `write_setpoint(0.0)` like any other cycle. 0 W is the correct
|
||||
# fail-toward-inaction direction and the log line is accurate about what it
|
||||
# refuses ("refusing to broadcast the site target"), but "main.py refuses to
|
||||
# write" would be the same overclaim this part just took out of 10g, so it is
|
||||
# graded as what it is: a substitution followed by a write.
|
||||
print(f" at N=2 main.py resolved to {_n2_target:+.0f} W, reason "
|
||||
f"{_n2_reason!r}, wrote {_n2_writes}")
|
||||
print(" and logged: "
|
||||
+ next((e for e in _n2_events if "ARBITER" in e), "(nothing)"))
|
||||
check("with two inverters and no allocator main.py does NOT hand the site "
|
||||
"target to the roster. The resolution it acts on is the 0 W failsafe it "
|
||||
"substitutes, and `explain()` says so - this is the branch a `if False:` "
|
||||
"deleted with the suite still green",
|
||||
_n2_target == 0.0 and "failsafe" in _n2_reason)
|
||||
check("...and it says WHY, in the action log, in the operator's own words - "
|
||||
"the refusal is to BROADCAST, and it names the roster size that caused it",
|
||||
any("refusing to broadcast the site target" in e and "2 inverters" in e
|
||||
for e in _n2_events))
|
||||
check("...and then it WRITES that 0 W, on the same line as every other cycle. "
|
||||
"It is a substitution, not an abstention: the setpoint is commanded to "
|
||||
"zero rather than left wherever the last cycle put it, which is the "
|
||||
"fail-toward-inaction direction and the opposite of leaving a stale "
|
||||
"command standing", _n2_writes == [0.0] and _n2_last == 0.0)
|
||||
check("...and the 0 W is NOT what the site target happened to be - resolve the "
|
||||
"same claims at a roster of ONE, where the seam passes through, and a "
|
||||
"real number comes back. That is what says the branch does work rather "
|
||||
"than agreeing with the resolution by luck",
|
||||
resolve(_cl2, units=("inverter-a",)).allocated is True
|
||||
and resolve(_cl2, units=("inverter-a",)).target_w != 0.0)
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
|
||||
Reference in New Issue
Block a user