Skip to main content

velesdb_memory/context/
chunk.rs

1//! Deterministic text chunking for the packing stage.
2//!
3//! No chunker exists anywhere else in the workspace (every other "chunk" is
4//! vector batching), so this is the reference implementation. Guarantees:
5//!
6//! - **Deterministic**: same text + same policy ⇒ same chunks, same ranges.
7//! - **UTF-8 safe**: never cuts inside a multi-byte char.
8//! - **Fence-atomic**: never cuts inside a triple-backtick-fenced code block — a fence
9//!   larger than [`ChunkPolicy::max_chunk_bytes`] stays one oversized chunk
10//!   rather than being broken (the packing layer decides its fate whole).
11//! - **Covering**: without overlap, the chunk ranges partition the input —
12//!   concatenating them reconstructs the text byte for byte.
13
14use schemars::JsonSchema;
15use serde::{Deserialize, Serialize};
16
17/// Which boundaries the chunker prefers to cut at.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
19#[serde(rename_all = "lowercase")]
20pub enum ChunkBoundary {
21    /// Cut at blank lines (`\n\n`), falling back to hard splits inside an
22    /// oversized paragraph.
23    Paragraph,
24    /// Cut after sentence enders (`.`, `!`, `?` followed by whitespace).
25    Sentence,
26    /// Cut at the byte ceiling only (char-aligned).
27    Fixed,
28}
29
30/// How oversized fragments are split before packing.
31#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
32#[serde(default)]
33#[schemars(transform = crate::schema::strip_int_formats)]
34pub struct ChunkPolicy {
35    /// Soft byte ceiling per chunk (fences may exceed it, see module doc).
36    pub max_chunk_bytes: usize,
37    /// Bytes of the previous chunk to repeat at the start of the next one
38    /// (char-aligned). `0` keeps chunks disjoint.
39    pub overlap_bytes: usize,
40    /// Preferred cut points.
41    pub boundary: ChunkBoundary,
42}
43
44impl Default for ChunkPolicy {
45    fn default() -> Self {
46        Self {
47            max_chunk_bytes: 2_048,
48            overlap_bytes: 0,
49            boundary: ChunkBoundary::Paragraph,
50        }
51    }
52}
53
54/// One chunk of a larger text.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct TextChunk {
57    /// The chunk text (including any overlap prefix).
58    pub text: String,
59    /// Where the *non-overlap* part of this chunk sits in the original text.
60    pub byte_range: core::ops::Range<usize>,
61    /// Position of this chunk in the sequence, starting at `0`.
62    pub index: usize,
63}
64
65/// Split `text` into chunks under `policy`. Empty text yields no chunks.
66#[must_use]
67pub fn chunk_text(text: &str, policy: &ChunkPolicy) -> Vec<TextChunk> {
68    if text.is_empty() {
69        return Vec::new();
70    }
71    let max = policy.max_chunk_bytes.max(1);
72    if text.len() <= max {
73        return vec![TextChunk {
74            text: text.to_owned(),
75            byte_range: 0..text.len(),
76            index: 0,
77        }];
78    }
79    let ranges = pack_units(text, &units(text, policy.boundary), max);
80    assemble(text, &ranges, policy.overlap_bytes)
81}
82
83/// One partition of the text: a byte range plus whether it is atomic
84/// (a genuine fenced block — never split, however large) or plain-text-
85/// derived (always eligible for a hard split when oversized). `atomic` is
86/// carried explicitly rather than re-derived from the range's content, so a
87/// plain-text cut that merely happens to start with `` ``` `` (e.g. a
88/// sentence boundary landing right before literal backticks in prose) is
89/// never mistaken for a real fence.
90struct Unit {
91    range: core::ops::Range<usize>,
92    atomic: bool,
93}
94
95/// Split `text` into atomic unit ranges: fenced blocks stay whole, the rest
96/// is cut at the preferred boundary. The ranges partition the text.
97fn units(text: &str, boundary: ChunkBoundary) -> Vec<Unit> {
98    let mut units = Vec::new();
99    for segment in fence_segments(text) {
100        match segment {
101            Segment::Fence(range) => units.push(Unit {
102                range,
103                atomic: true,
104            }),
105            Segment::Plain(range) => split_plain(text, range, boundary, &mut units),
106        }
107    }
108    units
109}
110
111/// A top-level slice of the text: either a whole fenced block or plain text.
112enum Segment {
113    /// A triple-backtick-fenced block, atomic.
114    Fence(core::ops::Range<usize>),
115    /// Plain text between fences.
116    Plain(core::ops::Range<usize>),
117}
118
119/// Walk the text line by line, separating triple-backtick-fenced blocks (atomic) from the
120/// plain text around them. An unclosed fence runs to the end of the text.
121fn fence_segments(text: &str) -> Vec<Segment> {
122    let mut segments = Vec::new();
123    let mut cursor = 0_usize;
124    let mut fence_start: Option<usize> = None;
125    let mut line_start = 0_usize;
126    for line in text.split_inclusive('\n') {
127        let opens_or_closes = line.trim_start().starts_with("```");
128        let line_end = line_start + line.len();
129        match (fence_start, opens_or_closes) {
130            (None, true) => {
131                if line_start > cursor {
132                    segments.push(Segment::Plain(cursor..line_start));
133                }
134                fence_start = Some(line_start);
135            }
136            (Some(start), true) => {
137                segments.push(Segment::Fence(start..line_end));
138                fence_start = None;
139                cursor = line_end;
140            }
141            _ => {}
142        }
143        line_start = line_end;
144    }
145    push_tail(&mut segments, fence_start, cursor, text.len());
146    segments
147}
148
149/// Close the segment walk: an unclosed fence (or the trailing plain text)
150/// runs to the end of the input.
151fn push_tail(segments: &mut Vec<Segment>, fence_start: Option<usize>, cursor: usize, end: usize) {
152    match fence_start {
153        Some(start) => segments.push(Segment::Fence(start..end)),
154        None if cursor < end => segments.push(Segment::Plain(cursor..end)),
155        None => {}
156    }
157}
158
159/// Cut one plain-text range at the preferred boundary, appending the pieces
160/// — always non-atomic: nothing derived from a `Plain` segment is ever a
161/// real fence, however it happens to start.
162fn split_plain(
163    text: &str,
164    range: core::ops::Range<usize>,
165    boundary: ChunkBoundary,
166    out: &mut Vec<Unit>,
167) {
168    let slice = &text[range.clone()];
169    let mut piece_start = 0_usize;
170    for cut in boundary_cuts(slice, boundary) {
171        out.push(Unit {
172            range: range.start + piece_start..range.start + cut,
173            atomic: false,
174        });
175        piece_start = cut;
176    }
177    if piece_start < slice.len() {
178        out.push(Unit {
179            range: range.start + piece_start..range.end,
180            atomic: false,
181        });
182    }
183}
184
185/// The byte offsets (relative to `slice`) *after* which a boundary cut is
186/// allowed. Offsets are strictly increasing and land on char boundaries.
187fn boundary_cuts(slice: &str, boundary: ChunkBoundary) -> Vec<usize> {
188    match boundary {
189        ChunkBoundary::Paragraph => paragraph_cuts(slice),
190        ChunkBoundary::Sentence => sentence_cuts(slice),
191        ChunkBoundary::Fixed => Vec::new(),
192    }
193}
194
195/// Cut points after each blank-line run (`\n\n…`).
196fn paragraph_cuts(slice: &str) -> Vec<usize> {
197    let mut cuts = Vec::new();
198    let bytes = slice.as_bytes();
199    let mut i = 0_usize;
200    while let Some(found) = find_from(bytes, i, b"\n\n") {
201        let mut end = found + 2;
202        while bytes.get(end) == Some(&b'\n') {
203            end += 1;
204        }
205        cuts.push(end);
206        i = end;
207    }
208    cuts
209}
210
211/// Cut points after each sentence ender followed by whitespace.
212fn sentence_cuts(slice: &str) -> Vec<usize> {
213    let mut cuts = Vec::new();
214    let mut previous: Option<char> = None;
215    for (offset, ch) in slice.char_indices() {
216        let after_ender = matches!(previous, Some('.' | '!' | '?'));
217        if after_ender && ch.is_whitespace() {
218            cuts.push(offset + ch.len_utf8());
219        }
220        previous = Some(ch);
221    }
222    cuts
223}
224
225/// Find `needle` in `haystack` at or after `from`.
226fn find_from(haystack: &[u8], from: usize, needle: &[u8]) -> Option<usize> {
227    haystack
228        .get(from..)?
229        .windows(needle.len())
230        .position(|window| window == needle)
231        .map(|position| from + position)
232}
233
234/// Greedily merge units into chunk ranges of at most `max` bytes. A single
235/// unit larger than `max` is hard-split at char boundaries — unless it is
236/// marked atomic (a genuine fence), which stays whole regardless of size.
237fn pack_units(text: &str, units: &[Unit], max: usize) -> Vec<core::ops::Range<usize>> {
238    let mut chunks: Vec<core::ops::Range<usize>> = Vec::new();
239    let mut open: Option<core::ops::Range<usize>> = None;
240    for unit in units {
241        if unit.range.len() > max {
242            flush(&mut chunks, &mut open);
243            append_oversized(text, unit, max, &mut chunks);
244        } else {
245            open = Some(merge_or_flush(&mut chunks, open, unit.range.clone(), max));
246        }
247    }
248    flush(&mut chunks, &mut open);
249    chunks
250}
251
252/// Extend the open chunk with `unit` if it fits, otherwise seal it and open a
253/// new chunk at `unit`; returns the chunk left open.
254fn merge_or_flush(
255    chunks: &mut Vec<core::ops::Range<usize>>,
256    open: Option<core::ops::Range<usize>>,
257    unit: core::ops::Range<usize>,
258    max: usize,
259) -> core::ops::Range<usize> {
260    match open {
261        Some(range) if unit.end - range.start <= max => range.start..unit.end,
262        Some(range) => {
263            chunks.push(range);
264            unit
265        }
266        None => unit,
267    }
268}
269
270/// Seal the open chunk, if any.
271fn flush(chunks: &mut Vec<core::ops::Range<usize>>, open: &mut Option<core::ops::Range<usize>>) {
272    if let Some(range) = open.take() {
273        chunks.push(range);
274    }
275}
276
277/// Append an oversized unit: an atomic unit (a genuine fence) stays whole,
278/// everything else hard-splits at char boundaries every `max` bytes — the
279/// unit's `atomic` flag decides, never its leading bytes (see [`Unit`]).
280fn append_oversized(
281    text: &str,
282    unit: &Unit,
283    max: usize,
284    chunks: &mut Vec<core::ops::Range<usize>>,
285) {
286    if unit.atomic {
287        chunks.push(unit.range.clone());
288        return;
289    }
290    let mut start = unit.range.start;
291    while start < unit.range.end {
292        let floored = char_floor(text, (start + max).min(unit.range.end));
293        // A ceiling smaller than the char at `start` cannot cut inside it:
294        // advance to the next char boundary instead of forcing a mid-char cut.
295        let end = if floored > start {
296            floored
297        } else {
298            char_ceil(text, start + 1).min(unit.range.end)
299        };
300        chunks.push(start..end);
301        start = end;
302    }
303}
304
305/// The largest char boundary at or below `at`.
306fn char_floor(text: &str, at: usize) -> usize {
307    let mut boundary = at.min(text.len());
308    while !text.is_char_boundary(boundary) {
309        boundary -= 1;
310    }
311    boundary
312}
313
314/// Materialize chunk ranges into [`TextChunk`]s, prepending the char-aligned
315/// overlap tail of the previous chunk when the policy asks for one.
316fn assemble(
317    text: &str,
318    ranges: &[core::ops::Range<usize>],
319    overlap_bytes: usize,
320) -> Vec<TextChunk> {
321    ranges
322        .iter()
323        .enumerate()
324        .map(|(index, range)| {
325            let mut chunk_text = String::new();
326            if overlap_bytes > 0 && index > 0 {
327                let overlap_start = char_ceil(text, range.start.saturating_sub(overlap_bytes));
328                chunk_text.push_str(&text[overlap_start..range.start]);
329            }
330            chunk_text.push_str(&text[range.clone()]);
331            TextChunk {
332                text: chunk_text,
333                byte_range: range.clone(),
334                index,
335            }
336        })
337        .collect()
338}
339
340/// The smallest char boundary at or above `at`.
341fn char_ceil(text: &str, at: usize) -> usize {
342    let mut boundary = at.min(text.len());
343    while !text.is_char_boundary(boundary) {
344        boundary += 1;
345    }
346    boundary
347}
348
349#[cfg(test)]
350#[path = "chunk_tests.rs"]
351mod tests;