From 732907e5e118d3ea5e837465ce6552dfff52f666 Mon Sep 17 00:00:00 2001 From: sylph-decoder Date: Mon, 31 Aug 2026 01:00:56 +0000 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v --- tools/re-capture/submenu_focus_sweep.py | 73 +++++++++++++++++++++---- 1 file changed, 62 insertions(+), 11 deletions(-) diff --git a/tools/re-capture/submenu_focus_sweep.py b/tools/re-capture/submenu_focus_sweep.py index a4a30aaa..20ccbf44 100644 --- a/tools/re-capture/submenu_focus_sweep.py +++ b/tools/re-capture/submenu_focus_sweep.py @@ -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)