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 in 7c93031 ahead 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 in 7c93031) -- 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 from 7c93031). 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 into 7c93031 as 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:
2026-05-23 07:36:57 +09:00
parent 7c93031f9b
commit f3ef4d917c
17 changed files with 3692 additions and 25 deletions

View File

@@ -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 ─