feat(IMP-15): 실행-3 — classifier consumes image+table events

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 21:45:06 +09:00
parent 2827622858
commit 535c4848fd
2 changed files with 177 additions and 11 deletions

View File

@@ -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, "