# CorpusFetch Method Slice (Zero-Prose Python Stdlib Reference)
# Canonical Residual: https://corpusfetch.com/c/which-invariants-prevent-relevance-score-eviction-from-silently-destroying-cross-turn-referents-before-compaction.md
# Remnant Warrant:    https://corpusfetch.com/c/which-invariants-prevent-relevance-score-eviction-from-silently-destroying-cross-turn-referents-before-compaction.warrant.json
# Markdown SHA-256:   557784e05ec52271a44bd136843788a21e7f6ff8aa177b2eff6fe7b76b6def90
# Code AST SHA-256:   cf22ff859229d634d30ddede9152d95a2fffd355b58eed1f4ffd2135ad44367f
# Verified Classes:   ReferentEvictionError, Segment
# Defensive Guards:   2 fail-closed invariants (zero external deps)

from dataclasses import dataclass, field
from typing import Iterable


class ReferentEvictionError(RuntimeError):
    """Raised when eviction would orphan an unresolved referent."""


@dataclass
class Segment:
    seg_id: str
    text: str
    referents_bound: set[str] = field(default_factory=set)   # ids this segment is the unique binding for
    referents_used: set[str] = field(default_factory=set)    # ids this segment mentions but does not bind


def compute_pinned_set(retained: Iterable[Segment], candidates: Iterable[Segment]) -> set[str]:
    """Transitive closure of segments that bind referents used anywhere in the
    retained set or in already-pinned segments. Pinned segments may not be evicted."""
    retained = list(retained)
    by_id = {s.seg_id: s for s in list(retained) + list(candidates)}
    pinned: set[str] = set()
    frontier = [s for s in retained]
    while frontier:
        seg = frontier.pop()
        for ref in seg.referents_used:
            binder = next((c for c in candidates if ref in c.referents_bound), None)
            if binder is None:
                continue
            if binder.seg_id not in pinned:
                pinned.add(binder.seg_id)
                frontier.append(binder)  # transitive: binder's own uses may pin more
    return pinned


def evict_by_score(
    retained: list[Segment],
    candidates: list[Segment],
    scores: dict[str, float],
    threshold: float,
    budget_tokens: int,
    token_cost,
) -> list[str]:
    pinned = compute_pinned_set(retained, candidates)
    evictable = [c for c in candidates if c.seg_id not in pinned]
    to_evict = [c.seg_id for c in evictable if scores.get(c.seg_id, 0.0) < threshold]

    freed = sum(token_cost(by_id_lookup(candidates, sid)) for sid in to_evict)
    if freed < budget_tokens:
        # Pinned set is the binding constraint. Do NOT evict pinned segments;
        # escalate to binding-table summarization instead.
        pinned_cost = sum(token_cost(by_id_lookup(candidates, sid)) for sid in pinned)
        if pinned_cost > budget_tokens:
            raise ReferentEvictionError(
                f"pinned set ({len(pinned)} segments, {pinned_cost} tokens) exceeds "
                f"budget {budget_tokens}; must summarize to binding table, not evict. "
                f"Pinned: {sorted(pinned)}"
            )
    return to_evict


def by_id_lookup(segs: list[Segment], sid: str) -> Segment:
    for s in segs:
        if s.seg_id == sid:
            return s
    raise ReferentEvictionError(f"segment {sid} not found in candidate set")
