Compare commits

...
2 changed files with 130 additions and 14 deletions
+114 -5
View File
@@ -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 = (
"<ul>" + "".join(f"<li>{v}</li>" for v in _vfs) + "</ul>"
@@ -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 지만 "
+16 -9
View File
@@ -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 ─────