Compare commits

...

3 Commits

Author SHA1 Message Date
ab2764c8d0 feat(IMP-08): U3 — frontend wire (zoneSections override)
Wires the frontend drag/drop zone assignment through to the backend
--override-section-assignment CLI flag.

PipelineOverrides gains an optional zoneSections field
(Record<string, string[]>) carrying canonical ordinal section ids
(e.g., "top": ["04-2-sub-1"]).

Vite middleware /api/run accepts overrides.zoneSections and forwards
each non-empty zone as `--override-section-assignment ZONE=sid[,sid]`.
Empty arrays and non-string sids are filtered to avoid bogus
assignments from a partially-built UI state.

Home.tsx builds the override with a diff-vs-default guard per Codex
Stage 3 R3 B3 fix : createInitialUserSelection seeds zone_sections with
the auto plan, so a literal copy would pollute backend assignment-source
provenance even on a fresh re-render. The diff compares each zone's
section list against sourcePlan.zones[].section_ids and only emits zones
that differ. Toast summary now reports zoneSections=N when forwarded.

Smoke verification : python -m src.phase_z2_pipeline samples/mdx_batch/04.mdx
test_imp08_smoke --override-section-assignment primary=04-2-sub-1 produces
section_assignment_plan with assignment_source=cli_override and
v4_selector_trace.candidates populated via the U1 alias resolver
(04-2-sub-1 -> 04-2.1 V4 entry).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:36:16 +09:00
5191acad85 feat(IMP-08): U2 — aligner canonical sub-id + N-R5 decimal alias guard
align_sections_to_v4_granularity now emits canonical sub-section ids
of the form ${section_id}-sub-${ordinal} (e.g., "04-2-sub-1"), matching
the frontend drag/drop schema. Each drilled sub-section populates
heading_number (decimal "2.1" / integer "1" / None for undecorated)
and v4_alias_keys for legacy V4 keys.

N-R5 decimal-only alias guard : v4_alias_keys is populated only when
heading_number matches re.fullmatch(r"\d+\.\d+", ...). Integer-only
H3 headings (e.g., MDX 05's "### 1", "### 2") and bare H3 headings
produce no alias to avoid sibling-parent V4 collisions (RULE 0
generalization — applies to all 32-frame MDX, not MDX 05-specific).

The drill regex is broadened from r"^###\s+(\d+\.\d+)\s+..." to
r"^###\s+(?:(\d+(?:\.\d+)?)\s+)?(.+?)$" so integer-only and bare H3
headings are now recognised as sub-sections; they previously failed
the regex and were silently kept under the parent section.

Tests : 7 new cases (MdxSection default 4-positional callers, V4 exact
passthrough, decimal drill with alias, integer-only no-alias guard,
bare H3 no-alias, no-H3 passthrough, end-to-end aligner -> resolver
round-trip with legacy V4 alias). 15/15 in test_phase_z2_subsection_schema
+ 14 override + 8 fallback baseline = 37/37 PASS.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:33:49 +09:00
a422d72c0b feat(IMP-08): U1 — schema helper + V4 alias resolver (4 lookup sites)
Adds sub-section schema fields (heading_number / v4_alias_keys /
sub_sections) to MdxSection with defaults so existing 4-positional
constructions remain valid. Introduces _resolve_v4_section_key helper
that resolves a V4 mdx_sections key in exact > alias > None order with
no parent/sibling promotion (axis 7 hybrid lock).

Rewires four runtime V4 lookup sites (lookup_v4_match,
lookup_v4_match_with_fallback, lookup_v4_all_judgments,
lookup_v4_candidates) to accept an optional alias_keys kwarg and go
through the resolver. U1 callers pass empty alias lists so behaviour
is byte-identical to the previous exact-match path; U2 will populate
aliases from MDX heading_number metadata.

Closure callers in run_phase_z2 build section_alias_by_id from
MdxSection.v4_alias_keys and forward into lookup_fn /
candidates_lookup_fn / lookup_v4_all_judgments (Step 7-A trace) and
into _select_template_for_overrides single-section selector.

Step 9 candidate report (post-decision diagnostic) is marked with an
inline English exemption comment per N-R6 — runtime selection goes
through _resolve_v4_section_key, the report path stays a direct
dict-shape lookup to avoid debug_zones schema plumbing.

derive_parent_id now recognises canonical ordinal ids
("03-1-sub-2" -> "03-1") first and keeps the legacy decimal fallback
("04-2.1" -> "04-2") for V4 alias compatibility.

Tests : 8 synthetic cases in tests/test_phase_z2_subsection_schema.py
covering derive_parent_id ordinal/decimal/none and the resolver
exact/alias/no-promote/miss cases. 30/30 PASS combined with the 14
override + 8 fallback baseline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:28:59 +09:00
6 changed files with 366 additions and 24 deletions

View File

@@ -300,6 +300,35 @@ export default function Home() {
if (zoneGeometries && Object.keys(zoneGeometries).length > 0) { if (zoneGeometries && Object.keys(zoneGeometries).length > 0) {
overrides.zoneGeometries = zoneGeometries; overrides.zoneGeometries = zoneGeometries;
} }
// IMP-08 B-3 : zoneSections forward only when the user diverged from
// the auto plan. Codex Stage 3 R3 B3 fix : `createInitialUserSelection`
// seeds `zone_sections` with the default placement, so a literal copy
// would pollute backend assignment-source provenance even on a fresh
// re-render. Diff against `sourcePlan.zones[].section_ids` per zone and
// only emit zones whose section list differs.
const userZoneSections = state.userSelection.overrides.zone_sections;
if (userZoneSections) {
const defaultByZone = new Map<string, string[]>();
sourcePlan.zones.forEach((z) => {
defaultByZone.set(z.zone_id, z.section_ids);
});
const zoneSectionsDiff: Record<string, string[]> = {};
for (const [zoneId, sids] of Object.entries(userZoneSections)) {
if (!Array.isArray(sids)) continue;
const cleaned = sids.filter((s) => typeof s === "string" && s.trim());
const defaults = defaultByZone.get(zoneId) ?? [];
const sameAsDefault =
cleaned.length === defaults.length &&
cleaned.every((sid, i) => sid === defaults[i]);
if (!sameAsDefault) {
zoneSectionsDiff[zoneId] = cleaned;
}
}
if (Object.keys(zoneSectionsDiff).length > 0) {
overrides.zoneSections = zoneSectionsDiff;
}
}
} }
setState((p) => ({ ...p, isLoading: true })); setState((p) => ({ ...p, isLoading: true }));
@@ -310,6 +339,8 @@ export default function Home() {
? `(overrides: ${[ ? `(overrides: ${[
overrides.layout && `layout=${overrides.layout}`, overrides.layout && `layout=${overrides.layout}`,
overrides.frames && `frames=${Object.keys(overrides.frames).length}`, overrides.frames && `frames=${Object.keys(overrides.frames).length}`,
overrides.zoneSections &&
`zoneSections=${Object.keys(overrides.zoneSections).length}`,
] ]
.filter(Boolean) .filter(Boolean)
.join(", ")})` .join(", ")})`

View File

@@ -251,6 +251,11 @@ export interface PipelineOverrides {
/** zone_id (top/bottom/left/right/...) → slide-body 내부 0~1 비율. /** zone_id (top/bottom/left/right/...) → slide-body 내부 0~1 비율.
* backend 의 build_layout_css 가 horizontal-2 / vertical-2 만 처리. */ * backend 의 build_layout_css 가 horizontal-2 / vertical-2 만 처리. */
zoneGeometries?: Record<string, { x: number; y: number; w: number; h: number }>; zoneGeometries?: Record<string, { x: number; y: number; w: number; h: number }>;
/** IMP-08 B-3 : zone_id -> list of section_id assignments
* (canonical ordinal `${parent}-sub-${n}`). Only forwarded when the
* user explicitly diverges from the auto plan; default placements
* are not echoed back to avoid polluting override provenance. */
zoneSections?: Record<string, string[]>;
} }
export async function runPipeline( export async function runPipeline(

View File

@@ -241,6 +241,9 @@ function vitePluginPhaseZApi(): Plugin {
layout?: string; layout?: string;
frames?: Record<string, string>; // unit_id → template_id frames?: Record<string, string>; // unit_id → template_id
zoneGeometries?: Record<string, { x: number; y: number; w: number; h: number }>; // zone_id → bbox (slide-body 내부 0~1) zoneGeometries?: Record<string, { x: number; y: number; w: number; h: number }>; // zone_id → bbox (slide-body 내부 0~1)
// IMP-08 B-3 : zone_id -> list of canonical section_id assignments
// (e.g., "top": ["03-1-sub-1"]). Forwarded as --override-section-assignment.
zoneSections?: Record<string, string[]>;
}; };
}; };
try { try {
@@ -322,6 +325,21 @@ function vitePluginPhaseZApi(): Plugin {
} }
} }
} }
// IMP-08 B-3 — zoneSections override forward to CLI.
// Each entry becomes `--override-section-assignment ZONE=sid[,sid]`.
// Empty arrays and non-string sids are filtered out so the backend
// never receives bogus assignments from a partially-built UI state.
if (overrides?.zoneSections && typeof overrides.zoneSections === "object") {
for (const [zoneId, sids] of Object.entries(overrides.zoneSections)) {
if (!Array.isArray(sids)) continue;
const cleaned = sids.filter((s) => typeof s === "string" && s.trim());
if (cleaned.length === 0) continue;
cliArgs.push(
"--override-section-assignment",
`${zoneId}=${cleaned.join(",")}`
);
}
}
console.log( console.log(
`[phase-z-api] spawn pipeline: run_id=${runId}, mdx=${mdxPath}, args=${JSON.stringify(cliArgs.slice(2))}` `[phase-z-api] spawn pipeline: run_id=${runId}, mdx=${mdxPath}, args=${JSON.stringify(cliArgs.slice(2))}`
); );

View File

@@ -21,6 +21,7 @@ Pipeline 의 빠진 layer = MDX 덩어리들을 *최종 zone unit* 으로 묶는
from __future__ import annotations from __future__ import annotations
import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -371,13 +372,20 @@ class CompositionUnit:
# ─── Heading Tree ────────────────────────────────────────────── # ─── Heading Tree ──────────────────────────────────────────────
def derive_parent_id(section_id: str) -> Optional[str]: def derive_parent_id(section_id: str) -> Optional[str]:
"""section_id 에서 parent 도출 — V4 키 컨벤션 기반. """Section id -> parent id derivation by V4 key convention.
예시 (코멘트, 룰 X) : IMP-08 B-3 : canonical ordinal `${parent}-sub-${n}` recognised first;
- "04-2.1""04-2" (decimal suffix → strip) legacy decimal `04-2.1` kept as fallback alias path.
- "04-1" → None (top-level, no parent)
- "04" → None Examples (illustrative, not rules) :
- "03-1-sub-2" -> "03-1" (canonical ordinal, IMP-08)
- "04-2.1" -> "04-2" (decimal suffix, legacy V4 key style)
- "04-1" -> None (top-level, no parent)
- "04" -> None
""" """
m = re.fullmatch(r"(.+?)-sub-(\d+)", section_id)
if m:
return m.group(1)
parts = section_id.split("-", 1) parts = section_id.split("-", 1)
if len(parts) != 2: if len(parts) != 2:
return None return None

View File

@@ -31,7 +31,7 @@ import re
import shutil import shutil
import sys import sys
import time import time
from dataclasses import asdict, dataclass from dataclasses import asdict, dataclass, field
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -136,6 +136,13 @@ class MdxSection:
section_num: int section_num: int
title: str title: str
raw_content: str raw_content: str
# IMP-08 B-3 sub-section schema (additive, defaults preserve 4-positional callers).
# heading_number: decimal "2.1" from MDX `### 2.1 Title` capture (U2-populated).
# v4_alias_keys: legacy V4 keys to try when canonical ordinal id misses (e.g. "04-2.1").
# sub_sections: raw child payloads from section_parser (Stage 0 adapter consumes).
heading_number: Optional[str] = None
v4_alias_keys: list = field(default_factory=list)
sub_sections: list = field(default_factory=list)
@dataclass @dataclass
@@ -368,6 +375,16 @@ def load_v4_result() -> dict:
def align_sections_to_v4_granularity(sections: list[MdxSection], v4: dict) -> list[MdxSection]: def align_sections_to_v4_granularity(sections: list[MdxSection], v4: dict) -> list[MdxSection]:
"""V4 section granularity 에 맞춰 sections 조정. """V4 section granularity 에 맞춰 sections 조정.
IMP-08 B-3 : canonical sub-section id ``${section_id}-sub-${ordinal}``
(예 : ``04-2-sub-1``) 를 emit 하고, legacy V4 키 (``04-2.1``) 는
``v4_alias_keys`` 로 보존하여 ``_resolve_v4_section_key`` 가 alias 경로로
매칭한다. canonical ordinal id 는 frontend drag/drop override 와 동일
schema (`section_id-sub-N`).
N-R5 alias guard : heading_number 가 decimal (``2.1``) 일 때만 alias
emit. integer-only (``1``) / non-numeric heading 은 alias 0 — sibling
parent V4 evidence 로 잘못 promote 되는 collision 방지 (RULE 0).
각 section 에 대해 : 각 section 에 대해 :
- V4 에 section.section_id 키 있음 → 그대로 유지 (## level 매칭) - V4 에 section.section_id 키 있음 → 그대로 유지 (## level 매칭)
- V4 에 키 없고 raw_content 에 ### sub-section 존재 → ### 로 drill - V4 에 키 없고 raw_content 에 ### sub-section 존재 → ### 로 drill
@@ -381,31 +398,52 @@ def align_sections_to_v4_granularity(sections: list[MdxSection], v4: dict) -> li
v4_keys = set(v4.get("mdx_sections", {}).keys()) v4_keys = set(v4.get("mdx_sections", {}).keys())
aligned: list[MdxSection] = [] aligned: list[MdxSection] = []
# IMP-08 B-3 : capture optional heading-number prefix (decimal "2.1" or
# integer "1") + heading title. None group = bare "### Title".
sub_pattern = re.compile(
r"^###\s+(?:(\d+(?:\.\d+)?)\s+)?(.+?)$", re.MULTILINE
)
decimal_re = re.compile(r"\d+\.\d+")
for section in sections: for section in sections:
if section.section_id in v4_keys: if section.section_id in v4_keys:
aligned.append(section) aligned.append(section)
continue continue
# ### drill 시도
sub_pattern = re.compile(r"^###\s+(\d+\.\d+)\s+(.+?)$", re.MULTILINE)
sub_matches = list(sub_pattern.finditer(section.raw_content)) sub_matches = list(sub_pattern.finditer(section.raw_content))
if not sub_matches: if not sub_matches:
aligned.append(section) # drill 불가, V4 lookup 에서 abort 됨 aligned.append(section) # drill 불가, V4 lookup 에서 abort 됨
continue continue
# ### sub-section 추출
mdx_id = section.section_id.split("-")[0] # e.g., "04" mdx_id = section.section_id.split("-")[0] # e.g., "04"
for i, m in enumerate(sub_matches): for ordinal, m in enumerate(sub_matches, start=1):
subnum = m.group(1) # e.g., "2.1" heading_number = m.group(1) # decimal "2.1" / integer "1" / None
sub_title = m.group(2).strip() sub_title = m.group(2).strip()
start = m.end() start = m.end()
end = sub_matches[i + 1].start() if i + 1 < len(sub_matches) else len(section.raw_content) end = (
sub_matches[ordinal].start()
if ordinal < len(sub_matches)
else len(section.raw_content)
)
raw = section.raw_content[start:end].strip() raw = section.raw_content[start:end].strip()
# N-R5 : alias only for decimal heading numbers. integer-only
# H3 (`### 1`) or undecorated H3 produce no alias to avoid
# sibling-parent V4 collisions (e.g., 05.mdx integer H3s).
alias_keys: list[str] = []
if heading_number and decimal_re.fullmatch(heading_number):
alias_keys.append(f"{mdx_id}-{heading_number}")
title = (
f"{heading_number} {sub_title}" if heading_number else sub_title
)
aligned.append(MdxSection( aligned.append(MdxSection(
section_id=f"{mdx_id}-{subnum}", # e.g., "04-2.1" section_id=f"{section.section_id}-sub-{ordinal}",
section_num=section.section_num, section_num=section.section_num,
title=f"{subnum} {sub_title}", title=title,
raw_content=raw, raw_content=raw,
heading_number=heading_number,
v4_alias_keys=alias_keys,
)) ))
return aligned return aligned
@@ -424,8 +462,39 @@ def _v4_match_from_judgment(section_id: str, judgment: dict, rank: Optional[int]
) )
def lookup_v4_match(v4: dict, section_id: str) -> Optional[V4Match]: def _resolve_v4_section_key(
sec = v4.get("mdx_sections", {}).get(section_id) v4: dict,
section_id: str,
*,
alias_keys: Optional[list] = None,
) -> Optional[str]:
"""Resolve a V4 ``mdx_sections`` key for *section_id*.
Resolution order :
1. exact match (canonical ordinal id wins)
2. alias_keys in given order (e.g. legacy decimal ``04-2.1`` for ``04-2-sub-1``)
3. None on miss.
Never promotes to parent or sibling — that would reinterpret V4 evidence
(axis 7 hybrid lock, RULE 0). U1 callers pass alias_keys=None so the
function is byte-identical to the previous exact-match lookup; U2 populates
aliases from MDX heading_number metadata.
"""
keys = v4.get("mdx_sections", {})
if section_id in keys:
return section_id
if alias_keys:
for a in alias_keys:
if a and a in keys:
return a
return None
def lookup_v4_match(
v4: dict, section_id: str, *, alias_keys: Optional[list] = None
) -> Optional[V4Match]:
resolved = _resolve_v4_section_key(v4, section_id, alias_keys=alias_keys)
sec = v4.get("mdx_sections", {}).get(resolved) if resolved else None
if not sec: if not sec:
return None return None
judgments = sec.get("judgments_full32", []) judgments = sec.get("judgments_full32", [])
@@ -462,13 +531,15 @@ def lookup_v4_match_with_fallback(
*, *,
raw_content: Optional[str] = None, raw_content: Optional[str] = None,
max_rank: int = 3, max_rank: int = 3,
alias_keys: Optional[list] = None,
) -> tuple[Optional[V4Match], dict]: ) -> tuple[Optional[V4Match], dict]:
"""Select V4 rank-1, or promote rank-2/3 when rank-1 is not auto-renderable. """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 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. presence, and the Phase Z capacity precheck; it does not call calculate_fit.
""" """
sec = v4.get("mdx_sections", {}).get(section_id) resolved = _resolve_v4_section_key(v4, section_id, alias_keys=alias_keys)
sec = v4.get("mdx_sections", {}).get(resolved) if resolved else None
trace = { trace = {
"section_id": section_id, "section_id": section_id,
"max_rank": max_rank, "max_rank": max_rank,
@@ -571,7 +642,9 @@ def lookup_v4_match_with_fallback(
return None, trace return None, trace
def lookup_v4_all_judgments(v4: dict, section_id: str) -> list[V4Match]: def lookup_v4_all_judgments(
v4: dict, section_id: str, *, alias_keys: Optional[list] = None
) -> list[V4Match]:
"""V4 raw 32 entry 그대로 반환 — reject 포함, max_n filter 없음. """V4 raw 32 entry 그대로 반환 — reject 포함, max_n filter 없음.
Step 7-A axis 보강 (사용자 lock 2026-05-08) — 사용자 UI 가 모든 frame 의 Step 7-A axis 보강 (사용자 lock 2026-05-08) — 사용자 UI 가 모든 frame 의
@@ -581,7 +654,8 @@ def lookup_v4_all_judgments(v4: dict, section_id: str) -> list[V4Match]:
Returns : Returns :
list[V4Match] — 0~32 길이. raw judgments_full32 순서 (= V4 score desc) 보존. list[V4Match] — 0~32 길이. raw judgments_full32 순서 (= V4 score desc) 보존.
""" """
sec = v4.get("mdx_sections", {}).get(section_id) resolved = _resolve_v4_section_key(v4, section_id, alias_keys=alias_keys)
sec = v4.get("mdx_sections", {}).get(resolved) if resolved else None
if not sec: if not sec:
return [] return []
judgments = sec.get("judgments_full32", []) judgments = sec.get("judgments_full32", [])
@@ -592,7 +666,11 @@ def lookup_v4_all_judgments(v4: dict, section_id: str) -> list[V4Match]:
def lookup_v4_candidates( def lookup_v4_candidates(
v4: dict, section_id: str, max_n: int = 6 v4: dict,
section_id: str,
max_n: int = 6,
*,
alias_keys: Optional[list] = None,
) -> list[V4Match]: ) -> list[V4Match]:
"""V4 non-reject 후보 list 반환 (Step 5 보완 axis — 사용자 lock 2026-05-08). """V4 non-reject 후보 list 반환 (Step 5 보완 axis — 사용자 lock 2026-05-08).
@@ -612,7 +690,8 @@ def lookup_v4_candidates(
호출처 무변. 본 함수는 Step 5 artifact + Step 9 application_plan input 호출처 무변. 본 함수는 Step 5 artifact + Step 9 application_plan input
위한 새 entry point. 위한 새 entry point.
""" """
sec = v4.get("mdx_sections", {}).get(section_id) resolved = _resolve_v4_section_key(v4, section_id, alias_keys=alias_keys)
sec = v4.get("mdx_sections", {}).get(resolved) if resolved else None
if not sec: if not sec:
return [] return []
judgments = sec.get("judgments_full32", []) judgments = sec.get("judgments_full32", [])
@@ -932,7 +1011,12 @@ def _build_position_assignment_plan(
if v4 is None or section is None: if v4 is None or section is None:
return None, "no_v4_section", None return None, "no_v4_section", None
raw_content = getattr(section, "raw_content", None) raw_content = getattr(section, "raw_content", None)
match, trace = lookup_v4_match_with_fallback(v4, sid, raw_content=raw_content) # IMP-08 B-3 : forward sub-section V4 aliases (decimal heading_number)
# when canonical ordinal id misses; safe for top-level sids (empty list).
alias_keys = list(getattr(section, "v4_alias_keys", []) or [])
match, trace = lookup_v4_match_with_fallback(
v4, sid, raw_content=raw_content, alias_keys=alias_keys
)
if match is None: if match is None:
return None, "no_direct_render_template", trace return None, "no_direct_render_template", trace
return match.template_id, None, trace return match.template_id, None, trace
@@ -2039,6 +2123,11 @@ def run_phase_z2_mvp1(
# candidate (separate / parent_merged) → score → greedy non-overlapping select → # candidate (separate / parent_merged) → score → greedy non-overlapping select →
# layout preset (count-based v0). # layout preset (count-based v0).
section_content_by_id = {s.section_id: s.raw_content for s in sections} section_content_by_id = {s.section_id: s.raw_content for s in sections}
# IMP-08 B-3 : sub-section ordinal id -> legacy V4 key aliases (e.g. "04-2.1").
# Empty list for canonical (top-level) sections — U1 baseline path is exact-only.
section_alias_by_id: dict[str, list] = {
s.section_id: list(getattr(s, "v4_alias_keys", []) or []) for s in sections
}
v4_fallback_traces: dict[str, dict] = {} v4_fallback_traces: dict[str, dict] = {}
def lookup_fn(sid: str) -> Optional[V4Match]: def lookup_fn(sid: str) -> Optional[V4Match]:
@@ -2047,6 +2136,7 @@ def run_phase_z2_mvp1(
sid, sid,
raw_content=section_content_by_id.get(sid), raw_content=section_content_by_id.get(sid),
max_rank=3, max_rank=3,
alias_keys=section_alias_by_id.get(sid),
) )
v4_fallback_traces[sid] = trace v4_fallback_traces[sid] = trace
return match return match
@@ -2054,7 +2144,7 @@ def run_phase_z2_mvp1(
# Step 6-A axis (사용자 lock 2026-05-08) — V4 raw dict 흡수 fn. # Step 6-A axis (사용자 lock 2026-05-08) — V4 raw dict 흡수 fn.
# composition module 은 V4 yaml shape 모름. 본 fn 만 통해 후보 list 받음. # composition module 은 V4 yaml shape 모름. 본 fn 만 통해 후보 list 받음.
def candidates_lookup_fn(sid: str) -> list[V4Match]: def candidates_lookup_fn(sid: str) -> list[V4Match]:
return lookup_v4_candidates(v4, sid) return lookup_v4_candidates(v4, sid, alias_keys=section_alias_by_id.get(sid))
units, layout_preset, comp_debug = plan_composition( units, layout_preset, comp_debug = plan_composition(
sections, lookup_fn, V4_LABEL_TO_PHASE_Z_STATUS, MVP1_ALLOWED_STATUSES, sections, lookup_fn, V4_LABEL_TO_PHASE_Z_STATUS, MVP1_ALLOWED_STATUSES,
@@ -2777,6 +2867,11 @@ def run_phase_z2_mvp1(
note="V4 evidence 와 B4 통합 미완 — 별 axis. 현재 = composition planner 의 V4 rank-1 채택.", note="V4 evidence 와 B4 통합 미완 — 별 axis. 현재 = composition planner 의 V4 rank-1 채택.",
) )
# Step 9 HTML — V4 top candidates per zone (rank 1~4) # Step 9 HTML — V4 top candidates per zone (rank 1~4)
# IMP-08 N-R6 diagnostic exemption : this report path is post-decision
# reporting only. Runtime selection goes through _resolve_v4_section_key
# (4 sites). Direct dict lookup here is intentional — debug_zones carries
# dict-shape entries without v4_alias_keys plumbing, and a miss here only
# yields a "V4 entry 없음" report line (runtime impact zero).
try: try:
with open(V4_RESULT_PATH, encoding="utf-8") as _vf: with open(V4_RESULT_PATH, encoding="utf-8") as _vf:
_v4_full = yaml.safe_load(_vf) _v4_full = yaml.safe_load(_vf)
@@ -3263,7 +3358,12 @@ def run_phase_z2_mvp1(
# 모든 frame 의 png 를 카드로 보여주기 위함). # 모든 frame 의 png 를 카드로 보여주기 위함).
# unit_id = source_section_ids join. parent_merged 는 첫 section 의 # unit_id = source_section_ids join. parent_merged 는 첫 section 의
# judgments 사용 (parent V4 entry 가 그 section 에 있으므로). # judgments 사용 (parent V4 entry 가 그 section 에 있으므로).
v4_all_for_unit = lookup_v4_all_judgments(v4, unit.source_section_ids[0]) # IMP-08 B-3 : forward sub-section V4 aliases (decimal heading_number)
# when canonical ordinal id misses; U1 default = empty list (no change).
_first_sid = unit.source_section_ids[0]
v4_all_for_unit = lookup_v4_all_judgments(
v4, _first_sid, alias_keys=section_alias_by_id.get(_first_sid)
)
# application_candidates : V4 후보 zip 으로 application_mode 변환 # application_candidates : V4 후보 zip 으로 application_mode 변환
app_candidates = [] app_candidates = []

View File

@@ -0,0 +1,180 @@
"""IMP-08 B-3 sub-section drag/drop — schema + V4 alias resolver tests.
Fully synthetic per Codex #7 generalization guardrail:
NO real catalog template_id / frame_id, NO ``v4_full32_result.yaml`` dependency,
NO MDX-specific section ids beyond canonical id format.
Locked scope (Stage 3 R8) :
A. ``derive_parent_id`` canonical ordinal recognition + legacy decimal fallback.
B. ``_resolve_v4_section_key`` exact > alias > None (no parent/sibling promotion).
C. ``align_sections_to_v4_granularity`` canonical ordinal id emit + N-R5
decimal-only alias guard + MdxSection default-construction stability.
"""
from __future__ import annotations
from src.phase_z2_composition import derive_parent_id
from src.phase_z2_pipeline import (
MdxSection,
_resolve_v4_section_key,
align_sections_to_v4_granularity,
)
# ─── A. derive_parent_id ────────────────────────────────────────────────────
def test_derive_parent_id_ordinal_sub():
assert derive_parent_id("03-1-sub-2") == "03-1"
assert derive_parent_id("04-2-sub-1") == "04-2"
def test_derive_parent_id_decimal_legacy_alias():
# Legacy V4 decimal id retains existing behaviour for alias path.
assert derive_parent_id("04-2.1") == "04-2"
def test_derive_parent_id_top_level_none():
assert derive_parent_id("04-1") is None
assert derive_parent_id("04") is None
assert derive_parent_id("nonsense") is None
# ─── B. _resolve_v4_section_key ─────────────────────────────────────────────
def _fake_v4(*keys):
return {"mdx_sections": {k: {"judgments_full32": []} for k in keys}}
def test_alias_resolver_exact_match_wins():
v4 = _fake_v4("04-2-sub-1", "04-2.1")
assert _resolve_v4_section_key(v4, "04-2-sub-1") == "04-2-sub-1"
assert (
_resolve_v4_section_key(v4, "04-2-sub-1", alias_keys=["04-2.1"])
== "04-2-sub-1"
)
def test_alias_resolver_decimal_alias_when_metadata_present():
v4 = _fake_v4("04-2.1")
assert (
_resolve_v4_section_key(v4, "04-2-sub-1", alias_keys=["04-2.1"])
== "04-2.1"
)
def test_alias_resolver_no_parent_promotion():
# parent V4 entry must not be promoted into a sibling sub-section lookup.
v4 = _fake_v4("04-2")
assert _resolve_v4_section_key(v4, "04-2-sub-1") is None
assert (
_resolve_v4_section_key(v4, "04-2-sub-1", alias_keys=["04-2"])
== "04-2"
) # alias is opt-in; only resolves when caller explicitly provides it
def test_alias_resolver_no_sibling_promotion():
# sibling sub-section entry must not be auto-promoted without an alias.
v4 = _fake_v4("04-2-sub-2")
assert _resolve_v4_section_key(v4, "04-2-sub-1") is None
def test_alias_resolver_miss_returns_none():
v4 = _fake_v4("99-1")
assert _resolve_v4_section_key(v4, "04-2-sub-1") is None
assert (
_resolve_v4_section_key(v4, "04-2-sub-1", alias_keys=["04-2.1"])
is None
)
# ─── C. align_sections_to_v4_granularity ────────────────────────────────────
def _section(section_id, num, title, raw_content):
"""Build an MdxSection with default sub-section schema fields."""
return MdxSection(
section_id=section_id,
section_num=num,
title=title,
raw_content=raw_content,
)
def test_mdx_section_default_construction_preserves_4_positional_callers():
# IMP-08 B-3 : MdxSection still accepts the legacy 4-positional shape
# (defaults for heading_number / v4_alias_keys / sub_sections).
s = MdxSection("04-1", 1, "1. Top", "body")
assert s.heading_number is None
assert s.v4_alias_keys == []
assert s.sub_sections == []
def test_align_passthrough_when_v4_key_exact_match():
# Section already aligned to V4 key — aligner keeps it untouched.
sections = [_section("04-1", 1, "1. Top", "body")]
v4 = {"mdx_sections": {"04-1": {"judgments_full32": []}}}
out = align_sections_to_v4_granularity(sections, v4)
assert len(out) == 1
assert out[0].section_id == "04-1"
def test_align_drill_emits_canonical_ordinal_id_with_decimal_alias():
# Decimal H3 headings -> canonical ordinal id + decimal alias (legacy V4 key).
raw = "### 2.1 First\nbody1\n### 2.2 Second\nbody2\n"
sections = [_section("04-2", 2, "2. Parent", raw)]
v4 = {"mdx_sections": {}} # forces drill (no exact key)
out = align_sections_to_v4_granularity(sections, v4)
assert [s.section_id for s in out] == ["04-2-sub-1", "04-2-sub-2"]
assert [s.heading_number for s in out] == ["2.1", "2.2"]
# N-R5 : decimal headings -> alias emitted.
assert out[0].v4_alias_keys == ["04-2.1"]
assert out[1].v4_alias_keys == ["04-2.2"]
def test_align_drill_integer_only_h3_emits_no_alias_n_r5_guard():
# N-R5 : integer-only H3 (e.g., "### 1 Title") must NOT generate an alias,
# otherwise it would collide with sibling parent V4 entries (`{mdx_id}-1`).
raw = "### 1 Alpha\nbody1\n### 2 Beta\nbody2\n"
sections = [_section("05-2", 2, "2. Parent", raw)]
v4 = {"mdx_sections": {}}
out = align_sections_to_v4_granularity(sections, v4)
assert [s.section_id for s in out] == ["05-2-sub-1", "05-2-sub-2"]
assert [s.heading_number for s in out] == ["1", "2"]
assert out[0].v4_alias_keys == []
assert out[1].v4_alias_keys == []
def test_align_drill_undecorated_h3_emits_no_alias():
# Plain `### Title` without numeric prefix -> heading_number=None, no alias.
raw = "### Alpha\nbody1\n### Beta\nbody2\n"
sections = [_section("03-3", 3, "3. Parent", raw)]
v4 = {"mdx_sections": {}}
out = align_sections_to_v4_granularity(sections, v4)
assert [s.section_id for s in out] == ["03-3-sub-1", "03-3-sub-2"]
assert [s.heading_number for s in out] == [None, None]
assert all(s.v4_alias_keys == [] for s in out)
def test_align_no_h3_passes_section_through_unchanged():
# No H3 sub-headings in raw_content -> aligner keeps the section.
sections = [_section("04-1", 1, "1. Top", "no subheadings here\njust prose")]
v4 = {"mdx_sections": {}}
out = align_sections_to_v4_granularity(sections, v4)
assert len(out) == 1
assert out[0].section_id == "04-1"
def test_align_resolver_round_trip_with_legacy_v4_alias():
# End-to-end : aligner emits canonical id + alias keys; resolver finds the
# legacy decimal key in V4 via alias path (no parent promotion).
raw = "### 2.1 First\nbody1\n"
sections = [_section("04-2", 2, "2. Parent", raw)]
v4 = {"mdx_sections": {"04-2.1": {"judgments_full32": []}}}
out = align_sections_to_v4_granularity(sections, v4)
sub = out[0]
assert sub.section_id == "04-2-sub-1"
resolved = _resolve_v4_section_key(
v4, sub.section_id, alias_keys=sub.v4_alias_keys
)
assert resolved == "04-2.1"