Files
C.E.L_Slide_test2/src/phase_z2_retry.py
T
KyeongminandClaude Opus 4.7 dceb10129f feat(#63): IMP-34 R1 donor capacity measured bound (u1+u2)
Bound donor capacity in plan_zone_ratio_retry by min(static_slack,
max(0, clientHeight-scrollHeight)) when both Step 14 measured fields
are present; fall back to static contract slack when absent. Prevents
the donor from being over-allocated when full-but-not-overflowing,
avoiding a wasted Selenium rerender before cascade falls to
cross_zone_redistribute.

- src/phase_z2_retry.py: planner block L122-157 only; donor filter
  (L107-112), slack<=0 gate, base_plan, greedy aggregation untouched.
  Adds measured_empty_px + slack_bound_source telemetry to
  donor_candidates_considered (additive only).
- tests/phase_z2/test_phase_z2_retry_measured_bound.py: 5-axis
  regression (static_fallback / measured<static / measured>=static /
  measured==0 excludes / filter+bool guard).

Guardrails honored: V4 rank-1 frame lock preserved, no frame_swap,
no spacing/padding/gap/line-height/font shrink, no content drop,
no MDX 03/04/05 branching, no Step 14 schema mutation. Static
fallback idempotent when measured fields absent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 21:37:41 +09:00

431 lines
18 KiB
Python

"""Phase Z-2 zone_ratio_retry action v0 (A3 — 실제 zone redistribution 구현).
router 가 *제안* 한 zone_ratio_retry action 의 **실행 layer**.
원칙 (A3 locked rules — 사용자 잠금 7+1) :
1. retry budget = 1 — 한 번만 시도
2. slide / slide-body / title / divider / footer / zone gap 모두 고정
(공통 spacing 깎기 금지)
3. 조정 대상 = router 가 지목한 *target zone* 만 height 증가
4. donor 선택 기준 :
- 같은 layout 의 sibling zone
- visual_check 통과 (이 zone 자체엔 overflow 없음)
- capacity_fit 가 ok
- 현재 height > min_height_px (slack > 0)
- donor min_height_px 아래로 줄지 X
- 여러 후보면 slack 가장 큰 것부터 (greedy)
- 부족 시 retry 실패
5. target_added_px = observed excess_y + safety_margin (small fixed)
— donor 가 min_height 아래로 가면 실패
6. retry 후 status :
- 성공 → PASS 가능
- 실패 → RENDERED_WITH_VISUAL_REGRESSION 유지 (CSS/padding/tolerance 보정 X)
7. debug trace 필수 (retry_attempted / target / donor / before/after / passed / reason)
8. revert 정책 ((b)) :
- redistribution check 실패 → rerender 안 함, original final.html 유지
- rerender 후 visual_check 실패 → original 로 revert (final.html 변경 X),
retried_candidate.html 은 *진단 artifact* 로만 별도 보관
- retry 성공 시에만 final.html = retried version
본 module 은 *plan + apply layer*. rerender / final.html 갱신 / revert 는 pipeline 이.
"""
from __future__ import annotations
import math
from typing import Optional
# 작은 고정 safety margin — 실험적 default. debug 에 기록.
DEFAULT_SAFETY_MARGIN_PX = 4
def plan_zone_ratio_retry(
*,
debug_zones: list[dict],
overflow: dict,
fit_classification: dict,
router_decision: dict,
safety_margin_px: int = DEFAULT_SAFETY_MARGIN_PX,
) -> Optional[dict]:
"""zone_ratio_retry 의 redistribution plan 을 산출.
*plan 만*. 실제 height 적용 / rerender X (caller 가 처리).
Returns:
None : retry 시도 자체가 불필요 (router 가 zone_ratio_retry 제안 X)
dict : retry attempt 정보 (feasible 여부 + 상세)
feasible=True 이면 caller 가 zones_after 로 layout_css 재구성 + rerender 시도.
feasible=False 이면 caller 는 retry 포기 (original final.html 유지).
"""
if not router_decision.get("router_active"):
return None
# zone_ratio_retry 가 router 제안에 포함된 첫 classification 을 target 으로
target_cls = None
for cls in fit_classification.get("classifications", []) or []:
if cls.get("proposed_action") == "zone_ratio_retry":
target_cls = cls
break
if target_cls is None:
return None # 다른 action (popup / reselect) — 본 retry 대상 아님
target_zone_position = target_cls.get("zone_position")
target_excess_y = float(target_cls.get("inputs", {}).get("excess_y", 0))
# round up to integer (subpixel 끼면 부족할 수 있음)
target_added_px = int(math.ceil(target_excess_y)) + int(safety_margin_px)
# zones_before — debug_zones 의 height_px 를 모음
zones_before: dict[str, int] = {}
zone_min_by_pos: dict[str, int] = {}
for dz in debug_zones:
pos = dz.get("position")
if pos is None:
continue
h = dz.get("height_px")
m = dz.get("min_height_px")
if h is None or m is None:
continue
zones_before[pos] = int(h)
zone_min_by_pos[pos] = int(m)
# overflow zone 별 visual fail 정보
overflow_zone_status: dict[str, dict] = {}
for z in overflow.get("zones", []) or []:
overflow_zone_status[z.get("position")] = z
# donor 후보 식별
donor_candidates: list[dict] = []
for dz in debug_zones:
pos = dz.get("position")
if pos is None or pos == target_zone_position:
continue
# rule 4-(a) sibling 확인은 layout 내 sibling = 같은 zones list 안에 있으면 OK
# (본 함수는 1 layout 내 zones 만 받음)
# rule 4-(b) visual_check 통과 — 이 zone 에 자체 overflow / clipped_inner 없음
zinfo = overflow_zone_status.get(pos, {})
zone_self_overflow = bool(zinfo.get("overflowed"))
zone_inner_clipped = bool(zinfo.get("clipped_inner"))
if zone_self_overflow or zone_inner_clipped:
continue
# rule 4-(c) capacity_fit 가 ok
cap_status = (
(dz.get("composition_rationale") or {}).get("capacity_fit", {}).get("fit_status")
)
# 'ok' 아니거나 missing/unknown 이면 보수적으로 제외 (no_contract 는 허용 — capacity_fit 자체 부재)
if cap_status not in {"ok", "no_contract", None}:
continue
# rule 4-(d) 현재 height > min_height
# IMP-34 u1: donor capacity bounded by measured empty space
# (clientHeight - scrollHeight from Step 14) when both fields are present,
# falling back to static contract slack when absent. Prevents the donor
# from being over-allocated when it is already full but not overflowing.
height = zones_before.get(pos)
min_h = zone_min_by_pos.get(pos)
if height is None or min_h is None:
continue
static_slack = height - min_h
client_h = zinfo.get("clientHeight")
scroll_h = zinfo.get("scrollHeight")
if (
isinstance(client_h, (int, float))
and isinstance(scroll_h, (int, float))
and not isinstance(client_h, bool)
and not isinstance(scroll_h, bool)
):
measured_empty_px = max(0, int(client_h) - int(scroll_h))
slack = min(static_slack, measured_empty_px)
slack_bound_source = "measured_bound"
else:
measured_empty_px = None
slack = static_slack
slack_bound_source = "static_fallback"
if slack <= 0:
continue
donor_candidates.append({
"position": pos,
"current_height": height,
"min_height": min_h,
"slack": slack,
"capacity_fit_status": cap_status,
"measured_empty_px": measured_empty_px,
"slack_bound_source": slack_bound_source,
})
# rule 4-(f) 여러 후보면 slack 가장 큰 것부터
donor_candidates.sort(key=lambda d: d["slack"], reverse=True)
# base plan dict (failure / success 공용)
base_plan = {
"target_zone_position": target_zone_position,
"target_excess_y": target_excess_y,
"target_added_px": target_added_px,
"safety_margin_px_used": int(safety_margin_px),
"donor_candidates_considered": donor_candidates,
"zones_before": dict(zones_before),
}
if not donor_candidates:
return {
**base_plan,
"feasible": False,
"donor_zone_position": None,
"donor_reduced_px": 0,
"zones_after": dict(zones_before),
"failure_reason": (
f"no donor candidates eligible (sibling visual_check OK + "
f"capacity_fit ok/no_contract + slack > 0)"
),
}
# IMP-12 u1 : multi-donor greedy aggregation (slack-desc 순서대로 합산)
aggregate_slack_available = sum(d["slack"] for d in donor_candidates)
if aggregate_slack_available < target_added_px:
return {
**base_plan,
"feasible": False,
"donor_zone_position": donor_candidates[0]["position"],
"donor_max_slack": donor_candidates[0]["slack"],
"donor_reduced_px": 0,
"donors_used": [],
"aggregate_slack_used": 0,
"aggregate_slack_available": aggregate_slack_available,
"zones_after": dict(zones_before),
"failure_reason": (
f"primary donor '{donor_candidates[0]['position']}' slack "
f"{donor_candidates[0]['slack']}px (aggregate "
f"{aggregate_slack_available}px across {len(donor_candidates)} "
f"candidate(s)) < target_added_px {target_added_px}px "
f"(excess_y {target_excess_y} + safety_margin {safety_margin_px})."
),
}
# feasible — greedy aggregation: 각 donor 에서 필요한 만큼만 차감
zones_after = dict(zones_before)
zones_after[target_zone_position] = zones_before[target_zone_position] + target_added_px
donors_used: list[dict] = []
remaining = target_added_px
for donor in donor_candidates:
if remaining <= 0:
break
take = min(donor["slack"], remaining)
zones_after[donor["position"]] = zones_before[donor["position"]] - take
donors_used.append({
"position": donor["position"],
"reduced_px": take,
"slack_before": donor["slack"],
"slack_after": donor["slack"] - take,
})
remaining -= take
primary_donor = donors_used[0]
return {
**base_plan,
"feasible": True,
"donor_zone_position": primary_donor["position"],
"donor_reduced_px": primary_donor["reduced_px"],
"donors_used": donors_used,
"aggregate_slack_used": target_added_px,
"aggregate_slack_available": aggregate_slack_available,
"zones_after": zones_after,
}
def apply_retry_to_layout_css(layout_css: dict, plan: dict, zones_data: list[dict],
total_height: int, gap_px: int) -> dict:
"""retry plan 의 zones_after 를 반영한 *새* layout_css 반환 (mutation X).
horizontal-2 같은 dynamic_rows 인 경우만 해당. fr-default layout 은 retry target 아님
(왜냐하면 dynamic heights 가 없으면 redistribution 의미 없음).
"""
new_layout_css = dict(layout_css)
# zone position 순서대로 height_px 추출
new_heights_px = [plan["zones_after"][zd["position"]] for zd in zones_data]
new_layout_css["heights_px"] = new_heights_px
new_layout_css["rows"] = " ".join(f"{h}px" for h in new_heights_px)
new_layout_css["ratios"] = [round(h / total_height, 3) for h in new_heights_px]
new_layout_css["computation"] = "zone_ratio_retry override (A3)"
new_layout_css["dynamic_rows"] = True
new_layout_css["raw_zone_layout"] = (layout_css.get("raw_zone_layout") or {}).copy()
new_layout_css["raw_zone_layout"]["retry_applied"] = True
return new_layout_css
# ──────────────────────────────────────
# IMP-12 u4 : cross_zone_redistribute (Step 17 salvage cascade — stage 1)
# Wraps src.fit_verifier.redistribute in the Step-17 plan signature so the
# failure-router cascade (donor_slack_insufficient → cross_zone_redistribute)
# can drive it deterministically. Plan-only — no rerender / no final.html
# mutation. Side-effect-free (operates on deepcopy of fit_analysis).
# ──────────────────────────────────────
def plan_cross_zone_redistribute(
*,
fit_analysis,
containers: dict,
min_margin_px: float | None = None,
) -> dict:
"""Cross-zone (intra-zone role-to-role) redistribute plan.
Plan-only — no rerender / no final.html mutation. Side-effect-free
(operates on deepcopy of fit_analysis).
"""
from copy import deepcopy
from src.fit_verifier import redistribute as _fv_redistribute
role_heights_before = {
role: float(rf.allocated_px) for role, rf in (fit_analysis.roles or {}).items()
}
base_plan = {
"action": "cross_zone_redistribute",
"role_heights_before": role_heights_before,
}
if not role_heights_before:
return {**base_plan, "feasible": False, "role_heights_after": {},
"can_redistribute": False,
"failure_reason": "no roles in fit_analysis — cannot redistribute."}
result = _fv_redistribute(deepcopy(fit_analysis), containers, min_margin_px=min_margin_px)
redistribution = dict(result.redistribution or {})
can_redistribute = bool(result.can_redistribute)
if not can_redistribute or not redistribution:
return {
**base_plan,
"feasible": False,
"role_heights_after": redistribution or dict(role_heights_before),
"can_redistribute": can_redistribute,
"failure_reason": (
"fit_verifier.redistribute can_redistribute=False — single-role zone(s) "
"or surplus insufficient to cover deficit within envelope."
),
}
return {**base_plan, "feasible": True, "role_heights_after": redistribution,
"can_redistribute": True}
def apply_cross_zone_redistribute_css(plan: dict) -> str:
"""Emit scoped role-height CSS overrides — [data-role="<role>"] only.
Honors feedback_phase_z_spacing_direction: no :root / body / .slide / .zone selectors.
"""
if not plan.get("feasible"):
return ""
role_heights_after = plan.get("role_heights_after") or {}
role_heights_before = plan.get("role_heights_before") or {}
rules: list[str] = []
for role, new_height in role_heights_after.items():
before = role_heights_before.get(role)
if before is None or abs(float(before) - float(new_height)) < 0.5:
continue
new_h_int = int(round(float(new_height)))
rules.append(
f'[data-role="{role}"] {{ height: {new_h_int}px; min-height: {new_h_int}px; }}'
)
return "\n".join(rules)
# IMP-12 u5 : glue_compression — Step 17 salvage cascade (stage 2).
# Wraps space_allocator.compute_glue_css_overrides in the Step-17 plan signature.
# Frame-scoped: overrides emitted only under [data-zone-position="<pos>"]
# (feedback_phase_z_spacing_direction — no :root/body/.slide/.zone mutation).
def plan_glue_compression(
*, excess_px: float, block_count: int, zone_position: str,
) -> dict:
"""Glue compression plan (frame-scoped). feasible only when envelope absorbs excess."""
from src.space_allocator import (
calculate_glue_absorption, compute_glue_css_overrides,
)
base = {"action": "glue_compression", "zone_position": zone_position,
"excess_px": float(excess_px), "block_count": int(block_count)}
if excess_px <= 0:
return {**base, "feasible": False, "overrides": {}, "absorption_max_px": 0.0,
"failure_reason": "excess_px <= 0 — no compression needed."}
absorption_max = float(calculate_glue_absorption(block_count))
overrides = compute_glue_css_overrides(excess_px, block_count) or {}
if excess_px > absorption_max:
return {**base, "feasible": False, "overrides": overrides,
"absorption_max_px": absorption_max,
"failure_reason": (
f"glue envelope insufficient — excess_px {excess_px:.1f} > "
f"max absorption {absorption_max:.1f}px "
f"(block_count={block_count}, SPACING_GLUE shrink budget)."
)}
return {**base, "feasible": True, "overrides": overrides,
"absorption_max_px": absorption_max}
def apply_glue_compression_css(plan: dict) -> str:
"""Emit zone-scoped glue CSS — wrapped in [data-zone-position="<pos>"] only."""
if not plan.get("feasible"):
return ""
zone_position = plan.get("zone_position")
overrides = plan.get("overrides") or {}
if not zone_position or not overrides:
return ""
var_lines = "\n".join(f" {k}: {v};" for k, v in overrides.items())
return f'[data-zone-position="{zone_position}"] {{\n{var_lines}\n}}'
# IMP-12 u6 : font_step_compression — Step 17 salvage cascade (stage 3).
# Wraps space_allocator.find_fitting_font_size in the Step-17 plan signature.
# Zone-scoped: only [data-zone-position="<pos>"] (no :root/body/.slide/.zone).
def plan_font_step_compression(
*, current_font_px: float, excess_after_glue_px: float,
available_lines: int, chars_per_line: int, zone_position: str,
) -> dict:
"""Font-step compression plan (zone-scoped). feasible only when FONT_SIZE_STEPS
contains a size whose line-height savings cover excess_after_glue_px. Missing
text_metrics yields feasible=False (cascade routes onward to layout_adjust)."""
from src.space_allocator import FONT_SIZE_STEPS, find_fitting_font_size
floor = float(FONT_SIZE_STEPS[-1])
base = {"action": "font_step_compression", "zone_position": zone_position,
"current_font_px": float(current_font_px),
"excess_after_glue_px": float(excess_after_glue_px),
"available_lines": int(available_lines or 0),
"chars_per_line": int(chars_per_line or 0),
"font_floor_px": floor}
if excess_after_glue_px <= 0:
return {**base, "feasible": False, "target_font_px": None,
"failure_reason": "excess_after_glue_px <= 0 — no font compression needed."}
if not available_lines or available_lines <= 0 or not chars_per_line or chars_per_line <= 0:
return {**base, "feasible": False, "target_font_px": None,
"failure_reason": "text_metrics missing — available_lines/chars_per_line required."}
if current_font_px <= floor:
return {**base, "feasible": False, "target_font_px": None,
"failure_reason": (
f"current_font_px {current_font_px:.1f} already at FONT_SIZE_STEPS floor {floor:.1f}px.")}
target = find_fitting_font_size(
current_font_px=float(current_font_px),
excess_after_glue_px=float(excess_after_glue_px),
available_lines=int(available_lines), chars_per_line=int(chars_per_line))
if target is None:
return {**base, "feasible": False, "target_font_px": None,
"failure_reason": (
f"font_step floor — {floor:.1f}px cannot absorb "
f"excess_after_glue_px={excess_after_glue_px:.1f}px "
f"(available_lines={available_lines}, FONT_SIZE_STEPS exhausted).")}
return {**base, "feasible": True, "target_font_px": float(target)}
def apply_font_step_compression_css(plan: dict) -> str:
"""Emit zone-scoped font-size CSS — [data-zone-position="<pos>"] only."""
if not plan.get("feasible"):
return ""
zone_position = plan.get("zone_position")
target_font_px = plan.get("target_font_px")
if not zone_position or target_font_px is None:
return ""
return (f'[data-zone-position="{zone_position}"] {{\n'
f" font-size: {float(target_font_px):.1f}px;\n}}")