SAFETY-38 round 3: pin the banner the SHIPPED install actually prints
checks / unit (pull_request) Failing after 3s

Round 2 pinned the operand order of `SignWatchdog.evaluate()` at main.py:1620 -
the SITE detector. Part 9c asserts that detector is never armed at a roster of
one, so on every install this add-on has, the PER-UNIT banner is the only
sign-disagreement sentence that can ever be printed, and its call site is a
different line. main.py:1560 was left unpinned.

1. THE SAME ZERO-BYTE DEFECT ONE CALL SITE OVER.
   `evaluate(stood, against, ...)` -> `evaluate(against, stood, ...)`:
   app/main.py 234 542 bytes before and after, delta 0, fingerprint
   61aa413f... -> e57e6026..., bytecode purged, tree mounted :ro, line re-read
   from disk. On the round-2 tree the FULL 21-FILE SUITE PASSES.

   Nothing behavioural could ever have caught it. `watchdog.disagrees()` is
   SYMMETRIC in its first two arguments - `commanded_w * against_w < 0` with an
   abs() gate on both - so the mutant takes identical actions on every input.
   Not "no fixture happens to separate them": no fixture CAN.

   What moves is the sentence. Measured by diffing the suite's own printed
   banners between the round-3 tree and the same tree carrying only this edit:
   9 emitted banner lines invert, in 3 distinct sentences.

     shipped  commanded -2000 W against a meter reading +2000 W
     swapped  commanded +2000 W against a meter reading -2000 W
     shipped  commanded +1000 W against its own measured power -1000 W
     swapped  commanded -1000 W against its own measured power +1000 W
     shipped  commanded -2000 W against a meter reading +4000 W   (the N=1 arm)
     swapped  commanded +4000 W against a meter reading -2000 W

   Persisted to `sign_reason` and re-read after the restart the latch exists to
   survive; also the action log, `_LOG.error` and `status_payload()`'s headline.

   CLOSED by the ORDERED PAIR on both per-unit banners, both operands read off
   the OBSERVED CYCLE - `inv.last_write` and `inv.batt` off the objects the
   plant moved, never off the arguments the detector was handed, which would
   compare the swap against itself:
     - part 3, the N>=2 trip: "commanded {stood:+.0f} W against its own
       measured power {meas:+.0f} W", plus the persisted namespaced copy;
     - part 7, the N=1 controller-driven trip: "commanded {stood:+.0f} W
       against a meter reading {grid:+.0f} W", plus the namespaced key AND the
       flat rollback mirror;
     - a PRECONDITION on each that the two operands are different numbers of
       opposite sign - without it a transposition produces the same string and
       the check is decoration.

   Measured after the fix: RED on four named checks in test_watchdog.py, GREEN
   in the other twenty files. M11 (the site swap) re-measured on this tree:
   still red. Round 1's and round 2's kills are intact.

2. A FALSE STATEMENT ROUND 2 INTRODUCED, AND IT WAS WORSE THAN REPORTED.
   Section 4 said "three of these left the whole suite green - M1, M2 and M3 -
   and the remaining seven were caught by SAFETY-30's own N>=2 checks". Round
   1's vaguer wording was harmless; naming three made it specific and wrong.

   Re-measured mutation by mutation on clean release/1.0 @ bbc903b, each on a
   fresh copy, bytecode purged, byte delta and fingerprint printed, FULL 21-FILE
   SUITE each time:

     M1  +24 B  fp 362374cf...  21/21 PASS - SURVIVOR
     M2   +9 B  fp 86573e74...  21/21 PASS - SURVIVOR
     M3   +4 B  fp 59fa8815...  21/21 PASS - SURVIVOR
     M4   +9 B  fp 43d852be...  21/21 PASS - SURVIVOR
     M5  +16 B  fp 58d26de8...  RED  test_watchdog.py, 3 checks
     M6   +1 B  fp 407ddb5f...  RED  test_watchdog.py, 3 checks
     M7  -11 B  fp 2a635a4e...  21/21 PASS - SURVIVOR
     M8   +6 B  fp b21eed89...  21/21 PASS - SURVIVOR
     M9   +6 B  fp 0973d3ca...  21/21 PASS - SURVIVOR
     M10  +7 B  fp 0e7b7a4f...  21/21 PASS - SURVIVOR

   EIGHT survived, not three - and not the four a reviewer proposed either.
   The corrected sentence is in DECISIONS section 4.

   AND THE TWO THAT WERE CAUGHT MAKE THE CARD'S CASE. M5 (`sum(abs ...)`) and
   M6 (`-sum`) are the only two entries that reverse the SIGN. Every other one
   changes only the MAGNITUDE, and `_sign_site` - the suite's only N>=2 site
   fixture before this card - runs shares of about -2500 W each against a 320 W
   gate, so sum / mean / max / min / sorted[-1] / [0] / self.target / half /
   double land on -2500, -2500, -2500, -2500, -2500, -2500, -2500, -1250 and
   -5000 W: nine numbers, ALL ON THE SAME SIDE OF THE SAME THRESHOLD. The
   pre-existing coverage could see a sign and nothing else.

3. THEN THE THING THAT WOULD HAVE CAUGHT BOTH: the SYMMETRIC-PREDICATE FAMILY,
   enumerated by machine rather than by hand for a third time.
   `tools/symmetric_message_scan.py` renames each parameter pair, erases every
   formatted string, canonicalises the commutative operators and iterates to a
   FIXPOINT - the symmetry is usually one call deep, and a single pass reports
   an EMPTY family on a tree whose answer is already known. A second census
   covers sentences the CALLER builds. Both print their own blind spots,
   including the one way the scan OVER-reports.

   It found one open survivor this card does not close and now discloses:
   rail_unreachable()'s dev_min/dev_max at main.py:3208 - 0 bytes, 21/21 green,
   a status-page NOT COVERED row - and twelve unpinned caller-built sentences,
   including SAFETY-31's `circ_reason`, which is the same shape and is also
   persisted and re-read after a restart. DECISIONS section 7 has the list, the
   pinned status of every member, and what the instrument cannot see.

No app/ code changes. MUTATION.md gains habit 15.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
2026-09-05 20:33:20 +02:00
co-authored by Claude Opus 5
parent 695da4791e
commit 97b59438dc
5 changed files with 1092 additions and 8 deletions
+93 -3
View File
@@ -1508,6 +1508,50 @@ check("⚠️ AC 1. THE REVERSED UNIT TRIPS AND ITS SIBLING DOES NOT. The eviden
check("...and the reason names the instrument, so the operator is sent to the "
"right one", AGAINST_OWN_POWER in _b30.sign_wd.reason)
print(f" {_b30.sign_wd.reason!r}")
# ⚠️ THE LAST CYCLE BEFORE B LATCHED, NAMED RATHER THAN INDEXED - part 5's
# lesson and 9a's. After the trip B's share is 0 W and its measured power
# follows it down, so a fixed index reads the REMEDY instead of the evidence.
# Both operands are read off THIS OBSERVED CYCLE - `last_write` and `batt` off
# the objects - and not off the fixture's constants, so the check cannot be
# satisfied by two numbers the fixture happens to hold anyway (habit 2).
_preb30 = [x for x in _s30 if not x["b"]]
_midb30 = _preb30[-1]
print(f" last cycle before B latched: B stood "
f"{_midb30['stood'][1]:+.0f} W, B measured {_midb30['meas'][1]:+.0f} W")
check("⚠️ ...AND THE ORDERED PAIR, on the ONE SIGN-DISAGREEMENT SENTENCE A "
"SHIPPED INSTALL CAN EVER PRINT. Part 9a pins the SITE banner's operand "
"order at main.py:1620; that call site is armed only at N>=2 and part 9c "
"asserts it is ABSENT at a roster of one, so on the shipped install the "
"PER-UNIT banner is the only one there is - and its call site is a "
"different line. Swapping the first two arguments at main.py:1560 - "
"`evaluate(stood, against, ...)` -> `evaluate(against, stood, ...)` - is "
"a ZERO-BYTE edit that changes NO trip decision, because `disagrees()` "
"is symmetric in those two arguments (`commanded_w * against_w < 0` with "
"an abs() gate on both). Nothing behavioural can see it and the full "
"21-file suite stayed green. What it changes is the SENTENCE: 'commanded "
"-1000 W against its own measured power +1000 W', the exact opposite of "
"which instrument read what, on the line somebody woken by this alarm "
"reads first - and it is persisted to `sign_reason` and re-read after a "
"restart. Graded on the CONTIGUOUS LITERAL with each value in its own "
"slot, per MUTATION.md habit 14: an `in` test on a number grades "
"PRESENCE, never ARRANGEMENT",
f"commanded {_midb30['stood'][1]:+.0f} W against its own measured power "
f"{_midb30['meas'][1]:+.0f} W" in _b30.sign_wd.reason)
check("PRECONDITION for the line above, and without it that check is "
"decoration: the two operands are DIFFERENT NUMBERS and OPPOSITE IN "
"SIGN, so transposing them really does produce a different string. A "
"cycle where B's share and B's measured power were equal, or where "
"either was 0 W, would make the swapped sentence identical to the "
"correct one and the assertion could not reach the failure it names",
_midb30["stood"][1] != _midb30["meas"][1]
and _midb30["stood"][1] * _midb30["meas"][1] < 0
and f"{_midb30['stood'][1]:+.0f}" != f"{_midb30['meas'][1]:+.0f}")
check("...and the SAME ordered pair is what an operator finds AFTER A RESTART, "
"because `sign_reason` is the persisted copy of this string. The swap "
"survives the container restart the same way the latch does",
f"commanded {_midb30['stood'][1]:+.0f} W against its own measured power "
f"{_midb30['meas'][1]:+.0f} W"
in str(_c30.store.data.get(unit_key("inverter-b", "sign_reason"), "")))
# ⚠️ READ OFF THE ALLOCATION RECORD, NOT OFF `Controller.reason`. At a roster
# of two `reason` is the SITE resolution's line - "loop -> 2000 W" - because
# B's failsafe is a UNIT claim resolved on B's own actuator and never competes
@@ -1764,11 +1808,20 @@ async def _n1_sign():
return _orig_eval(*a, **kw)
c.site_sign_wd.evaluate = _spy
await _drive(c, n=12, dt=0.2)
return c, _n1_calls
# ⚠️ THE WATCH READS THE CONTROLLER'S OWN STATE, NOT `_spy`'s ARGUMENTS.
# `_n1_calls` records what was HANDED to a detector, so grading the banner
# against it would compare the swap against itself - the DEV-02 defect.
# `last_write` and `c.grid` are read off the objects the plant moved.
seen = await _drive(c, n=12, dt=0.2, watch=lambda c: dict(
grid=c.grid,
stood=(c.inverters[0].last_write
if c.inverters[0].last_write is not None
else c.inverters[0].target),
tripped=c.inverters[0].sign_wd.tripped))
return c, _n1_calls, seen
_c1s, _n1_site_calls = asyncio.run(_n1_sign())
_c1s, _n1_site_calls, _seen1s = asyncio.run(_n1_sign())
_inv1 = _c1s.inverters[0]
print(f" roster={_c1s.units} armed against {_inv1.sign_wd.against!r} "
f"at {_inv1.sign_wd.cfg.min_w:.0f} W / {_inv1.sign_wd.cfg.hold_s:.0f} s")
@@ -1786,6 +1839,43 @@ check("...the reason sentence still says 'a meter reading', word for word - it "
"is what an operator reads, what the action log carries and what is "
"persisted, so it is graded on the literal",
"against a meter reading" in _inv1.sign_wd.reason)
# The last cycle before the latch, named rather than indexed: afterwards the
# failsafe stands 0 W and the plant's meter relaxes, so a fixed index reads the
# remedy. Both operands come off the controller, not off the fixture's options.
_pre1s = [x for x in _seen1s if not x["tripped"]]
_mid1s = _pre1s[-1]
print(f" last cycle before the latch: stood {_mid1s['stood']:+.0f} W, "
f"meter {_mid1s['grid']:+.0f} W")
print(f" {_inv1.sign_wd.reason!r}")
check("⚠️ ...AND THE ORDERED PAIR ON THE SHIPPING CALL SITE. This is the SAME "
"zero-byte defect part 9a closed at main.py:1620, one call site over at "
"main.py:1560 - and this is the one a one-inverter install actually "
"reaches, since the site detector is never armed here (asserted below). "
"`evaluate(stood, against, ...)` -> `evaluate(against, stood, ...)` "
"changes no trip decision - `disagrees()` is symmetric in those two "
"arguments - and inverts the banner to 'commanded +4000 W against a "
"meter reading -2000 W': the loop is standing at -2000 W of charge INTO "
"a +4000 W import and the latching banner says the opposite. The "
"substring check above passes under the swap, because both numbers are "
"still in the sentence; only the ORDERED PAIR can tell the slots apart "
"(MUTATION.md habit 14)",
f"commanded {_mid1s['stood']:+.0f} W against a meter reading "
f"{_mid1s['grid']:+.0f} W" in _inv1.sign_wd.reason)
check("PRECONDITION: the two operands are DIFFERENT NUMBERS and OPPOSITE IN "
"SIGN on that cycle, so a transposition genuinely produces a different "
"string. Equal or zero operands would make the swapped banner identical "
"and the check above vacuous",
_mid1s["stood"] != _mid1s["grid"]
and _mid1s["stood"] * _mid1s["grid"] < 0
and f"{_mid1s['stood']:+.0f}" != f"{_mid1s['grid']:+.0f}")
check("...and the persisted copy carries the same ordered pair, under BOTH the "
"namespaced key and the flat rollback mirror - which is what an operator "
"reads after the restart the latch is designed to survive",
f"commanded {_mid1s['stood']:+.0f} W against a meter reading "
f"{_mid1s['grid']:+.0f} W" in str(_c1s.store.data.get(REASON_KEY, ""))
and f"commanded {_mid1s['stood']:+.0f} W against a meter reading "
f"{_mid1s['grid']:+.0f} W"
in str(_c1s.store.data.get("sign_reason", "")))
check("...the latch is under the same namespaced key with the same flat "
"rollback mirror, and SAFETY-30's site pair is untouched at its default. "
"A one-inverter install's state file is byte-identical to what it was",