Skip to main content

kopitiam_ai/
glossary.rs

1//! Deterministic terminology glossary enforcement (token-max card **III-5**).
2//!
3//! A project glossary maps a **source** term to the **target** term the project
4//! has already decided on — e.g. `华龙一号` → `"Hualong One"`, `数字孪生` →
5//! `"digital twin"`, `压水堆` → `"PWR"`, `乏燃料` → `"spent fuel"` — and is
6//! applied to translated text by [`Glossary::apply`] **deterministically**, with
7//! no model call. The driving case is Chinese-language nuclear literature
8//! (`kopitiam_token_max.md` §13), where the same handful of domain terms recur
9//! hundreds of times across a long document.
10//!
11//! # Why this is a token-max capability (§0.3, §13 Task III-5)
12//!
13//! §0.3 — *deterministic tool over model call*: once the project has decided
14//! that `华龙一号` is rendered `"Hualong One"`, re-deciding that per occurrence
15//! is pure waste. A translation model, asked to translate a segment containing
16//! the term, spends judgment tokens on it **every time it appears**, and — worse
17//! — is free to answer differently each time (`"Hualong One"` here,
18//! `"Hualong-1"` three pages later, `"HPR1000"` in the conclusion). That drift
19//! is invisible until a reviewer catches it, and fixing it costs a **whole
20//! review pass over the finished document**.
21//!
22//! Applying the glossary deterministically spends **zero** model tokens on
23//! terminology and makes drift impossible by construction: every occurrence of a
24//! source term becomes byte-identical target text, this run and every future
25//! run. The measurement is direct — see [`Applied`] and [`Substitution`]:
26//! `N` glossary terms hit across `M` total occurrences is `M` per-occurrence
27//! model decisions eliminated, each one also a drift opportunity removed.
28//!
29//! # Deterministic matching rules (the contract — read before trusting output)
30//!
31//! [`Glossary::apply`] makes a **single left-to-right pass** over the input and
32//! substitutes **non-overlapping** matches. Three rules make the result exact
33//! and reproducible:
34//!
35//! 1. **Longest-source-first.** At each position the longest source term that
36//!    matches wins, so a 4-character term is never partially clobbered by a
37//!    shorter prefix entry. If a glossary contained both `华龙一号` and a
38//!    hypothetical `华龙`, the text `华龙一号` matches the full 4-char entry, not
39//!    the 2-char one. Ties in length are broken by declaration order, so the
40//!    result is a pure function of `(entries, input)`.
41//! 2. **CJK vs Latin boundaries, per entry.** CJK writing has no inter-word
42//!    spaces, so a substring match is exactly right for a CJK source term. An
43//!    ASCII/Latin source term instead respects **word boundaries**, so `"core"`
44//!    is not rewritten inside `"encore"` or `"scoreboard"`. Each entry's rule is
45//!    chosen from its own source text (see [`SourceKind`]): a source containing
46//!    any CJK ideograph matches as a substring; an all-ASCII source with a
47//!    letter/digit matches only at word boundaries; anything else falls back to
48//!    substring.
49//! 3. **Idempotent within a pass.** After a match the cursor advances **past the
50//!    consumed source**, never into the freshly inserted target, so an inserted
51//!    target is never re-scanned for substitution in the same pass. For the
52//!    normal source-language → target-language direction (no target text
53//!    contains another entry's source), [`Glossary::apply`] is therefore fully
54//!    idempotent: applying it twice yields byte-identical output to applying it
55//!    once. (The one way to break that is a glossary whose *target* contains
56//!    another entry's *source* — e.g. mapping `twin` → something while also
57//!    producing `"digital twin"`; a normal cross-language glossary never does.)
58//!
59//! **Case.** Latin source matching is **case-sensitive** by default: `"PWR"`
60//! does not match `"pwr"`. This is deliberate — technical acronyms and
61//! capitalised proper terms are case-bearing, and a case-insensitive default
62//! would silently rewrite unrelated lowercase words. A case-folding option is a
63//! documented future extension (see [`Glossary::apply`]); it is not enabled here
64//! because the driving CJK case does not need it.
65//!
66//! # Auditability (never silent)
67//!
68//! Like the II-6 preprocessing helpers, this never edits text silently:
69//! [`Glossary::apply`] returns an [`Applied`] whose [`Applied::substitutions`]
70//! lists every term that fired and its exact occurrence `count`, so the saving
71//! is measurable and every rewrite is reviewable.
72
73use anyhow::{Result, anyhow};
74use serde::{Deserialize, Serialize};
75
76/// One glossary rule: replace every (deterministically matched) occurrence of
77/// `source` with `target`.
78///
79/// Deserializable so a glossary can be loaded from a serde structure (JSON, or
80/// TOML via a `toml`-owning caller — this crate adds no `toml` dependency). A
81/// JSON entry is `{"source": "华龙一号", "target": "Hualong One"}`.
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct Entry {
84    /// The term as it appears in the (translated) text.
85    pub source: String,
86    /// The project-decided replacement.
87    pub target: String,
88}
89
90impl Entry {
91    /// Construct an entry from any string-likes.
92    pub fn new(source: impl Into<String>, target: impl Into<String>) -> Self {
93        Self { source: source.into(), target: target.into() }
94    }
95}
96
97/// Which deterministic matching rule an [`Entry`]'s `source` uses, decided from
98/// the source text itself (rule 2 above).
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(rename_all = "snake_case")]
101pub enum SourceKind {
102    /// Source contains at least one CJK ideograph → matched as a raw substring
103    /// (CJK has no word boundaries, so any occurrence is a real occurrence).
104    Cjk,
105    /// Source is all-ASCII and contains a letter or digit → matched only at
106    /// word boundaries, so it is not rewritten inside a larger word.
107    Latin,
108    /// Neither of the above (e.g. non-CJK non-ASCII script, or punctuation
109    /// only) → matched as a raw substring, the safe general default.
110    Other,
111}
112
113impl SourceKind {
114    /// Classify a source term. See [`SourceKind`] variants for the rule.
115    #[must_use]
116    pub fn of(source: &str) -> Self {
117        if source.chars().any(is_cjk) {
118            SourceKind::Cjk
119        } else if source.is_ascii() && source.chars().any(|c| c.is_ascii_alphanumeric()) {
120            SourceKind::Latin
121        } else {
122            SourceKind::Other
123        }
124    }
125
126    /// Whether this kind must respect ASCII word boundaries when matching.
127    fn word_bounded(self) -> bool {
128        matches!(self, SourceKind::Latin)
129    }
130}
131
132/// One reported substitution: `source` was replaced by `target` exactly `count`
133/// times. Only terms that actually fired appear in an [`Applied`] report.
134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
135pub struct Substitution {
136    /// The matched source term.
137    pub source: String,
138    /// The replacement written in its place.
139    pub target: String,
140    /// How many non-overlapping occurrences were replaced.
141    pub count: usize,
142}
143
144/// The result of [`Glossary::apply`]: the rewritten `output`, plus the audit
145/// report of exactly what was substituted.
146#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
147pub struct Applied {
148    /// The text after deterministic glossary substitution.
149    pub output: String,
150    /// Every term that fired, in glossary declaration order, with its exact
151    /// occurrence count. Empty when nothing matched (a pure pass-through).
152    pub substitutions: Vec<Substitution>,
153}
154
155impl Applied {
156    /// Total occurrences replaced across all terms — the count of
157    /// per-occurrence model decisions this pass eliminated (§13 Task III-5).
158    #[must_use]
159    pub fn total_substitutions(&self) -> usize {
160        self.substitutions.iter().map(|s| s.count).sum()
161    }
162}
163
164/// An ordered set of [`Entry`]s applied deterministically to text.
165///
166/// Declaration order is preserved (it is the tie-break for equal-length sources
167/// and the order of the [`Applied::substitutions`] report); matching itself is
168/// longest-source-first regardless of declaration order (rule 1).
169///
170/// Deserializable from `{"entries": [ {"source": ..., "target": ...}, ... ]}`.
171#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
172pub struct Glossary {
173    #[serde(default)]
174    entries: Vec<Entry>,
175}
176
177impl Glossary {
178    /// Build a glossary from an ordered collection of entries.
179    ///
180    /// Entries with an empty `source` are rejected (an empty source would match
181    /// at every position and cannot be a meaningful term).
182    pub fn from_entries(entries: impl IntoIterator<Item = Entry>) -> Result<Self> {
183        let entries: Vec<Entry> = entries.into_iter().collect();
184        if let Some(bad) = entries.iter().position(|e| e.source.is_empty()) {
185            return Err(anyhow!("glossary entry {bad} has an empty source term"));
186        }
187        Ok(Self { entries })
188    }
189
190    /// Parse a glossary from a simple line-oriented text format: one
191    /// `source = target` per line, `#` line comments and blank lines ignored.
192    /// The split is on the **first** `=`, so a target may itself contain `=`;
193    /// both sides are trimmed of surrounding whitespace.
194    ///
195    /// This is a hand-rolled parser on purpose — it needs no `toml` dependency
196    /// (this crate adds none for III-5). For structured config, deserialize
197    /// [`Glossary`] / [`Entry`] from JSON via the existing `serde` dep instead.
198    ///
199    /// ```text
200    /// # nuclear terminology
201    /// 华龙一号 = Hualong One
202    /// 数字孪生 = digital twin
203    /// 压水堆   = PWR
204    /// ```
205    pub fn from_text(text: &str) -> Result<Self> {
206        let mut entries = Vec::new();
207        for (i, raw) in text.lines().enumerate() {
208            let line = raw.trim();
209            if line.is_empty() || line.starts_with('#') {
210                continue;
211            }
212            let (source, target) = line
213                .split_once('=')
214                .ok_or_else(|| anyhow!("glossary line {}: expected `source = target`, got {raw:?}", i + 1))?;
215            let source = source.trim();
216            if source.is_empty() {
217                return Err(anyhow!("glossary line {}: empty source term", i + 1));
218            }
219            entries.push(Entry::new(source, target.trim()));
220        }
221        Ok(Self { entries })
222    }
223
224    /// The entries, in declaration order.
225    #[must_use]
226    pub fn entries(&self) -> &[Entry] {
227        &self.entries
228    }
229
230    /// Whether the glossary has no entries (its [`Glossary::apply`] is a
231    /// guaranteed pass-through).
232    #[must_use]
233    pub fn is_empty(&self) -> bool {
234        self.entries.is_empty()
235    }
236
237    /// Apply the glossary to `text` deterministically and report what changed.
238    ///
239    /// Single left-to-right pass, non-overlapping, longest-source-first, with
240    /// per-entry CJK-substring vs Latin-word-boundary matching, advancing past
241    /// consumed source so inserted targets are never re-scanned (module rules
242    /// 1–3). Same `(glossary, text)` in ⇒ byte-identical `output` out, every
243    /// run; an empty glossary is an exact pass-through.
244    ///
245    /// Latin sources match case-sensitively; a case-folding option is a future
246    /// extension and is intentionally not enabled here (see module docs).
247    #[must_use]
248    pub fn apply(&self, text: &str) -> Applied {
249        // Precompute each entry's source as chars + its matching kind, and the
250        // order to try entries at a given position: longest source first, ties
251        // by declaration order (stable) so the result is a pure function.
252        let prepared: Vec<PreparedEntry> = self
253            .entries
254            .iter()
255            .enumerate()
256            .map(|(idx, e)| PreparedEntry {
257                idx,
258                source: e.source.chars().collect(),
259                kind: SourceKind::of(&e.source),
260            })
261            .collect();
262
263        let mut order: Vec<usize> = (0..prepared.len()).collect();
264        // Stable sort by descending source length; equal lengths keep
265        // declaration order.
266        order.sort_by(|&a, &b| prepared[b].source.len().cmp(&prepared[a].source.len()));
267
268        let chars: Vec<char> = text.chars().collect();
269        let mut output = String::with_capacity(text.len());
270        let mut counts = vec![0usize; self.entries.len()];
271
272        let mut i = 0;
273        while i < chars.len() {
274            let mut matched = false;
275            for &oi in &order {
276                let pe = &prepared[oi];
277                let len = pe.source.len();
278                if len == 0 || i + len > chars.len() {
279                    continue;
280                }
281                if chars[i..i + len] != pe.source[..] {
282                    continue;
283                }
284                if pe.kind.word_bounded() && !word_boundaries_ok(&chars, i, len) {
285                    continue;
286                }
287                // Match: write the target, advance past the consumed source.
288                output.push_str(&self.entries[pe.idx].target);
289                counts[pe.idx] += 1;
290                i += len;
291                matched = true;
292                break;
293            }
294            if !matched {
295                output.push(chars[i]);
296                i += 1;
297            }
298        }
299
300        // Report in declaration order, only terms that actually fired.
301        let substitutions = self
302            .entries
303            .iter()
304            .enumerate()
305            .filter(|(idx, _)| counts[*idx] > 0)
306            .map(|(idx, e)| Substitution {
307                source: e.source.clone(),
308                target: e.target.clone(),
309                count: counts[idx],
310            })
311            .collect();
312
313        Applied { output, substitutions }
314    }
315}
316
317/// An [`Entry`] preprocessed for matching: its source as a char slice and its
318/// [`SourceKind`], plus the declaration index to look the target/count up.
319struct PreparedEntry {
320    idx: usize,
321    source: Vec<char>,
322    kind: SourceKind,
323}
324
325/// True when the substring `chars[start..start+len]` is not glued to an ASCII
326/// word character on either side — the Latin word-boundary rule. A boundary is
327/// satisfied at the text edge, or against any non-`[A-Za-z0-9_]` char.
328fn word_boundaries_ok(chars: &[char], start: usize, len: usize) -> bool {
329    let left_ok = start == 0 || !is_word_char(chars[start - 1]);
330    let after = start + len;
331    let right_ok = after >= chars.len() || !is_word_char(chars[after]);
332    left_ok && right_ok
333}
334
335/// ASCII "word" character for boundary purposes: `[A-Za-z0-9_]`.
336fn is_word_char(c: char) -> bool {
337    c.is_ascii_alphanumeric() || c == '_'
338}
339
340/// Whether `c` is a CJK ideograph in one of the common blocks, meaning a source
341/// term containing it should match as a substring (no word boundaries in CJK).
342///
343/// Covers CJK Unified Ideographs and Extension A, the two Extension blocks in
344/// the SIP, and the Compatibility Ideographs — the ranges that carry Chinese
345/// technical terminology. Punctuation and fullwidth Latin are intentionally not
346/// treated as CJK here.
347fn is_cjk(c: char) -> bool {
348    matches!(c as u32,
349        0x3400..=0x4DBF   // CJK Unified Ideographs Extension A
350        | 0x4E00..=0x9FFF // CJK Unified Ideographs
351        | 0xF900..=0xFAFF // CJK Compatibility Ideographs
352        | 0x2_0000..=0x2_A6DF // Extension B
353        | 0x2_A700..=0x2_EBEF // Extensions C–F
354    )
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360
361    /// The headline case: two CJK terms replaced in a sentence, with an exact
362    /// audit report. This is the deterministic, zero-model-token measurement —
363    /// 2 terms, 2 occurrences, all decided once and reproducibly.
364    #[test]
365    fn replaces_cjk_terms_in_a_sentence_and_reports_them() {
366        let g = Glossary::from_entries([
367            Entry::new("华龙一号", "Hualong One"),
368            Entry::new("数字孪生", "digital twin"),
369        ])
370        .unwrap();
371
372        let applied = g.apply("华龙一号的数字孪生模型");
373        assert_eq!(applied.output, "Hualong One的digital twin模型");
374        assert_eq!(
375            applied.substitutions,
376            vec![
377                Substitution { source: "华龙一号".into(), target: "Hualong One".into(), count: 1 },
378                Substitution { source: "数字孪生".into(), target: "digital twin".into(), count: 1 },
379            ]
380        );
381        assert_eq!(applied.total_substitutions(), 2);
382    }
383
384    /// Rule 1: a longer term is never clobbered by a shorter prefix entry, even
385    /// when the shorter entry is declared first.
386    #[test]
387    fn longest_source_wins_over_shorter_prefix() {
388        let g = Glossary::from_entries([
389            Entry::new("华龙", "Hualong"),      // 2-char prefix, declared first
390            Entry::new("华龙一号", "Hualong One"), // 4-char full term
391        ])
392        .unwrap();
393
394        // The full 4-char term must win where it appears...
395        let applied = g.apply("华龙一号");
396        assert_eq!(applied.output, "Hualong One");
397        assert_eq!(applied.total_substitutions(), 1);
398        assert_eq!(applied.substitutions[0].source, "华龙一号");
399
400        // ...while the short entry still fires where only it matches.
401        let applied2 = g.apply("华龙集团");
402        assert_eq!(applied2.output, "Hualong集团");
403    }
404
405    /// Rule 2 (Latin): a Latin source respects word boundaries — `core` is
406    /// replaced when it stands alone but not inside `encore`.
407    #[test]
408    fn latin_source_respects_word_boundaries() {
409        let g = Glossary::from_entries([Entry::new("core", "reactor core")]).unwrap();
410
411        let applied = g.apply("the core melted during the encore, core.");
412        // Standalone `core` (x2) rewritten; the `core` inside `encore` is not.
413        assert_eq!(applied.output, "the reactor core melted during the encore, reactor core.");
414        assert_eq!(applied.total_substitutions(), 2);
415        assert_eq!(applied.substitutions[0].count, 2);
416    }
417
418    /// Rule 2 (CJK): a CJK source matches as a substring with no boundary
419    /// requirement — every occurrence, including adjacent runs, is replaced and
420    /// counted.
421    #[test]
422    fn cjk_source_matches_every_occurrence() {
423        let g = Glossary::from_entries([Entry::new("乏燃料", "spent fuel")]).unwrap();
424        let applied = g.apply("乏燃料池储存乏燃料");
425        assert_eq!(applied.output, "spent fuel池储存spent fuel");
426        assert_eq!(applied.substitutions[0].count, 2);
427    }
428
429    /// Rule 3: applying twice yields byte-identical output to applying once
430    /// (idempotence), and the same input always gives the same output.
431    #[test]
432    fn apply_is_idempotent_and_deterministic() {
433        let g = Glossary::from_entries([
434            Entry::new("压水堆", "PWR"),
435            Entry::new("数字孪生", "digital twin"),
436        ])
437        .unwrap();
438        let input = "压水堆的数字孪生,压水堆机组";
439
440        let once = g.apply(input);
441        let twice = g.apply(&once.output);
442        assert_eq!(twice.output, once.output, "apply twice == apply once");
443        assert!(twice.substitutions.is_empty(), "second pass substitutes nothing");
444
445        // Determinism: a fresh glossary + same input reproduces byte-for-byte.
446        let again = Glossary::from_entries([
447            Entry::new("压水堆", "PWR"),
448            Entry::new("数字孪生", "digital twin"),
449        ])
450        .unwrap();
451        assert_eq!(again.apply(input).output, once.output);
452    }
453
454    /// An empty glossary is an exact pass-through with an empty report.
455    #[test]
456    fn empty_glossary_is_a_noop() {
457        let g = Glossary::default();
458        assert!(g.is_empty());
459        let applied = g.apply("华龙一号 core 数字孪生");
460        assert_eq!(applied.output, "华龙一号 core 数字孪生");
461        assert!(applied.substitutions.is_empty());
462        assert_eq!(applied.total_substitutions(), 0);
463    }
464
465    /// The report counts occurrences correctly across multiple terms and only
466    /// lists terms that actually fired.
467    #[test]
468    fn report_counts_occurrences_and_omits_unfired_terms() {
469        let g = Glossary::from_entries([
470            Entry::new("压水堆", "PWR"),
471            Entry::new("沸水堆", "BWR"), // never appears
472            Entry::new("数字孪生", "digital twin"),
473        ])
474        .unwrap();
475
476        let applied = g.apply("压水堆、压水堆、压水堆 和 数字孪生");
477        assert_eq!(applied.substitutions.len(), 2, "unfired 沸水堆 omitted");
478        assert_eq!(applied.substitutions[0].source, "压水堆");
479        assert_eq!(applied.substitutions[0].count, 3);
480        assert_eq!(applied.substitutions[1].source, "数字孪生");
481        assert_eq!(applied.substitutions[1].count, 1);
482    }
483
484    #[test]
485    fn source_kind_classification() {
486        assert_eq!(SourceKind::of("华龙一号"), SourceKind::Cjk);
487        assert_eq!(SourceKind::of("PWR"), SourceKind::Latin);
488        assert_eq!(SourceKind::of("core"), SourceKind::Latin);
489        // Mixed CJK + ASCII counts as CJK (substring rule is correct there).
490        assert_eq!(SourceKind::of("华龙1号"), SourceKind::Cjk);
491    }
492
493    /// Latin matching is case-sensitive by default (documented behaviour).
494    #[test]
495    fn latin_matching_is_case_sensitive() {
496        let g = Glossary::from_entries([Entry::new("PWR", "pressurised water reactor")]).unwrap();
497        let applied = g.apply("PWR and pwr");
498        assert_eq!(applied.output, "pressurised water reactor and pwr");
499        assert_eq!(applied.total_substitutions(), 1);
500    }
501
502    /// The hand-rolled text format parses `source = target`, keeps `=` in the
503    /// target, and ignores comments / blank lines.
504    #[test]
505    fn from_text_parses_source_equals_target() {
506        let g = Glossary::from_text(
507            "# nuclear terms\n\
508             华龙一号 = Hualong One\n\
509             \n\
510             数字孪生 = digital twin\n\
511             ratio = a = b\n",
512        )
513        .unwrap();
514        assert_eq!(g.entries().len(), 3);
515        assert_eq!(g.entries()[0], Entry::new("华龙一号", "Hualong One"));
516        assert_eq!(g.entries()[2], Entry::new("ratio", "a = b"));
517    }
518
519    #[test]
520    fn empty_source_is_rejected() {
521        assert!(Glossary::from_entries([Entry::new("", "x")]).is_err());
522        assert!(Glossary::from_text(" = target").is_err());
523    }
524
525    /// Compile-time check that the serde derives are wired: [`Glossary`],
526    /// [`Entry`], [`Substitution`] and [`Applied`] are all `Serialize` +
527    /// `Deserialize`, so a caller can load a glossary from JSON/TOML using its
528    /// own serde-format crate (this crate adds none). Kept as a bound assertion
529    /// rather than a live JSON round-trip so no `serde_json` dev-dependency is
530    /// introduced.
531    #[test]
532    fn serde_derives_are_present() {
533        fn assert_serde<T: serde::Serialize + serde::de::DeserializeOwned>() {}
534        assert_serde::<Glossary>();
535        assert_serde::<Entry>();
536        assert_serde::<Substitution>();
537        assert_serde::<Applied>();
538        assert_serde::<SourceKind>();
539    }
540}