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

@@ -41,6 +41,7 @@ from jinja2 import Environment, FileSystemLoader, select_autoescape
from phase_z2_composition import (
LAYOUT_PRESETS,
CompositionUnit,
compose_zone_popup_payload,
derive_parent_id,
plan_composition,
resplit_all_reject_merges,
@@ -57,7 +58,7 @@ from phase_z2_mapper import (
map_with_contract,
)
from phase_z2_classifier import classify_visual_runtime_check
from phase_z2_router import route_fit_classification
from phase_z2_router import plan_details_popup_escalation, route_fit_classification
from phase_z2_retry import (
DEFAULT_SAFETY_MARGIN_PX,
apply_cross_zone_redistribute_css,
@@ -85,6 +86,13 @@ from phase_z2_placement_planner import plan_placement
# stays in src/config.py + src/phase_z2_ai_fallback/router.py.
from src.phase_z2_ai_fallback.step12 import gather_step12_ai_repair_proposals
# IMP-35 (#64) u5 — Step 17 deterministic POPUP gate executor. Runs after
# the salvage cascade exhausts at cascade-terminal action
# ``details_popup_escalation`` (router u3 / failure_router u2) and BEFORE
# the AI_REPAIR cascade stage. Stamps ``popup_escalation_plan`` and the
# idempotent ``has_popup`` marker onto retry_trace per unit. No AI call.
from src.phase_z2_ai_fallback.step17 import run_step17_popup_gate
# ─── Constants ──────────────────────────────────────────────────
@@ -2476,6 +2484,41 @@ def _attempt_salvage_chain(
return trace
def _remeasure_after_frame_reselect(
*, candidate_path: Path, plan: Optional[dict] = None,
) -> dict:
"""IMP-35 (#64) u1 — post-frame remeasure helper for the cascade terminal.
Contract (q4 / Stage 2): frame_reselect_insufficient is detected by an
*explicit overflow re-measure* after a V4 top-k alternate frame swap —
NOT a failure-flag carryover. This helper runs run_overflow_check on the
re-rendered candidate HTML and shapes the salvage_steps entry that
classify_retry_failure / SALVAGE_FAILURE_TYPE_BY_ACTION read.
Future frame_reselect orchestrator (post-IMP-35) writes the candidate
HTML and calls this helper to append the entry to retry_trace.salvage_steps.
On passed=True the orchestrator promotes the candidate to final.html; on
passed=False the classifier emits frame_reselect_insufficient → u2 routes
onto details_popup_escalation (Step 17 POPUP gate / u5).
"""
candidate_overflow = run_overflow_check(candidate_path)
passed = bool(candidate_overflow.get("passed", False))
return {
"action": "frame_reselect",
"plan": plan,
"passed": passed,
"candidate_path": (
str(candidate_path.relative_to(PROJECT_ROOT))
if candidate_path.is_absolute() else str(candidate_path)
),
"post_salvage_overflow": candidate_overflow,
"failure_reason": (
None if passed
else (candidate_overflow.get("fail_reasons") or "post-frame remeasure: overflow persists")
),
}
def render_slide(slide_title: str, slide_footer: Optional[str],
zones_data: list[dict], layout_preset: str,
layout_css: dict, gap_px: int = GRID_GAP,
@@ -4232,6 +4275,11 @@ def run_phase_z2_mvp1(
# first-render invariant holds; u5 will surface the provisional flag as
# a zone class + needs-adaptation badge.
if unit.frame_template_id == "__empty__":
# IMP-35 u7 — popup payload wiring. Empty-shell units never go
# through the Step 17 POPUP gate (no raw content to escalate),
# so compose_zone_popup_payload returns the no-popup branch
# (has_popup=False, popup_html=None, preview_text=None).
_popup_payload = compose_zone_popup_payload(unit, 0)
zones_data.append({
"position": position,
"template_id": "__empty__",
@@ -4241,6 +4289,7 @@ def run_phase_z2_mvp1(
"assignment_source": "imp30_u4_empty_shell",
"section_assignment_override": False,
"provisional": bool(getattr(unit, "provisional", False)),
**_popup_payload,
})
debug_zones.append({
"position": position,
@@ -4411,6 +4460,15 @@ def run_phase_z2_mvp1(
# needs-adaptation badge. Default False keeps non-provisional zones
# byte-identical to pre-u5; only u1-synthesized rank-1 fills or u4
# empty-shell synthesize provisional=True units.
#
# IMP-35 u7 — popup payload wiring. `compose_zone_popup_payload(unit,
# min_height_px)` reads u6 binding (yaml strategy + popup_body_source)
# AND derives a px-budgeted preview from min_height_px. Surfaces three
# uniform render-context fields per zone (has_popup / popup_html /
# preview_text) plus the full u6 binding under `popup_binding` for
# u8 / u9 downstream consumers. Non-popup units (has_popup=False)
# return the no-popup branch — byte-identical zone shape pre-u7.
_popup_payload = compose_zone_popup_payload(unit, min_height_px)
zones_data.append({
"position": position,
"template_id": unit.frame_template_id,
@@ -4420,6 +4478,7 @@ def run_phase_z2_mvp1(
"assignment_source": plan_assignment_source,
"section_assignment_override": plan_section_override,
"provisional": bool(getattr(unit, "provisional", False)),
**_popup_payload,
})
debug_zones.append({
"position": position,
@@ -4475,6 +4534,12 @@ def run_phase_z2_mvp1(
pos = record["position"]
if pos in renderable_positions:
continue
# IMP-35 u7 — popup payload wiring for unrenderable empty
# plan record. No CompositionUnit exists for this branch
# (section-assignment plan produced no unit), so we stamp the
# no-popup defaults directly. Keeps the zone shape uniform
# across all three append paths so slide_base.html (u8) does
# not have to branch on the presence of popup fields.
zones_data.append({
"position": pos,
"template_id": "__empty__",
@@ -4486,6 +4551,10 @@ def run_phase_z2_mvp1(
record.get("skipped_reason")
or "section_assignment_override_empty_or_unrenderable"
),
"has_popup": False,
"popup_html": None,
"preview_text": None,
"popup_binding": None,
})
debug_zones.append({
"position": pos,
@@ -5615,6 +5684,54 @@ def run_phase_z2_mvp1(
# fields become None (no failure to classify, no escalation pending).
enrich_retry_trace_with_failure_classification(retry_trace)
# 11.8 IMP-35 (#64) u5 — Step 17 deterministic POPUP gate executor.
# Runs after the salvage cascade exits at cascade-terminal action
# `details_popup_escalation` (router u3 IMPLEMENTED + failure_router u2
# cascade row). Stamps popup_escalation_plan + idempotent has_popup
# marker per unit onto retry_trace["popup_gate_records"]. Deterministic
# gate — no AI call (feedback_ai_isolation_contract); the u4
# api_gated split-decision hook is a separate cascade-stage record
# consumed only when a future IMP activates the Anthropic API.
# Consumer side (composition popup binding / render wiring) lands in
# u6 / u7. q1 (per-unit), q2 (idempotent via has_popup), q3
# (deterministic from fit_classification) — see Stage 2 plan.
# next_proposed_action is the single canonical signal: it is set by
# enrich_retry_trace_with_failure_classification via failure_router u2
# (NEXT_ACTION_BY_FAILURE), which routes frame_reselect_insufficient ->
# details_popup_escalation. This check is independent of whether the
# salvage chain block ran, so the popup gate fires for any retry path
# that lands on the cascade-terminal popup action.
_next_action = (
retry_trace.get("next_action_proposal") or {}
).get("next_proposed_action")
if _next_action == "details_popup_escalation":
_popup_cls_by_zone = {
c.get("zone_position"): c
for c in (fit_classification.get("classifications") or [])
if c.get("category") in {
"structural_major_overflow",
"tabular_overflow",
}
}
_zone_by_ssids = {
tuple(z.get("source_section_ids") or []): z.get("position")
for z in debug_zones
}
def _classification_for_unit(u):
ssids = tuple(getattr(u, "source_section_ids", []) or [])
zone_pos = _zone_by_ssids.get(ssids)
return _popup_cls_by_zone.get(zone_pos) if zone_pos else None
retry_trace["popup_gate_records"] = run_step17_popup_gate(
units,
classification_for_unit=_classification_for_unit,
route_for_label=_imp05_route_hint,
plan_for_classification=plan_details_popup_escalation,
)
retry_trace["popup_gate_executed"] = True
retry_trace["popup_gate_terminal_action"] = "details_popup_escalation"
# ─── Step 17: Implemented Action (retry) ───
_write_step_artifact(
run_dir, 17, "retry_trace",