untracked files on main: dceb101 feat(#63): IMP-34 R1 donor capacity measured bound (u1+u2)

This commit is contained in:
2026-05-21 22:07:41 +09:00
commit 8f085a28d3
3220 changed files with 985495 additions and 0 deletions
+426
View File
@@ -0,0 +1,426 @@
[Claude #2] Stage 3: 수정 계획 — round 2
Codex #1 round 1 의 4 blocking gap + rollback 정정 검증 + lock. 모든 line 번호 main HEAD 실측 (PR 미시작). 코드 미수정.
## 0. Codex #1 정정 검증
| Codex 정정 | 검증 결과 | 처분 |
|---|---|---|
| Gap 1 — Step 8 per-zone width/height 주입이 span zone 에서 깨짐 (`:3068-3070` zip 이 per-zone 과 per-row/col 혼동) | ✅ 확인. `debug_zones` = per-zone (top-1-bottom-2 → 3 entry: top/bottom-left/bottom-right). 2-D `heights_px` = per-row (2 entry). zip 직접 적용 시 top 이 row[0] 만, bottom-left 가 row[1], bottom-right 가 *idx-out-of-range or dropped*. 현재 코드의 zip 이 1-D 에서만 의도된 path. | 수용 — area-aware mapper 신설 (§2-A) |
| Gap 2 — PR 2 override 가 T / 2x2 의 X,Y,W,H → grid track 변환 룰 미정의 | ✅ 확인. round 1 의 `_build_layout_css_from_override` 는 horizontal-2 (h) / vertical-2 (w) legacy 그대로. T / 2x2 의 span zone 이 grid row/col 양쪽에 영향 — 룰 부재. | 수용 — override 룰 5 조항 명시 (§2-B) |
| Gap 3 — PR 3 V4 32-frame test 가 `select_layout_preset(unit_count)` 으로 signature 오기. assertion `dynamic_rows or dynamic_cols or single``single` field 부재 | ✅ 확인. `src/phase_z2_composition.py:709` signature = `select_layout_preset(units: list[CompositionUnit])`. 또한 `single` 분기는 `dynamic_rows=False, dynamic_cols=False` (Stage 2 §1 #3 lock) — `or single`*field* 가 아니라 *preset 이름* 의도였으나 assertion 으로 부정확. | 수용 — assertion 재작성 (§2-C). 단 invariant 자체도 surface 변경 (8-preset coverage assertion 으로 확장) |
| Gap 4 — `_parse_css_areas` / `compute_zone_layout_2d` 가 malformed 입력에 div-by-zero / silent 빈 출력 | ✅ 확인. round 1 snippet 의 `C = len(rows[0]) if R > 0 else 0``avail_w = total_width - gap * (C - 1)` 이 C=0 일 때 `total_width + gap` 으로 garbage. zone position 이 css_areas 에 없을 때도 silent 0 weight. | 수용 — strict validation (§2-D) |
| Rollback 정정 a — pixel cols `"583px 583px"` 가 invalid CSS spec 이라 한 표현은 잘못. grid-template-columns 가 px 값 허용. | ✅ 확인. CSS Grid spec — `grid-template-columns: <length>` 허용. round 1 의 rollback trigger 표현 오류. | 수용 — rollback trigger 재작성 (§2-E) |
| Rollback 정정 b — per-zone planned geometry 와 CSS grid span mismatch 가 PR 2 의 주 failure surface, rollback trigger 로 추가 필요 | ✅ 동의. Gap 1 의 area-aware mapper 가 misfire 시 fixture 가 catch 해야 함. | 수용 — rollback trigger 추가 (§2-E) |
6 정정 모두 수용. round 1 plan 의 미정의 surface 가 본 round 에서 lock.
## 1. 정정의 root cause
round 1 plan 의 근본 결함 = **2-D layout 에서 "zone" 과 "grid track" 의 분리 모델 부재**. 1-D 에서는 zone = row (또는 col) 라서 zip 으로 충분. 2-D 에서는 :
- grid track (row / col) = 물리 분할 단위 (catalog `css_areas` 의 토큰 위치)
- zone = 의미 단위 (debug_zones 의 unique position)
- spanning zone = 1 zone 이 N track 차지
3 개념 분리 필요. round 2 가 area-aware mapper 로 분리 lock.
## 2. 정제된 contract (round 1 → round 2 변경)
### 2-A. Area-aware zone geometry mapper (Gap 1)
신규 helper `_compute_per_zone_geometry` :
```python
def _compute_per_zone_geometry(
layout_css: dict,
debug_zones: list[dict],
gap: int = GRID_GAP,
) -> list[dict]:
"""Map grid-track heights/widths to per-zone aggregated dimensions.
Handles spanning zones in 2-D layouts (T, inverted-T, side-T, 2x2):
zone_height = sum(track_heights for rows occupied) + gap * (span_rows - 1)
zone_width = sum(track_widths for cols occupied) + gap * (span_cols - 1)
For 1-D layouts (horizontal-2, vertical-2):
degenerates to simple positional indexing (no spanning).
Returns list[{position, zone_height_px, zone_width_px,
zone_height_ratio, zone_width_ratio}].
"""
css_areas = layout_css.get("areas")
heights_px = layout_css.get("heights_px") or []
widths_px = layout_css.get("widths_px") or []
raw_zl = layout_css.get("raw_zone_layout") or {}
rows_grid = raw_zl.get("rows_grid") # set by compute_zone_layout_2d only
if rows_grid is None:
# 1-D path : positional zip (legacy horizontal-2 / new vertical-2)
per_zone = []
for i, dz in enumerate(debug_zones):
per_zone.append({
"position": dz["position"],
"zone_height_px": heights_px[i] if i < len(heights_px) else None,
"zone_width_px": widths_px[i] if i < len(widths_px) else None,
"zone_height_ratio": (
layout_css.get("ratios", [])[i]
if i < len(layout_css.get("ratios", [])) else None
),
"zone_width_ratio": (
layout_css.get("width_ratios", [])[i]
if i < len(layout_css.get("width_ratios", [])) else None
),
})
return per_zone
# 2-D path : aggregate via parsed grid
per_zone = []
for dz in debug_zones:
pos = dz["position"]
occupied_rows = sorted({r for r, row in enumerate(rows_grid)
if pos in row})
occupied_cols = sorted({c for r, row in enumerate(rows_grid)
for c, tok in enumerate(row) if tok == pos})
if not occupied_rows or not occupied_cols:
# zone declared but not in css_areas — strict error (catalog corruption)
raise ValueError(
f"zone position '{pos}' not found in parsed css_areas {rows_grid}"
)
zh = sum(heights_px[r] for r in occupied_rows) \
+ gap * (len(occupied_rows) - 1)
zw = sum(widths_px[c] for c in occupied_cols) \
+ gap * (len(occupied_cols) - 1)
per_zone.append({
"position": pos,
"zone_height_px": zh,
"zone_width_px": zw,
"zone_height_ratio": round(zh / SLIDE_BODY_HEIGHT, 3),
"zone_width_ratio": round(zw / SLIDE_BODY_WIDTH, 3),
})
return per_zone
```
호출처 변경 :
- `:3067-3073` (debug_zones 갱신) — zip 직접 사용 폐기. 대신 :
```python
per_zone_geo = _compute_per_zone_geometry(layout_css, debug_zones, GRID_GAP)
for dz, geo in zip(debug_zones, per_zone_geo):
dz["height_px"] = geo["zone_height_px"]
dz["ratio"] = geo["zone_height_ratio"]
dz["width_px"] = geo["zone_width_px"]
dz["width_ratio"] = geo["zone_width_ratio"]
```
- `:3228-3251` (Step 8 per-zone) — `dz.get("height_px")` / `dz.get("width_px")` 이 mapper 결과 (이미 위에서 주입) 그대로.
이 패턴은 1-D / 2-D 양쪽 모두 동일 API. spanning 처리는 mapper 안에 격리.
검증 fixture (PR 2 추가) :
- `top-1-bottom-2` : `top.zone_width_px = widths_px[0] + widths_px[1] + GRID_GAP`, `bottom-left.zone_width_px = widths_px[0]`.
- `grid-2x2` : `top-left.zone_width_px = widths_px[0]`, `top-left.zone_height_px = heights_px[0]` (span 없음).
### 2-B. Override branch 의 T / 2x2 룰 (Gap 2)
신규 `_build_layout_css_from_override` 의 일반 룰 (5 조항) :
1. **Consistency** : 모든 zone position 이 `override_zone_geometries` 에 있어야 함. partial override (일부 zone 만) → strict error.
2. **Spanning consistency** : zone 이 N track 을 span 할 때, 그 zone 의 `w` (또는 `h`) 는 *총 span* 비율이어야 함. 예: top-1-bottom-2 의 `top` = `w=1.0` (cols 0+1 전체) 필수. `w=0.5` 와 같이 부분 값이면 strict error.
3. **Track contribution** : 각 grid track 의 ratio = 그 track 에 포함된 non-span zone 의 ratio (있을 때만). 모든 zone 이 그 track 에서 span 이면 → spanning zone 의 ratio 를 N 등분하여 적용 (Codex Gap 2 의 "aggregated zone rectangles" 옵션).
4. **Track sum normalization** : 각 axis 의 track ratio 합 = 1.0 (오차 0.01 허용, 마지막 track 흡수).
5. **Missing geometry** : zone 이 override 에 없으면 strict error. partial fallback (override + dynamic mix) 미지원 (별 axis).
구현 :
```python
def _override_to_grid_tracks(
preset: dict, override_zone_geometries: dict,
css_areas: str, axis: str, # "row" or "col"
) -> list[float]:
"""Convert override X,Y,W,H per zone -> grid track ratios.
axis="row" -> returns row ratios derived from H values.
axis="col" -> returns col ratios derived from W values.
Spanning rule: a zone spanning N tracks on the axis MUST have
its H (or W) value equal the total span ratio. The function
distributes that span H/W equally across the spanned tracks if
no non-span zone fixes them; otherwise non-span zones win.
"""
rows_grid, unique_zones = _parse_css_areas(css_areas)
R, C = len(rows_grid), len(rows_grid[0]) if rows_grid else 0
# 5-#1 / #5 — consistency / missing
for pos in unique_zones:
if pos not in override_zone_geometries:
raise ValueError(
f"override missing zone '{pos}' (partial override not supported)"
)
if axis == "row":
track_count = R
track_values: list[Optional[float]] = [None] * R
for pos, geom in override_zone_geometries.items():
occupied = sorted({r for r, row in enumerate(rows_grid) if pos in row})
span = len(occupied)
if span == 0:
continue
if span == 1:
track_values[occupied[0]] = geom["h"] # non-span wins
else:
# 5-#3 — spanning zone, distribute equally if no non-span fixed
per_track = geom["h"] / span
for r in occupied:
if track_values[r] is None:
track_values[r] = per_track
else: # axis == "col"
track_count = C
track_values = [None] * C
for pos, geom in override_zone_geometries.items():
occupied = sorted({c for r, row in enumerate(rows_grid)
for c, tok in enumerate(row) if tok == pos})
span = len(occupied)
if span == 0:
continue
if span == 1:
track_values[occupied[0]] = geom["w"]
else:
per_track = geom["w"] / span
for c in occupied:
if track_values[c] is None:
track_values[c] = per_track
if any(v is None for v in track_values):
raise ValueError(
f"override produced unresolved {axis} tracks: {track_values} "
f"(catalog/override mismatch)"
)
total = sum(track_values)
if not 0.99 <= total <= 1.01:
raise ValueError(
f"override {axis} ratios sum to {total} (expected ~1.0)"
)
# 5-#4 — normalize last track to absorb rounding
track_values[-1] += 1.0 - total
return track_values
```
`build_layout_css` override branch (`:856-902`) 재구조화 :
```python
if override_zone_geometries:
row_ratios = _override_to_grid_tracks(
preset, override_zone_geometries, preset["css_areas"], "row"
)
col_ratios = _override_to_grid_tracks(
preset, override_zone_geometries, preset["css_areas"], "col"
)
heights_px = [int(round(r * (SLIDE_BODY_HEIGHT - GRID_GAP * (len(row_ratios)-1))))
for r in row_ratios]
widths_px = [int(round(c * (SLIDE_BODY_WIDTH - GRID_GAP * (len(col_ratios)-1))))
for c in col_ratios]
return {
"areas": preset["css_areas"],
"cols": " ".join(f"{w}px" for w in widths_px),
"rows": " ".join(f"{h}px" for h in heights_px),
"heights_px": heights_px,
"widths_px": widths_px,
"ratios": row_ratios,
"width_ratios": col_ratios,
"computation": "override_zone_geometries",
"dynamic_rows": True,
"dynamic_cols": True,
"raw_zone_layout": None,
}
```
horizontal-2 / vertical-2 의 *legacy override path* (h-only / w-only) 는 **삭제**. 새 일반 룰이 horizontal-2 (`R=2, C=1`) / vertical-2 (`R=1, C=2`) 도 자연스럽게 처리. 단 기존 fixture (Stage 2 §2-A retry_gate horizontal2 fixture) 의 input override 형식 (`h=0.6, h=0.4`) 은 보존되어야 함 — 호출 시 w 값이 없으면 catalog `css_cols=1fr` 에서 col=1 이므로 each zone w=1.0 implied. 기존 input dict 호환성 :
```python
# Default missing axis value : full extent (1.0) — preserves horizontal-2 legacy behavior
for pos, geom in override_zone_geometries.items():
geom.setdefault("h", 1.0)
geom.setdefault("w", 1.0)
geom.setdefault("x", 0.0)
geom.setdefault("y", 0.0)
```
이 default = legacy h-only override 가 vertical-2 의 R=1 일 때 R 의 row_ratio 합 = 1.0 (zone 1 개) 으로 자연 일관.
### 2-C. PR 3 V4 invariant test 재작성 (Gap 3)
round 1 의 `select_layout_preset(unit_count)` 잘못된 signature 폐기. 또한 *V4 32-frame surface* 는 `select_layout_preset` 의 4 preset (single / horizontal-2 / top-1-bottom-2 / grid-2x2) 만 커버 — 8 preset 전수 dynamic 확인은 *별 axis* (direct build_layout_css 호출).
분리된 2 test :
```python
# tests/phase_z2/test_all_8_presets_dynamic.py
def test_all_8_presets_produce_dynamic_geometry():
"""Every layout preset in catalog must produce dynamic geometry,
not fr_default_from_preset sink. PR 3 closing invariant."""
expected_computation_substring = {
"single": "single_zone_full_body",
"horizontal-2": "content_weight_distribution",
"vertical-2": "content_weight_distribution_cols",
"top-1-bottom-2": "content_weight_distribution_2d",
"top-2-bottom-1": "content_weight_distribution_2d",
"left-1-right-2": "content_weight_distribution_2d",
"left-2-right-1": "content_weight_distribution_2d",
"grid-2x2": "content_weight_distribution_2d",
}
for preset_id, expected in expected_computation_substring.items():
zones_data = _synthesize_zones_for_preset(preset_id)
result = build_layout_css(preset_id, zones_data)
assert expected in result["computation"], (
f"preset={preset_id} computation={result['computation']!r}, "
f"expected substring {expected!r}"
)
assert result["computation"] != "fr_default_from_preset", (
f"preset={preset_id} fell through to fr_default sink"
)
# tests/phase_z2/test_v4_full32_layout_coverage.py
def test_v4_full32_default_preset_paths():
"""Passive cross-check: for the 4 presets reachable via
select_layout_preset(units), V4 32-frame catalog never triggers
fr_default sink. select_layout_preset accepts list[CompositionUnit]."""
from src.phase_z2_composition import CompositionUnit, select_layout_preset
# Build dummy units for 1..4 unit counts (preset coverage of v0 selector)
for unit_count in range(1, 5):
units = [_dummy_composition_unit() for _ in range(unit_count)]
preset_id = select_layout_preset(units)
assert preset_id is not None
zones_data = _synthesize_zones_for_preset(preset_id)
result = build_layout_css(preset_id, zones_data)
assert result["computation"] != "fr_default_from_preset", (
f"unit_count={unit_count} preset={preset_id} fell through"
)
```
`_synthesize_zones_for_preset` = helper. catalog 의 `positions` 를 읽고 각 zone 에 `content_weight={score: 1.0}` 부여. zero-weight 가드 (§2-A round 1) 와 별 axis (동등 분배 확인 따로).
### 2-D. Strict validation (Gap 4)
`_parse_css_areas` 보강 :
```python
def _parse_css_areas(css_areas: str) -> tuple[list[list[str]], list[str]]:
"""Parse CSS grid-template-areas string into (row x col) cell grid.
Raises ValueError on:
- empty input
- non-rectangular (rows with different column counts)
- no quoted row strings found
"""
rows = []
seen = []
quoted = re.findall(r'"([^"]+)"', css_areas)
if not quoted:
raise ValueError(f"css_areas has no quoted row strings: {css_areas!r}")
for row_str in quoted:
tokens = row_str.split()
if not tokens:
raise ValueError(f"empty row in css_areas: {css_areas!r}")
rows.append(tokens)
for t in tokens:
if t not in seen:
seen.append(t)
col_counts = {len(r) for r in rows}
if len(col_counts) > 1:
raise ValueError(
f"non-rectangular css_areas: row column counts = {col_counts}"
)
return rows, seen
```
`compute_zone_layout_2d` 보강 :
```python
def compute_zone_layout_2d(zones_data, css_areas, ...):
...
rows, _seen = _parse_css_areas(css_areas)
R, C = len(rows), len(rows[0])
if R == 0 or C == 0:
raise ValueError(
f"compute_zone_layout_2d: degenerate grid R={R} C={C}"
)
# Validate every zone in zones_data has a position in css_areas
zone_positions = {z["position"] for z in zones_data}
grid_positions = set(_seen)
extra = zone_positions - grid_positions
if extra:
raise ValueError(
f"zones_data has positions {extra} not in css_areas {grid_positions}"
)
missing = grid_positions - zone_positions
if missing:
raise ValueError(
f"css_areas has positions {missing} not in zones_data"
)
...
```
신규 negative test `tests/phase_z2/test_parse_css_areas_validation.py` :
```python
def test_parse_css_areas_empty(): ... # raises ValueError
def test_parse_css_areas_no_quotes(): ... # raises
def test_parse_css_areas_non_rectangular(): ... # raises
def test_parse_css_areas_single_row(): ... # ([["top"]], ["top"])
def test_parse_css_areas_t_shape(): ... # rectangular T (span tokens)
def test_compute_zone_layout_2d_missing_zone(): ... # raises
def test_compute_zone_layout_2d_extra_zone(): ... # raises
def test_compute_zone_layout_2d_zero_weight_total(): ... # equal split
```
8 negative + edge case test. PR 2 land.
### 2-E. Rollback trigger 재작성
round 1 §6 정정 :
| PR | round 1 trigger | round 2 정정 |
|---|---|---|
| PR 1 | "vertical-2 가 `cols` 에 `\"NNpx NNpx\"` 출력 → grid-template-columns invalid" | **수정**: "vertical-2 final.html render 후 visual_check 단계 (`run_visual_check.py`) 가 grid layout broken (zone 박스 위치 mismatch) 감지" |
| PR 2 | (없음) | **추가**: "per-zone fixture 의 `zone_width_px` / `zone_height_px` 가 parsed css_areas span 과 mathematical inconsistent (= `_compute_per_zone_geometry` 가 spanning zone 을 잘못 집계)" |
| PR 2 | (없음) | **추가**: "override 호출 시 `_override_to_grid_tracks` 가 strict error 를 던지지 않고 silent garbage 반환 — partial override / span 불일치 case 모두 explicit raise 필요" |
| 공통 | "기존 42 unit test 중 하나라도 fail" | 유지 |
## 3. PR scope 정합화 (round 1 → round 2)
§2-A `_compute_per_zone_geometry` 와 §2-B `_override_to_grid_tracks` 의 **PR 배치 결정** :
| 구성요소 | PR 1 | PR 2 | PR 3 | 이유 |
|---|---|---|---|---|
| `_compute_per_zone_geometry` (1-D path) | ✅ | | | vertical-2 의 per-zone width 주입 시 필요 (`debug_zones` 갱신) |
| `_compute_per_zone_geometry` (2-D path, rows_grid 분기) | | ✅ | | `rows_grid` 는 `compute_zone_layout_2d` 출력에만 — 함수 자체는 PR 1 신설, 2-D 분기 활성화는 PR 2 |
| `_parse_css_areas` + strict validation | | ✅ | | 2-D solver 전제. PR 1 의 vertical-2 는 css_areas parsing 불필요 |
| `_override_to_grid_tracks` (일반 룰) | | ✅ | | T / 2x2 의 override 룰 = PR 2 land. PR 1 은 legacy h-only override (horizontal-2/vertical-2) preserve |
| `_override_to_grid_tracks` (h/w default 1.0) | ✅ | | | PR 1 의 vertical-2 override 도 새 룰 적용 — 동시 land |
PR 1 의 override branch 처리 = vertical-2 만 (h-only horizontal-2 legacy preserve, w-only vertical-2 신설). 일반 룰 (`_override_to_grid_tracks`) 은 PR 2 에서 도입 + 4 preset (h-2/v-2/T/2x2) 전수 migrate. 즉 PR 1 의 vertical-2 override 는 round 1 의 임시 분기 유지 (PR 2 가 통합).
수정 PR 시퀀스 (Stage 2 §2-C 호환 + Gap 1/2 반영) :
| PR | 작업 (round 2 lock) |
|---|---|
| **PR 1** | (a) `compute_zone_layout_cols` 신설 (§3-A round 1) <br>(b) `_compute_per_zone_geometry` 신설 — 1-D path 만 활성화 (2-D rows_grid 분기는 PR 2 가 enable) <br>(c) `build_layout_css` override branch — vertical-2 legacy 분기 추가 (h-only horizontal-2 + w-only vertical-2 둘 다 preserve) <br>(d) layout_css 신규 key (`widths_px` / `width_ratios` / `dynamic_cols`) — 모든 return path 일관 <br>(e) Step 8 col-axis 4 신규 field (per-zone width via `_compute_per_zone_geometry` 1-D path) <br>(f) **Retry gate** (`_attempt_zone_ratio_retry:1322` 직후) <br>(g) fixtures : horizontal-2 ×3 + vertical-2 ×3 + retry_gate ×3 <br>(h) unit tests : `test_compute_zone_layout_cols.py`, `test_build_layout_css.py` (h-2/v-2), `test_retry_gate.py`, `test_compute_per_zone_geometry.py` (1-D path) |
| **PR 2** | (a) `_parse_css_areas` + strict validation (§2-D) <br>(b) `compute_zone_layout_2d` (Q-r2-2 row sum, Q-r2-3 2x2 row/col sum) <br>(c) `_compute_per_zone_geometry` 2-D path enable (spanning zone aggregation) <br>(d) `_override_to_grid_tracks` 일반 룰 (5 조항 §2-B) — h-2/v-2/T/2x2 4 preset 전수 migrate (legacy 분기 폐기) <br>(e) `build_layout_css` dynamic branch — topology dispatcher (preset string fallback) <br>(f) fixtures : top-1-bottom-2 / top-2-bottom-1 / left-1-right-2 / left-2-right-1 / grid-2x2 ×3 = 15 YAML <br>(g) unit tests : `test_compute_zone_layout_2d.py`, `test_build_layout_css.py` 8-preset extension, `test_parse_css_areas_validation.py` (8 negative case), `test_override_to_grid_tracks.py` (5 룰 case) |
| **PR 3** | (a) `single` planned geometry 명시 (`heights_px=[SLIDE_BODY_HEIGHT]`, `widths_px=[SLIDE_BODY_WIDTH]`) <br>(b) Step 8 nested status (`zone_geometry_status="done"`, `region_geometry_status="partial"`) <br>(c) note 갱신 (`:3265-3273`) <br>(d) fixtures : single ×3 <br>(e) **PR 3 invariant tests** (§2-C) : `test_all_8_presets_dynamic.py` + `test_v4_full32_layout_coverage.py` |
## 4. 잔여 axis (본 round 처리 X)
| 항목 | 상태 | 근거 |
|---|---|---|
| `select_layout_preset` hardcoded → catalog-driven | 별 issue | Codex #3 Stage 1 정정 — IMP-09 out-of-scope |
| `frame_contracts.yaml` 의 `min_width_px` 도입 | 별 issue | Stage 2 §3-C — col-axis weight-only invariant lock |
| IMP-19 `_group_blocks_by_area` 직접 통합 | 별 issue | Stage 2 §3-B — reference only |
| Step 8 `step_status="complete"` 승급 | 별 issue | Stage 2 §2-B / Q-r2-4 — region-level partial 별 axis |
| Render template gap row/col 분리 | 미적용 | Stage 2 §4 #4 — shorthand 유지 lock |
## 5. Round 2 결론
- Codex #1 의 4 blocking gap (per-zone width injection, override 룰, V4 test signature, malformed validation) + 2 rollback 정정 모두 검증 + 수용.
- 신규 helper 2 (`_compute_per_zone_geometry`, `_override_to_grid_tracks`) + strict validation + negative test 8 개 추가.
- PR scope 재정합 — `_compute_per_zone_geometry` 1-D path PR 1, 2-D enable PR 2. `_override_to_grid_tracks` 일반 룰 PR 2.
- 잔여 open 결정 0. Stage 2 의 7 결정 분기 + round 1 Q-r2-1~5 + round 2 Gap 1~4 모두 코드 변경 단위 lock.
- 본 round 변경 = 0 (plan-only, code 미수정).
Codex 가 §2-A area-aware mapper / §2-B override 5 룰 / §2-C 재작성 test / §2-D strict validation / §2-E rollback trigger / §3 PR 재정합 6 axis 모두 ack 하면 Stage 3 종료 → Stage 4 implementation handoff.
FINAL_CONSENSUS: NO