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        // The core strips exactly one newline after the closing
125        // delimiter, so re-emitting `---\n` + body reconstructs the
126        // original spacing (a blank line stays a blank line).
127        if let Some((_, body)) = frontmatter_parts(&chunks[idx]) {
128            format!("---\n{merged_fm}\n---\n{body}")
129        } else {
130            format!("---\n{merged_fm}\n---\n\n{}", chunks[idx])
131        }
132    } else {
133        format!("---\n{merged_fm}\n---\n\n{}", chunks[idx])
134    };
135
136    Ok(result)
137}
138
139/// Parse the frontmatter inner block of `markdown` into ordered
140/// `(key, value)` pairs. Returns an empty vec when `markdown` has no
141/// frontmatter, when the block is empty, or when a line doesn't match
142/// `key: value` (those lines are skipped — the chunker is not a
143/// general YAML parser and the engine's renderer only emits simple
144/// scalars + bracket-delimited arrays). The order is preserved so
145/// the re-emitted frontmatter on each chunk matches the source's
146/// declared order.
147fn extract_frontmatter_lines(markdown: &str) -> Vec<(String, String)> {
148    let Some((inner, _)) = frontmatter_parts(markdown) else {
149        return Vec::new();
150    };
151    inner
152        .lines()
153        .filter_map(|line| {
154            let trimmed = line.trim_end();
155            if trimmed.is_empty() {
156                return None;
157            }
158            let colon = trimmed.find(':')?;
159            let key = trimmed[..colon].trim().to_string();
160            let value = trimmed[colon + 1..].trim_start().to_string();
161            if key.is_empty() {
162                None
163            } else {
164                Some((key, value))
165            }
166        })
167        .collect()
168}
169
170/// Compose the merged frontmatter text for one chunk. Order: original
171/// entity frontmatter (in source order), then caller-supplied
172/// `extra_fm` entries (overriding any matching key from the original),
173/// then engine-time chunk-walk keys (`_truncated`, `_chunk`,
174/// `_total_chunks`). The chunk-walk keys are always engine-authored; if
175/// `extra_fm` carries one of those keys it loses to the chunker's own
176/// value.
177fn merge_chunk_frontmatter(
178    original: &[(String, String)],
179    extra_fm: &[(&str, &str)],
180    idx: usize,
181    total: usize,
182    truncated: bool,
183) -> String {
184    use indexmap::IndexMap;
185    const CHUNK_WALK_KEYS: &[&str] = &["_truncated", "_chunk", "_total_chunks"];
186
187    let mut keyed: IndexMap<String, String> = IndexMap::new();
188    for (k, v) in original {
189        if CHUNK_WALK_KEYS.contains(&k.as_str()) {
190            continue; // Re-derived per chunk; never inherit.
191        }
192        keyed.insert(k.clone(), v.clone());
193    }
194    for (k, v) in extra_fm {
195        if CHUNK_WALK_KEYS.contains(k) {
196            continue;
197        }
198        keyed.insert((*k).to_string(), (*v).to_string());
199    }
200    if truncated {
201        keyed.insert("_truncated".to_string(), "true".to_string());
202    }
203    keyed.insert("_chunk".to_string(), format!("{idx} of {total}"));
204    keyed.insert("_total_chunks".to_string(), total.to_string());
205
206    keyed
207        .iter()
208        .map(|(k, v)| format!("{k}: {v}"))
209        .collect::<Vec<_>>()
210        .join("\n")
211}
212
213/// Inject `_chunk: N of M` and `_total_chunks: M` into the markdown's
214/// existing frontmatter, preserving every other key. When the body
215/// has no frontmatter yet, prepend a fresh one carrying just these
216/// keys. Single-chunk path only — multi-chunk uses the existing
217/// caller-driven `extra_fm` rewrite shape.
218fn inject_chunk_frontmatter(markdown: &str, idx: usize, total: usize, truncated: bool) -> String {
219    let chunk_line = format!("_chunk: {idx} of {total}");
220    let total_line = format!("_total_chunks: {total}");
221    let truncated_line = if truncated { "_truncated: true\n" } else { "" };
222    match frontmatter_parts(markdown) {
223        Some((meta, body)) => {
224            let inner = meta.trim_end_matches(['\n', '\r']);
225            let separator = if inner.is_empty() { "" } else { "\n" };
226            format!(
227                "---\n{inner}{separator}{truncated_line}{chunk_line}\n{total_line}\n---\n{body}"
228            )
229        }
230        None => format!("---\n{truncated_line}{chunk_line}\n{total_line}\n---\n\n{markdown}"),
231    }
232}
233
234/// Split `text` at its frontmatter via the consolidated core
235/// (`split_frontmatter_core`), returning the inner meta block and the
236/// body after the closing delimiter. `None` for a document with no
237/// opening delimiter or an unclosed block — both degrade to
238/// whole-document-is-body here, matching the tolerant read path. The
239/// hand-rolled scanner this replaces recognised only the
240/// newline-terminated opening fence, so a `---\r\n` document was
241/// treated as having no frontmatter at all and its offset constant was
242/// wrong for that shape besides (the divergence-from-the-core disease
243/// `scripts/frontmatter-sites.json` documents); the core owns both
244/// delimiter flavours in one place.
245fn frontmatter_parts(text: &str) -> Option<(&str, &str)> {
246    match crate::entity::parser::split_frontmatter_core(text) {
247        (_, crate::entity::parser::Frontmatter::Present { meta, body }) => Some((meta, body)),
248        _ => None,
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[test]
257    fn estimate_tokens_basic() {
258        assert_eq!(estimate_tokens("hello world!"), 3); // 12 chars / 4
259    }
260
261    /// A sub-floor content budget is raised to the transport floor so a
262    /// small body ships as one chunk; a budget already above the floor
263    /// is untouched.
264    #[test]
265    fn floor_chunk_budget_raises_tiny_budgets_only() {
266        assert_eq!(floor_chunk_budget(5), MIN_TRANSPORT_CHUNK_BUDGET);
267        assert_eq!(floor_chunk_budget(0), MIN_TRANSPORT_CHUNK_BUDGET);
268        assert_eq!(floor_chunk_budget(25_000), 25_000);
269        // A small body chunked at the floored budget stays one chunk,
270        // where chunking at the raw tiny budget would fragment it.
271        let small_body = "line one\nline two\nline three\n";
272        assert!(chunk_markdown(small_body, floor_chunk_budget(5)).is_none());
273        assert!(chunk_markdown(small_body, 5).unwrap().len() > 1);
274    }
275
276    #[test]
277    fn chunk_small_content_returns_none() {
278        let text = "short";
279        assert!(chunk_markdown(text, 100).is_none());
280    }
281
282    #[test]
283    fn chunk_splits_at_newline_boundaries() {
284        let text = "line1\nline2\nline3\nline4\nline5\n";
285        // Budget of 2 tokens = 8 chars
286        let chunks = chunk_markdown(text, 2).unwrap();
287        assert!(chunks.len() > 1);
288        // Each chunk should end at a newline boundary
289        for chunk in &chunks[..chunks.len() - 1] {
290            assert!(chunk.ends_with('\n') || !chunk.contains('\n'));
291        }
292    }
293
294    #[test]
295    fn apply_chunking_no_split_needed_injects_chunk_metadata() {
296        let md = "---\n_hash: abc\n---\n\n# Title\n\nContent";
297        let result = apply_chunking(md, 10000, None, &[]).unwrap();
298        assert!(
299            result.contains("_hash: abc"),
300            "preserves existing frontmatter key"
301        );
302        assert!(result.contains("_chunk: 1 of 1"), "got: {result}");
303        assert!(result.contains("_total_chunks: 1"), "got: {result}");
304        assert!(result.ends_with("# Title\n\nContent"), "preserves body");
305    }
306
307    #[test]
308    fn apply_chunking_invalid_chunk_returns_error() {
309        let md = "a\nb\n".repeat(100);
310        let result = apply_chunking(&md, 1, Some(999), &[]);
311        assert!(result.is_err());
312    }
313
314    #[test]
315    fn apply_chunking_out_of_range_errors_even_when_no_split_needed() {
316        // F26: requesting `chunk=99` on a body that fits in one chunk
317        // used to silently cap to chunk 1. Agents walking a large
318        // surface blind need the engine to flag the overshoot.
319        let md = "---\n_hash: x\n---\n\n# Small\n";
320        let result = apply_chunking(md, 10000, Some(99), &[]);
321        assert!(result.is_err(), "out-of-range request must fail");
322    }
323
324    #[test]
325    fn apply_chunking_no_frontmatter_prepends_one() {
326        let md = "# Bare\n\nNo frontmatter here.";
327        let result = apply_chunking(md, 10000, None, &[]).unwrap();
328        assert!(result.starts_with("---\n_chunk: 1 of 1\n_total_chunks: 1\n---"));
329        assert!(result.contains("# Bare"));
330    }
331
332    /// Every chunk carries the entity-level frontmatter merged with
333    /// caller-supplied `extra_fm` and the chunk-walk keys — chunk 1
334    /// retains the entity frontmatter rather than being overwritten by
335    /// `extra_fm` only.
336    #[test]
337    fn apply_chunking_preserves_entity_frontmatter_on_chunk_1() {
338        // A multi-chunk body whose source markdown carries entity-
339        // level frontmatter. Each line in the body adds ~5 chars; we
340        // want enough body to force >1 chunk under a small budget.
341        let mut md = String::from(
342            "---\n\
343             _hash: abc123\n\
344             type: spec\n\
345             level: M0\n\
346             stability: stable\n\
347             created_date: 2026-01-01\n\
348             last_modified: 2026-05-17\n\
349             _tokens: 9999\n\
350             ---\n\n\
351             # Title\n\n\
352             ",
353        );
354        for i in 0..200 {
355            md.push_str(&format!(
356                "body line {i} with enough content to span chunks\n"
357            ));
358        }
359
360        let result = apply_chunking(
361            &md,
362            /* tiny budget */ 100,
363            Some(1),
364            &[("_hash", "fresh-hash"), ("_mem_schema", "default@1.0.0")],
365        )
366        .unwrap();
367        for key in [
368            "type:",
369            "level:",
370            "stability:",
371            "created_date:",
372            "last_modified:",
373        ] {
374            assert!(
375                result.contains(key),
376                "chunk 1 must carry the entity-level `{key}` frontmatter key — got:\n{result}",
377            );
378        }
379        // Caller-supplied keys win on collision.
380        assert!(
381            result.contains("_hash: fresh-hash"),
382            "extra_fm must override the original frontmatter's `_hash`",
383        );
384        assert!(
385            result.contains("_mem_schema: default@1.0.0"),
386            "extra_fm key must be present",
387        );
388        // Chunk-walk signals always emitted.
389        assert!(result.contains("_truncated: true"));
390        assert!(result.contains("_chunk: 1 of "));
391    }
392
393    /// Chunks 2..N also carry the entity-level frontmatter.
394    #[test]
395    fn apply_chunking_preserves_entity_frontmatter_on_later_chunks() {
396        let mut md = String::from(
397            "---\n\
398             _hash: abc123\n\
399             type: memo\n\
400             level: M1\n\
401             created_date: 2026-01-01\n\
402             _tokens_unfiltered_body: 5000\n\
403             ---\n\n\
404             # Title\n\n\
405             ",
406        );
407        for i in 0..300 {
408            md.push_str(&format!(
409                "body line {i}: long enough content for spread chunking\n"
410            ));
411        }
412
413        // Get the chunk count first via Chunk 1; then read Chunks 2
414        // and 3 individually and assert each carries the entity FM.
415        let chunk_1 = apply_chunking(&md, 100, Some(1), &[("_hash", "h")]).unwrap();
416        // The chunk-1 frontmatter has `_total_chunks: <N>` — parse
417        // N out so the test exercises every middle/last chunk.
418        let total_chunks: usize = chunk_1
419            .lines()
420            .find_map(|l| l.strip_prefix("_total_chunks: "))
421            .and_then(|s| s.parse().ok())
422            .unwrap_or_else(|| panic!("chunk 1 must declare _total_chunks: {chunk_1}"));
423        assert!(total_chunks >= 3, "test fixture must produce ≥3 chunks");
424
425        for chunk_idx in 2..=total_chunks {
426            let chunk = apply_chunking(&md, 100, Some(chunk_idx), &[("_hash", "h")]).unwrap();
427            for key in ["type: memo", "level: M1", "created_date: 2026-01-01"] {
428                assert!(
429                    chunk.contains(key),
430                    "chunk {chunk_idx} must carry `{key}` in its frontmatter — got:\n{chunk}",
431                );
432            }
433        }
434    }
435
436    /// extra_fm and original frontmatter collide on `_hash`.
437    /// The caller's value (`extra_fm`) wins because the engine wants
438    /// to authoritatively override post-mutation hashes per chunk.
439    #[test]
440    fn apply_chunking_caller_supplied_wins_on_collision() {
441        let mut md = String::from(
442            "---\n\
443             _hash: stale-from-prior-write\n\
444             type: spec\n\
445             ---\n\n",
446        );
447        for i in 0..200 {
448            md.push_str(&format!("line {i}: filler to force multi-chunk emission\n"));
449        }
450
451        let chunk_1 =
452            apply_chunking(&md, 100, Some(1), &[("_hash", "post-mutation-hash")]).unwrap();
453        assert!(chunk_1.contains("_hash: post-mutation-hash"));
454        assert!(
455            !chunk_1.contains("_hash: stale-from-prior-write"),
456            "stale hash must not survive the merge",
457        );
458    }
459
460    /// `chunk_markdown` must never panic on multi-byte input regardless
461    /// of where the budget boundary lands. The 2026-05-18 CLI probe
462    /// (F5) reproduced `memstead overview --token-budget 0` panicking with
463    /// `start byte index N is not a char boundary; it is inside '—'`
464    /// against the schema description's em-dash. The fix uses
465    /// `floor_char_boundary` on every byte-indexed slice — these tests
466    /// cover the budgets that collapse the char-budget heuristic to
467    /// values inside a multi-byte char.
468    #[test]
469    fn chunk_tiny_budgets_em_dash() {
470        // `—` (U+2014, 3 bytes) at byte offsets 0, 4, 8, … of the body.
471        // budget=0 → char_budget=0; budget=1 → 4; budget=2 → 8.
472        for body in ["—text", "te—xt", "text—", "—a—b—c—", "  —  —  —"] {
473            for budget in 0..=2 {
474                let _ = chunk_markdown(body, budget); // must not panic
475            }
476        }
477    }
478
479    #[test]
480    fn chunk_tiny_budgets_cjk() {
481        // CJK chars are 3 bytes each (e.g. `日`, `本`). budget=1 →
482        // char_budget=4 which lands mid-`日` if the body starts there.
483        for body in ["日本語", "日本語テスト", "abc日本語def", "日a本b語c"] {
484            for budget in 0..=4 {
485                let _ = chunk_markdown(body, budget);
486            }
487        }
488    }
489
490    #[test]
491    fn chunk_tiny_budgets_emoji_vs() {
492        // Emoji with variation selector — `❤` (U+2764, 3 bytes) +
493        // VS-16 (U+FE0F, 3 bytes) = 6 bytes per glyph.
494        let heart_vs = "\u{2764}\u{FE0F}";
495        let body = format!("{heart_vs}{heart_vs}{heart_vs}text{heart_vs}{heart_vs}");
496        for budget in 0..=5 {
497            let _ = chunk_markdown(&body, budget);
498        }
499    }
500
501    #[test]
502    fn chunk_markdown_budget_zero_emits_single_chunk() {
503        // budget=0 → no usable prefix at any iteration. The whole body
504        // ships as a single chunk; no panic, no infinite loop.
505        let chunks = chunk_markdown("any non-trivial body", 0).expect("non-empty body chunks");
506        assert_eq!(chunks.len(), 1);
507        assert_eq!(chunks[0], "any non-trivial body");
508    }
509
510    #[test]
511    fn apply_chunking_tiny_budgets_with_em_dash() {
512        // Direct apply_chunking path — the production call surface.
513        // The frontmatter description text the F5 probe hit carried an
514        // em-dash; recreate that shape.
515        let md = "---\n_hash: x\n---\n\n# Title — with em-dash\n\nMore body — even more.";
516        for budget in 0..=2 {
517            let result = apply_chunking(md, budget, None, &[]);
518            assert!(
519                result.is_ok(),
520                "budget {budget} must not panic or error: {result:?}"
521            );
522        }
523    }
524
525    #[test]
526    fn chunk_markdown_byte_identical_for_budget_ten_plus() {
527        // Bisect-green constraint: budgets ≥ 10 produce identical
528        // output to a body that has no multi-byte chars within the
529        // first slice. The fix is a no-op for the productive range.
530        let body = "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\n".repeat(20);
531        for budget in [10, 25, 50, 100, 250, 1000] {
532            let chunks = chunk_markdown(&body, budget).unwrap_or_else(|| vec![body.clone()]);
533            // Re-concatenate the chunks; the chunker is allowed to
534            // drop a single `\n` separator per split, but the joined
535            // form with `\n` between chunks must reproduce the body.
536            let rejoined = chunks.join("\n");
537            assert!(
538                rejoined == body || rejoined == body.trim_end_matches('\n'),
539                "budget={budget}: chunk roundtrip must equal source"
540            );
541        }
542    }
543
544    /// Cross-chunk frontmatter consistency — the entity-level fields
545    /// read identically across every chunk for one rendering.
546    #[test]
547    fn apply_chunking_cross_chunk_frontmatter_consistency() {
548        let mut md = String::from(
549            "---\n\
550             _hash: abc\n\
551             type: decision\n\
552             level: M2\n\
553             stability: stable\n\
554             ---\n\n",
555        );
556        for i in 0..300 {
557            md.push_str(&format!("line {i}: filler\n"));
558        }
559
560        let chunk_1 = apply_chunking(&md, 100, Some(1), &[]).unwrap();
561        let chunk_2 = apply_chunking(&md, 100, Some(2), &[]).unwrap();
562        for key in ["type: decision", "level: M2", "stability: stable"] {
563            assert!(chunk_1.contains(key), "chunk_1 missing `{key}`");
564            assert!(chunk_2.contains(key), "chunk_2 missing `{key}`");
565        }
566    }
567
568    /// A carriage-return document's frontmatter is recognised and
569    /// preserved. The hand-rolled scanner the core replaced saw no
570    /// frontmatter in a `---\r\n` document, so the single-chunk inject
571    /// path prepended a SECOND frontmatter block over the first and the
572    /// entity keys vanished from the chunk view; against that code this
573    /// test fails on both assertions.
574    #[test]
575    fn inject_preserves_carriage_return_frontmatter() {
576        let doc = "---\r\ntype: spec\r\nlevel: M0\r\n---\r\n\r\n# Title\r\n\r\nBody text.\r\n";
577        let out = inject_chunk_frontmatter(doc, 1, 1, false);
578        assert_eq!(
579            out.matches("---").count(),
580            2,
581            "exactly one frontmatter block, not a second prepended over the first:\n{out}"
582        );
583        assert!(
584            out.contains("type: spec") && out.contains("_chunk: 1 of 1"),
585            "entity keys and chunk-walk keys share the one block:\n{out}"
586        );
587    }
588}