feat(#16): overflow 압력 기반 popup escalation — mdx04 최초 PASS (#98 추천 3)

구조 결함 2개 수정으로 IMP-35 popup 레버를 실동작화:

1. 트리거 모순 해소: 기존 T28.5d 트리거는 eligible_candidate_count==0 요구
   — 후보가 '존재만' 해도 미발화인데 그 후보의 자동 소비는 T19d 로 금지
   → 영구 미발화 (mdx04 재현). salvage 정책 차단 터미널
   (T19d layout / T28.5 font) 소진 시 overflow zone 을 대상으로 하는
   두 번째 트리거 추가 (salvage_exhausted_overflow_pressure).

2. 높이 실감소: slide_base 는 partial 을 그대로 렌더 + <details> 추가일 뿐
   (preview_text 는 렌더 경로 미소비 — popup 이 높이를 못 줄였음).
   _trim_slot_payload_for_overflow 로 본문 라인을 예산만큼 트리밍 —
   재귀 수집 (list-of-str + {text,indent} line-object, f13b 3중 중첩 포함),
   라인 경계만, 슬롯당 최소 1라인, label/title 무접촉.
   측정 피드백 루프 (최대 3회): 후보 렌더의 잔여 excess 로 추가 트림.
   트리밍 전문은 popup body(raw_content 원문)에 보존 — 텍스트 무손실.

실전 검증:
- mdx04: RENDERED_WITH_VISUAL_REGRESSION → **PASS** (trimmed=6, coverage missing 0,
  promoted). presentation_ready=False 는 density tight — #15 게이트 정직 판정
- mdx01/02/03/05: 오발화 0, 상태 불변
- SHA baseline 재캡처 (mdx04 final.html 정당 변경), 전체 게이트 1027 passed

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-07 09:17:32 +09:00
co-authored by Claude Opus 4.8
parent a57b197c37
commit 63f4479b05
4 changed files with 392 additions and 42 deletions
@@ -2,7 +2,7 @@
"schema_version": 2,
"axis": "IMP-89 89-a u4 — final.html SHA baseline captured via FULL run_phase_z2_mvp1 pipeline (flag OFF / default)",
"description": "Frozen SHA-256 of `final.html` bytes (the artifact written to disk at src/phase_z2_pipeline.py:5994-5996) captured by running the full Phase Z pipeline end-to-end for each mdx 01-05 under PHASE_Z_B4_MAPPER_SOURCE=OFF. Under flag OFF the 89-a selector `_select_mapper_template_id(plan, T)` returns `T` verbatim, so the mapper input is byte-identical to the pre-89-a legacy call shape `map_mdx_to_slots(section, unit.frame_template_id)` — the rendered HTML and therefore the final.html SHA match the pre-89-a baseline. The u4 regression test runs the same pipeline shape under flag OFF and asserts SHA equality. Regenerate only when an upstream mapper/render/template delta is deliberately reviewed and accepted.",
"captured_at_utc": "2026-07-03T03:39:01Z",
"captured_at_utc": "2026-07-07T00:11:02Z",
"renderer": {
"entrypoint": "src.phase_z2_pipeline.run_phase_z2_mvp1",
"write_site": "src/phase_z2_pipeline.py:5994-5996",
@@ -40,9 +40,9 @@
"04.mdx": {
"mdx_file": "04.mdx",
"run_id": "89a_baseline_04",
"final_html_size_bytes": 40206,
"sha256": "8ba35bc6642b0e651cd46b9216070bd49052b016807611d9771fd630ca7a2613",
"pipeline_exit_code": 1
"final_html_size_bytes": 42265,
"sha256": "9c680c6f99c2e401f72edfdaf65e527e01263223eaaee70b9af75cb273ad3d3b",
"pipeline_exit_code": null
},
"05.mdx": {
"mdx_file": "05.mdx",
@@ -2,7 +2,7 @@
"schema_version": 1,
"axis": "IMP-95 u8 — final.html SHA baseline captured via FULL run_phase_z2_mvp1 pipeline under PHASE_Z_B4_V4_EVIDENCE=OFF and PHASE_Z_B4_MAPPER_SOURCE=OFF (defaults)",
"description": "Frozen SHA-256 of `final.html` bytes (production write site src/phase_z2_pipeline.py:5994-5996) for mdx 01/02/04/05 under PHASE_Z_B4_V4_EVIDENCE OFF. Under flag OFF, IMP-95 (u1~u7) is strictly no-op for final.html (planner branch falls through to legacy _select_frame at u3; u4/u5/u6 additive telemetry confined to placement_trace per the trace-only docstring at src/phase_z2_pipeline.py:86). The u8 regression test asserts SHA equality with these frozen values, so any future code change that drifts the flag-OFF render output produces a mismatch and breaks the test. mdx 03 is excluded per Stage 2 u8 scope (mdx 03 정비 LOCK). Regenerate only when an upstream delta is reviewed and accepted as the new pre-IMP-95 reference.",
"captured_at_utc": "2026-07-03T00:02:27Z",
"captured_at_utc": "2026-07-07T00:09:32Z",
"renderer": {
"entrypoint": "src.phase_z2_pipeline.run_phase_z2_mvp1",
"write_site": "src/phase_z2_pipeline.py:5994-5996",
@@ -32,9 +32,9 @@
"04.mdx": {
"mdx_file": "04.mdx",
"run_id": "imp95_baseline_04",
"final_html_size_bytes": 40206,
"sha256": "8ba35bc6642b0e651cd46b9216070bd49052b016807611d9771fd630ca7a2613",
"pipeline_exit_code": 1
"final_html_size_bytes": 42265,
"sha256": "9c680c6f99c2e401f72edfdaf65e527e01263223eaaee70b9af75cb273ad3d3b",
"pipeline_exit_code": null
},
"05.mdx": {
"mdx_file": "05.mdx",
@@ -0,0 +1,190 @@
"""issue #16 — overflow 압력 기반 popup escalation 테스트 (MDX04 height-fit).
배경 (실측):
- salvage cascade 실효 수단은 cross_zone/glue 2단뿐 (font_step=T28.5 차단,
layout_adjust=T19d 차단) — 소진 시 popup 이 유일한 무손실 자동 레버
- 기존 T28.5d 트리거는 eligible_candidate_count==0 요구 → 후보가 '존재만'
해도 영구 미발화 (mdx04: 후보 2개 있으나 자동 소비는 정책 금지)
- slide_base 는 partial 을 그대로 렌더 + <details> 추가 — preview_text 는
렌더 미소비라 popup 이 높이를 못 줄였음 → 라인 트리밍으로 실감소
계약:
- 트리밍은 라인 경계에서만, 슬롯당 최소 1라인, label/title 무접촉
- 트리밍 전문은 popup body(raw_content 원문)에 보존 — 텍스트 무손실
- promote 는 candidate visual pass 시에만 (기존 정책 유지)
"""
from __future__ import annotations
from types import SimpleNamespace
from src.phase_z2_pipeline import (
_build_t28_5d_popup_targets,
_trim_slot_payload_for_overflow,
)
def _unit(ids):
return SimpleNamespace(source_section_ids=list(ids), raw_content="- 본문", title="t")
def _reselection(zones, *, triggered=False, next_step="frame_or_layout_candidate_available",
layout_allowed=True):
return {
"triggered": triggered,
"next_step": next_step,
"layout_reselection": {"allowed": layout_allowed},
"zones": zones,
}
_BLOCKED_RT = {
"salvage_attempted": True,
"salvage_passed": False,
"salvage_terminal_action": "layout_adjust_blocked_no_silent_layout_mutation",
}
# ── 트리거 확장 ─────────────────────────────────────────────────────
def test_salvage_exhausted_triggers_even_with_candidates():
"""mdx04 재현: 후보 2개 존재 + salvage 정책 차단 소진 → 트리거."""
zones = [{
"position": "bottom", "source_section_ids": ["04-2"],
"current_template_id": "three_parallel_requirements",
"overflow_px": 70, "eligible_candidate_count": 2,
}]
plan = _build_t28_5d_popup_targets(
units=[_unit(["04-2"])],
presentation_reselection=_reselection(zones),
retry_trace=_BLOCKED_RT,
)
assert plan["triggered"] is True
assert plan["salvage_exhausted_trigger"] is True
assert plan["targets"][0]["trigger_reason"] == "salvage_exhausted_overflow_pressure"
def test_font_step_block_terminal_also_triggers():
zones = [{"position": "top", "source_section_ids": ["05-1"],
"overflow_px": 30, "eligible_candidate_count": 1}]
rt = {**_BLOCKED_RT,
"salvage_terminal_action": "font_step_compression_blocked_no_font_shrink"}
plan = _build_t28_5d_popup_targets(
units=[_unit(["05-1"])],
presentation_reselection=_reselection(zones), retry_trace=rt,
)
assert plan["triggered"] is True
def test_no_overflow_zone_not_targeted():
zones = [{"position": "top", "source_section_ids": ["01-1"],
"overflow_px": 0, "eligible_candidate_count": 0}]
plan = _build_t28_5d_popup_targets(
units=[_unit(["01-1"])],
presentation_reselection=_reselection(zones), retry_trace=_BLOCKED_RT,
)
assert plan["triggered"] is False
def test_salvage_passed_does_not_trigger_new_branch():
zones = [{"position": "bottom", "source_section_ids": ["04-2"],
"overflow_px": 70, "eligible_candidate_count": 2}]
rt = {"salvage_attempted": True, "salvage_passed": True,
"salvage_terminal_action": None}
plan = _build_t28_5d_popup_targets(
units=[_unit(["04-2"])],
presentation_reselection=_reselection(zones), retry_trace=rt,
)
assert plan["triggered"] is False
def test_legacy_terminal_trigger_preserved():
"""기존 트리거(터미널 + 후보 0)는 retry_trace 없이도 동작 유지."""
zones = [{"position": "top", "source_section_ids": ["05-1"],
"overflow_px": 40, "eligible_candidate_count": 0}]
plan = _build_t28_5d_popup_targets(
units=[_unit(["05-1"])],
presentation_reselection=_reselection(
zones, triggered=True,
next_step="escalate_to_t28_5d_or_presentation_not_ready",
layout_allowed=False,
),
)
assert plan["triggered"] is True
assert plan["targets"][0]["trigger_reason"] == "t28_5c_terminal_no_candidates"
# ── 라인 트리밍 ─────────────────────────────────────────────────────
def test_trim_removes_lines_from_longest_slot_first():
payload = {
"title": "제목",
"pillar_1_body": ["a", "b", "c", "d", "e"],
"pillar_2_body": ["x", "y"],
"pillar_1_label": "라벨",
}
new, n = _trim_slot_payload_for_overflow(payload, overflow_px=70)
# 70px → ceil(70/18)+... = 70//18+1 = 4 lines
assert n == 4
assert new["title"] == "제목" # 비-list 무접촉
assert new["pillar_1_label"] == "라벨"
assert len(new["pillar_1_body"]) + len(new["pillar_2_body"]) == 7 - 4
assert len(new["pillar_2_body"]) >= 1 # 슬롯당 최소 1라인
# 원본 불변 (새 dict)
assert len(payload["pillar_1_body"]) == 5
def test_trim_never_empties_a_slot():
payload = {"body": ["only-line"]}
new, n = _trim_slot_payload_for_overflow(payload, overflow_px=100)
assert n == 0
assert new["body"] == ["only-line"]
def test_trim_zero_overflow_noop():
payload = {"body": ["a", "b"]}
new, n = _trim_slot_payload_for_overflow(payload, overflow_px=0)
assert n == 0 and new == payload
def test_trim_stops_when_all_slots_at_minimum():
payload = {"a": ["1", "2"], "b": ["1", "2"]}
new, n = _trim_slot_payload_for_overflow(payload, overflow_px=500)
assert n == 2 # 각 슬롯 1라인까지만
assert new["a"] == ["1"] and new["b"] == ["1"]
def test_trim_nested_list_of_dict_columns():
"""중첩 1레벨 — pillars[i].lines 트리밍."""
payload = {"title": "t", "pillars": [
{"label": "A", "lines": ["1", "2", "3", "4"]},
{"label": "B", "lines": ["1", "2", "3", "4"]},
]}
new, n = _trim_slot_payload_for_overflow(payload, overflow_px=70)
assert n == 4
assert new["pillars"][0]["label"] == "A" # label 무접촉
assert len(new["pillars"][0]["lines"]) >= 1
total = sum(len(p["lines"]) for p in new["pillars"])
assert total == 8 - 4
assert len(payload["pillars"][0]["lines"]) == 4 # 원본 불변 (deepcopy)
def test_trim_f13b_real_shape_text_line_objects():
"""mdx04 실측 형태 — pillars[i].sections[j].text_lines[k]={'text':...}
3중 중첩 + line-object 리스트 (최초 두 구현이 못 찾던 케이스)."""
def lines(n):
return [{"text": f"내용 {i}", "indent": 0} for i in range(n)]
payload = {"title": "2. DX 추진의 실태", "pillars": [
{"label": "2.1", "sections": [{"heading": "", "text_lines": lines(9)}]},
{"label": "2.2", "sections": [{"heading": "", "text_lines": lines(9)}]},
]}
new, n = _trim_slot_payload_for_overflow(payload, overflow_px=70)
assert n == 4 # 70//18+1
total = sum(
len(s["text_lines"]) for p in new["pillars"] for s in p["sections"]
)
assert total == 18 - 4
# 원본 불변 + label/heading 무접촉
assert len(payload["pillars"][0]["sections"][0]["text_lines"]) == 9
assert new["pillars"][0]["label"] == "2.1"