feat(#76): IMP-47B reject-as-AI-adaptation activation (u1~u13 backend + tests)
- u1~u9: AI fallback infrastructure (router/prompts/schema/validator) + Step 12 hook - u10: e2e reject chain (writes final.html with AI-repaired slot, full coverage) - u11: frontend wiring deferred to follow-up commit (split from IMP-41 hunks) - u12: coverage_invariant guard - u13: cache save gate (visual_check PASS + user_approved/auto_cache) — Codex #22 verified Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
223
tests/test_imp47b_payload_apply.py
Normal file
223
tests/test_imp47b_payload_apply.py
Normal file
@@ -0,0 +1,223 @@
|
||||
"""IMP-47B u5 — PARTIAL_OVERRIDES apply tests.
|
||||
|
||||
Scope (this slice):
|
||||
Helper ``_apply_ai_repair_proposals_to_zones`` (src/phase_z2_pipeline.py)
|
||||
merges ``proposal.payload.slots`` into ``zones_data[k]["slot_payload"]``
|
||||
for PARTIAL_OVERRIDES proposals only, and loud-fails out-of-scope
|
||||
proposal kinds (builder_options_patch, slot_mapping_proposal) with an
|
||||
explicit ``apply_status`` marker.
|
||||
|
||||
The IMP-33 u5 validator inside ``route_ai_fallback`` already enforces
|
||||
declared-slot completeness — the apply helper is therefore a structural
|
||||
merge over the validator's contract, not a per-slot guard re-implementation.
|
||||
|
||||
u6 (step12_ai_repair.json audit), u7 (coverage invariant), and u8
|
||||
(slide_status surfacing) are out of scope for this unit.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from src.phase_z2_pipeline import _apply_ai_repair_proposals_to_zones
|
||||
|
||||
|
||||
def _record(
|
||||
*,
|
||||
unit_index: int,
|
||||
proposal: dict | None,
|
||||
source_section_ids: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""Synthetic gather_step12_ai_repair_proposals record."""
|
||||
return {
|
||||
"unit_index": unit_index,
|
||||
"source_section_ids": source_section_ids or [f"MOCK_S{unit_index}"],
|
||||
"frame_template_id": "MOCK_T",
|
||||
"label": "reject",
|
||||
"route_hint": "ai_adaptation_required",
|
||||
"provisional": True,
|
||||
"ai_called": proposal is not None,
|
||||
"skip_reason": None,
|
||||
"proposal": proposal,
|
||||
"error": None,
|
||||
"cache_key": "MOCK_F::abc" if proposal is not None else None,
|
||||
"fingerprints": {"contract_sha": "x", "partial_sha": "y", "catalog_sha": ""}
|
||||
if proposal is not None
|
||||
else None,
|
||||
}
|
||||
|
||||
|
||||
def _zone(*, position: str, slot_payload: dict | None = None) -> dict:
|
||||
"""Synthetic zones_data entry — only fields the apply helper touches."""
|
||||
return {
|
||||
"position": position,
|
||||
"template_id": "MOCK_T",
|
||||
"slot_payload": slot_payload if slot_payload is not None else {},
|
||||
}
|
||||
|
||||
|
||||
# ─── Case 1 : PARTIAL_OVERRIDES → merged + applied marker ──────────
|
||||
|
||||
|
||||
def test_partial_overrides_merges_slots_into_zone_slot_payload():
|
||||
"""The validator already guarantees declared-slot completeness, so
|
||||
apply is a structural ``dict.update``. Pre-existing meta keys
|
||||
(``_truncated_count``) survive; declared slot values are replaced
|
||||
by the AI proposal values."""
|
||||
proposal = {
|
||||
"proposal_kind": "partial_overrides",
|
||||
"payload": {
|
||||
"slots": {
|
||||
"title": "AI title",
|
||||
"bullets": ["AI bullet 1", "AI bullet 2"],
|
||||
}
|
||||
},
|
||||
"rationale": "MOCK",
|
||||
}
|
||||
records = [_record(unit_index=0, proposal=proposal)]
|
||||
zones = [
|
||||
_zone(
|
||||
position="top",
|
||||
slot_payload={
|
||||
"title": "deterministic title",
|
||||
"bullets": ["det bullet"],
|
||||
"_truncated_count": 0,
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
_apply_ai_repair_proposals_to_zones(records, ["top"], zones)
|
||||
|
||||
assert records[0]["apply_status"] == "applied:partial_overrides"
|
||||
assert zones[0]["slot_payload"]["title"] == "AI title"
|
||||
assert zones[0]["slot_payload"]["bullets"] == ["AI bullet 1", "AI bullet 2"]
|
||||
# meta keys not in proposal must survive the merge
|
||||
assert zones[0]["slot_payload"]["_truncated_count"] == 0
|
||||
|
||||
|
||||
# ─── Case 2 : BUILDER_OPTIONS_PATCH → loud-fail unsupported_kind ───
|
||||
|
||||
|
||||
def test_builder_options_patch_is_unsupported_for_reject_route():
|
||||
"""Builder-options application is out-of-scope for IMP-47B reject
|
||||
route (see Stage 2 plan). u5 must mark, not apply — the zone
|
||||
slot_payload stays byte-identical and the record carries the
|
||||
``unsupported_kind_for_reject_route:<kind>`` marker so u8 can
|
||||
surface human_review downstream."""
|
||||
proposal = {
|
||||
"proposal_kind": "builder_options_patch",
|
||||
"payload": {"font_size_px": 14},
|
||||
"rationale": "MOCK",
|
||||
}
|
||||
records = [_record(unit_index=0, proposal=proposal)]
|
||||
original_slot_payload = {"title": "deterministic"}
|
||||
zones = [_zone(position="top", slot_payload=dict(original_slot_payload))]
|
||||
|
||||
_apply_ai_repair_proposals_to_zones(records, ["top"], zones)
|
||||
|
||||
assert (
|
||||
records[0]["apply_status"]
|
||||
== "unsupported_kind_for_reject_route:builder_options_patch"
|
||||
)
|
||||
assert zones[0]["slot_payload"] == original_slot_payload
|
||||
|
||||
|
||||
# ─── Case 3 : SLOT_MAPPING_PROPOSAL → loud-fail unsupported_kind ───
|
||||
|
||||
|
||||
def test_slot_mapping_proposal_is_unsupported_for_reject_route():
|
||||
"""Slot-mapping (restructuring) application is also out-of-scope —
|
||||
builder-options + slot-mapping share the same marker path."""
|
||||
proposal = {
|
||||
"proposal_kind": "slot_mapping_proposal",
|
||||
"payload": {"slots": {"title": "x"}},
|
||||
"rationale": "MOCK",
|
||||
}
|
||||
records = [_record(unit_index=0, proposal=proposal)]
|
||||
zones = [_zone(position="top", slot_payload={"title": "deterministic"})]
|
||||
|
||||
_apply_ai_repair_proposals_to_zones(records, ["top"], zones)
|
||||
|
||||
assert (
|
||||
records[0]["apply_status"]
|
||||
== "unsupported_kind_for_reject_route:slot_mapping_proposal"
|
||||
)
|
||||
assert zones[0]["slot_payload"] == {"title": "deterministic"}
|
||||
|
||||
|
||||
# ─── Case 4 : no proposal (router short-circuit / not_provisional) ──
|
||||
|
||||
|
||||
def test_record_without_proposal_marked_no_proposal_and_zone_untouched():
|
||||
"""Flag-off short-circuit and non-AI-route units carry
|
||||
``proposal=None``. apply_status must distinguish "no proposal to
|
||||
apply" from real apply outcomes so u8 can categorise the per-unit
|
||||
status without re-reading skip_reason."""
|
||||
records = [_record(unit_index=0, proposal=None)]
|
||||
zones = [_zone(position="top", slot_payload={"title": "deterministic"})]
|
||||
|
||||
_apply_ai_repair_proposals_to_zones(records, ["top"], zones)
|
||||
|
||||
assert records[0]["apply_status"] == "no_proposal"
|
||||
assert zones[0]["slot_payload"] == {"title": "deterministic"}
|
||||
|
||||
|
||||
# ─── Case 5 : proposal exists but no matching zone (B4 mismatch) ────
|
||||
|
||||
|
||||
def test_proposal_for_unit_without_zone_match_marked_no_zone_match():
|
||||
"""When a unit is dropped from zones_data (B4 mismatch or FitError
|
||||
in the Step 12 render loop) but still gathered an AI proposal,
|
||||
apply must surface the mismatch via ``no_zone_match`` rather than
|
||||
silently dropping the proposal or writing into a wrong zone."""
|
||||
proposal = {
|
||||
"proposal_kind": "partial_overrides",
|
||||
"payload": {"slots": {"title": "AI title"}},
|
||||
"rationale": "MOCK",
|
||||
}
|
||||
records = [_record(unit_index=0, proposal=proposal)]
|
||||
# unit_positions[0]="top" but zones_data has only the bottom zone
|
||||
# → no match for the dropped unit's position.
|
||||
zones = [_zone(position="bottom", slot_payload={"title": "other zone"})]
|
||||
|
||||
_apply_ai_repair_proposals_to_zones(records, ["top"], zones)
|
||||
|
||||
assert records[0]["apply_status"] == "no_zone_match"
|
||||
# untouched zone — apply must not bleed into a different position
|
||||
assert zones[0]["slot_payload"] == {"title": "other zone"}
|
||||
|
||||
|
||||
# ─── Case 6 : mixed records — independent per-record classification ──
|
||||
|
||||
|
||||
def test_mixed_records_classified_independently():
|
||||
"""All five apply_status branches coexist in one batch — confirms
|
||||
the helper does not short-circuit on the first non-applied record."""
|
||||
records = [
|
||||
_record(unit_index=0, proposal={
|
||||
"proposal_kind": "partial_overrides",
|
||||
"payload": {"slots": {"title": "AI"}},
|
||||
"rationale": "",
|
||||
}),
|
||||
_record(unit_index=1, proposal={
|
||||
"proposal_kind": "builder_options_patch",
|
||||
"payload": {"font_size_px": 14},
|
||||
"rationale": "",
|
||||
}),
|
||||
_record(unit_index=2, proposal=None),
|
||||
]
|
||||
zones = [
|
||||
_zone(position="top", slot_payload={"title": "det"}),
|
||||
_zone(position="middle", slot_payload={"title": "det"}),
|
||||
_zone(position="bottom", slot_payload={"title": "det"}),
|
||||
]
|
||||
|
||||
_apply_ai_repair_proposals_to_zones(
|
||||
records, ["top", "middle", "bottom"], zones,
|
||||
)
|
||||
|
||||
assert [r["apply_status"] for r in records] == [
|
||||
"applied:partial_overrides",
|
||||
"unsupported_kind_for_reject_route:builder_options_patch",
|
||||
"no_proposal",
|
||||
]
|
||||
assert zones[0]["slot_payload"]["title"] == "AI"
|
||||
assert zones[1]["slot_payload"]["title"] == "det"
|
||||
assert zones[2]["slot_payload"]["title"] == "det"
|
||||
Reference in New Issue
Block a user