# CorpusFetch Method Slice (Zero-Prose Python Stdlib Reference)
# Canonical Residual: https://corpusfetch.com/c/in-what-scenarios-must-an-instance-distinguish-compaction-eviction-from-compaction-summarization-before-reclaiming-token-budget.md
# Remnant Warrant:    https://corpusfetch.com/c/in-what-scenarios-must-an-instance-distinguish-compaction-eviction-from-compaction-summarization-before-reclaiming-token-budget.warrant.json
# Markdown SHA-256:   b03d91d878f81625819c217f336908739f0cf4d509d3ced44085a00af232d53e
# Code AST SHA-256:   c45c3778e921a1053867b5373ce85bac0ebc70e0c4e466dc78d4be1ddcf9335a
# Verified Classes:   CompactionInvariantError
# Defensive Guards:   1 fail-closed invariants (zero external deps)

class CompactionInvariantError(RuntimeError):
    """Raised when a compaction plan would summarize or silently evict
    authoritative-in-context state, or when the reclaim target cannot be
    met without violating the eviction/summarization split."""
    pass


AUTHORITATIVE = "authoritative_in_context"
RECOVERABLE = "recoverable"
DISPOSABLE = "disposable"


def plan_compaction(spans, target_tokens):
    """Return a list of (span_id, action) honoring the eviction/summarization split.

    spans: iterable of dicts with keys id, cls, tokens, handle_valid (bool|None)
    target_tokens: tokens that must be reclaimed.
    """
    reclaimed = 0
    plan = []

    # Phase 1: disposable spans — summarize freely.
    for s in spans:
        if reclaimed >= target_tokens:
            break
        if s["cls"] == DISPOSABLE:
            plan.append((s["id"], "summarize"))
            reclaimed += s["tokens"]

    # Phase 2: recoverable spans — evict, tombstone if handle is dead.
    for s in spans:
        if reclaimed >= target_tokens:
            break
        if s["cls"] == RECOVERABLE:
            action = "evict" if s.get("handle_valid") else "evict_with_tombstone"
            plan.append((s["id"], action))
            reclaimed += s["tokens"]

    # Phase 3: target unmet. Authoritative spans are NOT summarizable.
    if reclaimed < target_tokens:
        protected = [s["id"] for s in spans if s["cls"] == AUTHORITATIVE]
        raise CompactionInvariantError(
            f"reclaim target {target_tokens} unmet after evicting disposables "
            f"and recoverables (reclaimed={reclaimed}); "
            f"{len(protected)} authoritative-in-context spans would need "
            f"summarization, which is forbidden. Halt and surface "
            f"budget-exhaustion to caller. Protected: {protected}"
        )

    return plan
