feat(#17): generic fallback 탈출 — V4 evidence 확장 + renderable-aware provisional + 게이트 순서 버그 수정

1. V4 evidence 확장 (pipeline_17b_extend_missing_sections.py):
   '결과물이 아니라 프로세스' 원칙 — pipeline_17 과 동일 평가 코드로 누락 3개
   섹션(01-intro/05-1/05-2)만 평가해 v4_full32_result.yaml 병합 (기존 무접촉,
   blind/ANSWER_MAP 불변). 결과: 05-1 → F20 light_edit 0.77 (design-matched!),
   01-intro/05-2 → all-reject (catalog gap 정직 노출 — F19 가 05 주제와 이름까지
   일치하나 partial 없음 → #2 프로모션 최우선 근거)

2. renderable-aware provisional (IMP-30 u1 정밀화):
   rank-1 무조건 승격 → partial 존재 AND (비-reject OR verbatim builder 보유)
   첫 후보 승격. reject+builder 미보유 renderable 의 mapper 네이티브 렌더는
   원문 drop 위험 (F23 1-atom 손실 실측) — 원문 보존 > design 개선 우선순위.

3. 게이트 순서 버그 수정 (_apply_quality_gate_downgrades 추출):
   T28.5d popup 승격 후 재계산 경로에 quality gate 강등 3종(coverage/forbidden/
   consistency) 미적용 → 텍스트 손실이 overall=PASS 로 통과 (mdx05 실측).
   양 경로 공통 헬퍼로 통일 — mdx04 의 #16 시점 PASS 일부가 이 버그 덕이었음을
   정직하게 정정 (현재 PARTIAL + frame mismatch 라벨, 텍스트는 완전).

최종 5-MDX: 전부 missing_atoms=0 (무손실) / 01·03 PASS / 02·04·05 PARTIAL(정직
frame-mismatch 라벨) / mdx01 readiness not_ready→needs_review 개선.
게이트: 1061 passed. SHA baseline 재캡처 (정당 변경).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-07 13:51:00 +09:00
co-authored by Claude Opus 4.8
parent 63f4479b05
commit 9b79bf7538
6 changed files with 1868 additions and 58 deletions
+121 -52
View File
@@ -664,6 +664,21 @@ def _build_verbatim_compare_table_2col(unit) -> dict:
}
# issue #17 — verbatim builder 를 보유한 template 집합 (아래 dispatch 와 동기).
# renderable-aware provisional 이 reject 라벨 후보를 승격해도 안전한지(원문
# 무손실 코드 렌더 가능한지) 판단하는 데 사용.
_VERBATIM_BUILDER_TEMPLATE_IDS: frozenset[str] = frozenset({
"three_parallel_requirements",
"three_persona_benefits",
"construction_goals_three_circle_intersection",
"construction_bim_three_usage",
"bim_dx_comparison_table",
"bim_issues_quadrant_four",
"sw_dependency_four_problems",
"pre_construction_model_info_stacked",
})
def _emergency_p4b_build_verbatim_slot_payload(
unit, template_id: str, override_slot_count: Optional[int] = None,
) -> Optional[dict]:
@@ -2797,16 +2812,41 @@ def lookup_v4_match_with_fallback(
# trace entries are left intact (their skip reasons remain accurate).
# Default-off keeps IMP-05 behavior byte-identical.
if allow_provisional:
rank_1_judgment = judgments[0]
# issue #17 — renderable-aware provisional. rank 순서는 유지하되
# 승격 가능 조건: partial HTML 존재 (IMP-95 u6 partial_exists 원칙)
# AND (비-reject 라벨 OR verbatim builder 지원). 근거 실측:
# - partial 없는 frame 승격 → mapper FitError → verbatim 복구 →
# selected_frame_not_applied 라벨 (mdx05 05-2 rank-1 F6)
# - reject 라벨 + verbatim builder 없는 frame 의 mapper 네이티브
# 렌더는 원문 drop 위험 (mdx05 05-2 F23: 1 atom 손실 실측 —
# 원문 보존이 design 개선보다 우선)
# 조건 만족 후보가 없으면 기존대로 rank-1 (동작 보존 — verbatim
# 복구가 텍스트 무손실을 보장).
provisional_judgment = judgments[0]
provisional_rank = 1
for _rank, _judgment in enumerate(judgments, start=1):
_tid = str(_judgment.get("template_id") or "")
if not _b4_partial_exists(_tid):
continue
_label = str(_judgment.get("label") or "")
if _label == "reject" and _tid not in _VERBATIM_BUILDER_TEMPLATE_IDS:
continue
provisional_judgment, provisional_rank = _judgment, _rank
break
provisional_match = _v4_match_from_judgment(
section_id, rank_1_judgment, rank=1
section_id, provisional_judgment, rank=provisional_rank
)
provisional_match.selection_path = "provisional_rank_1"
_prov_path = (
"provisional_rank_1"
if provisional_rank == 1
else f"provisional_renderable_rank_{provisional_rank}"
)
provisional_match.selection_path = _prov_path
provisional_match.fallback_reason = trace["fallback_reason"]
provisional_match.provisional = True
trace.update({
"selection_path": "provisional_rank_1",
"selected_rank": 1,
"selection_path": _prov_path,
"selected_rank": provisional_rank,
"selected_template_id": provisional_match.template_id,
"selected_frame_id": provisional_match.frame_id,
"selected_label": provisional_match.label,
@@ -5870,6 +5910,63 @@ def _compute_density_gate(zones_data: list[dict], overflow: dict) -> dict:
}
def _apply_quality_gate_downgrades(
slide_status: dict,
*,
rendered_text_coverage: dict,
forbidden_rendered_syntax: dict,
applied_render_consistency: dict,
) -> None:
"""issue #17 — quality gate 3종의 overall 강등을 단일 헬퍼로.
slide_status 조립 경로(최초 + T28.5d popup 승격 재계산) 동일한
강등 규칙을 공유해야 한다 재계산 경로에 블록이 없어서 텍스트
손실(rendered_text_coverage fail) overall=PASS 통과하는 버그가
있었음 (mdx05 05-2 F23 렌더 1-atom 손실 실측).
"""
if not rendered_text_coverage.get("passed"):
slide_status.setdefault("quality_gate_failures", []).append(
"rendered_text_coverage_missing"
)
slide_status.setdefault("visual_fail_reasons", []).append(
"rendered_text_coverage_missing: "
f"{rendered_text_coverage.get('missing_atoms_count')} source text atom(s) "
"not visible in final.html"
)
if slide_status.get("overall") == "PASS":
slide_status["overall"] = "PARTIAL_COVERAGE"
if not forbidden_rendered_syntax.get("passed"):
slide_status.setdefault("quality_gate_failures", []).append(
"forbidden_rendered_syntax"
)
slide_status.setdefault("visual_fail_reasons", []).append(
"forbidden_rendered_syntax: "
f"{forbidden_rendered_syntax.get('forbidden_syntax_count')} JSX/CSS/code "
"fragment(s) visible in final.html"
)
if slide_status.get("overall") == "PASS":
slide_status["overall"] = "PARTIAL_COVERAGE"
if not applied_render_consistency.get("passed"):
for failure in applied_render_consistency.get("failures") or []:
slide_status.setdefault("quality_gate_failures", []).append(failure)
if applied_render_consistency.get("frame_mismatch_count"):
slide_status.setdefault("visual_fail_reasons", []).append(
"selected_frame_not_applied: "
f"{applied_render_consistency.get('frame_mismatch_count')} zone(s) "
"rendered with a different template than Step 6 selected"
)
if "final_layout_positions_mismatch" in (applied_render_consistency.get("failures") or []):
slide_status.setdefault("visual_fail_reasons", []).append(
"final_layout_positions_mismatch: "
f"expected {applied_render_consistency.get('expected_positions')} but "
f"final.html has {applied_render_consistency.get('final_positions')}"
)
if slide_status.get("overall") == "PASS":
slide_status["overall"] = "PARTIAL_COVERAGE"
def _compute_design_readiness(
*,
units: list[CompositionUnit],
@@ -11310,53 +11407,17 @@ def run_phase_z2_mvp1(
density_gate=density_gate,
)
slide_status["design_readiness"] = design_readiness
if not rendered_text_coverage.get("passed"):
slide_status.setdefault("quality_gate_failures", []).append(
"rendered_text_coverage_missing"
)
slide_status.setdefault("visual_fail_reasons", []).append(
"rendered_text_coverage_missing: "
f"{rendered_text_coverage.get('missing_atoms_count')} source text atom(s) "
"not visible in final.html"
)
if slide_status.get("overall") == "PASS":
slide_status["overall"] = "PARTIAL_COVERAGE"
# IMP-47B u8 — Surface Step 12 AI repair outcomes through slide_status.
# Composes u4 gather errors + u5 apply_status + u7 coverage_invariant
# into a single ``ai_repair_status`` axis the frontend (u11) reads to
# render human_review notifications. Auto pipeline first
# ([[feedback_auto_pipeline_first]]) — no review_queue insertion;
# explicit status enum + human_review_required flag.
if not forbidden_rendered_syntax.get("passed"):
slide_status.setdefault("quality_gate_failures", []).append(
"forbidden_rendered_syntax"
)
slide_status.setdefault("visual_fail_reasons", []).append(
"forbidden_rendered_syntax: "
f"{forbidden_rendered_syntax.get('forbidden_syntax_count')} JSX/CSS/code "
"fragment(s) visible in final.html"
)
if slide_status.get("overall") == "PASS":
slide_status["overall"] = "PARTIAL_COVERAGE"
if not applied_render_consistency.get("passed"):
for failure in applied_render_consistency.get("failures") or []:
slide_status.setdefault("quality_gate_failures", []).append(failure)
if applied_render_consistency.get("frame_mismatch_count"):
slide_status.setdefault("visual_fail_reasons", []).append(
"selected_frame_not_applied: "
f"{applied_render_consistency.get('frame_mismatch_count')} zone(s) "
"rendered with a different template than Step 6 selected"
)
if "final_layout_positions_mismatch" in (applied_render_consistency.get("failures") or []):
slide_status.setdefault("visual_fail_reasons", []).append(
"final_layout_positions_mismatch: "
f"expected {applied_render_consistency.get('expected_positions')} but "
f"final.html has {applied_render_consistency.get('final_positions')}"
)
if slide_status.get("overall") == "PASS":
slide_status["overall"] = "PARTIAL_COVERAGE"
# IMP-47B u8 주석 이력 보존 — Surface Step 12 AI repair outcomes through
# slide_status (아래 ai_repair_status 블록). quality gate downgrade 3종은
# issue #17 에서 _apply_quality_gate_downgrades 로 추출 (popup 승격 후
# 재계산 경로에도 동일 적용 — 미적용 시 text 손실이 PASS 로 통과하는
# 게이트 순서 버그가 있었음: mdx05 실측).
_apply_quality_gate_downgrades(
slide_status,
rendered_text_coverage=rendered_text_coverage,
forbidden_rendered_syntax=forbidden_rendered_syntax,
applied_render_consistency=applied_render_consistency,
)
presentation_fit = _build_presentation_fit_report(
overflow=overflow,
@@ -11448,6 +11509,14 @@ def run_phase_z2_mvp1(
slide_status["density_gate"] = density_gate
slide_status["design_readiness"] = design_readiness
slide_status["presentation_fit"] = presentation_fit
# issue #17 — 재계산 경로에도 quality gate 강등 적용 (미적용 시
# text 손실이 overall=PASS 로 통과하던 게이트 순서 버그).
_apply_quality_gate_downgrades(
slide_status,
rendered_text_coverage=rendered_text_coverage,
forbidden_rendered_syntax=forbidden_rendered_syntax,
applied_render_consistency=applied_render_consistency,
)
slide_status["technical_pass"] = bool(
slide_status.get("rendered")
and slide_status.get("full_mdx_coverage")
@@ -0,0 +1,151 @@
"""Pipeline Step 17b — V4 full-32 평가를 누락 섹션에 확장 (GitHub issue #17).
배경: v4_full32_result.yaml (2026-04-29) 은 mdx 01~04 의 10개 섹션만 평가 —
`01-intro` / `05-1` / `05-2` 는 V4 source 자체가 없어 런타임에서 무조건
generic fallback (design_readiness=not_ready) 이 됨 (emergency.md C3).
원칙: "결과물을 고치지 말고 프로세스를 고친다" — yaml 손편집이 아니라
pipeline_17 과 동일한 평가 코드(detect_mdx_analysis + compute_template_fit
+ route)로 누락 섹션만 평가해 병합한다. 기존 섹션 엔트리는 무접촉.
실행: python tests/matching/pipeline_17b_extend_missing_sections.py
출력: v4_full32_result.yaml (기존 10 + 신규 3 = 13 sections)
"""
import datetime
import sys
from pathlib import Path
import yaml
HERE = Path(__file__).parent
sys.path.insert(0, str(HERE))
from phase_common import load_32_frames, load_frame_index
from detect_mdx import detect_mdx_analysis
from template_fit import (
load_templates_v1, collect_anchor_vocab, compute_template_fit, route,
)
from embeddings import embed_texts, cosine
from pipeline_01_extract_nodes import MDX_DIR
OUT_PATH = HERE / 'v4_full32_result.yaml'
# 신규 평가 대상 — 런타임 section_id 와 동일한 키 (lookup_v4 exact match).
# 01-intro 는 heading 없는 pre-intro 블록이라 시작 라인을 본문 첫 그룹으로 지정.
NEW_SECTIONS = {
'01-intro': {'file': '01.mdx', 'start': '* **용어의 혼용**', 'end_prefix': '## 1.',
'title': '건설산업 DX의 올바른 이해 — 도입(용어의 혼용)'},
'05-1': {'file': '05.mdx', 'start': '## 1. 설계의 자동화', 'end_prefix': '## 2.',
'title': None},
'05-2': {'file': '05.mdx', 'start': '## 2. S/W 중심 설계 방식', 'end_prefix': None,
'title': None},
}
def extract_raw(cfg):
lines = (MDX_DIR / cfg['file']).read_text(encoding='utf-8').split('\n')
start = None
for i, ln in enumerate(lines):
if ln.strip() == cfg['start'].strip():
start = i
break
if start is None:
raise RuntimeError(f"start line not found: {cfg['start']!r} in {cfg['file']}")
end = len(lines)
if cfg.get('end_prefix'):
for i in range(start + 1, len(lines)):
if lines[i].strip().startswith(cfg['end_prefix']):
end = i
break
section = lines[start:end]
title = cfg.get('title') or section[0].lstrip('#').strip()
return title, '\n'.join(section)
def main():
existing = yaml.safe_load(OUT_PATH.read_text(encoding='utf-8'))
already = set(existing['mdx_sections'].keys())
todo = {sid: cfg for sid, cfg in NEW_SECTIONS.items() if sid not in already}
if not todo:
print('신규 평가 대상 없음 — 모두 존재.')
return
templates = load_templates_v1()
anchor_vocab = collect_anchor_vocab(templates)
frames = load_32_frames()
idx_data, frame_to_short = load_frame_index()
fids = list(frames.keys())
frame_num_map = {fid: int(frame_to_short[fid]) for fid in fids}
frame_contents = [frames[fid].get('content', '') for fid in fids]
print(f'[17b] 32 frame content 임베딩 중... (신규 섹션: {sorted(todo)})')
frame_vecs = embed_texts(frame_contents)
for sid, cfg in sorted(todo.items()):
title, raw_text = extract_raw(cfg)
mdx_analysis = detect_mdx_analysis(raw_text, title, anchor_vocab=anchor_vocab)
mdx_vec = embed_texts([mdx_analysis['summary']])[0]
judgments = []
for i, fid in enumerate(fids):
if fid not in templates:
continue
template = templates[fid]
content_emb = max(0.0, min(1.0, float(cosine(mdx_vec, frame_vecs[i]))))
fit = compute_template_fit(mdx_analysis, template, content_emb)
label = route(fit['confidence'], fit['axes'], fit['adaptation'], fit['not_suits'])
judgments.append({
'frame_id': fid,
'frame_number': frame_num_map[fid],
'template_id': template.get('template_id'),
'confidence': round(float(fit['confidence']), 4),
'base': round(float(fit['base']), 4),
'penalty': round(float(fit['total_penalty']), 4),
'label': label,
'content_embedding': round(content_emb, 4),
'axes': {
'anchor': round(float(fit['axes']['anchor']['score']), 4),
'cardinality': round(float(fit['axes']['cardinality']), 4),
'relation': round(float(fit['axes']['relation']), 4),
'slot': round(float(fit['axes']['slot']), 4),
'content': round(float(fit['axes']['content']), 4),
},
})
judgments.sort(key=lambda x: -x['confidence'])
for new_rank, item in enumerate(judgments, start=1):
item['v4_full_rank'] = new_rank
existing['mdx_sections'][sid] = {
'mdx_title': title,
'answer_frame_number': None, # blind — ANSWER_MAP 변경 금지 (Holdout 원칙 1)
'is_holdout': True,
'judgments_full32': judgments,
'usable_count': sum(1 for j in judgments if j['label'] != 'reject'),
'reject_count': sum(1 for j in judgments if j['label'] == 'reject'),
}
top = judgments[0]
print(f" {sid}: rank1 = F{top['frame_number']} {top['template_id']} "
f"({top['label']}, {top['confidence']}) usable={existing['mdx_sections'][sid]['usable_count']}")
meta = existing.setdefault('meta', {})
ext = meta.setdefault('extensions', [])
ext.append({
'step': '17b_extend_missing_sections',
'added_sections': sorted(todo),
'reason': 'GitHub issue #17 — generic fallback 탈출 (C3: V4 source 누락 해소)',
'generated_at': datetime.datetime.now().isoformat(timespec='seconds'),
})
holdout = meta.setdefault('holdout_sections', [])
for sid in sorted(todo):
if sid not in holdout:
holdout.append(sid)
OUT_PATH.write_text(
yaml.safe_dump(existing, allow_unicode=True, sort_keys=False, width=1000),
encoding='utf-8',
)
print(f'병합 완료: {OUT_PATH} (총 {len(existing["mdx_sections"])} sections)')
if __name__ == '__main__':
main()
File diff suppressed because it is too large Load Diff
@@ -2,7 +2,7 @@
"schema_version": 2,
"axis": "IMP-89 89-a u4 — final.html SHA baseline captured via FULL run_phase_z2_mvp1 pipeline (flag OFF / default)",
"description": "Frozen SHA-256 of `final.html` bytes (the artifact written to disk at src/phase_z2_pipeline.py:5994-5996) captured by running the full Phase Z pipeline end-to-end for each mdx 01-05 under PHASE_Z_B4_MAPPER_SOURCE=OFF. Under flag OFF the 89-a selector `_select_mapper_template_id(plan, T)` returns `T` verbatim, so the mapper input is byte-identical to the pre-89-a legacy call shape `map_mdx_to_slots(section, unit.frame_template_id)` — the rendered HTML and therefore the final.html SHA match the pre-89-a baseline. The u4 regression test runs the same pipeline shape under flag OFF and asserts SHA equality. Regenerate only when an upstream mapper/render/template delta is deliberately reviewed and accepted.",
"captured_at_utc": "2026-07-07T00:11:02Z",
"captured_at_utc": "2026-07-07T04:43:23Z",
"renderer": {
"entrypoint": "src.phase_z2_pipeline.run_phase_z2_mvp1",
"write_site": "src/phase_z2_pipeline.py:5994-5996",
@@ -19,8 +19,8 @@
"01.mdx": {
"mdx_file": "01.mdx",
"run_id": "89a_baseline_01",
"final_html_size_bytes": 38731,
"sha256": "749ebb626e284a62d37b5be32af97c61ae2a5b8d4226d1c3fa90f04c843d9fef",
"final_html_size_bytes": 36240,
"sha256": "1e398a65298064e0fe653453b81e625bf2eeaec4077a78d85f8e307e58d69c80",
"pipeline_exit_code": null
},
"02.mdx": {
@@ -2,7 +2,7 @@
"schema_version": 1,
"axis": "IMP-95 u8 — final.html SHA baseline captured via FULL run_phase_z2_mvp1 pipeline under PHASE_Z_B4_V4_EVIDENCE=OFF and PHASE_Z_B4_MAPPER_SOURCE=OFF (defaults)",
"description": "Frozen SHA-256 of `final.html` bytes (production write site src/phase_z2_pipeline.py:5994-5996) for mdx 01/02/04/05 under PHASE_Z_B4_V4_EVIDENCE OFF. Under flag OFF, IMP-95 (u1~u7) is strictly no-op for final.html (planner branch falls through to legacy _select_frame at u3; u4/u5/u6 additive telemetry confined to placement_trace per the trace-only docstring at src/phase_z2_pipeline.py:86). The u8 regression test asserts SHA equality with these frozen values, so any future code change that drifts the flag-OFF render output produces a mismatch and breaks the test. mdx 03 is excluded per Stage 2 u8 scope (mdx 03 정비 LOCK). Regenerate only when an upstream delta is reviewed and accepted as the new pre-IMP-95 reference.",
"captured_at_utc": "2026-07-07T00:09:32Z",
"captured_at_utc": "2026-07-07T04:41:54Z",
"renderer": {
"entrypoint": "src.phase_z2_pipeline.run_phase_z2_mvp1",
"write_site": "src/phase_z2_pipeline.py:5994-5996",
@@ -18,8 +18,8 @@
"01.mdx": {
"mdx_file": "01.mdx",
"run_id": "imp95_baseline_01",
"final_html_size_bytes": 38731,
"sha256": "749ebb626e284a62d37b5be32af97c61ae2a5b8d4226d1c3fa90f04c843d9fef",
"final_html_size_bytes": 36240,
"sha256": "1e398a65298064e0fe653453b81e625bf2eeaec4077a78d85f8e307e58d69c80",
"pipeline_exit_code": null
},
"02.mdx": {
@@ -0,0 +1,118 @@
"""issue #17 — renderable-aware provisional 선택 테스트.
계약: IMP-30 u1 provisional 합성이 rank-1 을 무조건 승격하던 것을,
partial HTML 이 존재하는 첫 judgment(rank 순) 승격으로 변경 (IMP-95 u6
partial_exists 원칙). 전부 non-renderable 이면 기존대로 rank-1 (동작 보존).
실측 근거: mdx05 05-2 rank-1 F6(compensation_complaint_map, contract-only)
승격 → mapper FitError → verbatim 복구 → selected_frame_not_applied 라벨
→ PARTIAL_COVERAGE. renderable rank-6 F23 선택 시 정상 경로.
"""
from __future__ import annotations
import src.phase_z2_pipeline as _pz
from src.phase_z2_pipeline import lookup_v4_match_with_fallback
def _j(rank, template_id, frame_id, label, conf=0.5):
return {
"v4_full_rank": rank,
"template_id": template_id,
"frame_id": frame_id,
"frame_number": rank,
"confidence": conf,
"label": label,
}
def _v4(judgments):
return {"mdx_sections": {"S1": {"judgments_full32": judgments}}}
def test_provisional_skips_contract_only_frames(monkeypatch):
"""rank1/2 는 partial 없음, rank3 존재(+verbatim builder) → rank3 승격."""
monkeypatch.setattr(
_pz, "_b4_partial_exists",
lambda tid: tid == "T_renderable",
)
monkeypatch.setattr(
_pz, "_VERBATIM_BUILDER_TEMPLATE_IDS", frozenset({"T_renderable"}),
)
v4 = _v4([
_j(1, "T_contract_only_a", "F1", "reject", 0.6),
_j(2, "T_contract_only_b", "F2", "reject", 0.5),
_j(3, "T_renderable", "F3", "reject", 0.4),
])
match, trace = lookup_v4_match_with_fallback(
v4, "S1", allow_provisional=True,
)
assert match is not None
assert match.template_id == "T_renderable"
assert match.provisional is True
assert trace["selection_path"] == "provisional_renderable_rank_3"
assert trace["selected_rank"] == 3
def test_provisional_rank1_when_renderable(monkeypatch):
monkeypatch.setattr(_pz, "_b4_partial_exists", lambda tid: True)
monkeypatch.setattr(
_pz, "_VERBATIM_BUILDER_TEMPLATE_IDS", frozenset({"T_a", "T_b"}),
)
v4 = _v4([
_j(1, "T_a", "F1", "reject", 0.6),
_j(2, "T_b", "F2", "reject", 0.5),
])
match, trace = lookup_v4_match_with_fallback(
v4, "S1", allow_provisional=True,
)
assert match.template_id == "T_a"
assert trace["selection_path"] == "provisional_rank_1"
assert trace["selected_rank"] == 1
def test_provisional_all_non_renderable_keeps_rank1(monkeypatch):
"""전부 partial 없음 → 기존 동작 보존 (rank-1, 정직 라벨은 하류 게이트)."""
monkeypatch.setattr(_pz, "_b4_partial_exists", lambda tid: False)
v4 = _v4([
_j(1, "T_a", "F1", "reject", 0.6),
_j(2, "T_b", "F2", "reject", 0.5),
])
match, trace = lookup_v4_match_with_fallback(
v4, "S1", allow_provisional=True,
)
assert match.template_id == "T_a"
assert trace["selection_path"] == "provisional_rank_1"
def test_provisional_reject_without_verbatim_builder_not_promoted(monkeypatch):
"""renderable 이어도 reject + verbatim builder 미보유면 승격 금지 —
mapper 네이티브 렌더의 원문 drop 위험 (mdx05 05-2 F23 1-atom 손실 실측).
조건 만족 후보 없음 → rank-1 유지 (verbatim 복구가 무손실 보장)."""
monkeypatch.setattr(_pz, "_b4_partial_exists", lambda tid: tid == "T_renderable")
monkeypatch.setattr(_pz, "_VERBATIM_BUILDER_TEMPLATE_IDS", frozenset())
v4 = _v4([
_j(1, "T_contract_only", "F1", "reject", 0.6),
_j(2, "T_renderable", "F2", "reject", 0.4),
])
match, trace = lookup_v4_match_with_fallback(
v4, "S1", allow_provisional=True,
)
assert match.template_id == "T_contract_only"
assert trace["selection_path"] == "provisional_rank_1"
def test_provisional_non_reject_renderable_promoted_without_builder(monkeypatch):
"""light_edit 등 비-reject 라벨은 verbatim builder 없어도 승격 가능
(mapper 가 정상 fit 예상 — V4 판정 자체가 사용 가능 평가)."""
monkeypatch.setattr(_pz, "_b4_partial_exists", lambda tid: tid == "T_light")
monkeypatch.setattr(_pz, "_VERBATIM_BUILDER_TEMPLATE_IDS", frozenset())
v4 = _v4([
_j(1, "T_contract_only", "F1", "reject", 0.6),
_j(2, "T_light", "F2", "light_edit", 0.5),
])
match, trace = lookup_v4_match_with_fallback(
v4, "S1", allow_provisional=True,
)
assert match.template_id == "T_light"
# IMP-39 label-priority sort 로 light_edit 이 정렬 1위 → rank_1 경로.
assert trace["selection_path"] == "provisional_rank_1"