feat(#61): IMP-33 AI fallback scaffolding (u1~u11, flag default OFF)
Frame-aware AI fallback module scaffolded under src/phase_z2_ai_fallback/ with master flag ai_fallback_enabled=False; normal-path AI call count remains 0. AI output constrained to builder_options_patch / partial_overrides / slot_mapping_proposal; MDX / frame_id / raw HTML / raw CSS mutations rejected at schema layer. IMP-46 cache gate (cache.py) raises AiFallbackCacheGateError unless visual_check_passed AND user_approved. Step 12 wires AI repair after IMP-30 provisional payload only; Step 17 stays blocked behind IMP-34 / IMP-35 prerequisites. AST isolation guard forbids fallback package from importing Phase Q / Kei / pipeline runtime symbols. Docs IMP-17 / IMP-31 bound to runtime module surface via 11-row structural test pin (test_docs_sync.py) so drift fails CI. Tests: 116 fallback / 161 phase_z2 regression / 526 scoped full sweep all passing. Existing pre-IMP-33 fixture issue in scripts/test_phase_t_* remains untouched (out of scope). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
208
tests/phase_z2_ai_fallback/test_step17.py
Normal file
208
tests/phase_z2_ai_fallback/test_step17.py
Normal file
@@ -0,0 +1,208 @@
|
||||
"""IMP-33 u9 — Step 17 AI repair wiring tests (BLOCKED until IMP-34 + IMP-35).
|
||||
|
||||
Covers:
|
||||
* :data:`OVERFLOW_CASCADE_ORDER` canonical order (4 stages).
|
||||
* :class:`OverflowCascadeStage` member values.
|
||||
* :data:`STEP17_AI_REPAIR_BLOCKED_REASON` constant value.
|
||||
* :func:`gather_step17_ai_repair_proposals` BLOCKED contract — every unit
|
||||
returns ``ai_called=False`` + ``skip_reason=STEP17_AI_REPAIR_BLOCKED_REASON``
|
||||
+ ``proposal=None`` regardless of provisional / label / route_hint.
|
||||
* Structural guarantee — the u9 module does NOT import
|
||||
:func:`src.phase_z2_ai_fallback.router.route_ai_fallback` or the
|
||||
``anthropic`` SDK. Step 17 AI repair stays structurally blocked.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from src.phase_z2_ai_fallback import step17 as step17_mod
|
||||
from src.phase_z2_ai_fallback.step17 import (
|
||||
OVERFLOW_CASCADE_ORDER,
|
||||
STEP17_AI_REPAIR_BLOCKED_REASON,
|
||||
OverflowCascadeStage,
|
||||
gather_step17_ai_repair_proposals,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeUnit:
|
||||
label: str | None
|
||||
provisional: bool
|
||||
frame_template_id: str = "tmpl"
|
||||
frame_id: str = "fid"
|
||||
source_section_ids: list[str] = field(default_factory=lambda: ["s1"])
|
||||
raw_content: str = "raw"
|
||||
v4_rank: int | None = 1
|
||||
|
||||
|
||||
_ROUTE_HINTS: dict[str | None, str | None] = {
|
||||
"use_as_is": "direct_render",
|
||||
"light_edit": "deterministic_minor_adjustment",
|
||||
"restructure": "ai_adaptation_required",
|
||||
"reject": "design_reference_only",
|
||||
None: None,
|
||||
}
|
||||
|
||||
|
||||
def _route_for_label(label: str | None) -> str | None:
|
||||
return _ROUTE_HINTS.get(label)
|
||||
|
||||
|
||||
# ─── Stage / order constants ─────────────────────────────────────────
|
||||
|
||||
|
||||
def test_overflow_cascade_order_is_canonical():
|
||||
assert OVERFLOW_CASCADE_ORDER == (
|
||||
OverflowCascadeStage.DETERMINISTIC,
|
||||
OverflowCascadeStage.POPUP,
|
||||
OverflowCascadeStage.AI_REPAIR,
|
||||
OverflowCascadeStage.USER_OVERRIDE,
|
||||
)
|
||||
|
||||
|
||||
def test_overflow_cascade_stage_string_values():
|
||||
assert OverflowCascadeStage.DETERMINISTIC.value == "deterministic"
|
||||
assert OverflowCascadeStage.POPUP.value == "popup"
|
||||
assert OverflowCascadeStage.AI_REPAIR.value == "ai_repair"
|
||||
assert OverflowCascadeStage.USER_OVERRIDE.value == "user_override"
|
||||
|
||||
|
||||
def test_step17_blocked_reason_constant_value():
|
||||
assert (
|
||||
STEP17_AI_REPAIR_BLOCKED_REASON
|
||||
== "step17_ai_blocked_imp_34_35_prerequisites_missing"
|
||||
)
|
||||
|
||||
|
||||
# ─── BLOCKED contract: every unit returns blocked record ─────────────
|
||||
|
||||
|
||||
def test_gather_returns_one_record_per_unit():
|
||||
units = [
|
||||
FakeUnit(label="restructure", provisional=True),
|
||||
FakeUnit(label="reject", provisional=False),
|
||||
FakeUnit(label="use_as_is", provisional=True),
|
||||
]
|
||||
records = gather_step17_ai_repair_proposals(units, route_for_label=_route_for_label)
|
||||
assert len(records) == 3
|
||||
|
||||
|
||||
def test_gather_records_blocked_skip_reason():
|
||||
"""Every record must carry the IMP-34/IMP-35 prerequisite block reason."""
|
||||
units = [FakeUnit(label="restructure", provisional=True)]
|
||||
records = gather_step17_ai_repair_proposals(units, route_for_label=_route_for_label)
|
||||
assert records[0]["skip_reason"] == STEP17_AI_REPAIR_BLOCKED_REASON
|
||||
|
||||
|
||||
def test_gather_blocks_even_when_route_is_ai_adaptation_required():
|
||||
"""Provisional + ai_adaptation_required must NOT bypass the u9 block.
|
||||
|
||||
Stage 2 contract: AI repair at Step 17 is blocked behind IMP-34 + IMP-35
|
||||
regardless of V4 route hint. Only u8 (Step 12) is allowed to invoke AI today.
|
||||
"""
|
||||
units = [FakeUnit(label="restructure", provisional=True)]
|
||||
record = gather_step17_ai_repair_proposals(
|
||||
units, route_for_label=_route_for_label
|
||||
)[0]
|
||||
assert record["route_hint"] == "ai_adaptation_required"
|
||||
assert record["ai_called"] is False
|
||||
assert record["proposal"] is None
|
||||
assert record["skip_reason"] == STEP17_AI_REPAIR_BLOCKED_REASON
|
||||
|
||||
|
||||
def test_gather_blocks_reject_units_too():
|
||||
"""Reject units (design_reference_only) are also blocked at u9 — same reason."""
|
||||
units = [FakeUnit(label="reject", provisional=False)]
|
||||
record = gather_step17_ai_repair_proposals(
|
||||
units, route_for_label=_route_for_label
|
||||
)[0]
|
||||
assert record["ai_called"] is False
|
||||
assert record["skip_reason"] == STEP17_AI_REPAIR_BLOCKED_REASON
|
||||
|
||||
|
||||
def test_gather_records_proposal_none_and_no_error():
|
||||
units = [FakeUnit(label="restructure", provisional=True)]
|
||||
record = gather_step17_ai_repair_proposals(
|
||||
units, route_for_label=_route_for_label
|
||||
)[0]
|
||||
assert record["proposal"] is None
|
||||
assert record["error"] is None
|
||||
|
||||
|
||||
def test_gather_records_cascade_stage_is_ai_repair():
|
||||
units = [FakeUnit(label="restructure", provisional=True)]
|
||||
record = gather_step17_ai_repair_proposals(
|
||||
units, route_for_label=_route_for_label
|
||||
)[0]
|
||||
assert record["cascade_stage"] == OverflowCascadeStage.AI_REPAIR.value
|
||||
|
||||
|
||||
def test_gather_preserves_unit_metadata():
|
||||
units = [
|
||||
FakeUnit(
|
||||
label="restructure",
|
||||
provisional=True,
|
||||
frame_template_id="frame_05_overview",
|
||||
source_section_ids=["s1", "s2"],
|
||||
)
|
||||
]
|
||||
record = gather_step17_ai_repair_proposals(
|
||||
units, route_for_label=_route_for_label
|
||||
)[0]
|
||||
assert record["unit_index"] == 0
|
||||
assert record["frame_template_id"] == "frame_05_overview"
|
||||
assert record["source_section_ids"] == ["s1", "s2"]
|
||||
assert record["label"] == "restructure"
|
||||
assert record["provisional"] is True
|
||||
|
||||
|
||||
def test_gather_with_empty_units_returns_empty_list():
|
||||
records = gather_step17_ai_repair_proposals([], route_for_label=_route_for_label)
|
||||
assert records == []
|
||||
|
||||
|
||||
# ─── Structural guarantee: u9 must NOT import route_ai_fallback / anthropic ─
|
||||
|
||||
|
||||
def _u9_imports() -> list[str]:
|
||||
src_path = Path(step17_mod.__file__)
|
||||
tree = ast.parse(src_path.read_text(encoding="utf-8"))
|
||||
imports: list[str] = []
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
imports.extend(alias.name for alias in node.names)
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
module = node.module or ""
|
||||
for alias in node.names:
|
||||
imports.append(f"{module}.{alias.name}")
|
||||
return imports
|
||||
|
||||
|
||||
def test_step17_module_does_not_import_route_ai_fallback():
|
||||
"""u9 must not be able to reach the u7 router — structural block."""
|
||||
imports = _u9_imports()
|
||||
forbidden = {
|
||||
"src.phase_z2_ai_fallback.router.route_ai_fallback",
|
||||
"src.phase_z2_ai_fallback.router",
|
||||
}
|
||||
assert not any(imp in forbidden for imp in imports), imports
|
||||
assert not hasattr(step17_mod, "route_ai_fallback")
|
||||
|
||||
|
||||
def test_step17_module_does_not_import_anthropic():
|
||||
"""u9 must not reach the Anthropic SDK directly — AI=0 in this layer."""
|
||||
imports = _u9_imports()
|
||||
leaked = [imp for imp in imports if imp.split(".", 1)[0] == "anthropic"]
|
||||
assert leaked == [], leaked
|
||||
|
||||
|
||||
def test_step17_module_does_not_import_ai_fallback_client():
|
||||
"""u9 must not instantiate the u4 client either."""
|
||||
imports = _u9_imports()
|
||||
forbidden_prefixes = ("src.phase_z2_ai_fallback.client",)
|
||||
leaked = [
|
||||
imp for imp in imports if imp.startswith(forbidden_prefixes)
|
||||
]
|
||||
assert leaked == [], leaked
|
||||
Reference in New Issue
Block a user