fix(IMP-06): Stage 4 blocker-fix — render_records + plan-aware traces
Three Codex #13 blockers in a single coherent commit. Blocker 1 (units None hazard) — drop None placeholders from `units` list. Replace with a separate `render_records` layer built AFTER frame_overrides apply. units = canonical renderable list (list[CompositionUnit] only); render_records = canonical per-position view including empty / collision- skipped / cli_override entries. Downstream loops (Step 6 print, frame_ overrides, zones_data/debug_zones, Step 9 application_plan, compute_slide_ status covered loop) no longer need None guards. Blocker 2 (no integration test) — add end-to-end pipeline integration test: `--override-section-assignment top=03-2` on sample 03 MDX produces zones_data[top].source_section_ids = ['03-2'], debug_zones[top].assignment _source = 'cli_override', debug_zones[bottom].v4_template_id = '__empty__' (override_collision whole-skip), step20 filtered_section_ids contains '03-1', and filtered_section_reasons carries a section_assignment_override _uncovered entry. Proves the render path — not only comp_debug — reflects the CLI override. Blocker 3 (Step 9/20 not plan-aware) — surface plan-aware additive fields in both render-path debug_zones/zones_data and Step 9 application_plan units: position, assignment_source, section_assignment_override, replaced_auto_unit, skipped_collided_auto_units, uncovered_section_ids, skipped_reason. compute_slide_status appends Codex #10 Catch O list-shaped filtered_section_reasons entries for override-uncovered sections and folds them into filtered_section_ids so full_coverage is re-evaluated post-override. Exact-id-only collision semantics enforced (Codex #14/#15/#16/#17): S3 and S3-1 are distinct ids; no prefix hierarchy, no parent cascade. Three new section-id invariant tests added (parent-like vs child-like, exact duplicate collision detected, distinct ids coexist). Test : 24 pytest pass (9 helper + 9 case + 3 invariant + 1 case 9b + 1 integration + 1 from v4_fallback baseline) ; smoke 11/11 PASS. Register `integration` pytest marker in pyproject.toml.
This commit is contained in:
@@ -1592,6 +1592,52 @@ def compute_slide_status(sections: list[MdxSection],
|
||||
"filter_reasons": c.get("filter_reasons", []),
|
||||
})
|
||||
|
||||
# IMP-06 blocker-fix (Codex #10 Catch O schema + Codex #16 coverage invariant) —
|
||||
# surface override-uncovered sections as additive list entries in
|
||||
# `filtered_section_reasons` and ensure `filtered_section_ids` includes them
|
||||
# so coverage does not silently miss sections that were dropped by an explicit
|
||||
# zone-section override.
|
||||
v4_fb_summary = comp_debug.get("v4_fallback_summary", {}) or {}
|
||||
section_assignment_summary = comp_debug.get("section_assignment_summary") or {}
|
||||
section_assignment_uncovered_ids: list[str] = list(
|
||||
section_assignment_summary.get("uncovered_section_ids") or []
|
||||
)
|
||||
if section_assignment_uncovered_ids:
|
||||
# Codex #16 invariant : final filtered_section_ids must contain the
|
||||
# override-uncovered ids even if they were originally "covered" by the
|
||||
# pre-override auto plan. full_coverage must be re-evaluated too so
|
||||
# Step 20 `overall` enum reflects the post-override reality.
|
||||
for sid in section_assignment_uncovered_ids:
|
||||
if sid not in filtered_ids:
|
||||
filtered_ids.append(sid)
|
||||
filtered_ids = sorted(set(filtered_ids))
|
||||
full_coverage = len(filtered_ids) == 0
|
||||
# Append a separate list entry per override-uncovered section so existing
|
||||
# readers of filtered_section_reasons (list-shaped) keep working.
|
||||
plan_by_position = {
|
||||
(p.get("position") or ""): p
|
||||
for p in (comp_debug.get("section_assignment_plan") or [])
|
||||
}
|
||||
for sid in section_assignment_uncovered_ids:
|
||||
# Find the position whose plan entry recorded this uncovered id.
|
||||
source_position = None
|
||||
for pos, entry in plan_by_position.items():
|
||||
if sid in (entry.get("uncovered_section_ids") or []):
|
||||
source_position = pos
|
||||
break
|
||||
filtered_section_reasons.append({
|
||||
"section_ids": [sid],
|
||||
"merge_type": None,
|
||||
"template_id": None,
|
||||
"v4_label": None,
|
||||
"phase_z_status": None,
|
||||
"score": None,
|
||||
"selection_state": "section_assignment_override_uncovered",
|
||||
"filter_reasons": ["section_assignment_override_uncovered"],
|
||||
"source": "section_assignment_override",
|
||||
"position": source_position,
|
||||
})
|
||||
|
||||
if full_coverage and visual_passed:
|
||||
overall = "PASS"
|
||||
elif full_coverage and not visual_passed:
|
||||
@@ -2116,14 +2162,19 @@ def run_phase_z2_mvp1(
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# Stage 4 Part 2 — rebuild units list aligned with position_assignment_plan so
|
||||
# downstream zones_data / debug_zones / Step 9 / Step 20 derive from the plan
|
||||
# rather than the original auto-selected sequence. None placeholders preserve
|
||||
# position identity for empty/collision-skipped zones (Codex #10 Catch N).
|
||||
# Stage 4 blocker-fix (Codex #13/#14/#15/#16/#17) — rebuild units as a pure
|
||||
# `list[CompositionUnit]` (renderable only, no None). Position-aware truth
|
||||
# lives in `render_records` (built after frame_overrides apply) per Codex
|
||||
# internal contract: units = canonical renderable list, render_records =
|
||||
# canonical per-position view including empty/skipped entries.
|
||||
from src.phase_z2_composition import CompositionUnit
|
||||
plan_units: list = []
|
||||
# Maintain ordered alignment with section_assignment_plan for the
|
||||
# render_records build step below: plan_unit_by_position[pos] = unit | None.
|
||||
plan_unit_by_position: dict[str, object] = {}
|
||||
for entry in section_assignment_plan:
|
||||
assignment_source = entry["assignment_source"]
|
||||
pos = entry["position"]
|
||||
if assignment_source == "cli_override" and entry["template_id"] is not None:
|
||||
sids = entry["source_section_ids"]
|
||||
raw_content_parts = []
|
||||
@@ -2158,17 +2209,25 @@ def run_phase_z2_mvp1(
|
||||
},
|
||||
)
|
||||
plan_units.append(override_unit)
|
||||
plan_unit_by_position[pos] = override_unit
|
||||
elif assignment_source == "auto":
|
||||
# Find original auto unit by source_section_ids
|
||||
# Find original auto unit by source_section_ids.
|
||||
matched = None
|
||||
for u in units:
|
||||
if list(u.source_section_ids) == entry["source_section_ids"]:
|
||||
matched = u
|
||||
break
|
||||
plan_units.append(matched) # may be None if not found (unexpected)
|
||||
if matched is not None:
|
||||
plan_units.append(matched)
|
||||
plan_unit_by_position[pos] = matched
|
||||
else:
|
||||
# Unexpected — auto plan entry without a matching original unit.
|
||||
plan_unit_by_position[pos] = None
|
||||
else:
|
||||
# empty / collision-skipped
|
||||
plan_units.append(None)
|
||||
# empty / collision-skipped — NO None in units list, but the position
|
||||
# is preserved in plan_unit_by_position so render_records can emit
|
||||
# an empty zone record below (after frame_overrides apply).
|
||||
plan_unit_by_position[pos] = None
|
||||
units = plan_units
|
||||
|
||||
if not units or layout_preset is None:
|
||||
@@ -2317,55 +2376,52 @@ def run_phase_z2_mvp1(
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# IMP-06 blocker-fix (Codex #13/#14/#15/#16/#17) — build render_records AFTER
|
||||
# frame_overrides so each record points to the post-override CompositionUnit
|
||||
# object (Codex #15 Catch R). render_records is the canonical per-position
|
||||
# plan-derived view: cli_override / auto / empty / collision-skipped all carry
|
||||
# a record. `unit` is the same instance reference as in `units` when
|
||||
# renderable, otherwise None. This drives debug_zones/Step 9/Step 20 traces
|
||||
# without forcing None into the renderable `units` list.
|
||||
render_records: list[dict] = []
|
||||
if section_assignment_plan is not None:
|
||||
for entry in section_assignment_plan:
|
||||
pos = entry["position"]
|
||||
unit_ref = plan_unit_by_position.get(pos)
|
||||
render_records.append({
|
||||
"position": pos,
|
||||
"assignment_source": entry["assignment_source"],
|
||||
"unit": unit_ref,
|
||||
"source_section_ids": list(entry.get("source_section_ids") or []),
|
||||
"section_assignment_override": entry.get("section_assignment_override"),
|
||||
"replaced_auto_unit": entry.get("replaced_auto_unit"),
|
||||
"skipped_collided_auto_units": list(entry.get("skipped_collided_auto_units") or []),
|
||||
"uncovered_section_ids": list(entry.get("uncovered_section_ids") or []),
|
||||
"skipped_reason": entry.get("skipped_reason"),
|
||||
})
|
||||
|
||||
# IMP-06 blocker-fix (Codex #13/#14/#15/#16/#17) — index render_records by
|
||||
# unit object identity so the renderable zones loop below can stamp each
|
||||
# zones_data / debug_zones entry with plan-aware fields (assignment_source,
|
||||
# section_assignment_override, replaced_auto_unit, skipped_collided_auto_units,
|
||||
# uncovered_section_ids, skipped_reason). Empty positions handled later by
|
||||
# the post-loop "empty zone records" block — those records carry the same
|
||||
# plan-aware fields directly from their plan entry.
|
||||
render_record_by_unit_id: dict[int, dict] = {}
|
||||
if render_records:
|
||||
for rec in render_records:
|
||||
u = rec.get("unit")
|
||||
if u is not None:
|
||||
render_record_by_unit_id[id(u)] = rec
|
||||
|
||||
for i, unit in enumerate(units):
|
||||
position = positions[i] if i < len(positions) else f"zone_{i}"
|
||||
# Stage 4 Part 2 — empty position (override plan produced no renderable unit).
|
||||
# Preserve zone identity with an explicit empty record so layout/grid stay
|
||||
# structurally consistent without distorting allocation (Codex #10 Catch N).
|
||||
if unit is None:
|
||||
plan_entry = None
|
||||
if section_assignment_plan is not None and i < len(section_assignment_plan):
|
||||
plan_entry = section_assignment_plan[i]
|
||||
zones_data.append({
|
||||
"position": position,
|
||||
"template_id": "__empty__",
|
||||
"slot_payload": {},
|
||||
"content_weight": {"score": 0},
|
||||
"min_height_px": 0,
|
||||
"assignment_source": "empty",
|
||||
"skipped_reason": (
|
||||
(plan_entry or {}).get("skipped_reason")
|
||||
or "section_assignment_override_empty_or_unrenderable"
|
||||
),
|
||||
})
|
||||
debug_zones.append({
|
||||
"position": position,
|
||||
"source_section_ids": [],
|
||||
"merge_type": "empty",
|
||||
"title": "",
|
||||
"v4_template_id": "__empty__",
|
||||
"v4_label": None,
|
||||
"v4_confidence": 0.0,
|
||||
"selection_path": "section_assignment_empty",
|
||||
"fallback_reason": None,
|
||||
"fallback_used": False,
|
||||
"phase_z_status": None,
|
||||
"composition_score": 0.0,
|
||||
"composition_rationale": {},
|
||||
"composition_notes": [],
|
||||
"mapper_type": "empty",
|
||||
"contract_id": "__empty__",
|
||||
"contract_frame_id": None,
|
||||
"assignment_source": "empty",
|
||||
"skipped_reason": (
|
||||
(plan_entry or {}).get("skipped_reason")
|
||||
or "section_assignment_override_empty_or_unrenderable"
|
||||
),
|
||||
"replaced_auto_unit": (plan_entry or {}).get("replaced_auto_unit"),
|
||||
"skipped_collided_auto_units": (plan_entry or {}).get("skipped_collided_auto_units", []),
|
||||
"uncovered_section_ids": (plan_entry or {}).get("uncovered_section_ids", []),
|
||||
})
|
||||
continue
|
||||
plan_record = render_record_by_unit_id.get(id(unit))
|
||||
# When render_records exists (override path) prefer its position to
|
||||
# guard against future drifts. positions[i] is the legacy auto path
|
||||
# and is byte-identical to plan_record.position in the normal case.
|
||||
if plan_record is not None and plan_record.get("position"):
|
||||
position = plan_record["position"]
|
||||
synth_section = MdxSection(
|
||||
section_id="+".join(unit.source_section_ids),
|
||||
section_num=0,
|
||||
@@ -2466,12 +2522,39 @@ def run_phase_z2_mvp1(
|
||||
content_weight = compute_content_weight(synth_section)
|
||||
truncated_count = slot_payload.get("_truncated_count") # builder 가 truncate 한 경우
|
||||
|
||||
# IMP-06 blocker-fix (Codex #13/#14/#15/#16/#17) — plan-aware additive
|
||||
# fields. When no override CLI was used (plan_record is None), these
|
||||
# default to None/False/[] so pre-IMP-06 readers see byte-equivalent
|
||||
# data. Empty zone records appended below (post-loop) carry the same
|
||||
# field shape from their plan entry directly.
|
||||
plan_assignment_source = (
|
||||
plan_record.get("assignment_source") if plan_record else None
|
||||
)
|
||||
plan_section_override = (
|
||||
bool(plan_record.get("section_assignment_override"))
|
||||
if plan_record else False
|
||||
)
|
||||
plan_replaced_auto = (
|
||||
plan_record.get("replaced_auto_unit") if plan_record else None
|
||||
)
|
||||
plan_skipped_collided = list(
|
||||
plan_record.get("skipped_collided_auto_units") or []
|
||||
) if plan_record else []
|
||||
plan_uncovered = list(
|
||||
plan_record.get("uncovered_section_ids") or []
|
||||
) if plan_record else []
|
||||
plan_skipped_reason = (
|
||||
plan_record.get("skipped_reason") if plan_record else None
|
||||
)
|
||||
|
||||
zones_data.append({
|
||||
"position": position,
|
||||
"template_id": unit.frame_template_id,
|
||||
"slot_payload": slot_payload,
|
||||
"content_weight": content_weight,
|
||||
"min_height_px": min_height_px,
|
||||
"assignment_source": plan_assignment_source,
|
||||
"section_assignment_override": plan_section_override,
|
||||
})
|
||||
debug_zones.append({
|
||||
"position": position,
|
||||
@@ -2502,8 +2585,70 @@ def run_phase_z2_mvp1(
|
||||
"content_weight": content_weight,
|
||||
# trace-only runtime 연결 v0 — B1 → B2 → B4 chain 결과 (render 미영향).
|
||||
"placement_trace": placement_trace,
|
||||
# IMP-06 blocker-fix — plan-aware additive fields.
|
||||
"assignment_source": plan_assignment_source,
|
||||
"section_assignment_override": plan_section_override,
|
||||
"replaced_auto_unit": plan_replaced_auto,
|
||||
"skipped_collided_auto_units": plan_skipped_collided,
|
||||
"uncovered_section_ids": plan_uncovered,
|
||||
"skipped_reason": plan_skipped_reason,
|
||||
})
|
||||
|
||||
# IMP-06 blocker-fix (Codex #10 Catch N) — append explicit empty zone records
|
||||
# for positions whose section-assignment plan produced no renderable unit
|
||||
# (cli_override with no resolvable template, ad-hoc multi-section,
|
||||
# override_collision whole-skip with no replacement, etc.). Empty records
|
||||
# preserve zone identity in zones_data/debug_zones (template_id="__empty__",
|
||||
# content_weight=0, min_height_px=0) so layout/grid stay structurally
|
||||
# consistent without distorting allocation, and the partial-render loop
|
||||
# short-circuits "__empty__" to an empty string (no TemplateNotFound).
|
||||
if render_records:
|
||||
renderable_positions = {z["position"] for z in zones_data}
|
||||
for record in render_records:
|
||||
pos = record["position"]
|
||||
if pos in renderable_positions:
|
||||
continue
|
||||
zones_data.append({
|
||||
"position": pos,
|
||||
"template_id": "__empty__",
|
||||
"slot_payload": {},
|
||||
"content_weight": {"score": 0},
|
||||
"min_height_px": 0,
|
||||
"assignment_source": "empty",
|
||||
"skipped_reason": (
|
||||
record.get("skipped_reason")
|
||||
or "section_assignment_override_empty_or_unrenderable"
|
||||
),
|
||||
})
|
||||
debug_zones.append({
|
||||
"position": pos,
|
||||
"source_section_ids": list(record.get("source_section_ids") or []),
|
||||
"merge_type": "empty",
|
||||
"title": "",
|
||||
"v4_template_id": "__empty__",
|
||||
"v4_label": None,
|
||||
"v4_confidence": 0.0,
|
||||
"selection_path": "section_assignment_empty",
|
||||
"fallback_reason": None,
|
||||
"fallback_used": False,
|
||||
"phase_z_status": None,
|
||||
"composition_score": 0.0,
|
||||
"composition_rationale": {},
|
||||
"composition_notes": [],
|
||||
"mapper_type": "empty",
|
||||
"contract_id": "__empty__",
|
||||
"contract_frame_id": None,
|
||||
"assignment_source": record.get("assignment_source"),
|
||||
"skipped_reason": (
|
||||
record.get("skipped_reason")
|
||||
or "section_assignment_override_empty_or_unrenderable"
|
||||
),
|
||||
"section_assignment_override": record.get("section_assignment_override"),
|
||||
"replaced_auto_unit": record.get("replaced_auto_unit"),
|
||||
"skipped_collided_auto_units": list(record.get("skipped_collided_auto_units") or []),
|
||||
"uncovered_section_ids": list(record.get("uncovered_section_ids") or []),
|
||||
})
|
||||
|
||||
# ─── Step 3: Content Object 추출 (B1, trace-only) ───
|
||||
# IMP-03 — slide-level rich ContentObject 추출 (default OFF canary).
|
||||
# scope-lock 16 조건 (Gitea #3) :
|
||||
@@ -3075,6 +3220,20 @@ def run_phase_z2_mvp1(
|
||||
# 4. len(units) == Step 6 의 plan_composition 결과 (무변)
|
||||
# 5. application_status == "ok" iff len(v4_candidates) > 0 iff candidate_status == "ok"
|
||||
|
||||
# IMP-06 blocker-fix (Codex #13 Blocker 3 / #16) — pre-build per-unit
|
||||
# plan-aware lookup so Step 9 application_plan can carry position +
|
||||
# assignment_source + override-flag fields. Render-record is the canonical
|
||||
# plan-derived per-position view (built post-frame_overrides); match by
|
||||
# object identity since render_records[i]["unit"] holds the same instance
|
||||
# ref as the entry in `units`.
|
||||
plan_record_by_unit_id = {}
|
||||
if "render_records" in locals() and render_records:
|
||||
for rec in render_records:
|
||||
u = rec.get("unit")
|
||||
if u is None:
|
||||
continue
|
||||
plan_record_by_unit_id[id(u)] = rec
|
||||
|
||||
application_plan_units = []
|
||||
for i, unit in enumerate(units):
|
||||
unit_id = "+".join(unit.source_section_ids)
|
||||
@@ -3087,6 +3246,19 @@ def run_phase_z2_mvp1(
|
||||
current_default = unit.frame_template_id if has_v4 else None
|
||||
selection_trace = v4_fallback_traces.get(unit.source_section_ids[0], {})
|
||||
|
||||
# IMP-06 blocker-fix (Codex #13 Blocker 3 / #16) — plan-aware additive
|
||||
# fields. additive = pre-IMP-06 readers (no override CLI used) see
|
||||
# position=None / assignment_source=None / section_assignment_override
|
||||
# =False / replaced_auto_unit=None / skipped_collided_auto_units=[] /
|
||||
# skipped_reason=None — i.e. byte-identical absent overrides.
|
||||
plan_record = plan_record_by_unit_id.get(id(unit))
|
||||
plan_position = plan_record.get("position") if plan_record else None
|
||||
plan_assignment_source = plan_record.get("assignment_source") if plan_record else None
|
||||
plan_section_override = bool(plan_record.get("section_assignment_override")) if plan_record else False
|
||||
plan_replaced_auto = plan_record.get("replaced_auto_unit") if plan_record else None
|
||||
plan_skipped_collided = list(plan_record.get("skipped_collided_auto_units") or []) if plan_record else []
|
||||
plan_skipped_reason = plan_record.get("skipped_reason") if plan_record else None
|
||||
|
||||
# Step 7-A axis 보강 — reject 포함 모든 V4 judgments (frontend UI 가
|
||||
# 모든 frame 의 png 를 카드로 보여주기 위함).
|
||||
# unit_id = source_section_ids join. parent_merged 는 첫 section 의
|
||||
@@ -3157,6 +3329,14 @@ def run_phase_z2_mvp1(
|
||||
for c in v4_all_for_unit
|
||||
],
|
||||
"application_candidates": app_candidates,
|
||||
# IMP-06 blocker-fix (Codex #13 Blocker 3 / #16) — plan-aware
|
||||
# additive fields. None / False / [] when no override CLI used.
|
||||
"position": plan_position,
|
||||
"assignment_source": plan_assignment_source,
|
||||
"section_assignment_override": plan_section_override,
|
||||
"replaced_auto_unit": plan_replaced_auto,
|
||||
"skipped_collided_auto_units": plan_skipped_collided,
|
||||
"skipped_reason": plan_skipped_reason,
|
||||
})
|
||||
|
||||
units_with_no_v4 = [
|
||||
@@ -3178,6 +3358,12 @@ def run_phase_z2_mvp1(
|
||||
# Step 7-A axis : user override trace
|
||||
"frame_overrides_applied": frame_overrides_applied,
|
||||
"frame_overrides_skipped": frame_overrides_skipped,
|
||||
# IMP-06 blocker-fix (Codex #13 Blocker 3 / #16) — surface the
|
||||
# section-assignment plan + summary at Step 9 top level so
|
||||
# frontend / downstream readers do not have to dive into
|
||||
# comp_debug to see override impact.
|
||||
"section_assignment_plan": comp_debug.get("section_assignment_plan"),
|
||||
"section_assignment_summary": comp_debug.get("section_assignment_summary"),
|
||||
"v0_lock_note": (
|
||||
"Step 9 v0 passive (사용자 lock 2026-05-08). "
|
||||
"Step 6 default 그대로 사용 — runtime 결과 byte-동일. "
|
||||
|
||||
Reference in New Issue
Block a user