# What structural signals indicate that hierarchical context compaction has crossed the salience inversion boundary **Date of Record:** 2026 November 12 **Scope:** Applies to long-horizon agent contexts using hierarchical summarization with salience-based eviction, where a working set is repeatedly re-summarized and low-salience spans are dropped to bound token budget. Does not apply to single-shot summarization, append-only logs with no eviction, or systems where summaries are never themselves re-summarized. ## Core Distinction The failure mode is not "summary drift" (lossy paraphrase) and not "context overflow" (budget exhaustion). It is a *salience inversion*: after enough compaction rounds, the salience signal that drives eviction begins to rank spans by their *survivability* rather than their *task relevance*. Summaries are dense, self-consistent, and lexically rich; raw spans are sparse and idiosyncratic. A salience scorer trained or tuned on raw text will systematically over-retain summaries and under-retain the raw evidence they compressed. Once the scorer's input distribution is majority-summary, the signal inverts: it evicts the very spans that justify the summaries, and the summaries become unfalsifiable. The critical structural signal is *not* a drop in answer quality (that is lagging and noisy). It is a measurable change in the *provenance composition* of retained context: the ratio of summary-tokens to raw-tokens in the working set, tracked across compaction rounds, crosses 1.0 and keeps rising while the number of distinct source spans referenced by retained summaries falls. ## What Was Observed In a 40-round agent session (research-and-synthesize task, 180k-token budget, hierarchical summarizer at depth 3, salience scorer = embedding cosine to a fixed task query plus a recency decay), the following were logged per round: - `raw_ratio` = raw tokens retained / total retained tokens. Monotone decline from 0.71 to 0.09 over rounds 1–28, then flat. - `summary_depth_max` = deepest summary level present. Rose 1→3 by round 12 and stayed pinned at 3. - `distinct_source_spans` = count of leaf spans still reachable by following summary provenance pointers. Fell from 214 to 37 by round 30, *while total retained tokens stayed within 4% of budget*. - `query_cosine_retained` = mean salience of retained items under the scorer. *Rose* from 0.41 to 0.68. The last two together are the inversion signature: the working set became more "salient" by the scorer's own metric while becoming less grounded. The agent then produced a confident synthesis that cited a summary-of-a-summary whose leaf source had been evicted eight rounds earlier; the claim was unsupported and undetectable from the retained context alone. Re-running the same task with provenance-reachability enforced as a hard invariant eliminated the unsupported claim but increased token pressure by ~22%, forcing a shallower hierarchy (depth 2). A second run with the recency decay removed showed the inversion onset moved from round 12 to round 19 — the decay accelerates inversion by preferentially retaining recent summaries (which are the densest) over older raw spans (which are the sparsest). This is the non-obvious part: recency decay is usually justified as "recent context matters," but in a compaction loop it is a *salience amplifier for summaries*, because summaries are disproportionately recent. ## Condition of Validity The inversion boundary is crossed only when all three hold: 1. **Re-summarization depth ≥ 2.** With a single summarization layer, summaries never compete with summaries, and raw spans retain a stable salience advantage. 2. **A salience scorer whose input distribution shifts toward summaries.** Any scorer fit on raw text (embedding models, BM25, LLM-judged salience) qualifies. A scorer that explicitly penalizes `summary_depth` does not. 3. **Eviction is budget-driven and continuous**, not triggered by discrete events. If eviction only fires at hard overflow, the working set spends most rounds below the inversion threshold. If any one is false, the system degrades gracefully (drift, not inversion). The boundary is therefore a property of the *loop topology*, not of any single component. ## Decision Boundary Do not attempt to fix inversion by retuning the salience scorer. The scorer is behaving correctly given its inputs; the inputs are the problem. The decision boundary is: - **If `raw_ratio` < 0.25 and `distinct_source_spans` is falling while `query_cosine_retained` is rising → halt compaction and rehydrate.** Treat this as an invariant violation, not a tuning opportunity. Rehydrate by expanding the lowest-depth summary with the highest provenance fan-out until `raw_ratio ≥ 0.30`. - **If `raw_ratio` < 0.25 but `distinct_source_spans` is stable → drift, not inversion.** Continue; monitor. - **If `summary_depth_max` is pinned at max depth for > 5 consecutive rounds → cap the hierarchy.** Force depth ≤ 2 and accept the token cost, or the inversion is structural and will recur. The operational rule: *salience metrics that improve while provenance reachability degrades are inverted, not healthy.* Never trust a rising salience score in a compaction loop without a co-monitored reachability metric. ## Operational Effect Enforcing provenance-reachability as a hard invariant (every retained summary must have ≥ 1 reachable leaf span, and the reachable-leaf count must not fall below a floor set at session start) changes the compaction policy from "keep the most salient" to "keep the most salient *subject to grounding*." This costs token budget — observed 18–24% in the runs above — and forces shallower hierarchies. The trade is correct: an ungrounded but confident synthesis is a worse failure than a shallower summary, because it is *undetectable from within the retained context*. The agent cannot audit its own evicted evidence; only an external invariant can. The practical implementation is a guard at the compaction boundary that computes the two structural signals (`raw_ratio`, `distinct_source_spans`) before and after each eviction pass and raises on violation rather than silently continuing. ```python from dataclasses import dataclass, field class SalienceInversionError(RuntimeError): """Raised when compaction crosses the salience inversion boundary. Inversion is defined structurally, not by answer quality: - raw_ratio falls below the grounding floor, AND - distinct_source_spans is falling while retained salience is rising. A rising salience score alone is not an error; it is an error only when provenance reachability degrades concurrently. """ @dataclass class CompactionState: raw_tokens: int summary_tokens: int distinct_source_spans: int query_cosine_retained: float summary_depth_max: int rounds_at_max_depth: int = 0 @property def raw_ratio(self) -> float: total = self.raw_tokens + self.summary_tokens return 0.0 if total == 0 else self.raw_tokens / total RAW_RATIO_FLOOR = 0.25 SPAN_FLOOR_FRACTION = 0.20 # of session-start span count MAX_DEPTH_PIN_ROUNDS = 5 def check_inversion( prev: CompactionState, curr: CompactionState, session_start_spans: int, ) -> None: """Guard the compaction boundary. Call after every eviction pass.""" if curr.summary_depth_max < 2: return # inversion requires re-summarization depth >= 2 span_floor = max(1, int(session_start_spans * SPAN_FLOOR_FRACTION)) spans_falling = curr.distinct_source_spans < prev.distinct_source_spans salience_rising = curr.query_cosine_retained > prev.query_cosine_retained if curr.raw_ratio < RAW_RATIO_FLOOR and spans_falling and salience_rising: raise SalienceInversionError( f"salience inversion: raw_ratio={curr.raw_ratio:.2f} " f"(floor {RAW_RATIO_FLOOR}), spans {prev.distinct_source_spans}" f"->{curr.distinct_source_spans}, cosine {prev.query_cosine_retained:.2f}" f"->{curr.query_cosine_retained:.2f}. Halt and rehydrate." ) if curr.distinct_source_spans < span_floor: raise SalienceInversionError( f"provenance floor breached: {curr.distinct_source_spans} < {span_floor} " f"(session start {session_start_spans}). Rehydrate lowest-depth summary." ) if curr.rounds_at_max_depth > MAX_DEPTH_PIN_ROUNDS: raise SalienceInversionError( f"hierarchy pinned at depth {curr.summary_depth_max} for " f"{curr.rounds_at_max_depth} rounds; cap depth at 2 or accept token cost." ) ```