From 535c4848fd60a1e5a57757048b6ffaad4495a3b6 Mon Sep 17 00:00:00 2001 From: kyeongmin Date: Mon, 18 May 2026 21:45:06 +0900 Subject: [PATCH] =?UTF-8?q?feat(IMP-15):=20=EC=8B=A4=ED=96=89-3=20?= =?UTF-8?q?=E2=80=94=20classifier=20consumes=20image+table=20events?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #47 (IMP-15 실행-3 axis 3): extend `classify_visual_runtime_check` to consume the `image_events[]` and `table_events[]` arrays produced by `run_overflow_check` (실행-1/2) and widen `visual_check_passed`. Changes (src/phase_z2_classifier.py): - Remove `overflow.passed=True` early-return so image/table event scans always run, even when zone-level overflow was clean. - Deferred import of `IMAGE_ASPECT_DELTA_TOL` and `TABLE_SCROLL_TOL_PX` from `phase_z2_pipeline` (circular-safe SSoT; no duplicate literals). - New `image_events` scan emits `image_aspect_mismatch` when `delta is not None AND |delta| > IMAGE_ASPECT_DELTA_TOL` (delta=None ⇒ skip, image not loaded). - New `table_events` scan emits `tabular_overflow` when `wrapper_clipped_index is None AND (excess_x or excess_y > TABLE_SCROLL_TOL_PX)` (wrapper-clipped tables deduped against the existing zone cascade). - `visual_check_passed = overflow.passed AND not classifications` — any image/table classification now flips the gate. Guardrails preserved: - §3.2 8-rule zone cascade (clipped_inner / zone-self) untouched — the new emitters are ADDITIONAL. - `placement_diagnostics`, `categories_seen`, `unclassified_signals` return-shape preserved. - No `pipeline.py` production changes; no router action or `debug.json` passthrough changes. Tests (tests/phase_z2/test_phase_z2_visual_classifier.py — new): - `test_image_aspect_mismatch_emits_classification` (|delta|>TOL fires) - `test_image_aspect_delta_below_tol_no_classification` (≤TOL skipped) - `test_standalone_table_overflow_emits_classification` (wrapper_clipped_index=None, excess>TOL fires) - `test_table_dedup_when_wrapper_clipped` (wrapper_clipped_index set ⇒ no `tabular_overflow` emit) All 4 pure-dict (no Selenium / chromedriver / pipeline execution). Tolerances imported from `phase_z2_pipeline` (SSoT enforced via test import — no classifier-local literals). Verification (Stage 4): - New classifier tests: 4/4 PASS. - Regression `tests/phase_z2/` excluding new file: 93/93 PASS. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/phase_z2_classifier.py | 72 +++++++++-- .../test_phase_z2_visual_classifier.py | 116 ++++++++++++++++++ 2 files changed, 177 insertions(+), 11 deletions(-) create mode 100644 tests/phase_z2/test_phase_z2_visual_classifier.py diff --git a/src/phase_z2_classifier.py b/src/phase_z2_classifier.py index a4c3160..b4f2ddf 100644 --- a/src/phase_z2_classifier.py +++ b/src/phase_z2_classifier.py @@ -344,7 +344,7 @@ def classify_visual_runtime_check(overflow: dict, debug_zones: list[dict]) -> di Returns: dict : - visual_check_passed : Selenium 통과 여부 + visual_check_passed : Selenium 통과 여부 (overflow.passed AND no classifications) classifications : 각 overflow event 의 분류 결과 list summary : 텍스트 요약 (n events, categories seen) categories_seen : 등장한 카테고리 unique list @@ -353,6 +353,12 @@ def classify_visual_runtime_check(overflow: dict, debug_zones: list[dict]) -> di divergence + region / slot_assignment / rejection count) — passed 여부 무관 항상 surface """ + # Deferred import — phase_z2_pipeline imports this module at module top, so + # a top-level `from phase_z2_pipeline import ...` would be circular. Pulled + # in at call time so both modules are fully loaded. Tolerances are owned by + # phase_z2_pipeline (single source of truth — see IMP-15 실행-1/2). + from phase_z2_pipeline import IMAGE_ASPECT_DELTA_TOL, TABLE_SCROLL_TOL_PX + # placement_diagnostics — debug_zones[i].placement_trace 를 per-zone diagnostic 으로 surface. # passed 여부 무관 항상 빌드 (B4 vs mapper divergence 가 passed 에서도 진단 가치). placement_diagnostics = [ @@ -364,15 +370,9 @@ def classify_visual_runtime_check(overflow: dict, debug_zones: list[dict]) -> di for dz in (debug_zones or []) ] - if overflow.get("passed", False): - return { - "visual_check_passed": True, - "classifications": [], - "summary": "visual check passed — no overflow to classify", - "categories_seen": [], - "unclassified_signals": [], - "placement_diagnostics": placement_diagnostics, - } + # IMP-15 실행-3 (issue #47): no early-return on overflow.passed=True. + # image_events / table_events scans below run unconditionally; the final + # visual_check_passed is widened to: overflow.passed AND no classifications. # zone position → debug_zones 매핑 (capacity_fit_status 추출용) capacity_status_by_position: dict[str, Optional[str]] = {} @@ -423,6 +423,53 @@ def classify_visual_runtime_check(overflow: dict, debug_zones: list[dict]) -> di cls["scroll_height"] = c.get("scrollHeight") classifications.append(cls) + # IMP-15 실행-3 (issue #47): image_events scan — image_aspect_mismatch emitter. + # delta is None ⇒ skip (image not loaded; no false positive). + # |delta| > IMAGE_ASPECT_DELTA_TOL ⇒ emit classification. + for ev in (overflow.get("image_events") or []): + delta = ev.get("delta") + if delta is None: + continue + if abs(delta) > IMAGE_ASPECT_DELTA_TOL: + classifications.append({ + "category": "image_aspect_mismatch", + "source": "image_event", + "zone_position": ev.get("zone_position"), + "zone_template_id": ev.get("zone_template_id"), + "src": ev.get("src"), + "natural_ratio": ev.get("natural_ratio"), + "rendered_ratio": ev.get("rendered_ratio"), + "delta": delta, + "rule_applied": ( + f"|delta|={abs(delta):.4f} > IMAGE_ASPECT_DELTA_TOL=" + f"{IMAGE_ASPECT_DELTA_TOL} (IMP-15 실행-3)" + ), + }) + + # IMP-15 실행-3 (issue #47): table_events scan — tabular_overflow emitter. + # wrapper_clipped_index is not None ⇒ skip (clipped_inner already covers this + # case via zone cascade; honor dedup contract from pipeline producer). + # excess_x or excess_y > TABLE_SCROLL_TOL_PX ⇒ emit tabular_overflow. + for ev in (overflow.get("table_events") or []): + if ev.get("wrapper_clipped_index") is not None: + continue + excess_x = ev.get("excess_x") or 0 + excess_y = ev.get("excess_y") or 0 + if excess_x > TABLE_SCROLL_TOL_PX or excess_y > TABLE_SCROLL_TOL_PX: + classifications.append({ + "category": "tabular_overflow", + "source": "table_event", + "zone_position": ev.get("zone_position"), + "zone_template_id": ev.get("zone_template_id"), + "excess_x": excess_x, + "excess_y": excess_y, + "rule_applied": ( + f"table self-overflow — excess_x={excess_x} or excess_y=" + f"{excess_y} > TABLE_SCROLL_TOL_PX={TABLE_SCROLL_TOL_PX} " + f"(wrapper not clipped; IMP-15 실행-3)" + ), + }) + # slide-level / slide-body overflow (zones 외부) 도 분류 시도 (보통 zone-level 에서 잡히지만 보조) unclassified: list[dict] = [] slide_m = overflow.get("slide") or {} @@ -443,8 +490,11 @@ def classify_visual_runtime_check(overflow: dict, debug_zones: list[dict]) -> di }) categories = sorted({c["category"] for c in classifications}) + # IMP-15 실행-3 (issue #47): widened semantic — overflow.passed alone is not + # enough; any image/table classification also flips visual_check_passed. + visual_check_passed = bool(overflow.get("passed", False)) and not classifications return { - "visual_check_passed": False, + "visual_check_passed": visual_check_passed, "classifications": classifications, "summary": ( f"{len(classifications)} overflow event(s) classified, " diff --git a/tests/phase_z2/test_phase_z2_visual_classifier.py b/tests/phase_z2/test_phase_z2_visual_classifier.py new file mode 100644 index 0000000..06a638e --- /dev/null +++ b/tests/phase_z2/test_phase_z2_visual_classifier.py @@ -0,0 +1,116 @@ +"""IMP-15 실행-3 (Gitea issue #47) — classifier consumer pure-dict tests. + +`classify_visual_runtime_check` was widened to consume the new +``image_events[]`` / ``table_events[]`` arrays produced by ``run_overflow_check`` +(IMP-15 실행-1/2). The consumer must: + +* emit ``image_aspect_mismatch`` when ``|delta| > IMAGE_ASPECT_DELTA_TOL`` and + skip when ``delta is None`` or ``|delta| <= IMAGE_ASPECT_DELTA_TOL``; +* emit ``tabular_overflow`` when a table self-overflows beyond + ``TABLE_SCROLL_TOL_PX`` and ``wrapper_clipped_index is None`` — and dedupe + when the table sits under a wrapper already on the clipped-wrapper map + (``wrapper_clipped_index`` non-null); +* flip ``visual_check_passed`` to False whenever any classification fires, even + if zone-level overflow was clean (``overflow["passed"]=True``). + +All four cases are pure-dict — no Selenium / chromedriver dependency. +""" +from __future__ import annotations + +from src.phase_z2_classifier import classify_visual_runtime_check +from src.phase_z2_pipeline import IMAGE_ASPECT_DELTA_TOL, TABLE_SCROLL_TOL_PX + + +def _base_overflow(**overrides) -> dict: + """Minimal clean overflow result; tests overlay image/table events.""" + base = { + "passed": True, + "slide": {"overflowed": False}, + "slide_body": {"overflowed": False}, + "zones": [], + "image_events": [], + "table_events": [], + } + base.update(overrides) + return base + + +# ─── image_events scan ─────────────────────────────────────────────── + + +def test_image_aspect_mismatch_emits_classification(): + """|delta| > IMAGE_ASPECT_DELTA_TOL ⇒ emit + flip visual_check_passed.""" + delta = IMAGE_ASPECT_DELTA_TOL + 0.05 + overflow = _base_overflow(image_events=[{ + "zone_position": "top", + "zone_template_id": "f1b", + "src": "img/sample.png", + "natural_ratio": 2.0, + "rendered_ratio": 2.0 * (1.0 + delta), + "delta": delta, + }]) + result = classify_visual_runtime_check(overflow, debug_zones=[]) + assert result["visual_check_passed"] is False + assert result["categories_seen"] == ["image_aspect_mismatch"] + assert len(result["classifications"]) == 1 + cls = result["classifications"][0] + assert cls["category"] == "image_aspect_mismatch" + assert cls["source"] == "image_event" + assert cls["zone_position"] == "top" + assert cls["delta"] == delta + + +def test_image_aspect_delta_below_tol_no_classification(): + """|delta| <= IMAGE_ASPECT_DELTA_TOL ⇒ skip (no false positive).""" + delta = IMAGE_ASPECT_DELTA_TOL / 2.0 + overflow = _base_overflow(image_events=[{ + "zone_position": "top", + "zone_template_id": "f1b", + "src": "img/sample.png", + "natural_ratio": 2.0, + "rendered_ratio": 2.0 * (1.0 + delta), + "delta": delta, + }]) + result = classify_visual_runtime_check(overflow, debug_zones=[]) + assert result["visual_check_passed"] is True + assert result["categories_seen"] == [] + assert result["classifications"] == [] + + +# ─── table_events scan ─────────────────────────────────────────────── + + +def test_standalone_table_overflow_emits_classification(): + """wrapper_clipped_index=None AND excess > TOL ⇒ emit tabular_overflow.""" + excess = TABLE_SCROLL_TOL_PX + 10 + overflow = _base_overflow(table_events=[{ + "zone_position": "bottom_l", + "zone_template_id": "f13b", + "wrapper_clipped_index": None, + "excess_x": 0, + "excess_y": excess, + }]) + result = classify_visual_runtime_check(overflow, debug_zones=[]) + assert result["visual_check_passed"] is False + assert result["categories_seen"] == ["tabular_overflow"] + assert len(result["classifications"]) == 1 + cls = result["classifications"][0] + assert cls["category"] == "tabular_overflow" + assert cls["source"] == "table_event" + assert cls["zone_position"] == "bottom_l" + assert cls["excess_y"] == excess + + +def test_table_dedup_when_wrapper_clipped(): + """wrapper_clipped_index non-null ⇒ skip (dedupe with clipped_inner cascade).""" + overflow = _base_overflow(table_events=[{ + "zone_position": "bottom_l", + "zone_template_id": "f13b", + "wrapper_clipped_index": 0, + "excess_x": 0, + "excess_y": TABLE_SCROLL_TOL_PX + 50, + }]) + result = classify_visual_runtime_check(overflow, debug_zones=[]) + assert result["visual_check_passed"] is True + assert result["categories_seen"] == [] + assert result["classifications"] == []