tools: submenu sweep gets a cursor-region rule and a two-sided self-test

Sweep 1 failed because ring_row scans the MAIN MENU's gutter and these screens put
their cursors elsewhere; sweep 2 failed because a whole-frame identity test cannot
match once a crash dialog covers the centre. This rework fixes both: the decision
uses the region that CHANGED when the cursor moved, so no per-screen geometry is
assumed, and the back-on-the-menu test is the narrow ring row, which is what kept
reading correctly under the dialog.

The self-test is sylpheed-port's sharpened rule -- a control must construct the
failure it is named after. Mine was one-sided: I checked only that the rule reports
RESETS on a known-RESETS triple, so a rule biased entirely to RESETS would have
passed. It now constructs both verdicts from the same frames and exits 3 if either
is wrong.

Committed BEFORE running, which is the process fix: the previous run aborted
because I was editing and deliberately breaking this file while its own run was in
flight, and the sweep read the broken version.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
This commit is contained in:
sylph-decoder
2026-08-31 01:00:56 +00:00
parent 292533e3fd
commit 732907e5e1

View File

@@ -105,6 +105,46 @@ def wait_until(pred, what, limit=60):
os.makedirs(OUT, exist_ok=True)
# ── SELF-TEST: the decision rule must CONSTRUCT both of its verdicts ──────────
# sylpheed-port's rule, after a control of theirs carried the right NAME over the
# wrong filter: a control must construct the failure it is named after. Mine was
# one-sided -- I checked only that the rule reports RESETS on a known-RESETS
# triple, so a rule biased entirely to RESETS would have passed. Both directions
# are constructed here from the SAME frames, and the EXIT CODE is the assertion:
# printing a verdict is not asserting it.
def _verdict(a, b, c):
cur = np.abs(a - b).max(axis=2) > 24
if cur.sum() == 0:
return "NO-CURSOR"
d1 = float(np.abs(c - a).max(axis=2)[cur].mean())
d2 = float(np.abs(c - b).max(axis=2)[cur].mean())
return "RESETS" if d1 < d2 * 0.5 else "PERSISTS" if d2 < d1 * 0.5 else "UNDECIDED"
def _self_test():
ref = os.environ.get("SWEEP_SELFTEST_DIR", "/sylph-home/re/extrasfocus")
try:
A, B, C = [np.asarray(Image.open(f"{ref}/{f}.png").convert("RGB"), dtype=int)
for f in ("E1", "E2", "E3")]
except Exception as e:
print(f"🔴 SELF-TEST UNAVAILABLE ({e}) — refusing to run", flush=True)
sys.exit(3)
bad = 0
for nm, args, want in (("known RESETS (real EXTRAS triple)", (A, B, C), "RESETS"),
("constructed PERSISTS", (A, B, B), "PERSISTS"),
("constructed RESETS", (A, B, A), "RESETS")):
got = _verdict(*args); ok = got == want; bad += not ok
print(f" {'' if ok else '🔴'} {nm:36} -> {got:9} (want {want})", flush=True)
if bad:
print("🔴 SELF-TEST FAILED — the rule cannot produce both verdicts.", flush=True)
sys.exit(3)
print(" ✅ self-test passed: the rule constructs both verdicts", flush=True)
print("── decision-rule self-test ──", flush=True)
_self_test()
MAIN = wait_until(lambda a: main_menu_item(ring_row(img(a))) is not None
and 250 <= glyph(a) <= 420, "the main menu", 150)
if MAIN is None:
@@ -117,9 +157,13 @@ results = {}
for tgt in TARGETS:
name = NAMES[tgt]
print(f"\n=========== {name} ===========", flush=True)
a = wait_until(lambda a: differs(a, MAIN) < 0.15, "the main menu", 60)
# 🔴 NARROW test, not whole-frame. A crash dialog covering the screen centre
# made a whole-frame identity test unable to match ever again, while the ring
# column the dialog did not cover read correctly throughout.
a = wait_until(lambda a: main_menu_item(ring_row(img(a))) is not None,
"the main menu (by ring row)", 60)
if a is None:
print(f" SKIP {name}: not on the main menu"); continue
print(f" SKIP {name}: no main-menu ring row"); continue
cur = main_menu_item(ring_row(img(a)))
if cur is None:
print(f" SKIP {name}: no main-menu ring row"); continue
@@ -164,7 +208,8 @@ for tgt in TARGETS:
if not press("B", "5801"):
results[name] = "skipped (B not delivered)"; continue
if wait_until(lambda x: differs(x, MAIN) < 0.15, "the main menu", 60) is None:
if wait_until(lambda x: main_menu_item(ring_row(img(x))) is not None,
"the main menu (by ring row)", 60) is None:
results[name] = "VOID: B did not return to the main menu"; continue
if not press("A", "5800"):
results[name] = "skipped (A not delivered on re-entry)"; continue
@@ -172,17 +217,23 @@ for tgt in TARGETS:
results[name] = "VOID: re-entry did not change the screen"; continue
time.sleep(3.0)
S3 = fresh(); img(S3).save(f"{OUT}/{name.replace(' ','_')}-S3.png")
d1, d2 = differs(S3, S1), differs(S3, S2)
print(f" S3 re-entered: {100*d1:.2f}% from S1 (opened on), "
f"{100*d2:.2f}% from S2 (left on)", flush=True)
if min(d1, d2) > 0.15:
results[name] = f"VOID: re-entry matches neither ({100*d1:.1f}% / {100*d2:.1f}%)"
# The pixels that changed when the cursor moved ARE the cursor's region -- no
# per-screen geometry, which is what defeated sweep 1 (ring_row scans the MAIN
# MENU's gutter; these screens put cursors at x 97..231, 338..1099, 153..479).
cur = np.abs(S1 - S2).max(axis=2) > 24
same_p95 = float(np.percentile(np.abs(S3 - S1).max(axis=2)[~cur], 95))
d1 = float(np.abs(S3 - S1).max(axis=2)[cur].mean())
d2 = float(np.abs(S3 - S2).max(axis=2)[cur].mean())
print(f" S3 re-entered: off-cursor p95 {same_p95:.1f}; in-cursor "
f"|S3-S1| {d1:.1f}, |S3-S2| {d2:.1f}", flush=True)
if same_p95 > 40:
results[name] = f"VOID: re-entry is not the same screen (off-cursor p95 {same_p95:.0f})"
elif d2 < d1 * 0.5:
results[name] = f"PERSISTS (S3 {100*d2:.2f}% from where left, {100*d1:.2f}% from opened)"
results[name] = f"PERSISTS (in-cursor {d2:.1f} from where left vs {d1:.1f} from opened)"
elif d1 < d2 * 0.5:
results[name] = f"RESETS (S3 {100*d1:.2f}% from opened, {100*d2:.2f}% from where left)"
results[name] = f"RESETS (in-cursor {d1:.1f} from opened vs {d2:.1f} from where left)"
else:
results[name] = f"UNDECIDED ({100*d1:.2f}% / {100*d2:.2f}%)"
results[name] = f"UNDECIDED (in-cursor {d1:.1f} / {d2:.1f})"
print(f" => {results[name]}", flush=True)
press("B", "5801"); time.sleep(3)