feat(IMP-06): zone-section assignment override CLI + plan helper (trace-only)

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 01:51:20 +09:00
parent 23d1b25144
commit d596fabde0
2 changed files with 613 additions and 0 deletions

View File

@@ -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,
)

View File

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