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.
112///
113/// `pub(crate)` (V2b-2): [`super::segment::segment_transcript`] reuses this
114/// same fence/plain partition to carve atomic code segments out of a
115/// transcript turn before running its own log-run detection over the
116/// remaining plain spans — one fence-detection implementation, never a
117/// second regex-free scanner copy-pasted for the transcript segmenter.
118pub(crate) enum Segment {
119    /// A triple-backtick-fenced block, atomic.
120    Fence(core::ops::Range<usize>),
121    /// Plain text between fences.
122    Plain(core::ops::Range<usize>),
123}
124
125/// Walk the text line by line, separating triple-backtick-fenced blocks (atomic) from the
126/// plain text around them. An unclosed fence runs to the end of the text.
127///
128/// `pub(crate)`, see [`Segment`]'s doc for why.
129pub(crate) fn fence_segments(text: &str) -> Vec<Segment> {
130    let mut segments = Vec::new();
131    let mut cursor = 0_usize;
132    let mut fence_start: Option<usize> = None;
133    let mut line_start = 0_usize;
134    for line in text.split_inclusive('\n') {
135        let opens_or_closes = line.trim_start().starts_with("```");
136        let line_end = line_start + line.len();
137        match (fence_start, opens_or_closes) {
138            (None, true) => {
139                if line_start > cursor {
140                    segments.push(Segment::Plain(cursor..line_start));
141                }
142                fence_start = Some(line_start);
143            }
144            (Some(start), true) => {
145                segments.push(Segment::Fence(start..line_end));
146                fence_start = None;
147                cursor = line_end;
148            }
149            _ => {}
150        }
151        line_start = line_end;
152    }
153    push_tail(&mut segments, fence_start, cursor, text.len());
154    segments
155}
156
157/// Close the segment walk: an unclosed fence (or the trailing plain text)
158/// runs to the end of the input.
159fn push_tail(segments: &mut Vec<Segment>, fence_start: Option<usize>, cursor: usize, end: usize) {
160    match fence_start {
161        Some(start) => segments.push(Segment::Fence(start..end)),
162        None if cursor < end => segments.push(Segment::Plain(cursor..end)),
163        None => {}
164    }
165}
166
167/// Cut one plain-text range at the preferred boundary, appending the pieces
168/// — always non-atomic: nothing derived from a `Plain` segment is ever a
169/// real fence, however it happens to start.
170fn split_plain(
171    text: &str,
172    range: core::ops::Range<usize>,
173    boundary: ChunkBoundary,
174    out: &mut Vec<Unit>,
175) {
176    let slice = &text[range.clone()];
177    let mut piece_start = 0_usize;
178    for cut in boundary_cuts(slice, boundary) {
179        out.push(Unit {
180            range: range.start + piece_start..range.start + cut,
181            atomic: false,
182        });
183        piece_start = cut;
184    }
185    if piece_start < slice.len() {
186        out.push(Unit {
187            range: range.start + piece_start..range.end,
188            atomic: false,
189        });
190    }
191}
192
193/// The byte offsets (relative to `slice`) *after* which a boundary cut is
194/// allowed. Offsets are strictly increasing and land on char boundaries.
195fn boundary_cuts(slice: &str, boundary: ChunkBoundary) -> Vec<usize> {
196    match boundary {
197        ChunkBoundary::Paragraph => paragraph_cuts(slice),
198        ChunkBoundary::Sentence => sentence_cuts(slice),
199        ChunkBoundary::Fixed => Vec::new(),
200    }
201}
202
203/// Cut points after each blank-line run (`\n\n…`).
204fn paragraph_cuts(slice: &str) -> Vec<usize> {
205    let mut cuts = Vec::new();
206    let bytes = slice.as_bytes();
207    let mut i = 0_usize;
208    while let Some(found) = find_from(bytes, i, b"\n\n") {
209        let mut end = found + 2;
210        while bytes.get(end) == Some(&b'\n') {
211            end += 1;
212        }
213        cuts.push(end);
214        i = end;
215    }
216    cuts
217}
218
219/// Cut points after each sentence ender followed by whitespace.
220fn sentence_cuts(slice: &str) -> Vec<usize> {
221    let mut cuts = Vec::new();
222    let mut previous: Option<char> = None;
223    for (offset, ch) in slice.char_indices() {
224        let after_ender = matches!(previous, Some('.' | '!' | '?'));
225        if after_ender && ch.is_whitespace() {
226            cuts.push(offset + ch.len_utf8());
227        }
228        previous = Some(ch);
229    }
230    cuts
231}
232
233/// Find `needle` in `haystack` at or after `from`.
234fn find_from(haystack: &[u8], from: usize, needle: &[u8]) -> Option<usize> {
235    haystack
236        .get(from..)?
237        .windows(needle.len())
238        .position(|window| window == needle)
239        .map(|position| from + position)
240}
241
242/// Greedily merge units into chunk ranges of at most `max` bytes. A single
243/// unit larger than `max` is hard-split at char boundaries — unless it is
244/// marked atomic (a genuine fence), which stays whole regardless of size.
245fn pack_units(text: &str, units: &[Unit], max: usize) -> Vec<core::ops::Range<usize>> {
246    let mut chunks: Vec<core::ops::Range<usize>> = Vec::new();
247    let mut open: Option<core::ops::Range<usize>> = None;
248    for unit in units {
249        if unit.range.len() > max {
250            flush(&mut chunks, &mut open);
251            append_oversized(text, unit, max, &mut chunks);
252        } else {
253            open = Some(merge_or_flush(&mut chunks, open, unit.range.clone(), max));
254        }
255    }
256    flush(&mut chunks, &mut open);
257    chunks
258}
259
260/// Extend the open chunk with `unit` if it fits, otherwise seal it and open a
261/// new chunk at `unit`; returns the chunk left open.
262fn merge_or_flush(
263    chunks: &mut Vec<core::ops::Range<usize>>,
264    open: Option<core::ops::Range<usize>>,
265    unit: core::ops::Range<usize>,
266    max: usize,
267) -> core::ops::Range<usize> {
268    match open {
269        Some(range) if unit.end - range.start <= max => range.start..unit.end,
270        Some(range) => {
271            chunks.push(range);
272            unit
273        }
274        None => unit,
275    }
276}
277
278/// Seal the open chunk, if any.
279fn flush(chunks: &mut Vec<core::ops::Range<usize>>, open: &mut Option<core::ops::Range<usize>>) {
280    if let Some(range) = open.take() {
281        chunks.push(range);
282    }
283}
284
285/// Append an oversized unit: an atomic unit (a genuine fence) stays whole,
286/// everything else hard-splits at char boundaries every `max` bytes — the
287/// unit's `atomic` flag decides, never its leading bytes (see [`Unit`]).
288fn append_oversized(
289    text: &str,
290    unit: &Unit,
291    max: usize,
292    chunks: &mut Vec<core::ops::Range<usize>>,
293) {
294    if unit.atomic {
295        chunks.push(unit.range.clone());
296        return;
297    }
298    let mut start = unit.range.start;
299    while start < unit.range.end {
300        let floored = char_floor(text, (start + max).min(unit.range.end));
301        // A ceiling smaller than the char at `start` cannot cut inside it:
302        // advance to the next char boundary instead of forcing a mid-char cut.
303        let end = if floored > start {
304            floored
305        } else {
306            char_ceil(text, start + 1).min(unit.range.end)
307        };
308        chunks.push(start..end);
309        start = end;
310    }
311}
312
313/// The largest char boundary at or below `at`.
314fn char_floor(text: &str, at: usize) -> usize {
315    let mut boundary = at.min(text.len());
316    while !text.is_char_boundary(boundary) {
317        boundary -= 1;
318    }
319    boundary
320}
321
322/// Materialize chunk ranges into [`TextChunk`]s, prepending the char-aligned
323/// overlap tail of the previous chunk when the policy asks for one.
324fn assemble(
325    text: &str,
326    ranges: &[core::ops::Range<usize>],
327    overlap_bytes: usize,
328) -> Vec<TextChunk> {
329    ranges
330        .iter()
331        .enumerate()
332        .map(|(index, range)| {
333            let mut chunk_text = String::new();
334            if overlap_bytes > 0 && index > 0 {
335                let overlap_start = char_ceil(text, range.start.saturating_sub(overlap_bytes));
336                chunk_text.push_str(&text[overlap_start..range.start]);
337            }
338            chunk_text.push_str(&text[range.clone()]);
339            TextChunk {
340                text: chunk_text,
341                byte_range: range.clone(),
342                index,
343            }
344        })
345        .collect()
346}
347
348/// The smallest char boundary at or above `at`.
349fn char_ceil(text: &str, at: usize) -> usize {
350    let mut boundary = at.min(text.len());
351    while !text.is_char_boundary(boundary) {
352        boundary += 1;
353    }
354    boundary
355}
356
357#[cfg(test)]
358#[path = "chunk_tests.rs"]
359mod tests;