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:
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user