feat(IMP-11): D-2 — frame min_height_px hint (backend → UI)

Step 9 v4_all_judgments[] now exposes per-candidate min_height_px from
catalog frame_contracts.visual_hints.min_height_px (None when contract
unregistered). SlideCanvas pendingLayout zones render a red ring + 'min H
Npx' badge when zone height falls below the active frame's threshold.
Visual hint only; resize clamp (minSize=0.05) unchanged.

5 axes (single commit per Stage 5 plan):
- u1 backend: src/phase_z2_pipeline.py — Step 9 builder adds min_height_px
  via single get_contract(c.template_id) lookup; reuses _contract for
  catalog_registered (no double-lookup).
- u2 type: Front/client/src/types/designAgent.ts — FrameCandidate gains
  optional minHeightPx?: number.
- u3 mapper: Front/client/src/services/designAgentApi.ts — maps snake-case
  min_height_px → camelCase minHeightPx on v4_all_judgments path;
  v4_candidates fallback remains undefined (graceful).
- u4 active-frame lookup: Front/client/src/components/SlideCanvas.tsx —
  activeFrameId = overrideFrameId ?? defaultFrameId; activeCandidate via
  region.frame_candidates.find.
- u5 hint render: Front/client/src/components/SlideCanvas.tsx —
  zoneHeightPx = height * SLIDE_H (logical px, no double-apply); compare
  against activeCandidate.minHeightPx in pendingLayout mode only; red
  border + badge when below.

Tests: 5/5 pass in tests/test_phase_z2_step9_v4_all_judgments_min_height.py
(source-string + catalog-shape guards + None propagation, registered and
unregistered template_ids).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 22:29:17 +09:00
parent 0fb168befc
commit a79bd8bc43
5 changed files with 231 additions and 12 deletions

View File

@@ -0,0 +1,169 @@
"""IMP-11 D-2 (u1) — Step 9 v4_all_judgments[] min_height_px field tests.
u1 contract:
Each v4_all_judgments[] entry MUST expose `min_height_px` sourced from
catalog `frame_contracts[template_id].visual_hints.min_height_px`
(logical 1280×720 px), with `None` fallback when contract is unregistered.
A single `get_contract(c.template_id)` lookup binds both
`catalog_registered` and `min_height_px` (no double-lookup cost).
Production code = inline list builder in `run_phase_z2_mvp1`
(`src/phase_z2_pipeline.py`, near v4_all_for_unit loop). These tests follow
the same source-string + catalog-shape guard pattern as the existing
`test_step9_production_emits_candidate_evidence_and_alias` in
`tests/test_phase_z2_v4_fallback.py`, kept until a helper is extracted.
"""
from __future__ import annotations
import inspect
from src import phase_z2_pipeline
from phase_z2_mapper import get_contract, load_frame_contracts
# ─── Case 1 : u1 production-source guard ────────────────────────────────────
def test_v4_all_judgments_emits_min_height_px_with_none_fallback():
"""Source guard — single get_contract bound to `_contract`, then both
`catalog_registered` and `min_height_px` derived from that binding.
`min_height_px` uses the `(_contract or {})` chain so unregistered
contracts propagate `None` (frontend tolerates undefined).
"""
source = inspect.getsource(phase_z2_pipeline)
# u1 marker present (locates the builder)
assert "IMP-11 D-2 (u1)" in source
# Single get_contract lookup bound to local var
assert "_contract = get_contract(c.template_id)" in source
# catalog_registered reuses the local binding (no second lookup)
assert '"catalog_registered": _contract is not None' in source
# min_height_px source = visual_hints chain; None when contract is None
assert (
'"min_height_px": (_contract or {})'
'.get("visual_hints", {})'
'.get("min_height_px")'
) in source
# v4_all_judgments wires the new builder list
assert '"v4_all_judgments": v4_all_judgments_list' in source
# ─── Case 2 : additive guarantee — existing 7 fields preserved ──────────────
def test_v4_all_judgments_preserves_existing_fields():
"""u1 is additive only — the existing 7 keys must remain in the per-entry
dict alongside the new `min_height_px`.
"""
source = inspect.getsource(phase_z2_pipeline)
builder_start = source.find("IMP-11 D-2 (u1)")
assert builder_start != -1
builder_end = source.find("application_plan_units.append", builder_start)
assert builder_end != -1
builder = source[builder_start:builder_end]
for field in (
'"template_id": c.template_id',
'"frame_id": c.frame_id',
'"frame_number": c.frame_number',
'"v4_rank": c.v4_rank',
'"confidence": c.confidence',
'"label": c.label',
'"catalog_registered": _contract is not None',
'"min_height_px":',
):
assert field in builder, f"missing field in u1 builder: {field!r}"
# ─── Case 3 : catalog reality — visual_hints.min_height_px shape is real ────
def test_catalog_visual_hints_min_height_px_path_is_real():
"""The source-string guard depends on the actual catalog shape having
`visual_hints.min_height_px` as a positive int on registered contracts
whose `visual_hints` block declares it. Verify against the real
`frame_contracts.yaml` so a future catalog schema change cannot silently
invalidate the `.get("visual_hints", {}).get("min_height_px")` chain.
"""
load_frame_contracts()
# Real registered template_ids that ship with visual_hints.min_height_px
# (verified via load_frame_contracts() — see frame_contracts.yaml).
sample_template_ids = (
"three_parallel_requirements",
"process_product_two_way",
"construction_goals_three_circle_intersection",
"bim_dx_comparison_table",
)
found = 0
for tid in sample_template_ids:
contract = get_contract(tid)
if contract is None:
continue # tolerate catalog rename — at least one must remain
# The exact .get chain used by the u1 builder
min_h = (contract or {}).get("visual_hints", {}).get("min_height_px")
assert isinstance(min_h, int), (
f"{tid}: visual_hints.min_height_px must be int, "
f"got {type(min_h).__name__}={min_h!r}"
)
assert min_h > 0, f"{tid}: min_height_px must be positive, got {min_h}"
found += 1
assert found > 0, (
"no sample registered contract present — catalog audit drift; "
"update sample_template_ids to match current frame_contracts.yaml"
)
def test_registered_contract_without_min_height_px_propagates_none():
"""Registered contract whose `visual_hints` block omits `min_height_px`
(or sets it to `null`) must also propagate `None` through the u1 chain.
Real example in current catalog: `bim_issues_quadrant_four`.
"""
load_frame_contracts()
tid = "bim_issues_quadrant_four"
contract = get_contract(tid)
if contract is None:
import pytest # noqa: PLC0415 — runtime skip only when catalog drifts
pytest.skip(f"sample template {tid!r} no longer registered")
# Exact chain used by u1 builder
min_h = (contract or {}).get("visual_hints", {}).get("min_height_px")
assert min_h is None, (
f"{tid}: expected None when visual_hints.min_height_px is absent/null, "
f"got {min_h!r} — chain semantics changed"
)
# catalog_registered must still be True (additive, independent of value)
assert (contract is not None) is True
# ─── Case 4 : None propagation for unregistered template_id ─────────────────
def test_unregistered_template_id_propagates_none():
"""When `get_contract(template_id)` returns `None`, the u1 chain
`(_contract or {}).get("visual_hints", {}).get("min_height_px")` must
yield `None` (frontend tolerates undefined; no KeyError).
"""
load_frame_contracts()
# Synthetic template_id guaranteed not to be in the catalog
unregistered = "MOCK_template_unregistered_for_u1_test"
assert get_contract(unregistered) is None, (
"test precondition broken — synthetic template_id leaked into catalog"
)
# Replicate the u1 chain exactly
_contract = get_contract(unregistered)
min_height = (_contract or {}).get("visual_hints", {}).get("min_height_px")
catalog_registered = _contract is not None
assert min_height is None
assert catalog_registered is False