Skip to main content

memstead_base/
chunking.rs

1//! Token-budget chunking for large MCP responses.
2
3/// Estimate token count from a string (rough: chars / 4).
4pub fn estimate_tokens(text: &str) -> usize {
5    text.chars().count() / 4
6}
7
8/// Minimum transport chunk budget for surfaces that reuse a *content*
9/// budget as the chunk size — the `overview` CLI passes its
10/// `--token-budget` straight into [`apply_chunking`]. A tiny content
11/// budget should shrink *what is included* (the composer's greedy-fill
12/// already drops heavy content), not fragment the always-shipped
13/// hard-required body into hundreds of mid-word pieces. So the chunk
14/// size is floored here while the content budget stays as the caller
15/// set it.
16///
17/// Deliberately NOT applied to `memstead_entity`'s `token_budget`: there the
18/// value IS an explicit transport cap the agent set on the text channel,
19/// so honouring small values (chunking) is the contract — see
20/// `apply_chunking` callers in the entity read path.
21pub const MIN_TRANSPORT_CHUNK_BUDGET: usize = 4096;
22
23/// Floor a requested chunk budget at [`MIN_TRANSPORT_CHUNK_BUDGET`] so a
24/// sub-floor budget ships small bodies as one chunk instead of
25/// fragmenting them.
26pub fn floor_chunk_budget(requested: usize) -> usize {
27    requested.max(MIN_TRANSPORT_CHUNK_BUDGET)
28}
29
30/// Split a large response into chunks that fit within a token budget.
31/// Splits at the nearest newline boundary to avoid breaking mid-syntax.
32/// Returns `None` if the content fits within the budget (no chunking needed).
33pub fn chunk_markdown(markdown: &str, budget: usize) -> Option<Vec<String>> {
34    let char_budget = budget * 4;
35    if markdown.len() <= char_budget {
36        return None;
37    }
38
39    let mut chunks = Vec::new();
40    let mut remaining = markdown;
41
42    while !remaining.is_empty() {
43        if remaining.len() <= char_budget {
44            chunks.push(remaining.to_string());
45            break;
46        }
47        // Round the byte budget down to the nearest char boundary so the
48        // initial slice never lands inside a multi-byte UTF-8 character.
49        let safe_budget = remaining.floor_char_boundary(char_budget);
50        // If the budget rounds down to 0 (the caller asked for budget=0,
51        // or a single multi-byte char wider than `char_budget` sits at
52        // position 0), there is no usable prefix at all. Emit the whole
53        // remaining content as a final chunk — the engine never panics
54        // under any input, and `_overview_mode: overbudget` already
55        // signals to callers that they're below the productive range.
56        if safe_budget == 0 {
57            chunks.push(remaining.to_string());
58            break;
59        }
60        // `rfind('\n')` on a char-bounded prefix lands on a `\n` byte
61        // (1-byte ASCII) so `split + 1` is also a valid char boundary.
62        // When rfind fails we split at `safe_budget` and advance to the
63        // same offset — no `+1`, since there's no newline byte to skip.
64        let (split_at, advance) = match remaining[..safe_budget].rfind('\n').filter(|&i| i > 0) {
65            Some(i) => (i, i + 1),
66            None => (safe_budget, safe_budget),
67        };
68        chunks.push(remaining[..split_at].to_string());
69        remaining = &remaining[advance..];
70    }
71
72    Some(chunks)
73}
74
75/// Apply chunking to a markdown response. Returns the chunk at `idx` (0-based)
76/// with appropriate frontmatter metadata.
77///
78/// `_chunk: N` and `_total_chunks: M` are always injected (including
79/// the `1 of 1` case) so an agent walking the surface can size
80/// pagination without first peeking at the response length. A request
81/// for `chunk > total_chunks` is always an error — even when the body
82/// fits in a single chunk and the silent-cap behaviour would have
83/// hidden the overshoot.
84pub fn apply_chunking(
85    markdown: &str,
86    budget: usize,
87    chunk: Option<usize>,
88    extra_fm: &[(&str, &str)],
89) -> Result<String, String> {
90    let chunks_opt = chunk_markdown(markdown, budget);
91    let total = chunks_opt.as_ref().map(|c| c.len()).unwrap_or(1);
92    let idx = chunk.unwrap_or(1).saturating_sub(1);
93    if idx >= total {
94        return Err(format!(
95            "Chunk {} does not exist. Content has {} chunk{s}.",
96            idx + 1,
97            total,
98            s = if total == 1 { "" } else { "s" },
99        ));
100    }
101
102    // Single-chunk case: preserve the original frontmatter and body
103    // verbatim; only inject the chunk-walk signals so an agent can
104    // size pagination without first peeking at the response length.
105    if chunks_opt.is_none() {
106        return Ok(inject_chunk_frontmatter(markdown, 1, 1, false));
107    }
108
109    let chunks = chunks_opt.unwrap();
110    let is_last = idx == total - 1;
111
112    // Every chunk carries the entity-level frontmatter (`type`,
113    // `level`, `stability`, `created_date`, `last_modified`,
114    // `_tokens_unfiltered_body`, …) merged with caller-supplied
115    // `extra_fm` (`_hash`, `_mem_schema`) and the chunk-walk signals
116    // (`_truncated`, `_chunk`, `_total_chunks`). The entity frontmatter
117    // is preserved on every chunk so an agent reading any single chunk
118    // in isolation can answer "what kind of entity is this and when was
119    // it last touched" without re-fetching chunk 1.
120    let original_fm = extract_frontmatter_lines(markdown);
121    let merged_fm = merge_chunk_frontmatter(&original_fm, extra_fm, idx + 1, total, !is_last);
122
123    let result = if idx == 0 {
124        if let Some(end) = find_frontmatter_end(&chunks[idx]) {
125            format!("---\n{merged_fm}\n---{}", &chunks[idx][end..])
126        } else {
127            format!("---\n{merged_fm}\n---\n\n{}", chunks[idx])
128        }
129    } else {
130        format!("---\n{merged_fm}\n---\n\n{}", chunks[idx])
131    };
132
133    Ok(result)
134}
135
136/// Parse the frontmatter inner block of `markdown` into ordered
137/// `(key, value)` pairs. Returns an empty vec when `markdown` has no
138/// frontmatter, when the block is empty, or when a line doesn't match
139/// `key: value` (those lines are skipped — the chunker is not a
140/// general YAML parser and the engine's renderer only emits simple
141/// scalars + bracket-delimited arrays). The order is preserved so
142/// the re-emitted frontmatter on each chunk matches the source's
143/// declared order.
144fn extract_frontmatter_lines(markdown: &str) -> Vec<(String, String)> {
145    let Some(end) = find_frontmatter_end(markdown) else {
146        return Vec::new();
147    };
148    // `end` points past the closing `\n---`; back up 4 to land on the
149    // closing marker's leading newline. The inner block starts after
150    // `---\n` (4 chars from the start) and ends at `inner_end`.
151    let inner_end = end - 4;
152    let inner = &markdown[4..inner_end];
153    inner
154        .lines()
155        .filter_map(|line| {
156            let trimmed = line.trim_end();
157            if trimmed.is_empty() {
158                return None;
159            }
160            let colon = trimmed.find(':')?;
161            let key = trimmed[..colon].trim().to_string();
162            let value = trimmed[colon + 1..].trim_start().to_string();
163            if key.is_empty() {
164                None
165            } else {
166                Some((key, value))
167            }
168        })
169        .collect()
170}
171
172/// Compose the merged frontmatter text for one chunk. Order: original
173/// entity frontmatter (in source order), then caller-supplied
174/// `extra_fm` entries (overriding any matching key from the original),
175/// then engine-time chunk-walk keys (`_truncated`, `_chunk`,
176/// `_total_chunks`). The chunk-walk keys are always engine-authored; if
177/// `extra_fm` carries one of those keys it loses to the chunker's own
178/// value.
179fn merge_chunk_frontmatter(
180    original: &[(String, String)],
181    extra_fm: &[(&str, &str)],
182    idx: usize,
183    total: usize,
184    truncated: bool,
185) -> String {
186    use indexmap::IndexMap;
187    const CHUNK_WALK_KEYS: &[&str] = &["_truncated", "_chunk", "_total_chunks"];
188
189    let mut keyed: IndexMap<String, String> = IndexMap::new();
190    for (k, v) in original {
191        if CHUNK_WALK_KEYS.contains(&k.as_str()) {
192            continue; // Re-derived per chunk; never inherit.
193        }
194        keyed.insert(k.clone(), v.clone());
195    }
196    for (k, v) in extra_fm {
197        if CHUNK_WALK_KEYS.contains(k) {
198            continue;
199        }
200        keyed.insert((*k).to_string(), (*v).to_string());
201    }
202    if truncated {
203        keyed.insert("_truncated".to_string(), "true".to_string());
204    }
205    keyed.insert("_chunk".to_string(), format!("{idx} of {total}"));
206    keyed.insert("_total_chunks".to_string(), total.to_string());
207
208    keyed
209        .iter()
210        .map(|(k, v)| format!("{k}: {v}"))
211        .collect::<Vec<_>>()
212        .join("\n")
213}
214
215/// Inject `_chunk: N of M` and `_total_chunks: M` into the markdown's
216/// existing frontmatter, preserving every other key. When the body
217/// has no frontmatter yet, prepend a fresh one carrying just these
218/// keys. Single-chunk path only — multi-chunk uses the existing
219/// caller-driven `extra_fm` rewrite shape.
220fn inject_chunk_frontmatter(markdown: &str, idx: usize, total: usize, truncated: bool) -> String {
221    let chunk_line = format!("_chunk: {idx} of {total}");
222    let total_line = format!("_total_chunks: {total}");
223    let truncated_line = if truncated { "_truncated: true\n" } else { "" };
224    match find_frontmatter_end(markdown) {
225        Some(end) => {
226            // `end` points just past the closing `\n---`; back up 4 to
227            // re-anchor on the closing marker, then trim the trailing
228            // `\n` from the inner block so we can re-emit it cleanly.
229            let inner_end = end - 4;
230            let inner = markdown[4..inner_end].trim_end_matches('\n');
231            let separator = if inner.is_empty() { "" } else { "\n" };
232            format!(
233                "---\n{inner}{separator}{truncated_line}{chunk_line}\n{total_line}\n---{}",
234                &markdown[end..]
235            )
236        }
237        None => format!("---\n{truncated_line}{chunk_line}\n{total_line}\n---\n\n{markdown}"),
238    }
239}
240
241/// Find the end of YAML frontmatter (position of the closing `---` including it).
242fn find_frontmatter_end(text: &str) -> Option<usize> {
243    if !text.starts_with("---\n") {
244        return None;
245    }
246    // Find closing ---
247    text[4..].find("\n---").map(|pos| pos + 4 + 4) // skip opening "---\n" + matched "\n---"
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253
254    #[test]
255    fn estimate_tokens_basic() {
256        assert_eq!(estimate_tokens("hello world!"), 3); // 12 chars / 4
257    }
258
259    /// A sub-floor content budget is raised to the transport floor so a
260    /// small body ships as one chunk; a budget already above the floor
261    /// is untouched.
262    #[test]
263    fn floor_chunk_budget_raises_tiny_budgets_only() {
264        assert_eq!(floor_chunk_budget(5), MIN_TRANSPORT_CHUNK_BUDGET);
265        assert_eq!(floor_chunk_budget(0), MIN_TRANSPORT_CHUNK_BUDGET);
266        assert_eq!(floor_chunk_budget(25_000), 25_000);
267        // A small body chunked at the floored budget stays one chunk,
268        // where chunking at the raw tiny budget would fragment it.
269        let small_body = "line one\nline two\nline three\n";
270        assert!(chunk_markdown(small_body, floor_chunk_budget(5)).is_none());
271        assert!(chunk_markdown(small_body, 5).unwrap().len() > 1);
272    }
273
274    #[test]
275    fn chunk_small_content_returns_none() {
276        let text = "short";
277        assert!(chunk_markdown(text, 100).is_none());
278    }
279
280    #[test]
281    fn chunk_splits_at_newline_boundaries() {
282        let text = "line1\nline2\nline3\nline4\nline5\n";
283        // Budget of 2 tokens = 8 chars
284        let chunks = chunk_markdown(text, 2).unwrap();
285        assert!(chunks.len() > 1);
286        // Each chunk should end at a newline boundary
287        for chunk in &chunks[..chunks.len() - 1] {
288            assert!(chunk.ends_with('\n') || !chunk.contains('\n'));
289        }
290    }
291
292    #[test]
293    fn apply_chunking_no_split_needed_injects_chunk_metadata() {
294        let md = "---\n_hash: abc\n---\n\n# Title\n\nContent";
295        let result = apply_chunking(md, 10000, None, &[]).unwrap();
296        assert!(
297            result.contains("_hash: abc"),
298            "preserves existing frontmatter key"
299        );
300        assert!(result.contains("_chunk: 1 of 1"), "got: {result}");
301        assert!(result.contains("_total_chunks: 1"), "got: {result}");
302        assert!(result.ends_with("# Title\n\nContent"), "preserves body");
303    }
304
305    #[test]
306    fn apply_chunking_invalid_chunk_returns_error() {
307        let md = "a\nb\n".repeat(100);
308        let result = apply_chunking(&md, 1, Some(999), &[]);
309        assert!(result.is_err());
310    }
311
312    #[test]
313    fn apply_chunking_out_of_range_errors_even_when_no_split_needed() {
314        // F26: requesting `chunk=99` on a body that fits in one chunk
315        // used to silently cap to chunk 1. Agents walking a large
316        // surface blind need the engine to flag the overshoot.
317        let md = "---\n_hash: x\n---\n\n# Small\n";
318        let result = apply_chunking(md, 10000, Some(99), &[]);
319        assert!(result.is_err(), "out-of-range request must fail");
320    }
321
322    #[test]
323    fn apply_chunking_no_frontmatter_prepends_one() {
324        let md = "# Bare\n\nNo frontmatter here.";
325        let result = apply_chunking(md, 10000, None, &[]).unwrap();
326        assert!(result.starts_with("---\n_chunk: 1 of 1\n_total_chunks: 1\n---"));
327        assert!(result.contains("# Bare"));
328    }
329
330    /// Every chunk carries the entity-level frontmatter merged with
331    /// caller-supplied `extra_fm` and the chunk-walk keys — chunk 1
332    /// retains the entity frontmatter rather than being overwritten by
333    /// `extra_fm` only.
334    #[test]
335    fn apply_chunking_preserves_entity_frontmatter_on_chunk_1() {
336        // A multi-chunk body whose source markdown carries entity-
337        // level frontmatter. Each line in the body adds ~5 chars; we
338        // want enough body to force >1 chunk under a small budget.
339        let mut md = String::from(
340            "---\n\
341             _hash: abc123\n\
342             type: spec\n\
343             level: M0\n\
344             stability: stable\n\
345             created_date: 2026-01-01\n\
346             last_modified: 2026-05-17\n\
347             _tokens: 9999\n\
348             ---\n\n\
349             # Title\n\n\
350             ",
351        );
352        for i in 0..200 {
353            md.push_str(&format!(
354                "body line {i} with enough content to span chunks\n"
355            ));
356        }
357
358        let result = apply_chunking(
359            &md,
360            /* tiny budget */ 100,
361            Some(1),
362            &[("_hash", "fresh-hash"), ("_mem_schema", "default@1.0.0")],
363        )
364        .unwrap();
365        for key in [
366            "type:",
367            "level:",
368            "stability:",
369            "created_date:",
370            "last_modified:",
371        ] {
372            assert!(
373                result.contains(key),
374                "chunk 1 must carry the entity-level `{key}` frontmatter key — got:\n{result}",
375            );
376        }
377        // Caller-supplied keys win on collision.
378        assert!(
379            result.contains("_hash: fresh-hash"),
380            "extra_fm must override the original frontmatter's `_hash`",
381        );
382        assert!(
383            result.contains("_mem_schema: default@1.0.0"),
384            "extra_fm key must be present",
385        );
386        // Chunk-walk signals always emitted.
387        assert!(result.contains("_truncated: true"));
388        assert!(result.contains("_chunk: 1 of "));
389    }
390
391    /// Chunks 2..N also carry the entity-level frontmatter.
392    #[test]
393    fn apply_chunking_preserves_entity_frontmatter_on_later_chunks() {
394        let mut md = String::from(
395            "---\n\
396             _hash: abc123\n\
397             type: memo\n\
398             level: M1\n\
399             created_date: 2026-01-01\n\
400             _tokens_unfiltered_body: 5000\n\
401             ---\n\n\
402             # Title\n\n\
403             ",
404        );
405        for i in 0..300 {
406            md.push_str(&format!(
407                "body line {i}: long enough content for spread chunking\n"
408            ));
409        }
410
411        // Get the chunk count first via Chunk 1; then read Chunks 2
412        // and 3 individually and assert each carries the entity FM.
413        let chunk_1 = apply_chunking(&md, 100, Some(1), &[("_hash", "h")]).unwrap();
414        // The chunk-1 frontmatter has `_total_chunks: <N>` — parse
415        // N out so the test exercises every middle/last chunk.
416        let total_chunks: usize = chunk_1
417            .lines()
418            .find_map(|l| l.strip_prefix("_total_chunks: "))
419            .and_then(|s| s.parse().ok())
420            .unwrap_or_else(|| panic!("chunk 1 must declare _total_chunks: {chunk_1}"));
421        assert!(total_chunks >= 3, "test fixture must produce ≥3 chunks");
422
423        for chunk_idx in 2..=total_chunks {
424            let chunk = apply_chunking(&md, 100, Some(chunk_idx), &[("_hash", "h")]).unwrap();
425            for key in ["type: memo", "level: M1", "created_date: 2026-01-01"] {
426                assert!(
427                    chunk.contains(key),
428                    "chunk {chunk_idx} must carry `{key}` in its frontmatter — got:\n{chunk}",
429                );
430            }
431        }
432    }
433
434    /// extra_fm and original frontmatter collide on `_hash`.
435    /// The caller's value (`extra_fm`) wins because the engine wants
436    /// to authoritatively override post-mutation hashes per chunk.
437    #[test]
438    fn apply_chunking_caller_supplied_wins_on_collision() {
439        let mut md = String::from(
440            "---\n\
441             _hash: stale-from-prior-write\n\
442             type: spec\n\
443             ---\n\n",
444        );
445        for i in 0..200 {
446            md.push_str(&format!("line {i}: filler to force multi-chunk emission\n"));
447        }
448
449        let chunk_1 =
450            apply_chunking(&md, 100, Some(1), &[("_hash", "post-mutation-hash")]).unwrap();
451        assert!(chunk_1.contains("_hash: post-mutation-hash"));
452        assert!(
453            !chunk_1.contains("_hash: stale-from-prior-write"),
454            "stale hash must not survive the merge",
455        );
456    }
457
458    /// `chunk_markdown` must never panic on multi-byte input regardless
459    /// of where the budget boundary lands. The 2026-05-18 CLI probe
460    /// (F5) reproduced `memstead overview --token-budget 0` panicking with
461    /// `start byte index N is not a char boundary; it is inside '—'`
462    /// against the schema description's em-dash. The fix uses
463    /// `floor_char_boundary` on every byte-indexed slice — these tests
464    /// cover the budgets that collapse the char-budget heuristic to
465    /// values inside a multi-byte char.
466    #[test]
467    fn chunk_tiny_budgets_em_dash() {
468        // `—` (U+2014, 3 bytes) at byte offsets 0, 4, 8, … of the body.
469        // budget=0 → char_budget=0; budget=1 → 4; budget=2 → 8.
470        for body in ["—text", "te—xt", "text—", "—a—b—c—", "  —  —  —"] {
471            for budget in 0..=2 {
472                let _ = chunk_markdown(body, budget); // must not panic
473            }
474        }
475    }
476
477    #[test]
478    fn chunk_tiny_budgets_cjk() {
479        // CJK chars are 3 bytes each (e.g. `日`, `本`). budget=1 →
480        // char_budget=4 which lands mid-`日` if the body starts there.
481        for body in ["日本語", "日本語テスト", "abc日本語def", "日a本b語c"] {
482            for budget in 0..=4 {
483                let _ = chunk_markdown(body, budget);
484            }
485        }
486    }
487
488    #[test]
489    fn chunk_tiny_budgets_emoji_vs() {
490        // Emoji with variation selector — `❤` (U+2764, 3 bytes) +
491        // VS-16 (U+FE0F, 3 bytes) = 6 bytes per glyph.
492        let heart_vs = "\u{2764}\u{FE0F}";
493        let body = format!("{heart_vs}{heart_vs}{heart_vs}text{heart_vs}{heart_vs}");
494        for budget in 0..=5 {
495            let _ = chunk_markdown(&body, budget);
496        }
497    }
498
499    #[test]
500    fn chunk_markdown_budget_zero_emits_single_chunk() {
501        // budget=0 → no usable prefix at any iteration. The whole body
502        // ships as a single chunk; no panic, no infinite loop.
503        let chunks = chunk_markdown("any non-trivial body", 0).expect("non-empty body chunks");
504        assert_eq!(chunks.len(), 1);
505        assert_eq!(chunks[0], "any non-trivial body");
506    }
507
508    #[test]
509    fn apply_chunking_tiny_budgets_with_em_dash() {
510        // Direct apply_chunking path — the production call surface.
511        // The frontmatter description text the F5 probe hit carried an
512        // em-dash; recreate that shape.
513        let md = "---\n_hash: x\n---\n\n# Title — with em-dash\n\nMore body — even more.";
514        for budget in 0..=2 {
515            let result = apply_chunking(md, budget, None, &[]);
516            assert!(
517                result.is_ok(),
518                "budget {budget} must not panic or error: {result:?}"
519            );
520        }
521    }
522
523    #[test]
524    fn chunk_markdown_byte_identical_for_budget_ten_plus() {
525        // Bisect-green constraint: budgets ≥ 10 produce identical
526        // output to a body that has no multi-byte chars within the
527        // first slice. The fix is a no-op for the productive range.
528        let body = "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\n".repeat(20);
529        for budget in [10, 25, 50, 100, 250, 1000] {
530            let chunks = chunk_markdown(&body, budget).unwrap_or_else(|| vec![body.clone()]);
531            // Re-concatenate the chunks; the chunker is allowed to
532            // drop a single `\n` separator per split, but the joined
533            // form with `\n` between chunks must reproduce the body.
534            let rejoined = chunks.join("\n");
535            assert!(
536                rejoined == body || rejoined == body.trim_end_matches('\n'),
537                "budget={budget}: chunk roundtrip must equal source"
538            );
539        }
540    }
541
542    /// Cross-chunk frontmatter consistency — the entity-level fields
543    /// read identically across every chunk for one rendering.
544    #[test]
545    fn apply_chunking_cross_chunk_frontmatter_consistency() {
546        let mut md = String::from(
547            "---\n\
548             _hash: abc\n\
549             type: decision\n\
550             level: M2\n\
551             stability: stable\n\
552             ---\n\n",
553        );
554        for i in 0..300 {
555            md.push_str(&format!("line {i}: filler\n"));
556        }
557
558        let chunk_1 = apply_chunking(&md, 100, Some(1), &[]).unwrap();
559        let chunk_2 = apply_chunking(&md, 100, Some(2), &[]).unwrap();
560        for key in ["type: decision", "level: M2", "stability: stable"] {
561            assert!(chunk_1.contains(key), "chunk_1 missing `{key}`");
562            assert!(chunk_2.contains(key), "chunk_2 missing `{key}`");
563        }
564    }
565}