wip: phase_z2 evidence 파이프라인 + matching 실험(phase2~26) + 프론트 trace 패널 진행분 스냅샷

- src: phase_z2 composition/mapper/pipeline/placement_planner/retry, ai_fallback(prompts/schema/validate), mdx_text_atoms 신규
- Front: PipelineTracePanel 신규, FramePanel/SlideCanvas/Home/designAgentApi 등 갱신 + 테스트 4종 추가
- templates/phase_z2: catalog(component_expansion_registry, node_slot_mapping 신규), frames, families, slide_base 갱신
- tests/matching: phase2~26 매칭 실험 스크립트·리포트·온톨로지 전체 (미커밋 진행분)
- tests: b4_v4 evidence, task5~28.5 시리즈, regression(imp95 baseline) 등 신규 테스트 대량 추가
- docs/reference: MDX 구조 인벤토리, MDX→Frame 구조 계약 문서
- scripts: mdx 계약/parity/coverage/viewport 체크, gitea comment, run sync 유틸
- .gitignore: tmp*.json, chromedriver, .orchestrator, *.pkl, Front_test* 등 임시/스냅샷 제외

미완성 작업의 보존용 스냅샷 커밋 (2026-07-02)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-02 17:03:42 +09:00
co-authored by Claude Opus 4.8
parent 97b7833a1b
commit b836e79ee1
527 changed files with 673036 additions and 717 deletions
+181
View File
@@ -0,0 +1,181 @@
"""Extract content-preserving text atoms from MDX-like source.
The extractor is intentionally conservative: it ignores markdown/HTML/JSX
syntax, style declarations, imports, and frontmatter, but preserves semantic
text that a slide conversion pipeline must not add, drop, or rewrite.
"""
from __future__ import annotations
from dataclasses import dataclass
import hashlib
import html
import re
from pathlib import Path
from typing import Iterable
_FRONTMATTER_RE = re.compile(r"\A---\s*\n.*?\n---\s*\n", re.DOTALL)
_TAG_RE = re.compile(r"<[^>]+>")
_IMAGE_RE = re.compile(r"!\[([^\]]*)\]\([^)]+\)")
_SUMMARY_TAG_RE = re.compile(r"<summary\b[^>]*>(.*?)</summary>", re.IGNORECASE)
_NOTE_RE = re.compile(r"^:::\s*(?:note|tip|info|warning|danger)(?:\[(.*?)\])?\s*$", re.IGNORECASE)
_TABLE_SEPARATOR_RE = re.compile(r"^\s*\|?\s*:?-{2,}:?\s*(?:\|\s*:?-{2,}:?\s*)+\|?\s*$")
_HEADING_RE = re.compile(r"^\s{0,3}#{1,6}\s+")
_LIST_MARKER_RE = re.compile(r"^\s*(?:[-*+]|\d+[.)])\s+")
_EMPHASIS_RE = re.compile(r"[*_`~]+")
_BR_RE = re.compile(r"<br\s*/?>", re.IGNORECASE)
_STYLE_PROP_RE = re.compile(r"^\s*[A-Za-z][A-Za-z0-9_-]*\s*:\s*['\"#]?[A-Za-z0-9#().,% -]+['\"]?\s*,?\s*$")
_JS_EVENT_RE = re.compile(r"^\s*(?:on[A-Z][A-Za-z]+|style|className)\s*=", re.ASCII)
_IMPORT_EXPORT_RE = re.compile(r"^\s*(?:import|export)\s+")
@dataclass(frozen=True)
class TextAtom:
source: str
line_no: int
kind: str
text: str
normalized: str
digest: str
def normalize_text_atom(text: str) -> str:
"""Normalize syntax-only variation while preserving semantic content."""
text = html.unescape(text)
text = text.replace("\u00a0", " ")
text = text.replace("<br/>", " ").replace("<br />", " ").replace("<br>", " ")
text = _TAG_RE.sub(" ", text)
text = _EMPHASIS_RE.sub("", text)
text = text.replace("&lt;", "<").replace("&gt;", ">").replace("&amp;", "&")
text = re.sub(r"\s+", " ", text)
return text.strip()
def atom_digest(normalized: str) -> str:
return hashlib.sha1(normalized.encode("utf-8")).hexdigest()
def extract_text_atoms(path_or_text: str | Path, *, source_name: str | None = None) -> list[TextAtom]:
if isinstance(path_or_text, Path):
text = path_or_text.read_text(encoding="utf-8")
source = source_name or path_or_text.name
else:
maybe_path = Path(path_or_text)
if "\n" not in path_or_text and maybe_path.exists():
text = maybe_path.read_text(encoding="utf-8")
source = source_name or maybe_path.name
else:
text = path_or_text
source = source_name or "<memory>"
text = _FRONTMATTER_RE.sub("", text)
atoms: list[TextAtom] = []
in_fenced_code = False
for line_no, raw_line in enumerate(text.splitlines(), start=1):
line = raw_line.rstrip()
stripped = line.strip()
if not stripped:
continue
if stripped.startswith("```"):
in_fenced_code = not in_fenced_code
continue
if in_fenced_code:
continue
if _IMPORT_EXPORT_RE.match(stripped):
continue
if stripped in {"---", "<br/>", "<br />", "<br>", "</div>", "</details>"}:
continue
if stripped in {"{", "}", "});", "}}", "}}>", ">" }:
continue
if stripped.startswith(("e.currentTarget.", "<div style", "<p style", "<ul style", "<li style", "<h3 style")):
continue
if _JS_EVENT_RE.match(stripped) or _STYLE_PROP_RE.match(stripped):
continue
if stripped.startswith(("<div", "<p", "<ul", "<li", "<h")) and stripped.endswith(">") and not _TAG_RE.sub("", stripped).strip():
continue
if stripped.startswith("</"):
continue
for alt in _IMAGE_RE.findall(stripped):
_append_atom(atoms, source, line_no, "image_alt", alt)
stripped = _IMAGE_RE.sub("", stripped).strip()
if not stripped:
continue
summary_match = _SUMMARY_TAG_RE.search(stripped)
if summary_match:
_append_atom(atoms, source, line_no, "details_summary", summary_match.group(1))
stripped = _SUMMARY_TAG_RE.sub("", stripped).strip()
if not stripped:
continue
note_match = _NOTE_RE.match(stripped)
if note_match:
if note_match.group(1):
_append_atom(atoms, source, line_no, "admonition_title", note_match.group(1))
continue
if stripped.startswith(":::"):
continue
if "|" in stripped and stripped.count("|") >= 2:
if _TABLE_SEPARATOR_RE.match(stripped):
continue
cells = [normalize_text_atom(c) for c in stripped.strip("|").split("|")]
for cell in cells:
if cell:
_append_atom(atoms, source, line_no, "table_cell", cell)
continue
kind = "paragraph"
if _HEADING_RE.match(stripped):
kind = "heading"
stripped = _HEADING_RE.sub("", stripped)
elif _LIST_MARKER_RE.match(stripped):
kind = "list_item"
stripped = _LIST_MARKER_RE.sub("", stripped)
stripped = re.sub(r"^\s*}+\s*>?\s*", "", stripped)
stripped = _BR_RE.sub(" ", stripped)
stripped = _TAG_RE.sub(" ", stripped)
_append_atom(atoms, source, line_no, kind, stripped)
return atoms
def compare_atom_sets(original_atoms: Iterable[TextAtom], standardized_atoms: Iterable[TextAtom]) -> dict[str, list[TextAtom]]:
original_by_digest = {a.digest: a for a in original_atoms}
standardized_by_digest = {a.digest: a for a in standardized_atoms}
missing = [a for digest, a in original_by_digest.items() if digest not in standardized_by_digest]
added = [a for digest, a in standardized_by_digest.items() if digest not in original_by_digest]
return {"missing_from_standardized": missing, "added_in_standardized": added}
def _append_atom(atoms: list[TextAtom], source: str, line_no: int, kind: str, text: str) -> None:
normalized = normalize_text_atom(text)
if not normalized:
return
if _looks_like_syntax_only(normalized):
return
atoms.append(
TextAtom(
source=source,
line_no=line_no,
kind=kind,
text=text.strip(),
normalized=normalized,
digest=atom_digest(normalized),
)
)
def _looks_like_syntax_only(text: str) -> bool:
if text in {"/", "/>", ">", "{", "}", "});"}:
return True
if text.startswith(("style=", "onMouse", "cursor:", "fontWeight:", "color:", "margin", "padding")):
return True
if re.fullmatch(r"[{}(),;:'\"#.\-\s]+", text):
return True
return False
+27 -2
View File
@@ -29,8 +29,12 @@ SYSTEM_PROMPT = (
f" 3. proposal_kind MUST be one of: {_ALLOWED_KINDS}.\n"
f" 4. Do NOT propose any of: {_FORBIDDEN_KINDS}.\n"
" 5. Do NOT change frame_id — V4 rank-1 frame is locked.\n"
" 6. Keep declared frame slots (text/table/image/details) populated.\n"
" 7. Respect Internal Region containment; place content units within "
" 6. Preferred Task-12 output is proposal_kind='design_adaptation_plan': "
"plan frame/layout/repeat changes only; do NOT output final text slots.\n"
" 7. Do NOT create titles, labels, summaries, body text, or slot content. "
"The code layer will place MDX verbatim text.\n"
" 8. Keep declared frame slots (text/table/image/details) accounted for.\n"
" 9. Respect Internal Region containment; place content units within "
"the declared region only."
)
@@ -73,6 +77,27 @@ def build_ai_fallback_prompt(
"figma_partial_json": figma_partial_json,
"internal_region": internal_region,
"mdx_text_READ_ONLY": mdx_text,
"task12_output_contract": {
"preferred_proposal_kind": ProposalKind.DESIGN_ADAPTATION_PLAN.value,
"allowed_operation_examples": [
"increase_repeat_count",
"split_group_across_repeat_blocks",
"rebalance_zone_ratio",
"compact_spacing",
"use_repeatable_frame",
],
"forbidden_content_fields": [
"text",
"title",
"label",
"body",
"slots",
"mdx_text",
"summary",
"raw_html",
"raw_css",
],
},
}
return {
"system": SYSTEM_PROMPT,
+2
View File
@@ -4,6 +4,7 @@ Whitelisted proposal kinds (Stage 2 plan):
- builder_options_patch : zone/frame builder option overrides
- partial_overrides : Internal Region / Frame Slot content overrides
- slot_mapping_proposal : restructuring proposal (content unit mapping)
- design_adaptation_plan : structure-only plan; code applies verbatim MDX
Forbidden output forms (rejected by validator):
- mdx_text (MDX read-only — `feedback_ai_isolation_contract`)
@@ -23,6 +24,7 @@ class ProposalKind(str, Enum):
BUILDER_OPTIONS_PATCH = "builder_options_patch"
PARTIAL_OVERRIDES = "partial_overrides"
SLOT_MAPPING_PROPOSAL = "slot_mapping_proposal"
DESIGN_ADAPTATION_PLAN = "design_adaptation_plan"
FORBIDDEN_KINDS: frozenset[str] = frozenset(
+50
View File
@@ -27,6 +27,43 @@ class AiFallbackValidationError(ValueError):
_SLOT_KINDS = (ProposalKind.PARTIAL_OVERRIDES, ProposalKind.SLOT_MAPPING_PROPOSAL)
_DESIGN_PLAN_FORBIDDEN_CONTENT_KEYS: frozenset[str] = frozenset(
{
"body",
"label",
"labels",
"mdx_text",
"new_text",
"raw_css",
"raw_html",
"slot_payload",
"slots",
"summary",
"text",
"title",
}
)
def _find_forbidden_design_plan_keys(value: Any, path: str = "payload") -> list[str]:
"""Return content-bearing keys that would let AI generate text.
Task 12 contract: design_adaptation_plan may describe structure changes,
but code remains the sole layer that maps MDX verbatim text into slots.
"""
hits: list[str] = []
if isinstance(value, dict):
for key, nested in value.items():
key_str = str(key)
nested_path = f"{path}.{key_str}"
if key_str in _DESIGN_PLAN_FORBIDDEN_CONTENT_KEYS:
hits.append(nested_path)
hits.extend(_find_forbidden_design_plan_keys(nested, nested_path))
elif isinstance(value, list):
for idx, nested in enumerate(value):
hits.extend(_find_forbidden_design_plan_keys(nested, f"{path}[{idx}]"))
return hits
def validate_proposal(
proposal: AiFallbackProposal,
@@ -73,6 +110,19 @@ def validate_proposal(
"from payload.slots (text/table/image/details must remain populated)."
)
if proposal.proposal_kind is ProposalKind.DESIGN_ADAPTATION_PLAN:
operations = payload.get("operations")
if not isinstance(operations, list) or not operations:
raise AiFallbackValidationError(
"design adaptation plan: payload.operations must be a non-empty list."
)
forbidden_paths = _find_forbidden_design_plan_keys(payload)
if forbidden_paths:
raise AiFallbackValidationError(
"design adaptation plan: AI must not emit content-bearing fields "
f"{forbidden_paths}; code maps MDX verbatim text."
)
region_id = payload.get("region_id")
if region_id is not None and internal_region is not None:
declared_region_id = internal_region.get("id")
+20 -3
View File
@@ -1039,10 +1039,22 @@ def select_composition_units(
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).
all_section_ids: ordered section id list. Used for final source-order
emission; also consulted for provisional coverage fill.
allow_provisional_fill: opt-in for last-resort provisional fill.
"""
source_order_index = {sid: i for i, sid in enumerate(all_section_ids or [])}
def _source_order_key(c: CompositionUnit) -> tuple[int, int, str]:
order_values = [
source_order_index[sid]
for sid in c.source_section_ids
if sid in source_order_index
]
if not order_values:
return (10**9, 10**9, "+".join(c.source_section_ids))
return (min(order_values), max(order_values), "+".join(c.source_section_ids))
scored = [score_candidate(c) for c in candidates]
viable = [
c for c in scored
@@ -1080,6 +1092,11 @@ def select_composition_units(
selected.append(c)
covered.update(c.source_section_ids)
# Task 9 (2026-05-28): frame score decides the covering frame, but final
# unit emission must preserve MDX source order.
if source_order_index:
selected.sort(key=_source_order_key)
return selected
@@ -1163,7 +1180,7 @@ def plan_composition(sections, v4_lookup_fn, v4_label_to_status: dict,
units = select_composition_units(
candidates,
allowed_statuses,
all_section_ids=[s.section_id for s in sections] if allow_provisional_fill else None,
all_section_ids=[s.section_id for s in sections],
allow_provisional_fill=allow_provisional_fill,
)
preset = select_layout_preset(units)
+172 -31
View File
@@ -172,11 +172,46 @@ def _split_label_for_bar(label: str) -> tuple[str, str]:
return label.strip(), ""
def normalize_markdown_inline_text(value: str | None) -> str:
"""Strip authoring-only Markdown/HTML markers without rewriting words."""
if value is None:
return ""
text = str(value).strip()
if not text:
return ""
if re.fullmatch(r"[-:|\s]+", text):
return ""
text = re.sub(r"<br\s*/?>", " ", text, flags=re.IGNORECASE)
text = text.replace("&nbsp;", " ")
text = re.sub(r"!\[([^\]]*)\]\([^)]+\)", r"\1", text)
text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text)
text = re.sub(r"`([^`]+)`", r"\1", text)
text = re.sub(r"^\s*(?:[-*+]|\u2022)\s+", "", text)
if text.startswith("|") and text.endswith("|"):
cells = [c.strip() for c in text.strip("|").split("|") if c.strip()]
if all(re.fullmatch(r"[-: ]+", c) for c in cells):
return ""
text = " / ".join(cells)
previous = None
while previous != text:
previous = text
text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
text = re.sub(r"__(.+?)__", r"\1", text)
text = re.sub(r"(?<!\*)\*([^*\n]+?)\*(?!\*)", r"\1", text)
text = re.sub(r"(?<!_)_([^_\n]+?)_(?!_)", r"\1", text)
return re.sub(r"\s+", " ", text).strip()
def _extract_bold_or_plain(top_line: str) -> str:
bold = re.search(r"\*\*(.+?)\*\*", top_line)
if bold:
return bold.group(1).strip()
return top_line.strip().lstrip("*-").strip()
return normalize_markdown_inline_text(bold.group(1))
return normalize_markdown_inline_text(top_line)
def _text_lines_with_indent(nested_lines: list[str], base_indent: int = 0) -> list[dict]:
@@ -193,8 +228,9 @@ def _text_lines_with_indent(nested_lines: list[str], base_indent: int = 0) -> li
rel = max(0, indent - base_indent)
indent_level = max(0, rel // 2)
text = re.sub(r"^[\*\-]\s+", "", s)
text = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", text)
text_lines.append({"text": text, "indent": indent_level})
text = normalize_markdown_inline_text(text)
if text:
text_lines.append({"text": text, "indent": indent_level})
return text_lines
@@ -213,12 +249,23 @@ def _extract_markdown_table(content: str) -> tuple[list[dict] | None, str]:
return None, content
rows = [r.strip() for r in m.group(1).strip().splitlines() if r.strip()]
transforms = []
header_cells = [c.strip() for c in rows[0].strip("|").split("|")]
if len(header_cells) >= 3:
header_from = normalize_markdown_inline_text(header_cells[0])
header_mid = normalize_markdown_inline_text(header_cells[1])
header_to = normalize_markdown_inline_text(header_cells[2])
if header_from or header_mid or header_to:
transforms.append({
"from": " / ".join(x for x in [header_from, header_mid] if x),
"to": header_to,
})
for r in rows[2:]:
cells = [c.strip() for c in r.strip("|").split("|")]
if len(cells) >= 3:
f = re.sub(r"\*\*(.+?)\*\*", r"\1", cells[0])
t = re.sub(r"\*\*(.+?)\*\*", r"\1", cells[2])
transforms.append({"from": f, "to": t})
f = normalize_markdown_inline_text(cells[0])
t = normalize_markdown_inline_text(cells[2])
if f or t:
transforms.append({"from": f, "to": t})
remaining = content[:m.start()] + content[m.end():]
return (transforms or None), remaining
@@ -244,16 +291,18 @@ def _parse_nested_pillar_sections(nested_lines: list[str]) -> list[dict]:
if cur_heading is not None:
sections.append({"heading": cur_heading, "text_lines": cur_text_lines})
bold = re.search(r"\*\*(.+?)\*\*", stripped)
cur_heading = (bold.group(1).strip() if bold
else stripped.lstrip("*-").strip())
cur_heading = normalize_markdown_inline_text(
bold.group(1) if bold else stripped
)
cur_text_lines = []
section_base_indent = indent
else:
rel_indent = indent - section_base_indent
indent_level = max(0, (rel_indent - 2) // 2)
text = re.sub(r"^[\*\-]\s+", "", stripped)
text = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", text)
cur_text_lines.append({"text": text, "indent": indent_level})
text = normalize_markdown_inline_text(text)
if text:
cur_text_lines.append({"text": text, "indent": indent_level})
if cur_heading is not None:
sections.append({"heading": cur_heading, "text_lines": cur_text_lines})
@@ -291,6 +340,39 @@ def parse_quadrant_item(unit: tuple[str, list[str]]) -> dict:
return {"label": label, "body": body}
def _group_parsed_items_for_slots(parsed: list[dict], slot_count: int) -> list[dict]:
"""Keep every source item while avoiding one visual card per tiny bullet.
Long sections can arrive as many sibling bullets even though the target
frame has a small fixed data axis. In that case, preserve order and text by
distributing the items across the existing slots instead of creating dozens
of clipped cards.
"""
if slot_count <= 0 or len(parsed) <= slot_count * 2:
return parsed
grouped: list[dict] = []
for slot_index in range(slot_count):
start = (len(parsed) * slot_index) // slot_count
end = (len(parsed) * (slot_index + 1)) // slot_count
bucket = parsed[start:end]
if not bucket:
grouped.append({"label": "", "body": []})
continue
body: list[dict] = []
first = bucket[0]
body.extend(first.get("body") or [])
for item in bucket[1:]:
label = item.get("label") or ""
if label:
body.append({"text": label, "indent": 0})
body.extend(item.get("body") or [])
grouped.append({"label": first.get("label") or "", "body": body})
return grouped
def parse_compare_row_2col_item(unit: tuple[str, list[str]]) -> dict:
"""F18-style — bold = category label, nested 2 bullets = col_a / col_b values.
@@ -309,8 +391,9 @@ def parse_compare_row_2col_item(unit: tuple[str, list[str]]) -> dict:
l_strip = l.strip()
if re.match(r"^[\*\-]\s", l_strip):
txt = re.sub(r"^[\*\-]\s+", "", l_strip)
txt = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", txt)
nested.append(txt)
txt = normalize_markdown_inline_text(txt)
if txt:
nested.append(txt)
col_a = nested[0] if len(nested) > 0 else ""
col_b = nested[1] if len(nested) > 1 else ""
return {"label": label, "col_a": col_a, "col_b": col_b}
@@ -331,22 +414,57 @@ def _parse_column_sections(body: str, transform_first: bool) -> list[dict]:
transform_first=True 면 첫 top-bullet 의 nested 안에 markdown table 이 있으면
text_lines 대신 transforms 로 산출 (AS-IS/TO-BE).
"""
groups = _split_top_bullets(body)
sections = []
for i, (top_line, nested_lines) in enumerate(groups):
title = _extract_bold_or_plain(top_line)
if i == 0 and transform_first:
nested_text = "\n".join(nested_lines)
transforms, _ = _extract_markdown_table(nested_text)
current_title: str | None = None
current_lines: list[str] = []
def flush() -> None:
nonlocal current_title, current_lines
if current_title is None:
return
raw_body = "\n".join(current_lines)
if transform_first and not sections:
transforms, remaining = _extract_markdown_table(raw_body)
if transforms:
sections.append({"title": title, "transforms": transforms})
continue
non_empty = [l for l in nested_lines if l.strip()]
base = min((len(l) - len(l.lstrip()) for l in non_empty), default=0)
sections.append({
"title": current_title,
"transforms": transforms,
"text_lines": _text_lines_with_indent(
remaining.splitlines(),
base_indent=0,
),
})
current_title = None
current_lines = []
return
sections.append({
"title": title,
"text_lines": _text_lines_with_indent(nested_lines, base_indent=base),
"title": current_title,
"text_lines": _text_lines_with_indent(current_lines, base_indent=0),
})
current_title = None
current_lines = []
for line in body.splitlines():
stripped = line.strip()
if not stripped or stripped == "---":
continue
if re.match(r"^[\*\-]\s+", stripped):
text = re.sub(r"^[\*\-]\s+", "", stripped)
text_norm = normalize_markdown_inline_text(text)
bold = re.search(r"\*\*(.+?)\*\*", text)
if bold:
flush()
current_title = normalize_markdown_inline_text(bold.group(1))
current_lines = []
elif current_title is not None and text_norm:
current_lines.append(f"- {text_norm}")
elif text_norm:
current_title = text_norm
current_lines = []
elif current_title is not None:
current_lines.append(line)
flush()
return sections
@@ -375,7 +493,7 @@ def _resolve_title(section, payload_spec: dict, contract: dict) -> dict:
if src is None:
return {}
if src == "section.title":
return {"title": section.title}
return {"title": normalize_markdown_inline_text(section.title)}
raise ValueError(
f"Contract '{contract['template_id']}' has unsupported title source "
f"'{src}'. v0 supports 'section.title' only."
@@ -439,6 +557,10 @@ def _build_process_product_pair(section, units, contract) -> dict:
payload: dict = {}
payload.update(_resolve_title(section, contract["payload"], contract))
subheadings = [
normalize_markdown_inline_text(m.group(1))
for m in re.finditer(r"(?m)^###\s+(.+?)\s*$", getattr(section, "raw_content", "") or "")
]
for i, col in enumerate(cols):
sub_title, sub_body = units[i]
@@ -455,7 +577,7 @@ def _build_process_product_pair(section, units, contract) -> dict:
while len(sections_list) < pad_to:
sections_list.append(dict(empty_template))
sections_list = sections_list[:pad_to]
payload[col["title_to"]] = sub_title
payload[col["title_to"]] = subheadings[i] if i < len(subheadings) else sub_title
payload[col["body_to"]] = {"sections": sections_list}
return payload
@@ -487,6 +609,7 @@ def _build_quadrant_flat_slots(section, units, contract) -> dict:
pad_to = options.get("pad_to", 4)
truncate_at = options.get("truncate_at", pad_to)
max_dynamic_slots = options.get("max_dynamic_slots")
label_key = options.get("label_key_pattern", "quadrant_{n}_label")
body_key = options.get("body_key_pattern", "quadrant_{n}_body")
empty_label = options.get("empty_label", "")
@@ -495,10 +618,29 @@ def _build_quadrant_flat_slots(section, units, contract) -> dict:
payload: dict = {}
payload.update(_resolve_title(section, contract["payload"], contract))
visible_units = list(units[:truncate_at])
visible_units = list(units)
parsed = [parser(u) for u in visible_units]
if max_dynamic_slots is None:
# Keep the original fixed-card frame readable. Long content is grouped
# into the declared slots rather than silently truncated or exploded
# into dozens of clipped visual cards.
max_dynamic_slots = pad_to
dynamic_count = min(max(pad_to, len(parsed)), max_dynamic_slots)
parsed = _group_parsed_items_for_slots(parsed, dynamic_count)
subheadings = [
normalize_markdown_inline_text(m.group(1))
for m in re.finditer(r"(?m)^###\s+(.+?)\s*$", getattr(section, "raw_content", "") or "")
]
if subheadings and parsed:
for i, heading in enumerate(subheadings):
if not heading:
continue
target_index = min(len(parsed) - 1, (len(parsed) * i) // len(subheadings))
body = parsed[target_index].setdefault("body", [])
if not any(line.get("text") == heading for line in body if isinstance(line, dict)):
body.insert(0, {"text": heading, "indent": 0})
for i in range(pad_to):
for i in range(dynamic_count):
n = i + 1
if i < len(parsed):
payload[label_key.format(n=n)] = parsed[i]["label"]
@@ -508,8 +650,7 @@ def _build_quadrant_flat_slots(section, units, contract) -> dict:
# list / dict default 는 항상 새 객체 — shared reference 방지
payload[body_key.format(n=n)] = list(empty_body) if isinstance(empty_body, list) else empty_body
if len(units) > truncate_at:
payload["_truncated_count"] = len(units) - truncate_at
payload["_slot_count"] = dynamic_count
return payload
+3255 -166
View File
File diff suppressed because it is too large Load Diff
+242 -5
View File
@@ -27,8 +27,9 @@ v0 minimal :
from __future__ import annotations
import os
from dataclasses import dataclass, field
from typing import Any, Optional
from typing import Any, Callable, Optional
# B4 v0 input contract = B1 (ContentObject) + B2 (InternalRegion / ZoneRegionPlan).
# 세 module 모두 dormant — runtime path 와 무관한 layer-agnostic 의존.
@@ -40,6 +41,43 @@ from phase_z2_internal_region_planner import (
)
# ─── IMP-95 u1 — V4 evidence flag + trace key constants ─────────
#
# u1 scope-lock : env flag reader + trace key constant names only.
# Selector wiring (u2), plan_placement signature (u3), Step 11 wiring
# (u4), gatekeeper short-circuit (u5), partial_exists precheck (u6),
# regression tests (u7~u10), and status-board markers (u11) are out of
# scope for u1.
PHASE_Z_B4_V4_EVIDENCE_ENV = "PHASE_Z_B4_V4_EVIDENCE"
TRACE_KEY_FRAME_SELECTION_BASIS = "frame_selection_basis"
TRACE_KEY_V4_EVIDENCE_CONSUMED = "v4_evidence_consumed"
TRACE_KEY_B4_V0_FALLBACK_REASON = "b4_v0_fallback_reason"
TRACE_KEY_V4_RANK_USED = "v4_rank_used"
TRACE_KEY_V4_B4_FRAME_MATCH = "v4_b4_frame_match"
TRACE_KEY_B4_PARTIAL_MISSING_SKIP = "b4_partial_missing_skip"
FRAME_SELECTION_BASIS_DECLARATION_ORDER = "declaration_order"
FRAME_SELECTION_BASIS_V4_RANKED = "v4_ranked"
def _b4_v4_evidence_enabled() -> bool:
"""IMP-95 u1 — PHASE_Z_B4_V4_EVIDENCE env flag reader (default OFF).
Gates V4-aware frame selection in _select_frame (u2) and Step 11 wiring
(u4). Default OFF preserves declaration-order behavior and final.html
SHA parity. Independent of PHASE_Z_B4_MAPPER_SOURCE (IMP-89 89-a) and
PHASE_Z_B4_GATEKEEPER. Truthy values: '1', 'true', 'yes' (case-insensitive,
trimmed) — mirrors the contract at src/phase_z2_pipeline.py:_b4_mapper_source_enabled.
"""
return os.environ.get(PHASE_Z_B4_V4_EVIDENCE_ENV, "").strip().lower() in {
"1",
"true",
"yes",
}
# ─── Output schema (SPEC v1 §4.1) ────────────────────────────────
@@ -71,6 +109,11 @@ class PlacementPlan:
slot_assignments : Stage B 결과 (region.content_unit → Frame Slot)
overflow_buffer : v0 = 빈 list (preview/details path 미활성)
rejection : 매칭 안 된 / cardinality 초과 등 — 자동 렌더 X 신호
selection_trace : IMP-95 u3 additive — frame selection telemetry.
Always populated. Default = declaration_order
(legacy behavior); V4-aware when env flag ON.
Keys mirror u1 TRACE_KEY_* constants — Step 11
wiring (u4) reads from this field, never inlines names.
"""
section_id: str
@@ -80,6 +123,7 @@ class PlacementPlan:
slot_assignments: list[SlotAssignment] = field(default_factory=list)
overflow_buffer: list[dict] = field(default_factory=list)
rejection: list[dict] = field(default_factory=list)
selection_trace: dict[str, Any] = field(default_factory=dict)
# ─── Frame selection ─────────────────────────────────────────────
@@ -107,6 +151,138 @@ def _select_frame(
return None
# ─── IMP-95 u2 — V4 evidence-aware selector ──────────────────────
#
# Stage 2 u2 scope-lock : add a V4-aware selector that ranks eligible
# contracts under the existing accepted_content_types ⊇ constraint,
# then falls back to declaration order. plan_placement wiring (u3),
# Step 11 trace exposure (u4), gatekeeper short-circuit (u5), and
# partial_exists precheck (u6) are out of scope for u2.
#
# Behavior contract (IMP-95 issue body + Stage 1 guardrails) :
# - V4 only re-orders among contracts that already satisfy
# accepted_content_types ⊇ content_type_set.
# - V4 ranks are tried in order; an ineligible / unmatched rank
# falls through to the next rank (Stage 1 unresolved Q1 lock).
# - When V4 evidence is empty / None / fails to resolve any rank,
# fall back to declaration-order _select_frame() (preserves
# final.html SHA parity under flag OFF when this helper is unused).
# - Returns (Optional[frame_contract], selection_metadata dict)
# using the u1 TRACE_KEY_* constants so u3 cannot drift names.
_FALLBACK_REASON_V4_EVIDENCE_EMPTY = "v4_evidence_empty"
_FALLBACK_REASON_NO_V4_RANK_ELIGIBLE = "no_v4_rank_eligible"
def _select_frame_v4_aware(
content_objects: list[ContentObject],
frame_contracts: list[dict[str, Any]],
v4_candidates: Optional[list[Any]] = None,
partial_exists: Optional[Callable[[str], bool]] = None,
) -> tuple[Optional[dict[str, Any]], dict[str, Any]]:
"""V4 evidence-aware variant of :func:`_select_frame`.
Algorithm :
1. content_type_set = {obj.type for obj in content_objects} (re-uses
the legacy ⊇ semantics — V4 only re-orders among eligible contracts).
2. When ``v4_candidates`` is non-empty, iterate rank-ordered :
a. Match candidate to a frame_contract by ``template_id`` first,
``str(frame_id)`` second (duck-typed — composition.py:678-684).
b. If the matched contract satisfies the ⊇ constraint, return it
with basis = 'v4_ranked' + ``v4_rank_used`` = current index.
c. Otherwise skip the candidate and continue to the next rank.
3. Fall back to declaration-order selection via :func:`_select_frame`.
The trace records why V4 evidence did not resolve
('v4_evidence_empty' | 'no_v4_rank_eligible').
Returns :
(frame_contract | None, selection_metadata dict)
Selection metadata keys (use u1 TRACE_KEY_* constants) :
- frame_selection_basis : 'v4_ranked' | 'declaration_order'
- v4_evidence_consumed : bool — True iff a V4 rank resolved.
- v4_rank_used : int | None — 0-based rank index, else None.
- v4_b4_frame_match : bool — True iff returned contract came from V4.
- b4_v0_fallback_reason : str | None — set only when basis = declaration_order.
- b4_partial_missing_skip : list[dict] — IMP-95 u6 ordered record of V4
ranks skipped because their template_id had
no partial HTML (contract-only template, 19
in catalog as of 2026-05-25). Always present;
empty when ``partial_exists`` is not supplied,
no V4 evidence consulted, or no rank skipped.
IMP-95 u6 — ``partial_exists`` callable :
When supplied and a V4 candidate resolves to a frame_contract by
template_id/frame_id, the matched contract's ``template_id`` is passed
to ``partial_exists`` BEFORE the accepted_content_types ⊇ check. If the
callable returns False (no partial HTML on disk), the rank is recorded
into ``b4_partial_missing_skip`` and the loop continues to the next
rank. None (default) disables the precheck entirely — preserves the
pre-u6 V4-aware selector behavior verbatim.
"""
content_type_set = {obj.type for obj in content_objects}
partial_missing_skips: list[dict[str, Any]] = []
if v4_candidates:
for rank_idx, cand in enumerate(v4_candidates):
cand_tid = getattr(cand, "template_id", None)
cand_fid = getattr(cand, "frame_id", None)
matched: Optional[dict[str, Any]] = None
# Two-pass match — template_id first across ALL contracts, then frame_id.
# Single-pass per contract would let an earlier contract whose frame_id
# matches the candidate win over a later contract whose template_id matches,
# contradicting the documented precedence (composition.py:678-684).
if cand_tid:
for fc in frame_contracts:
if fc.get("template_id") == cand_tid:
matched = fc
break
if matched is None and cand_fid is not None:
cand_fid_str = str(cand_fid)
for fc in frame_contracts:
fc_fid = fc.get("frame_id")
if fc_fid is not None and str(fc_fid) == cand_fid_str:
matched = fc
break
if matched is None:
continue
# IMP-95 u6 — partial_exists precheck. Skip contract-only templates
# (no partial HTML on disk) before accepting the V4 rank. Trace the
# skip in declaration order so downstream consumers can audit the
# rank fall-through. Precheck is gated on partial_exists callable;
# None (default) preserves the pre-u6 selector behavior.
matched_tid = matched.get("template_id")
if partial_exists is not None and matched_tid is not None:
if not partial_exists(matched_tid):
partial_missing_skips.append({
"rank": rank_idx,
"template_id": matched_tid,
})
continue
accepted = set(matched.get("accepted_content_types") or [])
if content_type_set <= accepted:
return matched, {
TRACE_KEY_FRAME_SELECTION_BASIS: FRAME_SELECTION_BASIS_V4_RANKED,
TRACE_KEY_V4_EVIDENCE_CONSUMED: True,
TRACE_KEY_V4_RANK_USED: rank_idx,
TRACE_KEY_V4_B4_FRAME_MATCH: True,
TRACE_KEY_B4_V0_FALLBACK_REASON: None,
TRACE_KEY_B4_PARTIAL_MISSING_SKIP: partial_missing_skips,
}
fallback_reason = _FALLBACK_REASON_NO_V4_RANK_ELIGIBLE
else:
fallback_reason = _FALLBACK_REASON_V4_EVIDENCE_EMPTY
return _select_frame(content_objects, frame_contracts), {
TRACE_KEY_FRAME_SELECTION_BASIS: FRAME_SELECTION_BASIS_DECLARATION_ORDER,
TRACE_KEY_V4_EVIDENCE_CONSUMED: False,
TRACE_KEY_V4_RANK_USED: None,
TRACE_KEY_V4_B4_FRAME_MATCH: False,
TRACE_KEY_B4_V0_FALLBACK_REASON: fallback_reason,
TRACE_KEY_B4_PARTIAL_MISSING_SKIP: partial_missing_skips,
}
# ─── Sub_zone assignment (Stage B) ───────────────────────────────
@@ -161,16 +337,43 @@ def _assign_region_to_sub_zone(
# ─── Public entry ────────────────────────────────────────────────
def _declaration_order_selection_trace() -> dict[str, Any]:
"""IMP-95 u3 — selection_trace default (legacy declaration-order path).
Returned when ``PHASE_Z_B4_V4_EVIDENCE`` is OFF or when no V4 evidence
is offered. Keys are stable u1 ``TRACE_KEY_*`` constants so u4 (Step 11
wiring) cannot drift names. ``b4_v0_fallback_reason`` is ``None`` here
because the V4 path is not attempted at all under this branch — the
fallback-reason enum is reserved for the V4-aware selector's own
fall-through bookkeeping (u2 contract).
"""
return {
TRACE_KEY_FRAME_SELECTION_BASIS: FRAME_SELECTION_BASIS_DECLARATION_ORDER,
TRACE_KEY_V4_EVIDENCE_CONSUMED: False,
TRACE_KEY_V4_RANK_USED: None,
TRACE_KEY_V4_B4_FRAME_MATCH: False,
TRACE_KEY_B4_V0_FALLBACK_REASON: None,
TRACE_KEY_B4_PARTIAL_MISSING_SKIP: [],
}
def plan_placement(
content_objects: list[ContentObject],
frame_contracts: list[dict[str, Any]],
section_id: str = "",
v4_candidates: Optional[list[Any]] = None,
partial_exists: Optional[Callable[[str], bool]] = None,
) -> PlacementPlan:
"""ContentObject[] + frame_contracts → PlacementPlan (Stage A + Stage B 통합).
v0 algorithm :
1. Stage A = B2 plan_internal_regions() 호출 → internal_regions 획득
2. frame 선택 : accepted_content_types cover + 입력 순서 first
2. frame 선택 : accepted_content_types cover + 입력 순서 first.
IMP-95 u3 — when ``PHASE_Z_B4_V4_EVIDENCE`` env flag is ON,
delegates to :func:`_select_frame_v4_aware` with the optional
``v4_candidates`` evidence. Flag OFF (default) preserves the
declaration-order ``_select_frame`` call site (final.html SHA
parity guarantee, Stage 2 A1 + A10).
- cover 실패 시 → rejection + early return
3. selected_frame 의 sub_zones 읽음 (B3 catalog)
4. Stage B (region 1:1 sub_zone 매핑) :
@@ -186,11 +389,31 @@ def plan_placement(
frame_contracts : list[dict] — frame_contracts.yaml 의 contract dict list
(YAML declaration order = list 순서로 입력 권고)
section_id : region_id / 결과 식별자 prefix
v4_candidates : optional V4 rank-ordered evidence (duck-typed —
``template_id`` + ``frame_id`` per
``src/phase_z2_composition.py:678-684``). Consumed
ONLY when ``PHASE_Z_B4_V4_EVIDENCE`` flag is ON
(default OFF → ignored, declaration order preserved).
Step 11 wiring (u4) forwards ``unit.v4_candidates``;
existing call sites that omit the kwarg keep
identical legacy behavior.
partial_exists : optional ``Callable[[str], bool]`` — IMP-95 u6
contract-only / no-partial precheck. When supplied
AND ``PHASE_Z_B4_V4_EVIDENCE`` is ON, V4 ranks whose
resolved ``template_id`` returns False are skipped
(recorded into ``b4_partial_missing_skip``) and the
loop falls through to the next rank. None (default)
disables the precheck — pre-u6 selector behavior.
Declaration-order legacy path (flag OFF) is NEVER
pre-checked, preserving final.html SHA parity.
Returns :
PlacementPlan
PlacementPlan — ``selection_trace`` (additive) records which path
(declaration_order vs v4_ranked) selected the frame and why a V4
candidate was / was not consumed.
"""
plan = PlacementPlan(section_id=section_id)
plan.selection_trace = _declaration_order_selection_trace()
if not content_objects:
return plan
@@ -203,8 +426,22 @@ def plan_placement(
)
plan.internal_regions = list(zone_plan.internal_regions)
# 2. frame 선택
selected_frame = _select_frame(content_objects, frame_contracts)
# 2. frame 선택 — IMP-95 u3 flag-gated V4-aware path.
# Flag OFF preserves declaration-order _select_frame call (legacy parity).
# Flag ON routes to _select_frame_v4_aware (u2) with optional V4 evidence;
# when v4_candidates is empty/None the selector itself emits the
# 'v4_evidence_empty' fallback reason and returns the same declaration-order
# frame, so flag-ON + no-evidence remains behaviorally equivalent for
# selected_frame_id / selected_template_id.
if _b4_v4_evidence_enabled():
selected_frame, plan.selection_trace = _select_frame_v4_aware(
content_objects,
frame_contracts,
v4_candidates,
partial_exists=partial_exists,
)
else:
selected_frame = _select_frame(content_objects, frame_contracts)
if selected_frame is None:
plan.rejection.append({
"reason": "no_frame_covers_content_types",
+61 -48
View File
@@ -47,36 +47,29 @@ def plan_zone_ratio_retry(
fit_classification: dict,
router_decision: dict,
safety_margin_px: int = DEFAULT_SAFETY_MARGIN_PX,
override_zone_geometries: Optional[dict[str, dict]] = None,
) -> Optional[dict]:
"""zone_ratio_retry 의 redistribution plan 을 산출.
"""Build a deterministic zone-height redistribution plan.
*plan 만*. 실제 height 적용 / rerender X (caller 가 처리).
Returns:
None : retry 시도 자체가 불필요 (router 가 zone_ratio_retry 제안 X)
dict : retry attempt 정보 (feasible 여부 + 상세)
feasible=True 이면 caller 가 zones_after 로 layout_css 재구성 + rerender 시도.
feasible=False 이면 caller 는 retry 포기 (original final.html 유지).
T28.5b: zones listed in ``override_zone_geometries`` are manual/frozen.
A frozen zone can be neither the overflow target nor a donor.
"""
if not router_decision.get("router_active"):
return None
# zone_ratio_retry 가 router 제안에 포함된 첫 classification 을 target 으로
target_cls = None
for cls in fit_classification.get("classifications", []) or []:
if cls.get("proposed_action") == "zone_ratio_retry":
target_cls = cls
break
if target_cls is None:
return None # 다른 action (popup / reselect) — 본 retry 대상 아님
return None
target_zone_position = target_cls.get("zone_position")
target_excess_y = float(target_cls.get("inputs", {}).get("excess_y", 0))
# round up to integer (subpixel 끼면 부족할 수 있음)
target_added_px = int(math.ceil(target_excess_y)) + int(safety_margin_px)
manual_positions = set((override_zone_geometries or {}).keys())
# zones_before — debug_zones 의 height_px 를 모음
zones_before: dict[str, int] = {}
zone_min_by_pos: dict[str, int] = {}
for dz in debug_zones:
@@ -90,40 +83,52 @@ def plan_zone_ratio_retry(
zones_before[pos] = int(h)
zone_min_by_pos[pos] = int(m)
# overflow zone 별 visual fail 정보
overflow_zone_status: dict[str, dict] = {}
for z in overflow.get("zones", []) or []:
overflow_zone_status[z.get("position")] = z
overflow_zone_status = {
z.get("position"): z for z in overflow.get("zones", []) or []
}
base_plan = {
"target_zone_position": target_zone_position,
"target_excess_y": target_excess_y,
"target_added_px": target_added_px,
"safety_margin_px_used": int(safety_margin_px),
"zones_before": dict(zones_before),
"manual_zone_positions": sorted(manual_positions),
"manual_geometry_policy": "per_zone_freeze",
}
if target_zone_position in manual_positions:
return {
**base_plan,
"feasible": False,
"manual_target_blocked": True,
"donor_zone_position": None,
"donor_reduced_px": 0,
"donor_candidates_considered": [],
"zones_after": dict(zones_before),
"failure_reason": (
f"manual target zone '{target_zone_position}' is frozen by "
"override_zone_geometries; emit warning/recommended_geometry only"
),
}
# donor 후보 식별
donor_candidates: list[dict] = []
for dz in debug_zones:
pos = dz.get("position")
if pos is None or pos == target_zone_position:
continue
# rule 4-(a) sibling 확인은 layout 내 sibling = 같은 zones list 안에 있으면 OK
# (본 함수는 1 layout 내 zones 만 받음)
if pos in manual_positions:
continue
# rule 4-(b) visual_check 통과 — 이 zone 에 자체 overflow / clipped_inner 없음
zinfo = overflow_zone_status.get(pos, {})
zone_self_overflow = bool(zinfo.get("overflowed"))
zone_inner_clipped = bool(zinfo.get("clipped_inner"))
if zone_self_overflow or zone_inner_clipped:
if bool(zinfo.get("overflowed")) or bool(zinfo.get("clipped_inner")):
continue
# rule 4-(c) capacity_fit 가 ok
cap_status = (
(dz.get("composition_rationale") or {}).get("capacity_fit", {}).get("fit_status")
)
# 'ok' 아니거나 missing/unknown 이면 보수적으로 제외 (no_contract 는 허용 — capacity_fit 자체 부재)
if cap_status not in {"ok", "no_contract", None}:
continue
# rule 4-(d) 현재 height > min_height
# IMP-34 u1: donor capacity bounded by measured empty space
# (clientHeight - scrollHeight from Step 14) when both fields are present,
# falling back to static contract slack when absent. Prevents the donor
# from being over-allocated when it is already full but not overflowing.
height = zones_before.get(pos)
min_h = zone_min_by_pos.get(pos)
if height is None or min_h is None:
@@ -157,18 +162,8 @@ def plan_zone_ratio_retry(
"slack_bound_source": slack_bound_source,
})
# rule 4-(f) 여러 후보면 slack 가장 큰 것부터
donor_candidates.sort(key=lambda d: d["slack"], reverse=True)
# base plan dict (failure / success 공용)
base_plan = {
"target_zone_position": target_zone_position,
"target_excess_y": target_excess_y,
"target_added_px": target_added_px,
"safety_margin_px_used": int(safety_margin_px),
"donor_candidates_considered": donor_candidates,
"zones_before": dict(zones_before),
}
base_plan["donor_candidates_considered"] = donor_candidates
if not donor_candidates:
return {
@@ -178,12 +173,11 @@ def plan_zone_ratio_retry(
"donor_reduced_px": 0,
"zones_after": dict(zones_before),
"failure_reason": (
f"no donor candidates eligible (sibling visual_check OK + "
f"capacity_fit ok/no_contract + slack > 0)"
"no donor candidates eligible (sibling visual_check OK + "
"capacity_fit ok/no_contract + slack > 0 + not manual/frozen)"
),
}
# IMP-12 u1 : multi-donor greedy aggregation (slack-desc 순서대로 합산)
aggregate_slack_available = sum(d["slack"] for d in donor_candidates)
if aggregate_slack_available < target_added_px:
return {
@@ -205,7 +199,6 @@ def plan_zone_ratio_retry(
),
}
# feasible — greedy aggregation: 각 donor 에서 필요한 만큼만 차감
zones_after = dict(zones_before)
zones_after[target_zone_position] = zones_before[target_zone_position] + target_added_px
donors_used: list[dict] = []
@@ -270,6 +263,7 @@ def plan_cross_zone_redistribute(
fit_analysis,
containers: dict,
min_margin_px: float | None = None,
manual_zone_positions: set[str] | list[str] | tuple[str, ...] | None = None,
) -> dict:
"""Cross-zone (intra-zone role-to-role) redistribute plan.
@@ -279,19 +273,38 @@ def plan_cross_zone_redistribute(
from copy import deepcopy
from src.fit_verifier import redistribute as _fv_redistribute
manual_positions = set(manual_zone_positions or [])
role_heights_before = {
role: float(rf.allocated_px) for role, rf in (fit_analysis.roles or {}).items()
role: float(rf.allocated_px)
for role, rf in (fit_analysis.roles or {}).items()
if role not in manual_positions
}
base_plan = {
"action": "cross_zone_redistribute",
"role_heights_before": role_heights_before,
"manual_zone_positions": sorted(manual_positions),
"manual_geometry_policy": "per_zone_freeze",
}
if not role_heights_before:
return {**base_plan, "feasible": False, "role_heights_after": {},
"can_redistribute": False,
"failure_reason": "no roles in fit_analysis — cannot redistribute."}
result = _fv_redistribute(deepcopy(fit_analysis), containers, min_margin_px=min_margin_px)
fit_for_redistribute = deepcopy(fit_analysis)
fit_for_redistribute.roles = {
role: rf
for role, rf in (fit_for_redistribute.roles or {}).items()
if role not in manual_positions
}
automatic_containers = {
role: value for role, value in (containers or {}).items()
if role not in manual_positions
}
result = _fv_redistribute(
fit_for_redistribute,
automatic_containers,
min_margin_px=min_margin_px,
)
redistribution = dict(result.redistribution or {})
can_redistribute = bool(result.can_redistribute)