diff --git a/src/phase_z2_retry.py b/src/phase_z2_retry.py index d467de9..f1d36ee 100644 --- a/src/phase_z2_retry.py +++ b/src/phase_z2_retry.py @@ -120,11 +120,30 @@ def plan_zone_ratio_retry( 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 - slack = height - min_h + 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 @@ -134,6 +153,8 @@ def plan_zone_ratio_retry( "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 가장 큰 것부터 diff --git a/tests/phase_z2/test_phase_z2_retry_measured_bound.py b/tests/phase_z2/test_phase_z2_retry_measured_bound.py new file mode 100644 index 0000000..8b786c6 --- /dev/null +++ b/tests/phase_z2/test_phase_z2_retry_measured_bound.py @@ -0,0 +1,180 @@ +"""IMP-34 R1 u2 — plan_zone_ratio_retry measured-empty donor capacity bound. + +Stage 2 contract (unit u2) regression coverage for u1 planner change at +`src/phase_z2_retry.py:122-157`: + + axis 1: absent measured fields → static_fallback (regression) + axis 2: measured_empty < static_slack → measured_bound applied + axis 3: measured_empty >= static_slack → static_slack honored + axis 4: measured_empty == 0 → donor excluded (slack<=0 gate) + axis 5: donor filter + telemetry source fields → eligibility + bool guards + +u1 adds an additive measured-empty bound on donor capacity in +`plan_zone_ratio_retry`. Step 14 schema unchanged; reuses `clientHeight` / +`scrollHeight` already in `overflow["zones"]`. Static fallback preserves +prior behavior when measured fields are absent. +""" +from __future__ import annotations + +from src.phase_z2_retry import plan_zone_ratio_retry + + +_ROUTER_ACTIVE = {"router_active": True} + + +def _classification(target_pos: str, excess_y: float) -> dict: + return { + "classifications": [ + { + "proposed_action": "zone_ratio_retry", + "zone_position": target_pos, + "inputs": {"excess_y": excess_y}, + } + ] + } + + +def _zone(position: str, height_px: int, min_height_px: int, + fit_status: str | None = "ok") -> dict: + return { + "position": position, + "height_px": height_px, + "min_height_px": min_height_px, + "composition_rationale": { + "capacity_fit": {"fit_status": fit_status}, + }, + } + + +def _ozone(position: str, *, client_h=None, scroll_h=None, + overflowed: bool = False, clipped_inner: bool = False) -> dict: + z: dict = {"position": position, "overflowed": overflowed, + "clipped_inner": clipped_inner} + if client_h is not None: + z["clientHeight"] = client_h + if scroll_h is not None: + z["scrollHeight"] = scroll_h + return z + + +def test_axis1_absent_measured_fields_uses_static_fallback(): + """Step 14 zones without clientHeight/scrollHeight → planner falls back to + static slack (regression: pre-u1 behavior preserved).""" + debug_zones = [ + _zone("top", height_px=200, min_height_px=180), + _zone("bottom", height_px=400, min_height_px=200), # static_slack=200 + ] + overflow = {"zones": [_ozone("bottom")]} # no clientHeight/scrollHeight + plan = plan_zone_ratio_retry( + debug_zones=debug_zones, + overflow=overflow, + fit_classification=_classification("top", excess_y=20.0), + router_decision=_ROUTER_ACTIVE, + ) + assert plan is not None and plan["feasible"] is True + donors = plan["donor_candidates_considered"] + assert len(donors) == 1 + assert donors[0]["position"] == "bottom" + assert donors[0]["slack"] == 200 # static slack unchanged + assert donors[0]["measured_empty_px"] is None + assert donors[0]["slack_bound_source"] == "static_fallback" + + +def test_axis2_measured_empty_less_than_static_slack_bound_applied(): + """measured_empty (30) < static_slack (200) → donor capacity bounded to 30, + telemetry reports measured_bound.""" + debug_zones = [ + _zone("top", height_px=200, min_height_px=180), + _zone("bottom", height_px=400, min_height_px=200), # static_slack=200 + ] + # clientHeight 400, scrollHeight 370 → measured_empty=30 + overflow = {"zones": [_ozone("bottom", client_h=400, scroll_h=370)]} + plan = plan_zone_ratio_retry( + debug_zones=debug_zones, + overflow=overflow, + fit_classification=_classification("top", excess_y=20.0), + router_decision=_ROUTER_ACTIVE, + ) + assert plan["feasible"] is True + donor = plan["donor_candidates_considered"][0] + assert donor["slack"] == 30 # bound by measured_empty + assert donor["measured_empty_px"] == 30 + assert donor["slack_bound_source"] == "measured_bound" + # target_added_px = ceil(20)+4 = 24, donor slack=30 covers it + assert plan["aggregate_slack_available"] == 30 + assert plan["donor_reduced_px"] == 24 + + +def test_axis3_measured_empty_ge_static_slack_static_honored(): + """measured_empty (300) >= static_slack (50) → static_slack wins via min(), + but telemetry still reports measured_bound (both fields present).""" + debug_zones = [ + _zone("top", height_px=200, min_height_px=180), + _zone("bottom", height_px=250, min_height_px=200), # static_slack=50 + ] + # clientHeight 250, scrollHeight -50 → measured_empty=max(0, 300)=300 + # (extreme value to prove min() honors static_slack) + overflow = {"zones": [_ozone("bottom", client_h=250, scroll_h=-50)]} + plan = plan_zone_ratio_retry( + debug_zones=debug_zones, + overflow=overflow, + fit_classification=_classification("top", excess_y=20.0), + router_decision=_ROUTER_ACTIVE, + ) + assert plan["feasible"] is True + donor = plan["donor_candidates_considered"][0] + assert donor["slack"] == 50 # static_slack honored via min() + assert donor["measured_empty_px"] == 300 + assert donor["slack_bound_source"] == "measured_bound" + + +def test_axis4_measured_empty_zero_excludes_donor(): + """measured_empty == 0 (donor full) → slack<=0 gate excludes the donor + (planner avoids over-allocating a visually-full sibling).""" + debug_zones = [ + _zone("top", height_px=200, min_height_px=180), + _zone("bottom", height_px=400, min_height_px=200), # static_slack=200 + ] + # clientHeight==scrollHeight → measured_empty=0 + overflow = {"zones": [_ozone("bottom", client_h=400, scroll_h=400)]} + plan = plan_zone_ratio_retry( + debug_zones=debug_zones, + overflow=overflow, + fit_classification=_classification("top", excess_y=20.0), + router_decision=_ROUTER_ACTIVE, + ) + assert plan is not None and plan["feasible"] is False + assert plan["donor_candidates_considered"] == [] + assert "no donor candidates eligible" in plan["failure_reason"] + # zones_after preserves zones_before — revert-friendly + assert plan["zones_after"]["bottom"] == 400 + + +def test_axis5_donor_filter_preservation_and_telemetry_bool_guard(): + """Donor eligibility filter (overflowed / clipped_inner) precedes the new + measured bound, and bool values must NOT be treated as numeric measured + fields (Python isinstance(True, int) is True without an explicit guard).""" + debug_zones = [ + _zone("top", height_px=200, min_height_px=180), + _zone("middle", height_px=400, min_height_px=200), # static_slack=200 + _zone("bottom", height_px=400, min_height_px=200), # static_slack=200 + ] + overflow = {"zones": [ + # middle is itself overflowed → must be filtered out regardless of measured fields + _ozone("middle", client_h=400, scroll_h=350, overflowed=True), + # bottom uses bool measured fields → must fall back to static_fallback (not crash, not measured_bound) + _ozone("bottom", client_h=True, scroll_h=False), + ]} + plan = plan_zone_ratio_retry( + debug_zones=debug_zones, + overflow=overflow, + fit_classification=_classification("top", excess_y=20.0), + router_decision=_ROUTER_ACTIVE, + ) + assert plan["feasible"] is True + donors = plan["donor_candidates_considered"] + # middle filtered by overflowed=True; only bottom remains + assert [d["position"] for d in donors] == ["bottom"] + assert donors[0]["slack"] == 200 # static slack (bools rejected as measured) + assert donors[0]["measured_empty_px"] is None + assert donors[0]["slack_bound_source"] == "static_fallback"