Skip to main content

monoloop_interpreter/
sentence.rs

1//! Deterministic sentence segmentation (versioned rules).
2//!
3//! Prefers waiting over premature emission. Special-cases ordered list markers
4//! (`1.` `2.`) so they stay attached to the following item text.
5
6/// Version label for the segmenter (recorded in interpretation diagnostics).
7pub const SENTENCE_SEGMENTER_VERSION: &str = "v2";
8
9/// One completed sentence from the segmenter, with buffer consumption.
10#[derive(Clone, Debug, PartialEq, Eq)]
11pub struct CompletedSentence {
12    /// Sentence text (trimmed trailing whitespace).
13    pub content: String,
14    /// Bytes of the content region (through terminator, excluding following
15    /// whitespace). Used for dialect source-time attribution.
16    pub content_bytes: usize,
17    /// Bytes removed from the assembly buffer for this sentence
18    /// (content region + trailing whitespace after the terminator).
19    pub bytes_consumed: usize,
20}
21
22/// Deterministic sentence boundary finder.
23///
24/// Prefers waiting over premature emission. Does not emit incomplete fragments.
25#[derive(Clone, Debug, Default)]
26pub struct SentenceSegmenter {
27    buf: String,
28}
29
30impl SentenceSegmenter {
31    /// Create an empty segmenter.
32    pub fn new() -> Self {
33        Self::default()
34    }
35
36    /// Current assembly buffer length in bytes.
37    pub fn buffered_bytes(&self) -> usize {
38        self.buf.len()
39    }
40
41    /// Push text and return newly completed sentences (in order).
42    ///
43    /// Incomplete remainder stays buffered.
44    pub fn push(&mut self, text: &str) -> Vec<CompletedSentence> {
45        self.buf.push_str(text);
46        self.drain_complete(false)
47    }
48
49    /// Seal remaining buffer at clean semantic completion (may lack terminal punctuation).
50    pub fn seal_at_clean_end(&mut self) -> Vec<CompletedSentence> {
51        self.drain_complete(true)
52    }
53
54    /// Discard buffer at abrupt end; return unresolved content without promoting it.
55    pub fn take_unresolved(&mut self) -> String {
56        std::mem::take(&mut self.buf)
57    }
58
59    fn drain_complete(&mut self, seal_remainder: bool) -> Vec<CompletedSentence> {
60        let mut out = Vec::new();
61        while let Some(end) = find_sentence_end(&self.buf) {
62            let raw = self.buf[..end].to_string();
63            let content = raw.trim_end().to_string();
64            // Content region ends at last non-whitespace of `raw` (usually `end`
65            // when the terminator is non-whitespace).
66            let content_bytes = content.len();
67            // advance past end and following whitespace (but keep newlines as
68            // paragraph hints only by dropping them from the next sentence start)
69            let mut consume = end;
70            while consume < self.buf.len() && self.buf.as_bytes()[consume].is_ascii_whitespace() {
71                consume += 1;
72            }
73            self.buf = self.buf[consume..].to_string();
74            if !content.is_empty() {
75                out.push(CompletedSentence {
76                    content,
77                    content_bytes,
78                    bytes_consumed: consume,
79                });
80            }
81        }
82        if seal_remainder && !self.buf.trim().is_empty() {
83            let bytes_consumed = self.buf.len();
84            let content = self.buf.trim().to_string();
85            let content_bytes = content.len();
86            self.buf.clear();
87            if !content.is_empty() {
88                out.push(CompletedSentence {
89                    content,
90                    content_bytes,
91                    bytes_consumed,
92                });
93            }
94        }
95        out
96    }
97}
98
99/// Find exclusive end index of the first complete sentence, if any.
100fn find_sentence_end(s: &str) -> Option<usize> {
101    let bytes = s.as_bytes();
102    let mut i = 0;
103    let mut in_code_span = false;
104    let mut paren = 0i32;
105    let mut bracket = 0i32;
106    let mut brace = 0i32;
107
108    while i < bytes.len() {
109        let c = bytes[i] as char;
110
111        if c == '`' {
112            in_code_span = !in_code_span;
113            i += 1;
114            continue;
115        }
116        if in_code_span {
117            i += 1;
118            continue;
119        }
120
121        match c {
122            '(' => paren += 1,
123            ')' => paren = (paren - 1).max(0),
124            '[' => bracket += 1,
125            ']' => bracket = (bracket - 1).max(0),
126            '{' => brace += 1,
127            '}' => brace = (brace - 1).max(0),
128            '.' | '!' | '?' => {
129                if paren > 0 || bracket > 0 || brace > 0 {
130                    i += 1;
131                    continue;
132                }
133                // Ordered list markers: "1." "12." at line/token start — not ends.
134                if c == '.' && looks_like_ordered_list_marker(s, i) {
135                    i += 1;
136                    continue;
137                }
138                // abbreviations / decimals / versions / URLs
139                if c == '.' && looks_like_abbreviation_or_decimal(s, i) {
140                    i += 1;
141                    continue;
142                }
143
144                let next = bytes.get(i + 1).copied();
145                match next {
146                    // Terminator at buffer end: wait for more (or seal_at_clean_end).
147                    None => {}
148                    Some(b) if b.is_ascii_whitespace() => {
149                        return Some(i + 1);
150                    }
151                    Some(b) if b == b'"' || b == b'\'' || b == b')' || b == b']' => {
152                        return Some(i + 1);
153                    }
154                    // Missing space between sentences: "create.CRUD" → split after '.'
155                    Some(b)
156                        if c == '.'
157                            && b.is_ascii_uppercase()
158                            && prev_is_sentence_letter(bytes, i) =>
159                    {
160                        return Some(i + 1);
161                    }
162                    // file.ext / version-like: stay open
163                    Some(b) if c == '.' && b.is_ascii_alphanumeric() => {}
164                    _ => {}
165                }
166            }
167            // Hard break: double newline closes a paragraph-like unit when stable.
168            // Do not seal if the only content so far is an ordered-list marker
169            // ("1." / "2.") — keep it for the following item text.
170            '\n' if i + 1 < bytes.len() && bytes[i + 1] == b'\n' => {
171                let before = s[..i].trim();
172                if !before.is_empty() && !is_only_list_marker(before) {
173                    return Some(i);
174                }
175            }
176            _ => {}
177        }
178        i += 1;
179    }
180    None
181}
182
183fn prev_is_sentence_letter(bytes: &[u8], dot_idx: usize) -> bool {
184    if dot_idx == 0 {
185        return false;
186    }
187    let p = bytes[dot_idx - 1];
188    p.is_ascii_lowercase() || p.is_ascii_uppercase()
189}
190
191fn is_only_list_marker(s: &str) -> bool {
192    let t = s.trim();
193    let b = t.as_bytes();
194    if b.is_empty() || b[b.len() - 1] != b'.' {
195        return false;
196    }
197    looks_like_ordered_list_marker(t, t.len() - 1)
198}
199
200/// `1.` / `12.` at the start of a line or after whitespace — keep with following item.
201fn looks_like_ordered_list_marker(s: &str, dot_idx: usize) -> bool {
202    let bytes = s.as_bytes();
203    let mut start = dot_idx;
204    while start > 0 && bytes[start - 1].is_ascii_digit() {
205        start -= 1;
206    }
207    if start == dot_idx {
208        return false;
209    }
210    let digit_len = dot_idx - start;
211    if !(1..=3).contains(&digit_len) {
212        return false;
213    }
214    // Must be at buffer start or after whitespace/newline.
215    if start > 0 && !bytes[start - 1].is_ascii_whitespace() {
216        return false;
217    }
218    // After the marker: whitespace, end of buffer, or markdown emphasis/start of item.
219    match bytes.get(dot_idx + 1) {
220        None => true,
221        Some(b) if b.is_ascii_whitespace() => true,
222        Some(b) if *b == b'*' || *b == b'_' || *b == b'`' || *b == b'[' => true,
223        _ => false,
224    }
225}
226
227fn looks_like_abbreviation_or_decimal(s: &str, dot_idx: usize) -> bool {
228    let bytes = s.as_bytes();
229    let prev = bytes.get(dot_idx.wrapping_sub(1)).copied();
230    let next = bytes.get(dot_idx + 1).copied();
231    if prev.is_some_and(|b| b.is_ascii_digit()) && next.is_some_and(|b| b.is_ascii_digit()) {
232        return true;
233    }
234    // Do not treat pure digit runs as abbreviations here — list markers handled above.
235    if prev.is_some_and(|b| b.is_ascii_alphabetic()) {
236        let mut start = dot_idx;
237        while start > 0 && bytes[start - 1].is_ascii_alphabetic() {
238            start -= 1;
239        }
240        let len = dot_idx - start;
241        if (1..=3).contains(&len) {
242            if len == 1 {
243                return true;
244            }
245            let word = &s[start..dot_idx];
246            const ABBREVS: &[&str] = &[
247                "Mr", "Mrs", "Ms", "Dr", "Prof", "Sr", "Jr", "vs", "etc", "Inc", "Ltd", "St",
248            ];
249            if ABBREVS.iter().any(|a| a.eq_ignore_ascii_case(word)) {
250                return true;
251            }
252        }
253    }
254    let mut t = dot_idx;
255    while t > 0 && !bytes[t - 1].is_ascii_whitespace() {
256        t -= 1;
257    }
258    let end = (dot_idx + 1).min(s.len());
259    let token = &s[t..end];
260    if token.contains("://") || token.contains("www.") {
261        return true;
262    }
263    if token.contains('/') {
264        return true;
265    }
266    false
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    fn contents(done: &[CompletedSentence]) -> Vec<String> {
274        done.iter().map(|c| c.content.clone()).collect()
275    }
276
277    #[test]
278    fn splits_on_period_space() {
279        let mut s = SentenceSegmenter::new();
280        let a = s.push("Hello world. ");
281        assert_eq!(contents(&a), vec!["Hello world.".to_string()]);
282        let b = s.push("Next one! ");
283        assert_eq!(contents(&b), vec!["Next one!".to_string()]);
284    }
285
286    #[test]
287    fn does_not_emit_partial() {
288        let mut s = SentenceSegmenter::new();
289        assert!(s.push("The build uses std::").is_empty());
290        let done = s.push("sync::Arc to share the handle. ");
291        assert_eq!(
292            contents(&done),
293            vec!["The build uses std::sync::Arc to share the handle.".to_string()]
294        );
295    }
296
297    #[test]
298    fn decimal_not_boundary() {
299        let mut s = SentenceSegmenter::new();
300        assert_eq!(s.push("Version 1.2.3 is ready. ").len(), 1);
301    }
302
303    #[test]
304    fn list_markers_stay_with_item() {
305        let mut s = SentenceSegmenter::new();
306        // classic broken case: "1.\n\n**CREATE** — foo."
307        assert!(s.push("1.\n\n").is_empty());
308        let done = s.push("**CREATE** — Wrote the file with `hello monoloop crud`.\n\n");
309        assert_eq!(done.len(), 1, "{done:?}");
310        assert!(
311            done[0].content.starts_with("1."),
312            "list marker must stay attached: {}",
313            done[0].content
314        );
315        assert!(done[0].content.contains("**CREATE**"));
316    }
317
318    #[test]
319    fn list_sequence_does_not_emit_bare_numbers() {
320        let mut s = SentenceSegmenter::new();
321        let text = "1. **CREATE** — Wrote the file.\n\n2. **READ** — File contained x.\n\n3. **UPDATE** — Done.\n\n";
322        let done = s.push(text);
323        assert_eq!(done.len(), 3, "{done:?}");
324        assert!(done.iter().all(|x| !x
325            .content
326            .trim()
327            .chars()
328            .all(|c| c.is_ascii_digit() || c == '.')));
329        assert!(done[0].content.contains("CREATE"));
330        assert!(done[1].content.contains("READ"));
331        assert!(done[2].content.contains("UPDATE"));
332    }
333
334    #[test]
335    fn missing_space_after_period_splits() {
336        let mut s = SentenceSegmenter::new();
337        // Grok sometimes concatenates chunks without a space after '.'
338        assert!(s.push("starting with create.").is_empty());
339        let done = s.push("CRUD exercise on the file only:\n\n");
340        assert_eq!(done.len(), 2, "{done:?}");
341        assert_eq!(done[0].content, "starting with create.");
342        assert!(done[1].content.starts_with("CRUD exercise"));
343    }
344
345    /// Exact token stream observed from live Grok CRUD (`target/live_grok_crud.raw.txt`).
346    #[test]
347    fn live_grok_crud_token_stream_assembles_cleanly() {
348        let chunks = [
349            "I'll",
350            " run",
351            " the",
352            " five",
353            " CRUD",
354            " steps",
355            " on",
356            " that",
357            " one",
358            " file",
359            " only",
360            ",",
361            " starting",
362            " with",
363            " create",
364            ".",
365            "CRUD",
366            " exercise",
367            " on",
368            " `",
369            "mon",
370            "olo",
371            "op",
372            "_",
373            "live",
374            "_",
375            "crud",
376            "_",
377            "test",
378            ".txt",
379            "`",
380            " only",
381            ":\n\n",
382            "1",
383            ".",
384            " **",
385            "CREATE",
386            "**",
387            " —",
388            " Wrote",
389            " the",
390            " file",
391            " with",
392            " `",
393            "hello",
394            " mon",
395            "olo",
396            "op",
397            " crud",
398            "`.",
399            "\n",
400            "2",
401            ".",
402            " **",
403            "READ",
404            "**",
405            " —",
406            " File",
407            " contained",
408            " `",
409            "hello",
410            " mon",
411            "olo",
412            "op",
413            " crud",
414            "`.",
415            "\n",
416            "3",
417            ".",
418            " **",
419            "UPDATE",
420            "**",
421            " —",
422            " Over",
423            "wrote",
424            " it",
425            " with",
426            " `",
427            "hello",
428            " mon",
429            "olo",
430            "op",
431            " crud",
432            " UPD",
433            "ATED",
434            "`.",
435            "\n",
436            "4",
437            ".",
438            " **",
439            "READ",
440            "**",
441            " —",
442            " File",
443            " contained",
444            " `",
445            "hello",
446            " mon",
447            "olo",
448            "op",
449            " crud",
450            " UPD",
451            "ATED",
452            "`.",
453            "\n",
454            "5",
455            ".",
456            " **",
457            "DELETE",
458            "**",
459            " —",
460            " Removed",
461            " the",
462            " file",
463            " (`",
464            "rm",
465            "`",
466            " exited",
467            " ",
468            "0",
469            ").",
470            "\n\n",
471            "No",
472            " other",
473            " files",
474            " were",
475            " touched",
476            ".",
477        ];
478        let mut s = SentenceSegmenter::new();
479        let mut done = Vec::new();
480        for c in chunks {
481            done.extend(s.push(c));
482        }
483        done.extend(s.seal_at_clean_end());
484        let texts = contents(&done);
485
486        // No glued create.CRUD; no bare list markers.
487        assert!(
488            texts.iter().all(|x| !x.contains("create.CRUD")),
489            "must split missing-space: {texts:?}"
490        );
491        assert!(
492            texts
493                .iter()
494                .all(|x| !is_only_list_marker(x) && x.trim() != "1." && x.trim() != "2."),
495            "bare list markers leaked: {texts:?}"
496        );
497        assert!(
498            texts.iter().any(|x| x.ends_with("create.")),
499            "expected sentence ending create.: {texts:?}"
500        );
501        assert!(
502            texts.iter().any(|x| x.starts_with("CRUD exercise")),
503            "expected CRUD exercise sentence: {texts:?}"
504        );
505        assert_eq!(
506            texts
507                .iter()
508                .filter(|x| x.contains("**CREATE**")
509                    || x.contains("**READ**")
510                    || x.contains("**UPDATE**")
511                    || x.contains("**DELETE**"))
512                .count(),
513            5,
514            "five step sentences: {texts:?}"
515        );
516        for step in texts.iter().filter(|x| {
517            x.contains("**CREATE**")
518                || x.contains("**READ**")
519                || x.contains("**UPDATE**")
520                || x.contains("**DELETE**")
521        }) {
522            assert!(
523                step.chars().next().is_some_and(|c| c.is_ascii_digit()),
524                "list marker must attach: {step}"
525            );
526        }
527        assert!(
528            texts
529                .iter()
530                .any(|x| x.contains("No other files were touched")),
531            "{texts:?}"
532        );
533    }
534
535    #[test]
536    fn seal_done_without_punct() {
537        let mut s = SentenceSegmenter::new();
538        s.push("Done");
539        let sealed = s.seal_at_clean_end();
540        assert_eq!(contents(&sealed), vec!["Done".to_string()]);
541    }
542
543    #[test]
544    fn abrupt_does_not_promote() {
545        let mut s = SentenceSegmenter::new();
546        s.push("The implementation will");
547        let unresolved = s.take_unresolved();
548        assert_eq!(unresolved, "The implementation will");
549    }
550}