기계 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:
@@ -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(
|
||||
*,
|
||||
units: list[CompositionUnit],
|
||||
rendered_text_coverage: dict,
|
||||
applied_render_consistency: dict,
|
||||
density_gate: Optional[dict] = None,
|
||||
) -> dict:
|
||||
"""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.",
|
||||
})
|
||||
|
||||
# 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"
|
||||
if warnings:
|
||||
status = "needs_review"
|
||||
@@ -11025,10 +11142,14 @@ def run_phase_z2_mvp1(
|
||||
debug_zones=debug_zones,
|
||||
)
|
||||
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(
|
||||
units=units,
|
||||
rendered_text_coverage=rendered_text_coverage,
|
||||
applied_render_consistency=applied_render_consistency,
|
||||
density_gate=density_gate,
|
||||
)
|
||||
slide_status["design_readiness"] = design_readiness
|
||||
if not rendered_text_coverage.get("passed"):
|
||||
@@ -11092,10 +11213,13 @@ def run_phase_z2_mvp1(
|
||||
and applied_render_consistency.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["technical_pass"]
|
||||
and slide_status["visual_pass"]
|
||||
and presentation_fit.get("passed")
|
||||
and density_gate.get("passed", True)
|
||||
)
|
||||
slide_status["presentation_reselection"] = _build_t28_5c_reselection_trace(
|
||||
units=units,
|
||||
@@ -11145,10 +11269,13 @@ def run_phase_z2_mvp1(
|
||||
rendered_html=final_html_for_status,
|
||||
debug_zones=debug_zones,
|
||||
)
|
||||
# issue #15 — popup 승격 후 재계산 경로에도 density gate 동일 배선
|
||||
density_gate = _compute_density_gate(zones_data, overflow)
|
||||
design_readiness = _compute_design_readiness(
|
||||
units=units,
|
||||
rendered_text_coverage=rendered_text_coverage,
|
||||
applied_render_consistency=applied_render_consistency,
|
||||
density_gate=density_gate,
|
||||
)
|
||||
presentation_fit = _build_presentation_fit_report(
|
||||
overflow=overflow,
|
||||
@@ -11158,6 +11285,7 @@ def run_phase_z2_mvp1(
|
||||
slide_status["text_coverage_passed"] = bool(rendered_text_coverage.get("passed"))
|
||||
slide_status["forbidden_rendered_syntax"] = forbidden_rendered_syntax
|
||||
slide_status["applied_render_consistency"] = applied_render_consistency
|
||||
slide_status["density_gate"] = density_gate
|
||||
slide_status["design_readiness"] = design_readiness
|
||||
slide_status["presentation_fit"] = presentation_fit
|
||||
slide_status["technical_pass"] = bool(
|
||||
@@ -11172,6 +11300,7 @@ def run_phase_z2_mvp1(
|
||||
slide_status["technical_pass"]
|
||||
and slide_status["visual_pass"]
|
||||
and presentation_fit.get("passed")
|
||||
and density_gate.get("passed", True)
|
||||
)
|
||||
slide_status["presentation_reselection"] = _build_t28_5c_reselection_trace(
|
||||
units=units,
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user