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

@@ -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],
*,

View File

@@ -315,6 +315,321 @@ def select_display_strategy_candidates(
return [s for s in order if s in eligible]
# ─── IMP-35 (#64) u6 — Composition popup binding (yaml strategy -> zone payload) ─
#
# Stage 2 binding contract (unit u6):
# Step 17 POPUP gate (u5 in src/phase_z2_ai_fallback/step17.py) stamps
# ``unit.has_popup=True`` AND ``unit.popup_escalation_plan=<plan>`` on
# composition units whose overflow category routes to
# ``details_popup_escalation``. u6 is the composition-side binding that
# translates the unit-side marker into a deterministic zone payload
# structure that u7 (pipeline composer -> render_slide wiring) reads to
# emit the ``<details>/<summary>`` markup u8 will add to slide_base.html.
#
# Inputs (unit-side, all duck-typed via getattr):
# has_popup — bool (False default; u5 sets True on
# feasible escalation only)
# popup_escalation_plan — dict | None (u3 router plan from
# plan_details_popup_escalation; carries
# feasible / category / rationale /
# needs_split_decision)
# raw_content — str (the source MDX content; popup body
# source per CLAUDE.md 자세히보기 원칙)
#
# Outputs (zone payload binding dict):
# display_strategy — catalog strategy id read from
# display_strategies.yaml (NOT hardcoded).
# ``inline_full`` when has_popup=False.
# ``inline_preview_with_details`` when
# has_popup=True (preview = excerpt from
# container px budget downstream; popup body
# preserves the FULL original).
# popup_body_source — str | None — the FULL raw_content. u7 passes
# this verbatim to the renderer; the popup
# body is the MDX 원문 (자세히보기 원칙),
# never summarized in the body branch.
# None when has_popup=False.
# detail_trigger — dict | None — placement + label read from
# the catalog strategy entry's
# ``detail_trigger``. None when has_popup=False.
# preserves_original — bool — echoed from the catalog entry.
# MUST be True for popup-binding strategies
# (absolute user lock — 오답노트 #5 /
# IMPROVEMENT-REDESIGN.md §3.6 line 110).
# has_popup — bool — echoed for downstream multiplex.
# popup_escalation_plan — dict | None — echoed verbatim (u5 plan).
# Provides traceability into the router
# category + rationale for downstream debug.
# strategy_meta — dict — full catalog entry (description /
# applies_to / forbidden_for / detail_trigger)
# so downstream traces can self-explain without
# re-reading the yaml.
#
# Guardrails honored:
# - feedback_ai_isolation_contract — NO AI call. Reads catalog + unit
# state only. The deterministic POPUP gate (u5) already established
# the marker; this function is pure composition-side binding.
# - feedback_no_hardcoding — strategy id is the ONLY name reference, and
# it is the catalog key (yaml is source of truth). detail_trigger
# placement / label come from the catalog entry, not literals.
# - MDX 원문 무손실 보존 — popup_body_source = full raw_content.
# u6 NEVER trims or summarizes; the body preview (excerpt from
# container px budget) is composed by u7 downstream.
# - Phase Z spacing 방향 — u6 binds a strategy that EXPANDS capacity
# (popup escalation) instead of shrinking common margins.
# Strategy id used when the unit carries no popup escalation marker.
# Catalog read — yaml is source of truth.
POPUP_BINDING_NO_POPUP_STRATEGY_ID = "inline_full"
# Strategy id used when the unit carries has_popup=True (deterministic
# choice — the preview body is a px-budget excerpt of the original, the
# popup body holds the FULL original per CLAUDE.md 자세히보기 원칙).
# u5 q3 — preview_chars deterministic from container px telemetry; that
# is an excerpt-from-original pattern, which matches
# ``inline_preview_with_details``. ``details_only`` (summary-only body)
# is the alternative future axis when an AI/summarizer is available.
POPUP_BINDING_ESCALATED_STRATEGY_ID = "inline_preview_with_details"
def bind_popup_display_strategy(unit) -> dict:
"""Bind catalog popup display strategy to a zone payload (IMP-35 u6).
Reads the unit-side ``has_popup`` + ``popup_escalation_plan`` markers
stamped by Step 17 POPUP gate (u5) and produces a zone payload dict
that u7 wires into the renderer. The catalog
(``display_strategies.yaml``) is the source of truth for both the
strategy id and the detail_trigger placement / label — no hardcoded
string literals.
Args:
unit: a CompositionUnit (or any duck-typed object exposing
``has_popup`` / ``popup_escalation_plan`` / ``raw_content``).
``has_popup`` defaults to False when the attribute is absent
(units that never went through the Step 17 POPUP gate).
Returns:
zone payload binding dict (see module-level u6 contract block
immediately above for the full schema).
Raises:
RuntimeError: if the chosen catalog strategy id is missing from
the loaded ``DISPLAY_STRATEGIES`` mapping. Defensive guard —
yaml drift would otherwise cause downstream KeyError on a
stale string literal. The constants
``POPUP_BINDING_NO_POPUP_STRATEGY_ID`` /
``POPUP_BINDING_ESCALATED_STRATEGY_ID`` must always resolve
against the catalog at import time.
"""
has_popup = bool(getattr(unit, "has_popup", False))
plan = getattr(unit, "popup_escalation_plan", None)
raw_content = getattr(unit, "raw_content", "") or ""
strategy_id = (
POPUP_BINDING_ESCALATED_STRATEGY_ID
if has_popup
else POPUP_BINDING_NO_POPUP_STRATEGY_ID
)
meta = DISPLAY_STRATEGIES.get(strategy_id)
if meta is None:
raise RuntimeError(
f"bind_popup_display_strategy: catalog drift — strategy id "
f"{strategy_id!r} is missing from display_strategies.yaml. "
f"Loaded keys: {sorted(DISPLAY_STRATEGIES)}."
)
if not has_popup:
return {
"display_strategy": strategy_id,
"popup_body_source": None,
"detail_trigger": None,
"preserves_original": bool(meta.get("preserves_original")),
"has_popup": False,
"popup_escalation_plan": None,
"strategy_meta": meta,
}
# has_popup=True path. preserves_original MUST be True per the catalog
# absolute user lock — defensive guard against yaml drift.
if not meta.get("preserves_original"):
raise RuntimeError(
f"bind_popup_display_strategy: catalog invariant violated — "
f"popup-binding strategy {strategy_id!r} has preserves_original="
f"{meta.get('preserves_original')!r}; MDX 원문 무손실 보존 "
f"requires preserves_original=True (오답노트 #5 / "
f"IMPROVEMENT-REDESIGN.md §3.6 line 110)."
)
trigger_meta = meta.get("detail_trigger") or {}
return {
"display_strategy": strategy_id,
# MDX 원문 무손실 보존 — popup body = full raw_content (verbatim).
"popup_body_source": raw_content,
"detail_trigger": {
"placement": trigger_meta.get("placement"),
"label": trigger_meta.get("label"),
},
"preserves_original": True,
"has_popup": True,
"popup_escalation_plan": plan,
"strategy_meta": meta,
}
# ─── IMP-35 (#64) u7 — Pipeline composer -> render_slide wiring ──
#
# Stage 2 wiring contract (unit u7):
# u6 (``bind_popup_display_strategy``) produced the deterministic zone
# binding from the unit-side marker stamped by Step 17 POPUP gate (u5).
# u7 wires that binding into the pipeline composer's zones_data so the
# render_slide call site (and downstream slide_base.html consumer u8)
# sees three uniform render-context field names per zone:
#
# has_popup : bool — escalation marker echo
# popup_html : str — popup body source (full ``raw_content`` per u6;
# u8 wraps it in ``<details>/<summary>``).
# ``None`` when has_popup=False.
# preview_text : str — px-budgeted excerpt of ``raw_content`` shown in
# the body / inline_preview slot. NEVER trims
# inside a line — line-boundary cut only — and
# the popup body retains the FULL original
# (MDX 원문 무손실 보존). ``None`` when
# has_popup=False.
#
# The full u6 binding is also echoed on the zone dict under
# ``popup_binding`` so downstream debug / catalog-aware consumers can
# self-explain without re-reading the yaml.
#
# Why the preview is a deterministic line-budget cut (u5 q3 resolution):
# The popup body holds the FULL original verbatim, so the preview loses
# no information — it just truncates at a deterministic boundary that
# fits the container height telemetry. Container telemetry source is the
# per-unit ``min_height_px`` (frame visual_hints), which is what the
# pipeline composer already knows at the zones_data append site.
#
# We never re-summarize, never AI-call, never reorder. Char-budget cut
# would risk splitting CJK words mid-character — line-boundary cut is
# the closest deterministic surface to ``raw_content`` semantics
# (MDX paragraph / bullet boundaries).
#
# Guardrails honored:
# - feedback_ai_isolation_contract — pure deterministic helper. No
# anthropic import, no AI fallback router path.
# - MDX 원문 무손실 보존 — preview is a CUT, never a rewrite; popup body
# stays equal to ``raw_content``.
# - feedback_no_hardcoding — line metric is parametric (line_height_px
# defaults to slide_base.html body line metric ~18 px = 11 px font *
# 1.6 line-height + ~0.4 px ascent guard). u9 will surface the literal
# value source.
# Line height in px used to convert a container-height budget into a
# line-count budget. Matches slide_base.html ``--font-body`` (11 px) at
# the ``.text-line`` line-height (1.6). Default — NOT a hardcoded magic
# constant: ``compute_popup_preview_text`` accepts an override so the
# downstream renderer (u8) or per-frame contracts can pass a tighter
# value if a frame uses a smaller body font.
POPUP_PREVIEW_DEFAULT_LINE_HEIGHT_PX = 18.0
def compute_popup_preview_text(
raw_content: str,
container_height_px: float,
*,
line_height_px: float = POPUP_PREVIEW_DEFAULT_LINE_HEIGHT_PX,
) -> str:
"""Px-budgeted preview excerpt of ``raw_content`` (IMP-35 u7).
Deterministic line-boundary cut — returns the leading lines of
``raw_content`` that fit within ``container_height_px`` at the slide
body line metric. Never trims inside a line (no mid-CJK-word cut);
the popup body (u6 ``popup_body_source``) retains the FULL original
verbatim so this excerpt loses no information.
Args:
raw_content: the unit's source MDX content; the popup body
source per CLAUDE.md 자세히보기 원칙.
container_height_px: container height telemetry. The pipeline
composer passes ``min_height_px`` (frame visual_hints) at
the zones_data append site. Non-positive values fall back
to returning the full content unchanged (popup gate would
not have fired without a real container budget anyway).
line_height_px: px per body line. Default matches slide_base.html
``.text-line`` (11 px font * 1.6 line-height + guard).
Overridable for tighter-font frames.
Returns:
The leading lines that fit the budget, joined verbatim. If the
content already fits, returns ``raw_content`` unchanged.
"""
if not raw_content:
return ""
if container_height_px <= 0 or line_height_px <= 0:
# No budget signal — return the full content unchanged. u5 POPUP
# gate would not have fired without a real container budget, so
# this branch is only reachable for non-popup units (where the
# preview is anyway unused — see compose_zone_popup_payload).
return raw_content
max_lines = int(container_height_px // line_height_px)
if max_lines < 1:
max_lines = 1
lines = raw_content.splitlines(keepends=False)
if len(lines) <= max_lines:
return raw_content
# Re-join with "\n" — splitlines drops the terminator so a verbatim
# round-trip of the leading lines is "\n".join(...). Preserves the
# exact head of raw_content up to the chosen line boundary.
return "\n".join(lines[:max_lines])
def compose_zone_popup_payload(unit, container_height_px: float) -> dict:
"""Compose the per-zone popup render-context payload (IMP-35 u7).
Reads u6 ``bind_popup_display_strategy(unit)`` and surfaces the three
uniform render-context field names the pipeline composer attaches to
each zone in ``zones_data``. The full u6 binding is also echoed
under ``popup_binding`` so downstream debug / u8 / u9 consumers can
self-explain without re-reading the yaml.
Args:
unit: a CompositionUnit (or any duck-typed object exposing
``has_popup`` / ``popup_escalation_plan`` / ``raw_content``).
container_height_px: container height telemetry. The pipeline
composer passes ``min_height_px`` at the zones_data append
site. The non-popup branch ignores the value (preview_text
is always None when has_popup=False).
Returns:
Dict with the four wiring keys (``has_popup``, ``popup_html``,
``preview_text``, ``popup_binding``). Spreadable into a zone
dict via ``zones_data.append({..., **payload})``.
"""
binding = bind_popup_display_strategy(unit)
has_popup = bool(binding.get("has_popup"))
if not has_popup:
return {
"has_popup": False,
"popup_html": None,
"preview_text": None,
"popup_binding": binding,
}
raw_content = getattr(unit, "raw_content", "") or ""
popup_html = binding.get("popup_body_source")
preview_text = compute_popup_preview_text(raw_content, container_height_px)
return {
"has_popup": True,
# popup body = FULL raw_content (u6 popup_body_source). u8 wraps
# this in <details>/<summary> markup on slide_base.html.
"popup_html": popup_html,
# body preview = px-budgeted line-boundary cut of raw_content.
# NEVER trims inside a line; popup body holds the FULL original
# so this excerpt loses no information.
"preview_text": preview_text,
# Full u6 binding echo — downstream debug surfaces (catalog
# detail_trigger placement, popup_escalation_plan category /
# rationale) without re-reading yaml.
"popup_binding": binding,
}
# ─── CompositionUnit ────────────────────────────────────────────
@dataclass

View File

@@ -34,8 +34,12 @@ frame_reselect (V4 top-k 의 다른 frame)
details_popup_escalation (가장 invasive — content popup, 마지막 resort)
```
`details_popup_escalation` 은 본 매핑에 *없음* — tabular_overflow / structural_major_overflow /
frame_reselect 실패 이후 단계에서 다룸 (별 step).
IMP-35 (#64) u2 — cascade terminal landed. `frame_reselect_insufficient`
(post-frame remeasure failure, classifier path locked in u1) now routes onto
`details_popup_escalation`. The status table records the popup action as
MISSING here; the actual executor stub + MISSING→IMPLEMENTED flip lives in
`src/phase_z2_router.py` (u3 surface), so this module advertises the cascade
terminal without claiming an implementation it does not own.
"""
from __future__ import annotations
@@ -74,6 +78,13 @@ FAILURE_TYPE_DESCRIPTIONS: dict[str, str] = {
"font_step_compression salvage step failed — FONT_SIZE_STEPS exhausted "
"down to the floor without resolving overflow (or text_metrics missing)"
),
"frame_reselect_insufficient": (
"frame_reselect salvage step failed — V4 top-k alternate frame swap "
"re-rendered + post-frame remeasure (run_overflow_check) still fails. "
"IMP-35 (#64) u1 contract: emitted from salvage_steps[-1].action == "
"'frame_reselect' AND passed=False AND post_salvage_overflow present. "
"Routes to details_popup_escalation in u2 (cascade terminal)."
),
}
@@ -86,6 +97,12 @@ SALVAGE_FAILURE_TYPE_BY_ACTION: dict[str, str] = {
"cross_zone_redistribute": "cross_zone_redistribute_insufficient",
"glue_compression": "glue_absorption_insufficient",
"font_step_compression": "font_step_insufficient",
# IMP-35 (#64) u1: post-frame remeasure failure. frame_reselect salvage step
# writes a salvage_steps entry with action='frame_reselect', passed=False,
# and post_salvage_overflow populated by run_overflow_check on the swapped
# frame's HTML. classifier reads that entry; u2 adds the NEXT_ACTION row
# that routes this onto details_popup_escalation.
"frame_reselect": "frame_reselect_insufficient",
}
@@ -98,6 +115,14 @@ NEXT_ACTION_BY_FAILURE: dict[str, str] = {
"glue_absorption_insufficient": "font_step_compression",
"font_step_insufficient": "layout_adjust",
"rerender_still_fails": "frame_reselect",
# IMP-35 (#64) u2 — cascade terminal. frame_reselect salvage exhausted
# (post-frame remeasure failed; classifier path gated on
# post_salvage_overflow per u1/q4) escalates onto details_popup_escalation.
# Popup body holds full MDX source; preview shows summary/subset
# (CLAUDE.md 자세히보기 원칙). Executor + MISSING→IMPLEMENTED flip lands
# in u3 (src/phase_z2_router.py); this module owns the cascade mapping
# only.
"frame_reselect_insufficient": "details_popup_escalation",
"not_attempted": "none",
}
@@ -127,6 +152,12 @@ NEXT_ACTION_RATIONALE: dict[str, str] = {
"frame/zone 조합 자체 부적합, V4 top-k 의 다른 frame 평가 (frame_reselect). "
"popup 직행은 아직 빠름 (tabular / structural_major 가 아닌 한)"
),
"frame_reselect_insufficient": (
"V4 top-k frame swap + 명시적 post-frame remeasure 까지 했는데도 overflow "
"잔존 → cascade terminal 인 details_popup_escalation 으로 escalate. "
"본문 = summary/subset, popup = MDX 원문 (자세히보기 원칙). "
"AI repair 진입 전 deterministic 마지막 단계."
),
"not_attempted": (
"retry 시도 자체가 없었음 (visual ok 등) — escalation 불필요"
),
@@ -145,6 +176,12 @@ NEXT_ACTION_IMPLEMENTATION_STATUS: dict[str, str] = {
"font_step_compression": "IMPLEMENTED", # u6 plan_font_step_compression + apply_font_step_compression_css
"layout_adjust": "MISSING",
"frame_reselect": "MISSING",
# IMP-35 (#64) u2 — cascade terminal advertised as MISSING here. The
# router executor stub + MISSING→IMPLEMENTED flip lives in
# src/phase_z2_router.py (u3). Keeping this entry as MISSING until u3
# lands prevents premature "popup ready" claims from the failure-router
# surface.
"details_popup_escalation": "MISSING",
"none": "n/a",
}
@@ -170,21 +207,40 @@ def classify_retry_failure(retry_trace: dict) -> Optional[dict]:
# case 0.7 : salvage chain attempted and ended in a salvage-level failure.
# zone_ratio_retry 가 먼저 실패한 뒤 _attempt_salvage_chain 이 가동된 path —
# 마지막 salvage step 의 action 으로 failure_type 을 분류한다. u3 가 routing.
#
# IMP-35 (#64) u1 — q4 explicit remeasure contract: the frame_reselect
# branch is gated on post_salvage_overflow being present on the salvage
# step. A bare passed=False flag with no remeasure payload is *not*
# sufficient to emit frame_reselect_insufficient (which routes to
# details_popup_escalation in u2). When the gate fails, the classifier
# falls through to lower-priority cases so the salvage trace surfaces as
# an unmatched defensive fallback instead of a spurious popup escalation.
salvage_steps = retry_trace.get("salvage_steps") or []
if salvage_steps:
last = salvage_steps[-1] or {}
if not last.get("passed"):
action = (last.get("action") or "").lower()
ftype = SALVAGE_FAILURE_TYPE_BY_ACTION.get(action)
if ftype is not None:
reason = last.get("failure_reason") or ""
return {
"failure_type": ftype,
"classification_rule": (
f"salvage_steps[-1].action == {action!r} "
f"AND passed=False. raw failure_reason: {reason!r}"
),
}
frame_reselect_blocked = (
action == "frame_reselect"
and not last.get("post_salvage_overflow")
)
if not frame_reselect_blocked:
ftype = SALVAGE_FAILURE_TYPE_BY_ACTION.get(action)
if ftype is not None:
reason = last.get("failure_reason") or ""
rule_suffix = (
" AND post_salvage_overflow present"
if action == "frame_reselect"
else ""
)
return {
"failure_type": ftype,
"classification_rule": (
f"salvage_steps[-1].action == {action!r} "
f"AND passed=False{rule_suffix}. "
f"raw failure_reason: {reason!r}"
),
}
# case 1 : retry 시도 자체 안 됨 (router_active=False 또는 다른 action)
if not retry_trace.get("retry_attempted"):

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",

View File

@@ -56,12 +56,24 @@ ACTION_RATIONALE: dict[str, str] = {
"위 매핑 모두 미적용 — 마지막 fallback (현재 코드는 sys.exit 으로 abort)",
}
# 각 action 의 *현재 코드* 구현 상태 (2026-04-29 기준; IMP-12 u7 cascade 2026-05-18)
# 각 action 의 *현재 코드* 구현 상태 (2026-04-29 기준; IMP-12 u7 cascade 2026-05-18;
# IMP-35 u3 popup-stub 2026-05-23)
# A2 단계에서 이 매핑이 *어디까지 자동 처리되고 어디서 막히는지* trace 확보용
ACTION_IMPLEMENTATION_STATUS: dict[str, str] = {
"zone_ratio_retry": "IMPLEMENTED", # A3 (2026-04-29) phase_z2_retry.plan_zone_ratio_retry + pipeline orchestration
"layout_adjust": "MISSING",
"details_popup_escalation": "MISSING", # CLAUDE.md 의 <details> 원칙은 있음, runtime 미구현
# IMP-35 (#64) u3 — MISSING → IMPLEMENTED on the primary router surface.
# `plan_details_popup_escalation` (below) provides the deterministic stub
# that downstream units consume: u4 binds the AI split-decision contract
# in `src/phase_z2_ai_fallback/step17.py`; u5 wires the Step 17 POPUP
# gate executor in `src/phase_z2_pipeline.py`. Router-level mapping is
# decoupled from orchestrator wiring (same precedent as the IMP-12 u7
# cascade actions below): IMPLEMENTED here reflects deterministic
# *surface availability* (importable stub), not whether a given pipeline
# run has invoked it. The failure_router companion surface
# (NEXT_ACTION_IMPLEMENTATION_STATUS in phase_z2_failure_router.py) keeps
# `details_popup_escalation` as MISSING until u5 lands the pipeline gate.
"details_popup_escalation": "IMPLEMENTED",
"frame_reselect": "PARTIAL", # IMP-05 pre-render rank-2/3 fallback implemented; post-render rerender trace-only
"adapter_needed": "PARTIAL", # composition v0.1.1 의 mapper FitError catch
"abort": "IMPLEMENTED", # sys.exit(1) — pipeline 의 현재 default
@@ -185,3 +197,112 @@ def route_fit_classification(fit_classification: dict) -> dict:
"MISSING 이면 그 action 은 실행 X 이고 기존 abort/status 흐름 (sys.exit(1)) 으로 종료."
),
}
# ─── IMP-35 (#64) u3 — details_popup_escalation deterministic stub ─
# Surface contract for the cascade-terminal popup escalation. This stub
# does NOT mutate HTML / CSS / MDX content; it emits the canonical plan
# marker that the Step 17 POPUP gate (u5) and the AI split-decision hook
# (u4) consume. Keeping the executor surface here (next to the primary
# ACTION_BY_CATEGORY mapping) lets the router report IMPLEMENTED for
# `details_popup_escalation` while u4/u5 are still landing.
#
# Contract (locked in Stage 2 IMPLEMENTATION_UNITS u3):
# - Inputs: classification dict (a single fit_classifier output row).
# The category MUST be one of the two ACTION_BY_CATEGORY
# rows that map onto `details_popup_escalation` —
# `structural_major_overflow` or `tabular_overflow`.
# Other categories raise the stub's defensive guard (so
# callers do not silently popup-escalate the wrong category).
# - Output: popup_escalation_plan dict with `feasible=True`,
# `stub=True`, the source category, the canonical
# ACTION_RATIONALE entry, and `needs_split_decision=True`
# to flag that u4 (AI hook) must run before u5 renders.
# - No side effects (no AI call, no MDX read, no HTML mutation).
#
# Guardrails honored:
# - feedback_ai_isolation_contract: stub is deterministic-with-data;
# no AI call inside the router surface.
# - Phase Z spacing 방향: stub does not shrink common margins; it
# expands capacity by routing content to popup downstream.
# - 자세히보기 원칙 (CLAUDE.md): plan carries the marker that u5 uses
# to put MDX 원문 in popup body and a summary/subset in preview.
# - 1 turn = 1 unit: this is router-surface only. u4/u5 own the
# downstream wiring on their respective files.
# Categories that legitimately escalate onto details_popup_escalation
# per the ACTION_BY_CATEGORY mapping above. Kept as a derived constant
# so the router cannot drift away from the single source of truth.
POPUP_ESCALATION_CATEGORIES: frozenset[str] = frozenset(
category
for category, action in ACTION_BY_CATEGORY.items()
if action == "details_popup_escalation"
)
def plan_details_popup_escalation(classification: dict) -> dict:
"""Cascade-terminal popup escalation plan stub (IMP-35 u3).
Returns a deterministic popup_escalation_plan marker. The actual
content split (popup_html / preview_text / has_popup payload) is
composed downstream: u4 binds the AI split-decision contract on
`src/phase_z2_ai_fallback/step17.py`; u5 wires the Step 17 POPUP
gate executor on `src/phase_z2_pipeline.py`.
Args:
classification: a single fit_classifier classification dict.
Must contain a `category` key. Only the categories that
map onto `details_popup_escalation` in ACTION_BY_CATEGORY
(currently `structural_major_overflow` and `tabular_overflow`)
are accepted; any other category produces an
`feasible=False` plan with `failure_reason` so the caller
never silently popup-escalates the wrong overflow shape.
Returns:
popup_escalation_plan dict with at least:
action : "details_popup_escalation"
feasible : True/False (True for accepted categories)
stub : True (marks u3 surface; u4/u5 fill in)
category : echoed from input
rationale : canonical ACTION_RATIONALE entry
needs_split_decision : True (u4 AI hook must run before u5 renders)
mapping_source : "IMP-35 u3 plan_details_popup_escalation stub"
note : downstream-wiring pointer text
"""
category = (classification or {}).get("category")
base = {
"action": "details_popup_escalation",
"stub": True,
"category": category,
"mapping_source": "IMP-35 u3 plan_details_popup_escalation stub",
}
if category not in POPUP_ESCALATION_CATEGORIES:
return {
**base,
"feasible": False,
"needs_split_decision": False,
"rationale": "",
"failure_reason": (
f"category {category!r} does not map onto details_popup_escalation "
f"in ACTION_BY_CATEGORY. Accepted categories: "
f"{sorted(POPUP_ESCALATION_CATEGORIES)}. Defensive guard — "
f"router must not silently popup-escalate the wrong overflow shape."
),
"note": (
"u3 stub — caller passed a category that should not popup-escalate. "
"Honour the ACTION_BY_CATEGORY mapping at the router entry point."
),
}
return {
**base,
"feasible": True,
"needs_split_decision": True,
"rationale": ACTION_RATIONALE.get(category, ""),
"note": (
"u3 stub — actual content split planning lands in u4 "
"(AI split-decision contract on src/phase_z2_ai_fallback/step17.py) "
"and u5 (Step 17 POPUP gate executor on src/phase_z2_pipeline.py). "
"popup body = MDX 원문, preview = summary/subset (자세히보기 원칙)."
),
}