diff --git a/src/phase_z2_pipeline.py b/src/phase_z2_pipeline.py
index 9265aad..c72a3e8 100644
--- a/src/phase_z2_pipeline.py
+++ b/src/phase_z2_pipeline.py
@@ -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 값의 ``
`` 라인. 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("
") + 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("
") + 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,
diff --git a/tests/test_phase_z2_issue15_density_gate.py b/tests/test_phase_z2_issue15_density_gate.py
new file mode 100644
index 0000000..69baacc
--- /dev/null
+++ b/tests/test_phase_z2_issue15_density_gate.py
@@ -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": "가
나
다"}
+ 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": "
".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"