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"