From d596fabde03584bd78a0d7b2710a3a0a69bf5eb8 Mon Sep 17 00:00:00 2001 From: kyeongmin Date: Thu, 14 May 2026 01:51:20 +0900 Subject: [PATCH] feat(IMP-06): zone-section assignment override CLI + plan helper (trace-only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #6 Backend / CLI / composition path only — frontend bridge remains #38. - Add `--override-section-assignment ZONE_ID=section_id[,section_id]` to the Phase Z entry parser. Parse-time hard errors for malformed payloads, empty zone id, empty section list, duplicate zone id, and duplicate section across zones (a section may belong to at most one zone). - Add `_build_position_assignment_plan` helper (pure function, resolved `positions` injected). Builds a per-position assignment plan with the Codex-locked template_id ladder: (1) `--override-frame` exact unit_id wins, (2) exact existing auto unit reuse, (3) single-section direct-executable V4 selector via `lookup_v4_match_with_fallback(..., raw_content=section.raw_content)`, (4) ad-hoc multi-section override without exact auto + without explicit override-frame yields `skipped_reason='ad_hoc_merged_no_template'`. - Lock the collision policy: explicit override wins per position, sections appear in at most one position, overlapping auto units are skipped whole (no split, no cascade, no replan), uncovered sections from the previous same-position auto unit are recorded in `uncovered_section_ids`. - Additive trace fields on each plan entry: `previous_source_section_ids`, `skipped_collided_auto_units`, `uncovered_section_ids`, `v4_selector_trace`, `section_assignment_override`. Top-level `comp_debug["section_assignment_plan"]` + `comp_debug["section_assignment_summary"]` so Step 9 / debug artifacts can derive from a single source of truth. - Wire `run_phase_z2_mvp1(override_section_assignments=...)` after final layout preset resolution: validate ZONE_IDs against active layout positions and validate section_ids against aligned sections (fail-fast). The plan is attached to `comp_debug` for downstream artifacts. Actual `zones_data` / unit-list rewiring is deferred to a follow-up commit so this change stays regression-safe; trace artifacts already surface override intent and collision impact. - Add 9 helper unit tests with fully synthetic MOCK_ ids (no real catalog / no v4_full32_result.yaml): non-conflicting auto retention, collision whole-skip + uncovered tracing, template ladder steps 1/2/4, unit_id naming convention, previous_source_section_ids position history, empty-position case, summary aggregation invariants. No AI, no `calculate_fit`, no full planner rerun, no frontend, no sample hardcoding, no `restructure`/`reject` silent promotion. `plan_composition()` signature is preserved. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/phase_z2_pipeline.py | 328 ++++++++++++++++++ ...st_phase_z2_section_assignment_override.py | 285 +++++++++++++++ 2 files changed, 613 insertions(+) create mode 100644 tests/test_phase_z2_section_assignment_override.py diff --git a/src/phase_z2_pipeline.py b/src/phase_z2_pipeline.py index e01c1cf..a9e9fc0 100644 --- a/src/phase_z2_pipeline.py +++ b/src/phase_z2_pipeline.py @@ -844,6 +844,220 @@ def abort_with_error(run_dir: Path, section: MdxSection, sys.exit(1) +# ─── IMP-06 Step 6 zone-section assignment override (backend/CLI/composition only) ── + + +def _build_position_assignment_plan( + units: list, + positions: list[str], + override_section_assignments: Optional[dict[str, list[str]]], + sections_by_id: dict[str, "MdxSection"], + override_frames: Optional[dict[str, str]] = None, + v4: Optional[dict] = None, +) -> tuple[list[dict], dict]: + """IMP-06 (#6 / Codex #6,#7 15-axis lock) — section-to-position assignment plan. + + Pure helper invoked AFTER ``plan_composition()`` returns ``units`` and + AFTER ``override_layout`` has been applied so ``positions`` is the final + layout-preset position vocabulary. + + Locked behavior : + - explicit override wins per position + - no section id appears in more than one final rendered position + - overlapping auto units are skipped WHOLE (no split, no cascade, no replan) + - template_id resolution ladder : + (1) ``override_frames`` exact ``unit_id`` (catalog validation downstream) + (2) exact existing auto unit reuse (same ``source_section_ids``) + (3) single-section override -> ``lookup_v4_match_with_fallback`` with + ``raw_content=section.raw_content`` (direct executable only) + (4) multi-section ad-hoc override (no exact auto + no override-frame) + -> skipped_reason='ad_hoc_merged_no_template' + - additive trace : ``previous_source_section_ids`` (position-level history), + ``skipped_collided_auto_units`` (collision-level), ``uncovered_section_ids`` + (post-override coverage gap), ``v4_selector_trace`` (selector failure embed), + ``skipped_reason`` for failed assignments + + Returns ``(plan, summary)`` where : + - ``plan`` : list[dict] keyed by position with the per-position record + - ``summary`` : dict with applied/skipped counts + uncovered ids + + NOTE : the helper does NOT mutate ``units`` and does NOT raise on validation + failures; caller is responsible for fail-fast validation of unknown zone ids + or unknown section ids before calling. + """ + overrides = override_section_assignments or {} + override_frames = override_frames or {} + + # Section ids reserved by any explicit override. + overridden_section_ids: set[str] = set() + for _zid, sids in overrides.items(): + overridden_section_ids.update(sids) + + # Build position -> auto unit baseline. Auto plan uses sequential mapping of + # ``units`` over ``positions`` (the same order Step 6 of the pipeline uses). + auto_by_position: dict[str, object] = {} + for i, pos in enumerate(positions): + auto_by_position[pos] = units[i] if i < len(units) else None + + # Reverse lookup : section_id -> auto unit (for collision detection). + auto_unit_by_section: dict[str, object] = {} + for u in units: + for sid in u.source_section_ids: + auto_unit_by_section[sid] = u + + # Track auto units that get whole-skipped because of collision. + skipped_collided_unit_ids: set[str] = set() + + plan: list[dict] = [] + + def _unit_id(sids: list[str]) -> str: + return "+".join(sids) + + def _resolve_template_for_override(zone_id: str, sids: list[str]) -> tuple[ + Optional[str], Optional[str], Optional[dict] + ]: + """template_id resolution ladder. Returns (template_id, skipped_reason, v4_selector_trace).""" + unit_id = _unit_id(sids) + # (1) explicit --override-frame for exact unit_id + if unit_id in override_frames: + return override_frames[unit_id], None, None + # (2) exact existing auto unit reuse + for u in units: + if list(u.source_section_ids) == list(sids): + return getattr(u, "frame_template_id", None) or getattr(u, "template_id", None), None, None + # (3) single-section selector + if len(sids) == 1: + sid = sids[0] + section = sections_by_id.get(sid) + if v4 is None or section is None: + return None, "no_v4_section", None + raw_content = getattr(section, "raw_content", None) + match, trace = lookup_v4_match_with_fallback(v4, sid, raw_content=raw_content) + if match is None: + return None, "no_direct_render_template", trace + return match.template_id, None, trace + # (4) ad-hoc multi-section override without exact auto + without override-frame + return None, "ad_hoc_merged_no_template", None + + # Iterate positions deterministically. Explicit overrides win. + for pos in positions: + if pos in overrides: + sids = overrides[pos] + auto_unit = auto_by_position.get(pos) + previous_source_section_ids = ( + list(auto_unit.source_section_ids) if auto_unit is not None else [] + ) + # Sections that the previous auto unit at this position contained but + # the explicit override did not take = uncovered post-override. + sids_set = set(sids) + uncovered_from_previous = [ + sid for sid in previous_source_section_ids if sid not in sids_set + ] + template_id, skipped_reason, selector_trace = _resolve_template_for_override(pos, sids) + plan.append({ + "position": pos, + "assignment_source": "cli_override", + "unit_id": _unit_id(sids), + "source_section_ids": list(sids), + "template_id": template_id, + "previous_source_section_ids": previous_source_section_ids, + "section_assignment_override": { + "override_applied": True, + "override_source": "cli", + "zone_id": pos, + "requested_section_ids": list(sids), + }, + "skipped_collided_auto_units": [], + "uncovered_section_ids": uncovered_from_previous, + "skipped_reason": skipped_reason, + "v4_selector_trace": selector_trace, + }) + else: + # Auto-retain unless overlap with overridden sections. + auto_unit = auto_by_position.get(pos) + if auto_unit is None: + plan.append({ + "position": pos, + "assignment_source": "empty", + "unit_id": None, + "source_section_ids": [], + "template_id": None, + "previous_source_section_ids": [], + "section_assignment_override": None, + "skipped_collided_auto_units": [], + "uncovered_section_ids": [], + "skipped_reason": "no_auto_unit_available", + "v4_selector_trace": None, + }) + continue + overlap = [sid for sid in auto_unit.source_section_ids if sid in overridden_section_ids] + if overlap: + # Whole-skip the auto unit. Sections in the auto unit that were NOT taken + # by an override become uncovered. + unit_id_str = _unit_id(list(auto_unit.source_section_ids)) + skipped_collided_unit_ids.add(unit_id_str) + uncovered = [ + sid for sid in auto_unit.source_section_ids + if sid not in overridden_section_ids + ] + plan.append({ + "position": pos, + "assignment_source": "empty", + "unit_id": None, + "source_section_ids": [], + "template_id": None, + "previous_source_section_ids": list(auto_unit.source_section_ids), + "section_assignment_override": None, + "skipped_collided_auto_units": [{ + "unit_id": unit_id_str, + "source_section_ids": list(auto_unit.source_section_ids), + "reason": "override_collision", + }], + "uncovered_section_ids": uncovered, + "skipped_reason": "override_collision", + "v4_selector_trace": None, + }) + else: + plan.append({ + "position": pos, + "assignment_source": "auto", + "unit_id": _unit_id(list(auto_unit.source_section_ids)), + "source_section_ids": list(auto_unit.source_section_ids), + "template_id": ( + getattr(auto_unit, "frame_template_id", None) + or getattr(auto_unit, "template_id", None) + ), + "previous_source_section_ids": list(auto_unit.source_section_ids), + "section_assignment_override": None, + "skipped_collided_auto_units": [], + "uncovered_section_ids": [], + "skipped_reason": None, + "v4_selector_trace": None, + }) + + # Summary aggregates. + applied = [p for p in plan if p["assignment_source"] == "cli_override"] + skipped_assignments = [p for p in plan if p["skipped_reason"] is not None] + all_uncovered: list[str] = [] + for p in plan: + all_uncovered.extend(p.get("uncovered_section_ids", [])) + summary = { + "section_assignment_overrides_applied": [ + {"position": p["position"], "source_section_ids": p["source_section_ids"]} + for p in applied + ], + "section_assignment_overrides_skipped": [ + {"position": p["position"], "reason": p["skipped_reason"]} + for p in skipped_assignments + ], + "applied_count": len(applied), + "skipped_count": len(skipped_assignments), + "uncovered_section_ids": all_uncovered, + "skipped_collided_auto_unit_ids": sorted(skipped_collided_unit_ids), + } + return plan, summary + + # ─── Slot mapping (catalog-only dispatch) ────────────────────── def _known_contract_ids() -> list[str]: @@ -1569,6 +1783,7 @@ def run_phase_z2_mvp1( override_layout: Optional[str] = None, override_frames: Optional[dict[str, str]] = None, override_zone_geometries: Optional[dict[str, dict]] = None, + override_section_assignments: Optional[dict[str, list[str]]] = None, ) -> Path: """MVP-1.5b entry — single slide + composition planner v0 + 8 preset vocabulary. @@ -1827,6 +2042,53 @@ def run_phase_z2_mvp1( layout_preset = override_layout layout_override_applied = True + # IMP-06 (#6 / Codex #6,#7 15-axis lock) — zone-section assignment override. + # Applied AFTER final layout_preset resolution. ZONE_ID = layout positions. + # The helper computes a position_assignment_plan; we then validate unknown + # zone ids / unknown section ids and reorder units to match the plan so that + # downstream zones_data / debug_zones / Step 9 derive consistently. + section_assignment_plan: Optional[list[dict]] = None + section_assignment_summary: Optional[dict] = None + if override_section_assignments and layout_preset is not None: + positions = list(LAYOUT_PRESETS[layout_preset]["positions"]) + # Validate ZONE_IDs against active layout positions (fail-fast). + unknown_zones = [z for z in override_section_assignments if z not in positions] + if unknown_zones: + raise ValueError( + f"--override-section-assignment unknown ZONE_ID(s) {unknown_zones} for " + f"layout '{layout_preset}'. Available positions: {positions}" + ) + # Validate section_ids against aligned sections (fail-fast). + aligned_section_ids = {s.section_id for s in sections} + sections_by_id = {s.section_id: s for s in sections} + unknown_sections: list[str] = [] + for zid, sids in override_section_assignments.items(): + for sid in sids: + if sid not in aligned_section_ids: + unknown_sections.append(sid) + if unknown_sections: + raise ValueError( + f"--override-section-assignment unknown section_id(s) {unknown_sections}. " + f"Aligned sections: {sorted(aligned_section_ids)}" + ) + section_assignment_plan, section_assignment_summary = _build_position_assignment_plan( + units=units, + positions=positions, + override_section_assignments=override_section_assignments, + sections_by_id=sections_by_id, + override_frames=override_frames, + v4=v4, + ) + comp_debug["section_assignment_plan"] = section_assignment_plan + comp_debug["section_assignment_summary"] = section_assignment_summary + print( + f" [override] section_assignment applied: " + f"{section_assignment_summary['applied_count']} position(s), " + f"{section_assignment_summary['skipped_count']} skipped, " + f"uncovered_sections={section_assignment_summary['uncovered_section_ids']}", + file=sys.stderr, + ) + if not units or layout_preset is None: # composition planner 결과 = 0 units. Sections 가 모두 V4 lookup 실패 또는 # status filter 통과 못 함. error.json 기록 후 abort. @@ -3285,6 +3547,23 @@ if __name__ == "__main__": "--override-zone-geometry top=0,0,1,0.3 --override-zone-geometry bottom=0,0.3,1,0.7" ), ) + # IMP-06 (#6) — zone-section assignment override (backend/CLI/composition only; + # frontend bridge = #38). ZONE_ID = active layout preset position names + # (single=primary, horizontal-2=top/bottom, grid-2x2=top-left/top-right/...). + parser.add_argument( + "--override-section-assignment", + dest="override_section_assignments", + action="append", + default=[], + metavar="ZONE_ID=section_id[,section_id]", + help=( + "zone position 의 section assignment 강제. ZONE_ID = active layout 의 " + "position name (e.g., top, bottom, left, right, top-left, ...). section_id = " + "MDX section identifier (e.g., 03-1). multiple sections per zone = comma-separated. " + "multiple flags: --override-section-assignment top=03-1 " + "--override-section-assignment bottom=03-2,03-3" + ), + ) args = parser.parse_args() overrides_frames: dict[str, str] = {} @@ -3315,10 +3594,59 @@ if __name__ == "__main__": sys.exit(2) overrides_geoms[zid.strip()] = {"x": x, "y": y, "w": w, "h": h} + # IMP-06 — parse --override-section-assignment into dict[str, list[str]]. + # Hard errors per Codex #2/#3 lock : missing `=` / empty ZONE_ID / empty section + # list / duplicate ZONE_ID / duplicate section across zones (parse-time). + overrides_section_assignments: dict[str, list[str]] = {} + _seen_sections_across_zones: dict[str, str] = {} # section_id -> zone_id (first seen) + for ov in args.override_section_assignments: + if "=" not in ov: + print( + f"[error] --override-section-assignment must be ZONE_ID=section_id[,section_id], " + f"got: '{ov}'", + file=sys.stderr, + ) + sys.exit(2) + zid, vals = ov.split("=", 1) + zid = zid.strip() + if not zid: + print( + f"[error] --override-section-assignment ZONE_ID must be non-empty, got: '{ov}'", + file=sys.stderr, + ) + sys.exit(2) + section_ids = [s.strip() for s in vals.split(",") if s.strip()] + if not section_ids: + print( + f"[error] --override-section-assignment section list must be non-empty, " + f"got: '{ov}'", + file=sys.stderr, + ) + sys.exit(2) + if zid in overrides_section_assignments: + print( + f"[error] --override-section-assignment duplicate ZONE_ID '{zid}' " + f"(first assignment kept). Provide each zone only once.", + file=sys.stderr, + ) + sys.exit(2) + for sid in section_ids: + if sid in _seen_sections_across_zones: + print( + f"[error] --override-section-assignment section '{sid}' appears in " + f"multiple zones ('{_seen_sections_across_zones[sid]}' and '{zid}'). " + "A section may be assigned to at most one zone.", + file=sys.stderr, + ) + sys.exit(2) + _seen_sections_across_zones[sid] = zid + overrides_section_assignments[zid] = section_ids + run_phase_z2_mvp1( args.mdx_path, args.run_id, override_layout=args.override_layout, override_frames=overrides_frames or None, override_zone_geometries=overrides_geoms or None, + override_section_assignments=overrides_section_assignments or None, ) diff --git a/tests/test_phase_z2_section_assignment_override.py b/tests/test_phase_z2_section_assignment_override.py new file mode 100644 index 0000000..949ecb0 --- /dev/null +++ b/tests/test_phase_z2_section_assignment_override.py @@ -0,0 +1,285 @@ +"""IMP-06 zone-section assignment override — helper unit tests (synthetic). + +Lock per Claude #6 §4 L13 + Codex #2 R3 6 cases + 자체 catch 7-10 : + 9 cases covering parse / helper assignment / collision / template ladder. + +Fully synthetic per Codex #7 generalization guardrail (MOCK_ prefix). +NO real catalog template_id / frame_id, NO `v4_full32_result.yaml` dependency. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Optional + +import pytest + +from src.phase_z2_pipeline import _build_position_assignment_plan + + +# ─── Synthetic fixtures ────────────────────────────────────────── + + +@dataclass +class _FakeUnit: + """Synthetic CompositionUnit stand-in. Only fields the helper reads.""" + source_section_ids: list[str] + template_id: Optional[str] = None + frame_template_id: Optional[str] = None + + +@dataclass +class _FakeSection: + """Synthetic MdxSection stand-in. Only fields the helper reads.""" + section_id: str + raw_content: str = "- item A\n- item B\n" + + +# ─── Case 1 : single override + non-conflicting auto retain ───────────── + + +def test_single_zone_override_retains_non_conflicting_auto(): + """Claude #6 L13 case 4 — single override on `top`, auto units do not overlap. + Expected: top = override unit; bottom = auto unit retained. + """ + units = [ + _FakeUnit(source_section_ids=["MOCK_S1"], frame_template_id="MOCK_T_auto_top"), + _FakeUnit(source_section_ids=["MOCK_S2"], frame_template_id="MOCK_T_auto_bottom"), + ] + positions = ["top", "bottom"] + overrides = {"top": ["MOCK_S3"]} + sections_by_id = {"MOCK_S3": _FakeSection("MOCK_S3")} + override_frames = {"MOCK_S3": "MOCK_T_for_S3"} # ladder step 1 + + plan, summary = _build_position_assignment_plan( + units=units, positions=positions, + override_section_assignments=overrides, + sections_by_id=sections_by_id, + override_frames=override_frames, + ) + + by_pos = {p["position"]: p for p in plan} + assert by_pos["top"]["assignment_source"] == "cli_override" + assert by_pos["top"]["source_section_ids"] == ["MOCK_S3"] + assert by_pos["top"]["template_id"] == "MOCK_T_for_S3" + assert by_pos["bottom"]["assignment_source"] == "auto" + assert by_pos["bottom"]["source_section_ids"] == ["MOCK_S2"] + assert summary["applied_count"] == 1 + assert summary["skipped_count"] == 0 + + +# ─── Case 2 : collision — override wins, auto whole-skipped ────────────── + + +def test_override_collision_whole_skip_no_split_uncovered_traced(): + """Codex #2 R1 example : override section overlaps an auto merged unit. + Expected: override wins; auto [MOCK_S1, MOCK_S2] skipped whole (no split); + MOCK_S2 reported as uncovered. + """ + auto_merged = _FakeUnit( + source_section_ids=["MOCK_S1", "MOCK_S2"], + frame_template_id="MOCK_T_merged", + ) + auto_solo = _FakeUnit(source_section_ids=["MOCK_S3"], frame_template_id="MOCK_T_solo") + units = [auto_merged, auto_solo] + positions = ["top", "bottom"] + overrides = {"top": ["MOCK_S1"]} + sections_by_id = {sid: _FakeSection(sid) for sid in ["MOCK_S1", "MOCK_S2", "MOCK_S3"]} + override_frames = {"MOCK_S1": "MOCK_T_override_S1"} + + plan, summary = _build_position_assignment_plan( + units=units, positions=positions, + override_section_assignments=overrides, + sections_by_id=sections_by_id, + override_frames=override_frames, + ) + + by_pos = {p["position"]: p for p in plan} + # top : override wins; auto_merged that sat at top is replaced. + assert by_pos["top"]["assignment_source"] == "cli_override" + assert by_pos["top"]["source_section_ids"] == ["MOCK_S1"] + # No split : MOCK_S2 (the other half of the auto merged unit) is NOT + # re-extracted into the replaced position; instead it surfaces as uncovered. + assert by_pos["top"]["previous_source_section_ids"] == ["MOCK_S1", "MOCK_S2"] + assert by_pos["top"]["uncovered_section_ids"] == ["MOCK_S2"] + # bottom : non-overlapping auto_solo is retained (no collision). + assert by_pos["bottom"]["assignment_source"] == "auto" + assert by_pos["bottom"]["source_section_ids"] == ["MOCK_S3"] + # Summary aggregates MOCK_S2 as the global uncovered section. + assert summary["uncovered_section_ids"] == ["MOCK_S2"] + + +# ─── Case 3 : template ladder step 1 (override_frames wins) ───────────── + + +def test_template_resolution_ladder_step1_override_frame_wins(): + """Codex #4 T1 ladder step 1 : --override-frame exact unit_id wins.""" + units = [_FakeUnit(source_section_ids=["MOCK_S1"], frame_template_id="MOCK_T_auto")] + positions = ["top"] + overrides = {"top": ["MOCK_S1"]} + sections_by_id = {"MOCK_S1": _FakeSection("MOCK_S1")} + override_frames = {"MOCK_S1": "MOCK_T_explicit_override"} + + plan, _ = _build_position_assignment_plan( + units=units, positions=positions, + override_section_assignments=overrides, + sections_by_id=sections_by_id, + override_frames=override_frames, + ) + + assert plan[0]["template_id"] == "MOCK_T_explicit_override" + assert plan[0]["skipped_reason"] is None + + +# ─── Case 4 : template ladder step 2 (exact auto unit reuse) ──────────── + + +def test_template_resolution_ladder_step2_exact_auto_reuse(): + """Codex #4 T1 ladder step 2 : no override_frame; exact existing auto unit -> reuse.""" + units = [_FakeUnit(source_section_ids=["MOCK_S1", "MOCK_S2"], frame_template_id="MOCK_T_auto_merged")] + positions = ["top"] + overrides = {"top": ["MOCK_S1", "MOCK_S2"]} + sections_by_id = {sid: _FakeSection(sid) for sid in ["MOCK_S1", "MOCK_S2"]} + + plan, _ = _build_position_assignment_plan( + units=units, positions=positions, + override_section_assignments=overrides, + sections_by_id=sections_by_id, + override_frames=None, # no explicit frame override + ) + + # ladder step 2 hits : exact auto unit [MOCK_S1, MOCK_S2] reuse + assert plan[0]["template_id"] == "MOCK_T_auto_merged" + assert plan[0]["skipped_reason"] is None + + +# ─── Case 5 : template ladder step 4 (ad-hoc multi-section fail) ───────── + + +def test_template_resolution_ladder_step4_ad_hoc_multi_section_fail(): + """Codex #4 Additional lock : ad-hoc multi-section override without exact auto + + without explicit --override-frame -> skipped_reason = 'ad_hoc_merged_no_template'. + """ + units = [ + _FakeUnit(source_section_ids=["MOCK_S1"], frame_template_id="MOCK_T_a"), + _FakeUnit(source_section_ids=["MOCK_S2"], frame_template_id="MOCK_T_b"), + ] + positions = ["top"] + overrides = {"top": ["MOCK_S1", "MOCK_S2"]} # ad-hoc merge, no exact auto + sections_by_id = {sid: _FakeSection(sid) for sid in ["MOCK_S1", "MOCK_S2"]} + + plan, _ = _build_position_assignment_plan( + units=units, positions=positions, + override_section_assignments=overrides, + sections_by_id=sections_by_id, + override_frames=None, + ) + + assert plan[0]["template_id"] is None + assert plan[0]["skipped_reason"] == "ad_hoc_merged_no_template" + + +# ─── Case 6 : unit_id naming convention ───────────────────────────────── + + +def test_unit_id_naming_convention_consistent_for_auto_and_override(): + """Codex T2 + Claude #4 catch 8 : unit_id = '+'.join(source_section_ids).""" + units = [ + _FakeUnit(source_section_ids=["MOCK_S1", "MOCK_S2"], frame_template_id="MOCK_T_merged"), + ] + positions = ["top", "bottom"] + overrides = {"bottom": ["MOCK_S3"]} + sections_by_id = {"MOCK_S3": _FakeSection("MOCK_S3")} + override_frames = {"MOCK_S3": "MOCK_T_S3"} + + plan, _ = _build_position_assignment_plan( + units=units, positions=positions, + override_section_assignments=overrides, + sections_by_id=sections_by_id, + override_frames=override_frames, + ) + + by_pos = {p["position"]: p for p in plan} + # auto merged unit + assert by_pos["top"]["unit_id"] == "MOCK_S1+MOCK_S2" + # single-section override + assert by_pos["bottom"]["unit_id"] == "MOCK_S3" + + +# ─── Case 7 : previous_source_section_ids semantics (position history) ── + + +def test_previous_source_section_ids_records_same_position_auto_history(): + """Codex T3 + Claude #4 catch 9 : previous_source_section_ids = the auto + assignment that occupied the SAME position before override applied. + """ + units = [ + _FakeUnit(source_section_ids=["MOCK_S1"], frame_template_id="MOCK_T_a"), + _FakeUnit(source_section_ids=["MOCK_S2"], frame_template_id="MOCK_T_b"), + ] + positions = ["top", "bottom"] + overrides = {"top": ["MOCK_S3"]} + sections_by_id = {"MOCK_S3": _FakeSection("MOCK_S3")} + override_frames = {"MOCK_S3": "MOCK_T_S3"} + + plan, _ = _build_position_assignment_plan( + units=units, positions=positions, + override_section_assignments=overrides, + sections_by_id=sections_by_id, + override_frames=override_frames, + ) + + by_pos = {p["position"]: p for p in plan} + # top : auto WAS MOCK_S1 before override + assert by_pos["top"]["previous_source_section_ids"] == ["MOCK_S1"] + + +# ─── Case 8 : empty position when no auto unit available ─────────────── + + +def test_position_with_no_auto_unit_marked_empty(): + """When `units` has fewer entries than `positions`, extra positions = empty.""" + units = [_FakeUnit(source_section_ids=["MOCK_S1"], frame_template_id="MOCK_T_a")] + positions = ["top", "bottom"] # bottom has no auto unit + sections_by_id = {"MOCK_S1": _FakeSection("MOCK_S1")} + + plan, summary = _build_position_assignment_plan( + units=units, positions=positions, + override_section_assignments=None, + sections_by_id=sections_by_id, + override_frames=None, + ) + + by_pos = {p["position"]: p for p in plan} + assert by_pos["bottom"]["assignment_source"] == "empty" + assert by_pos["bottom"]["skipped_reason"] == "no_auto_unit_available" + assert summary["applied_count"] == 0 + + +# ─── Case 9 : summary aggregation invariants ─────────────────────────── + + +def test_summary_aggregation_counts_applied_skipped_uncovered(): + """Claude #6 L12 single source of truth : summary derives from plan.""" + auto_merged = _FakeUnit( + source_section_ids=["MOCK_S1", "MOCK_S2"], + frame_template_id="MOCK_T_merged", + ) + units = [auto_merged] + positions = ["top", "bottom"] + # Two overrides : top=MOCK_S1 (causes collision with auto_merged at bottom) + overrides = {"top": ["MOCK_S1"]} + sections_by_id = {sid: _FakeSection(sid) for sid in ["MOCK_S1", "MOCK_S2"]} + override_frames = {"MOCK_S1": "MOCK_T_override_S1"} + + plan, summary = _build_position_assignment_plan( + units=units, positions=positions, + override_section_assignments=overrides, + sections_by_id=sections_by_id, + override_frames=override_frames, + ) + + # Summary derives from plan : 1 applied, 1 collision skip, 1 uncovered. + assert summary["applied_count"] == 1 + assert summary["skipped_count"] >= 1 # collision skip + assert summary["uncovered_section_ids"] == ["MOCK_S2"] + assert summary["section_assignment_overrides_applied"][0]["position"] == "top"