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' => {
171                if i + 1 < bytes.len() && bytes[i + 1] == b'\n' {
172                    let before = s[..i].trim();
173                    if !before.is_empty() && !is_only_list_marker(before) {
174                        return Some(i);
175                    }
176                }
177            }
178            _ => {}
179        }
180        i += 1;
181    }
182    None
183}
184
185fn prev_is_sentence_letter(bytes: &[u8], dot_idx: usize) -> bool {
186    if dot_idx == 0 {
187        return false;
188    }
189    let p = bytes[dot_idx - 1];
190    p.is_ascii_lowercase() || p.is_ascii_uppercase()
191}
192
193fn is_only_list_marker(s: &str) -> bool {
194    let t = s.trim();
195    let b = t.as_bytes();
196    if b.is_empty() || b[b.len() - 1] != b'.' {
197        return false;
198    }
199    looks_like_ordered_list_marker(t, t.len() - 1)
200}
201
202/// `1.` / `12.` at the start of a line or after whitespace — keep with following item.
203fn looks_like_ordered_list_marker(s: &str, dot_idx: usize) -> bool {
204    let bytes = s.as_bytes();
205    let mut start = dot_idx;
206    while start > 0 && bytes[start - 1].is_ascii_digit() {
207        start -= 1;
208    }
209    if start == dot_idx {
210        return false;
211    }
212    let digit_len = dot_idx - start;
213    if !(1..=3).contains(&digit_len) {
214        return false;
215    }
216    // Must be at buffer start or after whitespace/newline.
217    if start > 0 && !bytes[start - 1].is_ascii_whitespace() {
218        return false;
219    }
220    // After the marker: whitespace, end of buffer, or markdown emphasis/start of item.
221    match bytes.get(dot_idx + 1) {
222        None => true,
223        Some(b) if b.is_ascii_whitespace() => true,
224        Some(b) if *b == b'*' || *b == b'_' || *b == b'`' || *b == b'[' => true,
225        _ => false,
226    }
227}
228
229fn looks_like_abbreviation_or_decimal(s: &str, dot_idx: usize) -> bool {
230    let bytes = s.as_bytes();
231    let prev = bytes.get(dot_idx.wrapping_sub(1)).copied();
232    let next = bytes.get(dot_idx + 1).copied();
233    if prev.is_some_and(|b| b.is_ascii_digit()) && next.is_some_and(|b| b.is_ascii_digit()) {
234        return true;
235    }
236    // Do not treat pure digit runs as abbreviations here — list markers handled above.
237    if prev.is_some_and(|b| b.is_ascii_alphabetic()) {
238        let mut start = dot_idx;
239        while start > 0 && bytes[start - 1].is_ascii_alphabetic() {
240            start -= 1;
241        }
242        let len = dot_idx - start;
243        if (1..=3).contains(&len) {
244            if len == 1 {
245                return true;
246            }
247            let word = &s[start..dot_idx];
248            const ABBREVS: &[&str] = &[
249                "Mr", "Mrs", "Ms", "Dr", "Prof", "Sr", "Jr", "vs", "etc", "Inc", "Ltd", "St",
250            ];
251            if ABBREVS.iter().any(|a| a.eq_ignore_ascii_case(word)) {
252                return true;
253            }
254        }
255    }
256    let mut t = dot_idx;
257    while t > 0 && !bytes[t - 1].is_ascii_whitespace() {
258        t -= 1;
259    }
260    let end = (dot_idx + 1).min(s.len());
261    let token = &s[t..end];
262    if token.contains("://") || token.contains("www.") {
263        return true;
264    }
265    if token.contains('/') {
266        return true;
267    }
268    false
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    fn contents(done: &[CompletedSentence]) -> Vec<String> {
276        done.iter().map(|c| c.content.clone()).collect()
277    }
278
279    #[test]
280    fn splits_on_period_space() {
281        let mut s = SentenceSegmenter::new();
282        let a = s.push("Hello world. ");
283        assert_eq!(contents(&a), vec!["Hello world.".to_string()]);
284        let b = s.push("Next one! ");
285        assert_eq!(contents(&b), vec!["Next one!".to_string()]);
286    }
287
288    #[test]
289    fn does_not_emit_partial() {
290        let mut s = SentenceSegmenter::new();
291        assert!(s.push("The build uses std::").is_empty());
292        let done = s.push("sync::Arc to share the handle. ");
293        assert_eq!(
294            contents(&done),
295            vec!["The build uses std::sync::Arc to share the handle.".to_string()]
296        );
297    }
298
299    #[test]
300    fn decimal_not_boundary() {
301        let mut s = SentenceSegmenter::new();
302        assert_eq!(s.push("Version 1.2.3 is ready. ").len(), 1);
303    }
304
305    #[test]
306    fn list_markers_stay_with_item() {
307        let mut s = SentenceSegmenter::new();
308        // classic broken case: "1.\n\n**CREATE** — foo."
309        assert!(s.push("1.\n\n").is_empty());
310        let done = s.push("**CREATE** — Wrote the file with `hello monoloop crud`.\n\n");
311        assert_eq!(done.len(), 1, "{done:?}");
312        assert!(
313            done[0].content.starts_with("1."),
314            "list marker must stay attached: {}",
315            done[0].content
316        );
317        assert!(done[0].content.contains("**CREATE**"));
318    }
319
320    #[test]
321    fn list_sequence_does_not_emit_bare_numbers() {
322        let mut s = SentenceSegmenter::new();
323        let text = "1. **CREATE** — Wrote the file.\n\n2. **READ** — File contained x.\n\n3. **UPDATE** — Done.\n\n";
324        let done = s.push(text);
325        assert_eq!(done.len(), 3, "{done:?}");
326        assert!(done.iter().all(|x| !x
327            .content
328            .trim()
329            .chars()
330            .all(|c| c.is_ascii_digit() || c == '.')));
331        assert!(done[0].content.contains("CREATE"));
332        assert!(done[1].content.contains("READ"));
333        assert!(done[2].content.contains("UPDATE"));
334    }
335
336    #[test]
337    fn missing_space_after_period_splits() {
338        let mut s = SentenceSegmenter::new();
339        // Grok sometimes concatenates chunks without a space after '.'
340        assert!(s.push("starting with create.").is_empty());
341        let done = s.push("CRUD exercise on the file only:\n\n");
342        assert_eq!(done.len(), 2, "{done:?}");
343        assert_eq!(done[0].content, "starting with create.");
344        assert!(done[1].content.starts_with("CRUD exercise"));
345    }
346
347    /// Exact token stream observed from live Grok CRUD (`target/live_grok_crud.raw.txt`).
348    #[test]
349    fn live_grok_crud_token_stream_assembles_cleanly() {
350        let chunks = [
351            "I'll",
352            " run",
353            " the",
354            " five",
355            " CRUD",
356            " steps",
357            " on",
358            " that",
359            " one",
360            " file",
361            " only",
362            ",",
363            " starting",
364            " with",
365            " create",
366            ".",
367            "CRUD",
368            " exercise",
369            " on",
370            " `",
371            "mon",
372            "olo",
373            "op",
374            "_",
375            "live",
376            "_",
377            "crud",
378            "_",
379            "test",
380            ".txt",
381            "`",
382            " only",
383            ":\n\n",
384            "1",
385            ".",
386            " **",
387            "CREATE",
388            "**",
389            " —",
390            " Wrote",
391            " the",
392            " file",
393            " with",
394            " `",
395            "hello",
396            " mon",
397            "olo",
398            "op",
399            " crud",
400            "`.",
401            "\n",
402            "2",
403            ".",
404            " **",
405            "READ",
406            "**",
407            " —",
408            " File",
409            " contained",
410            " `",
411            "hello",
412            " mon",
413            "olo",
414            "op",
415            " crud",
416            "`.",
417            "\n",
418            "3",
419            ".",
420            " **",
421            "UPDATE",
422            "**",
423            " —",
424            " Over",
425            "wrote",
426            " it",
427            " with",
428            " `",
429            "hello",
430            " mon",
431            "olo",
432            "op",
433            " crud",
434            " UPD",
435            "ATED",
436            "`.",
437            "\n",
438            "4",
439            ".",
440            " **",
441            "READ",
442            "**",
443            " —",
444            " File",
445            " contained",
446            " `",
447            "hello",
448            " mon",
449            "olo",
450            "op",
451            " crud",
452            " UPD",
453            "ATED",
454            "`.",
455            "\n",
456            "5",
457            ".",
458            " **",
459            "DELETE",
460            "**",
461            " —",
462            " Removed",
463            " the",
464            " file",
465            " (`",
466            "rm",
467            "`",
468            " exited",
469            " ",
470            "0",
471            ").",
472            "\n\n",
473            "No",
474            " other",
475            " files",
476            " were",
477            " touched",
478            ".",
479        ];
480        let mut s = SentenceSegmenter::new();
481        let mut done = Vec::new();
482        for c in chunks {
483            done.extend(s.push(c));
484        }
485        done.extend(s.seal_at_clean_end());
486        let texts = contents(&done);
487
488        // No glued create.CRUD; no bare list markers.
489        assert!(
490            texts.iter().all(|x| !x.contains("create.CRUD")),
491            "must split missing-space: {texts:?}"
492        );
493        assert!(
494            texts
495                .iter()
496                .all(|x| !is_only_list_marker(x) && x.trim() != "1." && x.trim() != "2."),
497            "bare list markers leaked: {texts:?}"
498        );
499        assert!(
500            texts.iter().any(|x| x.ends_with("create.")),
501            "expected sentence ending create.: {texts:?}"
502        );
503        assert!(
504            texts.iter().any(|x| x.starts_with("CRUD exercise")),
505            "expected CRUD exercise sentence: {texts:?}"
506        );
507        assert_eq!(
508            texts
509                .iter()
510                .filter(|x| x.contains("**CREATE**")
511                    || x.contains("**READ**")
512                    || x.contains("**UPDATE**")
513                    || x.contains("**DELETE**"))
514                .count(),
515            5,
516            "five step sentences: {texts:?}"
517        );
518        for step in texts.iter().filter(|x| {
519            x.contains("**CREATE**")
520                || x.contains("**READ**")
521                || x.contains("**UPDATE**")
522                || x.contains("**DELETE**")
523        }) {
524            assert!(
525                step.chars().next().is_some_and(|c| c.is_ascii_digit()),
526                "list marker must attach: {step}"
527            );
528        }
529        assert!(
530            texts
531                .iter()
532                .any(|x| x.contains("No other files were touched")),
533            "{texts:?}"
534        );
535    }
536
537    #[test]
538    fn seal_done_without_punct() {
539        let mut s = SentenceSegmenter::new();
540        s.push("Done");
541        let sealed = s.seal_at_clean_end();
542        assert_eq!(contents(&sealed), vec!["Done".to_string()]);
543    }
544
545    #[test]
546    fn abrupt_does_not_promote() {
547        let mut s = SentenceSegmenter::new();
548        s.push("The implementation will");
549        let unresolved = s.take_unresolved();
550        assert_eq!(unresolved, "The implementation will");
551    }
552}