구조 결함 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:
1 parent
a57b197c37
commit
63f4479b05
4 files changed
+392
-42
No files matched your search
+194
-34
@@ -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"):
|
||||
|
||||
Reference in new issue
Block a user