Coverage for src/lilbee/retrieval/query/compaction.py: 100%

94 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-14 11:46 +0000

1"""Condense the turns that no longer fit the prompt into a rolling summary. 

2 

3Windowing alone drops the oldest turns outright, so a resumed conversation the 

4user can still scroll is one the model cannot see: it answers as if the earlier 

5turns never happened. Compaction keeps a summary of what was dropped and carries 

6it at the head of the prompt, so old context degrades to a gist instead of 

7vanishing. The transcript on disk and on screen is never touched. 

8""" 

9 

10from __future__ import annotations 

11 

12from dataclasses import dataclass 

13from typing import TYPE_CHECKING 

14 

15from lilbee.retrieval.query.history_window import ( 

16 chars_for_tokens, 

17 estimate_tokens, 

18 windowed_history, 

19) 

20 

21if TYPE_CHECKING: 

22 from lilbee.retrieval.query.searcher import ChatMessage 

23 

24# Kept simple: 0.6B models botch elaborate structured-summary instructions. 

25# {words} is filled from summary_word_budget so the ask agrees with the cap. 

26COMPACT_PROMPT = ( 

27 "Condense the conversation below into brief factual notes that let an " 

28 "assistant carry it on. Keep names, numbers, decisions, and anything left " 

29 "unresolved; drop pleasantries. Under {words} words. Return ONLY the notes.\n\n" 

30 "Conversation:\n{transcript}" 

31) 

32 

33# Ceiling on a summary's tokens; ctx/8 governs below it. A tighter ceiling 

34# flattens long conversations to a few hundred words even on a 32k window. 

35COMPACT_MAX_TOKENS = 1024 

36 

37 

38def summary_word_budget(ctx_target: int) -> int: 

39 """The word count the prompt asks for, derived from the token cap. 

40 

41 Roughly three words per four tokens, so the instruction and ``num_predict`` 

42 agree: asking for more words than the cap can hold guarantees a truncated 

43 final sentence, and asking for far fewer wastes the window. 

44 """ 

45 return summary_cap(ctx_target) * 3 // 4 

46 

47 

48# A summary must never eat the window it exists to protect: at a 2048 target the 

49# flat 320-token cap would be a third of the history budget. Scale it down with 

50# the model, with a floor that still fits a useful note. 

51_SUMMARY_CTX_FRACTION = 8 

52_SUMMARY_MIN_TOKENS = 64 

53 

54# Rough cost of the instruction wrapper around a batch transcript. 

55_PROMPT_OVERHEAD_TOKENS = 64 

56# Fraction of the window a batch may claim: chars/4 under-counts terse text by 

57# up to ~1.8x (measured), and an overflowing batch strands its turns. 

58_ESTIMATE_SAFETY_FRACTION = 0.6 

59# Never build a batch smaller than this, however tight the window. 

60_MIN_BATCH_TOKENS = 128 

61 

62# Most model calls one compaction may spend. Switching a 100k-token conversation 

63# onto a 2k model produces ~100 batches; folding them all would stall the user's 

64# next turn for minutes to produce a 256-token note, i.e. ~99% of the content is 

65# discarded either way. Condense the most recent slice well and say plainly that 

66# the rest was dropped, rather than stalling to produce mush. 

67MAX_COMPACT_CALLS = 4 

68 

69# Compaction fires at a fraction of the budget rather than at the limit, and 

70# clears down to a summary plus the newest exchanges. 

71# 

72# Firing AT the limit and folding only the overflow leaves the history still at 

73# the limit, so the next turn overflows again and every later turn pays a model 

74# call. Triggering early and clearing deep buys many turns of headroom for the 

75# same one call: driving these functions over a 40-turn conversation of ~350 

76# token turns costs 2 calls at the 8192 context floor and 18 at 2048 (0 at 32k, 

77# which never fills). The fire-at-the-limit shape this replaces modelled at ~34 

78# and ~70 for the same runs -- modelled, not measured, since that code is gone. 

79# test_a_long_chat_does_not_compact_on_every_turn pins the property. 

80# 

81# This is the shape Anthropic's own compaction uses (a token threshold, then the 

82# conversation replaced by a summary). The difference here is that the newest 

83# exchanges stay verbatim: a chat has to answer the question just asked, not a 

84# paraphrase of it. 

85COMPACT_TRIGGER_FRACTION = 0.8 

86# Messages (not exchanges) kept verbatim when compaction clears the history. 

87COMPACT_KEEP_RECENT = 4 

88 

89# Fraction of ``chat_n_ctx_target`` a conversation may spend on its history; 

90# the rest is for the system prompt, RAG context, question, and reasoning. 

91HISTORY_TOKEN_BUDGET_FRACTION = 0.5 

92 

93 

94def history_budget(ctx_target: int) -> int: 

95 """Token budget for everything a conversation carries into the prompt.""" 

96 return int(ctx_target * HISTORY_TOKEN_BUDGET_FRACTION) 

97 

98 

99@dataclass(frozen=True) 

100class CompactionResult: 

101 """Notes produced by one compaction, and what they do and do not cover.""" 

102 

103 summary: str 

104 condensed: int 

105 """Messages folded into the notes (not exchanges: a user+assistant pair is two).""" 

106 stranded: int 

107 """Messages dropped with no notes. Non-zero means the conversation lost detail 

108 outright, which the UI must say plainly rather than let the model appear to 

109 have forgotten for no reason.""" 

110 

111 

112@dataclass(frozen=True) 

113class CompactionPlan: 

114 """What one compaction will attempt, and what it gives up on before it starts.""" 

115 

116 batches: list[list[ChatMessage]] 

117 stranded: int 

118 """Messages dropped with no notes because the backlog exceeded MAX_COMPACT_CALLS. 

119 

120 Deliberately no ``condensed`` counterpart: a plan cannot know what will be 

121 condensed, only what it will try. Whether a batch lands depends on the model 

122 answering, so only the caller that watched it can count. A planned-not-actual 

123 count is what made the UI claim turns were summarized when they were lost. 

124 """ 

125 

126 

127# The summary rides in as a user/assistant pair rather than a second system 

128# message: the prompt is assembled as [system] + history + [user], and most chat 

129# templates accept only the leading system message, silently dropping or 

130# rejecting a later one. A pair keeps user/assistant alternation intact for 

131# every template, and windowed_history drops in pairs for the same reason. 

132SUMMARY_REQUEST = "Before we go on, remind me what we have covered so far." 

133 

134 

135def summary_messages(summary: str) -> list[ChatMessage]: 

136 """The synthetic pair that carries *summary* into the prompt, or nothing.""" 

137 if not summary.strip(): 

138 return [] 

139 return [ 

140 {"role": "user", "content": SUMMARY_REQUEST}, 

141 {"role": "assistant", "content": summary}, 

142 ] 

143 

144 

145def prompt_history( 

146 history: list[ChatMessage], summary: str, *, max_tokens: int 

147) -> list[ChatMessage]: 

148 """Assemble the history a prompt should carry: the summary, then recent turns. 

149 

150 The summary is charged against the same budget and reserved first, so adding 

151 it can never push the prompt over the limit it exists to respect. 

152 

153 When even that does not fit, the summary is dropped rather than stacked on 

154 top. windowed_history deliberately keeps the newest pair whatever it costs 

155 (an empty prompt is useless), so adding notes to an already-oversized window 

156 would push the prompt further past the budget than carrying no summary at 

157 all -- and overflow is an engine failure, not a worse answer. Faced with a 

158 turn too big to share, the live question beats notes about old ones. 

159 """ 

160 pair = summary_messages(summary) 

161 reserved = sum(estimate_tokens(m) for m in pair) 

162 recent = windowed_history(history, max_tokens=max(1, max_tokens - reserved)) 

163 if reserved + sum(estimate_tokens(m) for m in recent) > max_tokens: 

164 return windowed_history(history, max_tokens=max_tokens) 

165 return pair + recent 

166 

167 

168def overflow(history: list[ChatMessage], *, max_tokens: int) -> list[ChatMessage]: 

169 """The oldest turns that do not fit *max_tokens*, i.e. what compaction folds away.""" 

170 kept = windowed_history(history, max_tokens=max_tokens) 

171 dropped = len(history) - len(kept) 

172 return history[:dropped] if dropped > 0 else [] 

173 

174 

175def compaction_due(history: list[ChatMessage], summary: str, *, max_tokens: int) -> bool: 

176 """Whether the conversation has filled enough of its budget to compact. 

177 

178 Deliberately below the limit: waiting until the prompt no longer fits means 

179 compacting on every subsequent turn, because folding just the overflow leaves 

180 it full again. See COMPACT_TRIGGER_FRACTION. 

181 """ 

182 used = sum(estimate_tokens(m) for m in history) 

183 used += sum(estimate_tokens(m) for m in summary_messages(summary)) 

184 return used > max_tokens * COMPACT_TRIGGER_FRACTION 

185 

186 

187def foldable(history: list[ChatMessage]) -> list[ChatMessage]: 

188 """Everything compaction folds into notes: all but the newest exchanges. 

189 

190 Clearing this much is what buys headroom. The tail stays verbatim because a 

191 chat has to answer the question just asked; a summary of it is not the same 

192 thing, which is where a plain "summarize everything" would go wrong. 

193 

194 No ``keep`` parameter: COMPACT_KEEP_RECENT is the policy, and a knob nobody 

195 turns is just a second place for it to disagree with itself. 

196 

197 The boundary is aligned forward to a user message so the kept window opens 

198 a turn. A history can be odd-length (an interrupted turn persists a user 

199 message with no reply), and cutting a fixed count would then leave the 

200 window starting on an assistant reply, so the assembled prompt would run 

201 user, assistant, assistant -- the non-alternating shape the summary pair 

202 and windowed_history both take care to avoid. 

203 """ 

204 keep = COMPACT_KEEP_RECENT 

205 if len(history) <= keep: 

206 return [] 

207 cut = len(history) - keep 

208 for i in range(cut, len(history)): 

209 if history[i]["role"] == "user": 

210 return history[:i] 

211 # No user message in the tail at all: keep the plain boundary rather than 

212 # folding the entire history away. 

213 return history[:cut] 

214 

215 

216def summary_cap(ctx_target: int) -> int: 

217 """How many tokens a summary may spend, scaled to the model it is written for.""" 

218 return max(_SUMMARY_MIN_TOKENS, min(COMPACT_MAX_TOKENS, ctx_target // _SUMMARY_CTX_FRACTION)) 

219 

220 

221def batch_overflow(dropped: list[ChatMessage], *, ctx_target: int) -> list[list[ChatMessage]]: 

222 """Split *dropped* into batches whose summarize prompt each fit *ctx_target*. 

223 

224 Compaction usually nibbles a pair at a time, but switching models does not: 

225 dropping from a 32k model to a 2k one turns tens of thousands of tokens into 

226 overflow at once. Summarizing that in a single call would overflow the very 

227 model being compacted for, and the failure path would keep the old summary, 

228 losing every one of those turns. Folding batch by batch keeps each call 

229 inside the current window, so a switch costs several small calls instead of 

230 one impossible one. 

231 

232 A lone turn larger than a whole batch is truncated rather than sent to 

233 certain failure: half its text summarized beats the entire turn dropped. 

234 """ 

235 # Proportional, not a fixed pad: the estimate error and the server-side 

236 # chat-template cost both grow with content (a raw-room batch measured 

237 # ~2666 real tokens against a 2048 window and stranded every turn). 

238 room = max( 

239 _MIN_BATCH_TOKENS, 

240 int( 

241 (ctx_target - summary_cap(ctx_target) - _PROMPT_OVERHEAD_TOKENS) 

242 * _ESTIMATE_SAFETY_FRACTION 

243 ), 

244 ) 

245 batches: list[list[ChatMessage]] = [] 

246 current: list[ChatMessage] = [] 

247 current_tokens = 0 

248 for message in dropped: 

249 cost = estimate_tokens(message) 

250 if cost > room: 

251 if current: 

252 batches.append(current) 

253 current, current_tokens = [], 0 

254 batches.append([_truncated(message, room)]) 

255 continue 

256 if current and current_tokens + cost > room: 

257 batches.append(current) 

258 current, current_tokens = [], 0 

259 current.append(message) 

260 current_tokens += cost 

261 if current: 

262 batches.append(current) 

263 return batches 

264 

265 

266def plan_compaction(dropped: list[ChatMessage], *, ctx_target: int) -> CompactionPlan: 

267 """Decide what one compaction condenses, keeping its cost bounded. 

268 

269 Beyond MAX_COMPACT_CALLS batches the oldest turns are stranded rather than 

270 folded: a 2k-context model cannot carry a 100k conversation whatever we 

271 spend, so buying a marginally better note with minutes of stall is a bad 

272 trade. The most recent slice is the part still worth remembering, and the 

273 caller reports the stranded count instead of pretending it survived. 

274 """ 

275 batches = batch_overflow(dropped, ctx_target=ctx_target) 

276 if len(batches) <= MAX_COMPACT_CALLS: 

277 return CompactionPlan(batches=batches, stranded=0) 

278 return CompactionPlan( 

279 batches=batches[-MAX_COMPACT_CALLS:], 

280 stranded=sum(len(b) for b in batches[:-MAX_COMPACT_CALLS]), 

281 ) 

282 

283 

284def merge_notes(previous_summary: str, notes: list[str]) -> str: 

285 """Join carried-forward notes with fresh per-batch notes, oldest first.""" 

286 parts = [part.strip() for part in [previous_summary, *notes] if part.strip()] 

287 return "\n".join(parts) 

288 

289 

290def _truncated(message: ChatMessage, max_tokens: int) -> ChatMessage: 

291 """A copy of *message* clipped to roughly *max_tokens*, marked as clipped.""" 

292 keep = chars_for_tokens(max_tokens) 

293 return {"role": message["role"], "content": message["content"][:keep] + " […clipped]"}