"""IMP-56 (#90) u6 — structure_override resolver (validator + apply). Step-22 user structure-edit persist axis. Consumed by Step 12 (u7 wiring) so a prior render's reorder / hide choices re-apply to the next render without re-clicking. Schema (defined verbatim in ``src/user_overrides_io.py:30`` u2) :: structure_overrides = { : { "slot_order": [, ...], # optional, partial reorder "hidden_slots": [, ...], # optional, hide these slot_keys }, ... } SCOPE LOCK (Stage 2 u6 contract, IMP-56 #90 u2 docstring) : The only allowed inner keys are ``slot_order`` and ``hidden_slots``. Any other key (e.g., ``frame_id``, ``template_id``, ``unit_id``, ``slot_payload``) is treated as a frame-swap / DOM-rebuild attempt and is DROPPED at validate time. Frame swap stays on the existing ``frames`` axis so the Phase Z no-AI-HTML-structure invariant remains intact. There is intentionally NO escape hatch through this axis. API (deterministic, no AI) : - ``validate_structure_overrides(overrides)`` → sanitized copy. Per-entry tolerant (drops malformed rows; never rejects the whole batch — mirrors ``src.text_override_resolver.validate_text_overrides`` u4 contract). - ``apply_structure_override(zone, override)`` → ``True`` if the slot-payload mapping was mutated (any hide or any reorder), ``False`` otherwise. The ``zone`` argument is the slot-payload mapping at Step 12 (a mutable mapping whose keys are slot_keys and whose values are typically ``list[str]`` of lines). Identity-preserving: mutates in-place via ``clear`` + ``update`` so caller references remain valid. Guardrails (refs : Stage 1 binding contract, Stage 2 unit u6) : - raw_content preservation is a wiring-layer (u7) responsibility — the resolver only ever reorders / removes top-level slot_payload entries. Per-slot ``list[str]`` line content is never inspected or mutated here. - AI-isolation : pure deterministic Python; no LLM calls. - Carve-out (IMP-46 #62) : brand-new module, does not touch the #76 commit ``1186ad8`` cache region. """ from __future__ import annotations from typing import Any, Mapping, MutableMapping class InvalidStructureOverride(ValueError): """Reserved for future strict-mode parse errors. Currently unused — the resolver follows the u4 per-entry-tolerant contract and silently drops malformed rows at validate time rather than raising. Kept as a public surface so u7 wiring (and future strict-mode callers) can distinguish source-malformation from stale-DOM misses without an API rev. """ _ALLOWED_INNER_KEYS: frozenset[str] = frozenset({"slot_order", "hidden_slots"}) def _sanitize_slot_list(raw: Any) -> list[str]: """Return a fresh list of non-empty string slot_keys (drop the rest).""" if not isinstance(raw, list): return [] out: list[str] = [] seen: set[str] = set() for slot in raw: if not isinstance(slot, str) or not slot: continue if slot in seen: # De-dup defensively — a duplicate slot_key in slot_order would # be meaningless (dicts can hold each key once); duplicate in # hidden_slots is redundant. Drop subsequent occurrences. continue seen.add(slot) out.append(slot) return out def validate_structure_overrides( overrides: Any, ) -> dict[str, dict[str, list[str]]]: """Return a sanitized copy of ``overrides`` (per-entry tolerant). Drops: - non-string or empty zone_ids, - non-mapping per-zone payloads, - per-zone inner keys other than ``slot_order`` / ``hidden_slots`` (frame-swap attempts are dropped at this gate — see SCOPE LOCK), - non-list ``slot_order`` / ``hidden_slots`` values, - non-string or empty slot_key entries within those lists, - per-zone payloads that contain neither a non-empty ``slot_order`` nor a non-empty ``hidden_slots`` after sanitization (empty intent carries no signal). Returns a fresh ``dict`` AND fresh nested dicts / lists so callers can use the result as a working buffer without aliasing the persisted payload from ``user_overrides_io.load``. """ if not isinstance(overrides, Mapping): return {} out: dict[str, dict[str, list[str]]] = {} for zone_id, mapping in overrides.items(): if not isinstance(zone_id, str) or not zone_id: continue if not isinstance(mapping, Mapping): continue zone_out: dict[str, list[str]] = {} for inner_key, inner_value in mapping.items(): if inner_key not in _ALLOWED_INNER_KEYS: # Frame-swap attempt or unknown key — drop silently per # SCOPE LOCK. No mechanism through this axis. continue sanitized = _sanitize_slot_list(inner_value) if sanitized: zone_out[inner_key] = sanitized if zone_out: out[zone_id] = zone_out return out def apply_structure_override( zone: MutableMapping[str, Any], override: Mapping[str, Any], ) -> bool: """Apply ONE structure override to ``zone`` in-place. ``zone`` is the slot-payload mapping at Step 12 — i.e. a mutable mapping whose keys are slot_keys and whose values are the per-slot line lists (or other content payload). Mutation is restricted to top-level key membership + ordering; per-slot values are NEVER inspected or modified here. ``override`` is the per-zone payload after :func:`validate_structure_overrides` sanitization — i.e. a mapping with only ``slot_order`` and / or ``hidden_slots`` keys, each holding a list of non-empty str slot_keys. This function is also defensive: if non-list values leak through, they are treated as empty (no raise). Semantics : 1. ``hidden_slots`` are popped first. Entries absent from ``zone`` are silently skipped (stale slot_keys from a prior frame). 2. ``slot_order`` partially reorders the surviving slot_keys: listed keys (that are present in ``zone``) move to the front in the given order; remaining keys keep their original relative order at the tail. Unknown slot_keys are silently skipped. Returns ``True`` if the zone's slot-payload mapping was mutated (any hide that removed a key OR any reorder that changed key order), ``False`` otherwise. Identity-preserving: rebuilds via ``clear`` + ``update`` so the caller's reference to ``zone`` remains valid. """ mutated = False raw_hidden = override.get("hidden_slots") if isinstance(override, Mapping) else None hidden = _sanitize_slot_list(raw_hidden) for slot in hidden: if slot in zone: del zone[slot] mutated = True raw_order = override.get("slot_order") if isinstance(override, Mapping) else None desired_order_seed = _sanitize_slot_list(raw_order) current_order = list(zone.keys()) desired_order: list[str] = [] seen: set[str] = set() for slot in desired_order_seed: if slot in zone and slot not in seen: desired_order.append(slot) seen.add(slot) for slot in current_order: if slot not in seen: desired_order.append(slot) seen.add(slot) if desired_order != current_order: snapshot = {k: zone[k] for k in desired_order} zone.clear() zone.update(snapshot) mutated = True return mutated