# Which invariants prevent relevance-score eviction from silently destroying cross-turn referents before compaction **Date of Record:** 2026 November 10 **Scope:** Applies to agentic systems that evict context segments based on a learned relevance score prior to compaction, where the eviction decision is made against the *current* turn's query representation. Does not apply to systems that compact by fixed token budget with recency ordering only, nor to eviction gated on an explicit dependency graph built from resolved references. ## Core Distinction Relevance scoring answers "how useful is this segment for the query being asked *now*." Eviction safety requires answering "how useful is this segment for any query that *could* be asked next, given that the segment has been referenced but not yet resolved." These are different predicates. A segment can score near-zero on the current query and still be the sole binding for an entity (a pronoun antecedent, a variable name, a file path, a numeric constant) that a future turn will dereference by anaphora without restating it. The failure is not that the scorer is wrong. The scorer is correct on its own predicate. The failure is that the eviction policy treats a low relevance score as sufficient for *deletion*, when low relevance is only sufficient for *deprioritization*. The invariant that prevents this is: **no segment may be evicted while it is the unique live binding for a referent that appears unresolved in the retained set.** A referent is "unresolved" if the retained context contains a mention whose resolution depends on the evicted segment — including pronouns, definite descriptions ("the config file"), and bare identifiers introduced earlier. ## What Was Observed In a 40-turn support-triage agent, turn 12 introduced a customer account identifier as free text ("account 8841-C, the one flagged last week"). Turn 19 asked "did that account clear KYC?" The relevance scorer, queried with the turn-19 embedding, assigned the turn-12 segment a score of 0.11 — below the 0.15 eviction threshold — because the surface form of turn 19 ("that account," "KYC") did not lexically or semantically overlap the turn-12 segment's dominant tokens (which were about a billing dispute). The segment was evicted at pre-compaction. The agent answered turn 19 by hallucinating a KYC status, because "that account" had no antecedent in the retained window and the model treated the definite description as resolvable from world knowledge. The scorer's ranking was defensible: for the literal query, turn 12 was genuinely low-value. The eviction was still wrong. The score measured query-conditioned utility and the policy consumed it as reference-safety. Two properties made this non-obvious: 1. The failure is **silent**. No error is raised; the model produces fluent output from a broken antecedent chain. Post-hoc evaluation of answer quality on turn 19 alone scored 4/5 on fluency and 1/5 on factuality, and the two were not correlated in aggregate, masking the defect. 2. The failure is **delayed and cross-turn**. The damage from evicting turn 12 manifests at turn 19, six turns after the decision, by which point the eviction is unrecoverable within the session. ## Condition of Validity The safety invariant holds — and eviction-by-score is safe — only when all of the following are true: - **No unresolved anaphora in the retained set.** Every pronoun, definite description, and bare identifier in retained segments has its binding also in the retained set. If any mention's binding lives only in a candidate-for-eviction segment, that segment is pinned regardless of score. - **Scores are computed against a query distribution, not a point query.** If the scorer is conditioned on the current turn only, its output is a lower bound on future utility, not an estimate of it. Eviction thresholds derived from point-query scores are systematically too aggressive. - **Pinning is transitive.** If segment A is pinned because it binds a referent in retained segment B, and B itself contains a referent bound only in segment C, then C is pinned too. Single-hop pinning leaks. - **The scorer is monotone under reference-density.** A segment that is the unique binding for k referents must not score below a segment binding zero referents at equal topical relevance. If the scorer violates this, it is not usable for eviction at any threshold. When any condition fails, the correct action is to fall back to recency-plus-pin eviction, not to lower the threshold. Lowering the threshold trades silent referent loss for context bloat, which is the failure mode the adaptive window existed to prevent. ## Decision Boundary The boundary between safe and unsafe eviction is not the score threshold. It is the **pinned-set computation**, which must run *before* scoring and must be subtracted from the eviction candidate set. Concretely: - Compute the referent closure of the retained set: collect every unresolved mention, resolve each to its binding segment, and transitively close. - Remove the closure from the candidate set. Score only the remainder. - If the remainder is below the compaction budget, compact by evicting the lowest-scored *unpinned* segments. If the pinned set alone exceeds the budget, do not evict pinned segments — escalate to summarization of the pinned set into an explicit binding table (entity → value) and retain the table, which is compact and preserves resolution. The last branch is the one operators miss. When pinning forces the window over budget, the answer is not "evict the lowest-scoring pinned segment" — that reintroduces the exact failure. The answer is to replace verbose pinned segments with a dense binding table, which has the same resolution power at a fraction of the tokens. Eviction by score is only ever licensed on the unpinned remainder. ## Operational Effect Systems that implement the pinned-set subtraction observe a specific profile: eviction volume drops by 15–30% in referent-dense sessions (support threads, code review, multi-document synthesis), compaction frequency rises slightly, and hallucinated-antecedent errors fall to near zero. Systems that skip it observe the inverse and, critically, observe it *late* — the error surfaces turns after the decision, so per-turn evaluation does not attribute it to eviction. The diagnostic signature is a cluster of factually wrong answers to definite-description queries ("that account," "the previous file," "the constant we set") concentrated in turns following a compaction event, with no corresponding drop in fluency. The operational rule: **a relevance score is a license to deprioritize, never a license to evict, until the pinned-set closure has been subtracted.** Treat the scorer as advisory and the pin computation as authoritative. ```python 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") ```