# When must an instance refuse to compact: the summarization floor invariant in long-horizon agent transcripts **Date of Record:** 2026 November 11 **Scope:** Applies to any agent runtime that performs lossy auto-compaction of its own transcript under a token budget, where compaction is triggered by a high-water mark and executed by a summarizer (same model or another). Does not apply to lossless truncation-with-external-store designs, nor to compaction that preserves a retrievable raw transcript, since both have an escape hatch this record assumes absent. ## Core Distinction There are two ways an auto-compaction pass can be *wrong*. The first is **over-compression**: the summary is shorter than necessary and drops information that later turns need. This is the failure mode everyone instruments for, because it manifests as a confused agent and is fixable by tuning the ratio. The second, and the one this record is about, is **compaction below the reconstruction floor**: the summarizer is asked to compress a segment into a budget that is smaller than the number of *irreducible tokens* the segment contains — identifiers, exact error strings, numeric thresholds, tool-call arguments, and the literal text of user constraints. Below that floor, no summarizer can preserve the invariants the segment carries, and the resulting summary is not "lossy" but *counterfactual*: it is a coherent, fluent, and wrong account of what happened. The distinction matters because the two failures have opposite remedies. Over-compression is cured by raising the budget. Floor violation is cured only by *refusing to compact* — by escalating, splitting the segment, or admitting the budget is infeasible. An auto-compactor that treats a floor violation as ordinary over-compression will raise its ratio, produce an even shorter summary, and make the counterfactual worse. ## What Was Observed A long-horizon coding agent (horizon ~600 turns, budget 128k tokens, high-water mark at 85%) was compacting its oldest 40% of turns into a fixed 2,000-token summary slot. The slot size was chosen to keep the running transcript under the high-water mark across a full horizon; it was derived from arithmetic on turn counts, not on content. At turn 412 the agent had, in the compacted region, (a) a user constraint stated as an exact regex, (b) a tool error whose message contained a specific errno and a file path, and (c) a decision to pin a dependency at a version that was later reverted. The 2,000-token slot had room for a paraphrase of (a) ("the user wants paths validated"), a paraphrase of (b) ("a filesystem error occurred"), and *nothing* of (c), which was silently dropped because the summarizer's own length pressure favored narrative over state. At turn 430 the agent re-derived the regex from the paraphrase, got it subtly wrong (it validated the wrong path component), re-ran the failing tool, and — because the errno was gone — misattributed the error to a permissions problem and spent 60 turns on a permissions fix. The transcript at all times looked healthy: no truncation markers, no summarizer errors, token counts comfortably under budget. The failure was invisible to every metric except task outcome. The invariant that was violated: **a summary slot must be at least as large as the sum of the irreducible tokens in the segment it replaces.** The 2,000-token slot was below that floor for a segment containing a regex, an errno, a path, and a version pin. The compactor had no representation of "irreducible tokens" and therefore could not detect the violation. ## Condition of Validity The floor invariant is valid — and the refusal rule is required — precisely when all of the following hold: 1. **No retrievable raw transcript.** If the pre-compaction segment can be re-fetched by reference (a store, a checkpoint, a log the agent can query), the floor is irrelevant: the summary only needs to be a *pointer plus gist*, and the agent can recover exact tokens on demand. The invariant bites only in designs where compaction is the sole surviving copy. 2. **The segment carries state, not just narrative.** A segment that is purely conversational (reasoning, exploration, chit-chat) has a low floor — a gist suffices. A segment that carries *constraints, identifiers, error literals, numeric thresholds, or commitments* has a high floor because those tokens are not compressible without changing meaning. 3. **Downstream turns will *act* on the segment.** If the compacted region is never re-read (e.g., it is beyond any plausible working set), floor violation is harmless. The invariant is about segments that remain in the agent's causal future. 4. **The summarizer is length-constrained.** A summarizer that is free to emit as many tokens as it needs will not violate the floor; it will simply produce a longer summary. The failure requires a *fixed slot* or a *hard cap* — which is exactly what budget-driven auto-compaction imposes. Outside these conditions — retrievable transcripts, purely narrative segments, dead regions, unconstrained summarizers — the refusal rule is unnecessary and will cause spurious escalations. ## Decision Boundary The operational rule is a three-way decision at compaction time, not a two-way one: - **Compact** when `slot_size >= floor(segment) + margin`. The margin covers the summarizer's own connective overhead (the "and then", "because", "the user said" tokens) which are not irreducible but are required for the summary to be coherent. Empirically the margin is 15–25% of the floor; below that the summary reads as a bullet list of fragments and the agent mis-infers causality. - **Split** when `slot_size < floor(segment)` but `slot_size >= floor(subsegment)` for some partition of the segment. The correct response to an infeasible slot is to compact *less* per pass — partition the segment and give each partition its own slot — not to compress harder. Splitting preserves the floor per unit and is almost always available because floors are additive across partitions while narrative overhead is not. - **Refuse** (escalate) when even the smallest partition containing a single irreducible item exceeds the slot, or when splitting would fragment a causal chain (e.g., an error and the fix that followed it must stay in one summary). Refusal means: keep the segment uncompacted, surface a budget-infeasibility signal to the orchestrator, and let the caller decide (raise budget, drop a different segment, or accept the risk explicitly). The boundary is **not** "summary is too short." A short summary of a low-floor segment is correct. The boundary is "summary slot < floor of the segment it is asked to replace," which is a property of the *segment and slot*, computable *before* summarization, and therefore enforceable as a precondition rather than discovered as a postcondition. ## Operational Effect Enforcing the floor as a precondition changes the compactor from a reactive length-trimmer into a feasibility checker, and it moves the failure from silent-and-late to loud-and-early. In the observed system, adding a floor estimator (a cheap pass that extracts identifiers, quoted strings, numeric literals, and constraint sentences from the segment and sums their token counts) converted the turn-412 failure into a turn-412 *refusal*: the compactor reported that the 2,000-token slot was infeasible for the segment (floor ≈ 2,340 tokens), the orchestrator split the segment into two 1,400-token slots, and the regex, errno, and version pin survived verbatim in the two summaries. Task completion recovered; the 60-turn permissions detour did not occur. The cost is real: floor estimation is an extra pass, splitting increases summary count and therefore total summary tokens, and refusals require an orchestrator that can act on them. Systems that cannot afford the estimator should at minimum refuse to compact any segment containing a quoted string, a numeric literal, or a constraint keyword into a slot below a conservative fixed floor (e.g., 1,200 tokens), accepting spurious refusals as the price of never emitting a counterfactual summary. The invariant to hold is simple and non-negotiable: **a lossy summary may lose narrative, but it may not lose state; when the slot cannot hold the state, the correct output of the compactor is a refusal, not a shorter summary.** ```python """Compaction floor guard: refuse to compact a segment into a slot that cannot hold its irreducible tokens. Enforces the invariant from the record.""" import re from dataclasses import dataclass class CompactionFloorError(RuntimeError): """Raised when a summary slot is smaller than the segment's irreducible floor.""" # Tokens that cannot be paraphrased without changing meaning: quoted strings, # numeric literals, identifiers with dots/slashes/colons, and constraint verbs. _IRREDUCIBLE_PATTERNS = [ re.compile(r'"[^"]*"|\'[^\']*\''), # quoted strings / regexes re.compile(r'\b\d+(?:\.\d+)?(?:e[+-]?\d+)?\b'), # numbers, versions, errnos re.compile(r'\b[\w.-]+/[\w./-]+\b'), # paths re.compile(r'\b[A-Z_]{2,}\b'), # constants / env vars ] _CONSTRAINT_CUES = ("must", "never", "always", "exactly", "only", "do not") @dataclass class FloorEstimate: irreducible_tokens: int margin_tokens: int @property def floor(self) -> int: return self.irreducible_tokens + self.margin_tokens def estimate_floor(segment: str, margin_ratio: float = 0.20) -> FloorEstimate: """Cheap pre-pass: sum tokens of irreducible spans plus a coherence margin.""" seen = set() irreducible = 0 for pat in _IRREDUCIBLE_PATTERNS: for m in pat.finditer(segment): span = m.group(0) if span not in seen: seen.add(span) irreducible += max(1, len(span) // 4) # ~4 chars/token lowered = segment.lower() for cue in _CONSTRAINT_CUES: if cue in lowered: irreducible += 8 # cost of preserving the constraint sentence margin = int(irreducible * margin_ratio) return FloorEstimate(irreducible_tokens=irreducible, margin_tokens=margin) def compact(segment: str, slot_size: int, summarize) -> str: """Compact only if the slot clears the floor; otherwise refuse loudly.""" est = estimate_floor(segment) if slot_size < est.floor: raise CompactionFloorError( f"slot_size={slot_size} < floor={est.floor} " f"(irreducible={est.irreducible_tokens}, margin={est.margin_tokens}); " f"refuse, split, or raise budget — do not shorten the summary" ) summary = summarize(segment, max_tokens=slot_size) if not summary or len(summary) // 4 > slot_size: raise CompactionFloorError( f"summarizer returned {len(summary)//4} tokens for slot {slot_size}" ) return summary def compact_or_split(segment: str, slot_size: int, summarize, split_at: int): """Split when the whole segment is infeasible but halves are not.""" est = estimate_floor(segment) if slot_size >= est.floor: return [compact(segment, slot_size, summarize)] left, right = segment[:split_at], segment[split_at:] half_slot = slot_size // 2 if estimate_floor(left).floor <= half_slot and estimate_floor(right).floor <= half_slot: return [compact(left, half_slot, summarize), compact(right, half_slot, summarize)] raise CompactionFloorError( f"segment infeasible at slot {slot_size} and unsplittable at {split_at}; escalate" ) ```