feat(#64): IMP-35 details_popup_escalation u1~u10 + Stage 3 R7 anchor re-pin
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>
This commit is contained in:
@@ -21,8 +21,10 @@ 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,
|
||||
)
|
||||
|
||||
|
||||
@@ -163,6 +165,160 @@ def test_gather_with_empty_units_returns_empty_list():
|
||||
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 ─
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user