1use std::collections::BTreeSet;
9
10use serde::{Deserialize, Serialize};
11
12use crate::compress::{estimate_tokens, truncate_to_tokens};
13
14pub trait Referable {
16 fn index(&self) -> usize;
17 fn url(&self) -> &str;
18}
19
20impl<T: Referable> Referable for &T {
21 fn index(&self) -> usize {
22 (*self).index()
23 }
24 fn url(&self) -> &str {
25 (*self).url()
26 }
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
31pub struct Reference {
32 pub index: usize,
33 pub url: String,
34}
35
36impl Referable for Reference {
37 fn index(&self) -> usize {
38 self.index
39 }
40 fn url(&self) -> &str {
41 &self.url
42 }
43}
44
45pub fn render_block<T: Referable>(references: &[T]) -> String {
55 if references.is_empty() {
56 return String::new();
57 }
58 let mut s = String::from("References:\n");
59 for r in references {
60 s.push_str(&format!("[{}] {}\n", r.index(), r.url()));
61 }
62 s.truncate(s.trim_end().len());
63 s
64}
65
66const MIN_BODY_TOKENS: usize = 64;
69
70const FIT_PASSES: usize = 6;
74
75pub fn cited_indices(text: &str) -> BTreeSet<usize> {
77 let mut out = BTreeSet::new();
78 let bytes = text.as_bytes();
79 let mut i = 0;
80 while i < bytes.len() {
81 if bytes[i] != b'[' {
82 i += 1;
83 continue;
84 }
85 let mut j = i + 1;
86 while j < bytes.len() && bytes[j].is_ascii_digit() {
87 j += 1;
88 }
89 if j > i + 1 && j < bytes.len() && bytes[j] == b']' {
90 if let Ok(n) = text[i + 1..j].parse::<usize>() {
91 out.insert(n);
92 }
93 }
94 i = j.max(i + 1);
95 }
96 out
97}
98
99fn assemble(body: &str, block: &str) -> String {
101 if block.is_empty() {
102 body.to_string()
103 } else {
104 format!("{body}\n\n{block}")
105 }
106}
107
108pub fn fit_to_budget<T: Referable>(
122 body: &str,
123 references: &[T],
124 max_tokens: Option<usize>,
125) -> (String, Vec<usize>) {
126 let all = || references.iter().map(Referable::index).collect::<Vec<_>>();
127
128 let Some(max_tokens) = max_tokens else {
129 return (assemble(body, &render_block(references)), all());
130 };
131
132 let mut budget = max_tokens;
133 for pass in 0..FIT_PASSES {
134 let body = truncate_to_tokens(body, budget);
135 let cited = cited_indices(&body);
136 let kept: Vec<&T> = references
137 .iter()
138 .filter(|r| cited.contains(&r.index()))
139 .collect();
140 let block = render_block(&kept);
141 let content = assemble(&body, &block);
142 let total = estimate_tokens(&content);
143
144 if total <= max_tokens {
145 return (content, kept.iter().map(|r| r.index()).collect());
146 }
147 if pass + 1 == FIT_PASSES || budget <= MIN_BODY_TOKENS {
148 return drop_until_fits(&body, &kept, max_tokens);
151 }
152 let scaled = (budget as u128 * max_tokens as u128 / total as u128) as usize;
159 let next = scaled.max(MIN_BODY_TOKENS);
160 if next >= budget {
161 return drop_until_fits(&body, &kept, max_tokens);
164 }
165 budget = next;
166 }
167 unreachable!("the loop returns on its final pass")
168}
169
170fn drop_until_fits<T: Referable>(
174 body: &str,
175 kept: &[&T],
176 max_tokens: usize,
177) -> (String, Vec<usize>) {
178 let mut kept = kept.to_vec();
179 while !kept.is_empty() {
180 let content = assemble(body, &render_block(&kept));
181 if estimate_tokens(&content) <= max_tokens {
182 return (content, kept.iter().map(|r| r.index()).collect());
183 }
184 kept.pop();
185 }
186 (body.to_string(), Vec::new())
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192
193 fn refs(n: usize) -> Vec<Reference> {
194 (1..=n)
195 .map(|i| Reference {
196 index: i,
197 url: format!("https://example.com/very/long/path/segment/{i}?query=value#frag"),
198 })
199 .collect()
200 }
201
202 fn body_citing(n: usize) -> String {
203 (1..=n)
204 .map(|i| format!("Item {i} with filler prose describing the thing [{i}]."))
205 .collect::<Vec<_>>()
206 .join("\n")
207 }
208
209 #[test]
210 fn cited_indices_finds_markers() {
211 let got = cited_indices("a [1] b [12] c [x] d [3]");
212 assert_eq!(got.into_iter().collect::<Vec<_>>(), vec![1, 3, 12]);
213 }
214
215 #[test]
216 fn no_budget_keeps_everything() {
217 let (content, kept) = fit_to_budget(&body_citing(3), &refs(3), None);
218 assert_eq!(kept, vec![1, 2, 3]);
219 assert!(content.contains("References:"));
220 assert!(content.contains("[3] https://example.com"));
221 }
222
223 #[test]
226 fn link_dense_page_respects_the_cap() {
227 let refs = refs(120);
228 let (content, kept) = fit_to_budget(&body_citing(120), &refs, Some(200));
229 assert!(
230 estimate_tokens(&content) <= 200,
231 "estimate {} content: {content}",
232 estimate_tokens(&content)
233 );
234 assert!(kept.len() < 120, "kept {} of 120", kept.len());
235 }
236
237 #[test]
238 fn every_kept_reference_is_still_cited() {
239 let refs = refs(120);
240 let (content, kept) = fit_to_budget(&body_citing(120), &refs, Some(300));
241 let body = content.split("References:").next().unwrap();
242 let cited = cited_indices(body);
243 for index in &kept {
244 assert!(cited.contains(index), "kept [{index}] is not cited");
245 }
246 }
247
248 #[test]
251 fn a_generous_budget_is_actually_used() {
252 let refs = refs(120);
253 let (content, _) = fit_to_budget(&body_citing(120), &refs, Some(1000));
254 let used = estimate_tokens(&content);
255 assert!(used <= 1000, "over budget: {used}");
256 assert!(used >= 800, "left {} of 1000 tokens unused", 1000 - used);
257 }
258
259 #[test]
260 fn tiny_budget_still_fits() {
261 let refs = refs(40);
262 let (content, _) = fit_to_budget(&body_citing(40), &refs, Some(80));
263 assert!(
264 estimate_tokens(&content) <= 80,
265 "estimate {}",
266 estimate_tokens(&content)
267 );
268 }
269
270 #[test]
271 fn generous_budget_is_a_noop() {
272 let refs = refs(3);
273 let body = body_citing(3);
274 let (content, kept) = fit_to_budget(&body, &refs, Some(100_000));
275 assert_eq!(kept, vec![1, 2, 3]);
276 assert_eq!(content, assemble(&body, &render_block(&refs)));
277 }
278
279 #[test]
280 fn body_without_references_is_plain_truncation() {
281 let empty: [Reference; 0] = [];
282 let (content, kept) = fit_to_budget(&"word ".repeat(500), &empty, Some(50));
283 assert!(kept.is_empty());
284 assert!(content.contains("…[truncated]"));
285 assert!(estimate_tokens(&content) <= 50);
286 }
287}