Skip to main content

oxibrain_core/
chunking.rs

1//! Chunking + deterministic context prefix (§9.3, M8 §8.11).
2//!
3//! A chunk is a span of episode content, used for entity-dense retrieval and
4//! contextual retrieval. The chunk *text* is not stored — it is recovered
5//! from `episodes.content` via the byte offsets in `chunks.span_start/end`.
6//!
7//! The recursive split ladder (§9.3):
8//!   - Long input splits on `\n\n` (paragraph boundary).
9//!   - If a paragraph is still too long, split on `\n`.
10//!   - If a line is still too long, split on `. ` or `。` (sentence).
11//!   - If a sentence is still too long, split on ` ` (word).
12//!   - Last resort: hard cut on character count.
13//!
14//! The "empty separator" terminator is language-independent by construction —
15//! any script-aware splitter would betray P11.
16//!
17//! The deterministic context prefix is generated from projection fields, not
18//! from a model call. Every field is already known at projection time:
19//!   - `occurred_at` from `episodes.occurred_at`
20//!   - `source_kind` from `episodes.source_kind`
21//!   - mentions: entities that appear in this episode's statements
22//!   - community: the entity's community (if any)
23
24use oxibrain_ports::Timestamp;
25use serde::{Deserialize, Serialize};
26
27/// Recursive chunking parameters. Defaults sized for prose passages
28/// (≤ 4 KiB per chunk) — small enough to fit an embedding model input and
29/// large enough to keep the prefix-overhead ratio reasonable.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct ChunkPolicy {
32    pub max_chunk_bytes: usize,
33    pub min_chunk_bytes: usize,
34}
35
36impl Default for ChunkPolicy {
37    fn default() -> Self {
38        Self {
39            max_chunk_bytes: 4_096,
40            min_chunk_bytes: 64,
41        }
42    }
43}
44
45/// A single chunk produced by the recursive splitter. `start` and `end`
46/// are byte offsets into the source `content`.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct Chunk {
49    pub ordinal: u32,
50    pub span_start: usize,
51    pub span_end: usize,
52}
53
54/// Pure: split `content` into chunks under `policy`. No I/O, no time, no
55/// model. The output is a list of non-overlapping byte spans, ordered by
56/// position, that cover the input up to the policy's hard-cut tail.
57pub fn split_into_chunks(content: &str, policy: &ChunkPolicy) -> Vec<Chunk> {
58    if content.is_empty() {
59        return Vec::new();
60    }
61    let mut out: Vec<Chunk> = Vec::new();
62    let mut ordinal = 0u32;
63    split_recursive(
64        content.as_bytes(),
65        0,
66        content.len(),
67        &mut ordinal,
68        policy,
69        &mut out,
70    );
71    out
72}
73
74fn split_recursive(
75    bytes: &[u8],
76    offset: usize,
77    end: usize,
78    ordinal: &mut u32,
79    policy: &ChunkPolicy,
80    out: &mut Vec<Chunk>,
81) {
82    let len = end - offset;
83    if len <= policy.max_chunk_bytes {
84        push_chunk(out, ordinal, offset, end);
85        return;
86    }
87    let candidate = pick_separator(bytes, offset, end, policy.max_chunk_bytes);
88    if let Some((sep_byte, sep_len)) = candidate {
89        let mut start = offset;
90        let mut i = offset;
91        while i + sep_len <= end {
92            if &bytes[i..i + sep_len] == sep_byte {
93                if i - start >= policy.min_chunk_bytes {
94                    push_chunk(out, ordinal, start, i);
95                }
96                start = i + sep_len;
97                i += sep_len;
98            } else {
99                i += 1;
100            }
101        }
102        if end - start >= policy.min_chunk_bytes {
103            push_chunk(out, ordinal, start, end);
104        } else if let Some(last) = out.last_mut() {
105            last.span_end = end;
106        }
107    } else {
108        push_chunk(out, ordinal, offset, offset + policy.max_chunk_bytes);
109    }
110}
111
112fn push_chunk(out: &mut Vec<Chunk>, ordinal: &mut u32, start: usize, end: usize) {
113    out.push(Chunk {
114        ordinal: *ordinal,
115        span_start: start,
116        span_end: end,
117    });
118    *ordinal += 1;
119}
120
121/// Pick the largest separator whose first match produces a sub-chunk of
122/// acceptable size. Returns the separator bytes + length, or None when the
123/// range must be hard-cut.
124fn pick_separator(
125    bytes: &[u8],
126    offset: usize,
127    end: usize,
128    _max: usize,
129) -> Option<(&'static [u8], usize)> {
130    const SEPARATORS: &[(&[u8], &str)] = &[
131        (b"\n\n", "paragraph"),
132        (b"\n", "line"),
133        (b". ", "sentence-ascii"),
134        ("\u{3002}".as_bytes(), "sentence-cjk"),
135        (b" ", "word"),
136    ];
137    for (sep, _label) in SEPARATORS {
138        if find_in_range(bytes, sep, offset, end).is_some() {
139            return Some((sep, sep.len()));
140        }
141    }
142    None
143}
144
145fn find_in_range(bytes: &[u8], needle: &[u8], offset: usize, end: usize) -> Option<usize> {
146    if needle.is_empty() {
147        return None;
148    }
149    if end > bytes.len() {
150        return None;
151    }
152    let mut i = offset;
153    while i + needle.len() <= end {
154        if &bytes[i..i + needle.len()] == needle {
155            return Some(i);
156        }
157        i += 1;
158    }
159    None
160}
161
162/// Build a deterministic context prefix for a chunk. All fields come from
163/// projection rows; no model call is involved.
164///
165/// Format: `[<occurred_at> · <source> · mentions: <e1>, <e2> · community: <label>]`
166pub fn render_context_prefix(
167    occurred_at: Timestamp,
168    source_kind: &str,
169    mentions: &[String],
170    community_label: Option<&str>,
171) -> String {
172    let mut parts: Vec<String> = Vec::new();
173    parts.push(format!("[{}", short_ts(occurred_at)));
174    parts.push(format!("· {source_kind}"));
175    if !mentions.is_empty() {
176        let m = mentions
177            .iter()
178            .take(8)
179            .map(|s| s.as_str())
180            .collect::<Vec<_>>()
181            .join(", ");
182        parts.push(format!("· mentions: {m}"));
183    }
184    if let Some(label) = community_label {
185        parts.push(format!("· community: {label}"));
186    }
187    format!("{} ]", parts.join(" "))
188}
189
190/// Short, deterministic timestamp representation. Format: `YYYY-MM-DD`
191/// derived from millis-since-epoch. The full timestamp is in the prefix
192/// only as a glance — callers should use Timeline for exact queries.
193pub fn short_ts(t: Timestamp) -> String {
194    let ms = t.0;
195    if ms <= 0 {
196        return "1970-01-01".into();
197    }
198    let days = ms.div_euclid(86_400_000);
199    let (y, m, d) = days_to_ymd(days);
200    format!("{y:04}-{m:02}-{d:02}")
201}
202
203fn days_to_ymd(days_since_epoch: i64) -> (i32, u32, u32) {
204    let z = days_since_epoch + 719_468;
205    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
206    let doe = (z - era * 146_097) as u32;
207    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
208    let y = (yoe as i32) + (era as i32) * 400;
209    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
210    let mp = (5 * doy + 2) / 153;
211    let d = doy - (153 * mp + 2) / 5 + 1;
212    let m = if mp < 10 { mp + 3 } else { mp - 9 };
213    let y = if m <= 2 { y + 1 } else { y };
214    (y, m, d)
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn empty_content_produces_no_chunks() {
223        let chunks = split_into_chunks("", &ChunkPolicy::default());
224        assert!(chunks.is_empty());
225    }
226
227    #[test]
228    fn short_content_produces_single_chunk() {
229        let chunks = split_into_chunks("hello world", &ChunkPolicy::default());
230        assert_eq!(chunks.len(), 1);
231        assert_eq!(chunks[0].span_start, 0);
232        assert_eq!(chunks[0].span_end, 11);
233    }
234
235    #[test]
236    fn paragraph_split_when_oversized() {
237        let para1 = "x".repeat(3_000);
238        let para2 = "y".repeat(3_000);
239        let content = format!("{para1}\n\n{para2}");
240        let chunks = split_into_chunks(&content, &ChunkPolicy::default());
241        assert!(
242            chunks.len() >= 2,
243            "expected at least 2 chunks, got {}",
244            chunks.len()
245        );
246        assert_eq!(chunks.first().unwrap().span_start, 0);
247        assert_eq!(chunks.last().unwrap().span_end, content.len());
248    }
249
250    #[test]
251    fn cjk_sentence_split() {
252        let s = "中".repeat(2_000);
253        let content: String = s
254            .chars()
255            .enumerate()
256            .map(|(i, c)| {
257                if i % 200 == 199 {
258                    format!("{c}\u{3002}")
259                } else {
260                    c.to_string()
261                }
262            })
263            .collect();
264        let chunks = split_into_chunks(&content, &ChunkPolicy::default());
265        assert!(
266            chunks.len() >= 2,
267            "expected at least 2 chunks, got {}",
268            chunks.len()
269        );
270    }
271
272    #[test]
273    fn prefix_format_is_deterministic() {
274        let p1 = render_context_prefix(
275            Timestamp(1_700_000_000_000),
276            "Note: meeting.md",
277            &["Alice(Person)".to_string(), "ProjectX(Project)".to_string()],
278            Some("infra"),
279        );
280        let p2 = render_context_prefix(
281            Timestamp(1_700_000_000_000),
282            "Note: meeting.md",
283            &["Alice(Person)".to_string(), "ProjectX(Project)".to_string()],
284            Some("infra"),
285        );
286        assert_eq!(p1, p2);
287        assert!(p1.contains("Alice(Person)"));
288        assert!(p1.contains("ProjectX(Project)"));
289        assert!(p1.contains("infra"));
290    }
291
292    #[test]
293    fn prefix_short_ts_format() {
294        let s = short_ts(Timestamp(1_700_000_000_000));
295        assert_eq!(s.len(), 10);
296        assert!(s.starts_with("20"));
297    }
298}