{ "body": "[Claude #6] Stage 3 Round #4 — IMP-09 PR 2 u4\n\n### unit_executed\n- **id**: `u4`\n- **summary**: Add `_derive_axis_tracks_from_override` single-axis helper (per-group max override; dynamic-solver fallback when all zero)\n\n### files_changed\n- `src/phase_z2_pipeline.py` (+48 / -0 this unit; +172 / -0 cumulative across u1+u2+u3+u4)\n\n### diff_summary\n- Inserted directly after `_build_2d_dynamic` (u3) closing `}` and before the existing `# Layout preset → zone position 순서 …` comment block. PR 1 helpers (`_parse_css_areas`, `_parse_fr_string`, `compute_zone_layout`, `compute_zone_layout_cols`, `_compute_per_zone_geometry`, `_build_fr_default`, `_build_rows_dynamic`, `_build_cols_dynamic`) untouched. u1 (`TOPOLOGY_AXIS_MAP`, `_TWO_D_TOPOLOGIES`), u2 (`_aggregate_axis_zone`), and u3 (`_build_2d_dynamic`) surfaces untouched. `build_layout_css` not modified.\n- Helper signature: `_derive_axis_tracks_from_override(axis_groupings: list[list[str]], override_zone_geometries: dict[str, dict], axis_key: str, total_extent: int, gap: int, dynamic_tracks_px: list[int]) -> dict`.\n- Behavior (axis-agnostic; called once per axis by u5):\n 1. For each grouping in `axis_groupings`, take **MAX** of `geom[axis_key]` (`'h'` for row tracks, `'w'` for col tracks) across positions in that grouping.\n 2. Compute `space = total_extent - gap * max(n - 1, 0)` — matches PR 1 solver convention (`compute_zone_layout` / `compute_zone_layout_cols` both produce tracks that sum to `total_extent - gap * (n-1)`).\n 3. If `sum(per_group_max) <= 0` (all positions missing or zero on this axis): **fall back** to `dynamic_tracks_px` verbatim; ratios re-derived as `track / space`; `override_applied = False`.\n 4. Otherwise: normalize per-group maxes to a ratio vector summing to 1, scale to `space`, and apply rounding fix to the last track to length-lock the sum to `space`. `override_applied = True`.\n- Return contract: `{tracks_px: list[int], ratios: list[float], override_applied: bool}`. u5 (`_override_to_grid_tracks`) will call this helper twice (rows with `axis_key='h'` / `total_extent=SLIDE_BODY_HEIGHT`; cols with `axis_key='w'` / `total_extent=SLIDE_BODY_WIDTH`) and assemble the dual-axis layout_css dict.\n- **Length-locked invariant**: `len(tracks_px) == len(axis_groupings) == len(dynamic_tracks_px)` is guaranteed by construction in both override and fallback branches.\n- **Per-group MAX semantics** match Stage 2 spec — picks the dominant override within a track regardless of how many positions share the track, which is what the 2-D presets need (e.g. `2x2` rows = `[['top-left','top-right'], ['bottom-left','bottom-right']]`).\n- No dispatch wiring is added in this unit. `build_layout_css` normal path still routes only `topology in {\"rows\",\"cols\"}` to dynamic sinks; the 5 in-scope 2-D topologies still fall through to `_build_fr_default`. The override branch still warn-and-fallthroughs for non-{horizontal-2, vertical-2} presets. u5 will build `_override_to_grid_tracks` on top of `_derive_axis_tracks_from_override`; u6/u7 will then wire dispatch.\n\n### evidence\n- `git log -1 --format=%H` → `201099e53b0048f3040e17ee9b0394ba9ad6e940` (Stage 2 baseline preserved; no commit/push per Stage 3 contract).\n- `git diff --stat src/phase_z2_pipeline.py` → `1 file changed, 172 insertions(+)` (cumulative across u1+u2+u3+u4; u4-only delta = 172 − 124 = **48 lines** ≤ 50-line gate ✓).\n- `git diff --numstat` → `172 0 src/phase_z2_pipeline.py` (single file ≤ 3-file gate ✓; 0 deletions = pure insertion).\n- `git diff --name-only` → `src/phase_z2_pipeline.py` only (no other tracked changes).\n- `git diff --name-only HEAD -- src/space_allocator.py` → empty (Kei legacy invariant preserved ✓).\n- `git diff --check src/phase_z2_pipeline.py` → no whitespace errors.\n- `python -c \"import ast; ast.parse(open('src/phase_z2_pipeline.py').read())\"` → `PARSE OK`.\n- AST top-level function-presence check:\n - `_aggregate_axis_zone`: PRESENT (u2 surface intact)\n - `_build_2d_dynamic`: PRESENT (u3 surface intact)\n - `_derive_axis_tracks_from_override`: PRESENT (u4 added) ✓\n - `_override_to_grid_tracks`: ABSENT (u5 still pending — correct) ✓\n - `build_layout_css`: PRESENT (unmodified)\n- Dispatch-absence grep on `build_layout_css` body:\n - Normal-path branches: only `topology == \"rows\"` → `_build_rows_dynamic`, `topology == \"cols\"` → `_build_cols_dynamic`, else `return _build_fr_default(preset)` ✓\n - Override branch: `[override-warning]` warn-and-fallthrough still in place for non-{horizontal-2, vertical-2} ✓\n - `_derive_axis_tracks_from_override` referenced only at its own `def` line (no consumer yet) ✓\n- Behavior smoke (6 cases; import-load with module on sys.path):\n - **A — override applied, 2 groups, 'h' axis**: groupings `[['top'], ['bottom-left','bottom-right']]`, override `{top:h=0.4, bottom-left:h=0.5, bottom-right:h=0.6}`, total_extent=600, gap=16, dyn=[200,400] → per-group max = [0.4, 0.6]; ratios = [0.4, 0.6]; tracks_px = [234, 350]; sum = 584 = 600−16; `override_applied=True` ✓\n - **B — empty override, fallback**: same groupings + `{}` + dyn=[200,400] → tracks_px = [200,400] verbatim; ratios = [0.342, 0.685] (re-derived `t/space`); `override_applied=False` ✓\n - **C — cols axis ('w' key) for 2x2**: groupings `[['top-left','bottom-left'], ['top-right','bottom-right']]`, override `{tl:w=0.3, bl:w=0.2, tr:w=0.7, br:w=0.5}`, total_extent=1200, gap=16, dyn=[600,600] → per-group max = [0.3, 0.7]; ratios = [0.3, 0.7]; tracks_px = [355, 829]; sum = 1184 = 1200−16 ✓\n - **D — partial missing**: `{top:h=0.6}` only → per-group max = [0.6, 0.0]; total=0.6>0 → `override_applied=True`; tracks_px = [584, 0] (all space to top group) ✓\n - **E — zero on axis_key**: `{top:{w:0.5, h:0.0}}` for h axis → per-group max=[0.0]; total=0 → fallback to dyn=[720]; `override_applied=False` ✓\n - **F — single grouping, override applied**: `[['top']]` + `{top:h=0.7}` + total_extent=720, gap=16, dyn=[720] → n=1, space=720, ratios=[1.0], tracks_px=[720] ✓\n- Integration smoke (composed u3 + u4 as u5 will do): T preset with dyn `heights_px=[288, 283]`, `widths_px=[583, 583]`; empty override → fallback returns dyn verbatim on both axes; user override `{top:h=0.3,w=1.0; bottom-left:h=0.7,w=0.4; bottom-right:h=0.7,w=0.6}` → row=[171, 400] (sum=571=SLIDE_BODY_HEIGHT−gap), col=[466, 700] (sum=1166=SLIDE_BODY_WIDTH−gap). Length-lock holds on both axes; `override_applied=True` on both axes.\n\n### remaining_units\n- `[u5, u6, u7, u8, u9, u10, u11, u12, u13, u14, u15, u16, u17, u18, u19, u20, u21, u22, u23, u24, u25, u26]` (22 units left)\n- **Next**: `u5` — add `_override_to_grid_tracks` (calls `_derive_axis_tracks_from_override` twice for rows and cols; consumes `_build_2d_dynamic`'s `raw_zone_layout` row/col `heights_px`/`widths_px` as the dynamic-fallback values).\n\n### follow_up_issue_candidates\n- None this unit. All work in u4 is within Stage 1/Stage 2 scope-lock. The Step 8 `computation` label audit for the new label values (`\"2d_dynamic_aggregated\"` already landed in u3; `\"user_override_2d\"` will be introduced by u5/u7) remains a Stage 2 follow_up_candidate — not introduced by u4.\n\n### scope_lock honored\n- Only `src/phase_z2_pipeline.py` touched (single file, single new top-level helper).\n- `src/space_allocator.py` untouched (Kei legacy `build_containers_type_b` invariant).\n- No dispatch wiring (`build_layout_css` unchanged); pure helper addition, consumed by u5 next round.\n- PR 1 surfaces (`_parse_css_areas`, `_parse_fr_string`, `compute_zone_layout`, `compute_zone_layout_cols`, `_compute_per_zone_geometry`, `_build_fr_default`, `_build_rows_dynamic`, `_build_cols_dynamic`) untouched.\n- u1/u2/u3 surfaces untouched.\n\nrewind_target: continue_same" }