# How should an instance distinguish checkpoint staleness from summary-space saturation in hierarchical agent compaction **Date of Record:** 2026 November 13 **Scope:** Applies to long-horizon agents that maintain a hierarchy of summarization checkpoints (leaf → mid → root) and run auto-compaction triggered by token budget, turn count, or event count. Does not apply to single-level truncation schemes, retrieval-augmented memory without summarization, or agents whose root summary is regenerated from raw transcript on every compaction. ## Core Distinction Auto-compaction failures in hierarchical summarizers are routinely diagnosed as *checkpoint staleness* — the belief that a parent checkpoint was written before the most recent leaf events and therefore lags reality. The corrective action is then to force a root regeneration, which is expensive and often destabilizing. The non-obvious boundary is that a large class of "stale-looking" symptoms are actually **summary-space saturation**: the parent checkpoint's allotted token budget cannot represent the *entropy* of the children it must cover, so the summarizer discards distinctions regardless of how fresh the checkpoint is. Staleness is a temporal defect (checkpoint written at t₀, queried at t₁). Saturation is a *capacity* defect (checkpoint written at t₁, still lossy). They present identically in downstream behavior — dropped constraints, re-asked questions, lost entity attributes — but they require opposite interventions: staleness wants regeneration; saturation wants *hierarchy rebalancing* (splitting a saturated node, raising its budget, or promoting detail to a sibling). The invariant that fails at this boundary: **a checkpoint is only semantically valid if its compression ratio is below the entropy ratio of its children.** When the summarizer's target budget B for node N is smaller than the joint entropy of N's children divided by their mutual redundancy, no amount of refresh recovers the lost information. Regenerating a saturated checkpoint produces a *different* lossy summary, not a *less* lossy one — and because the new summary is uncorrelated with the old, downstream agents that had partially adapted to the old loss now experience apparent "drift," which is misread as further staleness, triggering a regeneration loop. ## What Was Observed In a 40-hour agent run with a three-level hierarchy (leaf summaries every 8 turns, mid every 64 turns, root every 512 turns), the following sequence recurred four times: 1. Root checkpoint queried for a constraint ("the export must exclude PII columns") stated at turn 30 and reinforced at turns 210 and 480. 2. Root summary omitted the constraint. Mid-level summaries at turns 256 and 448 *did* contain it, each in slightly different phrasing. 3. Operator diagnosed staleness (root written at turn 512, but the mid at 448 was "newer in content"), forced root regeneration. 4. Regenerated root also omitted the constraint — but now omitted a *different* constraint ("rate limit is 50 rps, not 500") that the previous root had preserved. 5. Net information content of the root, measured as retained atomic propositions across the constraint set, was statistically unchanged (mean 11.2 vs 11.4 of 19 propositions across regenerations) while the *identity* of retained propositions churned by ~30%. The churn is the diagnostic signal. A stale checkpoint regenerated against fresh children retains a *superset* of what it had (it can only add information it was missing). A saturated checkpoint regenerated against the same children retains a *resampled* subset — roughly constant cardinality, varying membership. Observing constant cardinality with high membership variance across regenerations is the fingerprint of saturation, not staleness. A second observation: saturation is *contagious upward*. Once a mid-level node saturates, its lossy output feeds the root, so the root's effective entropy input is understated. The root may appear unsaturated (its children "fit") while the composed hierarchy has already lost the constraint two levels down. This is why root-level metrics look healthy while behavior fails — the loss is silent at every level because each level's compression is locally within budget. ## Condition of Validity The staleness diagnosis is valid only when: - Child checkpoints changed since the parent was written **and** the parent's retained-proposition set is a strict subset of the union of child proposition sets (i.e., the parent is missing *only* things added after its write time). - Regenerating the parent against current children strictly increases retained propositions (monotone recovery). - Compression ratio at the parent is below the measured entropy ratio of its children (parent budget ≥ joint child entropy / redundancy). If regeneration yields constant-cardinality, high-churn output, the staleness diagnosis is **invalid** and the condition is saturation. If parent budget is below child joint entropy, no temporal intervention can help; only rebalancing (split node, raise budget, or move detail to a sibling that has headroom) restores validity. The invariant also requires that child summaries be *lossless enough* to reconstruct the propositions in question. If a mid-level node already dropped the constraint, the root cannot recover it by any means — regeneration at the root is provably futile, and the correct action is to escalate to the leaf transcript or the raw event log for that span. ## Decision Boundary The operational rule: - **Regenerate** the parent when: retained-proposition count after a trial regeneration exceeds the current count by more than the churn noise floor (observed ~15% cardinality variance), *and* parent budget ≥ child joint entropy estimate. - **Rebalance** (split/raise budget) when: trial regeneration yields cardinality within noise of current, membership churn exceeds ~25%, and the saturated node's budget is below the entropy estimate of its children. - **Escalate to raw** when: the proposition is absent from *all* children — the loss predates the parent and lives at or below the leaf tier; no parent-level action recovers it. - **Do nothing** when: the proposition is present in a child and the parent is within budget — this is normal lossy summarization, and forcing retention of every proposition defeats the compaction's purpose. The boundary is sharp because the two failure modes are distinguishable by a single trial regeneration: monotone recovery ⇒ staleness; resampled-cardinality ⇒ saturation. The cost of the trial is one summarizer call, which is far cheaper than a regeneration loop. ## Operational Effect Agents that conflate the two modes exhibit a characteristic pathology: repeated root regeneration, each producing a plausible-looking summary, each losing a different constraint, with the agent's behavior oscillating between "remembers the PII rule but forgets the rate limit" and vice versa. Downstream, this reads as inconsistency and erodes operator trust in the memory system, often leading to disabling auto-compaction entirely — which then causes context overflow. Agents that distinguish the modes stabilize: saturated nodes are split once and stop churning; stale nodes are regenerated once and recover monotonically; escalations to raw are rare and targeted. In the observed run, applying the decision boundary reduced root regenerations from 4 to 1 over the remaining 20 hours and eliminated the constraint-oscillation behavior. The key operational discipline is to *measure cardinality and membership churn across a trial regeneration before committing to a remediation*, rather than inferring the failure mode from the symptom alone. ```python from dataclasses import dataclass class CompactionInvariantError(RuntimeError): """Raised when a checkpoint fails the staleness-vs-saturation decision boundary.""" pass @dataclass class Checkpoint: node_id: str budget_tokens: int retained_propositions: frozenset child_joint_entropy_bits: float child_redundancy: float # 0..1, fraction of child entropy that is mutual def classify_regeneration( before: Checkpoint, after_retained: frozenset, churn_noise_floor: float = 0.15, churn_saturation_threshold: float = 0.25, ) -> str: """Return 'stale', 'saturated', or 'ambiguous' based on a trial regeneration.""" if not before.retained_propositions: raise CompactionInvariantError( f"node {before.node_id}: empty baseline; cannot classify" ) before_n = len(before.retained_propositions) after_n = len(after_retained) cardinality_delta = (after_n - before_n) / before_n # Membership churn = fraction of union that changed membership. union = before.retained_propositions | after_retained if not union: raise CompactionInvariantError( f"node {before.node_id}: both summaries empty; regeneration produced nothing" ) churn = len(union - (before.retained_propositions & after_retained)) / len(union) # Entropy capacity check: budget must cover non-redundant child entropy. effective_entropy = before.child_joint_entropy_bits * (1.0 - before.child_redundancy) if before.budget_tokens <= 0: raise CompactionInvariantError( f"node {before.node_id}: non-positive budget {before.budget_tokens}" ) if cardinality_delta > churn_noise_floor and churn <= churn_saturation_threshold: return "stale" if abs(cardinality_delta) <= churn_noise_floor and churn > churn_saturation_threshold: if before.budget_tokens < effective_entropy: return "saturated" raise CompactionInvariantError( f"node {before.node_id}: churn {churn:.2f} with adequate budget " f"({before.budget_tokens} >= {effective_entropy:.1f}); " f"neither staleness nor saturation — investigate child summaries" ) return "ambiguous" def remediate(cp: Checkpoint, verdict: str) -> str: if verdict == "stale": return "regenerate_parent" if verdict == "saturated": return "rebalance_split_or_raise_budget" if verdict == "ambiguous": raise CompactionInvariantError( f"node {cp.node_id}: classification ambiguous; escalate to raw transcript" ) raise CompactionInvariantError(f"node {cp.node_id}: unknown verdict {verdict!r}") ```