feat(IMP-06): Stage 4 Part 2 — render-path integration (units rebuild + empty zone + Catch K fix)

Refs #6

After `position_assignment_plan` is built, rebuild the `units` list to be
plan-aligned so downstream `zones_data` / `debug_zones` / mapper / render
all see the post-override sequence. This resolves the long-standing
trace-only gap and closes Codex Catch K naturally because the helper now
actually drives downstream materialization.

- run_phase_z2_mvp1: after `_build_position_assignment_plan`, rebuild
  `units` ordered by `position_assignment_plan`. `cli_override` entries
  synthesize a CompositionUnit (resolved template_id + concatenated
  section raw_content + contract frame_id + selection_path="cli_override"
  + override audit in rationale). `auto` entries reuse the original
  planner unit. empty/collision-skipped entries become None placeholders
  so the downstream zone loop can emit an explicit empty zone record
  without distorting layout allocation.
- zones_data / debug_zones loop: handle `unit is None` by appending an
  explicit empty record with template_id="__empty__", content_weight=0,
  min_height_px=0, plus the plan's skipped_reason / replaced_auto_unit /
  skipped_collided_auto_units / uncovered_section_ids audit fields.
- partial render loop: `template_id == "__empty__"` short-circuits to
  `partial_html = ""` so the slide_base zones loop preserves grid identity
  without raising TemplateNotFound.
- Update the helper-invocation comment so it now describes the actual
  Part 2 behavior (units rebuild + empty zone handling). Catch K is no
  longer a future-tense placeholder.

Stage 4 Part 3 (follow-up commit) will add: Step 9 application_plan
plan-aware additive fields, Step 20 list-shaped filtered_section_reasons
entries for override-uncovered sections (Codex #10 Catch O schema), and
integration tests proving zones_data["top"] actually contains the
overridden section ids when --override-section-assignment is supplied.

Regression: 20/20 unit tests pass (9 IMP-06 helper + 8 IMP-05 fallback +
2 catalog invariant + 1 dedicated replaced_auto_unit test), smoke
self-check 11/11 (IMP-04 F17 calibration intact).

No AI, no calculate_fit, no full planner rerun, no frontend, no sample
hardcoding. plan_composition() signature preserved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 06:50:35 +09:00
parent b81e564f65
commit 1f15495117

View File

@@ -1299,6 +1299,12 @@ def render_slide(slide_title: str, slide_footer: Optional[str],
autoescape=select_autoescape(["html"]),
)
for zone in zones_data:
# Stage 4 Part 2 (Codex #10 Catch N) — empty zone produced by section
# assignment override has no partial template; render an empty string so
# the slide_base zones loop preserves grid identity without TemplateNotFound.
if zone.get("template_id") == "__empty__":
zone["partial_html"] = ""
continue
partial = env.get_template(f"families/{zone['template_id']}.html")
zone["partial_html"] = partial.render(slot_payload=zone["slot_payload"])
@@ -2058,12 +2064,16 @@ def run_phase_z2_mvp1(
layout_preset = override_layout
layout_override_applied = True
# IMP-06 (#6 / Codex #6/#7/#10 lock) — zone-section assignment override.
# IMP-06 (#6 / Codex #6/#7/#10/#11/#12 lock) — zone-section assignment override.
# Applied AFTER final layout_preset resolution. ZONE_ID = layout positions.
# The helper validates unknown zone ids / unknown section ids and builds a
# `position_assignment_plan`. The plan drives downstream zones_data /
# debug_zones / Step 9 application_plan / Step 20 coverage (see Stage 4
# block further below where `units_by_position` is materialized).
# `position_assignment_plan`. Immediately below, Stage 4 Part 2 rebuilds the
# `units` list aligned with that plan: cli_override entries synthesize a
# CompositionUnit, auto entries reuse the original planner unit, and empty/
# collision-skipped entries become None placeholders. The downstream
# zones_data / debug_zones loop then handles None entries by emitting an
# explicit empty zone record (template_id="__empty__") so the slide grid
# preserves position identity without distorting layout allocation.
section_assignment_plan: Optional[list[dict]] = None
section_assignment_summary: Optional[dict] = None
if override_section_assignments and layout_preset is not None:
@@ -2106,6 +2116,61 @@ def run_phase_z2_mvp1(
file=sys.stderr,
)
# Stage 4 Part 2 — rebuild units list aligned with position_assignment_plan so
# downstream zones_data / debug_zones / Step 9 / Step 20 derive from the plan
# rather than the original auto-selected sequence. None placeholders preserve
# position identity for empty/collision-skipped zones (Codex #10 Catch N).
from src.phase_z2_composition import CompositionUnit
plan_units: list = []
for entry in section_assignment_plan:
assignment_source = entry["assignment_source"]
if assignment_source == "cli_override" and entry["template_id"] is not None:
sids = entry["source_section_ids"]
raw_content_parts = []
title_parts = []
for sid in sids:
sect = sections_by_id.get(sid)
if sect is None:
continue
raw_content_parts.append(sect.raw_content or "")
if sect.title:
title_parts.append(sect.title)
contract = get_contract(entry["template_id"])
contract_frame_id = (contract or {}).get("frame_id") or ""
override_unit = CompositionUnit(
source_section_ids=list(sids),
merge_type="cli_override",
frame_template_id=entry["template_id"],
frame_id=str(contract_frame_id),
frame_number=0,
confidence=0.0,
label="use_as_is",
phase_z_status="matched_zone",
raw_content="\n\n".join(raw_content_parts),
title=" / ".join(title_parts) if title_parts else "+".join(sids),
v4_rank=None,
selection_path="cli_override",
fallback_reason=None,
score=0.0,
rationale={
"section_assignment_override": entry["section_assignment_override"],
"replaced_auto_unit": entry["replaced_auto_unit"],
},
)
plan_units.append(override_unit)
elif assignment_source == "auto":
# Find original auto unit by source_section_ids
matched = None
for u in units:
if list(u.source_section_ids) == entry["source_section_ids"]:
matched = u
break
plan_units.append(matched) # may be None if not found (unexpected)
else:
# empty / collision-skipped
plan_units.append(None)
units = plan_units
if not units or layout_preset is None:
# composition planner 결과 = 0 units. Sections 가 모두 V4 lookup 실패 또는
# status filter 통과 못 함. error.json 기록 후 abort.
@@ -2254,6 +2319,53 @@ def run_phase_z2_mvp1(
for i, unit in enumerate(units):
position = positions[i] if i < len(positions) else f"zone_{i}"
# Stage 4 Part 2 — empty position (override plan produced no renderable unit).
# Preserve zone identity with an explicit empty record so layout/grid stay
# structurally consistent without distorting allocation (Codex #10 Catch N).
if unit is None:
plan_entry = None
if section_assignment_plan is not None and i < len(section_assignment_plan):
plan_entry = section_assignment_plan[i]
zones_data.append({
"position": position,
"template_id": "__empty__",
"slot_payload": {},
"content_weight": {"score": 0},
"min_height_px": 0,
"assignment_source": "empty",
"skipped_reason": (
(plan_entry or {}).get("skipped_reason")
or "section_assignment_override_empty_or_unrenderable"
),
})
debug_zones.append({
"position": position,
"source_section_ids": [],
"merge_type": "empty",
"title": "",
"v4_template_id": "__empty__",
"v4_label": None,
"v4_confidence": 0.0,
"selection_path": "section_assignment_empty",
"fallback_reason": None,
"fallback_used": False,
"phase_z_status": None,
"composition_score": 0.0,
"composition_rationale": {},
"composition_notes": [],
"mapper_type": "empty",
"contract_id": "__empty__",
"contract_frame_id": None,
"assignment_source": "empty",
"skipped_reason": (
(plan_entry or {}).get("skipped_reason")
or "section_assignment_override_empty_or_unrenderable"
),
"replaced_auto_unit": (plan_entry or {}).get("replaced_auto_unit"),
"skipped_collided_auto_units": (plan_entry or {}).get("skipped_collided_auto_units", []),
"uncovered_section_ids": (plan_entry or {}).get("uncovered_section_ids", []),
})
continue
synth_section = MdxSection(
section_id="+".join(unit.source_section_ids),
section_num=0,