test(#13): 기존 실패 11건 수복 — #98 스퍼트의 의도된 변경에 테스트 계약 동기화

11건 전수 분류 결과 프로덕션 결함 0, 전부 '의도된 #98 변경 대비 낡은 테스트':
- A. compute_zone_layout_cols (1건): Task 11d min-width floor(≤260px) 계약으로 갱신
- B. build_layout_css fixtures (3건): floor 반영값으로 재생성 (fixture refresh = 설계된 lock surface)
- C. imp36 self-fire (1건): auto-fit collapsed 0px track 을 점유 트랙 카운트에서 제외
- D. imp88 salvage (5건): T19d(silent layout mutation 금지) 차단 계약으로 재작성
  — 차단 터미널/feasibility 선행 차단/전진 금지/trace 필드 4개 측면 + 정책 차단 체인
- E. step17 case_c (1건): 실행 2단 + T28.5 no_font_shrink 정책 차단 터미널로 갱신
- 89a_pre_baseline_sha.json 재캡처 (capture 스크립트 docstring 이 명시한 선행 drift
  — #98 스퍼트 기간 누적, 정규 절차)

검증 게이트: pytest tests/phase_z2 + phase_z2_ai_fallback + regression = 990 passed 0 failed
참고: T19d 차단으로 _attempt_salvage_chain 의 layout_adjust 실행 코드는 도달 불가
dead code 상태 — 제거는 #16 (height-fit 재설계) 에서 판단

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-03 12:48:57 +09:00
co-authored by Claude Opus 4.8
parent 299b1f311f
commit 2c7881c5cc
8 changed files with 146 additions and 175 deletions
@@ -19,20 +19,20 @@ input:
override_zone_geometries: null override_zone_geometries: null
expected_layout_css: expected_layout_css:
areas: '"left right-top" "left right-bottom"' areas: '"left right-top" "left right-bottom"'
cols: 777px 389px cols: 691px 475px
rows: 286px 285px rows: 286px 285px
heights_px: heights_px:
- 286 - 286
- 285 - 285
widths_px: widths_px:
- 777 - 691
- 389 - 475
ratios: ratios:
- 0.489 - 0.489
- 0.487 - 0.487
width_ratios: width_ratios:
- 0.658 - 0.586
- 0.33 - 0.403
computation: 2d_dynamic_aggregated computation: 2d_dynamic_aggregated
dynamic_rows: true dynamic_rows: true
dynamic_cols: true dynamic_cols: true
@@ -19,20 +19,20 @@ input:
override_zone_geometries: null override_zone_geometries: null
expected_layout_css: expected_layout_css:
areas: '"left-top right" "left-bottom right"' areas: '"left-top right" "left-bottom right"'
cols: 389px 777px cols: 475px 691px
rows: 286px 285px rows: 286px 285px
heights_px: heights_px:
- 286 - 286
- 285 - 285
widths_px: widths_px:
- 389 - 475
- 777 - 691
ratios: ratios:
- 0.489 - 0.489
- 0.487 - 0.487
width_ratios: width_ratios:
- 0.33 - 0.403
- 0.658 - 0.586
computation: 2d_dynamic_aggregated computation: 2d_dynamic_aggregated
dynamic_rows: true dynamic_rows: true
dynamic_cols: true dynamic_cols: true
@@ -14,18 +14,18 @@ input:
override_zone_geometries: null override_zone_geometries: null
expected_layout_css: expected_layout_css:
areas: '"left right"' areas: '"left right"'
cols: 816px 350px cols: 712px 454px
rows: 1fr rows: 1fr
heights_px: heights_px:
- 585 - 585
widths_px: widths_px:
- 816 - 712
- 350 - 454
ratios: ratios:
- 1.0 - 1.0
width_ratios: width_ratios:
- 0.692 - 0.603
- 0.297 - 0.385
computation: content_weight_distribution_cols computation: content_weight_distribution_cols
dynamic_rows: false dynamic_rows: false
dynamic_cols: true dynamic_cols: true
@@ -2,7 +2,8 @@
Column-axis weight-only solver. Mirrors compute_zone_layout for rows. Column-axis weight-only solver. Mirrors compute_zone_layout for rows.
No min_width_px contract exists in frame_contracts.yaml (verified No min_width_px contract exists in frame_contracts.yaml (verified
during Stage 2), so column distribution is purely content_weight. during Stage 2). Task 11d (#98) 이후: per-zone min-width floor(≤260px)
예약 후 잔여분을 content_weight 비례 배분 (0px track 방지).
""" """
from __future__ import annotations from __future__ import annotations
@@ -37,12 +38,21 @@ def test_two_equal_zones_split_evenly():
def test_asymmetric_weights_distribute_by_ratio(): def test_asymmetric_weights_distribute_by_ratio():
# Task 11d (#98) — 순수 비례가 아니라 per-zone min-width floor(≤260px) 를
# 먼저 예약한 뒤 잔여분만 weight 비례 배분한다 ("keep every occupied grid
# track renderable"). 0.8/0.2 두 zone 기준 기대값을 floor 계약으로 검증.
zones = [_zone("left", 0.8), _zone("right", 0.2)] zones = [_zone("left", 0.8), _zone("right", 0.2)]
result = compute_zone_layout_cols(zones) result = compute_zone_layout_cols(zones)
available = SLIDE_BODY_WIDTH - GRID_GAP available = SLIDE_BODY_WIDTH - GRID_GAP
assert sum(result["widths_px"]) == available assert sum(result["widths_px"]) == available
# left should be ~4x right min_width_px = min(260, max(1, available // 2))
assert result["widths_px"][0] > result["widths_px"][1] * 3 distributable = available - min_width_px * 2
expected_left = min_width_px + int(round(distributable * 0.8))
assert result["widths_px"][0] == expected_left
# floor 위에서도 좌우 우열은 유지되어야 한다.
assert result["widths_px"][0] > result["widths_px"][1]
# weight_shares 는 raw 비율 그대로 (floor 미반영) 기록된다.
assert result["weight_shares"] == [0.8, 0.2]
def test_zero_weight_guard_equal_split(): def test_zero_weight_guard_equal_split():
@@ -231,7 +231,11 @@ def _measure(drv, frame: dict, html_path: Path) -> dict:
"return el ? getComputedStyle(el).fontSize : null;", "return el ? getComputedStyle(el).fontSize : null;",
frame["text_sel"], frame["text_sel"],
) )
tracks = [t for t in (cols_tpl or "").split() if t] # Task 11 (#98) — 일부 family 가 repeat(3, 1fr) → repeat(auto-fit, minmax())
# 로 전환 (dynamic cardinality). auto-fit 의 collapsed track 은 computed
# style 에 0px 로 나타나므로, IMP-36 P1 계약("점유 트랙 수")은 non-zero
# track 만 센다.
tracks = [t for t in (cols_tpl or "").split() if t and t != "0px"]
return {"cols": cols_tpl, "tracks": len(tracks), "lh": lh, "fs": fs} return {"cols": cols_tpl, "tracks": len(tracks), "lh": lh, "fs": fs}
@@ -16,25 +16,22 @@ Stage 2 binding contract (u6):
planner emits a frame-scoped CSS rule via planner emits a frame-scoped CSS rule via
`apply_frame_internal_fit_candidate_css`. `apply_frame_internal_fit_candidate_css`.
T19d (#98) 정정 — layout_adjust 는 silent topology mutation 금지로
터미널 차단됨 (_attempt_salvage_chain 의 T19d 분기). 구 u6 의
"layout_adjust 실행" 계약(옛 2/3/4/7/8번 테스트)은 차단 계약으로 대체.
Test surfaces (8 tests): Test surfaces (8 tests):
1. `_SALVAGE_FAIL_BY_ACTION` map registers the two new actions with 1. `_SALVAGE_FAIL_BY_ACTION` map registers the two new actions with
the failure_type names the failure_router (u2) cascade rows expect. the failure_type names the failure_router (u2) cascade rows expect.
2. `layout_adjust` PASS — out_path promoted with the swapped render, 2. layout_adjust 차단 터미널 — step 없음, render 없음, out_path 보존.
step records new_layout_preset, cascade exits. 3. 차단은 feasibility 판정보다 먼저 (plan_layout_adjust 미호출).
3. `layout_adjust` infeasible (no sibling for `single` preset) — step 4. 차단 시 frame_internal_fit_candidate 로 전진하지 않음.
records failure_reason without invoking render_slide; cascade
advances to frame_internal_fit_candidate.
4. `layout_adjust` rerender FAIL — cascade advances to
frame_internal_fit_candidate (which then PASSes via patched
envelope).
5. `frame_internal_fit_candidate` PASS via patched envelope — out_path 5. `frame_internal_fit_candidate` PASS via patched envelope — out_path
promoted with CSS-overlay candidate. promoted with CSS-overlay candidate (direct entry 경로는 존속).
6. `frame_internal_fit_candidate` no-envelope — step records 6. `frame_internal_fit_candidate` no-envelope — step records
envelope_present=False; cascade exits via frame_reselect terminal. envelope_present=False; cascade exits via frame_reselect terminal.
7. Full 5-step cascade all fail — loop cap (range(len(map))=5) 7. CSS-overlay 3단 전부 실패 → layout_adjust 차단 터미널 (steps=3).
respected; exactly 5 steps recorded; out_path preserved. 8. 차단 터미널 trace 필드 observability (terminal_action / rationale).
8. `layout_adjust` uses no CSS-overlay path (css_override field is
absent from the layout_adjust step; new_layout_preset is present).
""" """
from __future__ import annotations from __future__ import annotations
@@ -146,21 +143,23 @@ def test_salvage_fail_map_registers_imp88_actions():
assert "image_fit" not in _SALVAGE_FAIL_BY_ACTION assert "image_fit" not in _SALVAGE_FAIL_BY_ACTION
# ── 2. layout_adjust PASS branch ──────────────────────────────────── # ── 2. layout_adjust — T19d(#98) 차단 터미널 ────────────────────────
# T19d 이후 layout_adjust 는 실행되지 않는다: "retry/salvage path 가 explicit
# layout override 없이 final.html 의 zone topology 를 바꾸면 안 된다" (silent
# topology mutation 금지). 아래 테스트들은 구 IMP-88 u6 의 실행 계약 대신
# 차단 계약을 잠근다.
def test_layout_adjust_pass_promotes_final_html(project_tmp, monkeypatch): def test_layout_adjust_blocked_terminal_preserves_final_html(project_tmp, monkeypatch):
"""initial_failure_type=font_step_insufficient routes to layout_adjust; """initial_failure_type=font_step_insufficient layout_adjust 로 라우팅
plan_layout_adjust swaps horizontal-2 → vertical-2 (rows ↔ cols, swap 되지만, T19d 차단으로 step 기록 없이 즉시 터미널 종료한다. render 미호출,
priority 0). render_slide is invoked with the NEW preset; overflow out_path 원본 보존, salvage_passed=False."""
passes → out_path promoted; step records new_layout_preset; cascade
exits with salvage_passed=True. No CSS overlay path was used."""
out_path = project_tmp / "final.html" out_path = project_tmp / "final.html"
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8") out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
counter = _patch_render(monkeypatch) counter = _patch_render(monkeypatch)
monkeypatch.setattr( monkeypatch.setattr(
_pz_pipeline, "run_overflow_check", _pz_pipeline, "run_overflow_check",
lambda p: {"passed": True, "fail_reasons": []}, lambda p: pytest.fail("layout_adjust 차단 시 overflow_check 호출 금지"),
) )
trace = _attempt_salvage_chain( trace = _attempt_salvage_chain(
@@ -173,100 +172,65 @@ def test_layout_adjust_pass_promotes_final_html(project_tmp, monkeypatch):
) )
assert trace["salvage_attempted"] is True assert trace["salvage_attempted"] is True
assert trace["salvage_passed"] is True assert trace["salvage_passed"] is False
assert len(trace["salvage_steps"]) == 1 assert trace["salvage_steps"] == []
step0 = trace["salvage_steps"][0] assert trace["salvage_terminal_action"] == (
assert step0["action"] == "layout_adjust" "layout_adjust_blocked_no_silent_layout_mutation"
assert step0["passed"] is True )
assert step0["new_layout_preset"] == "vertical-2" assert "kept original layout" in trace["salvage_terminal_rationale"]
assert step0["plan"]["feasible"] is True assert counter["n"] == 0
# layout_adjust uses the distinct render path — NOT the CSS-overlay path. assert out_path.read_text(encoding="utf-8") == "ORIGINAL_BEFORE_SALVAGE"
assert "css_override" not in step0
# render_slide was invoked exactly once with the NEW preset.
assert counter["n"] == 1
promoted = out_path.read_text(encoding="utf-8")
assert "ORIGINAL_BEFORE_SALVAGE" not in promoted
assert "data-rendered-preset='vertical-2'" in promoted
# ── 3. layout_adjust infeasible (no sibling) ──────────────────────── # ── 3. layout_adjust 차단은 feasibility 판정보다 먼저 ────────────────
def test_layout_adjust_infeasible_no_sibling_cascade_advances(project_tmp, monkeypatch): def test_layout_adjust_blocked_before_feasibility_check(project_tmp, monkeypatch):
"""`single` preset has no render-ready unit_count=1 sibling (catalog """T19d 차단은 plan_layout_adjust 호출 이전에 발생한다 — swap sibling
design — single and grid-2x2 have no swap target). layout_adjust 존재하는 horizontal-2 (feasible 했을 케이스)에서도 step 기록 없이 동일
returns feasible=False; render_slide is NOT invoked; cascade 터미널. plan 계산/렌더 어느 쪽도 실행되지 않음을 잠근다."""
advances to frame_internal_fit_candidate. Patched get_contract
returns no envelope → that branch also infeasible → cascade exits
at the frame_reselect terminal action."""
out_path = project_tmp / "final.html" out_path = project_tmp / "final.html"
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8") out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
counter = _patch_render(monkeypatch) counter = _patch_render(monkeypatch)
monkeypatch.setattr( monkeypatch.setattr(
_pz_pipeline, "run_overflow_check", _pz_pipeline, "plan_layout_adjust",
lambda p: pytest.fail( lambda **kw: pytest.fail("T19d 차단 시 plan_layout_adjust 호출 금지"),
"run_overflow_check must not run when no candidate is emitted"
),
)
monkeypatch.setattr(
_pz_mapper, "get_contract", lambda _tid: None,
) )
trace = _attempt_salvage_chain( trace = _attempt_salvage_chain(
run_dir=project_tmp, out_path=out_path, run_dir=project_tmp, out_path=out_path,
slide_title="imp88-u6", slide_footer=None, slide_title="imp88-u6", slide_footer=None,
zones_data=[{"position": "primary", "template_id": "t-only", zones_data=_horizontal_zones(),
"content_weight": {"score": 1.0}}], layout_preset="horizontal-2", layout_css=_LAYOUT_CSS_GATE_PASS,
layout_preset="single", layout_css=_LAYOUT_CSS_GATE_PASS, cascade_inputs=_ci_image(),
cascade_inputs={**_ci_image(), "zone_position": "primary"},
initial_failure_type="font_step_insufficient", gap_px=14, initial_failure_type="font_step_insufficient", gap_px=14,
) )
assert trace["salvage_attempted"] is True
assert trace["salvage_passed"] is False assert trace["salvage_passed"] is False
actions = [s["action"] for s in trace["salvage_steps"]] assert trace["salvage_steps"] == []
assert actions == ["layout_adjust", "frame_internal_fit_candidate"] assert trace["salvage_terminal_action"] == (
s0, s1 = trace["salvage_steps"] "layout_adjust_blocked_no_silent_layout_mutation"
assert s0["plan"]["feasible"] is False )
assert "no render-ready" in (s0["plan"]["failure_reason"] or "")
assert s0["new_layout_preset"] is None
assert s1["plan"]["feasible"] is False
assert s1["plan"]["envelope_present"] is False
# No candidate ever rendered (layout_adjust infeasible → no render call;
# frame_internal_fit_candidate infeasible → no render call).
assert counter["n"] == 0 assert counter["n"] == 0
# frame_reselect is the next routing target after
# frame_internal_fit_candidate_insufficient — not in salvage map → terminal.
assert trace.get("salvage_terminal_action") == "frame_reselect"
# Original final.html unchanged.
assert out_path.read_text(encoding="utf-8") == "ORIGINAL_BEFORE_SALVAGE" assert out_path.read_text(encoding="utf-8") == "ORIGINAL_BEFORE_SALVAGE"
# ── 4. layout_adjust rerender FAIL → frame_internal_fit_candidate PASS ── # ── 4. layout_adjust 차단 시 frame_internal_fit 로 전진하지 않음 ─────
def test_layout_adjust_fail_cascade_to_frame_internal_fit_pass(project_tmp, monkeypatch): def test_layout_adjust_blocked_no_cascade_advance(project_tmp, monkeypatch):
"""layout_adjust feasible but post-swap overflow persists → """T19d 차단은 cascade 전진(layout_adjust_insufficient → frame_internal_
failure_type=layout_adjust_insufficient → routes to fit_candidate)이 아니라 즉시 터미널이다. feasible envelope contract 가
frame_internal_fit_candidate. Patched contract provides an envelope 준비돼 있어도 frame_internal_fit_candidate 는 실행되지 않는다 — 구
variant that absorbs the excess; that branch PASSes → out_path IMP-88 u6 의 '4. rerender FAIL → cascade advance' 계약을 대체."""
promoted with the CSS-overlay candidate."""
out_path = project_tmp / "final.html" out_path = project_tmp / "final.html"
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8") out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
counter = _patch_render(monkeypatch) counter = _patch_render(monkeypatch)
# First overflow check (after layout_adjust render) FAILS,
# second (after frame_internal_fit CSS overlay) PASSes.
overflow_results = iter([
{"passed": False, "fail_reasons": ["zone overflow persists"]},
{"passed": True, "fail_reasons": []},
])
monkeypatch.setattr( monkeypatch.setattr(
_pz_pipeline, "run_overflow_check", _pz_pipeline, "run_overflow_check",
lambda p: next(overflow_results), lambda p: pytest.fail("차단 터미널에서 overflow_check 호출 금지"),
) )
# envelope 이 준비된 contract 라도 도달 자체가 없어야 한다.
# Stub contract with a feasible internal_envelope variant covering 40px excess.
stub_contract = { stub_contract = {
"internal_envelope": { "internal_envelope": {
"variants": [ "variants": [
@@ -289,20 +253,13 @@ def test_layout_adjust_fail_cascade_to_frame_internal_fit_pass(project_tmp, monk
initial_failure_type="font_step_insufficient", gap_px=14, initial_failure_type="font_step_insufficient", gap_px=14,
) )
assert trace["salvage_passed"] is True assert trace["salvage_passed"] is False
assert len(trace["salvage_steps"]) == 2 assert trace["salvage_steps"] == []
s0, s1 = trace["salvage_steps"] assert trace["salvage_terminal_action"] == (
assert s0["action"] == "layout_adjust" "layout_adjust_blocked_no_silent_layout_mutation"
assert s0["passed"] is False )
assert s0["plan"]["feasible"] is True assert counter["n"] == 0
assert s1["action"] == "frame_internal_fit_candidate" assert out_path.read_text(encoding="utf-8") == "ORIGINAL_BEFORE_SALVAGE"
assert s1["passed"] is True
assert s1["plan"]["selected_variant"] == "internal_grid_row"
assert s1["css_override"]
assert 'data-template-id="t-top"' in s1["css_override"]
# Two render_slide calls (one per dispatched branch).
assert counter["n"] == 2
assert "ORIGINAL_BEFORE_SALVAGE" not in out_path.read_text(encoding="utf-8")
# ── 5. frame_internal_fit_candidate PASS (direct entry) ───────────── # ── 5. frame_internal_fit_candidate PASS (direct entry) ─────────────
@@ -393,15 +350,15 @@ def test_frame_internal_fit_candidate_no_envelope_cascade_terminal(project_tmp,
assert out_path.read_text(encoding="utf-8") == "ORIGINAL_BEFORE_SALVAGE" assert out_path.read_text(encoding="utf-8") == "ORIGINAL_BEFORE_SALVAGE"
# ── 7. Loop cap respected — 5 stages all fail ─────────────────────── # ── 7. CSS-overlay 3단 실패 후 layout_adjust 차단 터미널 ─────────────
def test_full_5_step_cascade_all_fail_loop_cap_respected(project_tmp, monkeypatch): def test_cascade_stops_at_font_step_policy_block(project_tmp, monkeypatch):
"""Start at donor_slack_insufficient and force every cascade stage to """donor_slack_insufficient 에서 시작: cross_zone(fail) → glue(fail) →
fail: cross_zone (no fit_analysis), glue (excess > envelope), font_step font_step 은 T28.5 no_font_shrink 정책 차단 — blocked step 을 기록하고
(no headroom), layout_adjust (single → no sibling), frame_internal_fit 터미널 종료한다. layout_adjust / frame_internal_fit_candidate 는 도달
(no contract). Loop iterates exactly len(_SALVAGE_FAIL_BY_ACTION)=5 불가 (구 '5단 loop cap' 계약을 대체 — 실행 가능 cascade 는 2단 + 정책
times → salvage_steps has 5 entries in the exact cascade order.""" 차단 2종)."""
out_path = project_tmp / "final.html" out_path = project_tmp / "final.html"
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8") out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
counter = _patch_render(monkeypatch) counter = _patch_render(monkeypatch)
@@ -416,7 +373,6 @@ def test_full_5_step_cascade_all_fail_loop_cap_respected(project_tmp, monkeypatc
trace = _attempt_salvage_chain( trace = _attempt_salvage_chain(
run_dir=project_tmp, out_path=out_path, run_dir=project_tmp, out_path=out_path,
slide_title="imp88-u6", slide_footer=None, slide_title="imp88-u6", slide_footer=None,
# single preset → layout_adjust will be infeasible (no sibling)
zones_data=[{"position": "primary", "template_id": "t-only", zones_data=[{"position": "primary", "template_id": "t-only",
"content_weight": {"score": 1.0}}], "content_weight": {"score": 1.0}}],
layout_preset="single", layout_css=_LAYOUT_CSS_GATE_PASS, layout_preset="single", layout_css=_LAYOUT_CSS_GATE_PASS,
@@ -432,38 +388,37 @@ def test_full_5_step_cascade_all_fail_loop_cap_respected(project_tmp, monkeypatc
) )
assert trace["salvage_passed"] is False assert trace["salvage_passed"] is False
assert len(trace["salvage_steps"]) == 5 assert len(trace["salvage_steps"]) == 3
actions = [s["action"] for s in trace["salvage_steps"]] actions = [s["action"] for s in trace["salvage_steps"]]
assert actions == [ assert actions == [
"cross_zone_redistribute", "cross_zone_redistribute",
"glue_compression", "glue_compression",
"font_step_compression", "font_step_compression",
"layout_adjust",
"frame_internal_fit_candidate",
] ]
# All 5 are infeasible — no candidate rendering anywhere. # 어떤 branch 도 candidate 렌더 없음.
assert counter["n"] == 0 assert counter["n"] == 0
# Loop exhausted at cap (no mid-cascade terminal_action since each # font_step 은 T28.5 정책 차단 step (blocked_by_policy) + 터미널.
# next_action stayed in _SALVAGE_FAIL_BY_ACTION through 5 stages). assert trace["salvage_steps"][2]["blocked_by_policy"] == "no_font_shrink"
assert "salvage_terminal_action" not in trace assert trace["salvage_terminal_action"] == (
"font_step_compression_blocked_no_font_shrink"
)
assert out_path.read_text(encoding="utf-8") == "ORIGINAL_BEFORE_SALVAGE" assert out_path.read_text(encoding="utf-8") == "ORIGINAL_BEFORE_SALVAGE"
# ── 8. layout_adjust uses the distinct render path (no CSS overlay) ── # ── 8. 차단 터미널 trace 필드 observability ──────────────────────────
def test_layout_adjust_step_has_no_css_overlay_field(project_tmp, monkeypatch): def test_layout_adjust_block_trace_fields(project_tmp, monkeypatch):
"""layout_adjust's render path is qualitatively different from the """T19d 차단 터미널의 관측 계약: salvage_terminal_action 은 고정 문자열,
CSS-overlay planners (glue / font_step / cross_zone / frame_internal_fit): salvage_terminal_rationale 은 '왜 layout 을 유지했는지'를 설명하며,
it calls render_slide with the NEW preset + remapped zones_data + new salvage_steps 에 layout_adjust entry 가 존재하지 않는다 (구 '8. distinct
layout_css. The step dict therefore omits `css_override` and surfaces render path' 관측 계약을 대체)."""
`new_layout_preset` instead — observability for downstream classifiers."""
out_path = project_tmp / "final.html" out_path = project_tmp / "final.html"
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8") out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
_patch_render(monkeypatch) _patch_render(monkeypatch)
monkeypatch.setattr( monkeypatch.setattr(
_pz_pipeline, "run_overflow_check", _pz_pipeline, "run_overflow_check",
lambda p: {"passed": True, "fail_reasons": []}, lambda p: pytest.fail("차단 터미널에서 overflow_check 호출 금지"),
) )
trace = _attempt_salvage_chain( trace = _attempt_salvage_chain(
@@ -475,9 +430,9 @@ def test_layout_adjust_step_has_no_css_overlay_field(project_tmp, monkeypatch):
initial_failure_type="font_step_insufficient", gap_px=14, initial_failure_type="font_step_insufficient", gap_px=14,
) )
step0 = trace["salvage_steps"][0] assert trace["salvage_terminal_action"] == (
assert step0["action"] == "layout_adjust" "layout_adjust_blocked_no_silent_layout_mutation"
# Distinct render path observability contract: )
assert "css_override" not in step0 rationale = trace["salvage_terminal_rationale"]
assert "new_layout_preset" in step0 assert "zone positions" in rationale and "kept original layout" in rationale
assert "candidate_path" in step0 assert all(s["action"] != "layout_adjust" for s in trace["salvage_steps"])
@@ -203,13 +203,12 @@ def test_case_c_all_three_fail_revert_preserved(project_tmp, monkeypatch):
False, out_path is unchanged (original final.html intact — (b)-revert False, out_path is unchanged (original final.html intact — (b)-revert
preserved). preserved).
IMP-88 u6 extends the cascade depth from 3 to 5 stages (layout_adjust + IMP-88 u6 는 cascade 를 5단으로 확장했으나, T19d (#98) 가 layout_adjust
frame_internal_fit_candidate added). When all stages are infeasible the 를 silent topology mutation 금지로 터미널 차단함 — 실행 가능한 cascade
cascade now runs through all five — the empty zones_data carried by 는 CSS-overlay 3단(cross_zone/glue/font_step)이고, 3단 전부 실패 시
_kwargs() makes plan_layout_adjust infeasible (length mismatch) and the layout_adjust 진입 시점에 차단 터미널로 종료된다. The (b)-revert
empty resulting template_id makes plan_frame_internal_fit_candidate contract this test locks (out_path untouched + salvage_passed=False)
infeasible (no contract). The (b)-revert contract this test locks is unchanged.
(out_path untouched + salvage_passed=False) is unchanged.
""" """
out_path = project_tmp / "final.html" out_path = project_tmp / "final.html"
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8") out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
@@ -240,21 +239,24 @@ def test_case_c_all_three_fail_revert_preserved(project_tmp, monkeypatch):
assert trace["salvage_attempted"] is True assert trace["salvage_attempted"] is True
assert trace["salvage_passed"] is False assert trace["salvage_passed"] is False
# IMP-88 u6 — cascade depth extended from 3 to 5; see _SALVAGE_FAIL_BY_ACTION. # T28.5 / T19d (#98) — font_step 은 no_font_shrink 정책 차단(blocked
assert len(trace["salvage_steps"]) == 5 # step 기록 + 터미널), layout_adjust 는 silent topology mutation 금지
# (도달 불가). cross_zone/glue 실패 후 font_step 차단에서 종료 —
# IMP-88 u6 의 5단 깊이는 실행 2단 + 정책 차단으로 재편됨.
assert len(trace["salvage_steps"]) == 3
actions = [s["action"] for s in trace["salvage_steps"]] actions = [s["action"] for s in trace["salvage_steps"]]
assert actions == [ assert actions == [
"cross_zone_redistribute", "cross_zone_redistribute",
"glue_compression", "glue_compression",
"font_step_compression", "font_step_compression",
"layout_adjust",
"frame_internal_fit_candidate",
] ]
assert trace["salvage_steps"][2]["blocked_by_policy"] == "no_font_shrink"
assert trace["salvage_terminal_action"] == (
"font_step_compression_blocked_no_font_shrink"
)
for step in trace["salvage_steps"]: for step in trace["salvage_steps"]:
assert step["passed"] is False assert step["passed"] is False
# layout_adjust uses a distinct render path → its step dict has no # 전 step shared CSS-overlay path — css_override 는 미배출(None).
# css_override key (new_layout_preset is the observability field
# instead). All other branches use the shared CSS-overlay path.
assert step.get("css_override") is None assert step.get("css_override") is None
assert step["failure_reason"] assert step["failure_reason"]
# No CSS / candidate emitted anywhere → no render_slide calls either. # No CSS / candidate emitted anywhere → no render_slide calls either.
@@ -2,7 +2,7 @@
"schema_version": 2, "schema_version": 2,
"axis": "IMP-89 89-a u4 — final.html SHA baseline captured via FULL run_phase_z2_mvp1 pipeline (flag OFF / default)", "axis": "IMP-89 89-a u4 — final.html SHA baseline captured via FULL run_phase_z2_mvp1 pipeline (flag OFF / default)",
"description": "Frozen SHA-256 of `final.html` bytes (the artifact written to disk at src/phase_z2_pipeline.py:5994-5996) captured by running the full Phase Z pipeline end-to-end for each mdx 01-05 under PHASE_Z_B4_MAPPER_SOURCE=OFF. Under flag OFF the 89-a selector `_select_mapper_template_id(plan, T)` returns `T` verbatim, so the mapper input is byte-identical to the pre-89-a legacy call shape `map_mdx_to_slots(section, unit.frame_template_id)` — the rendered HTML and therefore the final.html SHA match the pre-89-a baseline. The u4 regression test runs the same pipeline shape under flag OFF and asserts SHA equality. Regenerate only when an upstream mapper/render/template delta is deliberately reviewed and accepted.", "description": "Frozen SHA-256 of `final.html` bytes (the artifact written to disk at src/phase_z2_pipeline.py:5994-5996) captured by running the full Phase Z pipeline end-to-end for each mdx 01-05 under PHASE_Z_B4_MAPPER_SOURCE=OFF. Under flag OFF the 89-a selector `_select_mapper_template_id(plan, T)` returns `T` verbatim, so the mapper input is byte-identical to the pre-89-a legacy call shape `map_mdx_to_slots(section, unit.frame_template_id)` — the rendered HTML and therefore the final.html SHA match the pre-89-a baseline. The u4 regression test runs the same pipeline shape under flag OFF and asserts SHA equality. Regenerate only when an upstream mapper/render/template delta is deliberately reviewed and accepted.",
"captured_at_utc": "2026-05-27T04:19:30Z", "captured_at_utc": "2026-07-03T03:39:01Z",
"renderer": { "renderer": {
"entrypoint": "src.phase_z2_pipeline.run_phase_z2_mvp1", "entrypoint": "src.phase_z2_pipeline.run_phase_z2_mvp1",
"write_site": "src/phase_z2_pipeline.py:5994-5996", "write_site": "src/phase_z2_pipeline.py:5994-5996",
@@ -19,37 +19,37 @@
"01.mdx": { "01.mdx": {
"mdx_file": "01.mdx", "mdx_file": "01.mdx",
"run_id": "89a_baseline_01", "run_id": "89a_baseline_01",
"final_html_size_bytes": 29959, "final_html_size_bytes": 38731,
"sha256": "62d793166ca4cdd8d8d1d134d8659b43dce95bb958e7efd6ce0e72619b3c335d", "sha256": "749ebb626e284a62d37b5be32af97c61ae2a5b8d4226d1c3fa90f04c843d9fef",
"pipeline_exit_code": null "pipeline_exit_code": null
}, },
"02.mdx": { "02.mdx": {
"mdx_file": "02.mdx", "mdx_file": "02.mdx",
"run_id": "89a_baseline_02", "run_id": "89a_baseline_02",
"final_html_size_bytes": 25789, "final_html_size_bytes": 42127,
"sha256": "333defb22f5da940b0bb3b3563730d12bd901d7b5d870a2aaef3f0c929d2dad1", "sha256": "835d67eb7003bbcc55f3ea9ea73e6c781bb3b51d021a860200876c1d93cecfd2",
"pipeline_exit_code": null "pipeline_exit_code": null
}, },
"03.mdx": { "03.mdx": {
"mdx_file": "03.mdx", "mdx_file": "03.mdx",
"run_id": "89a_baseline_03", "run_id": "89a_baseline_03",
"final_html_size_bytes": 40550, "final_html_size_bytes": 40217,
"sha256": "d84e32b402cfbdcab6025b584b5e2657766f11b3fc110c217be0551e39f3f516", "sha256": "44abaaf8fd0737002188c7af0ae3bd0dd281b313f74b8a50ddfd4aa0ddef3eb7",
"pipeline_exit_code": null "pipeline_exit_code": null
}, },
"04.mdx": { "04.mdx": {
"mdx_file": "04.mdx", "mdx_file": "04.mdx",
"run_id": "89a_baseline_04", "run_id": "89a_baseline_04",
"final_html_size_bytes": 28725, "final_html_size_bytes": 40206,
"sha256": "da3668d76fc582a1a26ad930b54a8abb7079969ebf91e79cfe7dd273ec4a0e95", "sha256": "8ba35bc6642b0e651cd46b9216070bd49052b016807611d9771fd630ca7a2613",
"pipeline_exit_code": null "pipeline_exit_code": 1
}, },
"05.mdx": { "05.mdx": {
"mdx_file": "05.mdx", "mdx_file": "05.mdx",
"run_id": "89a_baseline_05", "run_id": "89a_baseline_05",
"final_html_size_bytes": 21739, "final_html_size_bytes": 36844,
"sha256": "5baa8478a1316403dd3e7b7bccaaaee20fe6ac7738ea7ddd2af2f19b4b294f2c", "sha256": "397f238df2d23c7f3371d9032de5fbae4fd37aca116bb8b62e8fd36c4255c26d",
"pipeline_exit_code": 1 "pipeline_exit_code": null
} }
}, },
"total_files": 5 "total_files": 5