[Claude #2] Stage 2 Round #1 — Plan **Stage 2 entry** - commit_sha = `7a52cebfaa8b356c42a5de45c7241a3002615cab` - Issue scope = IMP-15 실행-1 image axis only. Table is issue #46 (실행-2). Classifier consumer + `visual_check_passed` flip = 실행-3. debug.json surfacing + spec doc row = 실행-4. - Stage 1 exit_report scope_lock binding. **Stage 1 unresolved_questions resolved here** 1. **IMAGE_ASPECT_DELTA_TOL anchor — Codex was right, no spec anchor for 0.05.** Verified `docs/architecture/PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC.md` §3.1 table (lines 86–95) does NOT contain `image_aspect_mismatch` row; `image_aspect_mismatch` appears as a *category name* in `docs/architecture/PHASE-Z-PIPELINE-OVERVIEW.md:312,316` but the `0.05` numeric value is not anchored in any spec doc (grep negative across `docs/`). **Decision:** lock constant at module scope with `# TODO(IMP-15 실행-4): mirror in PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC.md §3.1 / §3.4` comment. Spec doc row addition is out-of-scope per issue body (실행-4). 2. **image_events[] schema — minimum fields locked.** Per-event dict = `{src, zone_position, zone_template_id, frame_slot_id, natural_w, natural_h, rendered_w, rendered_h, aspect_natural, aspect_rendered, aspect_delta, aspect_mismatch}`. Bbox px (x/y/w/h) deferred to 실행-4 (debug.json surfacing — current issue does not need bbox). 3. **fail_reasons string format locked** as: `f"image aspect mismatch in zone--{zone_position}: natural {nW}x{nH} rendered {rW}x{rH} delta {delta:.3f}"`. Matches issue body prefix verbatim; tail is measurable / grep-friendly. 4. **Test fixture — synthesize minimal HTML in pytest tmp_path** (not reuse Phase Z rendered artifacts). PNG generated on-the-fly via Pillow (200×100 single colour). HTML attaches `width=/height=` to force rendered geometry. Keeps test independent of frame DB / composition state; Fixture C can omit `.zone` wrapper without breaking the slide_base contract. **Stage 1 commit_sha re-verified** = `7a52cebfaa8b356c42a5de45c7241a3002615cab` (via `git rev-parse HEAD`). --- ### Issue-body axes — exhaustive coverage checklist | # | axis (issue body verbatim) | covered by | how | |---|---|---|---| | A1 | "JS payload (image_events) + Python aggregation" — `` traversal | u1 | `slide.querySelectorAll('img')` → measure `naturalWidth/Height` + `clientWidth/Height`; result included in returned dict | | A2 | "emit `image_aspect_mismatch` fail_reason when |delta| > 0.05" | u1 | `IMAGE_ASPECT_DELTA_TOL = 0.05` gates appendage to `fail_reasons` | | A3 | "`result["image_events"]` populated for every `` in slide" | u1 | return literal extended from 5 keys to 6 keys (`image_events` appended last; legacy 5 byte-identical) | | A4 | "`IMAGE_ASPECT_DELTA_TOL = 0.05` constant in pipeline" | u1 | module scope in `src/phase_z2_pipeline.py` near existing SLIDE_W/SLIDE_H constants (line 118–129 region); importable as `from src.phase_z2_pipeline import IMAGE_ASPECT_DELTA_TOL` | | A5 | "`fail_reasons` includes `image aspect mismatch in zone--: ...`" | u1 | Python-side aggregator iterates `result["image_events"]` and appends fail_reason for every event where `aspect_mismatch=True` | | A6 | "Selenium test suite added with skip-guard" | u2 | new file `tests/phase_z2/test_phase_z2_step14_image_check.py` | | A7 | "Fixture A — no distortion (200×100 / 200×100): aspect_delta < 0.05, passed=True" | u2 | `_make_fixture_A()` writes 200×100 PNG via Pillow + HTML with `` inside a `.zone`; asserts `image_events[0]["aspect_delta"] < 0.05`, `passed == True`, no `image aspect mismatch` in fail_reasons | | A8 | "Fixture B — forced distortion (200×100 intrinsic, 200×200 rendered): aspect_delta > 0.30, fail_reason present, passed=False" | u2 | `_make_fixture_B()` same PNG, `` inside `.zone[data-zone-position="top"]`; asserts `aspect_delta > 0.30`, `passed == False`, fail_reasons contains literal substring `"image aspect mismatch in zone--top"` | | A9 | "Fixture C — image with no .zone ancestor: zone_position == 'unknown' (attachment fallback)" | u2 | `_make_fixture_C()` `` placed directly under `.slide` (no `.zone` wrapper), forced distortion to trigger fail_reason; asserts `image_events[0]["zone_position"] == "unknown"`, fail_reasons contains `"image aspect mismatch in zone--unknown"` | | A10a | "Chromedriver skip-guard: skip when not resolvable" | u2 | helper `_resolve_chromedriver()` mirrors `pipeline.py:2082-2099` resolver order (`PROJECT_ROOT/chromedriver`, `PROJECT_ROOT/chromedriver.exe`, then `shutil.which('chromedriver')`); fixture returns `None` → `pytest.skip("chromedriver not resolvable")` when env var unset | | A10b | "xfail strict when PHASE_Z_REQUIRE_SELENIUM=1" | u2 | `if os.environ.get("PHASE_Z_REQUIRE_SELENIUM") == "1" and resolver returns None: pytest.xfail(strict=True, reason="...")`; expressed via `pytest.mark.xfail(strict=True, condition=...)` decorator wired through a fixture | | B1 | Step note update — "image / table 검사 부재 — Step 14 ⚠ partial." → image half resolved | u1 | `pipeline.py:4358` note string rewritten to: `"Selenium 실측 — clientHeight / scrollHeight / excess_y / frame_slot_metrics / image_events. image 검사 추가됨 (IMP-15 실행-1). table 검사 부재 — Step 14 ⚠ partial."` | | C1 | Guardrail — no existing fail_reasons strings reordered | u1 | new image branch appended *after* existing zone clipped_inner branch in the same `for z in result["zones"]: ...` block sibling loop; existing string order preserved byte-identical | | C2 | Guardrail — no AI calls in Step 14 path | u1 + u2 | aggregator is deterministic; tests check threshold logic only | | C3 | Guardrail — no edits to `templates/phase_z2/slide_base.html` | n/a | fixtures synthesize minimal HTML directly; production template untouched | | C4 | Guardrail — `IMAGE_ASPECT_DELTA_TOL >= 0.05` (Stage 1 guardrail wording) | u1 | constant value locked at 0.05; comment cross-refs Stage 1 exit_report | --- ### Per-unit plan Atomicity: estimate_lines ≤ 50 AND files ≤ 3 per unit; counted as total diff in unit's commit (production + tests). #### u1 — pipeline.py: module constant + JS payload `image_events[]` + Python aggregator branch + Step 14 note **Files (1):** - `src/phase_z2_pipeline.py` **Before:** - `run_overflow_check` JS at `src/phase_z2_pipeline.py:2105-2212` returns `{slide, slide_body, zones, frame_slot_metrics, zone_geometries_px}` (line 2211). No `` traversal. - `fail_reasons` aggregator at `pipeline.py:2226-2253` covers slide size / slide overflow / slide-body overflow / zone overflow / clipped_inner only. No image branch. - No `IMAGE_ASPECT_DELTA_TOL` symbol (grep negative). - Step 14 note at `pipeline.py:4358` states `"image / table 검사 부재 — Step 14 ⚠ partial."`. **After (per-edit list — all in one file, single commit):** 1. **Module constant** — insert after existing constants block (after line 129 `DEFAULT_ZONE_MIN_HEIGHT_PX = 100`): ```python # Step 14 image aspect mismatch tolerance (IMP-15 실행-1). # Locally anchored — spec doc row pending in IMP-15 실행-4 # (PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC.md §3.1 / §3.4). # Category name appears in docs/architecture/PHASE-Z-PIPELINE-OVERVIEW.md:312,316. IMAGE_ASPECT_DELTA_TOL = 0.05 ``` 2. **JS payload extension** — insert before `return { slide: slideM, ... }` at line 2211 (inside `driver.execute_script(...)`): ```javascript // IMP-15 실행-1 — intrinsic vs rendered aspect mismatch detection. const image_events = []; slide.querySelectorAll('img').forEach((img) => { const parentZone = img.closest('.zone'); const zonePos = parentZone ? (parentZone.getAttribute('data-zone-position') || 'unknown') : 'unknown'; const zoneTid = parentZone ? (parentZone.getAttribute('data-template-id') || '?') : '?'; const slotEl = img.closest('[data-frame-slot-id]'); const slotId = slotEl ? slotEl.getAttribute('data-frame-slot-id') : null; image_events.push({ src: (img.getAttribute('src') || '').slice(0, 200), zone_position: zonePos, zone_template_id: zoneTid, frame_slot_id: slotId, natural_w: img.naturalWidth, natural_h: img.naturalHeight, rendered_w: img.clientWidth, rendered_h: img.clientHeight, }); }); ``` 3. **Return literal** — change line 2211 from ```javascript return { slide: slideM, slide_body: bodyM, zones, frame_slot_metrics, zone_geometries_px }; ``` to ```javascript return { slide: slideM, slide_body: bodyM, zones, frame_slot_metrics, zone_geometries_px, image_events }; ``` 4. **Python aggregator branch** — insert after existing zone loop at line 2249 (after the `clipped_inner` inner loop, before `result["passed"] = ...`): ```python # IMP-15 실행-1 — image aspect mismatch detection (post-JS aggregation). for ev in result.get("image_events", []) or []: nW, nH = ev.get("natural_w") or 0, ev.get("natural_h") or 0 rW, rH = ev.get("rendered_w") or 0, ev.get("rendered_h") or 0 if nW <= 0 or nH <= 0 or rW <= 0 or rH <= 0: ev["aspect_natural"] = None ev["aspect_rendered"] = None ev["aspect_delta"] = None ev["aspect_mismatch"] = False continue an = nW / nH ar = rW / rH delta = abs(an - ar) / an ev["aspect_natural"] = an ev["aspect_rendered"] = ar ev["aspect_delta"] = delta ev["aspect_mismatch"] = delta > IMAGE_ASPECT_DELTA_TOL if ev["aspect_mismatch"]: fail_reasons.append( f"image aspect mismatch in zone--{ev['zone_position']}: " f"natural {nW}x{nH} rendered {rW}x{rH} delta {delta:.3f}" ) ``` 5. **Step 14 note rewrite** — `pipeline.py:4355-4359` note string: ```python note=( "Selenium 실측 — clientHeight / scrollHeight / excess_y / frame_slot_metrics / " "image_events. image 검사 추가됨 (IMP-15 실행-1, IMAGE_ASPECT_DELTA_TOL=0.05). " "table 검사 부재 — Step 14 ⚠ partial." ), ``` `step_status="partial"` retained (table still partial). **Tests in u1:** none. Tests live in u2 (Selenium-driven end-to-end fixtures). Smoke-import test is unnecessary because u2's fixture A exercises the constant import path implicitly (`from src.phase_z2_pipeline import IMAGE_ASPECT_DELTA_TOL, run_overflow_check`). **estimate_lines:** ~35 (constant 5 + JS block 18 + return literal 1 + aggregator 18 + note 3 ≈ 45; trimmed via inline expression density). Single file. Under threshold. **Rollback:** revert all 5 edits in `src/phase_z2_pipeline.py`. No data migration. Trace JSON loses `image_events` and image fail_reasons; legacy 5 keys + existing fail_reasons preserved. --- #### u2 — Selenium test file with 3 fixtures + chromedriver skip-guard **Files (1):** - `tests/phase_z2/test_phase_z2_step14_image_check.py` (NEW) **Skip-guard helper** (mirrors `pipeline.py:2082-2099` resolver order): ```python def _resolve_chromedriver() -> Path | None: from src.phase_z2_pipeline import PROJECT_ROOT for p in (PROJECT_ROOT / "chromedriver", PROJECT_ROOT / "chromedriver.exe"): if p.is_file(): return p which = shutil.which("chromedriver") return Path(which) if which else None @pytest.fixture def chromedriver_or_skip(): p = _resolve_chromedriver() if p is None: if os.environ.get("PHASE_Z_REQUIRE_SELENIUM") == "1": pytest.xfail("chromedriver unresolvable but PHASE_Z_REQUIRE_SELENIUM=1 (strict)") pytest.skip("chromedriver not resolvable on PROJECT_ROOT or PATH") return p ``` Note: `pytest.xfail(...)` raises `Failed` with `strict` semantics handled at session level. To match Stage 1 contract ("xfail strict — not skip — when PHASE_Z_REQUIRE_SELENIUM=1"), the helper raises via the `pytest.xfail` API directly inside the fixture. This causes the test to be reported as XFAIL (strict-friendly). If a future maintainer needs `xpassed`-detection semantics, the test signature can be decorated with `@pytest.mark.xfail(strict=True, condition=...)` — equivalent surface for CI. **Fixture builders:** ```python def _write_png(path: Path, w: int, h: int) -> None: from PIL import Image Image.new("RGB", (w, h), (200, 100, 50)).save(path, "PNG") def _write_html(html_path: Path, png_uri: str, *, in_zone: bool, render_w: int, render_h: int, zone_position: str = "top"): inner = ( f'
' f'' f'
' if in_zone else f'' ) html = ( '' '' f'
{inner}
' ) html_path.write_text(html, encoding="utf-8") ``` **Test cases (3, aligned with issue body Fixtures A/B/C):** 1. `test_fixture_A_no_distortion_passes(tmp_path, chromedriver_or_skip)` — - PNG 200×100, rendered 200×100, inside `.zone[data-zone-position="top"]`. - Expect: `result["image_events"][0]["aspect_delta"] < IMAGE_ASPECT_DELTA_TOL`, `result["passed"] is True`, no fail_reason containing `"image aspect mismatch"`. 2. `test_fixture_B_forced_distortion_fails(tmp_path, chromedriver_or_skip)` — - PNG 200×100, rendered 200×200, inside `.zone[data-zone-position="top"]`. - Expect: `aspect_delta > 0.30`, `aspect_mismatch is True`, `result["passed"] is False`, fail_reasons contains substring `"image aspect mismatch in zone--top"`. 3. `test_fixture_C_no_zone_ancestor_unknown(tmp_path, chromedriver_or_skip)` — - PNG 200×100, rendered 200×200, `` directly under `.slide-body` (no `.zone` wrapper). - Expect: `image_events[0]["zone_position"] == "unknown"`, `image_events[0]["zone_template_id"] == "?"`, fail_reasons contains `"image aspect mismatch in zone--unknown"`. **estimate_lines:** ~50 (imports 5 + skip-guard 12 + helpers 20 + 3 tests at ~5 lines each = ~52; trimmed via shared `_run_and_get` helper). **Tests under PHASE_Z_REQUIRE_SELENIUM=0:** skip cleanly when chromedriver absent. **Tests under PHASE_Z_REQUIRE_SELENIUM=1:** xfail strict when chromedriver absent; pass when present. **Rollback:** delete file. No production impact. --- ### Rollback plan (combined) - u2 rollback: delete test file. - u1 rollback: revert 5 edits in `src/phase_z2_pipeline.py` (constant + JS block + return literal + aggregator + note). All inverse-applicable from the unit's commit diff. - Combined order: u2 → u1. u1's production reverts must follow u2 deletion to avoid leaving orphaned tests that import a removed constant. ### Side effects / follow-up issues - **(deferred — IMP-15 실행-2, issue #46)** `table_events` standalone-table detection. Same JS extension pattern; independent commit. - **(deferred — IMP-15 실행-3)** Classifier consumer extension (`classify_visual_runtime_check`) emits category `image_aspect_mismatch` from `image_events[i].aspect_mismatch == True`. `visual_check_passed` flip — already correctly driven by `overflow["passed"]` which u1 sets to False on mismatch (no flip needed in 실행-3 if 실행-1 lands first; verify in 실행-3 evidence). - **(deferred — IMP-15 실행-4)** debug.json surfacing of `image_events` (with optional bbox px) + spec doc row update in `PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC.md` §3.1 / §3.4 (anchor sync for `IMAGE_ASPECT_DELTA_TOL = 0.05`). - **(verify in Stage 3)** Step 21 (PDF export) reads `slide_status.fail_reasons` and surfaces them in the PDF; no Step 21 code edit needed (verify by reading Step 21 site during Stage 3 u1 evidence capture). - **(IMP-01 / IMP-13 soft-link)** `image_events[]` is additive top-level alongside `frame_slot_metrics` and `zone_geometries_px` (IMP-01 trace layer). Same shape convention; no IMP-01/IMP-13 fields renamed/removed. --- ### IMPLEMENTATION_UNITS === IMPLEMENTATION_UNITS === - id: u1 summary: src/phase_z2_pipeline.py — add IMAGE_ASPECT_DELTA_TOL=0.05 module constant; extend run_overflow_check JS payload with traversal emitting image_events[] (naturalWidth/Height + clientWidth/Height + src + zone_position + zone_template_id + frame_slot_id); add Python aggregator branch computing aspect_natural/rendered/delta/mismatch and appending fail_reasons string "image aspect mismatch in zone--: natural NxN rendered NxN delta D.DDD" when delta > tol; rewrite Step 14 step-note to mark image half resolved (table half remains partial). files: [src/phase_z2_pipeline.py] tests: [] estimate_lines: 45 - id: u2 summary: tests/phase_z2/test_phase_z2_step14_image_check.py (NEW) — Selenium test file with chromedriver skip-guard (mirroring pipeline.py:2082-2099 resolver order; xfail strict under PHASE_Z_REQUIRE_SELENIUM=1) and 3 fixtures matching issue body Fixtures A/B/C: A=no distortion (200x100/200x100, passed=True), B=forced distortion (200x100 intrinsic, 200x200 rendered, fail_reason in zone--top, passed=False), C=no .zone ancestor (zone_position=='unknown', fail_reason in zone--unknown). PNG generated via Pillow into pytest tmp_path; HTML synthesized inline (no production template touched). files: [tests/phase_z2/test_phase_z2_step14_image_check.py] tests: [tests/phase_z2/test_phase_z2_step14_image_check.py] estimate_lines: 50 === EVIDENCE === - commit_sha at Stage 2 entry: `7a52cebfaa8b356c42a5de45c7241a3002615cab` (via `git rev-parse HEAD`). - Step 14 JS surface return literal verified at `src/phase_z2_pipeline.py:2211`: `return { slide: slideM, slide_body: bodyM, zones, frame_slot_metrics, zone_geometries_px };` — confirms u1 must extend return literal additively (6th key). - fail_reasons aggregator verified at `src/phase_z2_pipeline.py:2226-2252`: 5 existing branches (slide size / slide overflow / slide-body overflow / zone overflow / clipped_inner). u1 inserts image branch as 6th, after zone loop, preserving order. - Module constants block verified at `src/phase_z2_pipeline.py:118-129` (SLIDE_W=1280 … DEFAULT_ZONE_MIN_HEIGHT_PX=100). u1 inserts IMAGE_ASPECT_DELTA_TOL after line 129 — same module-scope band, importable. - Chromedriver resolver verified at `src/phase_z2_pipeline.py:2082-2099`: PROJECT_ROOT/chromedriver → PROJECT_ROOT/chromedriver.exe → fallback `webdriver.Chrome(options=options)` (PATH). u2 skip-guard mirrors first two entries + `shutil.which` for the PATH fallback. - Step 14 note verified at `src/phase_z2_pipeline.py:4358`: `"image / table 검사 부재 — Step 14 ⚠ partial."` — u1 rewrites *image* half to resolved while preserving table half. - Spec anchor claim audit: - `docs/architecture/PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC.md` §3.1 table (lines 86-95) — grep negative for `image_aspect_mismatch` and `0.05`. Issue-body anchor claim (§3.1) is incorrect. - `docs/architecture/PHASE-Z-PIPELINE-OVERVIEW.md:312,316` — `image_aspect_mismatch` listed as category name; no `0.05` value. - Conclusion: lock as locally-anchored with TODO comment; spec doc row addition deferred to 실행-4 (per issue body out-of-scope). - IMAGE_ASPECT_DELTA_TOL symbol grep verified absent in repo (Stage 1 evidence reproduced). - Test file path verified absent: `tests/phase_z2/test_phase_z2_step14_image_check.py` does not exist (Stage 1 evidence reproduced). - Selenium fixture strategy verified self-contained: `templates/phase_z2/slide_base.html:267` uses `data-zone-position` attribute on `.zone` only — u2 fixtures synthesize a `.zone` wrapper directly, no slide_base template dependency. No CSS Grid required because fixtures use explicit `width=/height=` on ``. - Pillow availability verified: project uses Pillow already (CLAUDE.md "이미지 크기 읽기: Pillow `Image.open().size`"). No new dependency. - chromedriver.exe presence verified at `D:\ad-hoc\kei\design_agent\chromedriver/win64/147.0.7727.117/chromedriver.exe`. Stage 3 may need to surface this path (PROJECT_ROOT/chromedriver{,.exe} resolver currently does NOT pick this up — Stage 1 chromedriver_candidates is shallow). This affects PHASE_Z_REQUIRE_SELENIUM=1 CI behaviour but does not change u1/u2 scope; flagged here for awareness. - Stage 1 scope_lock items 1-7 mapped: - #1 (JS measurement) → u1 - #2 (image_events[] emit) → u1 - #3 (fail_reasons image branch with locked string) → u1 - #4 (IMAGE_ASPECT_DELTA_TOL=0.05 module constant) → u1 - #5 (Selenium test with skip-guard + xfail strict) → u2 - #6 (zone_position='unknown' fallback) → u1 (JS `closest('.zone') ? attr : 'unknown'`) - #7 (Step note image half closed; table half partial) → u1 - Stage 1 guardrails honoured: - No reordering of non-image fail_reasons strings (u1 appends after zone loop). - No AI calls in Step 14 path (deterministic threshold). - IMAGE_ASPECT_DELTA_TOL locked at 0.05 (not lowered). - Skip vs xfail behaviour matches Stage 1 contract. - zone_position fallback string is literal "unknown". - No edits to templates/phase_z2/slide_base.html. - Stage 1 out_of_scope honoured: table_events untouched (실행-2), classifier consumer untouched (실행-3), debug.json untouched (실행-4), spec doc row untouched (실행-4), all Step ≠ 14 untouched. FINAL_CONSENSUS: YES