"""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=""] 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=""] # (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=""] 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=""] (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=""] 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}}") # ────────────────────────────────────── # IMP-88 u3 : layout_adjust — Step 17 retry chain (8-preset topology swap). # Honors feedback_phase_z_spacing_direction: no shared margin / gap / slide-body # shrink. Cascade entry per u2: image_fit_insufficient → layout_adjust; # downstream per u2: layout_adjust_insufficient → frame_internal_fit_candidate. # Plan-only — dispatcher (u6) consumes new_layout_preset + new_zones_data and # rebuilds layout_css via apply_layout_adjust_layout_css(plan, gap_px). # ────────────────────────────────────── def _layout_swap_priority(current_topology: str, candidate_topology: str) -> int: """Lower = preferred swap target. Honors topology-axis mirroring first.""" pair = frozenset({current_topology, candidate_topology}) if pair == frozenset({"rows", "cols"}): return 0 if pair == frozenset({"T", "inverted-T"}): return 1 if pair == frozenset({"side-T-left", "side-T-right"}): return 2 return 3 def plan_layout_adjust( *, current_layout_preset: str, zones_data: list[dict], ) -> dict: """Layout-preset switch plan (Step 17 retry chain — IMP-88 u3). Finds a render-ready sibling preset (same candidate_when.unit_count) and remaps zone positions in catalog order. No common spacing shrink — feedback_phase_z_spacing_direction lock: escalate via layout topology only. """ from src.phase_z2_composition import LAYOUT_PRESETS base = { "action": "layout_adjust", "current_layout_preset": current_layout_preset, } current_spec = LAYOUT_PRESETS.get(current_layout_preset) if current_spec is None: return { **base, "feasible": False, "new_layout_preset": None, "candidates_considered": [], "failure_reason": ( f"current_layout_preset '{current_layout_preset}' not in " f"LAYOUT_PRESETS catalog — cannot enumerate same-unit-count siblings." ), } current_positions = list(current_spec.get("positions") or []) if len(zones_data) != len(current_positions): return { **base, "feasible": False, "new_layout_preset": None, "candidates_considered": [], "failure_reason": ( f"zones_data length {len(zones_data)} != current preset " f"'{current_layout_preset}' positions {current_positions} — " f"cannot remap to a sibling preset." ), } unit_count = (current_spec.get("candidate_when") or {}).get("unit_count") candidates = [ pid for pid, spec in LAYOUT_PRESETS.items() if pid != current_layout_preset and spec.get("render_ready", False) and ((spec.get("candidate_when") or {}).get("unit_count") == unit_count) ] base = {**base, "unit_count": unit_count, "candidates_considered": list(candidates)} if not candidates: return { **base, "feasible": False, "new_layout_preset": None, "failure_reason": ( f"no render-ready 8-preset sibling for unit_count {unit_count} " f"(current='{current_layout_preset}'). single (1) and grid-2x2 (4) " f"have no swap target by catalog design." ), } catalog_order = list(LAYOUT_PRESETS.keys()) current_topo = current_spec.get("topology") candidates.sort(key=lambda pid: ( _layout_swap_priority(current_topo, LAYOUT_PRESETS[pid].get("topology")), catalog_order.index(pid), )) new_preset = candidates[0] new_positions = list(LAYOUT_PRESETS[new_preset].get("positions") or []) new_zones_data = [ {**zd, "position": new_positions[i]} for i, zd in enumerate(zones_data) ] return { **base, "feasible": True, "new_layout_preset": new_preset, "swap_topology_from": current_topo, "swap_topology_to": LAYOUT_PRESETS[new_preset].get("topology"), "position_remap": dict(zip(current_positions, new_positions)), "new_zones_data": new_zones_data, } def apply_layout_adjust_layout_css(plan: dict, gap_px: int) -> Optional[dict]: """Build a fresh layout_css dict for the swapped preset. Returns None when plan is infeasible. Dispatcher (u6) re-renders with render_slide(zones_data=plan['new_zones_data'], layout_preset=plan ['new_layout_preset'], layout_css=, gap_px=gap_px). """ if not plan.get("feasible"): return None new_preset = plan.get("new_layout_preset") new_zones_data = plan.get("new_zones_data") or [] if not new_preset or not new_zones_data: return None from src.phase_z2_pipeline import build_layout_css new_layout_css = dict(build_layout_css(new_preset, new_zones_data, gap=gap_px)) raw = dict(new_layout_css.get("raw_zone_layout") or {}) raw["layout_adjust_applied"] = True raw["layout_adjust_from"] = plan.get("current_layout_preset") raw["layout_adjust_to"] = new_preset new_layout_css["raw_zone_layout"] = raw return new_layout_css # ────────────────────────────────────── # IMP-88 u4 : image_fit — Step 17 single-pass image-scoped CSS override. # Consumes overflow_metrics.image_events directly (natural_w/h, rendered_w/h, # natural_ratio, rendered_ratio, delta). Honors feedback_phase_z_spacing # _direction — image-scoped CSS only, no shared margin / frame envelope shrink. # Plan-only — Step 17 entry (u7) is the runtime caller. # Default delta_tol mirrors src.phase_z2_pipeline.IMAGE_ASPECT_DELTA_TOL = 0.05; # overridable arg keeps tests free of the pipeline import cycle. # ────────────────────────────────────── def plan_image_fit( *, image_event: dict, delta_tol: float = 0.05, ) -> dict: """Image_fit planner (Step 17 retry chain — IMP-88 u4). Emits frame-scoped object-fit + max-width/height from a single image_event (overflow_metrics.image_events). Image-scoped only. """ base = { "action": "image_fit", "src": image_event.get("src"), "zone_position": image_event.get("zone_position"), "zone_template_id": image_event.get("zone_template_id"), } delta = image_event.get("delta") if delta is None: return { **base, "feasible": False, "css_overrides": None, "failure_reason": ( "image_event delta is None — image not loaded; no aspect " "mismatch can be measured." ), } if abs(float(delta)) <= float(delta_tol): return { **base, "feasible": False, "css_overrides": None, "delta": float(delta), "failure_reason": ( f"|delta|={abs(float(delta)):.4f} <= delta_tol={delta_tol} — " f"no image_aspect_mismatch to correct (planner no-op)." ), } rendered_w = image_event.get("rendered_w") rendered_h = image_event.get("rendered_h") if not (isinstance(rendered_w, (int, float)) and rendered_w > 0 and isinstance(rendered_h, (int, float)) and rendered_h > 0): return { **base, "feasible": False, "css_overrides": None, "delta": float(delta), "failure_reason": ( "image_event missing positive rendered_w / rendered_h — " "cannot bound max-width / max-height for image-scoped CSS." ), } return { **base, "feasible": True, "delta": float(delta), "natural_ratio": image_event.get("natural_ratio"), "rendered_ratio": image_event.get("rendered_ratio"), "natural_w": image_event.get("natural_w"), "natural_h": image_event.get("natural_h"), "rendered_w": int(rendered_w), "rendered_h": int(rendered_h), # delta > 0 ⇒ rendered_ratio > natural_ratio ⇒ rendered too wide ⇒ # width axis correction; delta < 0 ⇒ height axis correction. "correction_axis": "width" if float(delta) > 0 else "height", "css_overrides": { "object_fit": "contain", "max_width_px": int(rendered_w), "max_height_px": int(rendered_h), "width": "auto", "height": "auto", }, } def apply_image_fit_css(plan: dict) -> Optional[str]: """Build a frame-scoped CSS snippet from a feasible image_fit plan. Returns None when plan is infeasible. u7 (Step 17 entry) injects the snippet into the per-slide style override and re-renders. """ if not plan.get("feasible"): return None overrides = plan.get("css_overrides") or {} if not overrides: return None src = plan.get("src") or "" zone_position = plan.get("zone_position") or "" if src: selector = ( f".zone[data-zone-position=\"{zone_position}\"] " f"img[src=\"{src}\"]" ) else: selector = f".zone[data-zone-position=\"{zone_position}\"] img" return ( f"{selector} {{\n" f" object-fit: {overrides.get('object_fit', 'contain')};\n" f" max-width: {int(overrides.get('max_width_px') or 0)}px;\n" f" max-height: {int(overrides.get('max_height_px') or 0)}px;\n" f" width: {overrides.get('width', 'auto')};\n" f" height: {overrides.get('height', 'auto')};\n" f"}}" ) # ────────────────────────────────────── # IMP-88 u5 : frame_internal_fit_candidate — Step 17 retry chain. # Operates ONLY inside the frame contract's declared `internal_envelope` # (PHASE-Z-PIPELINE-OVERVIEW.md:333 lock). Sub-mechanism names allowed by # the OVERVIEW: density envelope / line rhythm / internal grid row / text # block allocation — all unified under the single action label # `frame_internal_fit_candidate` so common-CSS/padding shrink antipatterns # stay quarantined ([[feedback_phase_z_spacing_direction]]). # # Envelope shape (dormant — catalog adds when contracts declare it): # frame_contract["internal_envelope"] = { # "variants": [ # {"name": "", # "excess_budget_px": , # "css_overrides": {: , ...}}, # ... # ] # } # Selection = walk variants in catalog order, pick first whose excess_budget_px # >= effective excess_y (greedy). Catalog order = catalog author's priority. # # No frame contract currently declares `internal_envelope`, so the planner # returns infeasible(envelope_present=False) for every live frame today. # Cascade hand-off: NEXT_ACTION_BY_FAILURE['frame_internal_fit_candidate_ # insufficient'] = 'frame_reselect' (set in u2). Plan-only — u6 (salvage # dispatcher) and u7 (Step 17 entry) own the runtime call site. # # frame_contract is an overridable kwarg so tests don't pay the mapper # catalog cache cost / pipeline import cycle (mirrors u4's delta_tol). # ────────────────────────────────────── def plan_frame_internal_fit_candidate( *, frame_template_id: str, frame_contract: Optional[dict] = None, overflow_zone: Optional[dict] = None, ) -> dict: """frame_internal_fit_candidate planner (Step 17 retry chain — IMP-88 u5). Walks `frame_contract['internal_envelope']['variants']` in catalog order and picks the first variant whose excess_budget_px covers `overflow_zone ['excess_y']`. Returns infeasible when no contract / no envelope / no variant fits. No common-margin shrink — sub-mechanism CSS is frame-scoped. """ base = { "action": "frame_internal_fit_candidate", "frame_template_id": frame_template_id, } if frame_contract is None: from src.phase_z2_mapper import get_contract frame_contract = get_contract(frame_template_id) if frame_contract is None: return { **base, "feasible": False, "envelope_present": False, "candidates_considered": [], "selected_variant": None, "css_overrides": None, "failure_reason": ( f"no frame contract registered for template_id " f"'{frame_template_id}' — cannot enumerate internal_envelope." ), } envelope = frame_contract.get("internal_envelope") if not isinstance(envelope, dict): return { **base, "feasible": False, "envelope_present": False, "candidates_considered": [], "selected_variant": None, "css_overrides": None, "failure_reason": ( f"frame contract '{frame_template_id}' does not declare " f"internal_envelope — cascade should escalate to frame_reselect." ), } variants = list(envelope.get("variants") or []) candidates_considered = [v.get("name") for v in variants if isinstance(v, dict)] if not variants: return { **base, "feasible": False, "envelope_present": True, "envelope_keys": sorted(envelope.keys()), "candidates_considered": candidates_considered, "selected_variant": None, "css_overrides": None, "failure_reason": ( f"frame contract '{frame_template_id}' internal_envelope " f"declares no variants — no sub-mechanism available." ), } excess_y = 0 if isinstance(overflow_zone, dict): ey = overflow_zone.get("excess_y") if isinstance(ey, (int, float)) and ey > 0: excess_y = int(math.ceil(float(ey))) selected: Optional[dict] = None for variant in variants: if not isinstance(variant, dict): continue budget = variant.get("excess_budget_px") if not isinstance(budget, (int, float)): continue if int(budget) >= excess_y: selected = variant break if selected is None: return { **base, "feasible": False, "envelope_present": True, "envelope_keys": sorted(envelope.keys()), "candidates_considered": candidates_considered, "selected_variant": None, "css_overrides": None, "excess_y": excess_y, "failure_reason": ( f"all {len(variants)} internal_envelope variant(s) for " f"'{frame_template_id}' have excess_budget_px below excess_y=" f"{excess_y}px — internal fit cannot absorb overflow." ), } overrides = selected.get("css_overrides") or {} return { **base, "feasible": True, "envelope_present": True, "envelope_keys": sorted(envelope.keys()), "candidates_considered": candidates_considered, "selected_variant": selected.get("name"), "selected_variant_budget_px": int(selected.get("excess_budget_px") or 0), "excess_y": excess_y, "css_overrides": dict(overrides), } def apply_frame_internal_fit_candidate_css(plan: dict) -> Optional[str]: """Build a frame-scoped CSS snippet from a feasible frame_internal_fit plan. Returns None when plan is infeasible. u6 / u7 inject the snippet into the per-slide style override and re-render. """ if not plan.get("feasible"): return None overrides = plan.get("css_overrides") or {} if not overrides: return None template_id = plan.get("frame_template_id") or "" if not template_id: return None selector = f".zone[data-template-id=\"{template_id}\"]" body_lines = [ f" {prop}: {value};" for prop, value in overrides.items() ] return f"{selector} {{\n" + "\n".join(body_lines) + "\n}"