Compare commits

Author SHA1 Message Date
Kyeongmin d1509ae7e4 On main: IMP-09 PR2 sketch (Stage 2 design reference; re-derive per unit) 2026-05-17 08:51:59 +09:00
Kyeongmin a311b8f11c index on main: 201099e feat(IMP-09): PR 1 — col-axis solver + per-zone geometry mapper + retry gate 2026-05-17 08:51:59 +09:00
KyeongminandClaude Opus 4.7 201099e53b feat(IMP-09): PR 1 — col-axis solver + per-zone geometry mapper + retry gate
Stage 3 round 4 lock implementation: extend build_layout_css beyond
the horizontal-2-only dynamic path. Every layout_css return now
carries length-locked col-axis keys (widths_px, width_ratios,
dynamic_cols) matching the parsed css_areas grid (R rows, C cols),
so 2-D layouts (T / 2x2 in PR 2) and the unified
_compute_per_zone_geometry mapper can plug in without further
contract churn.

PR 1 scope:
  - _parse_css_areas + _parse_fr_string + _compute_per_zone_geometry
    (unified — 1-D and 2-D from the same code path)
  - compute_zone_layout_cols (vertical-2 weight-only solver)
  - _build_fr_default / _build_rows_dynamic / _build_cols_dynamic
    (populate widths_px/heights_px on every return path)
  - build_layout_css override branch keeps the warn-and-fallthrough
    legacy for unsupported presets (PR 2 promotes to strict raise)
  - retry gate in _attempt_zone_ratio_retry skips when dynamic_cols=True
    or dynamic_rows=False, with explicit retry_skipped_reason
  - Step 8 artifact gains zone_widths_px_planned /
    zone_col_ratios_planned (top-level) + zone_width_px_planned /
    zone_col_ratio_planned (per-zone)
  - debug_zones width injection via _compute_per_zone_geometry
    (replaces the legacy row-only zip)

Tests: tests/phase_z2/ — 47 new cases (parse / fr-string / cols solver /
per-zone geometry / build_layout_css contract / retry gate +
6 build_layout_css YAML fixtures + 3 retry_gate fixtures).

Verification: python -m pytest -q tests = 89 passed (was 42).
horizontal-2 grid CSS strings (areas/cols/rows) byte-identical to
legacy; only additive col-axis keys are introduced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:03:23 +09:00
KyeongminandClaude Opus 4.7 8f6cffc2a7 fix(IMP-08): Stage 5 R2 — aligner force-drill on sub-id override targets
Codex #1 (Stage 5) reproduced a smoke regression on the actual checkout :
when V4 carries the parent exact key (e.g., `04-2`) AND the drag/drop
override targets a sub-id (`primary=04-2-sub-1`), the aligner kept the
parent at parent granularity and emit `['04-1', '04-2']`, so the override
flag failed with `unknown section_id(s) ['04-2-sub-1']`.

Fix : `align_sections_to_v4_granularity` gains an optional
`override_target_section_ids` keyword. From each canonical
`${parent}-sub-N` target it derives the parent id and adds it to a
`force_drill_parents` set. Sections in that set are drilled into
sub-sections regardless of whether V4 carries the parent exact key.
Top-level override targets (no derived parent) do not trigger
force-drill, so backward-compat is preserved for parent-granularity
overrides.

The call site in `run_phase_z2_mvp1` collects sub-ids from
`override_section_assignments` and forwards them to the aligner.

Generalization (RULE 0) :
- Trigger is the override schema (`X-sub-N`), not a specific MDX / section /
  frame id. Applies to all 32-frame MDX uniformly.
- Decision is deterministic on the override target shape, independent of
  V4 yaml content.
- Default (no override) path is unchanged byte-for-byte.

Side fixes (forward-only RULE 1 cleanup, no history rewrite) :
- `align_sections_to_v4_granularity` docstring rewritten in English
  (overwrites the Korean docstring committed in 5191aca).
- Step 9 diagnostic comment quoted-string rewritten in English
  (overwrites `"V4 entry 없음"` committed in a422d72).

Tests : 3 new cases in `test_phase_z2_subsection_schema.py` —
`test_align_parent_v4_exact_keeps_section_when_no_override_targets_sub`
(backward-compat axis), `test_align_force_drills_when_override_targets_sub_id_with_parent_in_v4`
(blocker regression), `test_align_top_level_override_target_does_not_force_drill_other_sections`
(force-drill scope guard). Pytest scope-qualified result :
`test_phase_z2_subsection_schema.py` + `_section_assignment_override.py` +
`_v4_fallback.py` = 40 / 40 PASS.

Smoke (axis = sub-id override -> aligner -> assignment plan, both V4 yaml
shapes) :
- HEAD V4 yaml (`04-1`, `04-2.1`, `04-2.2` only) :
  `--override-section-assignment primary=04-2-sub-1` ->
  `aligned_section_ids=['04-1', '04-2-sub-1', '04-2-sub-2']`,
  `plan[0].assignment_source='cli_override'`,
  `plan[0].source_section_ids=['04-2-sub-1']`.
- V4 yaml with `04-2` exact key (Codex's stress case) : identical
  aligned output and identical assignment plan.

Downstream `composition_planner` abort
(`phase_z_status_not_allowed:extract_matched_zone`) is IMP-05 territory,
unchanged in both shapes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 02:28:46 +09:00
KyeongminandClaude Opus 4.7 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
KyeongminandClaude Opus 4.7 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
KyeongminandClaude Opus 4.7 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
23 changed files with 2146 additions and 74 deletions
+31
View File
@@ -300,6 +300,35 @@ export default function Home() {
if (zoneGeometries && Object.keys(zoneGeometries).length > 0) {
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 }));
@@ -310,6 +339,8 @@ export default function Home() {
? `(overrides: ${[
overrides.layout && `layout=${overrides.layout}`,
overrides.frames && `frames=${Object.keys(overrides.frames).length}`,
overrides.zoneSections &&
`zoneSections=${Object.keys(overrides.zoneSections).length}`,
]
.filter(Boolean)
.join(", ")})`
@@ -251,6 +251,11 @@ export interface PipelineOverrides {
/** zone_id (top/bottom/left/right/...) → slide-body 내부 0~1 비율.
* backend 의 build_layout_css 가 horizontal-2 / vertical-2 만 처리. */
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(
+18
View File
@@ -241,6 +241,9 @@ function vitePluginPhaseZApi(): Plugin {
layout?: string;
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)
// 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 {
@@ -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(
`[phase-z-api] spawn pipeline: run_id=${runId}, mdx=${mdxPath}, args=${JSON.stringify(cliArgs.slice(2))}`
);
+13 -5
View File
@@ -21,6 +21,7 @@ Pipeline 의 빠진 layer = MDX 덩어리들을 *최종 zone unit* 으로 묶는
from __future__ import annotations
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
@@ -371,13 +372,20 @@ class CompositionUnit:
# ─── Heading Tree ──────────────────────────────────────────────
def derive_parent_id(section_id: str) -> Optional[str]:
"""section_id 에서 parent 도출 — V4 키 컨벤션 기반.
"""Section id -> parent id derivation by V4 key convention.
예시 (코멘트, 룰 X) :
- "04-2.1" → "04-2" (decimal suffix → strip)
- "04-1" → None (top-level, no parent)
- "04" → None
IMP-08 B-3 : canonical ordinal `${parent}-sub-${n}` recognised first;
legacy decimal `04-2.1` kept as fallback alias path.
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)
if len(parts) != 2:
return None
+788 -69
View File
File diff suppressed because it is too large Load Diff
View File
@@ -0,0 +1,31 @@
input:
layout_preset: horizontal-2
zones_data:
- position: top
template_id: MOCK_top
content_weight:
score: 0.5
min_height_px: 200
- position: bottom
template_id: MOCK_bottom
content_weight:
score: 0.5
min_height_px: 200
override_zone_geometries: null
expected_layout_css:
areas: '"top" "bottom"'
cols: 1fr
rows: 286px 285px
heights_px:
- 286
- 285
widths_px:
- 1180
ratios:
- 0.489
- 0.487
width_ratios:
- 1.0
computation: min_height_first + content_weight_distribution
dynamic_rows: true
dynamic_cols: false
@@ -0,0 +1,41 @@
input:
layout_preset: horizontal-2
zones_data:
- position: top
template_id: MOCK_top
content_weight:
score: 0.5
min_height_px: 200
- position: bottom
template_id: MOCK_bottom
content_weight:
score: 0.5
min_height_px: 200
override_zone_geometries:
top:
x: 0
y: 0
w: 1.0
h: 0.3
bottom:
x: 0
y: 0.3
w: 1.0
h: 0.7
expected_layout_css:
areas: '"top" "bottom"'
cols: 1fr
rows: 176px 410px
heights_px:
- 176
- 410
widths_px:
- 1180
ratios:
- 0.3
- 0.7
width_ratios:
- 1.0
computation: user_override_geometry
dynamic_rows: true
dynamic_cols: false
@@ -0,0 +1,31 @@
input:
layout_preset: horizontal-2
zones_data:
- position: top
template_id: MOCK_top
content_weight:
score: 0.8
min_height_px: 200
- position: bottom
template_id: MOCK_bottom
content_weight:
score: 0.2
min_height_px: 150
override_zone_geometries: null
expected_layout_css:
areas: '"top" "bottom"'
cols: 1fr
rows: 377px 194px
heights_px:
- 377
- 194
widths_px:
- 1180
ratios:
- 0.644
- 0.332
width_ratios:
- 1.0
computation: min_height_first + content_weight_distribution
dynamic_rows: true
dynamic_cols: false
@@ -0,0 +1,31 @@
input:
layout_preset: vertical-2
zones_data:
- position: left
template_id: MOCK_left
content_weight:
score: 0.5
min_height_px: 200
- position: right
template_id: MOCK_right
content_weight:
score: 0.5
min_height_px: 200
override_zone_geometries: null
expected_layout_css:
areas: '"left right"'
cols: 583px 583px
rows: 1fr
heights_px:
- 585
widths_px:
- 583
- 583
ratios:
- 1.0
width_ratios:
- 0.494
- 0.494
computation: content_weight_distribution_cols
dynamic_rows: false
dynamic_cols: true
@@ -0,0 +1,41 @@
input:
layout_preset: vertical-2
zones_data:
- position: left
template_id: MOCK_left
content_weight:
score: 0.5
min_height_px: 200
- position: right
template_id: MOCK_right
content_weight:
score: 0.5
min_height_px: 200
override_zone_geometries:
left:
x: 0
y: 0
w: 0.4
h: 1.0
right:
x: 0.4
y: 0
w: 0.6
h: 1.0
expected_layout_css:
areas: '"left right"'
cols: 40.0fr 60.0fr
rows: 1fr
heights_px:
- 585
widths_px:
- 466
- 700
ratios:
- 1.0
width_ratios:
- 0.4
- 0.6
computation: user_override_geometry
dynamic_rows: false
dynamic_cols: true
@@ -0,0 +1,31 @@
input:
layout_preset: vertical-2
zones_data:
- position: left
template_id: MOCK_left
content_weight:
score: 0.7
min_height_px: 200
- position: right
template_id: MOCK_right
content_weight:
score: 0.3
min_height_px: 200
override_zone_geometries: null
expected_layout_css:
areas: '"left right"'
cols: 816px 350px
rows: 1fr
heights_px:
- 585
widths_px:
- 816
- 350
ratios:
- 1.0
width_ratios:
- 0.692
- 0.297
computation: content_weight_distribution_cols
dynamic_rows: false
dynamic_cols: true
@@ -0,0 +1,24 @@
case_id: horizontal2_dynamic_rows
description: |
horizontal-2 layout with dynamic_rows=True must pass the IMP-09 retry
gate. The base trace should record retry_attempted=True (legacy
plan/rerender path continues). retry_skipped_reason MUST NOT contain
either of the IMP-09 gate skip strings.
input_layout_css:
areas: '"top" "bottom"'
cols: 1fr
rows: 333px 238px
heights_px: [333, 238]
widths_px: [1180]
ratios: [0.569, 0.407]
width_ratios: [1.0]
dynamic_rows: true
dynamic_cols: false
router_decision:
router_active: true
proposed_actions_summary: [zone_ratio_retry]
expected_gate:
retry_attempted: true
retry_skipped_reason_excludes:
- "dynamic_cols"
- "fr_default_from_preset"
@@ -0,0 +1,26 @@
case_id: single_fr_default
description: |
Post-IMP-09 PR 2 the only preset that still falls through to
fr_default_from_preset is `single` (top-1-bottom-2 / top-2-bottom-1 /
left-1-right-2 / left-2-right-1 / grid-2x2 were promoted to 2-D
dynamic). This fixture exercises the IMP-09 retry-gate fr_default
skip path using a layout_css with dynamic_rows=False AND
dynamic_cols=False (the surviving fr_default signature). Row-axis
retry must be skipped with a fr_default_from_preset skip reason.
input_layout_css:
areas: '"top top" "bottom-left bottom-right"'
cols: 1fr 1fr
rows: 1fr 1fr
heights_px: [285, 286]
widths_px: [583, 583]
ratios: [0.487, 0.489]
width_ratios: [0.494, 0.494]
dynamic_rows: false
dynamic_cols: false
router_decision:
router_active: true
proposed_actions_summary: [zone_ratio_retry]
expected_gate:
retry_attempted: false
retry_skipped_reason_contains:
- "fr_default_from_preset"
@@ -0,0 +1,24 @@
case_id: vertical2_dynamic_cols
description: |
vertical-2 layout with dynamic_cols=True must be skipped by the
IMP-09 retry gate before plan/rerender, because the existing
apply_retry_to_layout_css mutates only row-axis fields and would
produce a misleading trace if it ran on a column-dynamic layout.
input_layout_css:
areas: '"left right"'
cols: 583px 583px
rows: 1fr
heights_px: [585]
widths_px: [583, 583]
ratios: [1.0]
width_ratios: [0.494, 0.494]
dynamic_rows: false
dynamic_cols: true
router_decision:
router_active: true
proposed_actions_summary: [zone_ratio_retry]
expected_gate:
retry_attempted: false
retry_skipped_reason_contains:
- "dynamic_cols"
- "IMP-09"
+246
View File
@@ -0,0 +1,246 @@
"""IMP-09 PR 1 — build_layout_css contract tests.
Verifies horizontal-2 byte-identity for the legacy grid strings
(areas / cols / rows) and that every return path now carries the new
length-locked col-axis keys (widths_px / width_ratios / dynamic_cols).
"""
from __future__ import annotations
import pytest
from src.phase_z2_pipeline import (
GRID_GAP,
SLIDE_BODY_HEIGHT,
SLIDE_BODY_WIDTH,
build_layout_css,
)
def _zone(position: str, score: float, min_h: int = 100) -> dict:
return {
"position": position,
"template_id": f"MOCK_{position}",
"content_weight": {"score": score},
"min_height_px": min_h,
}
# ────────────────────── new-key contract ──────────────────────
NEW_KEYS = {"widths_px", "width_ratios", "dynamic_cols"}
def test_all_presets_carry_new_col_axis_keys():
"""Every PR 1 return path must include widths_px / width_ratios /
dynamic_cols, and heights_px / widths_px must be length-locked to
the catalog grid (R rows, C cols)."""
cases = [
("single", [_zone("primary", 1.0)]),
("horizontal-2", [_zone("top", 0.6), _zone("bottom", 0.4)]),
("vertical-2", [_zone("left", 0.5), _zone("right", 0.5)]),
("top-1-bottom-2", [
_zone("top", 0.5),
_zone("bottom-left", 0.25),
_zone("bottom-right", 0.25),
]),
("grid-2x2", [
_zone("top-left", 0.25),
_zone("top-right", 0.25),
_zone("bottom-left", 0.25),
_zone("bottom-right", 0.25),
]),
]
for preset, zones in cases:
result = build_layout_css(preset, zones)
missing = NEW_KEYS - set(result)
assert not missing, f"{preset} missing new keys: {missing}"
# heights_px / widths_px never empty in PR 1 (length-locked).
assert len(result["heights_px"]) > 0, f"{preset} empty heights_px"
assert len(result["widths_px"]) > 0, f"{preset} empty widths_px"
# ────────────────────── horizontal-2 byte-identity ──────────────────────
def test_horizontal_2_grid_strings_match_legacy():
zones = [_zone("top", 0.6), _zone("bottom", 0.4)]
result = build_layout_css("horizontal-2", zones)
# Legacy contract: areas / cols / rows strings preserved.
assert result["areas"] == '"top" "bottom"'
assert result["cols"] == "1fr"
assert result["rows"].count("px") == 2
# heights_px sum to body height; ratios consistent.
assert sum(result["heights_px"]) == SLIDE_BODY_HEIGHT - GRID_GAP
assert result["dynamic_rows"] is True
assert result["dynamic_cols"] is False
# New col-axis defaults: full body width, ratio 1.0.
assert result["widths_px"] == [SLIDE_BODY_WIDTH]
assert result["width_ratios"] == [1.0]
def test_horizontal_2_override_preserves_rows():
zones = [_zone("top", 0.6), _zone("bottom", 0.4)]
override = {
"top": {"x": 0, "y": 0, "w": 1.0, "h": 0.3},
"bottom": {"x": 0, "y": 0.3, "w": 1.0, "h": 0.7},
}
result = build_layout_css(
"horizontal-2", zones, override_zone_geometries=override
)
assert result["computation"] == "user_override_geometry"
assert result["dynamic_rows"] is True
assert result["dynamic_cols"] is False
assert result["heights_px"][0] < result["heights_px"][1]
assert result["widths_px"] == [SLIDE_BODY_WIDTH]
# Override ratio target.
assert result["ratios"] == [0.3, 0.7]
# ────────────────────── vertical-2 new dynamic ──────────────────────
def test_vertical_2_normal_produces_dynamic_cols():
zones = [_zone("left", 0.7), _zone("right", 0.3)]
result = build_layout_css("vertical-2", zones)
assert result["dynamic_rows"] is False
assert result["dynamic_cols"] is True
# cols string is px-based (no fr).
assert "fr" not in result["cols"]
assert result["cols"].count("px") == 2
# Heights span full body in a single row.
assert result["heights_px"] == [SLIDE_BODY_HEIGHT]
# Widths reflect 70/30 weight split.
assert result["widths_px"][0] > result["widths_px"][1]
def test_vertical_2_override_keeps_fr_cols_legacy():
"""PR 1 v-2 override path keeps legacy fr-string cols but now
populates widths_px in pixels for downstream consumers."""
zones = [_zone("left", 0.5), _zone("right", 0.5)]
override = {
"left": {"x": 0, "y": 0, "w": 0.4, "h": 1.0},
"right": {"x": 0.4, "y": 0, "w": 0.6, "h": 1.0},
}
result = build_layout_css(
"vertical-2", zones, override_zone_geometries=override
)
assert result["computation"] == "user_override_geometry"
assert "fr" in result["cols"]
assert result["dynamic_cols"] is True
assert result["dynamic_rows"] is False
# widths_px now populated.
assert len(result["widths_px"]) == 2
assert sum(result["widths_px"]) == SLIDE_BODY_WIDTH - GRID_GAP
assert result["width_ratios"] == [0.4, 0.6]
# ───────────────── PR 2: 5 in-scope 2-D presets dynamic ─────────────────
_TWO_D_PRESETS = [
"top-1-bottom-2",
"top-2-bottom-1",
"left-1-right-2",
"left-2-right-1",
"grid-2x2",
]
def _zones_for(preset: str) -> list[dict]:
"""Default zone fixtures (positions per LAYOUT_PRESETS, equal score)."""
if preset == "top-1-bottom-2":
return [_zone("top", 0.5), _zone("bottom-left", 0.25),
_zone("bottom-right", 0.25)]
if preset == "top-2-bottom-1":
return [_zone("top-left", 0.3), _zone("top-right", 0.2),
_zone("bottom", 0.5)]
if preset == "left-1-right-2":
return [_zone("left", 0.5), _zone("right-top", 0.3),
_zone("right-bottom", 0.2)]
if preset == "left-2-right-1":
return [_zone("left-top", 0.3), _zone("left-bottom", 0.2),
_zone("right", 0.5)]
if preset == "grid-2x2":
return [_zone("top-left", 0.25), _zone("top-right", 0.25),
_zone("bottom-left", 0.25), _zone("bottom-right", 0.25)]
raise ValueError(f"no _zones_for({preset!r})")
@pytest.mark.parametrize("preset", _TWO_D_PRESETS)
def test_two_d_preset_promoted_to_dynamic(preset):
"""PR 2 — T / inverted-T / side-T-{left,right} / 2x2 must dispatch
to _build_2d_dynamic (computation=='2d_dynamic_aggregated') with
dynamic_rows=True AND dynamic_cols=True, and grid-template strings
in pixels."""
result = build_layout_css(preset, _zones_for(preset))
assert result["computation"] == "2d_dynamic_aggregated"
assert result["dynamic_rows"] is True
assert result["dynamic_cols"] is True
# Both axes pixel-based.
assert "fr" not in result["rows"]
assert "fr" not in result["cols"]
assert result["rows"].count("px") == 2
assert result["cols"].count("px") == 2
# Length contract — R=2 rows, C=2 cols for all 5 in-scope presets.
assert len(result["heights_px"]) == 2
assert len(result["widths_px"]) == 2
@pytest.mark.parametrize("preset", _TWO_D_PRESETS)
def test_two_d_preset_override_dispatches_via_helper(preset):
"""Override on a 2-D preset must route through
_override_to_grid_tracks (computation=='user_override_geometry')
with both axes marked dynamic."""
zones = _zones_for(preset)
positions = [z["position"] for z in zones]
override = {pos: {"x": 0, "y": 0, "w": 0.5, "h": 0.5} for pos in positions}
result = build_layout_css(preset, zones, override_zone_geometries=override)
assert result["computation"] == "user_override_geometry"
assert result["dynamic_rows"] is True
assert result["dynamic_cols"] is True
assert len(result["heights_px"]) == 2
assert len(result["widths_px"]) == 2
# Total of axis cell sums equals body minus inter-track gap.
assert sum(result["heights_px"]) == SLIDE_BODY_HEIGHT - GRID_GAP
assert sum(result["widths_px"]) == SLIDE_BODY_WIDTH - GRID_GAP
@pytest.mark.parametrize("preset", _TWO_D_PRESETS)
def test_two_d_preset_override_invalid_falls_back_to_dynamic(preset):
"""Zero-only override on a 2-D preset must fall back to dynamic
normal-path solvers on both axes; computation still labeled
user_override_geometry (override invocation context preserved)
and raw_zone_layout.{row_source,col_source}=='dynamic_fallback'."""
zones = _zones_for(preset)
positions = [z["position"] for z in zones]
# Zero-only override — must trigger per-axis fallback to solvers.
override = {pos: {"x": 0, "y": 0, "w": 0.0, "h": 0.0} for pos in positions}
result = build_layout_css(preset, zones, override_zone_geometries=override)
assert result["computation"] == "user_override_geometry"
rzl = result["raw_zone_layout"]
assert rzl["row_source"] == "dynamic_fallback"
assert rzl["col_source"] == "dynamic_fallback"
# Result must match what dynamic normal path would produce.
normal = build_layout_css(preset, zones)
assert result["heights_px"] == normal["heights_px"]
assert result["widths_px"] == normal["widths_px"]
# ────────────────────── fr_default sink (PR 2 — single only) ──────────────────────
def test_single_remains_fr_default_sink():
"""After PR 2 the only preset that still falls through to
_build_fr_default is `single` (PR 3 will promote it). Length
contract (R=1, C=1) must still hold."""
zones = [_zone("primary", 1.0)]
result = build_layout_css("single", zones)
assert result["computation"] == "fr_default_from_preset"
assert result["dynamic_rows"] is False
assert result["dynamic_cols"] is False
assert len(result["heights_px"]) == 1
assert len(result["widths_px"]) == 1
@@ -0,0 +1,101 @@
"""IMP-09 PR 1 — _compute_per_zone_geometry tests (1-D paths).
Verifies the unified per-zone geometry aggregator on horizontal-2 and
vertical-2 (the two 1-D presets active in PR 1). 2-D spanning zone
cases (T / 2x2) are exercised in PR 2.
The helper aggregates grid-track sizes into per-zone dimensions and
must produce length-locked outputs:
- layout_css["heights_px"] length == R (parsed css_areas rows)
- layout_css["widths_px"] length == C (parsed css_areas cols)
"""
from __future__ import annotations
import pytest
from src.phase_z2_pipeline import (
GRID_GAP,
SLIDE_BODY_HEIGHT,
SLIDE_BODY_WIDTH,
_compute_per_zone_geometry,
build_layout_css,
)
def _zone(position: str, score: float) -> dict:
return {
"position": position,
"template_id": f"MOCK_{position}",
"content_weight": {"score": score},
"min_height_px": 100,
}
def test_horizontal_2_per_zone_widths_match_slide_body():
zones = [_zone("top", 0.6), _zone("bottom", 0.4)]
layout_css = build_layout_css("horizontal-2", zones)
debug_zones = [{"position": "top"}, {"position": "bottom"}]
per_zone = _compute_per_zone_geometry(layout_css, debug_zones, GRID_GAP)
# Both zones share the single column => width == SLIDE_BODY_WIDTH.
assert per_zone[0]["zone_width_px"] == SLIDE_BODY_WIDTH
assert per_zone[1]["zone_width_px"] == SLIDE_BODY_WIDTH
# Heights mirror layout_css.heights_px.
assert per_zone[0]["zone_height_px"] == layout_css["heights_px"][0]
assert per_zone[1]["zone_height_px"] == layout_css["heights_px"][1]
def test_vertical_2_per_zone_heights_match_slide_body():
zones = [_zone("left", 0.5), _zone("right", 0.5)]
layout_css = build_layout_css("vertical-2", zones)
debug_zones = [{"position": "left"}, {"position": "right"}]
per_zone = _compute_per_zone_geometry(layout_css, debug_zones, GRID_GAP)
# Both zones share the single row => height == SLIDE_BODY_HEIGHT.
assert per_zone[0]["zone_height_px"] == SLIDE_BODY_HEIGHT
assert per_zone[1]["zone_height_px"] == SLIDE_BODY_HEIGHT
# Widths mirror layout_css.widths_px.
assert per_zone[0]["zone_width_px"] == layout_css["widths_px"][0]
assert per_zone[1]["zone_width_px"] == layout_css["widths_px"][1]
def test_heights_px_length_mismatch_raises():
layout_css = {
"areas": '"top" "bottom"',
"heights_px": [300], # wrong length, expected 2
"widths_px": [SLIDE_BODY_WIDTH],
}
with pytest.raises(ValueError, match="heights_px length"):
_compute_per_zone_geometry(
layout_css, [{"position": "top"}], GRID_GAP
)
def test_widths_px_length_mismatch_raises():
layout_css = {
"areas": '"left right"',
"heights_px": [SLIDE_BODY_HEIGHT],
"widths_px": [600], # wrong length, expected 2
}
with pytest.raises(ValueError, match="widths_px length"):
_compute_per_zone_geometry(
layout_css, [{"position": "left"}], GRID_GAP
)
def test_unknown_position_raises():
zones = [_zone("top", 0.5), _zone("bottom", 0.5)]
layout_css = build_layout_css("horizontal-2", zones)
debug_zones = [{"position": "ghost"}]
with pytest.raises(ValueError, match="not present in css_areas"):
_compute_per_zone_geometry(layout_css, debug_zones, GRID_GAP)
def test_fr_default_single_returns_full_body():
# 'single' is the fr_default sink in PR 1; widths_px / heights_px
# must still be populated (length 1 each).
layout_css = build_layout_css("single", [_zone("primary", 1.0)])
debug_zones = [{"position": "primary"}]
per_zone = _compute_per_zone_geometry(layout_css, debug_zones, GRID_GAP)
assert per_zone[0]["zone_height_px"] == SLIDE_BODY_HEIGHT
assert per_zone[0]["zone_width_px"] == SLIDE_BODY_WIDTH
@@ -0,0 +1,76 @@
"""IMP-09 PR 1 — compute_zone_layout_cols tests.
Column-axis weight-only solver. Mirrors compute_zone_layout for rows.
No min_width_px contract exists in frame_contracts.yaml (verified
during Stage 2), so column distribution is purely content_weight.
"""
from __future__ import annotations
from src.phase_z2_pipeline import (
GRID_GAP,
SLIDE_BODY_WIDTH,
compute_zone_layout_cols,
)
def _zone(position: str, score: float) -> dict:
return {
"position": position,
"template_id": f"MOCK_{position}",
"content_weight": {"score": score},
}
def test_empty_zones_returns_empty_result():
result = compute_zone_layout_cols([])
assert result["widths_px"] == []
assert result["width_ratios"] == []
def test_two_equal_zones_split_evenly():
zones = [_zone("left", 0.5), _zone("right", 0.5)]
result = compute_zone_layout_cols(zones)
available = SLIDE_BODY_WIDTH - GRID_GAP # one gap between two zones
assert sum(result["widths_px"]) == available
assert result["widths_px"][0] == result["widths_px"][1]
assert result["computation"] == "content_weight_distribution_cols"
def test_asymmetric_weights_distribute_by_ratio():
zones = [_zone("left", 0.8), _zone("right", 0.2)]
result = compute_zone_layout_cols(zones)
available = SLIDE_BODY_WIDTH - GRID_GAP
assert sum(result["widths_px"]) == available
# left should be ~4x right
assert result["widths_px"][0] > result["widths_px"][1] * 3
def test_zero_weight_guard_equal_split():
zones = [_zone("left", 0.0), _zone("right", 0.0)]
result = compute_zone_layout_cols(zones)
available = SLIDE_BODY_WIDTH - GRID_GAP
assert sum(result["widths_px"]) == available
assert result["widths_px"][0] == result["widths_px"][1]
# weight_shares fallback to equal share.
assert result["weight_shares"] == [0.5, 0.5]
def test_integer_rounding_absorbed_by_last_zone():
# Three zones with weights that don't divide evenly.
zones = [
_zone("a", 0.333333),
_zone("b", 0.333333),
_zone("c", 0.333334),
]
result = compute_zone_layout_cols(zones)
available = SLIDE_BODY_WIDTH - 2 * GRID_GAP
assert sum(result["widths_px"]) == available
def test_width_ratios_match_total_width():
zones = [_zone("left", 0.6), _zone("right", 0.4)]
result = compute_zone_layout_cols(zones)
# width_ratios should be widths_px / SLIDE_BODY_WIDTH (not / available)
assert abs(
result["width_ratios"][0] - result["widths_px"][0] / SLIDE_BODY_WIDTH
) < 1e-3
+100
View File
@@ -0,0 +1,100 @@
"""IMP-09 PR 1 — fixture-driven regression checks.
Loads the YAML snapshots under tests/phase_z2/fixtures/ and exercises
build_layout_css + _attempt_zone_ratio_retry against them. Any drift
in IMP-09 output forces a fixture refresh, which is the lock surface
called out in Stage 3 round 4 §5.
"""
from __future__ import annotations
from pathlib import Path
import pytest
import yaml
from src.phase_z2_pipeline import _attempt_zone_ratio_retry, build_layout_css
FIXTURES_DIR = Path(__file__).parent / "fixtures"
def _load_yaml(path: Path) -> dict:
with path.open(encoding="utf-8") as f:
return yaml.safe_load(f)
# ──────────────────────── build_layout_css fixtures ────────────────────────
_BUILD_DIR = FIXTURES_DIR / "build_layout_css"
_BUILD_FIXTURES = sorted(_BUILD_DIR.glob("*.yaml")) if _BUILD_DIR.exists() else []
@pytest.mark.parametrize(
"fixture_path",
_BUILD_FIXTURES,
ids=[p.stem for p in _BUILD_FIXTURES],
)
def test_build_layout_css_matches_fixture(fixture_path: Path):
payload = _load_yaml(fixture_path)
inp = payload["input"]
expected = payload["expected_layout_css"]
result = build_layout_css(
inp["layout_preset"],
inp["zones_data"],
override_zone_geometries=inp.get("override_zone_geometries"),
)
# raw_zone_layout is intentionally not snapshotted (contains
# solver internals); compare the rest.
actual = {k: v for k, v in result.items() if k != "raw_zone_layout"}
assert actual == expected, (
f"layout_css drift in fixture {fixture_path.name}:\n"
f" expected={expected}\n actual={actual}"
)
# ────────────────────────── retry_gate fixtures ──────────────────────────
_RETRY_DIR = FIXTURES_DIR / "retry_gate"
_RETRY_FIXTURES = sorted(_RETRY_DIR.glob("*.yaml")) if _RETRY_DIR.exists() else []
@pytest.mark.parametrize(
"fixture_path",
_RETRY_FIXTURES,
ids=[p.stem for p in _RETRY_FIXTURES],
)
def test_retry_gate_matches_fixture(fixture_path: Path, tmp_path: Path):
payload = _load_yaml(fixture_path)
layout_css = payload["input_layout_css"]
router_decision = payload["router_decision"]
expected = payload["expected_gate"]
trace = _attempt_zone_ratio_retry(
run_dir=tmp_path,
out_path=tmp_path / "final.html",
slide_title="fixture",
slide_footer=None,
zones_data=[],
debug_zones=[],
layout_preset="fixture",
layout_css=layout_css,
overflow={},
fit_classification={},
router_decision=router_decision,
gap_px=14,
)
assert trace["retry_attempted"] == expected["retry_attempted"]
skip_reason = trace.get("retry_skipped_reason")
for needle in expected.get("retry_skipped_reason_contains", []):
assert skip_reason is not None and needle in skip_reason, (
f"expected {needle!r} in retry_skipped_reason, got {skip_reason!r}"
)
for forbidden in expected.get("retry_skipped_reason_excludes", []):
if skip_reason is not None:
assert forbidden not in skip_reason, (
f"forbidden {forbidden!r} found in retry_skipped_reason {skip_reason!r}"
)
@@ -0,0 +1,71 @@
"""IMP-09 PR 1 — _parse_css_areas strict validation tests.
Covers the four ValueError cases declared in the Stage 3 round 4 lock
(plan §2-D): empty input, no quoted rows, empty row tokens, and
non-rectangular grids. Also exercises positive parsing on all 8
catalog presets so any future catalog drift in row/col counts is
caught here.
"""
from __future__ import annotations
import pytest
from src.phase_z2_pipeline import _parse_css_areas
def test_parse_empty_string_raises():
with pytest.raises(ValueError, match="no quoted row strings"):
_parse_css_areas("")
def test_parse_no_quotes_raises():
with pytest.raises(ValueError, match="no quoted row strings"):
_parse_css_areas("top top bottom-left bottom-right")
def test_parse_empty_row_raises():
# Whitespace-only quoted row -> tokens list is empty.
with pytest.raises(ValueError, match="empty row"):
_parse_css_areas('" "')
def test_parse_non_rectangular_raises():
# First row has 1 token, second row has 2 tokens.
with pytest.raises(ValueError, match="non-rectangular"):
_parse_css_areas('"top" "bottom-left bottom-right"')
def test_parse_single_zone():
rows, seen = _parse_css_areas('"primary"')
assert rows == [["primary"]]
assert seen == ["primary"]
def test_parse_horizontal_2():
rows, seen = _parse_css_areas('"top" "bottom"')
assert rows == [["top"], ["bottom"]]
assert seen == ["top", "bottom"]
def test_parse_vertical_2():
rows, seen = _parse_css_areas('"left right"')
assert rows == [["left", "right"]]
assert seen == ["left", "right"]
def test_parse_top_1_bottom_2_span():
rows, seen = _parse_css_areas('"top top" "bottom-left bottom-right"')
assert rows == [["top", "top"], ["bottom-left", "bottom-right"]]
# 'top' should appear once in seen even though it occupies two cells.
assert seen == ["top", "bottom-left", "bottom-right"]
def test_parse_grid_2x2_four_zones():
rows, seen = _parse_css_areas(
'"top-left top-right" "bottom-left bottom-right"'
)
assert rows == [
["top-left", "top-right"],
["bottom-left", "bottom-right"],
]
assert seen == ["top-left", "top-right", "bottom-left", "bottom-right"]
+49
View File
@@ -0,0 +1,49 @@
"""IMP-09 PR 1 — _parse_fr_string tests.
Catalog presets only use `1fr` / `1fr 1fr` specs (verified
templates/phase_z2/layouts/layouts.yaml). The helper must reject
non-fr tokens and round to integer pixel sizes summing to `total`.
"""
from __future__ import annotations
import pytest
from src.phase_z2_pipeline import _parse_fr_string
def test_single_fr_returns_full_total():
assert _parse_fr_string("1fr", 585) == [585]
def test_two_equal_fr_splits_evenly():
result = _parse_fr_string("1fr 1fr", 1180)
assert result == [590, 590]
assert sum(result) == 1180
def test_unequal_fr_distributes_by_ratio():
result = _parse_fr_string("2fr 1fr", 300)
assert sum(result) == 300
assert result[0] > result[1]
def test_rounding_absorbed_by_last_track():
# 1fr 1fr 1fr / total=100 -> 33,33,33 + diff 1 absorbed by last.
result = _parse_fr_string("1fr 1fr 1fr", 100)
assert sum(result) == 100
assert result == [33, 33, 34]
def test_non_fr_token_raises():
with pytest.raises(ValueError, match="non-fr token"):
_parse_fr_string("200px 1fr", 1000)
def test_empty_spec_raises():
with pytest.raises(ValueError, match="empty spec"):
_parse_fr_string("", 1000)
def test_zero_fr_raises():
with pytest.raises(ValueError, match="total fr"):
_parse_fr_string("0fr 0fr", 1000)
+129
View File
@@ -0,0 +1,129 @@
"""IMP-09 PR 1 — retry gate tests (_attempt_zone_ratio_retry early exit).
Stage 3 round 4 lock §2-A: row-axis retry must skip when layout has
dynamic_cols=True (2-D topology) OR dynamic_rows=False (fr_default
sink). The horizontal-2 path (dynamic_rows=True, dynamic_cols=False)
must still proceed through the gate.
These tests exercise the gate by routing the request through
_attempt_zone_ratio_retry with router_active=True + proposed
zone_ratio_retry — but with layout_css fields that should trip the
gate. We confirm the early skip by asserting retry_attempted==False
and retry_skipped_reason content.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from src.phase_z2_pipeline import _attempt_zone_ratio_retry
_ROUTER_ACTIVE = {
"router_active": True,
"proposed_actions_summary": ["zone_ratio_retry"],
}
def _dummy_kwargs(layout_css: dict, tmp_path: Path) -> dict:
"""All params required by _attempt_zone_ratio_retry. Only
`layout_css` and `router_decision` matter pre-gate."""
return {
"run_dir": tmp_path,
"out_path": tmp_path / "final.html",
"slide_title": "test",
"slide_footer": None,
"zones_data": [],
"debug_zones": [],
"layout_preset": "horizontal-2",
"layout_css": layout_css,
"overflow": {},
"fit_classification": {},
"router_decision": _ROUTER_ACTIVE,
"gap_px": 14,
}
def test_vertical_2_dynamic_cols_skips_retry(tmp_path):
layout_css = {
"areas": '"left right"',
"cols": "583px 583px",
"rows": "1fr",
"heights_px": [585],
"widths_px": [583, 583],
"ratios": [1.0],
"width_ratios": [0.494, 0.494],
"dynamic_rows": False,
"dynamic_cols": True,
}
trace = _attempt_zone_ratio_retry(**_dummy_kwargs(layout_css, tmp_path))
assert trace["retry_attempted"] is False
assert "dynamic_cols" in trace["retry_skipped_reason"]
assert "IMP-09" in trace["retry_skipped_reason"]
def test_fr_default_sink_skips_retry(tmp_path):
# PR 1 single / T-shape / 2x2 fall through to fr_default and must
# not enter row-only retry plan.
layout_css = {
"areas": '"top top" "bottom-left bottom-right"',
"cols": "1fr 1fr",
"rows": "1fr 1fr",
"heights_px": [285, 286],
"widths_px": [583, 583],
"ratios": [0.487, 0.489],
"width_ratios": [0.494, 0.494],
"dynamic_rows": False,
"dynamic_cols": False,
}
trace = _attempt_zone_ratio_retry(**_dummy_kwargs(layout_css, tmp_path))
assert trace["retry_attempted"] is False
assert "fr_default_from_preset" in trace["retry_skipped_reason"]
def test_horizontal_2_dynamic_rows_passes_gate(tmp_path):
"""horizontal-2 with dynamic_rows=True must pass the gate. The
test does not need plan_zone_ratio_retry to succeed; it only
asserts the gate did not early-skip with one of the new
skip reasons."""
layout_css = {
"areas": '"top" "bottom"',
"cols": "1fr",
"rows": "333px 238px",
"heights_px": [333, 238],
"widths_px": [1180],
"ratios": [0.569, 0.407],
"width_ratios": [1.0],
"dynamic_rows": True,
"dynamic_cols": False,
}
# plan_zone_ratio_retry will return None because debug_zones is
# empty, so retry_attempted=True but plan==None.
trace = _attempt_zone_ratio_retry(**_dummy_kwargs(layout_css, tmp_path))
assert trace["retry_attempted"] is True
# The gate was passed; skip reason (if any) is the legacy
# plan-failure reason, not the new gate reasons.
skip_reason = trace.get("retry_skipped_reason")
if skip_reason is not None:
assert "dynamic_cols" not in skip_reason
assert "fr_default_from_preset" not in skip_reason
def test_router_inactive_skips_before_gate(tmp_path):
"""When router_active=False, the early skip happens before the
new IMP-09 gate. Verify the existing behavior is unchanged."""
layout_css = {
"areas": '"left right"',
"dynamic_rows": False,
"dynamic_cols": True,
"heights_px": [585],
"widths_px": [583, 583],
"ratios": [1.0],
"width_ratios": [0.5, 0.5],
}
kwargs = _dummy_kwargs(layout_css, tmp_path)
kwargs["router_decision"] = {"router_active": False}
trace = _attempt_zone_ratio_retry(**kwargs)
assert trace["retry_attempted"] is False
assert "router_active=False" in trace["retry_skipped_reason"]
+239
View File
@@ -0,0 +1,239 @@
"""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 (no override target): aligner
# keeps it untouched. Parent-level V4 evidence flows via exact-match
# lookup.
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_parent_v4_exact_keeps_section_when_no_override_targets_sub():
# Backward-compat axis: when V4 carries the parent exact key and no
# drag/drop override targets a sub-id of this section, the aligner
# MUST keep the parent (preserves V4 evidence at parent granularity).
raw = "### 2.1 First\nbody1\n### 2.2 Second\nbody2\n"
sections = [_section("03-2", 2, "2. Parent", raw)]
v4 = {"mdx_sections": {"03-2": {"judgments_full32": []}}}
out = align_sections_to_v4_granularity(sections, v4)
assert [s.section_id for s in out] == ["03-2"]
def test_align_force_drills_when_override_targets_sub_id_with_parent_in_v4():
# Stage 5 R2 blocker-fix regression: when V4 has the parent exact key
# AND an override targets a sub-id of that section, the aligner MUST
# drill regardless of V4 parent presence. This makes drag/drop
# addressing deterministic across all V4 yaml shapes.
raw = "### 2.1 First\nbody1\n### 2.2 Second\nbody2\n"
sections = [_section("04-2", 2, "2. Parent", raw)]
v4 = {
"mdx_sections": {
"04-2": {"judgments_full32": []}, # parent V4 entry present
"04-2.1": {"judgments_full32": []}, # plus decimal sub entries
"04-2.2": {"judgments_full32": []},
}
}
out = align_sections_to_v4_granularity(
sections, v4, override_target_section_ids=["04-2-sub-1"]
)
# Force-drill: parent id MUST be replaced by canonical sub-ids.
assert [s.section_id for s in out] == ["04-2-sub-1", "04-2-sub-2"]
# Decimal aliases preserved (N-R5: decimal heading_number).
assert out[0].v4_alias_keys == ["04-2.1"]
assert out[1].v4_alias_keys == ["04-2.2"]
def test_align_top_level_override_target_does_not_force_drill_other_sections():
# Top-level override target ("primary=03-1") has no derive_parent_id,
# so it MUST NOT force-drill any section. Only "X-sub-N" targets
# trigger force-drill on parent X.
raw = "### 2.1 First\nbody1\n"
sections = [
_section("03-1", 1, "1. Top", "body"),
_section("03-2", 2, "2. Parent", raw),
]
v4 = {
"mdx_sections": {
"03-1": {"judgments_full32": []},
"03-2": {"judgments_full32": []},
}
}
out = align_sections_to_v4_granularity(
sections, v4, override_target_section_ids=["03-1"]
)
# No sub-id target -> both sections kept at parent granularity.
assert [s.section_id for s in out] == ["03-1", "03-2"]
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"