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
co-authored by Claude Opus 4.7
parent 7c93031f9b
commit f3ef4d917c
17 changed files with 3692 additions and 25 deletions
+241
View File
@@ -73,6 +73,247 @@ STEP17_AI_REPAIR_BLOCKED_REASON = (
)
# IMP-35 (#64) u4 — POPUP cascade AI split-decision contract (API gated).
#
# Step 17 POPUP escalation needs an AI hook to decide *what content* stays in
# the body (summary/subset) vs. moves into the <details> popup (full MDX).
# That hook is the AI split-decision contract. u4 ships the contract surface
# (function signature + record schema + cascade_stage + route_for_label +
# skip_reason) WITHOUT enabling the Anthropic API. The deterministic POPUP
# gate executor (u5) runs ahead of this contract and stamps
# popup_escalation_plan + has_popup; u4's hook is a forward-compatible
# placeholder so downstream wiring (u5 executor / future IMP activating the
# API) can rely on a stable schema. ``api_gated=True`` on every record makes
# the gate state machine-readable; ``ai_called`` stays False everywhere.
#
# Per feedback_ai_isolation_contract: AI = fallback path only. The contract
# function MUST NOT import route_ai_fallback, the u4 client (despite name
# collision — u4 here is the IMP-35 unit, not the Step 12 client module),
# or any anthropic SDK symbol. Structural import guards in the test surface
# already enforce this and continue to hold after this change.
STEP17_POPUP_SPLIT_DECISION_API_GATED_REASON = (
"step17_popup_split_decision_api_gated"
)
# IMP-35 (#64) u5 — deterministic POPUP gate executor (cascade-terminal).
#
# Runs AFTER the DETERMINISTIC stage exhausts and BEFORE the AI_REPAIR
# cascade stage (canonical OVERFLOW_CASCADE_ORDER). Per unit:
#
# 1. Idempotency (q2): if a unit carries ``has_popup=True`` already,
# ``run_step17_popup_gate`` short-circuits with
# ``gate_status="idempotent_short_circuit"``. No duplicate plan,
# no re-routing. Re-running Step 17 on already-escalated units is
# safe — the gate emits a deterministic record per unit but does
# NOT re-stamp the plan or flip the marker. The persistence of
# ``has_popup`` and ``popup_escalation_plan`` on the unit itself
# (see step 4 below) is what makes the second call observe the
# stamp from the first call and short-circuit correctly.
# 2. Classification: ``classification_for_unit(unit)`` returns the
# fit_classifier row associated with this unit (or ``None`` if the
# unit has no overflow on this run).
# 3. Plan: ``plan_for_classification(cls)`` is the router u3 stub
# (``src.phase_z2_router.plan_details_popup_escalation``). Only
# the categories in ``POPUP_ESCALATION_CATEGORIES`` of the router
# surface (currently ``structural_major_overflow`` and
# ``tabular_overflow``) emit a feasible plan; anything else falls
# through to ``gate_status="infeasible_category"`` so the gate
# never silently escalates the wrong overflow shape.
# 4. Feasible plan → record stamps ``popup_escalation_plan`` and
# flips ``has_popup=True`` in the returned record AND persists
# the same two fields on the unit via ``setattr`` (``unit.has_popup``
# and ``unit.popup_escalation_plan``). The unit-side persistence
# is the q2 idempotency contract: a second call to
# ``run_step17_popup_gate`` over the same unit reads
# ``unit.has_popup=True`` at step 1 and short-circuits before
# classification / plan callable invocation. The marker is also
# what u6 composition binding and u7 render wiring read from the
# unit downstream.
#
# AI isolation contract: NO Anthropic call inside this gate. The
# deterministic split between popup body (full MDX) and preview
# (summary/subset) is composed downstream from container px budgets
# (q3 — preview_chars derives from container px telemetry already on
# the retry_trace). The u4 AI hook (``gather_step17_popup_split_decisions``)
# sits at the same cascade stage but is API-gated (``api_gated=True``)
# and never invoked from this deterministic path. ``ai_called=False`` on
# every record this gate emits.
#
# cascade_stage="popup" on every record so Step 17 retry-trace consumers
# can multiplex DETERMINISTIC / POPUP / AI_REPAIR records without
# ambiguity. The schema mirrors :func:`gather_step17_popup_split_decisions`
# (unit_index / source_section_ids / frame_template_id / label /
# route_hint / provisional) PLUS u5-specific fields:
# ``gate_status`` / ``popup_escalation_plan`` / ``has_popup`` /
# ``skip_reason`` (only set for non-escalated gate_status values).
STEP17_POPUP_GATE_ESCALATED_REASON = "step17_popup_gate_escalated"
STEP17_POPUP_GATE_IDEMPOTENT_SHORT_CIRCUIT_REASON = (
"step17_popup_gate_idempotent_short_circuit"
)
STEP17_POPUP_GATE_INFEASIBLE_CATEGORY_REASON = (
"step17_popup_gate_infeasible_category"
)
STEP17_POPUP_GATE_NO_CLASSIFICATION_REASON = (
"step17_popup_gate_no_classification_for_unit"
)
def run_step17_popup_gate(
units: Iterable[Any],
*,
classification_for_unit: Callable[[Any], dict | None],
route_for_label: Callable[[str | None], str | None],
plan_for_classification: Callable[[dict], dict],
) -> list[dict]:
"""Deterministic POPUP gate executor for Step 17 cascade (IMP-35 u5).
See module-level block comment (immediately above) for the full
contract — idempotency (q2), classification source, router u3 stub
coupling, AI isolation, and cascade_stage multiplexing.
Args:
units: provisional / non-provisional Step 17 units. The gate is
agnostic to provisional state; the marker ``has_popup`` flows
from this function regardless.
classification_for_unit: maps a unit to its fit_classifier
classification row (or ``None`` if the unit has no overflow).
Tests inject a fake dict / lookup; the pipeline composes
this from ``fit_classification.classifications`` matched by
``zone_position``.
route_for_label: same callable shape as
:func:`gather_step17_ai_repair_proposals` /
:func:`gather_step17_popup_split_decisions`. The route hint
is stamped on every record for downstream consumers.
plan_for_classification: the router u3 stub
(``src.phase_z2_router.plan_details_popup_escalation``).
Injected as a callable so this module stays decoupled from
the router surface and tests can stub the plan output.
Returns:
list[dict] — one record per unit. Records carry
``cascade_stage="popup"`` and ``ai_called=False`` everywhere.
Feasible-escalation records also carry
``popup_escalation_plan`` (the router u3 plan dict) and
``has_popup=True``. Non-escalation records carry a
``skip_reason`` enum.
"""
records: list[dict] = []
for index, unit in enumerate(units):
label = getattr(unit, "label", None)
already_escalated = bool(getattr(unit, "has_popup", False))
record: dict = {
"unit_index": index,
"source_section_ids": list(
getattr(unit, "source_section_ids", []) or []
),
"frame_template_id": getattr(unit, "frame_template_id", None),
"label": label,
"route_hint": route_for_label(label),
"provisional": bool(getattr(unit, "provisional", False)),
"cascade_stage": OverflowCascadeStage.POPUP.value,
"ai_called": False,
"has_popup": already_escalated,
"popup_escalation_plan": None,
"gate_status": None,
"skip_reason": None,
}
if already_escalated:
# q2 idempotency — short-circuit. The previously stamped
# popup_escalation_plan stays on the unit (carried by u6/u7
# composition); this gate does NOT re-emit it.
record["gate_status"] = "idempotent_short_circuit"
record["skip_reason"] = (
STEP17_POPUP_GATE_IDEMPOTENT_SHORT_CIRCUIT_REASON
)
records.append(record)
continue
classification = classification_for_unit(unit)
if not classification:
record["gate_status"] = "no_classification"
record["skip_reason"] = STEP17_POPUP_GATE_NO_CLASSIFICATION_REASON
records.append(record)
continue
plan = plan_for_classification(classification)
record["popup_escalation_plan"] = plan
if plan and plan.get("feasible"):
record["gate_status"] = "escalated"
record["has_popup"] = True
record["skip_reason"] = None
# q2 idempotency persistence — stamp the marker AND the plan
# on the unit itself so a second run of the gate over the
# same unit observes ``unit.has_popup=True`` at the top of
# the loop and short-circuits before re-invoking the
# classification / plan callables. The unit-side persistence
# is also what u6 composition binding and u7 render wiring
# read downstream.
setattr(unit, "has_popup", True)
setattr(unit, "popup_escalation_plan", plan)
else:
# Plan rejected by router (wrong category). Defensive guard —
# the gate must not silently escalate the wrong overflow
# shape (see router u3 plan_details_popup_escalation defensive
# guard).
record["gate_status"] = "infeasible_category"
record["skip_reason"] = (
STEP17_POPUP_GATE_INFEASIBLE_CATEGORY_REASON
)
records.append(record)
return records
def gather_step17_popup_split_decisions(
units: Iterable[Any],
*,
route_for_label: Callable[[str | None], str | None],
) -> list[dict]:
"""Return one API-gated split-decision record per unit (POPUP cascade).
Schema mirrors :func:`gather_step17_ai_repair_proposals` so a Step 17
artifact consumer can multiplex DETERMINISTIC / POPUP / AI_REPAIR records
onto the same retry trace. POPUP-specific fields:
* ``cascade_stage`` — always ``"popup"``.
* ``api_gated`` — always ``True`` at u4. Future IMP activating the
Anthropic API for popup splitting will flip this to ``False`` for
units that traversed the deterministic POPUP gate (u5) without
resolving via summary-only.
* ``ai_called`` — always ``False`` at u4 (contract surface only).
* ``skip_reason`` — always
:data:`STEP17_POPUP_SPLIT_DECISION_API_GATED_REASON`.
* ``split_decision`` — always ``None`` at u4. Once activated, this will
carry the AI-proposed ``{"body_preview": ..., "popup_full": ...}``
pair; u5 deterministic gate fills the same field deterministically
from container px budgets (preview_chars) and never invokes AI.
Per IMP-35 u4 binding contract: the API stays gated. No Anthropic call,
no route_ai_fallback import, no client instantiation. Structural import
tests in :mod:`tests.phase_z2_ai_fallback.test_step17` continue to lock
these guarantees.
"""
records: list[dict] = []
for index, unit in enumerate(units):
label = getattr(unit, "label", None)
record: dict = {
"unit_index": index,
"source_section_ids": list(
getattr(unit, "source_section_ids", []) or []
),
"frame_template_id": getattr(unit, "frame_template_id", None),
"label": label,
"route_hint": route_for_label(label),
"provisional": bool(getattr(unit, "provisional", False)),
"cascade_stage": OverflowCascadeStage.POPUP.value,
"ai_called": False,
"api_gated": True,
"skip_reason": STEP17_POPUP_SPLIT_DECISION_API_GATED_REASON,
"split_decision": None,
"error": None,
}
records.append(record)
return records
def gather_step17_ai_repair_proposals(
units: Iterable[Any],
*,