Skip to main content

steeldb/
spans.rs

1//! **Span boundary repair** — turn model predictions over sub-word pieces into spans over whole words.
2//!
3//! A token classifier predicts over wordpieces, not words. "Registeel" is tokenised as something like
4//! `Reg ##ist ##eel`, and if the `B-ENT` label lands on `##ist` the decoded span is the three characters
5//! `ist`. The prediction was right about *where* the entity is and wrong about where it *starts*, which is a
6//! boundary problem, not a classification problem — so it is repaired here rather than trained around.
7//!
8//! Left unrepaired this is corrosive rather than merely untidy: fragments become gazetteer entries, index
9//! tokens and clustering inputs, so an entity is stored under a name that appears nowhere in the text and can
10//! never be matched again. A real run of the exporter produced `itar` for Registeel, `topolis City` for
11//! Sootopolis City and `202` for 2025, and the resulting facet codebook was debris.
12//!
13//! Kept free of feature gates and model dependencies so every consumer shares one implementation: the ONNX
14//! tagger, the tuned tagger, and offline export all need the same repair.
15
16/// Expand `[start, end)` so both edges sit on word boundaries in `text`.
17///
18/// Byte offsets are snapped to character boundaries first, so a span cutting a multi-byte character cannot
19/// panic when it is sliced. A word is alphanumeric plus `'` and `-`, which keeps "Ward's" and "11-minute"
20/// whole.
21pub fn snap_to_words(text: &str, start: usize, end: usize) -> (usize, usize) {
22    let is_word = |c: char| c.is_alphanumeric() || c == '\'' || c == '-';
23
24    let mut s = start.min(text.len());
25    let mut e = end.clamp(s, text.len());
26    while s > 0 && !text.is_char_boundary(s) {
27        s -= 1;
28    }
29    while e < text.len() && !text.is_char_boundary(e) {
30        e += 1;
31    }
32
33    // walk left while the character before the span continues the same word
34    while s > 0 {
35        match text[..s].chars().next_back() {
36            Some(c) if is_word(c) => s -= c.len_utf8(),
37            _ => break,
38        }
39    }
40    // walk right while the character after the span continues the word
41    while e < text.len() {
42        match text[e..].chars().next() {
43            Some(c) if is_word(c) => e += c.len_utf8(),
44            _ => break,
45        }
46    }
47    (s, e)
48}
49
50/// Snap a list of `(start, end, kind)` ranges and merge neighbours of the same kind that snapping brought
51/// into contact.
52///
53/// Merging is required, not cosmetic: two pieces of one word are frequently labelled separately, and once both
54/// expand to the whole word they become duplicates covering identical text. Ranges separated only by
55/// whitespace are also joined, so a multi-word mention predicted piecewise arrives as one span.
56pub fn snap_and_merge(text: &str, ranges: &[(usize, usize, String)]) -> Vec<(usize, usize, String)> {
57    let mut snapped: Vec<(usize, usize, String)> = ranges
58        .iter()
59        .map(|(s, e, k)| {
60            let (ss, se) = snap_to_words(text, *s, *e);
61            (ss, se, k.clone())
62        })
63        .filter(|(s, e, _)| e > s)
64        .collect();
65
66    snapped.sort_by(|a, b| a.0.cmp(&b.0).then(b.1.cmp(&a.1)));
67
68    let mut out: Vec<(usize, usize, String)> = Vec::new();
69    for (s, e, kind) in snapped {
70        // same kind, and either overlapping or separated only by whitespace → one span
71        let joins = out.last().is_some_and(|(_, pe, pk)| {
72            *pk == kind && (s <= *pe || text.get(*pe..s).is_some_and(|gap| gap.chars().all(char::is_whitespace)))
73        });
74        if joins {
75            if let Some((_, pe, _)) = out.last_mut() {
76                *pe = (*pe).max(e);
77            }
78        } else {
79            out.push((s, e, kind));
80        }
81    }
82    out
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    #[test]
90    fn a_mid_word_prediction_recovers_the_whole_word() {
91        let t = "Registeel took the win";
92        // the model labelled only "ist" inside Registeel
93        let (s, e) = snap_to_words(t, 3, 6);
94        assert_eq!(&t[s..e], "Registeel");
95    }
96
97    #[test]
98    fn a_four_digit_year_is_not_truncated() {
99        let t = "held in 2025 at the venue";
100        let (s, e) = snap_to_words(t, 8, 11); // "202"
101        assert_eq!(&t[s..e], "2025");
102    }
103
104    #[test]
105    fn an_already_aligned_span_is_unchanged() {
106        let t = "Sootopolis City hosted it";
107        let (s, e) = snap_to_words(t, 0, 15);
108        assert_eq!(&t[s..e], "Sootopolis City");
109    }
110
111    #[test]
112    fn multibyte_text_does_not_panic_and_slices_cleanly() {
113        // é and ° are multi-byte; a byte-wise walk panics here
114        for t in ["a Pokémon named Aggron", "recorded 28 °C at the site", "Café Ecruteak"] {
115            for start in 0..t.len() {
116                for end in start..t.len() {
117                    let (s, e) = snap_to_words(t, start, end);
118                    assert!(t.get(s..e).is_some(), "span {s}..{e} must slice {t:?}");
119                }
120            }
121        }
122    }
123
124    #[test]
125    fn apostrophes_and_hyphens_stay_inside_one_word() {
126        let t = "Cynthia Ward's 11-minute battle";
127        let (s, e) = snap_to_words(t, 8, 12); // inside "Ward's"
128        assert_eq!(&t[s..e], "Ward's");
129        let (s2, e2) = snap_to_words(t, 15, 17); // inside "11-minute"
130        assert_eq!(&t[s2..e2], "11-minute");
131    }
132
133    #[test]
134    fn two_pieces_of_one_word_merge_to_a_single_span() {
135        let t = "Sootopolis City";
136        // predicted as two fragments of the same kind
137        let ranges = vec![
138            (0usize, 4usize, "GEO".to_string()),   // "Soot"
139            (4usize, 10usize, "GEO".to_string()),  // "opolis"
140        ];
141        let out = snap_and_merge(t, &ranges);
142        assert_eq!(out.len(), 1, "{out:?}");
143        assert_eq!(&t[out[0].0..out[0].1], "Sootopolis");
144    }
145
146    #[test]
147    fn a_multi_word_mention_predicted_piecewise_becomes_one_span() {
148        let t = "at Sootopolis City today";
149        let ranges = vec![
150            (3usize, 8usize, "GEO".to_string()),   // "Sooto"
151            (14usize, 18usize, "GEO".to_string()), // "City"
152        ];
153        let out = snap_and_merge(t, &ranges);
154        assert_eq!(out.len(), 1, "{out:?}");
155        assert_eq!(&t[out[0].0..out[0].1], "Sootopolis City");
156    }
157
158    #[test]
159    fn different_kinds_are_never_merged() {
160        let t = "Aggron 1082 m";
161        let ranges = vec![
162            (0usize, 6usize, "ENT".to_string()),
163            (7usize, 13usize, "QTY".to_string()),
164        ];
165        let out = snap_and_merge(t, &ranges);
166        assert_eq!(out.len(), 2, "adjacent spans of different kinds must stay separate: {out:?}");
167    }
168
169    #[test]
170    fn spans_separated_by_real_words_stay_separate() {
171        let t = "Aggron and also Salamence";
172        let ranges = vec![
173            (0usize, 6usize, "ENT".to_string()),
174            (16usize, 25usize, "ENT".to_string()),
175        ];
176        let out = snap_and_merge(t, &ranges);
177        assert_eq!(out.len(), 2, "{out:?}");
178    }
179}