From b81e564f65b9bad1d4a2bbdcc5cfcd82ab039c6c Mon Sep 17 00:00:00 2001 From: kyeongmin Date: Thu, 14 May 2026 06:10:43 +0900 Subject: [PATCH] =?UTF-8?q?feat(IMP-06):=20Stage=204=20part=201=20?= =?UTF-8?q?=E2=80=94=20replaced=5Fauto=5Funit=20field=20+=20comment=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #6 Stage 4 split per Codex #10 acceptance: this commit lands the schema + trace refinements required before the render-path rewiring. The actual units/zones_data/Step 9/Step 20 plan-driven materialization remains in Stage 4 part 2 (follow-up commit) so each commit is reviewable on its own and regression-safe. - _build_position_assignment_plan: add replaced_auto_unit field. Populated only when the explicitly overridden position already held an auto unit AND that auto unit had different source_section_ids than the override. Documents a same-position override replacement as a distinct audit fact, separate from skipped_collided_auto_units which captures cross-position whole-skips per the locked collision policy. - Backfill replaced_auto_unit = None on the empty/collision/auto branches for schema-stable consumers. - Update the override-application comment near the helper invocation so it no longer claims the helper "reorders units"; Stage 4 part 2 will be the commit that wires the plan into the actual render path. - Helper unit tests: assert replaced_auto_unit shape in the collision scenario and add a dedicated case that distinguishes same-sections (template swap via --override-frame -> None) from different-sections same-position replacement (populated, reason="same_position_override_replacement"). No AI, no calculate_fit, no full planner rerun, no frontend, no sample hardcoding. plan_composition() signature preserved. helper remains pure. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/phase_z2_pipeline.py | 25 +++++++++-- ...st_phase_z2_section_assignment_override.py | 42 +++++++++++++++++++ 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/src/phase_z2_pipeline.py b/src/phase_z2_pipeline.py index a9e9fc0..f8062a5 100644 --- a/src/phase_z2_pipeline.py +++ b/src/phase_z2_pipeline.py @@ -954,6 +954,18 @@ def _build_position_assignment_plan( 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) + # IMP-06 Stage 4 (Codex #9 R1 + Claude #9 Catch L + Codex #10) — replaced_auto_unit + # populated only when the same position previously had an auto unit and that + # auto unit was different from the requested override. Documents "this auto + # unit was removed from this position to apply the override" as a distinct + # audit fact (vs skipped_collided_auto_units which is cross-position skip). + replaced_auto_unit = None + if auto_unit is not None and list(auto_unit.source_section_ids) != list(sids): + replaced_auto_unit = { + "unit_id": _unit_id(list(auto_unit.source_section_ids)), + "source_section_ids": list(auto_unit.source_section_ids), + "reason": "same_position_override_replacement", + } plan.append({ "position": pos, "assignment_source": "cli_override", @@ -967,6 +979,7 @@ def _build_position_assignment_plan( "zone_id": pos, "requested_section_ids": list(sids), }, + "replaced_auto_unit": replaced_auto_unit, "skipped_collided_auto_units": [], "uncovered_section_ids": uncovered_from_previous, "skipped_reason": skipped_reason, @@ -984,6 +997,7 @@ def _build_position_assignment_plan( "template_id": None, "previous_source_section_ids": [], "section_assignment_override": None, + "replaced_auto_unit": None, "skipped_collided_auto_units": [], "uncovered_section_ids": [], "skipped_reason": "no_auto_unit_available", @@ -1008,6 +1022,7 @@ def _build_position_assignment_plan( "template_id": None, "previous_source_section_ids": list(auto_unit.source_section_ids), "section_assignment_override": None, + "replaced_auto_unit": None, "skipped_collided_auto_units": [{ "unit_id": unit_id_str, "source_section_ids": list(auto_unit.source_section_ids), @@ -1029,6 +1044,7 @@ def _build_position_assignment_plan( ), "previous_source_section_ids": list(auto_unit.source_section_ids), "section_assignment_override": None, + "replaced_auto_unit": None, "skipped_collided_auto_units": [], "uncovered_section_ids": [], "skipped_reason": None, @@ -2042,11 +2058,12 @@ 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. + # IMP-06 (#6 / Codex #6/#7/#10 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. + # 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). section_assignment_plan: Optional[list[dict]] = None section_assignment_summary: Optional[dict] = None if override_section_assignments and layout_preset is not None: diff --git a/tests/test_phase_z2_section_assignment_override.py b/tests/test_phase_z2_section_assignment_override.py index 949ecb0..652f9f8 100644 --- a/tests/test_phase_z2_section_assignment_override.py +++ b/tests/test_phase_z2_section_assignment_override.py @@ -101,9 +101,15 @@ def test_override_collision_whole_skip_no_split_uncovered_traced(): # 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"] + # IMP-06 Stage 4 (Codex #10): replaced_auto_unit populated for same-position + # override replacement (previous auto unit had different sections). + assert by_pos["top"]["replaced_auto_unit"] is not None + assert by_pos["top"]["replaced_auto_unit"]["unit_id"] == "MOCK_S1+MOCK_S2" + assert by_pos["top"]["replaced_auto_unit"]["reason"] == "same_position_override_replacement" # 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"] + assert by_pos["bottom"]["replaced_auto_unit"] is None # Summary aggregates MOCK_S2 as the global uncovered section. assert summary["uncovered_section_ids"] == ["MOCK_S2"] @@ -255,6 +261,42 @@ def test_position_with_no_auto_unit_marked_empty(): assert summary["applied_count"] == 0 +# ─── Case 9b : replaced_auto_unit distinguishes same-sections vs different ── + + +def test_replaced_auto_unit_only_when_previous_auto_differs_from_override(): + """Codex #10 R1 + Claude #10 Catch L : replaced_auto_unit populated only when + the same position previously had an auto unit AND that auto unit had + different sections than the override. Same sections (override just swaps + template via --override-frame) yields replaced_auto_unit = None. + """ + # Case A : same sections → replaced_auto_unit None (just template swap) + units_a = [_FakeUnit(source_section_ids=["MOCK_S1"], frame_template_id="MOCK_T_auto")] + plan_a, _ = _build_position_assignment_plan( + units=units_a, + positions=["top"], + override_section_assignments={"top": ["MOCK_S1"]}, + sections_by_id={"MOCK_S1": _FakeSection("MOCK_S1")}, + override_frames={"MOCK_S1": "MOCK_T_explicit_swap"}, + ) + assert plan_a[0]["replaced_auto_unit"] is None + assert plan_a[0]["template_id"] == "MOCK_T_explicit_swap" + + # Case B : different sections → replaced_auto_unit populated + units_b = [_FakeUnit(source_section_ids=["MOCK_S1"], frame_template_id="MOCK_T_auto")] + plan_b, _ = _build_position_assignment_plan( + units=units_b, + positions=["top"], + override_section_assignments={"top": ["MOCK_S2"]}, + sections_by_id={"MOCK_S2": _FakeSection("MOCK_S2")}, + override_frames={"MOCK_S2": "MOCK_T_for_S2"}, + ) + assert plan_b[0]["replaced_auto_unit"] is not None + assert plan_b[0]["replaced_auto_unit"]["unit_id"] == "MOCK_S1" + assert plan_b[0]["replaced_auto_unit"]["source_section_ids"] == ["MOCK_S1"] + assert plan_b[0]["replaced_auto_unit"]["reason"] == "same_position_override_replacement" + + # ─── Case 9 : summary aggregation invariants ───────────────────────────