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>
This commit is contained in:
2026-05-21 21:37:41 +09:00
parent a06dd3d4b0
commit dceb10129f
2 changed files with 202 additions and 1 deletions

View File

@@ -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 가장 큰 것부터

View File

@@ -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"