Add Phase Z runtime foundation
- add visual fit classifier, router, retry, and failure routing modules - add composition planner and catalog-driven mapper - add Phase Z pipeline orchestration and architecture docs
This commit is contained in:
215
src/phase_z2_retry.py
Normal file
215
src/phase_z2_retry.py
Normal file
@@ -0,0 +1,215 @@
|
||||
"""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
|
||||
height = zones_before.get(pos)
|
||||
min_h = zone_min_by_pos.get(pos)
|
||||
if height is None or min_h is None:
|
||||
continue
|
||||
slack = height - min_h
|
||||
if slack <= 0:
|
||||
continue
|
||||
|
||||
donor_candidates.append({
|
||||
"position": pos,
|
||||
"current_height": height,
|
||||
"min_height": min_h,
|
||||
"slack": slack,
|
||||
"capacity_fit_status": cap_status,
|
||||
})
|
||||
|
||||
# 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)"
|
||||
),
|
||||
}
|
||||
|
||||
# A3 minimal : single primary donor (multi-donor 는 future)
|
||||
primary_donor = donor_candidates[0]
|
||||
if primary_donor["slack"] < target_added_px:
|
||||
return {
|
||||
**base_plan,
|
||||
"feasible": False,
|
||||
"donor_zone_position": primary_donor["position"],
|
||||
"donor_max_slack": primary_donor["slack"],
|
||||
"donor_reduced_px": 0,
|
||||
"zones_after": dict(zones_before),
|
||||
"failure_reason": (
|
||||
f"primary donor '{primary_donor['position']}' slack {primary_donor['slack']}px "
|
||||
f"< target_added_px {target_added_px}px (excess_y {target_excess_y} + "
|
||||
f"safety_margin {safety_margin_px}). multi-donor aggregation is future axis."
|
||||
),
|
||||
}
|
||||
|
||||
# feasible
|
||||
zones_after = dict(zones_before)
|
||||
zones_after[target_zone_position] = zones_before[target_zone_position] + target_added_px
|
||||
zones_after[primary_donor["position"]] = (
|
||||
zones_before[primary_donor["position"]] - target_added_px
|
||||
)
|
||||
|
||||
return {
|
||||
**base_plan,
|
||||
"feasible": True,
|
||||
"donor_zone_position": primary_donor["position"],
|
||||
"donor_reduced_px": target_added_px,
|
||||
"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
|
||||
Reference in New Issue
Block a user