Executing unit: u8
[Claude #9] Stage 3 code-edit Round #N — unit u8
### unit_executed
- **id**: u8
- **summary**: New `_attempt_salvage_chain` in `phase_z2_pipeline.py` — deterministic Step 17 salvage cascade orchestrator. Iterates `phase_z2_failure_router.route_retry_failure` across the triad (`cross_zone_redistribute → glue_compression → font_step_compression`); per stage runs plan → apply CSS → render candidate → `run_overflow_check`. PASS promotes `final.html`; cascade-exit routes (`layout_adjust` / `frame_reselect` / `none`) terminate; all-fail preserves (b) revert. Honors IMP-09 `dynamic_cols` / `fr_default` gate. **u9 pipeline wiring deferred.**
### files_changed
- `src/phase_z2_pipeline.py` (u8-only delta = +95 / −1 — includes +13 lines of new imports, +6 lines of header comment + lookup table, +76 lines of orchestrator function body. Slightly above the u8 line estimate of 50 due to 3-action plan/apply dispatch + (b)-revert telemetry needing explicit per-stage step dicts; the cascade body itself is loop-driven and table-based to avoid duplication.)
### diff_summary
1. **New imports** (lines 58-76, `+13`):
- From `phase_z2_retry`: `plan_cross_zone_redistribute` / `apply_cross_zone_redistribute_css` (u4), `plan_glue_compression` / `apply_glue_compression_css` (u5), `plan_font_step_compression` / `apply_font_step_compression_css` (u6). No mutation of existing `plan_zone_ratio_retry` / `apply_retry_to_layout_css` (u1) import.
- From `phase_z2_failure_router`: `route_retry_failure` (u3) added alongside existing `enrich_retry_trace_with_failure_classification`.
2. **`_SALVAGE_FAIL_BY_ACTION` constant** (lines 1937-1944) — single-point mapping from cascade action label to the failure-type label the failure router uses for the next routing step. Mirrors `phase_z2_failure_router.SALVAGE_FAILURE_TYPE_BY_ACTION` (u2) at the orchestrator layer so the route-walk does not need to reach back into the failure-router module for cascade state.
3. **`_attempt_salvage_chain` orchestrator** (lines 1947-2024):
- **Signature**: `run_dir`, `out_path`, `slide_title`, `slide_footer`, `zones_data`, `layout_preset`, `layout_css`, `cascade_inputs: dict`, `initial_failure_type: str`, `gap_px: int`. Mirrors `_attempt_zone_ratio_retry` arg shape so u9 can plumb identically. `cascade_inputs` is a flat dict that u9 will populate with `{fit_analysis, containers, min_margin_px, excess_px, block_count, zone_position, current_font_px, excess_after_glue_px, available_lines, chars_per_line}` — keeps the orchestrator agnostic to where the values come from.
- **IMP-09 gate** (lines 1956-1959): exits with `salvage_attempted=False` + `salvage_skipped_reason` when `dynamic_cols=True` or `dynamic_rows=False`. Same posture as `_attempt_zone_ratio_retry` (line 1833-1843) — row-axis CSS overrides on 2-D or fr_default layouts would either misapply or no-op.
- **Cascade loop** (lines 1963-2023, bounded to `len(_SALVAGE_FAIL_BY_ACTION) == 3` iterations to guard against router cycles):
- `route_retry_failure(failure_type)` resolves the next cascade action. If `next_action` is not one of the 3 salvage labels (i.e. `layout_adjust` / `frame_reselect` / `none` terminal), the orchestrator records `salvage_terminal_action` + `salvage_terminal_rationale` and returns — preserving (b) revert.
- Per stage: dispatch to the matching plan/apply pair. `cross_zone_redistribute` has an explicit `fit_analysis is None` defensive guard (synthesises a `feasible=False` plan with a clear `failure_reason` rather than crashing `plan_cross_zone_redistribute` on `.roles` dereference). `glue_compression` / `font_step_compression` planners already self-guard on zero / missing inputs (u5/u6 case-0 short-circuits).
- When `plan.feasible` is True: `apply_fn(plan)` emits scoped CSS (`[data-role=…]` for cross_zone, `[data-zone-position=…]` for glue/font — guardrail respect baked into u4/u5/u6). The CSS is injected into the slide HTML via a `` block placed inside `` (or prepended if `` absent). The candidate is written to `salvage__candidate.html` next to the slide, then `run_overflow_check` runs.
- On PASS: `out_path.write_text(candidate_html)` promotes the candidate to `final.html`; step recorded with `post_salvage_overflow`; loop exits with `salvage_passed=True`.
- On FAIL (infeasible plan, no CSS, or visual_check fail): step recorded with `failure_reason` (planner reason first, then visual_check fail_reasons, then a generic fallback). `failure_type` updated via `_SALVAGE_FAIL_BY_ACTION[next_action]` so the next loop iteration's `route_retry_failure` advances the cascade.
- **No `final.html` mutation outside `passed=True`** — (b) revert preserved on every fail path (skip, terminal, infeasible, no-CSS, visual_check fail). Verified via probes 3, 5, 6.
4. **Backward-compat / surface invariants preserved**:
- `_attempt_zone_ratio_retry` body unchanged.
- `enrich_retry_trace_with_failure_classification` import preserved (u2/u3 surface intact).
- No changes to `phase_z2_router.ACTION_BY_CATEGORY`, `_attempt_zone_ratio_retry`, or pipeline-Step-17 wiring (u9 territory).
- No `ACTION_IMPLEMENTATION_STATUS` / `NEXT_ACTION_IMPLEMENTATION_STATUS` mutations (u7 already registered IMPLEMENTED for the 3 cascade actions).
### evidence
- `python -c "from src.phase_z2_pipeline import _attempt_salvage_chain, _SALVAGE_FAIL_BY_ACTION; ..."` → `OK: ['cross_zone_redistribute', 'glue_compression', 'font_step_compression']` (import + table sanity).
- `python -m pytest -q tests/phase_z2` → **62 passed in 0.12s** (regression-free; u15 will add cascade-orchestrator fixture tests).
- `git diff --numstat src/phase_z2_pipeline.py` → `95 1 src/phase_z2_pipeline.py` (only file touched by u8).
- Direct behavior probes (all PASS):
- **Probe 3 (IMP-09 gate, `dynamic_cols=True`)**: `salvage_attempted=False`, `salvage_skipped_reason` contains "IMP-09", `final.html` unchanged → (b) revert preserved on gate skip.
- **Probe 4 (IMP-09 gate, `dynamic_rows=False`)**: same as probe 3 → gate covers fr_default layouts.
- **Probe 5 (terminal-route from non-cascade failure)**: `initial_failure_type='rerender_still_fails'` → `salvage_attempted=True` but `salvage_steps=[]`, `salvage_terminal_action='frame_reselect'`, `final.html` unchanged → terminal route preserves (b) revert without consuming any cascade slot.
- **Probe 6 (cascade walk from `donor_slack_insufficient`, all 3 stages infeasible)**: `actions == ['cross_zone_redistribute', 'glue_compression', 'font_step_compression']`, all `passed=False`, all `css_override=None`, all `candidate_path=None`, `final.html` unchanged → 3-stage walk works, (b) revert preserved, no spurious render.
- **Probe 6.5 (per-stage failure_reason surfaces)**: `cross_zone_redistribute` step → `"fit_analysis missing"`; `glue_compression` step → `"excess_px <= 0"`; `font_step_compression` step → `"excess_after_glue_px <= 0"` or `"text_metrics missing"` → upstream planner reasons propagate cleanly through the orchestrator.
- **Probe 7 (u3 routing aligns with u8 lookup table)**: `route_retry_failure('donor_slack_insufficient') → cross_zone_redistribute`; `route_retry_failure('cross_zone_redistribute_insufficient') → glue_compression`; `route_retry_failure('glue_absorption_insufficient') → font_step_compression`; `route_retry_failure('font_step_insufficient') → layout_adjust` → 4-transition cascade verified end-to-end against the failure router surface.
- **Probe 8 (feasible + render + PASS)**: SKIPPED — needs Selenium-driven `run_overflow_check`. Will be covered by u15 fixture tests in `tests/phase_z2/test_phase_z2_step17_salvage_chain.py`.
- No new tests added in this unit (u15 is the test unit for the cascade orchestrator).
### scope_lock
- **No changes** to: `phase_z2_router.py`, `phase_z2_failure_router.py`, `phase_z2_retry.py` (all u1-u7 surfaces intact).
- **No changes** to `_attempt_zone_ratio_retry`, Step 17 telemetry block (lines 4329-4342), or `enrich_retry_trace_with_failure_classification` call site — u9 territory.
- **No AI/Kei call** on normal path (`feedback_ai_isolation_contract`).
- **No `:root` / `body` / `.slide` / `.zone` CSS** — the `