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}