diff --git a/docs/architecture/PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC.md b/docs/architecture/PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC.md index 3f810b8..ad9740b 100644 --- a/docs/architecture/PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC.md +++ b/docs/architecture/PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC.md @@ -93,6 +93,7 @@ action : | `moderate_overflow` | content_type ∈ {`text_flow`, `frame_label`} AND `line_equivalent` ∈ (1.5, 4] | | `minor_overflow` | content_type ∈ {`text_flow`, `frame_label`} AND `line_equivalent` ≤ 1.5 | | `hard_visual_fail` | 위 어디에도 매핑 안 됨 OR retry budget 소진 | +| `image_aspect_mismatch` | Post-render `fail_reasons` signal — Step 14 visual_runtime_check 가 이미지 frame slot 의 rendered aspect ratio 와 declared aspect ratio 불일치를 감지 (router-routed fit_classifier 출력 아님; 별도 image_events stream 으로 표면화) | ### 3.2 분류 우선순위 (위에서 아래로) diff --git a/src/phase_z2_pipeline.py b/src/phase_z2_pipeline.py index 35098bb..a5c0d08 100644 --- a/src/phase_z2_pipeline.py +++ b/src/phase_z2_pipeline.py @@ -2737,6 +2737,9 @@ def write_debug_json(run_dir: Path, layout_preset: str, "visual_runtime_check": visual_runtime_check, # A-6 (IMP-01 #1) — additive top-level zone bbox trace (slide-relative px) "zone_geometries_px": (visual_runtime_check or {}).get("zone_geometries_px", []), + # IMP-15 실행-4 (issue #48) — additive top-level Step 14 event streams + "image_events": (visual_runtime_check or {}).get("image_events", []), + "table_events": (visual_runtime_check or {}).get("table_events", []), } debug_path = run_dir / "debug.json" debug_path.write_text(json.dumps(debug, ensure_ascii=False, indent=2), encoding="utf-8") diff --git a/tests/phase_z2/test_debug_json_event_surfacing.py b/tests/phase_z2/test_debug_json_event_surfacing.py new file mode 100644 index 0000000..f71269c --- /dev/null +++ b/tests/phase_z2/test_debug_json_event_surfacing.py @@ -0,0 +1,81 @@ +"""IMP-15 실행-4 (Gitea issue #48) — debug.json top-level event surfacing. + +Verifies ``write_debug_json`` lifts ``image_events`` + ``table_events`` out of +``visual_runtime_check`` and exposes them as top-level keys, mirroring the +existing ``zone_geometries_px`` precedent (src/phase_z2_pipeline.py:2739). + +Two scenarios: + +* Populated — ``visual_runtime_check`` carries non-empty event lists; the + written debug dict surfaces both at the top level with identical payloads. +* None — ``visual_runtime_check is None``; both top-level keys default to ``[]`` + (no KeyError, no propagated None). +""" +from __future__ import annotations + +import json +from pathlib import Path + +from src.phase_z2_pipeline import write_debug_json + + +def _read_debug(run_dir: Path) -> dict: + return json.loads((run_dir / "debug.json").read_text(encoding="utf-8")) + + +def test_write_debug_json_surfaces_image_and_table_events(tmp_path: Path) -> None: + image_events = [ + { + "src": "img/a.png", + "zone_position": "primary", + "zone_template_id": "tid-1", + "natural_w": 200, + "natural_h": 100, + "rendered_w": 200, + "rendered_h": 200, + "delta": 1.0, + } + ] + table_events = [ + { + "zone_position": "secondary", + "zone_template_id": "tid-2", + "clientWidth": 300, + "scrollWidth": 360, + "excess_x": 60, + "wrapper_clipped_index": 0, + } + ] + visual_runtime_check = { + "image_events": image_events, + "table_events": table_events, + "zone_geometries_px": [], + } + + write_debug_json( + run_dir=tmp_path, + layout_preset="single", + debug_zones=[], + layout_css={}, + visual_runtime_check=visual_runtime_check, + ) + + debug = _read_debug(tmp_path) + assert "image_events" in debug, "image_events must be a top-level key" + assert "table_events" in debug, "table_events must be a top-level key" + assert debug["image_events"] == image_events + assert debug["table_events"] == table_events + + +def test_write_debug_json_defaults_when_visual_runtime_check_none(tmp_path: Path) -> None: + write_debug_json( + run_dir=tmp_path, + layout_preset="single", + debug_zones=[], + layout_css={}, + visual_runtime_check=None, + ) + + debug = _read_debug(tmp_path) + assert debug["image_events"] == [] + assert debug["table_events"] == [] diff --git a/tests/phase_z2/test_spec_taxonomy_image_aspect_mismatch.py b/tests/phase_z2/test_spec_taxonomy_image_aspect_mismatch.py new file mode 100644 index 0000000..dc12308 --- /dev/null +++ b/tests/phase_z2/test_spec_taxonomy_image_aspect_mismatch.py @@ -0,0 +1,63 @@ +"""Spec lint: PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC.md §3.1 taxonomy must declare +the `image_aspect_mismatch` row (IMP-15 실행-4, issue #48 u2). + +The row encodes a post-render `fail_reasons` signal surfaced by Step 14 +visual_runtime_check, not a router-routed fit_classifier output. It is +intentionally placed inside §3.1 to keep the taxonomy vocabulary aligned +with the event streams now exposed at debug.json top level (u1). +""" + +from __future__ import annotations + +import re +from pathlib import Path + +SPEC_PATH = ( + Path(__file__).resolve().parents[2] + / "docs" + / "architecture" + / "PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC.md" +) + + +def _read_spec_text() -> str: + return SPEC_PATH.read_text(encoding="utf-8") + + +def _extract_section_3_1(text: str) -> str: + start_match = re.search(r"^###\s+3\.1\b", text, flags=re.MULTILINE) + assert start_match, "§3.1 heading missing from spec" + after_3_1 = text[start_match.end():] + end_match = re.search(r"^###\s+3\.2\b", after_3_1, flags=re.MULTILINE) + assert end_match, "§3.2 heading missing from spec" + return after_3_1[: end_match.start()] + + +def test_spec_section_3_1_contains_image_aspect_mismatch_row(): + section = _extract_section_3_1(_read_spec_text()) + row_pattern = re.compile(r"^\|\s*`image_aspect_mismatch`\s*\|", re.MULTILINE) + matches = row_pattern.findall(section) + assert len(matches) == 1, ( + "Expected exactly 1 `image_aspect_mismatch` row inside §3.1 taxonomy, " + f"found {len(matches)}" + ) + + +def test_image_aspect_mismatch_row_reflects_post_render_semantic(): + section = _extract_section_3_1(_read_spec_text()) + row_line = next( + ( + line + for line in section.splitlines() + if line.lstrip().startswith("| `image_aspect_mismatch`") + ), + None, + ) + assert row_line is not None, "image_aspect_mismatch row not found" + assert "Post-render" in row_line or "post-render" in row_line, ( + "Row must mark the signal as post-render (Stage 1 guardrail)" + ) + assert "fail_reasons" in row_line, ( + "Row must reference `fail_reasons` so the vocabulary mirrors the " + "visual_runtime_check output" + )