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:
@@ -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 가장 큰 것부터
|
||||
|
||||
180
tests/phase_z2/test_phase_z2_retry_measured_bound.py
Normal file
180
tests/phase_z2/test_phase_z2_retry_measured_bound.py
Normal 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"
|
||||
Reference in New Issue
Block a user