feat(#61): IMP-33 AI fallback scaffolding (u1~u11, flag default OFF)

Frame-aware AI fallback module scaffolded under src/phase_z2_ai_fallback/
with master flag ai_fallback_enabled=False; normal-path AI call count
remains 0. AI output constrained to builder_options_patch /
partial_overrides / slot_mapping_proposal; MDX / frame_id / raw HTML /
raw CSS mutations rejected at schema layer. IMP-46 cache gate (cache.py)
raises AiFallbackCacheGateError unless visual_check_passed AND
user_approved. Step 12 wires AI repair after IMP-30 provisional payload
only; Step 17 stays blocked behind IMP-34 / IMP-35 prerequisites.
AST isolation guard forbids fallback package from importing Phase Q /
Kei / pipeline runtime symbols. Docs IMP-17 / IMP-31 bound to runtime
module surface via 11-row structural test pin (test_docs_sync.py) so
drift fails CI.

Tests: 116 fallback / 161 phase_z2 regression / 526 scoped full sweep
all passing. Existing pre-IMP-33 fixture issue in scripts/test_phase_t_*
remains untouched (out of scope).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-21 12:46:49 +09:00
co-authored by Claude Opus 4.7
parent c412f1ea75
commit c864fe0479
24 changed files with 2119 additions and 5 deletions
+83
View File
@@ -0,0 +1,83 @@
"""IMP-33 u5 — AI fallback proposal validator (fallback path only).
Defence-in-depth layer between the u4 client output (already u2-schema-valid)
and the caller. Adds the four Stage 2 guards that u2 cannot express purely at
the schema level:
1. builder-options whitelist (BUILDER_OPTIONS_PATCH may only touch keys
already declared in ``frame_contract.payload.builder_options``).
2. dropped-slot guard (PARTIAL_OVERRIDES / SLOT_MAPPING_PROPOSAL must keep
every declared ``sub_zones[*].id`` populated — text/table/image/details
slots cannot disappear; `feedback_ai_isolation_contract`).
3. frame-swap guard (no ``frame_id`` mutation inside payload — V4 rank-1
protected; `feedback_phase_z_spacing_direction`).
4. Internal Region containment (``payload.region_id`` must match the
declared Internal Region id when present).
"""
from __future__ import annotations
from typing import Any
from src.phase_z2_ai_fallback.schema import AiFallbackProposal, ProposalKind
class AiFallbackValidationError(ValueError):
"""Raised when a proposal violates an IMP-33 u5 guard."""
_SLOT_KINDS = (ProposalKind.PARTIAL_OVERRIDES, ProposalKind.SLOT_MAPPING_PROPOSAL)
def validate_proposal(
proposal: AiFallbackProposal,
*,
frame_contract: dict[str, Any],
internal_region: dict[str, Any] | None = None,
) -> None:
"""Validate an AI fallback proposal against the active frame contract.
Raises ``AiFallbackValidationError`` on any guard violation. Returns
``None`` on success — caller is responsible for downstream application.
"""
AiFallbackProposal.model_validate(proposal.model_dump())
payload = proposal.payload
frame_id = frame_contract.get("frame_id")
if "frame_id" in payload and payload["frame_id"] != frame_id:
raise AiFallbackValidationError(
f"frame-swap guard: payload.frame_id={payload['frame_id']!r} "
f"differs from contract frame_id={frame_id!r}; V4 rank-1 is locked."
)
if proposal.proposal_kind is ProposalKind.BUILDER_OPTIONS_PATCH:
declared = (frame_contract.get("payload") or {}).get("builder_options") or {}
unknown = set(payload.keys()) - set(declared.keys())
if unknown:
raise AiFallbackValidationError(
f"builder whitelist: keys {sorted(unknown)} not in "
f"frame_contract.payload.builder_options {sorted(declared)}."
)
if proposal.proposal_kind in _SLOT_KINDS:
declared_slot_ids = [z.get("id") for z in (frame_contract.get("sub_zones") or [])]
slots = payload.get("slots")
if not isinstance(slots, dict):
raise AiFallbackValidationError(
"dropped-slot guard: PARTIAL_OVERRIDES / SLOT_MAPPING_PROPOSAL "
"payload MUST include a 'slots' mapping."
)
missing = [sid for sid in declared_slot_ids if sid not in slots]
if missing:
raise AiFallbackValidationError(
f"dropped-slot guard: declared slots {missing} are absent "
"from payload.slots (text/table/image/details must remain populated)."
)
region_id = payload.get("region_id")
if region_id is not None and internal_region is not None:
declared_region_id = internal_region.get("id")
if region_id != declared_region_id:
raise AiFallbackValidationError(
f"Internal Region containment: payload.region_id={region_id!r} "
f"differs from internal_region.id={declared_region_id!r}."
)