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:
@@ -344,7 +344,7 @@ def classify_visual_runtime_check(overflow: dict, debug_zones: list[dict]) -> di
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict :
|
dict :
|
||||||
visual_check_passed : Selenium 통과 여부
|
visual_check_passed : Selenium 통과 여부 (overflow.passed AND no classifications)
|
||||||
classifications : 각 overflow event 의 분류 결과 list
|
classifications : 각 overflow event 의 분류 결과 list
|
||||||
summary : 텍스트 요약 (n events, categories seen)
|
summary : 텍스트 요약 (n events, categories seen)
|
||||||
categories_seen : 등장한 카테고리 unique list
|
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
|
divergence + region / slot_assignment / rejection
|
||||||
count) — passed 여부 무관 항상 surface
|
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.
|
# placement_diagnostics — debug_zones[i].placement_trace 를 per-zone diagnostic 으로 surface.
|
||||||
# passed 여부 무관 항상 빌드 (B4 vs mapper divergence 가 passed 에서도 진단 가치).
|
# passed 여부 무관 항상 빌드 (B4 vs mapper divergence 가 passed 에서도 진단 가치).
|
||||||
placement_diagnostics = [
|
placement_diagnostics = [
|
||||||
@@ -364,15 +370,9 @@ def classify_visual_runtime_check(overflow: dict, debug_zones: list[dict]) -> di
|
|||||||
for dz in (debug_zones or [])
|
for dz in (debug_zones or [])
|
||||||
]
|
]
|
||||||
|
|
||||||
if overflow.get("passed", False):
|
# IMP-15 실행-3 (issue #47): no early-return on overflow.passed=True.
|
||||||
return {
|
# image_events / table_events scans below run unconditionally; the final
|
||||||
"visual_check_passed": True,
|
# visual_check_passed is widened to: overflow.passed AND no classifications.
|
||||||
"classifications": [],
|
|
||||||
"summary": "visual check passed — no overflow to classify",
|
|
||||||
"categories_seen": [],
|
|
||||||
"unclassified_signals": [],
|
|
||||||
"placement_diagnostics": placement_diagnostics,
|
|
||||||
}
|
|
||||||
|
|
||||||
# zone position → debug_zones 매핑 (capacity_fit_status 추출용)
|
# zone position → debug_zones 매핑 (capacity_fit_status 추출용)
|
||||||
capacity_status_by_position: dict[str, Optional[str]] = {}
|
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")
|
cls["scroll_height"] = c.get("scrollHeight")
|
||||||
classifications.append(cls)
|
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 에서 잡히지만 보조)
|
# slide-level / slide-body overflow (zones 외부) 도 분류 시도 (보통 zone-level 에서 잡히지만 보조)
|
||||||
unclassified: list[dict] = []
|
unclassified: list[dict] = []
|
||||||
slide_m = overflow.get("slide") or {}
|
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})
|
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 {
|
return {
|
||||||
"visual_check_passed": False,
|
"visual_check_passed": visual_check_passed,
|
||||||
"classifications": classifications,
|
"classifications": classifications,
|
||||||
"summary": (
|
"summary": (
|
||||||
f"{len(classifications)} overflow event(s) classified, "
|
f"{len(classifications)} overflow event(s) classified, "
|
||||||
|
|||||||
116
tests/phase_z2/test_phase_z2_visual_classifier.py
Normal file
116
tests/phase_z2/test_phase_z2_visual_classifier.py
Normal file
@@ -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"] == []
|
||||||
Reference in New Issue
Block a user