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 = ( "