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:
@@ -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],
|
||||
*,
|
||||
|
||||
Reference in New Issue
Block a user