diff --git a/src/phase_z2_composition.py b/src/phase_z2_composition.py index 8050cb4..7b09123 100644 --- a/src/phase_z2_composition.py +++ b/src/phase_z2_composition.py @@ -368,6 +368,15 @@ class CompositionUnit: # 0 길이 = "no_non_reject_v4_candidate" 신호 (Step 9 application_plan input). v4_candidates: list = field(default_factory=list) + # IMP-30 u2 — provisional first-render flag. True when the V4Match + # backing this unit was synthesized via lookup_v4_match_with_fallback + # (allow_provisional=True) after chain_exhausted, or when u3 inserts + # a last-resort provisional fill for an uncovered section. Carried as + # data (not re-derived from label/selection_path downstream) so the + # render path / status / zone template can surface "needs adaptation" + # uniformly. Default False keeps non-provisional units byte-identical. + provisional: bool = False + # ─── Heading Tree ────────────────────────────────────────────── @@ -490,6 +499,7 @@ def collect_candidates(sections, v4_lookup_fn, v4_label_to_status: dict, raw_content=s.raw_content, title=s.title, v4_candidates=_v4_cands(s.section_id), + provisional=getattr(match, "provisional", False), ) _apply_capacity_fit(c, capacity_fit_fn) candidates.append(c) @@ -524,6 +534,7 @@ def collect_candidates(sections, v4_lookup_fn, v4_label_to_status: dict, raw_content=merged_raw, title=pid, v4_candidates=_v4_cands(pid), + provisional=getattr(parent_match, "provisional", False), ) _apply_capacity_fit(c_pm, capacity_fit_fn) candidates.append(c_pm) @@ -624,6 +635,10 @@ def collect_candidates(sections, v4_lookup_fn, v4_label_to_status: dict, notes=notes, # rep_child 의 V4 후보 list (rep_match 와 같은 출처, frame_* 와 일관). v4_candidates=_v4_cands(rep_child.section_id), + # IMP-30 u2 — rep_match drives frame selection so its provisional + # flag flows here. If a non-rep child match is provisional but the + # rep is not, this unit is not provisional (the rep frame is real). + provisional=getattr(rep_match, "provisional", False), ) _apply_capacity_fit(c_inf, capacity_fit_fn) candidates.append(c_inf) @@ -670,7 +685,13 @@ def score_candidate(c: CompositionUnit) -> CompositionUnit: # ─── Selection ───────────────────────────────────────────────── -def select_composition_units(candidates, allowed_statuses: set[str]) -> list[CompositionUnit]: +def select_composition_units( + candidates, + allowed_statuses: set[str], + *, + all_section_ids: Optional[list[str]] = None, + allow_provisional_fill: bool = False, +) -> list[CompositionUnit]: """Greedy non-overlapping selection by score, with coverage tiebreak. 1. 모든 candidate 점수 매김 @@ -685,6 +706,27 @@ def select_composition_units(candidates, allowed_statuses: set[str]) -> list[Com auto_selectable=False candidate 는 자동 선택 X. debug 의 candidates_summary 에는 남음. UI/editor layer 에서 사용자가 별도 처리 가능 (현 v0 범위 X). + + IMP-30 u3 — last-resort provisional fill (opt-in via allow_provisional_fill): + After the normal greedy pass, sections in ``all_section_ids`` that are + still uncovered are filled with the highest-score *provisional* + candidate (``c.provisional == True``) that includes at least one + uncovered section and does not collide with already-covered ones. A + provisional candidate's backing V4Match was synthesized via + ``lookup_v4_match_with_fallback(allow_provisional=True)`` (IMP-30 u1) + after chain_exhausted; its ``phase_z_status`` is therefore typically + *outside* ``allowed_statuses`` (extract_matched_zone / fallback_candidate), + which is why it gets filtered out of the normal greedy pass. The fill + preserves first-render invariant for sections whose rank-1~3 are all + restructure/reject. Default ``allow_provisional_fill=False`` keeps + pre-u3 behavior byte-identical (IMP-05 regression guard). + + Args: + candidates: full candidate pool from collect_candidates(). + allowed_statuses: phase_z_status set considered auto-renderable. + all_section_ids: ordered section id list (only consulted when + allow_provisional_fill=True; required for coverage check). + allow_provisional_fill: opt-in for last-resort provisional fill. """ scored = [score_candidate(c) for c in candidates] viable = [ @@ -701,6 +743,28 @@ def select_composition_units(candidates, allowed_statuses: set[str]) -> list[Com selected.append(c) covered.update(c.source_section_ids) + # IMP-30 u3 — last-resort provisional fill (opt-in, default off). + # Honors first-render invariant by surfacing chain_exhausted sections as + # provisional zones instead of dropping them. Skip reasons on + # non-provisional filtered candidates are preserved (not mutated here). + if allow_provisional_fill and all_section_ids: + uncovered = {sid for sid in all_section_ids if sid not in covered} + if uncovered: + provisional_pool = [ + c for c in scored + if c.provisional + and any(sid in uncovered for sid in c.source_section_ids) + ] + provisional_pool.sort( + key=lambda c: (c.score, len(c.source_section_ids)), + reverse=True, + ) + for c in provisional_pool: + if any(sid in covered for sid in c.source_section_ids): + continue + selected.append(c) + covered.update(c.source_section_ids) + return selected @@ -740,7 +804,9 @@ def select_layout_preset(units: list[CompositionUnit]) -> Optional[str]: def plan_composition(sections, v4_lookup_fn, v4_label_to_status: dict, allowed_statuses: set[str], capacity_fit_fn=None, - v4_candidates_lookup_fn=None) -> tuple[list[CompositionUnit], Optional[str], dict]: + v4_candidates_lookup_fn=None, + *, + allow_provisional_fill: bool = False) -> tuple[list[CompositionUnit], Optional[str], dict]: """Composition planner v0.2 entry. v0.2 변경 : @@ -753,6 +819,14 @@ def plan_composition(sections, v4_lookup_fn, v4_label_to_status: dict, logic 변화 X — 단일 frame_template_id / frame_id / label / confidence 는 그대로. runtime 결과 무변. Step 9 application_plan input 위한 schema 확장. + IMP-30 u3 — last-resort provisional fill (opt-in, default off): + ``allow_provisional_fill`` is plumbed to select_composition_units(). + When True, uncovered sections receive a provisional fill from candidates + whose backing V4Match was synthesized via ``allow_provisional=True`` + (IMP-30 u1). ``_candidate_state`` returns ``selected_provisional`` for + those filled units so the debug summary distinguishes greedy selections + from provisional fills. Default False keeps IMP-05 behavior identical. + v0.1 / v0.1.1 동작 (유지) : - parent_merged_inferred candidate 생성 (parent V4 없어도) - review 개념 X. auto_selectable + filter_reasons 만으로 자동 결정 @@ -771,11 +845,22 @@ def plan_composition(sections, v4_lookup_fn, v4_label_to_status: dict, ) scored_all = [score_candidate(c) for c in candidates] - units = select_composition_units(candidates, allowed_statuses) + units = select_composition_units( + candidates, + allowed_statuses, + all_section_ids=[s.section_id for s in sections] if allow_provisional_fill else None, + allow_provisional_fill=allow_provisional_fill, + ) preset = select_layout_preset(units) def _candidate_state(c: CompositionUnit) -> str: if c in units: + # IMP-30 u3 — provisional-fill units surface as a distinct state so + # downstream debug consumers can tell greedy selection apart from + # last-resort fill. unit.provisional flows from u1 (V4Match + # synthesis) → u2 (CompositionUnit propagation). + if c.provisional: + return "selected_provisional" return "selected" if c.phase_z_status not in allowed_statuses: return "filtered_status" # V4 label → status not auto-renderable diff --git a/src/phase_z2_pipeline.py b/src/phase_z2_pipeline.py index 8e450be..42233a1 100644 --- a/src/phase_z2_pipeline.py +++ b/src/phase_z2_pipeline.py @@ -176,6 +176,12 @@ class V4Match: v4_rank: Optional[int] = None selection_path: str = "rank_1" fallback_reason: Optional[str] = None + # IMP-30 u1 — provisional first-render flag. True when the selector + # synthesizes a rank-1 V4 candidate after chain_exhausted because the + # opt-in allow_provisional kwarg was set. Default False keeps IMP-05 + # behavior byte-identical; downstream surfaces this for zone-level + # "needs adaptation" marking without altering V4 evidence. + provisional: bool = False def to_phase_z_status(match: V4Match) -> str: @@ -585,11 +591,26 @@ def lookup_v4_match_with_fallback( raw_content: Optional[str] = None, max_rank: int = 3, alias_keys: Optional[list] = None, + allow_provisional: bool = False, ) -> tuple[Optional[V4Match], dict]: """Select V4 rank-1, or promote rank-2/3 when rank-1 is not auto-renderable. This is an IMP-05 selector only. It uses existing V4 labels, frame-contract presence, and the Phase Z capacity precheck; it does not call calculate_fit. + + IMP-30 u1 — when ``allow_provisional=True`` and the rank-1..max_rank chain + is exhausted (no candidate passes MVP1 filter + contract + capacity), the + selector synthesizes a *provisional* V4Match from the rank-1 judgment so + the first-render invariant can be satisfied downstream. The synthesized + match carries ``provisional=True``, ``selection_path="provisional_rank_1"``, + and ``fallback_reason`` mirrors the existing chain-exhaust reason. The + candidate trace shape is unchanged (synthetic injection only updates the + top-level ``selection_path`` + ``selected_*`` mirrors). When the rank-1 + judgment itself is missing (``empty_v4_judgments`` / ``no_v4_section``), + no provisional is synthesized — the caller (u3 / u4) handles those cases + with a placeholder zone or empty-shell. + + Default ``allow_provisional=False`` keeps the IMP-05 behavior byte-identical. """ resolved = _resolve_v4_section_key(v4, section_id, alias_keys=alias_keys) sec = v4.get("mdx_sections", {}).get(resolved) if resolved else None @@ -692,6 +713,32 @@ def lookup_v4_match_with_fallback( trace["selection_path"] = "chain_exhausted" trace["fallback_reason"] = first_skip_reason or "no_auto_renderable_rank_1_to_3" + + # IMP-30 u1 — opt-in provisional first-render synthesis. When the caller + # signals allow_provisional, promote rank-1 judgment as a provisional + # match so downstream composition can satisfy the first-render invariant. + # Top-level mirrors (selection_path / selected_*) are updated; candidate + # trace entries are left intact (their skip reasons remain accurate). + # Default-off keeps IMP-05 behavior byte-identical. + if allow_provisional: + rank_1_judgment = judgments[0] + provisional_match = _v4_match_from_judgment( + section_id, rank_1_judgment, rank=1 + ) + provisional_match.selection_path = "provisional_rank_1" + provisional_match.fallback_reason = trace["fallback_reason"] + provisional_match.provisional = True + trace.update({ + "selection_path": "provisional_rank_1", + "selected_rank": 1, + "selected_template_id": provisional_match.template_id, + "selected_frame_id": provisional_match.frame_id, + "selected_label": provisional_match.label, + "fallback_used": True, + "provisional": True, + }) + return provisional_match, trace + return None, trace @@ -2437,6 +2484,9 @@ def compute_slide_status(sections: list[MdxSection], - full_mdx_coverage : aligned 된 모든 section_id 가 어떤 selected unit 에 의해 covered - adapter_needed_count : mapper FitError 로 자동 렌더 못 한 unit 수 (별 review 개념 X — 자동 실패 보고) - content_truncated_count : builder 가 truncate 한 zone 수 (informational) + - provisional_first_render_count : IMP-30 first-render invariant 로 합성된 unit 수 + (u1 V4Match synthesis / u3 last-resort fill / + u4 empty-shell — needs user/AI adaptation 신호) overall enum : PASS — visual OK + full coverage + adapter_needed=0 @@ -2444,6 +2494,8 @@ def compute_slide_status(sections: list[MdxSection], PARTIAL_COVERAGE — 일부 section 필터됨, 렌더된 부분만 visual OK PARTIAL_COVERAGE_WITH_VISUAL_REGRESSION — 둘 다 (adapter_needed > 0 시 status note 추가, overall 은 위 enum 사용) + (IMP-30 u6 : provisional_first_render_count 도 qualifier 일 뿐, overall enum 변경 X. + Stage 1 Q3 + Codex #10 D4 lock.) """ aligned_ids = [s.section_id for s in sections] covered = set() @@ -2555,6 +2607,29 @@ def compute_slide_status(sections: list[MdxSection], _fallback_selection_count = _v4_fb_summary.get("fallback_selection_count", 0) _selection_paths = _v4_fb_summary.get("selection_paths", []) + # IMP-30 u6 — Step 20 additive qualifier fields for the first-render invariant. + # provisional_first_render_count = number of selected units whose .provisional + # flag is True (set by u1 V4Match synthesis → u2 CompositionUnit propagation, + # u3 last-resort fill, or u4 empty-shell synthesis). The list mirrors the shape + # of fallback_selections / adapter_needed_units for symmetry. Top-level overall + # enum stays unchanged per IMP-05 Codex #10 D4 + Stage 1 Q3 decision: this + # signal is a qualifier, not a new failure class. Defensive getattr keeps the + # function safe when units come from legacy code paths predating u2. + provisional_first_render_units: list[dict] = [] + for u in units: + if not getattr(u, "provisional", False): + continue + provisional_first_render_units.append({ + "source_section_ids": list(getattr(u, "source_section_ids", []) or []), + "phase_z_status": getattr(u, "phase_z_status", None), + "frame_template_id": getattr(u, "frame_template_id", None), + "frame_id": getattr(u, "frame_id", None), + "label": getattr(u, "label", None), + "selection_path": getattr(u, "selection_path", None), + "fallback_reason": getattr(u, "fallback_reason", None), + "v4_rank": getattr(u, "v4_rank", None), + }) + return { "rendered": True, "visual_check_passed": visual_passed, @@ -2574,12 +2649,17 @@ def compute_slide_status(sections: list[MdxSection], "adapter_needed_units": adapter_needed_units, "content_truncated_count": len(content_truncated), "content_truncated_units": content_truncated, + # IMP-30 u6 — additive provisional qualifiers (overall enum unchanged). + "provisional_first_render_count": len(provisional_first_render_units), + "provisional_first_render_units": provisional_first_render_units, "overall": overall, "note": ( "자동 파이프라인 결과 보고. review/UI 개념 X. final.html 파일명 != PASS 의미. " "overall == PASS 는 visual OK + full coverage + adapter_needed=0 일 때만. " "adapter_needed_count > 0 = mapper 가 contract 와 안 맞아 자동 렌더 못 한 zone 존재. " - "content_truncated_count > 0 = builder 가 truncate 한 zone 존재 (rendered 됐지만 일부 콘텐츠 손실)." + "content_truncated_count > 0 = builder 가 truncate 한 zone 존재 (rendered 됐지만 일부 콘텐츠 손실). " + "provisional_first_render_count > 0 = IMP-30 first-render invariant 가 작동한 unit 존재 " + "(empty_shell / chain_exhausted_provisional / 등 — needs user/AI adaptation)." ), } @@ -3154,25 +3234,145 @@ def run_phase_z2_mvp1( units = plan_units if not units or layout_preset is None: - # composition planner 결과 = 0 units. Sections 가 모두 V4 lookup 실패 또는 - # status filter 통과 못 함. error.json 기록 후 abort. - run_dir.mkdir(parents=True, exist_ok=True) - error_data = { - "stage": "composition_planner", - "reason": ( - "Composition planner v0 selected 0 viable units. " - f"Either no V4 entries for any section, or all candidates filtered out by " - f"allowed_statuses={sorted(MVP1_ALLOWED_STATUSES)}." - ), - "aligned_section_ids": [s.section_id for s in sections], - "composition_debug": comp_debug, - } - err_path = run_dir / "error.json" - err_path.write_text(json.dumps(error_data, ensure_ascii=False, indent=2), encoding="utf-8") - print(f"\n[Phase Z-2 MVP-1.5b] ABORT @ composition_planner", file=sys.stderr) - print(f" reason : 0 viable units after composition v0", file=sys.stderr) - print(f" error : {err_path}", file=sys.stderr) - sys.exit(1) + # IMP-30 u4 — first-render invariant. The pre-u4 path here was + # `sys.exit(1)` after writing error.json. That violated the invariant + # ("final.html + Step 20 slide_status MUST be written for every input + # where Step 0~5 succeed") whenever V4 evidence for any section was + # restructure/reject (chain_exhausted) or missing (no_v4_section / + # empty_v4_judgments). + # + # Recovery has two phases: + # Phase A — provisional retry (u1 + u3 opt-in). Re-run plan_composition + # with allow_provisional=True (in lookup_fn) and allow_provisional_fill + # =True. Synthesizes rank-1 provisional V4Match on chain_exhausted + # (u1) and last-resort-fills uncovered sections with provisional + # candidates (u3). Skipped when the CLI override path was used — + # re-running plan_composition there would discard the override. + # Phase B — terminal empty-shell. If retry still yields zero units + # (true "no rank-1 V4 anywhere" case, or override path with no + # resolvable assignments), synthesize a single placeholder + # CompositionUnit with frame_template_id="__empty__", layout_preset + # ="single". The per-unit loop's __empty__ guard emits a placeholder + # zones_data / debug_zones record; final.html renders the slide + # base shell (title + footer + empty zone) so the first-render + # invariant holds. Provisional flag = True surfaces the "needs + # adaptation" signal (u5 zone class + u6 status qualifier). + provisional_recovered = False + if section_assignment_plan is None: + def _lookup_fn_provisional(sid: str) -> Optional[V4Match]: + match, trace = lookup_v4_match_with_fallback( + v4, + sid, + raw_content=section_content_by_id.get(sid), + max_rank=3, + alias_keys=section_alias_by_id.get(sid), + allow_provisional=True, + ) + v4_fallback_traces[sid] = trace + return match + + units_retry, layout_preset_retry, comp_debug_retry = plan_composition( + sections, + _lookup_fn_provisional, + V4_LABEL_TO_PHASE_Z_STATUS, + MVP1_ALLOWED_STATUSES, + capacity_fit_fn=compute_capacity_fit, + v4_candidates_lookup_fn=candidates_lookup_fn, + allow_provisional_fill=True, + ) + comp_debug["imp30_u4_provisional_retry"] = { + "applied": True, + "result_unit_count": len(units_retry), + "result_layout_preset": layout_preset_retry, + "candidates_summary": comp_debug_retry.get("candidates_summary"), + } + if units_retry and layout_preset_retry is not None: + units = units_retry + layout_preset = layout_preset_retry + provisional_recovered = True + # v4_fallback_traces was overwritten by _lookup_fn_provisional; + # refresh the IMP-05 selection_paths telemetry so Step 20 reflects + # the actual selection (provisional_rank_1) rather than the stale + # chain_exhausted state from the first attempt. + _imp05_selection_paths_retry = [ + { + "section_id": sid, + "selection_path": t.get("selection_path"), + "selected_rank": t.get("selected_rank"), + "selected_template_id": t.get("selected_template_id"), + "fallback_trigger": ( + t.get("fallback_reason") if t.get("fallback_used") else None + ), + } + for sid, t in v4_fallback_traces.items() + ] + comp_debug["v4_fallback_selections"] = list(v4_fallback_traces.values()) + if "v4_fallback_summary" in comp_debug: + comp_debug["v4_fallback_summary"]["selection_paths"] = ( + _imp05_selection_paths_retry + ) + print( + f" [IMP-30 u4] provisional retry recovered {len(units)} unit(s) " + f"— first-render invariant preserved.", + file=sys.stderr, + ) + + if not provisional_recovered: + # Phase B — terminal empty-shell. No rank-1 V4 evidence for any + # section, or override path produced no renderable assignments. + from src.phase_z2_composition import CompositionUnit as _CompositionUnit + run_dir.mkdir(parents=True, exist_ok=True) + empty_shell_unit = _CompositionUnit( + source_section_ids=[s.section_id for s in sections], + merge_type="empty_shell", + frame_template_id="__empty__", + frame_id="__empty__", + frame_number=0, + confidence=0.0, + label="empty_shell", + phase_z_status="empty_shell", + raw_content="\n\n".join((s.raw_content or "") for s in sections), + title=" / ".join((s.title or "") for s in sections), + v4_rank=None, + selection_path="empty_shell", + fallback_reason="no_v4_rank_1_for_any_section", + score=0.0, + rationale={ + "imp30_u4": "terminal_first_render_empty_shell", + "reason": ( + "no_rank_1_V4_evidence_in_any_section" + if section_assignment_plan is None + else "section_assignment_override_yielded_no_renderable_units" + ), + "aligned_section_ids": [s.section_id for s in sections], + }, + provisional=True, + ) + units = [empty_shell_unit] + layout_preset = "single" + comp_debug["imp30_u4_empty_shell"] = { + "applied": True, + "reason": ( + "no_rank_1_V4_for_any_section" + if section_assignment_plan is None + else "section_assignment_override_yielded_no_renderable_units" + ), + "aligned_section_ids": [s.section_id for s in sections], + } + print( + f"\n[Phase Z-2 IMP-30 u4] EMPTY-SHELL @ composition_planner", + file=sys.stderr, + ) + print( + f" reason : " + f"{'no rank-1 V4 evidence for any section' if section_assignment_plan is None else 'override produced no renderable units'}", + file=sys.stderr, + ) + print( + f" shell : 1 placeholder unit, preset='single' " + f"(sections={[s.section_id for s in sections]})", + file=sys.stderr, + ) print(f" preset : {layout_preset} ({len(units)} units, composition v0 count-based)") for u in units: @@ -3345,6 +3545,63 @@ def run_phase_z2_mvp1( # 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"] + + # IMP-30 u4 — empty-shell synthesized unit. frame_template_id="__empty__" + # has no catalog contract by design; bypass mapper/contract path and emit + # a placeholder zone record so render_slide() short-circuits to empty + # partial_html (existing `__empty__` branch at render_slide:2106). The + # slide_base still renders title + footer + empty grid cell so the + # first-render invariant holds; u5 will surface the provisional flag as + # a zone class + needs-adaptation badge. + if unit.frame_template_id == "__empty__": + zones_data.append({ + "position": position, + "template_id": "__empty__", + "slot_payload": {}, + "content_weight": {"score": 0}, + "min_height_px": 0, + "assignment_source": "imp30_u4_empty_shell", + "section_assignment_override": False, + "provisional": bool(getattr(unit, "provisional", False)), + }) + debug_zones.append({ + "position": position, + "source_section_ids": list(unit.source_section_ids), + "merge_type": unit.merge_type, + "title": unit.title, + "v4_rank1_frame_id": unit.frame_id, + "v4_rank1_frame_number": unit.frame_number, + "v4_template_id": "__empty__", + "v4_label": unit.label, + "v4_confidence": float(unit.confidence or 0.0), + "v4_selected_rank": unit.v4_rank, + "selection_path": unit.selection_path, + "fallback_reason": unit.fallback_reason, + "fallback_used": False, + "phase_z_status": unit.phase_z_status, + "composition_score": float(unit.score or 0.0), + "composition_rationale": dict(unit.rationale or {}), + "composition_notes": list(unit.notes), + "mapper_type": "empty_shell", + "contract_id": "__empty__", + "contract_frame_id": None, + "builder": None, + "min_height_px": 0, + "slot_payload_keys": [], + "content_truncated_count": None, + "assets_dir": None, + "content_weight": {"score": 0}, + "placement_trace": None, + "assignment_source": "imp30_u4_empty_shell", + "section_assignment_override": False, + "replaced_auto_unit": None, + "skipped_collided_auto_units": [], + "uncovered_section_ids": [], + "skipped_reason": "imp30_u4_empty_shell_no_v4_evidence", + "provisional": bool(getattr(unit, "provisional", False)), + }) + continue + synth_section = MdxSection( section_id="+".join(unit.source_section_ids), section_num=0, @@ -3470,6 +3727,12 @@ def run_phase_z2_mvp1( plan_record.get("skipped_reason") if plan_record else None ) + # IMP-30 u5 — `provisional` flag flows as data through V4Match (u1) → + # CompositionUnit (u2) → zones_data here. slide_base.html reads + # zone.provisional to apply the `zone--provisional` class + inline + # needs-adaptation badge. Default False keeps non-provisional zones + # byte-identical to pre-u5; only u1-synthesized rank-1 fills or u4 + # empty-shell synthesize provisional=True units. zones_data.append({ "position": position, "template_id": unit.frame_template_id, @@ -3478,6 +3741,7 @@ def run_phase_z2_mvp1( "min_height_px": min_height_px, "assignment_source": plan_assignment_source, "section_assignment_override": plan_section_override, + "provisional": bool(getattr(unit, "provisional", False)), }) debug_zones.append({ "position": position, @@ -3515,6 +3779,8 @@ def run_phase_z2_mvp1( "skipped_collided_auto_units": plan_skipped_collided, "uncovered_section_ids": plan_uncovered, "skipped_reason": plan_skipped_reason, + # IMP-30 u5 — provisional signal mirror for debug.json consumers. + "provisional": bool(getattr(unit, "provisional", False)), }) # IMP-06 blocker-fix (Codex #10 Catch N) — append explicit empty zone records diff --git a/templates/phase_z2/slide_base.html b/templates/phase_z2/slide_base.html index 1c95fbe..8c8485b 100644 --- a/templates/phase_z2/slide_base.html +++ b/templates/phase_z2/slide_base.html @@ -114,6 +114,43 @@ min-height: 0; } + /* ── IMP-30 u5 : provisional zone marker (first-render invariant) ── + When V4 rank-1 candidate falls outside MVP1_ALLOWED_STATUSES (chain_exhausted) + the pipeline still renders the rank-1 frame so the first-render invariant + holds, but the zone is tagged `provisional` so the user/AI can adapt later + (IMP-31). Visual contract: + - dashed amber border + striped wash → "needs adaptation" at a glance + - inline badge top-right → text label for non-color-perceiving readers + MDX content is preserved as-is; no shrink, no rewrite. */ + .zone--provisional { + outline: 2px dashed #b8860b; + outline-offset: -2px; + background-image: repeating-linear-gradient( + 45deg, + rgba(184, 134, 11, 0.04) 0, + rgba(184, 134, 11, 0.04) 8px, + transparent 8px, + transparent 16px + ); + } + .zone--provisional .zone__needs-adaptation-badge { + position: absolute; + top: 4px; + right: 4px; + z-index: 10; + padding: 2px 6px; + background: #b8860b; + color: #fff; + font-size: 9px; + font-weight: 700; + line-height: 1.2; + letter-spacing: 0.04em; + border-radius: 2px; + text-transform: uppercase; + pointer-events: none; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); + } + /* ── Frame-family text layout contract (shared, reusable) ── feedback-1 (mvp1.5b_test7): visible improvement 강화. Stronger hanging indent + breathing line spacing + visible hierarchy. */ @@ -264,7 +301,8 @@