feat(#67): IMP-38 V4 max_rank policy formalization (u1~u3, 4 round consensus)

- u1: separate templates/phase_z2/catalog/v4_fallback_policy.yaml + load_v4_fallback_policy() loader
  (catalog pollution prevention — Codex #1 correction)
- u2: dynamic effective max_rank in lookup_v4_match_with_fallback (3-variable ceiling min,
  Codex #2 correction: min(configured, len(judgments_full32))) + 3-tier usable predicate
  (status + catalog + optional capacity) + trace 8 fields (requested/default/configured_extended/
  judgments_count/effective_extended_ceiling/effective_max_rank/usable_count/policy_applied)
- u3: 2 production call site cleanup (max_rank=3 removed, HEAD baseline) + tracked
  Front/vite.config.ts PHASE_Z_MAX_RANK env retired + 4 regression scenarios

verified: 32 passed (IMP-38 focused scope) — IMP-05 L4 dedup / L2 schema preserved,
IMP-30 allow_provisional byte-identical, caller_override backward compat (tests)

Stage cycle (#67, 7 round Claude + 5 round Codex):
- Stage 1: Claude #1 -> Codex #1 YES + 5 corrections
- Stage 2 r1+r2: Claude #2-#4 -> Codex #2 Q2 -> Codex #3 YES (4 round consensus LOCK 23195)
- Stage 3 U1+U2+U3: Claude #5-#9 -> Codex #6 NO 4to3 correction -> Codex #7 YES -> Codex #8 YES
- Stage 4: Claude #11 -> Codex #9 (anchor attribution nuance) -> Codex #10 readiness -> Codex #11

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 22:14:05 +09:00
parent dceb10129f
commit 90503cadd6
7 changed files with 576 additions and 15 deletions

View File

@@ -32,6 +32,7 @@ import yaml
PROJECT_ROOT = Path(__file__).parent.parent
CATALOG_PATH = PROJECT_ROOT / "templates" / "phase_z2" / "catalog" / "frame_contracts.yaml"
V4_FALLBACK_POLICY_PATH = PROJECT_ROOT / "templates" / "phase_z2" / "catalog" / "v4_fallback_policy.yaml"
class FitError(Exception):
@@ -57,6 +58,44 @@ def get_contract(template_id: str) -> dict | None:
return load_frame_contracts().get(template_id)
# ─── V4 fallback policy loading (IMP-38) ──────────────────────────
_V4_FALLBACK_POLICY_CACHE: dict | None = None
_V4_FALLBACK_POLICY_DEFAULT: dict = {
"policy_type": "static",
"usable_threshold": 1,
"default_max_rank": 3,
"extended_max_rank": 3, # graceful: yaml 없을 시 확장 X (byte-identical to pre-IMP-38)
}
def load_v4_fallback_policy() -> dict:
"""IMP-38 V4 fallback policy loader (separate yaml, catalog 오염 방지).
Returns dict with keys: policy_type, usable_threshold, default_max_rank, extended_max_rank.
Codex #1 권장: frame_contracts.yaml top-level 오염 회피 (별 yaml).
Codex #3 LOCK: load_frame_contracts() shape 변경 X (이 함수는 별 cache).
Graceful fallback:
yaml 파일 없을 시 → _V4_FALLBACK_POLICY_DEFAULT (default_max_rank=3, extended=3)
→ backward compat byte-identical to pre-IMP-38 behavior.
Returns:
dict — 정책 키 (정책 yaml 의 superset 가능, 알 수 없는 키는 무시 권장).
"""
global _V4_FALLBACK_POLICY_CACHE
if _V4_FALLBACK_POLICY_CACHE is None:
if V4_FALLBACK_POLICY_PATH.exists():
loaded = yaml.safe_load(V4_FALLBACK_POLICY_PATH.read_text(encoding="utf-8")) or {}
# merge with default (yaml 키 부분 누락 시 default 로 fall through)
_V4_FALLBACK_POLICY_CACHE = {**_V4_FALLBACK_POLICY_DEFAULT, **loaded}
else:
_V4_FALLBACK_POLICY_CACHE = dict(_V4_FALLBACK_POLICY_DEFAULT)
return _V4_FALLBACK_POLICY_CACHE
# ─── Source-shape splitters ──────────────────────────────────────
def _split_top_bullets(content: str) -> list[tuple[str, list[str]]]:

View File

@@ -52,6 +52,7 @@ from phase_z2_mapper import (
compute_capacity_fit,
get_contract,
load_frame_contracts,
load_v4_fallback_policy,
map_with_contract,
)
from phase_z2_classifier import classify_visual_runtime_check
@@ -589,34 +590,106 @@ def lookup_v4_match_with_fallback(
section_id: str,
*,
raw_content: Optional[str] = None,
max_rank: int = 3,
max_rank: Optional[int] = None,
alias_keys: Optional[list] = None,
allow_provisional: bool = False,
) -> tuple[Optional[V4Match], dict]:
"""Select V4 rank-1, or promote rank-2/3 when rank-1 is not auto-renderable.
"""Select V4 rank-1, or promote rank-2..N when rank-1 is not auto-renderable.
This is an IMP-05 selector only. It uses existing V4 labels, frame-contract
presence, and the Phase Z capacity precheck; it does not call calculate_fit.
IMP-30 u1 — when ``allow_provisional=True`` and the rank-1..max_rank chain
is exhausted (no candidate passes MVP1 filter + contract + capacity), the
selector synthesizes a *provisional* V4Match from the rank-1 judgment so
IMP-30 u1 — when ``allow_provisional=True`` and the rank-1..effective_max_rank
chain is exhausted (no candidate passes MVP1 filter + contract + capacity),
the selector synthesizes a *provisional* V4Match from the rank-1 judgment so
the first-render invariant can be satisfied downstream. The synthesized
match carries ``provisional=True``, ``selection_path="provisional_rank_1"``,
and ``fallback_reason`` mirrors the existing chain-exhaust reason. The
candidate trace shape is unchanged (synthetic injection only updates the
top-level ``selection_path`` + ``selected_*`` mirrors). When the rank-1
judgment itself is missing (``empty_v4_judgments`` / ``no_v4_section``),
no provisional is synthesized — the caller (u3 / u4) handles those cases
with a placeholder zone or empty-shell.
no provisional is synthesized — the caller handles those cases with a
placeholder zone or empty-shell.
Default ``allow_provisional=False`` keeps the IMP-05 behavior byte-identical.
IMP-38 — dynamic effective max_rank via ``load_v4_fallback_policy()``
(4 round 합의 / Codex #1~#3 + Claude #1~#4 LOCK at #67 comment 23195):
- ``max_rank=None`` (default) → policy applied:
usable_count = candidates in rank 1..default_max_rank passing 3-tier
predicate (status in MVP1 + catalog registered + optional capacity).
usable_count >= usable_threshold → effective_max_rank = default_max_rank.
Otherwise → effective_max_rank = min(extended_max_rank,
len(judgments_full32)) = effective_extended_ceiling (Codex #2 정정).
- ``max_rank`` explicitly passed → caller_override: that value is used
as-is (backward compat for tests / explicit IMP-05/IMP-30 paths).
Trace gains 8 IMP-38 fields: ``requested_max_rank``, ``default_max_rank``,
``configured_extended_max_rank``, ``judgments_count``,
``effective_extended_ceiling``, ``effective_max_rank``, ``usable_count``,
``policy_applied``. ``max_rank`` legacy field kept as alias for backward
compat (= effective_max_rank).
"""
resolved = _resolve_v4_section_key(v4, section_id, alias_keys=alias_keys)
sec = v4.get("mdx_sections", {}).get(resolved) if resolved else None
all_judgments = (sec.get("judgments_full32") if sec else None) or []
judgments_count = len(all_judgments)
# IMP-38 — load policy (graceful: yaml 없을 시 default_max_rank=3, extended=3)
_policy = load_v4_fallback_policy()
default_max_rank = int(_policy.get("default_max_rank", 3))
configured_extended_max_rank = int(_policy.get("extended_max_rank", default_max_rank))
usable_threshold = int(_policy.get("usable_threshold", 1))
# Codex #2 정정: min(configured, len(judgments_full32)) — yaml ceiling 무력화 방지
effective_extended_ceiling = min(configured_extended_max_rank, judgments_count) if judgments_count else default_max_rank
usable_count: Optional[int] = None # set only when policy path active
if max_rank is not None:
# caller override (backward compat — explicit IMP-05/IMP-30 paths, tests)
effective_max_rank = int(max_rank)
policy_applied = "caller_override"
elif judgments_count == 0:
# no judgments — slicing 빈 list 라 어차피 영향 X
effective_max_rank = default_max_rank
policy_applied = "no_judgments"
else:
# IMP-38 policy path — 3-tier predicate usable_count on default window
usable_count = 0
default_window = all_judgments[:default_max_rank]
for _j in default_window:
_m = _v4_match_from_judgment(section_id, _j, rank=0)
if to_phase_z_status(_m) not in MVP1_ALLOWED_STATUSES:
continue
if get_contract(_m.template_id) is None:
continue
if raw_content is not None:
_cap = compute_capacity_fit(_m.template_id, raw_content)
if _cap and _cap.get("fit_status") not in {
"ok", "no_contract", "unknown_source_shape",
}:
continue
usable_count += 1
if usable_count >= usable_threshold:
effective_max_rank = default_max_rank
policy_applied = "default_max_rank"
else:
effective_max_rank = effective_extended_ceiling
policy_applied = "extended_max_rank"
trace = {
"section_id": section_id,
"max_rank": max_rank,
# IMP-38 — 8 trace fields (4 round LOCK)
"requested_max_rank": max_rank,
"default_max_rank": default_max_rank,
"configured_extended_max_rank": configured_extended_max_rank,
"judgments_count": judgments_count,
"effective_extended_ceiling": effective_extended_ceiling,
"effective_max_rank": effective_max_rank,
"usable_count": usable_count,
"policy_applied": policy_applied,
# legacy alias for backward compat (= effective_max_rank)
"max_rank": effective_max_rank,
"selection_path": "no_v4_candidate",
"selected_rank": None,
"selected_template_id": None,
@@ -630,7 +703,7 @@ def lookup_v4_match_with_fallback(
trace["fallback_reason"] = "no_v4_section"
return None, trace
judgments = (sec.get("judgments_full32") or [])[:max_rank]
judgments = all_judgments[:effective_max_rank]
if not judgments:
trace["fallback_reason"] = "empty_v4_judgments"
return None, trace
@@ -712,7 +785,7 @@ def lookup_v4_match_with_fallback(
trace["candidates"].append(candidate_trace)
trace["selection_path"] = "chain_exhausted"
trace["fallback_reason"] = first_skip_reason or "no_auto_renderable_rank_1_to_3"
trace["fallback_reason"] = first_skip_reason or f"no_auto_renderable_rank_1_to_{effective_max_rank}"
# IMP-30 u1 — opt-in provisional first-render synthesis. When the caller
# signals allow_provisional, promote rank-1 judgment as a provisional
@@ -3218,7 +3291,6 @@ def run_phase_z2_mvp1(
v4,
sid,
raw_content=section_content_by_id.get(sid),
max_rank=3,
alias_keys=section_alias_by_id.get(sid),
)
v4_fallback_traces[sid] = trace
@@ -3434,7 +3506,6 @@ def run_phase_z2_mvp1(
v4,
sid,
raw_content=section_content_by_id.get(sid),
max_rank=3,
alias_keys=section_alias_by_id.get(sid),
allow_provisional=True,
)