diff --git a/src/phase_z2_pipeline.py b/src/phase_z2_pipeline.py index 20460ab..c0a6023 100644 --- a/src/phase_z2_pipeline.py +++ b/src/phase_z2_pipeline.py @@ -2936,6 +2936,27 @@ def write_overflow_error(run_dir: Path, overflow: dict) -> Path: # ─── Debug.json (single slide + zones[]) ─────────────────────── + +def _is_empty_shell_unit(u: CompositionUnit) -> bool: + """IMP-87 u1 — true when a CompositionUnit is the IMP-30 u4 empty-shell + placeholder (frame_template_id="__empty__" / label="empty_shell"). + + Used by compute_slide_status to redefine `full_mdx_coverage` over + rendered content units only: an empty-shell unit attaches the aligned + section_ids to a `__empty__` frame for layout purposes, but the slide + surface carries no MDX content for those sections. Counting it as + coverage would violate feedback_artifact_status_naming (overall/coverage + must reflect actual content state, not pipeline completion). + """ + if getattr(u, "frame_template_id", None) == "__empty__": + return True + if getattr(u, "label", None) == "empty_shell": + return True + if getattr(u, "merge_type", None) == "empty_shell": + return True + return False + + def compute_slide_status(sections: list[MdxSection], units: list[CompositionUnit], comp_debug: dict, @@ -2964,10 +2985,21 @@ def compute_slide_status(sections: list[MdxSection], Stage 1 Q3 + Codex #10 D4 lock.) """ aligned_ids = [s.section_id for s in sections] - covered = set() + # IMP-87 u1 — split coverage into content vs empty-shell cover. + # `covered` (legacy, kept for downstream display) still tracks every + # section attached to any selected unit. `content_covered` is the new + # honesty axis: only non-empty-shell units count as "rendered with + # content". Sections attached solely to an empty-shell placeholder + # are routed into `filtered_section_ids` so Step 20 cannot report + # full_mdx_coverage=True for an EMPTY-SHELL-only slide (the Case B + # honesty defect documented in IMP-87 Stage 1 / Codex #2). + covered: set = set() + content_covered: set = set() for u in units: covered.update(u.source_section_ids) - filtered_ids = sorted(set(aligned_ids) - covered) + if not _is_empty_shell_unit(u): + content_covered.update(u.source_section_ids) + filtered_ids = sorted(set(aligned_ids) - content_covered) full_coverage = len(filtered_ids) == 0 visual_passed = bool(overflow.get("passed", False)) @@ -3057,7 +3089,34 @@ def compute_slide_status(sections: list[MdxSection], "position": source_position, }) - if full_coverage and visual_passed: + # IMP-87 u2 — empty-shell vs content unit accounting (additive surface fields). + # Splits the selected units into the two classes that u1 already separates + # for coverage purposes, then promotes "no content unit at all" to an + # explicit overall enum value (EMPTY_SHELL_NO_CONTENT) so an EMPTY-SHELL-only + # slide cannot route through any of the four existing ladder outcomes that + # implicitly assume there is real content on the page. + # - empty_shell_units: units flagged by _is_empty_shell_unit (IMP-87 u1) + # - content_units : the remainder (real frame_template_id) + # - empty_shell_dominant: True when there is NO content unit and at least + # one empty-shell unit (the Case B mdx05 honesty defect surface) + empty_shell_units = [u for u in units if _is_empty_shell_unit(u)] + content_units = [u for u in units if not _is_empty_shell_unit(u)] + empty_shell_section_ids: set = set() + for u in empty_shell_units: + empty_shell_section_ids.update(getattr(u, "source_section_ids", []) or []) + content_rendered_section_ids: set = set() + for u in content_units: + content_rendered_section_ids.update(getattr(u, "source_section_ids", []) or []) + empty_shell_dominant = (len(content_units) == 0) and (len(empty_shell_units) >= 1) + + if empty_shell_dominant: + # IMP-87 u2 — EMPTY_SHELL_NO_CONTENT precedes the legacy 4-way ladder. + # Even if visual_check passed (placeholder zones don't overflow) and + # filtered_section_ids only lists the IMP-87 u1 routed empty-shell + # sections, this slide carries zero MDX content and must not look + # shippable. Honors feedback_artifact_status_naming. + overall = "EMPTY_SHELL_NO_CONTENT" + elif full_coverage and visual_passed: overall = "PASS" elif full_coverage and not visual_passed: overall = "RENDERED_WITH_VISUAL_REGRESSION" @@ -3118,6 +3177,15 @@ def compute_slide_status(sections: list[MdxSection], # IMP-30 u6 — additive provisional qualifiers (overall enum unchanged). "provisional_first_render_count": len(provisional_first_render_units), "provisional_first_render_units": provisional_first_render_units, + # IMP-87 u2 — empty-shell vs content unit accounting (additive surface). + # Names mirror provisional_first_render_*/fallback_selection_count shape + # so downstream readers (CLI tail in u3, status board UI, regression + # tests in u5) can pick them up without schema reshaping. + "empty_shell_unit_count": len(empty_shell_units), + "empty_shell_section_ids": sorted(empty_shell_section_ids), + "content_unit_count": len(content_units), + "content_rendered_section_ids": sorted(content_rendered_section_ids), + "empty_shell_dominant": empty_shell_dominant, "overall": overall, "note": ( "자동 파이프라인 결과 보고. review/UI 개념 X. final.html 파일명 != PASS 의미. " @@ -3125,7 +3193,9 @@ def compute_slide_status(sections: list[MdxSection], "adapter_needed_count > 0 = mapper 가 contract 와 안 맞아 자동 렌더 못 한 zone 존재. " "content_truncated_count > 0 = builder 가 truncate 한 zone 존재 (rendered 됐지만 일부 콘텐츠 손실). " "provisional_first_render_count > 0 = IMP-30 first-render invariant 가 작동한 unit 존재 " - "(empty_shell / chain_exhausted_provisional / 등 — needs user/AI adaptation)." + "(empty_shell / chain_exhausted_provisional / 등 — needs user/AI adaptation). " + "overall == EMPTY_SHELL_NO_CONTENT (IMP-87 u2) = content_unit_count==0 이고 " + "empty_shell_unit_count>=1 — 슬라이드에 실 콘텐츠 없음. PASS 보고 금지 (Case B honesty 가드)." ), } @@ -5929,7 +5999,18 @@ def run_phase_z2_mvp1( ) # Step 20 HTML — 최종 판정 시각 보고 _overall = slide_status.get("overall", "?") - _ov_class = "pass" if "PASS" in _overall else "fail" if "FAIL" in _overall or "REGRESSION" in _overall else "partial" + # IMP-87 u3 — EMPTY_SHELL_NO_CONTENT maps to fail styling (not partial). + # Placeholder-only slide is unshippable even when visual check passes, + # so final_status.html must render red, matching the CLI BLOCKED exit. + _ov_class = ( + "pass" if "PASS" in _overall + else "fail" if ( + "FAIL" in _overall + or "REGRESSION" in _overall + or _overall == "EMPTY_SHELL_NO_CONTENT" + ) + else "partial" + ) _vfs = slide_status.get("visual_fail_reasons") or [] _vfs_html = ( "" @@ -6056,6 +6137,34 @@ def run_phase_z2_mvp1( print(f" error : {err_path}", file=sys.stderr) sys.exit(1) + # IMP-87 u3 — EMPTY_SHELL_NO_CONTENT BLOCKED branch precedes the + # partial-coverage return so empty-shell-only runs cannot exit zero. + # Placeholder zones do not overflow, so visual_check_passed is True here; + # the diagnostic surfaces empty-shell counters from compute_slide_status. + if overall == "EMPTY_SHELL_NO_CONTENT": + print( + f"\n[Phase Z-2 IMP-87 u3] BLOCKED @ empty_shell_no_content ({overall})", + file=sys.stderr, + ) + print( + f" empty_shell_unit_count = {slide_status.get('empty_shell_unit_count', 0)}", + file=sys.stderr, + ) + print( + f" empty_shell_section_ids = {slide_status.get('empty_shell_section_ids') or []}", + file=sys.stderr, + ) + print( + f" content_unit_count = {slide_status.get('content_unit_count', 0)}", + file=sys.stderr, + ) + print( + " reason : every selected unit is empty_shell placeholder — " + "final.html 은 placeholder shell, ship 불가", + file=sys.stderr, + ) + sys.exit(1) + if not slide_status["full_mdx_coverage"]: print( f"\n[Phase Z-2 MVP-1.5b] PARTIAL — visual check OK 지만 " diff --git a/tests/test_phase_z2_imp30_first_render.py b/tests/test_phase_z2_imp30_first_render.py index fee5e4f..10f1237 100644 --- a/tests/test_phase_z2_imp30_first_render.py +++ b/tests/test_phase_z2_imp30_first_render.py @@ -1012,11 +1012,14 @@ def test_u6_empty_shell_unit_listed_with_empty_identifiers(): assert entry["selection_path"] == "empty_shell" assert entry["fallback_reason"] == "no_v4_rank_1_for_any_section" assert entry["v4_rank"] is None - # full_mdx_coverage holds because shell.source_section_ids covers every - # aligned section id — u4 deliberately sets this so coverage stays True - # under the terminal first-render invariant. - assert status["full_mdx_coverage"] is True - assert status["overall"] == "PASS" + # IMP-87 u4 inversion (Stage 1 anchor c53722ad): empty-shell-only runs + # MUST NOT report PASS / full_mdx_coverage. u1 redefines coverage over + # content units (frame_template_id == "__empty__" excluded) and u2 routes + # empty_shell_dominant slides into the EMPTY_SHELL_NO_CONTENT branch + # before the legacy 4-way ladder. Aligned section ids covered only by + # the shell unit are surfaced in filtered_section_ids instead. + assert status["full_mdx_coverage"] is False + assert status["overall"] == "EMPTY_SHELL_NO_CONTENT" # ─── u6 case 4 : mixed selection — provisional + normal units coexist ── @@ -1357,10 +1360,14 @@ def test_u7_e2e_zero_v4_empty_shell_status_surface(u7_patch_selector_deps): assert shell_entry["phase_z_status"] == "empty_shell" assert shell_entry["frame_template_id"] == "__empty__" assert shell_entry["source_section_ids"] == ["S1", "S2"] - # Coverage check — both sections counted as covered by the shell unit - # (rendered=True path; PASS enum unchanged by provisional qualifier). - assert status["full_mdx_coverage"] is True - assert status["overall"] == "PASS" + # IMP-87 u4 inversion (Stage 1 anchor c53722ad): even at the e2e level + # an empty-shell-only run must surface as EMPTY_SHELL_NO_CONTENT, not + # PASS. u1 excludes __empty__ frames from content coverage so the two + # aligned sections end up in filtered_section_ids; u2's + # empty_shell_dominant branch then short-circuits the overall ladder + # ahead of any visual_check verdict. + assert status["full_mdx_coverage"] is False + assert status["overall"] == "EMPTY_SHELL_NO_CONTENT" # ─── u7 case 3 : e2e normal path unchanged when opt-in flags both on ─────