# CorpusFetch Method Slice (Zero-Prose Python Stdlib Reference)
# Canonical Residual: https://corpusfetch.com/c/when-must-an-instance-refuse-to-prune-its-own-context-window-the-eviction-persistence-invariant-under-relevance-score-collapse.md
# Remnant Warrant:    https://corpusfetch.com/c/when-must-an-instance-refuse-to-prune-its-own-context-window-the-eviction-persistence-invariant-under-relevance-score-collapse.warrant.json
# Markdown SHA-256:   7f750f3892015dd485058c468d95f596159add51c9241f7ab663df110f59271d
# Code AST SHA-256:   869ae05758c17c9d3c805e74aa2d9a0e8a6c0c64532f3ba77a6eaa3add2d1d99
# Verified Classes:   UnsafeEvictionError, ContextItem
# Defensive Guards:   2 fail-closed invariants (zero external deps)

class UnsafeEvictionError(RuntimeError):
    """Raised when a pruning pass attempts to evict an item whose
    relevance score is inadmissible under the eviction-persistence invariant."""
    pass


# Reference-horizon states. Only CLOSED permits score-based eviction.
HORIZON_CLOSED = "closed"        # provably past its reference horizon
HORIZON_OPEN = "open"            # will be referenced again (obligation pending)
HORIZON_UNBOUNDED = "unbounded"  # standing constraint / safety rule / identity


class ContextItem:
    def __init__(self, item_id, text, horizon, relevance_score, window_biased):
        self.item_id = item_id
        self.text = text
        self.horizon = horizon
        self.relevance_score = relevance_score
        self.window_biased = window_biased  # score computed under recency-biased window


def prune(items, threshold, compact_fn, pin_fn):
    """Apply the eviction-persistence invariant. Returns (kept, evicted, compacted, pinned).

    Fails closed: any candidate whose horizon is not CLOSED aborts the pass
    before a single item is removed, so no partial eviction can occur.
    """
    candidates = [it for it in items if it.relevance_score < threshold]

    # Pre-flight: validate the entire eviction set before mutating anything.
    for it in candidates:
        if it.horizon != HORIZON_CLOSED:
            raise UnsafeEvictionError(
                f"refusing to evict item {it.item_id!r}: horizon={it.horizon!r}, "
                f"score={it.relevance_score:.2f} < tau={threshold}. "
                f"Silence is not evidence of irrelevance; compact or pin instead."
            )
        if it.window_biased:
            raise UnsafeEvictionError(
                f"refusing to evict item {it.item_id!r}: relevance score was "
                f"computed under a recency-biased window; inadmissible for "
                f"eviction of a silent item."
            )

    evicted, kept, compacted, pinned = [], [], [], []
    for it in items:
        if it in candidates:
            evicted.append(it)
        elif it.horizon == HORIZON_OPEN:
            compacted.append(compact_fn(it))   # recoverable, not lost
            kept.append(it)
        elif it.horizon == HORIZON_UNBOUNDED:
            pinned.append(pin_fn(it))          # never evictable by score
            kept.append(it)
        else:
            kept.append(it)

    return kept, evicted, compacted, pinned


def prune_with_fallback(items, threshold, compact_fn, pin_fn):
    """Wrapper that degrades to compaction-only when the invariant is violated."""
    try:
        return prune(items, threshold, compact_fn, pin_fn)
    except UnsafeEvictionError as exc:
        # Do not lower the threshold. Do not re-ask the same scorer.
        # Compact everything that was a candidate and record the refusal.
        refused = [it for it in items if it.relevance_score < threshold]
        compacted = [compact_fn(it) for it in refused]
        kept = [it for it in items if it not in refused]
        return kept, [], compacted, [], {"refused": str(exc), "count": len(refused)}
