Skip to main content

webfetch_core/
refs.rs

1//! Shared reference-style URL preservation.
2//!
3//! Both the fetch path and the search path cite URLs with inline `[N]` markers
4//! and collect the full URLs into a trailing block. This module owns the one
5//! canonical rendering of that block, and the budgeting rule that keeps the
6//! block and the body it belongs to inside a token cap together.
7
8use std::collections::BTreeSet;
9
10use serde::{Deserialize, Serialize};
11
12use crate::compress::{estimate_tokens, truncate_to_tokens};
13
14/// Anything that can be listed in a reference block: an index and a URL.
15pub 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/// A slim reference entry (index → URL) for an output's reference block.
30#[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
45/// Render references into the canonical block:
46///
47/// ```text
48/// References:
49/// [1] https://example.com/a
50/// [2] https://example.com/b
51/// ```
52///
53/// Returns an empty string when there are no references.
54pub 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
66/// The smallest body budget we will ever leave, so a page dominated by links
67/// still shows *some* body rather than collapsing to a bare reference list.
68const MIN_BODY_TOKENS: usize = 64;
69
70/// How many times we re-shrink the body budget trying to fit body + block.
71/// Each pass drops uncited references, which shrinks the block, which frees
72/// budget — the sequence is monotone and converges in two or three passes.
73const FIT_PASSES: usize = 6;
74
75/// Collect the distinct `[N]` reference indices cited in `text`, in order.
76pub 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
99/// Join a body and a rendered reference block into final output.
100fn 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
108/// Fit `body` plus its reference block inside `max_tokens`.
109///
110/// Returns the assembled content and the reference indices it kept.
111///
112/// The old rule reserved room for the *whole* reference block and then appended
113/// it regardless of size, so a link-dense page blew straight through the cap
114/// (a 120-link page answered `--max-tokens 200` with ~3300 tokens). The rule
115/// here is the other way round: truncate the body first, then keep only the
116/// references the surviving text still cites. Dropping a reference nobody cites
117/// costs nothing and is usually enough on its own; if the block still does not
118/// fit, references are dropped from the tail so the cap holds.
119///
120/// With `max_tokens == None` nothing is truncated and every reference is kept.
121pub 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            // The block alone is over budget (very long URLs, very small cap).
149            // Drop references from the tail until the whole thing fits.
150            return drop_until_fits(&body, &kept, max_tokens);
151        }
152        // Scale the body budget by how far over we landed, rather than
153        // subtracting the overshoot. Subtracting punishes the body for the
154        // reference block's size — one long block drove the budget straight to
155        // the floor and answered a 1000-token cap with under 300 tokens of
156        // output. Scaling converges on the cap from below in two or three
157        // passes instead.
158        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            // Not converging (already at the floor): stop shrinking the body
162            // and take references off the tail instead.
163            return drop_until_fits(&body, &kept, max_tokens);
164        }
165        budget = next;
166    }
167    unreachable!("the loop returns on its final pass")
168}
169
170/// Last resort: shrink the reference block itself, tail first. Markers for
171/// dropped references stop resolving, which is a worse output than a complete
172/// block — but a silently ignored token cap is worse still.
173fn 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    /// The regression this function exists for: a link-dense page must not
224    /// answer a small budget with a full reference block.
225    #[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    /// Fitting the cap is necessary but not sufficient: answering a 1000-token
249    /// budget with 280 tokens wastes most of the caller's allowance.
250    #[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}