diff --git a/src/phase_z2_pipeline.py b/src/phase_z2_pipeline.py index c72a3e8..12a13a2 100644 --- a/src/phase_z2_pipeline.py +++ b/src/phase_z2_pipeline.py @@ -25,6 +25,7 @@ MVP-1.5b spec : - mvp1.5b_test* : 본 모듈, 원래 설계 라인 합류 """ +import copy import hashlib import json import os @@ -6112,15 +6113,31 @@ def _unit_for_t28_5_source_ids( return None +# issue #16 — salvage 정책 차단 터미널 집합. 이 터미널에 도달했다는 것은 +# 자동 fit 레버(cross_zone/glue)가 소진되고 나머지(font/layout)는 정책 +# 잠금이라는 뜻 — popup escalation 이 유일한 무손실 자동 레버. +_SALVAGE_POLICY_BLOCKED_TERMINALS = frozenset({ + "layout_adjust_blocked_no_silent_layout_mutation", + "font_step_compression_blocked_no_font_shrink", +}) + + def _build_t28_5d_popup_targets( *, units: list[CompositionUnit], presentation_reselection: dict, + retry_trace: Optional[dict] = None, ) -> dict: """Select T28.5d popup targets from the T28.5c terminal trace. This is a deterministic bridge from T28.5c's abort/no-recovery signal onto the existing popup/details chain. It does not mutate units or HTML. + + issue #16 — 두 번째 트리거 추가: salvage cascade 가 정책 차단 터미널로 + 소진됐는데 overflow 가 남은 zone (기존 트리거의 + ``eligible_candidate_count == 0`` 조건은 이 분기에 미적용 — 그 후보들은 + 사용자 수동 경로일 뿐 자동 소비가 T19d 로 금지돼 있어, 후보가 있다는 + 이유로 무손실 popup 레버를 막으면 mdx04 처럼 영구 미발화가 됨). """ layout_reselection = presentation_reselection.get("layout_reselection") or {} terminal = ( @@ -6129,36 +6146,119 @@ def _build_t28_5d_popup_targets( == "escalate_to_t28_5d_or_presentation_not_ready" and layout_reselection.get("allowed") is False ) + salvage_exhausted = bool( + retry_trace + and retry_trace.get("salvage_attempted") + and not retry_trace.get("salvage_passed") + and retry_trace.get("salvage_terminal_action") + in _SALVAGE_POLICY_BLOCKED_TERMINALS + ) targets: list[dict] = [] - if terminal: - for zone in presentation_reselection.get("zones") or []: - overflow_px = int(zone.get("overflow_px") or 0) - overflow_x_px = int(zone.get("overflow_x_px") or 0) - if overflow_px <= 0 and overflow_x_px <= 0: - continue - if int(zone.get("eligible_candidate_count") or 0) != 0: - continue - source_ids = list(zone.get("source_section_ids") or []) - unit = _unit_for_t28_5_source_ids(units, source_ids) - if unit is None: - continue - targets.append({ - "position": zone.get("position"), - "source_section_ids": source_ids, - "current_template_id": zone.get("current_template_id"), - "overflow_px": overflow_px, - "overflow_x_px": overflow_x_px, - "manual_geometry_applied": bool(zone.get("manual_geometry_applied")), - }) + seen_positions: set = set() + for zone in presentation_reselection.get("zones") or []: + overflow_px = int(zone.get("overflow_px") or 0) + overflow_x_px = int(zone.get("overflow_x_px") or 0) + if overflow_px <= 0 and overflow_x_px <= 0: + continue + trigger_reason = None + if terminal and int(zone.get("eligible_candidate_count") or 0) == 0: + trigger_reason = "t28_5c_terminal_no_candidates" + elif salvage_exhausted: + trigger_reason = "salvage_exhausted_overflow_pressure" + if trigger_reason is None: + continue + source_ids = list(zone.get("source_section_ids") or []) + unit = _unit_for_t28_5_source_ids(units, source_ids) + if unit is None or zone.get("position") in seen_positions: + continue + seen_positions.add(zone.get("position")) + targets.append({ + "position": zone.get("position"), + "source_section_ids": source_ids, + "current_template_id": zone.get("current_template_id"), + "overflow_px": overflow_px, + "overflow_x_px": overflow_x_px, + "manual_geometry_applied": bool(zone.get("manual_geometry_applied")), + "trigger_reason": trigger_reason, + }) return { "triggered": bool(targets), "policy": "t28_5d_second_popup_pass_reuse_existing_chain", "terminal_next_step": presentation_reselection.get("next_step"), + "salvage_exhausted_trigger": salvage_exhausted, "target_count": len(targets), "targets": targets, } +# issue #16 — popup escalation 시 zone 본문을 예산 내로 트리밍하는 헬퍼. +# 기존 구조 결함: slide_base 는 partial_html 을 그대로 렌더하고
+# 를 덧붙이기만 함 (preview_text 는 렌더 경로 어디에서도 미소비) — popup +# 이 높이를 전혀 줄이지 못해 escalation 이 항상 실패했음. 트리밍된 라인의 +# 전문은 popup body(raw_content 원문)에 보존 — 자세히보기 원칙, 텍스트 +# 무손실 (rendered_text_coverage 는 details 내부 텍스트도 집계). +_POPUP_TRIM_LINE_PX = 18 # 13px font × 1.4lh 근사 (보수적) + + +def _trim_slot_payload_for_overflow( + slot_payload: dict, overflow_px: int, +) -> tuple[dict, int]: + """list-값 슬롯에서 overflow 해소에 필요한 만큼 라인을 제거 (긴 슬롯부터 + round-robin, 슬롯당 최소 1라인 유지, label/title/_slot_count 무접촉). + + 두 형태를 지원: + - top-level list-of-str 슬롯 (예: ``pill_1_body``) + - list-of-dict 컬럼 내부의 list-of-str 값 (예: ``pillars[i].lines`` — + f13b three_parallel_requirements 계열) + + Returns (새 payload, 제거 라인 수). 라인 경계에서만 자름 — 라인 내부 + truncate 금지 (IMP-35 preview 철학과 동일). + """ + if overflow_px <= 0 or not isinstance(slot_payload, dict): + return dict(slot_payload or {}), 0 + need = int(overflow_px // _POPUP_TRIM_LINE_PX) + 1 + new_payload = copy.deepcopy(slot_payload) + + def _is_lines(v) -> bool: + # 라인 리스트 두 형태: list-of-str (verbatim builder 계열) 또는 + # list-of-{text,...} line-object (f13b pillars.sections.text_lines 계열) + if not isinstance(v, list) or not v: + return False + if all(isinstance(x, str) for x in v): + return True + return all( + isinstance(x, dict) and isinstance(x.get("text"), str) for x in v + ) + + # trimmable pool: (container_dict, key) 참조를 재귀 수집 (깊이 제한 5) + pools: list[tuple[dict, str]] = [] + + def _collect(node, depth: int = 0) -> None: + if depth > 5: + return + if isinstance(node, dict): + for k, v in node.items(): + if _is_lines(v): + pools.append((node, k)) + elif isinstance(v, (dict, list)): + _collect(v, depth + 1) + elif isinstance(node, list): + for item in node: + if isinstance(item, (dict, list)): + _collect(item, depth + 1) + + _collect(new_payload) + trimmed = 0 + while trimmed < need: + candidates = [(c, k) for c, k in pools if len(c[k]) > 1] + if not candidates: + break + container, key = max(candidates, key=lambda p: len(p[0][p[1]])) + container[key] = container[key][:-1] + trimmed += 1 + return new_payload, trimmed + + def _t28_5d_popup_escalation_plan(target: dict) -> dict: return { "action": "details_popup_escalation", @@ -6195,16 +6295,23 @@ def _attempt_t28_5d_popup_second_pass( postprocess_html=None, render_func=render_slide, overflow_check_func=run_overflow_check, + retry_trace: Optional[dict] = None, ) -> dict: """T28.5d: re-render final.html with existing popup/details payloads. The pass is intentionally after T28.5c because its trigger depends on presentation_fit + reselection trace. It is not marker-only: a candidate is rendered, measured, and promoted only if the visual check passes. + + issue #16 — 두 가지 확장: + 1. salvage 정책 차단 소진 시에도 트리거 (retry_trace 기반) + 2. 대상 zone 의 본문 라인을 overflow 예산만큼 트리밍 — popup 이 실제로 + 높이를 줄이도록 (전문은 popup body 에 보존, 무손실) """ target_plan = _build_t28_5d_popup_targets( units=units, presentation_reselection=presentation_reselection, + retry_trace=retry_trace, ) result = { "triggered": target_plan["triggered"], @@ -6244,30 +6351,81 @@ def _attempt_t28_5d_popup_second_pass( ) zone.update(popup_payload) zone["t28_5d_popup_escalated"] = True + # issue #16 — 본문 라인 트리밍으로 높이 실감소 (전문은 popup body). + trimmed_payload, trimmed_lines = _trim_slot_payload_for_overflow( + zone.get("slot_payload") or {}, + int(target.get("overflow_px") or 0), + ) + if trimmed_lines: + zone["slot_payload"] = trimmed_payload + zone["popup_trimmed_lines"] = trimmed_lines applied_targets.append({ **target, "popup_plan": plan, + "trimmed_lines": trimmed_lines, }) if not applied_targets: result["failure_reason"] = "target_units_or_zones_not_found" return result - candidate_html = render_func( - slide_title, - slide_footer, - zones_candidate, - layout_preset, - layout_css, - gap_px=gap_px, - ) - if postprocess_html is not None: - candidate_html = postprocess_html(candidate_html) - + # issue #16 — 측정 피드백 트림 루프 (최대 3회). 라인당 px 추정치(18)가 + # frame 마다 다를 수 있어, 후보 렌더의 측정 잔여 excess 로 추가 트림. candidate_path = run_dir / "t28_5d_popup_candidate.html" - candidate_path.write_text(candidate_html, encoding="utf-8") - candidate_overflow = overflow_check_func(candidate_path) - passed = bool(candidate_overflow.get("passed", False)) + target_positions = {t.get("position") for t in applied_targets} + candidate_html = "" + candidate_overflow: dict = {} + passed = False + for _trim_round in range(3): + candidate_html = render_func( + slide_title, + slide_footer, + zones_candidate, + layout_preset, + layout_css, + gap_px=gap_px, + ) + if postprocess_html is not None: + candidate_html = postprocess_html(candidate_html) + candidate_path.write_text(candidate_html, encoding="utf-8") + candidate_overflow = overflow_check_func(candidate_path) + passed = bool(candidate_overflow.get("passed", False)) + if passed: + break + # 대상 zone 의 측정 잔여 excess 산출 → 추가 트림 + extra_trimmed = 0 + for oz in candidate_overflow.get("zones") or []: + pos = oz.get("position") + if pos not in target_positions: + continue + remaining = max( + int(round(float(oz.get("excess_y") or 0))), + max( + [int(round(float(c.get("excess_y") or 0))) + for c in oz.get("clipped_inner") or []] or [0] + ), + ) + if remaining <= 0: + continue + zone = zone_by_position.get(pos) + if zone is None: + continue + more_payload, more = _trim_slot_payload_for_overflow( + zone.get("slot_payload") or {}, remaining, + ) + if more: + zone["slot_payload"] = more_payload + zone["popup_trimmed_lines"] = ( + int(zone.get("popup_trimmed_lines") or 0) + more + ) + for t in applied_targets: + if t.get("position") == pos: + t["trimmed_lines"] = ( + int(t.get("trimmed_lines") or 0) + more + ) + extra_trimmed += more + if extra_trimmed == 0: + break # 더 줄일 여지 없음 — 정직 실패 result.update({ "attempted": True, "passed": passed, @@ -11247,6 +11405,8 @@ def run_phase_z2_mvp1( override_slide_css=override_slide_css, mdx_source_text=mdx_source_text, ), + # issue #16 — salvage 정책 차단 소진 트리거용 (retry_trace 기반) + retry_trace=retry_trace, ) slide_status["presentation_popup"] = t28_5d_popup_result if t28_5d_popup_result.get("promoted"): diff --git a/tests/regression/fixtures/89a_pre_baseline_sha.json b/tests/regression/fixtures/89a_pre_baseline_sha.json index 649b665..63b1b74 100644 --- a/tests/regression/fixtures/89a_pre_baseline_sha.json +++ b/tests/regression/fixtures/89a_pre_baseline_sha.json @@ -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", diff --git a/tests/regression/fixtures/imp95_pre_baseline_sha.json b/tests/regression/fixtures/imp95_pre_baseline_sha.json index 05addc9..35ea54a 100644 --- a/tests/regression/fixtures/imp95_pre_baseline_sha.json +++ b/tests/regression/fixtures/imp95_pre_baseline_sha.json @@ -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", diff --git a/tests/test_phase_z2_issue16_popup_overflow_escalation.py b/tests/test_phase_z2_issue16_popup_overflow_escalation.py new file mode 100644 index 0000000..701bd60 --- /dev/null +++ b/tests/test_phase_z2_issue16_popup_overflow_escalation.py @@ -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 을 그대로 렌더 +
추가 — 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"