feat(#15): density/readability gate — MDX02 false-positive 차단 (#98 추천 1·2)

기계 PASS vs 사람 '빽빽함' 판정 간극을 잡는 결정론적 밀도 신호:
- _estimate_max_card_lines: repeat 슬롯/list-of-dict 컬럼/<br> 라인 payload 추정 (v0)
- _compute_density_gate: px_per_line = (측정 zone 높이 − 56) / 카드 최대 라인
  임계값은 mdx 01~05 실측 캘리브레이션 — dense<16 / tight<45 px/line
  (mdx03 105.8=ready, mdx02 40~44=cramped, mdx01 bottom 11.5=dense 와 정렬)
- design_readiness 통합: dense/tight → warnings(needs_review) — 사람 판정 정렬
  (mdx01 11.5 도 사람 판정은 needs_review 였음 — not_ready 아님)
- presentation_ready 에 density AND 조건 추가 (#98 추천 1: 신호 통합)
- popup 승격 후 재계산 경로에도 동일 배선. 리포팅 전용 — final.html 영향 0
  (SHA parity 게이트 무접촉 확인: regression 40/40 포함 전체 1016 passed)

실전 acceptance:
- mdx02: presentation_ready=False + density_tight×2 (44.0/40.4 px/line) — 정직 표시
- mdx03: PASS + comfortable(105.8) 유지, density warnings 0
  (mdx03 design_readiness=needs_review 는 기존 reject-label warning — 회귀 아님)

후속: B5 slot metrics(#20) 성숙 시 payload 추정 → DOM 실측 교체

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-06 17:34:42 +09:00
co-authored by Claude Opus 4.8
parent 9a72e7de3e
commit a57b197c37
2 changed files with 286 additions and 0 deletions
+129
View File
@@ -5770,11 +5770,111 @@ def _compute_applied_render_consistency(
} }
# ── issue #15 — density/readability gate (Gitea #98 추천 1·2) ──────────
# 기계 PASS vs 사람 "빽빽함" 판정의 간극(false-positive, MDX02 최악)을 잡는
# 결정론적 신호. B5 slot metrics(#20) 부재 상태의 v0 휴리스틱:
# px_per_line = (zone 측정 높이 − 오버헤드) / 카드당 최대 추정 라인 수
# 임계값은 mdx 01~05 실측 캘리브레이션 (2026-07-06, 사람 판정과 정렬):
# mdx03 105.8(ready) / mdx05 50~64(density 문제 아님) / mdx02 40~44(cramped)
# / mdx04 top 37(dense) / mdx01 bottom-right 11.5("bottom comparison dense")
_DENSITY_PX_PER_LINE_DENSE = 16.0 # 물리적 한 줄 높이 미만 — 확실한 과밀
_DENSITY_PX_PER_LINE_TIGHT = 45.0 # 사람 판정 cramped 경계 (mdx02 44.0 포함)
_DENSITY_ZONE_OVERHEAD_PX = 56 # zone title + padding 추정 오버헤드
_CARD_SLOT_RE = re.compile(r".+?_(\d+)_(label|body)$")
def _estimate_max_card_lines(slot_payload: dict) -> int:
"""카드/행 단위 최대 라인 수 추정 (휴리스틱 v0 — payload 결정론).
인식 형태: ``{prefix}_{n}_{label|body}`` repeat 슬롯, ``pillars``
list-of-dict 컬럼, str 값의 ``<br>`` 라인. B5 marker(#20) 성숙 시 실측
DOM 라인으로 대체 예정.
"""
if not isinstance(slot_payload, dict):
return 0
cards: dict[str, int] = {}
for key, value in slot_payload.items():
if key in ("title", "_slot_count") or not value:
continue
m = _CARD_SLOT_RE.match(str(key))
if m:
n_lines = (
len(value) if isinstance(value, list)
else str(value).count("<br>") + 1
)
cards[m.group(1)] = cards.get(m.group(1), 0) + n_lines
continue
if isinstance(value, list) and value and isinstance(value[0], dict):
for i, card in enumerate(value):
n = 0
for v in card.values():
if not v:
continue
n += len(v) if isinstance(v, list) else str(v).count("<br>") + 1
cards[f"{key}[{i}]"] = n
return max(cards.values()) if cards else 0
def _compute_density_gate(zones_data: list[dict], overflow: dict) -> dict:
"""issue #15 — per-zone 밀도 판정 (dense / tight / comfortable).
측정 높이(run_overflow_check clientHeight) payload 라인 추정만 사용
렌더/최종 HTML 영향 없는 순수 리포팅 계층 (slide_status 전용).
라인 추정 불가 zone(카드 패턴 아님) 판정 제외 (not_measurable).
"""
heights = {
str(z.get("position")): z.get("clientHeight")
for z in (overflow.get("zones") or [])
if isinstance(z, dict)
}
zone_records: list[dict] = []
worst = "comfortable"
for zone in zones_data or []:
pos = str(zone.get("position"))
payload = zone.get("slot_payload") or {}
max_lines = _estimate_max_card_lines(payload)
height = heights.get(pos)
rec: dict = {
"position": pos,
"template_id": zone.get("template_id"),
"estimated_max_lines": max_lines,
}
if not max_lines or not isinstance(height, (int, float)) or height <= 0:
rec["verdict"] = "not_measurable"
zone_records.append(rec)
continue
px_per_line = (float(height) - _DENSITY_ZONE_OVERHEAD_PX) / max_lines
rec["zone_height_px"] = int(height)
rec["px_per_line"] = round(px_per_line, 1)
if px_per_line < _DENSITY_PX_PER_LINE_DENSE:
rec["verdict"] = "dense"
worst = "dense"
elif px_per_line < _DENSITY_PX_PER_LINE_TIGHT:
rec["verdict"] = "tight"
if worst != "dense":
worst = "tight"
else:
rec["verdict"] = "comfortable"
zone_records.append(rec)
return {
"passed": worst == "comfortable",
"worst_verdict": worst,
"zones": zone_records,
"thresholds": {
"dense_px_per_line": _DENSITY_PX_PER_LINE_DENSE,
"tight_px_per_line": _DENSITY_PX_PER_LINE_TIGHT,
"zone_overhead_px": _DENSITY_ZONE_OVERHEAD_PX,
},
"heuristic": "payload_line_estimate_v0",
}
def _compute_design_readiness( def _compute_design_readiness(
*, *,
units: list[CompositionUnit], units: list[CompositionUnit],
rendered_text_coverage: dict, rendered_text_coverage: dict,
applied_render_consistency: dict, applied_render_consistency: dict,
density_gate: Optional[dict] = None,
) -> dict: ) -> dict:
"""T20f: separate technical PASS from design-readiness. """T20f: separate technical PASS from design-readiness.
@@ -5841,6 +5941,23 @@ def _compute_design_readiness(
"detail": "The rendered frame is selected from a reject/provisional path.", "detail": "The rendered frame is selected from a reject/provisional path.",
}) })
# issue #15 — density 신호 통합 (#98 추천 1: readiness 신호 통합).
# 사람 판정 캘리브레이션상 dense/tight 모두 needs_review 계층 (not_ready
# 아님 — mdx01 bottom 11.5px/line 도 사람 판정은 needs_review 였음).
if density_gate and not density_gate.get("passed", True):
for z in density_gate.get("zones") or []:
if z.get("verdict") in ("dense", "tight"):
warnings.append({
"position": z.get("position"),
"template_id": z.get("template_id"),
"code": f"density_{z['verdict']}",
"detail": (
f"{z.get('px_per_line')}px/line "
f"(max {z.get('estimated_max_lines')} lines/card in "
f"{z.get('zone_height_px')}px zone) — 카드 과밀 신호"
),
})
status = "ready" status = "ready"
if warnings: if warnings:
status = "needs_review" status = "needs_review"
@@ -11025,10 +11142,14 @@ def run_phase_z2_mvp1(
debug_zones=debug_zones, debug_zones=debug_zones,
) )
slide_status["applied_render_consistency"] = applied_render_consistency slide_status["applied_render_consistency"] = applied_render_consistency
# issue #15 — density gate (payload 결정론 + 측정 높이, 리포팅 전용)
density_gate = _compute_density_gate(zones_data, overflow)
slide_status["density_gate"] = density_gate
design_readiness = _compute_design_readiness( design_readiness = _compute_design_readiness(
units=units, units=units,
rendered_text_coverage=rendered_text_coverage, rendered_text_coverage=rendered_text_coverage,
applied_render_consistency=applied_render_consistency, applied_render_consistency=applied_render_consistency,
density_gate=density_gate,
) )
slide_status["design_readiness"] = design_readiness slide_status["design_readiness"] = design_readiness
if not rendered_text_coverage.get("passed"): if not rendered_text_coverage.get("passed"):
@@ -11092,10 +11213,13 @@ def run_phase_z2_mvp1(
and applied_render_consistency.get("passed") and applied_render_consistency.get("passed")
) )
slide_status["visual_pass"] = bool(overflow.get("passed")) slide_status["visual_pass"] = bool(overflow.get("passed"))
# issue #15 (#98 추천 1) — presentation_ready 에 density 통합: 기계 PASS
# 인데 사람 눈에 빽빽한 false-positive(MDX02) 차단.
slide_status["presentation_ready"] = bool( slide_status["presentation_ready"] = bool(
slide_status["technical_pass"] slide_status["technical_pass"]
and slide_status["visual_pass"] and slide_status["visual_pass"]
and presentation_fit.get("passed") and presentation_fit.get("passed")
and density_gate.get("passed", True)
) )
slide_status["presentation_reselection"] = _build_t28_5c_reselection_trace( slide_status["presentation_reselection"] = _build_t28_5c_reselection_trace(
units=units, units=units,
@@ -11145,10 +11269,13 @@ def run_phase_z2_mvp1(
rendered_html=final_html_for_status, rendered_html=final_html_for_status,
debug_zones=debug_zones, debug_zones=debug_zones,
) )
# issue #15 — popup 승격 후 재계산 경로에도 density gate 동일 배선
density_gate = _compute_density_gate(zones_data, overflow)
design_readiness = _compute_design_readiness( design_readiness = _compute_design_readiness(
units=units, units=units,
rendered_text_coverage=rendered_text_coverage, rendered_text_coverage=rendered_text_coverage,
applied_render_consistency=applied_render_consistency, applied_render_consistency=applied_render_consistency,
density_gate=density_gate,
) )
presentation_fit = _build_presentation_fit_report( presentation_fit = _build_presentation_fit_report(
overflow=overflow, overflow=overflow,
@@ -11158,6 +11285,7 @@ def run_phase_z2_mvp1(
slide_status["text_coverage_passed"] = bool(rendered_text_coverage.get("passed")) slide_status["text_coverage_passed"] = bool(rendered_text_coverage.get("passed"))
slide_status["forbidden_rendered_syntax"] = forbidden_rendered_syntax slide_status["forbidden_rendered_syntax"] = forbidden_rendered_syntax
slide_status["applied_render_consistency"] = applied_render_consistency slide_status["applied_render_consistency"] = applied_render_consistency
slide_status["density_gate"] = density_gate
slide_status["design_readiness"] = design_readiness slide_status["design_readiness"] = design_readiness
slide_status["presentation_fit"] = presentation_fit slide_status["presentation_fit"] = presentation_fit
slide_status["technical_pass"] = bool( slide_status["technical_pass"] = bool(
@@ -11172,6 +11300,7 @@ def run_phase_z2_mvp1(
slide_status["technical_pass"] slide_status["technical_pass"]
and slide_status["visual_pass"] and slide_status["visual_pass"]
and presentation_fit.get("passed") and presentation_fit.get("passed")
and density_gate.get("passed", True)
) )
slide_status["presentation_reselection"] = _build_t28_5c_reselection_trace( slide_status["presentation_reselection"] = _build_t28_5c_reselection_trace(
units=units, units=units,
+157
View File
@@ -0,0 +1,157 @@
"""issue #15 — density/readability gate 테스트 (Gitea #98 추천 1·2).
계약:
- payload 결정론 + 측정 높이만 사용 (final.html 영향 0 — 리포팅 계층)
- 임계값은 mdx 01~05 실측 캘리브레이션: dense<16 / tight<45 px/line
(사람 판정 정렬: mdx03 105.8=ready, mdx02 40~44=cramped, mdx01 11.5=dense)
- dense/tight 는 design_readiness 의 warnings(needs_review) — not_ready 아님
- presentation_ready 는 density 실패 시 False (#98 추천 1: 신호 통합)
"""
from __future__ import annotations
from src.phase_z2_pipeline import (
_compute_density_gate,
_compute_design_readiness,
_estimate_max_card_lines,
)
def _overflow(heights: dict[str, int]) -> dict:
return {"passed": True, "zones": [
{"position": p, "clientHeight": h} for p, h in heights.items()
]}
# ── _estimate_max_card_lines ────────────────────────────────────────
def test_repeat_slot_pattern_lines():
payload = {
"title": "t", "_slot_count": 3,
"pill_1_label": "라벨", "pill_1_body": ["a", "b", "c"],
"pill_2_label": "라벨", "pill_2_body": ["a"],
"pill_3_label": "", "pill_3_body": [],
}
# pill_1: label 1 + body 3 = 4
assert _estimate_max_card_lines(payload) == 4
def test_list_of_dict_columns_lines():
payload = {"pillars": [
{"label": "A", "lines": ["1", "2", "3"]},
{"label": "B", "lines": ["1"]},
]}
assert _estimate_max_card_lines(payload) == 4 # label 1 + 3 lines
def test_br_joined_string_lines():
payload = {"row_1_body": "가<br>나<br>다"}
assert _estimate_max_card_lines(payload) == 3
def test_non_card_payload_not_measurable():
assert _estimate_max_card_lines({"title": "t", "body_text": "x"}) == 0
# ── _compute_density_gate (실측 캘리브레이션 fixture) ─────────────────
def _zone(pos: str, payload: dict, template="three_persona_benefits") -> dict:
return {"position": pos, "template_id": template, "slot_payload": payload}
def test_mdx03_calibration_comfortable():
"""mdx03 left: 585px, pillar 최대 5라인 → 105.8px/line = comfortable."""
zones = [_zone("left", {"pillars": [
{"label": "L", "lines": ["1", "2", "3", "4"]},
]})]
gate = _compute_density_gate(zones, _overflow({"left": 585}))
assert gate["passed"] is True
assert gate["zones"][0]["verdict"] == "comfortable"
assert gate["zones"][0]["px_per_line"] == 105.8
def test_mdx02_calibration_tight():
"""mdx02 bottom: 339px, intro 7라인 → 40.4px/line = tight (사람: cramped)."""
zones = [_zone("bottom", {"intro_sections": [
{"label": "a", "lines": ["1", "2", "3", "4", "5", "6"]}, # 7 lines
{"label": "b", "lines": ["1", "2"]},
]})]
gate = _compute_density_gate(zones, _overflow({"bottom": 339}))
assert gate["passed"] is False
assert gate["worst_verdict"] == "tight"
assert gate["zones"][0]["px_per_line"] == 40.4
def test_mdx01_calibration_dense():
"""mdx01 bottom-right: 286px 표 20라인 → 11.5px/line = dense
(#98 사람 판정 'bottom comparison dense')."""
zones = [_zone("bottom-right", {"rows": [
{"cell": "<br>".join(str(i) for i in range(20))},
]}, template="bim_dx_comparison_table")]
gate = _compute_density_gate(zones, _overflow({"bottom-right": 286}))
assert gate["worst_verdict"] == "dense"
assert gate["zones"][0]["px_per_line"] == 11.5
def test_unmeasurable_zone_does_not_fail_gate():
zones = [_zone("top", {"title": "only-title"})]
gate = _compute_density_gate(zones, _overflow({"top": 300}))
assert gate["passed"] is True
assert gate["zones"][0]["verdict"] == "not_measurable"
def test_missing_height_not_measurable():
zones = [_zone("top", {"pill_1_label": "L", "pill_1_body": ["a"]})]
gate = _compute_density_gate(zones, _overflow({}))
assert gate["zones"][0]["verdict"] == "not_measurable"
assert gate["passed"] is True
# ── design_readiness 통합 ────────────────────────────────────────────
def _readiness(density_gate):
return _compute_design_readiness(
units=[],
rendered_text_coverage={"passed": True},
applied_render_consistency={"passed": True},
density_gate=density_gate,
)
def test_tight_zone_downgrades_to_needs_review():
gate = {"passed": False, "zones": [
{"position": "bottom", "template_id": "t", "verdict": "tight",
"px_per_line": 40.4, "estimated_max_lines": 7, "zone_height_px": 339},
]}
r = _readiness(gate)
assert r["status"] == "needs_review" # not_ready 아님 (사람 판정 정렬)
assert any(w["code"] == "density_tight" for w in r["warnings"])
def test_dense_zone_also_needs_review_not_not_ready():
gate = {"passed": False, "zones": [
{"position": "br", "template_id": "t", "verdict": "dense",
"px_per_line": 11.5, "estimated_max_lines": 20, "zone_height_px": 286},
]}
r = _readiness(gate)
assert r["status"] == "needs_review"
assert any(w["code"] == "density_dense" for w in r["warnings"])
def test_comfortable_gate_keeps_ready():
gate = {"passed": True, "zones": [
{"position": "left", "verdict": "comfortable"},
]}
r = _readiness(gate)
assert r["status"] == "ready"
def test_density_gate_none_backward_compat():
r = _compute_design_readiness(
units=[],
rendered_text_coverage={"passed": True},
applied_render_consistency={"passed": True},
)
assert r["status"] == "ready"