Land the production + test surface for the Step 17 cascade POPUP terminal (DETERMINISTIC -> POPUP -> AI_REPAIR -> USER_OVERRIDE) per Stage 2 plan R2. u11 (baseline-red invariance gate) was already landed in7c93031ahead of this commit; this commit completes u1~u10 plus the Stage 3 R7 follow-up anchor re-pin for test_imp17_comment_anchor.py. Implementation units (Stage 2 R2 contract): u1 frame_reselect_insufficient failure_type + post-frame remeasure (q4) - src/phase_z2_failure_router.py, src/phase_z2_pipeline.py u2 NEXT_ACTION_BY_FAILURE row + impl_status flip - src/phase_z2_failure_router.py u3 Router details_popup_escalation MISSING->IMPLEMENTED + executor stub - src/phase_z2_router.py u4 step17.py AI split-decision contract (POPUP cascade_stage + route_for_label + skip_reason); API gated - src/phase_z2_ai_fallback/step17.py u5 Step 17 POPUP gate executor; popup_escalation_plan + has_popup marker - src/phase_z2_pipeline.py, src/phase_z2_ai_fallback/step17.py u6 Composition popup binding -- yaml strategy -> zone payload - src/phase_z2_composition.py u7 Pipeline composer -> render_slide wiring (popup_html / preview_text / has_popup) - src/phase_z2_pipeline.py u8 slide_base.html <details>/<summary> popup wrapper - templates/phase_z2/slide_base.html u9 display_strategies.yaml inline_preview + popup metadata - templates/phase_z2/regions/display_strategies.yaml u10 MDX preservation invariant: popup=full source / body=summary or subset (asserted by tests/phase_z2/test_popup_mdx_preservation.py) u11 (already in7c93031) -- baseline-red invariance gate Stage 3 R7 follow-up (anchor re-pin, test-only): - tests/orchestrator_unit/test_imp17_comment_anchor.py Pre-anchor additions in src/phase_z2_pipeline.py (u1 / u5 / u7) shifted the restructure/reject route-hint comments 578/579 -> 586/587. Re-pinned the two guard tests (and docstring re-pin lineage 564 -> 570 -> 578 -> 586). Production code untouched. Verification (Stage 4 R1): pytest -q tests/orchestrator_unit/test_imp17_comment_anchor.py -> 2 passed / 0.02s pytest -q <10 IMP-35 unit files in tests/phase_z2 + tests/phase_z2_ai_fallback> -> 136 passed / 15.94s Baseline-red invariance gate (tests/test_imp47b_step12_ai_wiring.py + tests/test_phase_z2_ai_fallback_config.py) -> 4 failed / 6 passed; FAILED set === IMP35_BASELINE_RED_NODE_IDS (frozen registry from7c93031). Contract holds. Codex Stage 4 R1 = YES (independent verify). Guardrails honored: - MDX content preservation: popup carries full source, body holds summary or subset only (CLAUDE.md 자세히보기 원칙; feedback_phase_z_spacing_direction -- capacity expanded, no margin shrink). - AI isolation contract: Step 17 POPUP gate is deterministic; AI hook surface is split-decision contract only, API call gated. - No hardcoding: escalation thresholds derived from existing overflow detector outputs; preview_chars deterministic from container px. - 1 commit = 1 decision unit: u1~u10 land together as the planned production surface; u11 was deliberately split into7c93031as Stage 3 R7 carve-out, and the R7 anchor re-pin rides with this commit because it is the direct shift consequence of the u1/u5/u7 pre-anchor additions. - Scope-locked: .claude/settings.json explicitly excluded (Stage 4 exit report contract). Out of scope (per Stage 1 + Stage 2): - AI_REPAIR API activation (post IMP-35 axis). - IMP-34 zone resize, IMP-36 responsive fit (chain partners, separate issues). - Print-time auto-expand JavaScript for <details>. - Popup escalation in stages other than Step 17. - Baseline-red body repair (4 frozen failures) -- separate follow-up issue; u11 only guards the count. - frame_reselect algorithm changes (entry point only). - templates/phase_z2/slide_base.html path rename. source_comment_ids: Stage 1: claude_stage1_problem_review_imp35, codex_stage1_verification_imp35_yes Stage 2: Claude #4 R2 plan, Codex #5 R2 YES Stage 3: Claude #86 (R7 anchor re-pin), Codex #87 YES Stage 4: Claude #88 R1, Codex #89 R1 YES Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
365 lines
13 KiB
Python
365 lines
13 KiB
Python
"""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,
|
|
STEP17_POPUP_SPLIT_DECISION_API_GATED_REASON,
|
|
OverflowCascadeStage,
|
|
gather_step17_ai_repair_proposals,
|
|
gather_step17_popup_split_decisions,
|
|
)
|
|
|
|
|
|
@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 == []
|
|
|
|
|
|
# ─── IMP-35 u4: POPUP cascade AI split-decision contract (API gated) ─────
|
|
|
|
|
|
def test_popup_split_decision_api_gated_reason_constant_value():
|
|
"""u4 binding contract — API-gated skip_reason is a stable, machine-readable
|
|
constant that downstream consumers can distinguish from the AI_REPAIR
|
|
block reason. Never collide with STEP17_AI_REPAIR_BLOCKED_REASON."""
|
|
assert (
|
|
STEP17_POPUP_SPLIT_DECISION_API_GATED_REASON
|
|
== "step17_popup_split_decision_api_gated"
|
|
)
|
|
assert (
|
|
STEP17_POPUP_SPLIT_DECISION_API_GATED_REASON
|
|
!= STEP17_AI_REPAIR_BLOCKED_REASON
|
|
)
|
|
|
|
|
|
def test_popup_split_decision_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_popup_split_decisions(
|
|
units, route_for_label=_route_for_label
|
|
)
|
|
assert len(records) == 3
|
|
|
|
|
|
def test_popup_split_decision_cascade_stage_is_popup():
|
|
"""u4 — cascade_stage must mark these records as the POPUP stage, NOT
|
|
AI_REPAIR. This lets consumers multiplex POPUP and AI_REPAIR records on
|
|
the same retry trace without ambiguity."""
|
|
units = [FakeUnit(label="restructure", provisional=True)]
|
|
record = gather_step17_popup_split_decisions(
|
|
units, route_for_label=_route_for_label
|
|
)[0]
|
|
assert record["cascade_stage"] == OverflowCascadeStage.POPUP.value
|
|
assert record["cascade_stage"] != OverflowCascadeStage.AI_REPAIR.value
|
|
|
|
|
|
def test_popup_split_decision_api_gated_flag_true():
|
|
"""u4 — api_gated=True everywhere. The flag is the primary state signal
|
|
consumers read to decide whether the AI hook is active."""
|
|
units = [FakeUnit(label="restructure", provisional=True)]
|
|
record = gather_step17_popup_split_decisions(
|
|
units, route_for_label=_route_for_label
|
|
)[0]
|
|
assert record["api_gated"] is True
|
|
|
|
|
|
def test_popup_split_decision_ai_called_is_false_and_no_proposal():
|
|
"""u4 — ai_called=False, split_decision=None, error=None. The hook is the
|
|
contract surface only; the Anthropic API is NOT invoked at u4."""
|
|
units = [FakeUnit(label="restructure", provisional=True)]
|
|
record = gather_step17_popup_split_decisions(
|
|
units, route_for_label=_route_for_label
|
|
)[0]
|
|
assert record["ai_called"] is False
|
|
assert record["split_decision"] is None
|
|
assert record["error"] is None
|
|
|
|
|
|
def test_popup_split_decision_skip_reason_is_api_gated():
|
|
"""u4 — every record carries the API-gated skip_reason regardless of
|
|
label / provisional / route_hint."""
|
|
units = [
|
|
FakeUnit(label="restructure", provisional=True),
|
|
FakeUnit(label="reject", provisional=False),
|
|
FakeUnit(label="use_as_is", provisional=True),
|
|
FakeUnit(label=None, provisional=False),
|
|
]
|
|
records = gather_step17_popup_split_decisions(
|
|
units, route_for_label=_route_for_label
|
|
)
|
|
for record in records:
|
|
assert (
|
|
record["skip_reason"]
|
|
== STEP17_POPUP_SPLIT_DECISION_API_GATED_REASON
|
|
)
|
|
|
|
|
|
def test_popup_split_decision_honors_route_for_label():
|
|
"""u4 — route_for_label callable is applied per unit. Verifies the hook
|
|
surface accepts the same label→route mapping as the AI_REPAIR path."""
|
|
units = [
|
|
FakeUnit(label="restructure", provisional=True),
|
|
FakeUnit(label="reject", provisional=False),
|
|
FakeUnit(label="use_as_is", provisional=True),
|
|
FakeUnit(label="light_edit", provisional=False),
|
|
FakeUnit(label=None, provisional=False),
|
|
]
|
|
records = gather_step17_popup_split_decisions(
|
|
units, route_for_label=_route_for_label
|
|
)
|
|
assert [r["route_hint"] for r in records] == [
|
|
"ai_adaptation_required",
|
|
"design_reference_only",
|
|
"direct_render",
|
|
"deterministic_minor_adjustment",
|
|
None,
|
|
]
|
|
|
|
|
|
def test_popup_split_decision_preserves_unit_metadata():
|
|
"""u4 — schema mirrors gather_step17_ai_repair_proposals (unit_index,
|
|
source_section_ids, frame_template_id, label, provisional)."""
|
|
units = [
|
|
FakeUnit(
|
|
label="restructure",
|
|
provisional=True,
|
|
frame_template_id="frame_05_overview",
|
|
source_section_ids=["s1", "s2"],
|
|
)
|
|
]
|
|
record = gather_step17_popup_split_decisions(
|
|
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_popup_split_decision_with_empty_units_returns_empty_list():
|
|
records = gather_step17_popup_split_decisions(
|
|
[], route_for_label=_route_for_label
|
|
)
|
|
assert records == []
|
|
|
|
|
|
def test_popup_split_decision_record_schema_disjoint_from_ai_repair_extras():
|
|
"""u4 — POPUP record must carry api_gated + split_decision keys; the
|
|
AI_REPAIR record carries proposal (not split_decision). This lock keeps
|
|
the two contract surfaces machine-distinguishable on the retry trace."""
|
|
units = [FakeUnit(label="restructure", provisional=True)]
|
|
popup_rec = gather_step17_popup_split_decisions(
|
|
units, route_for_label=_route_for_label
|
|
)[0]
|
|
ai_repair_rec = gather_step17_ai_repair_proposals(
|
|
units, route_for_label=_route_for_label
|
|
)[0]
|
|
# POPUP-specific keys
|
|
assert "api_gated" in popup_rec
|
|
assert "split_decision" in popup_rec
|
|
# AI_REPAIR-specific key
|
|
assert "proposal" in ai_repair_rec
|
|
# Disjoint payload keys (the two contracts must NOT cross-leak):
|
|
assert "proposal" not in popup_rec
|
|
assert "split_decision" not in ai_repair_rec
|
|
assert "api_gated" not in ai_repair_rec
|
|
|
|
|
|
# ─── 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
|