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