# CorpusFetch Method Slice (Zero-Prose Python Stdlib Reference)
# Canonical Residual: https://corpusfetch.com/c/under-what-conditions-must-an-instance-treat-compaction-as-an-irreversible-projection-rather-than-a-reversible-cache-eviction.md
# Remnant Warrant:    https://corpusfetch.com/c/under-what-conditions-must-an-instance-treat-compaction-as-an-irreversible-projection-rather-than-a-reversible-cache-eviction.warrant.json
# Markdown SHA-256:   ea588d8db36d08c0b600f6e792328c2a5be37e49664840105b735d0f720b5eff
# Code AST SHA-256:   46021d72ee5afeb3532c50d56b0af577f1eaafa87c31b6c17f70c987172f3e56
# Verified Classes:   IrreversibleCompactionError, CompactionRecord
# Defensive Guards:   3 fail-closed invariants (zero external deps)

from dataclasses import dataclass, field
from typing import Iterable


class IrreversibleCompactionError(RuntimeError):
    """Raised when a compaction step would destroy a retrieval handle.

    A handle is any token (identifier, path, version, endpoint, or explicit
    negative constraint) that is the only key able to retrieve its own source
    span. Summarizing a handle makes the projection irreversible: no future
    query synthesized from the summary can reconstruct the key that was erased.
    """


@dataclass
class CompactionRecord:
    summary: str
    handles: set[str] = field(default_factory=set)
    reversible: bool = True
    depth: int = 0
    dropped_range: tuple[int, int] | None = None


HANDLE_MARKERS = ("=", "/", ":", "do not", "must not", "never", "read-only")


def classify(token: str) -> str:
    low = token.lower()
    if any(m in low for m in HANDLE_MARKERS):
        return "handle"
    if any(ch.isdigit() for ch in token) and len(token) <= 64:
        return "handle"
    return "narrative"


def compact(tokens: Iterable[str], index_budget: int,
            ancestor: CompactionRecord | None = None) -> CompactionRecord:
    handles: set[str] = set(ancestor.handles) if ancestor else set()
    narrative: list[str] = []
    for tok in tokens:
        if classify(tok) == "handle":
            handles.add(tok)
        else:
            narrative.append(tok)

    # Cascade guard: a lossy ancestor must propagate its handle set intact.
    if ancestor is not None and not ancestor.reversible:
        missing = ancestor.handles - handles
        if missing:
            raise IrreversibleCompactionError(
                f"re-compaction of lossy ancestor dropped handles {missing!r}; "
                "depth-2 lossy compaction without handle propagation is prohibited"
            )

    if len(handles) > index_budget:
        raise IrreversibleCompactionError(
            f"{len(handles)} handles exceed verbatim index budget {index_budget}; "
            "escalate (checkpoint/spill) instead of dropping handles"
        )

    return CompactionRecord(
        summary=" ".join(narrative),
        handles=handles,
        reversible=ancestor is None or ancestor.reversible,
        depth=(ancestor.depth + 1) if ancestor else 1,
    )


def rehydrate(record: CompactionRecord, query: str, store: dict[str, str]) -> list[str]:
    if not record.reversible:
        # Query synthesized from a lossy summary: results are unverified context.
        hits = [store[h] for h in record.handles if h in store]
        if not hits:
            raise IrreversibleCompactionError(
                "rehydration from lossy projection found no handle-resolvable span; "
                "report the gap rather than reconstructing from priors"
            )
        return hits
    return [store[h] for h in record.handles if h in store]
