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:
kmlee1andClaude Opus 4.8 committed 2026-07-07 09:17:32 +09:00
1 parent a57b197c37
commit 63f4479b05
4 files changed
+392 -42

No files matched your search

+194 -34
View File
@@ -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 을 그대로 렌더하고 <details>
# 를 덧붙이기만 함 (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"):