diff --git a/src/phase_z2_pipeline.py b/src/phase_z2_pipeline.py index 42233a1..a4e9c20 100644 --- a/src/phase_z2_pipeline.py +++ b/src/phase_z2_pipeline.py @@ -2826,6 +2826,176 @@ def write_debug_json(run_dir: Path, layout_preset: str, return debug_path +# ─── Step 9 application-plan helpers (IMP-32 u1) ─────────────── + +def _application_candidates_for_unit(unit) -> list[dict]: + """Step 9 (IMP-32 u1) — application candidate dicts from unit.v4_candidates. + + Pure extraction of inline block at src/phase_z2_pipeline.py:4487-4501. + Behavior preserved: key set/order, APPLICATION_MODE_BY_V4_LABEL lookup, + required_changes placeholder = [] (v0 = trace-only). + """ + app_candidates = [] + for c in unit.v4_candidates: + mode, auto_app, delegated = APPLICATION_MODE_BY_V4_LABEL.get( + c.label, ("exclude", False, None) + ) + app_candidates.append({ + "template_id": c.template_id, + "frame_id": c.frame_id, + "v4_label": c.label, + "application_mode": mode, + "auto_applicable": auto_app, + "required_changes": [], # v0 = trace-only + "delegated_to": delegated, + }) + return app_candidates + + +def _v4_all_judgments_for_unit(v4_all_for_unit) -> list[dict]: + """Step 9 (IMP-32 u2) — V4 all-judgment dicts (reject 포함) for a unit. + + Pure extraction of inline block at src/phase_z2_pipeline.py:4529-4545 + (post-u1 line numbers). IMP-11 D-2 markers preserved in this helper: + single `_contract = get_contract(c.template_id)` bind, `catalog_registered` + boolean, and `min_height_px` chain `(_contract or {}).get("visual_hints", {}).get("min_height_px")`. + Key set/order unchanged: template_id, frame_id, frame_number, v4_rank, + confidence, label, catalog_registered, min_height_px. + """ + # IMP-11 D-2 (u1) — per-candidate min_height_px source = catalog + # frame_contracts[template_id].visual_hints.min_height_px (logical 1280×720 px). + # None when contract unregistered (frontend tolerates undefined). + # Single get_contract lookup binds both catalog_registered and min_height_px. + v4_all_judgments_list = [] + for c in v4_all_for_unit: + _contract = get_contract(c.template_id) + v4_all_judgments_list.append({ + "template_id": c.template_id, + "frame_id": c.frame_id, + "frame_number": c.frame_number, + "v4_rank": c.v4_rank, + "confidence": c.confidence, + "label": c.label, + "catalog_registered": _contract is not None, + "min_height_px": (_contract or {}).get("visual_hints", {}).get("min_height_px"), + }) + return v4_all_judgments_list + + +def _build_application_plan_unit( + unit, + zone_plan, + selection_trace, + plan_record, + v4_all_for_unit, + layout_preset, + layout_candidates_list, +) -> dict: + """Step 9 (IMP-32 u3) — per-unit application_plan dict assembly. + + Pure extraction of the inline `application_plan_units.append({...})` block + currently at src/phase_z2_pipeline.py:4577-4623 (post-u1/u2 line numbers). + Byte-identical output (key set + key order + value identity) when called + with the same per-unit inputs: + + - unit : Step 6 unit (source_section_ids, v4_candidates, + v4_rank, selection_path, fallback_reason, + frame_template_id). + - zone_plan : Step 8 per-unit zone_plan dict (region_layout_ + candidates, display_strategy_candidates). + - selection_trace : v4_fallback_traces[unit.source_section_ids[0]] + (candidates list for candidate_evidence / + fallback_chain compat alias). + - plan_record : plan_record_by_unit_id[id(unit)] or None + (IMP-06 plan-aware additive fields). + - v4_all_for_unit : lookup_v4_all_judgments(...) result (Step 7-A + axis trace — reject 포함 모든 V4 후보). + - layout_preset : Step 7 preset name (e.g., "Type A"). + - layout_candidates_list : Step 7 candidate list. + + Per-index/per-id lookups (zone_region_plans[i], v4_fallback_traces.get(...), + plan_record_by_unit_id.get(id(unit)), section_alias_by_id, lookup_v4_all_ + judgments(...)) stay at the call-site (u4). + + Invariants preserved: + - candidate_evidence = selection_trace.get("candidates", []) — primary field. + - fallback_chain = same list — compat alias for pre-IMP-05 readers. + - v4_candidates list comprehension fields + order unchanged. + - IMP-06 additive plan fields (position / assignment_source / section_ + assignment_override / replaced_auto_unit / skipped_collided_auto_units / + skipped_reason) — None / False / [] when no override CLI used. + """ + unit_id = "+".join(unit.source_section_ids) + + has_v4 = bool(unit.v4_candidates) + candidate_status = "ok" if has_v4 else "no_non_reject_v4_candidate" + application_status = "ok" if has_v4 else "no_v4_candidate" + current_default = unit.frame_template_id if has_v4 else None + + # 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_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 + + app_candidates = _application_candidates_for_unit(unit) + v4_all_judgments_list = _v4_all_judgments_for_unit(v4_all_for_unit) + + return { + "unit_id": unit_id, + "layout_preset": layout_preset, + "layout_candidates": layout_candidates_list, + "region_layout_candidates": zone_plan.get("region_layout_candidates", []), + "display_strategy_candidates": zone_plan.get("display_strategy_candidates", []), + "candidate_status": candidate_status, + "application_status": application_status, + "current_default_candidate": current_default, + "selected_v4_rank": unit.v4_rank, + "selection_path": unit.selection_path, + "fallback_used": bool(unit.selection_path and "fallback" in unit.selection_path), + "fallback_reason": unit.fallback_reason, + # IMP-05 L2 (Codex #10 D4 / #16 idea A) — Step 9 per-unit candidate evidence. + # candidate_evidence is the primary field for future frontend / AI consumers. + # fallback_chain is kept as a compat alias for any pre-IMP-05 reader. + "candidate_evidence": selection_trace.get("candidates", []), + "fallback_chain": selection_trace.get("candidates", []), # compat alias; prefer candidate_evidence + "v4_candidates": [ + { + "template_id": c.template_id, + "frame_id": c.frame_id, + "frame_number": c.frame_number, + "v4_rank": c.v4_rank, + "confidence": c.confidence, + "label": c.label, + } + for c in unit.v4_candidates + ], + # Step 7-A axis 보강 (사용자 lock 2026-05-08) — frontend UI 가 reject + # 포함 모든 V4 후보를 시각 차별 (회색) 로 보여줄 수 있도록 trace. + # length = 0~32. label 별 count : v4_candidates 는 non-reject only, + # v4_all_judgments 는 reject 포함. + # catalog_registered = frame_contracts.yaml 에 contract 있는지 여부. + # false 면 사용자가 override 시도해도 Step 7-A 가 skip (render path 미연결). + # IMP-11 D-2 (u1) : per-candidate min_height_px added (None when unregistered). + "v4_all_judgments": v4_all_judgments_list, + "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, + } + + # ─── Main entry ──────────────────────────────────────────────── def run_phase_z2_mvp1( @@ -4450,28 +4620,10 @@ def run_phase_z2_mvp1( application_plan_units = [] for i, unit in enumerate(units): - unit_id = "+".join(unit.source_section_ids) # zone_region_plans 는 unit i 와 1:1 (Step 6 unit → Step 8 zone_plan). zone_plan = zone_region_plans[i] if i < len(zone_region_plans) else {} - - has_v4 = bool(unit.v4_candidates) - candidate_status = "ok" if has_v4 else "no_non_reject_v4_candidate" - application_status = "ok" if has_v4 else "no_v4_candidate" - 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 를 카드로 보여주기 위함). @@ -4484,87 +4636,22 @@ def run_phase_z2_mvp1( v4, _first_sid, alias_keys=section_alias_by_id.get(_first_sid) ) - # application_candidates : V4 후보 zip 으로 application_mode 변환 - app_candidates = [] - for c in unit.v4_candidates: - mode, auto_app, delegated = APPLICATION_MODE_BY_V4_LABEL.get( - c.label, ("exclude", False, None) + # IMP-32 u4 — per-unit application_plan dict assembly extracted into + # _build_application_plan_unit(...). Per-index/per-id lookups + # (zone_region_plans[i], v4_fallback_traces.get(...), + # plan_record_by_unit_id.get(id(unit)), section_alias_by_id, + # lookup_v4_all_judgments(...)) stay at the call-site. + application_plan_units.append( + _build_application_plan_unit( + unit, + zone_plan, + selection_trace, + plan_record, + v4_all_for_unit, + layout_preset, + layout_candidates_list, ) - app_candidates.append({ - "template_id": c.template_id, - "frame_id": c.frame_id, - "v4_label": c.label, - "application_mode": mode, - "auto_applicable": auto_app, - "required_changes": [], # v0 = trace-only - "delegated_to": delegated, - }) - - # IMP-11 D-2 (u1) — per-candidate min_height_px source = catalog - # frame_contracts[template_id].visual_hints.min_height_px (logical 1280×720 px). - # None when contract unregistered (frontend tolerates undefined). - # Single get_contract lookup binds both catalog_registered and min_height_px. - v4_all_judgments_list = [] - for c in v4_all_for_unit: - _contract = get_contract(c.template_id) - v4_all_judgments_list.append({ - "template_id": c.template_id, - "frame_id": c.frame_id, - "frame_number": c.frame_number, - "v4_rank": c.v4_rank, - "confidence": c.confidence, - "label": c.label, - "catalog_registered": _contract is not None, - "min_height_px": (_contract or {}).get("visual_hints", {}).get("min_height_px"), - }) - - application_plan_units.append({ - "unit_id": unit_id, - "layout_preset": layout_preset, - "layout_candidates": layout_candidates_list, - "region_layout_candidates": zone_plan.get("region_layout_candidates", []), - "display_strategy_candidates": zone_plan.get("display_strategy_candidates", []), - "candidate_status": candidate_status, - "application_status": application_status, - "current_default_candidate": current_default, - "selected_v4_rank": unit.v4_rank, - "selection_path": unit.selection_path, - "fallback_used": bool(unit.selection_path and "fallback" in unit.selection_path), - "fallback_reason": unit.fallback_reason, - # IMP-05 L2 (Codex #10 D4 / #16 idea A) — Step 9 per-unit candidate evidence. - # candidate_evidence is the primary field for future frontend / AI consumers. - # fallback_chain is kept as a compat alias for any pre-IMP-05 reader. - "candidate_evidence": selection_trace.get("candidates", []), - "fallback_chain": selection_trace.get("candidates", []), # compat alias; prefer candidate_evidence - "v4_candidates": [ - { - "template_id": c.template_id, - "frame_id": c.frame_id, - "frame_number": c.frame_number, - "v4_rank": c.v4_rank, - "confidence": c.confidence, - "label": c.label, - } - for c in unit.v4_candidates - ], - # Step 7-A axis 보강 (사용자 lock 2026-05-08) — frontend UI 가 reject - # 포함 모든 V4 후보를 시각 차별 (회색) 로 보여줄 수 있도록 trace. - # length = 0~32. label 별 count : v4_candidates 는 non-reject only, - # v4_all_judgments 는 reject 포함. - # catalog_registered = frame_contracts.yaml 에 contract 있는지 여부. - # false 면 사용자가 override 시도해도 Step 7-A 가 skip (render path 미연결). - # IMP-11 D-2 (u1) : per-candidate min_height_px added (None when unregistered). - "v4_all_judgments": v4_all_judgments_list, - "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 = [ u["unit_id"] for u in application_plan_units diff --git a/tests/test_phase_z2_v4_fallback.py b/tests/test_phase_z2_v4_fallback.py index dfd750f..158f42e 100644 --- a/tests/test_phase_z2_v4_fallback.py +++ b/tests/test_phase_z2_v4_fallback.py @@ -295,25 +295,79 @@ def test_existing_trace_shape_does_not_regress(patch_selector_deps): assert trace["selection_path"] == "rank_1" -# ─── Case 7 : Step 9 production-source guard (Codex #20 blocker fix) ─── +# ─── Case 7 : Step 9 helper-call shape test (IMP-32 u5 — replaces source guard) ─── -def test_step9_production_emits_candidate_evidence_and_alias(): - """Temporary production-source guard for IMP-05 Step 9 evidence fields. +def test_build_application_plan_unit_emits_candidate_evidence_and_alias(): + """IMP-32 u5 — direct helper-call shape test for Step 9 evidence fields. - Step 9 application-plan unit assembly is currently inline, so this test - checks the exact production assignments until IMP-32 extracts a helper. - Once that helper exists, replace this source-string guard with a direct - helper-call test. + Replaces the IMP-05 Case 7 `inspect.getsource(phase_z2_pipeline)` literal + guard (introduced at commit `23d1b25` while Step 9 unit assembly was + inline) with a direct call to `_build_application_plan_unit`, the helper + extracted in IMP-32 u3. Verification axes preserved: + + - candidate_evidence list identity sourced from `selection_trace["candidates"]` + - fallback_chain compat-alias identity (same list object as candidate_evidence) + - key order: candidate_evidence before fallback_chain + - compat-alias comment preserved on the helper's fallback_chain line """ - source = inspect.getsource(phase_z2_pipeline) - candidate_line = '"candidate_evidence": selection_trace.get("candidates", [])' - alias_line = '"fallback_chain": selection_trace.get("candidates", [])' + from types import SimpleNamespace - assert candidate_line in source - assert alias_line in source - assert source.index(candidate_line) < source.index(alias_line) - assert "compat alias; prefer candidate_evidence" in source + from src.phase_z2_pipeline import _build_application_plan_unit + + candidates_list = [ + {"rank": 1, "template_id": "MOCK_template_direct_a", "label": "use_as_is"}, + ] + selection_trace = {"candidates": candidates_list} + + # Synthetic CompositionUnit-shape duck-typed input — matches V4Match attrs + # used inside the helper (template_id / frame_id / frame_number / v4_rank / + # confidence / label per src/phase_z2_pipeline.py V4Match dataclass). + v4_candidate = SimpleNamespace( + template_id="MOCK_template_direct_a", + frame_id="MOCK_frame_001", + frame_number=1, + v4_rank=1, + confidence=0.9, + label="use_as_is", + ) + unit = SimpleNamespace( + source_section_ids=["S1"], + v4_candidates=[v4_candidate], + v4_rank=1, + selection_path="rank_1", + fallback_reason=None, + frame_template_id="MOCK_template_direct_a", + ) + + result = _build_application_plan_unit( + unit=unit, + zone_plan={}, + selection_trace=selection_trace, + plan_record=None, + v4_all_for_unit=[], + layout_preset="Type A", + layout_candidates_list=[], + ) + + # IMP-05 L2 — candidate_evidence is the primary field, identity-bound to + # selection_trace["candidates"] (not a copy). + assert "candidate_evidence" in result + assert result["candidate_evidence"] is candidates_list + + # compat alias — fallback_chain references the SAME list object as + # candidate_evidence (verified by `is` identity, not equality). + assert "fallback_chain" in result + assert result["fallback_chain"] is candidates_list + + # key order — candidate_evidence MUST precede fallback_chain in the + # returned dict to preserve documented L2 ordering. + keys = list(result.keys()) + assert keys.index("candidate_evidence") < keys.index("fallback_chain") + + # compat-alias comment preserved on the helper's fallback_chain line. + helper_source = inspect.getsource(_build_application_plan_unit) + assert "compat alias; prefer candidate_evidence" in helper_source # ─── Case 8 : Step 20 slide-status qualifier fields presence + defensive default