untracked files on main: dceb101 feat(#63): IMP-34 R1 donor capacity measured bound (u1+u2)

This commit is contained in:
2026-05-21 22:07:41 +09:00
commit 8f085a28d3
3220 changed files with 985495 additions and 0 deletions
+285
View File
@@ -0,0 +1,285 @@
[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 8695) 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" — `<img>` 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 `<img>` 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 118129 region); importable as `from src.phase_z2_pipeline import IMAGE_ASPECT_DELTA_TOL` |
| A5 | "`fail_reasons` includes `image aspect mismatch in zone--<pos>: ...`" | 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 `<img width=200 height=100>` 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, `<img width=200 height=200>` 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()` `<img>` 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 `<img>` 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 — <img> 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'<div class="zone" data-zone-position="{zone_position}" data-template-id="t1">'
f'<img src="{png_uri}" width="{render_w}" height="{render_h}">'
f'</div>'
if in_zone
else f'<img src="{png_uri}" width="{render_w}" height="{render_h}">'
)
html = (
'<!doctype html><html><head><meta charset="utf-8">'
'<style>html,body{margin:0;padding:0;}'
'.slide{width:1280px;height:720px;position:relative;}'
'.slide-body{width:1180px;height:585px;position:absolute;left:50px;top:76px;}'
'.zone{position:relative;}</style></head>'
f'<body><div class="slide"><div class="slide-body">{inner}</div></div></body></html>'
)
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, `<img>` 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 <img> 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--<pos>: 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 `<img>`.
- 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 <img> 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