Skip to main content

cortiq_engine/
lookup.rs

1//! `lookup` records at run time (CMF_V2_SPEC §9.5.2; the lookup spec §3):
2//! an explicit key → card table appended to a sealed genome, reached
3//! through the request-level resonance router.
4//!
5//! * [`LookupTable`] — the four tensors `skill.{id}.lookup.*` of one
6//!   record as the runtime reads them: sorted key hashes (binary search),
7//!   entry per key, slot offsets, and the UTF-8 card blob left in the
8//!   file's mapping (zero-copy).
9//! * [`extract_key`] — O(len) key extraction from a user message: a Latin
10//!   binomial in parentheses (the innermost group first), then a
11//!   capitalised `Genus species` pair anywhere in the ORIGINAL message
12//!   ([`capitalised_binomials`]), then every 1..=[`MAX_NGRAM`]-word n-gram
13//!   of the `cmf-key-v2`-normalised message against the exact key index
14//!   (the longest match wins, ties → the first occurrence), then the same
15//!   n-grams STEMMED ([`stem_key`]: a light, deterministic Russian /
16//!   English suffix stripper) against the table's [`StemIndex`]. A STRONG
17//!   candidate ([`is_strong_key`]: ≥ 2 real words, no digits) wins over
18//!   any weak one, in every path — the router's pick, `key_first`, the
19//!   conversation memory; without one, the first weak candidate (a weak
20//!   stem only when nothing matched exactly). The file stores only key
21//!   hashes, so the stem index is built at open from the key texts the
22//!   cards themselves spell ([`recover_key_texts`]): no format change.
23//! * [`LookupTable::find_answer_turns`] — conversation memory: the key
24//!   is taken from the most recent of up to [`MEMORY_TURNS`] user turns
25//!   that holds one; the field and the language come from the LAST turn.
26//! * [`select_field`] / [`pick_lang`] — the card field the question asks
27//!   for (keyword rules) and the language of the answer (script).
28//! * [`LookupMode`] — `answer` (the table answers, no generation),
29//!   `context` (the card — or, when a field was selected, its first
30//!   sentence plus that field — is prepended to the message and the
31//!   backbone generates), `off` (the table is ignored); flag or
32//!   `CMF_LOOKUP_MODE`.
33//! * [`resolve_lookup`] — the one step every caller shares: a
34//!   [`RouteDecision`] whose target is a lookup record becomes the lookup
35//!   outcome; no key in the message, an empty card in every language, or
36//!   mode `off` sends the request to the BACKBONE unchanged. A lookup
37//!   record never has a lane of its own: the backbone pipeline — the same
38//!   object F0 runs — serves it, so the logits of every non-hit request
39//!   are bit-identical to F0's.
40//! * [`LookupPolicy`] / [`resolve_lookup_gated`] — the record's routing
41//!   policy (`LookupInfo.policy`): `router_and_key` (the default) looks a
42//!   key up only in what the φ router sent; `key_first` lets a STRONG key
43//!   of the message ([`is_strong_key`]) take a request the router sent to
44//!   the backbone — only on a file whose router could pick a skill at all
45//!   (no fail-closed state). A one-word key (`чай`, `мята`) still needs
46//!   the router; a message without a strong key keeps the router's
47//!   decision untouched. An unknown policy value reads as
48//!   `router_and_key`.
49
50use crate::router::{self, PromptFrame, RouteDecision, RouteOptions, RouteTarget};
51use cortiq_core::knowledge::{
52    LOOKUP_MAX_NGRAM, LookupInfo, lookup_leaf, lookup_policy, lookup_tensor_name, normalize_key,
53    normalized_key_hash, read_u32_le, read_u64_le, skill_kind,
54};
55use cortiq_core::{CmfModel, SkillRecord};
56use std::collections::BTreeMap;
57use std::sync::{Arc, Mutex};
58
59// ───────────────────────── mode ─────────────────────────
60
61/// Environment default of [`LookupMode`] (`answer` | `context` | `off`).
62pub const LOOKUP_MODE_ENV: &str = "CMF_LOOKUP_MODE";
63
64/// What a request routed to a lookup record does with the card.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
66pub enum LookupMode {
67    /// The table answers: the selected field's text (or the whole card);
68    /// nothing is generated. The default.
69    #[default]
70    Answer,
71    /// The card is prepended to the user message
72    /// ([`context_prompt`]) and the BACKBONE generates.
73    Context,
74    /// The table is ignored: the backbone runs on the plain message.
75    Off,
76}
77
78impl LookupMode {
79    /// `answer` | `context` | `off` (case-insensitive).
80    pub fn parse(s: &str) -> Result<Self, String> {
81        match s.trim().to_ascii_lowercase().as_str() {
82            "answer" => Ok(Self::Answer),
83            "context" => Ok(Self::Context),
84            "off" | "none" => Ok(Self::Off),
85            other => Err(format!(
86                "lookup mode '{other}': expected answer | context | off"
87            )),
88        }
89    }
90
91    /// [`LOOKUP_MODE_ENV`], `answer` when unset or empty.
92    pub fn from_env() -> Result<Self, String> {
93        match std::env::var(LOOKUP_MODE_ENV) {
94            Ok(v) if !v.trim().is_empty() => {
95                Self::parse(&v).map_err(|e| format!("{LOOKUP_MODE_ENV}: {e}"))
96            }
97            _ => Ok(Self::Answer),
98        }
99    }
100
101    /// The flag when given, else the environment, else `answer`.
102    pub fn resolve(flag: Option<&str>) -> Result<Self, String> {
103        match flag {
104            Some(f) => Self::parse(f).map_err(|e| format!("--lookup-mode: {e}")),
105            None => Self::from_env(),
106        }
107    }
108
109    pub fn label(self) -> &'static str {
110        match self {
111            Self::Answer => "answer",
112            Self::Context => "context",
113            Self::Off => "off",
114        }
115    }
116}
117
118/// The header line of a `context`-mode prompt.
119pub const CONTEXT_HEADER: &str = "Справочная карточка / Reference card:";
120
121/// The user message the backbone generates from in `context` mode.
122pub fn context_prompt(card: &str, user_text: &str) -> String {
123    format!("{CONTEXT_HEADER}\n{card}\n\n{user_text}")
124}
125
126// ───────────────────────── routing policy ─────────────────────────
127
128/// How a request reaches a lookup record (`LookupInfo.policy`, spec
129/// §9.5.2).
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
131pub enum LookupPolicy {
132    /// `router_and_key` (the default, also when the field is absent): the
133    /// φ router sends the request to the record, the table then looks the
134    /// key up in it; a request the router sent to the backbone never
135    /// reaches the table.
136    #[default]
137    RouterAndKey,
138    /// `key_first`: a STRONG key in the message ([`is_strong_key`]) sends
139    /// the request to the record even when the router picked the backbone
140    /// (the backbone nearest, a novel input, the margin not beaten); a
141    /// one-word key still needs the router's decision.
142    KeyFirst,
143}
144
145impl LookupPolicy {
146    /// `router_and_key` | `key_first`.
147    pub fn parse(s: &str) -> Result<Self, String> {
148        match s {
149            lookup_policy::ROUTER_AND_KEY => Ok(Self::RouterAndKey),
150            lookup_policy::KEY_FIRST => Ok(Self::KeyFirst),
151            other => Err(format!(
152                "lookup policy '{other}': expected {}",
153                lookup_policy::ALL.join(" | ")
154            )),
155        }
156    }
157
158    /// The policy a record declares ([`LookupInfo::policy_label`]). A
159    /// value this reader does not know (a newer writer's policy, a hand
160    /// edit) is `router_and_key` — the conservative reading, the one a
161    /// reader from before the field existed applies — and never a reason
162    /// to refuse the file (review KF-6; `CmfModel::open` warns once, the
163    /// writers `lookup-build` / `lookup-policy` refuse such a value).
164    pub fn of(info: &LookupInfo) -> Self {
165        Self::parse(info.policy_label()).unwrap_or_default()
166    }
167
168    /// Does the record declare a policy this reader knows (absent = the
169    /// default, known)?
170    pub fn is_known(info: &LookupInfo) -> bool {
171        Self::parse(info.policy_label()).is_ok()
172    }
173
174    pub fn label(self) -> &'static str {
175        match self {
176            Self::RouterAndKey => lookup_policy::ROUTER_AND_KEY,
177            Self::KeyFirst => lookup_policy::KEY_FIRST,
178        }
179    }
180}
181
182/// Who sent a request to a lookup record — `decided_by` in the route
183/// summary.
184#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
185pub enum DecidedBy {
186    /// The decision the lookup step was handed: the φ router's (or a
187    /// pinned `--route <id>`).
188    #[default]
189    Router,
190    /// The `key_first` policy: the router picked the backbone, a strong
191    /// key of the message took the request.
192    KeyFirst,
193}
194
195impl DecidedBy {
196    pub fn label(self) -> &'static str {
197        match self {
198            Self::Router => "router",
199            Self::KeyFirst => "key_first",
200        }
201    }
202}
203
204// ───────────────────────── question → field / language ─────────────────────────
205
206/// The card fields [`select_field`] can name, in rule order (the first
207/// rule that matches wins): the specific ones before `uses`, which almost
208/// every question mentions in passing (`safe to use`, `parts used`, `in
209/// what form is it used`).
210pub const FIELDS: &[&str] = &[
211    "family",
212    "parts",
213    "compounds",
214    "safety",
215    "evidence",
216    "preparations",
217    "uses",
218];
219
220/// The field a question asks for, by keyword (lookup spec §3), in
221/// [`FIELDS`] order:
222/// `семейств|family → family`; `части|часть|частей|частям|частях|частью|
223/// частями|part|parts → parts` (whole words: `частуха` is a plant,
224/// `часто` an adverb); `веществ|соединен|состав|компонент|ингредиент|
225/// compound|constituent|ingredient|chemical → compounds`; `противопоказ|
226/// безопас|побочн|опасн|ядовит|токсич|contraindic|toxic|poison|safety|
227/// safe|risk|risks|side effect → safety`; `доказ|исследован|evidence|
228/// study|studies|research|clinical|trial|trials → evidence`; `форм|
229/// препарат|дозиров|preparation|dosage|form|forms|dose|doses →
230/// preparations`; `примен|use|uses|used|usage → uses`. Stems match a word
231/// prefix, the short Latin words match whole words (so `because` is not
232/// `use`, `information` is not `form`). `None` = no field named: the
233/// whole card answers.
234pub fn select_field(question: &str) -> Option<&'static str> {
235    let norm = normalize_key(question);
236    let words: Vec<&str> = norm.split(' ').filter(|w| !w.is_empty()).collect();
237    let prefix = |p: &str| words.iter().any(|w| w.starts_with(p));
238    let word = |x: &str| words.iter().any(|w| *w == x);
239    let bigram = |a: &str, b: &str| words.windows(2).any(|w| w[0] == a && w[1].starts_with(b));
240    if prefix("семейств") || word("family") || word("families") {
241        return Some("family");
242    }
243    if ["части", "часть", "частей", "частям", "частях", "частью", "частями"]
244        .iter()
245        .any(|w| word(w))
246        || word("part")
247        || word("parts")
248    {
249        return Some("parts");
250    }
251    if prefix("веществ")
252        || prefix("соединен")
253        || prefix("состав")
254        || prefix("компонент")
255        || prefix("ингредиент")
256        || prefix("compound")
257        || prefix("constituent")
258        || prefix("ingredient")
259        || prefix("chemical")
260    {
261        return Some("compounds");
262    }
263    if prefix("противопоказ")
264        || prefix("безопас")
265        || prefix("побочн")
266        || prefix("опасн")
267        || prefix("ядовит")
268        || prefix("токсич")
269        || prefix("contraindic")
270        || prefix("toxic")
271        || prefix("poison")
272        || word("safety")
273        || word("safe")
274        || word("risk")
275        || word("risks")
276        || bigram("side", "effect")
277    {
278        return Some("safety");
279    }
280    if prefix("доказ")
281        || prefix("исследован")
282        || word("evidence")
283        || word("study")
284        || word("studies")
285        || word("research")
286        || word("clinical")
287        || word("trial")
288        || word("trials")
289    {
290        return Some("evidence");
291    }
292    if prefix("форм")
293        || prefix("препарат")
294        || prefix("дозиров")
295        || prefix("preparation")
296        || prefix("dosage")
297        || word("form")
298        || word("forms")
299        || word("dose")
300        || word("doses")
301    {
302        return Some("preparations");
303    }
304    if prefix("примен") || word("use") || word("uses") || word("used") || word("usage") {
305        return Some("uses");
306    }
307    None
308}
309
310/// Any Cyrillic letter in `s`.
311pub fn has_cyrillic(s: &str) -> bool {
312    s.chars().any(|c| ('\u{0400}'..='\u{052F}').contains(&c))
313}
314
315/// The slot language of the answer: `ru` for a Cyrillic question, else
316/// `en`; a language the record lacks falls back to `en`, then to the
317/// record's first language. Returns the index into `langs`.
318pub fn pick_lang(question: &str, langs: &[String]) -> usize {
319    let want = if has_cyrillic(question) { "ru" } else { "en" };
320    langs
321        .iter()
322        .position(|l| l == want)
323        .or_else(|| langs.iter().position(|l| l == "en"))
324        .unwrap_or(0)
325}
326
327// ───────────────────────── key extraction ─────────────────────────
328
329/// Longest n-gram (in words of the normalised message) tried as a key —
330/// [`LOOKUP_MAX_NGRAM`] of the core, the number the builder drops longer
331/// keys by.
332pub const MAX_NGRAM: usize = LOOKUP_MAX_NGRAM;
333
334/// Conversation memory of `serve` ([`LookupTable::find_answer_turns`]):
335/// the key is searched in the last user message and up to this many
336/// user turns in total, most recent first.
337pub const MEMORY_TURNS: usize = 6;
338
339/// Where a key hit came from.
340#[derive(Debug, Clone, Copy, PartialEq, Eq)]
341pub enum KeySource {
342    /// A parenthesised Latin group, e.g. `(Abies balsamea)`.
343    Parenthesised,
344    /// A capitalised `Genus species` pair anywhere in the original
345    /// message ([`capitalised_binomials`]).
346    Binomial,
347    /// An n-gram of the normalised message.
348    Ngram,
349}
350
351impl KeySource {
352    pub fn label(self) -> &'static str {
353        match self {
354            Self::Parenthesised => "parenthesised",
355            Self::Binomial => "binomial",
356            Self::Ngram => "n-gram",
357        }
358    }
359}
360
361/// Which index answered: the exact `cmf-key-v2` hashes (primary), or the
362/// stem index ([`StemIndex`], consulted only when the primary found
363/// nothing). Reported as `lookup_match` in the route summary.
364#[derive(Debug, Clone, Copy, PartialEq, Eq)]
365pub enum MatchVia {
366    Exact,
367    Stem,
368}
369
370impl MatchVia {
371    pub fn label(self) -> &'static str {
372        match self {
373            Self::Exact => "exact",
374            Self::Stem => "stem",
375        }
376    }
377}
378
379/// A key found in a user message.
380#[derive(Debug, Clone, PartialEq, Eq)]
381pub struct KeyHit {
382    /// The normalised text of the message that matched (`cmf-key-v2`): the
383    /// key itself for an exact hit, the inflected n-gram for a stem hit.
384    pub key: String,
385    /// The hash that hit: of `key` for an exact match, of `stem` for a
386    /// stem match.
387    pub hash: u64,
388    /// The entry the key names.
389    pub entry: u32,
390    /// Words in the key.
391    pub words: usize,
392    pub source: KeySource,
393    pub via: MatchVia,
394    /// The stemmed n-gram that hit the stem index (stem matches only).
395    pub stem: Option<String>,
396    /// How many user turns back the key was found: 0 = the message the
397    /// request was routed on ([`LookupTable::find_answer_turns`]).
398    pub turn: usize,
399    /// A STRONG key ([`is_strong_key`]): the `key_first` policy acts on
400    /// it, and every search prefers it over a weak one.
401    pub strong: bool,
402}
403
404/// The candidate groups of every `( … )` of `s`, in order: each group
405/// runs from a `(` to the first `)` after it; when the group holds
406/// another `(` (a nested `(name (Binomial))`, `(syn. …)`) the text after
407/// its LAST `(` — the innermost group — comes first, then the whole
408/// group.
409fn parenthesised(s: &str) -> Vec<&str> {
410    let mut out = Vec::new();
411    let mut rest = s;
412    while let Some(open) = rest.find('(') {
413        let after = &rest[open + 1..];
414        let Some(close) = after.find(')') else {
415            break;
416        };
417        let group = &after[..close];
418        if let Some(inner) = group.rfind('(') {
419            out.push(&group[inner + 1..]);
420        }
421        out.push(group);
422        rest = &after[close + 1..];
423    }
424    out
425}
426
427/// A letter of the Latin script: ASCII, or a Latin letter with a
428/// diacritic the key normalisation cannot fold (`æ`, `ø`, `ß`, `ł`, …).
429fn is_latin_char(c: char) -> bool {
430    c.is_ascii_alphabetic()
431        || (c.is_alphabetic() && matches!(c, '\u{00C0}'..='\u{024F}' | '\u{1E00}'..='\u{1EFF}'))
432}
433
434/// A word of the Latin script ([`is_latin_char`] throughout). A binomial
435/// in parentheses is Latin; a Cyrillic or numeric group is not tried as
436/// one.
437fn is_latin_word(w: &str) -> bool {
438    !w.is_empty() && w.chars().all(is_latin_char)
439}
440
441/// A lowercase Latin word of at least `min` letters.
442fn lower_latin_word(t: &str, min: usize) -> bool {
443    t.chars().count() >= min && t.chars().all(|c| c.is_lowercase() && is_latin_char(c))
444}
445
446/// Every `Genus species` pair of the ORIGINAL (case-kept) message, in
447/// order: a capitalised Latin word of ≥ 3 letters followed by a lowercase
448/// Latin word of ≥ 3 letters, optionally hyphenated (`nux-vomica`: ≥ 3
449/// and ≥ 2 letters around the hyphen); punctuation around the words is
450/// ignored (`(Abies balsamea)?`). The pairs are tried as EXACT keys only,
451/// before the n-grams; a genus alone is never tried (ambiguous). A
452/// capitalised English pair (`What plant`) is a candidate too — harmless,
453/// it is a key or it is not.
454pub fn capitalised_binomials(s: &str) -> Vec<String> {
455    let genus = |t: &str| {
456        let mut cs = t.chars();
457        matches!(cs.next(), Some(c) if c.is_uppercase() && is_latin_char(c))
458            && lower_latin_word(cs.as_str(), 2)
459    };
460    let epithet = |t: &str| match t.split_once('-') {
461        None => lower_latin_word(t, 3),
462        Some((a, b)) => lower_latin_word(a, 3) && lower_latin_word(b, 2),
463    };
464    let toks: Vec<&str> = s
465        .split_whitespace()
466        .map(|t| t.trim_matches(|c: char| !is_latin_char(c)))
467        .collect();
468    toks.windows(2)
469        .filter(|w| genus(w[0]) && epithet(w[1]))
470        .map(|w| format!("{} {}", w[0], w[1]))
471        .collect()
472}
473
474// ───────────────────────── stemming ─────────────────────────
475
476/// Russian inflectional endings [`stem_word`] strips, longest first
477/// (adjective and noun endings of every case and number; `й` and `ь`
478/// so that `зверобой` / `зверобоя` and `полынь` / `полыни` meet).
479pub const RU_SUFFIXES: &[&str] = &[
480    "ого", "его", "ому", "ему", "ыми", "ими", "ами", "ями", // 3
481    "ая", "яя", "ой", "ей", "ий", "ый", "ое", "ее", "ые", "ых", "их", "ую", "юю", "ою", "ею",
482    "ам", "ям", "ах", "ях", "ом", "ем", "ым", "им", "ов", "ев", "ии", "ия", "ие", "ье", "ья",
483    "ью", // 2
484    "а", "я", "ы", "и", "у", "ю", "о", "е", "ь", "й", // 1
485];
486/// A Russian stem keeps at least this many letters (`чай`, `вид`, `дуб`
487/// are never shortened).
488pub const RU_STEM_MIN: usize = 3;
489/// An English stem keeps at least this many letters.
490pub const EN_STEM_MIN: usize = 4;
491/// A Latin-script word shorter than this is never stemmed (`sage`,
492/// `uses`, `wort`, `Pinus` stay as they are).
493pub const LATIN_STEM_MIN_WORD: usize = 5;
494/// A ONE-word stem candidate shorter than this (in letters) is not looked
495/// up: `виды` → `вид`, `чая` → `чая` never match by stem. Exact one-word
496/// matches are unaffected.
497pub const STEM_UNIGRAM_MIN: usize = 5;
498
499/// A light, deterministic stem of one normalised word — the same function
500/// on the keys (at open) and on the message (per query), so an inflected
501/// form meets its nominative key. Cyrillic: the longest ending of
502/// [`RU_SUFFIXES`] is stripped, repeatedly, while ≥ [`RU_STEM_MIN`]
503/// letters remain (`тойона` → `тойон`, `магнолии` → `магнол`,
504/// `лекарственной` → `лекарственн`, `зверобоя` → `зверобо` → `звероб`).
505/// Latin script, ≥ [`LATIN_STEM_MIN_WORD`] letters: `ies` → `i`, `es`
506/// after `ss`/`x`/`z`/`ch`/`sh`, or a final `s` (not after `ss`/`us`/`is`)
507/// is dropped when ≥ [`EN_STEM_MIN`] letters remain; otherwise a final
508/// `y` → `i` so that `berry` meets `berries`. Latin binomials mostly stay
509/// as they are (`pinus`, `officinalis`, `balsamea`). A word with a digit
510/// or another script is returned unchanged.
511pub fn stem_word(w: &str) -> String {
512    if w.is_empty() || !w.chars().all(char::is_alphabetic) {
513        return w.to_string();
514    }
515    if has_cyrillic(w) {
516        stem_cyrillic(w)
517    } else {
518        stem_latin(w)
519    }
520}
521
522fn stem_cyrillic(w: &str) -> String {
523    let mut s = w.to_string();
524    let mut n = s.chars().count();
525    'strip: loop {
526        for suf in RU_SUFFIXES {
527            let k = suf.chars().count();
528            if n >= k + RU_STEM_MIN && s.ends_with(suf) {
529                s.truncate(s.len() - suf.len());
530                n -= k;
531                continue 'strip;
532            }
533        }
534        return s;
535    }
536}
537
538fn stem_latin(w: &str) -> String {
539    let n = w.chars().count();
540    if n < LATIN_STEM_MIN_WORD {
541        return w.to_string();
542    }
543    let mut s = w.to_string();
544    if s.ends_with("ies") && n - 3 >= EN_STEM_MIN {
545        s.truncate(s.len() - 3);
546        s.push('i');
547    } else if ["sses", "xes", "zes", "ches", "shes"]
548        .iter()
549        .any(|e| s.ends_with(e))
550        && n - 2 >= EN_STEM_MIN
551    {
552        s.truncate(s.len() - 2);
553    } else if s.ends_with('s')
554        && !["ss", "us", "is"].iter().any(|e| s.ends_with(e))
555        && n - 1 >= EN_STEM_MIN
556    {
557        s.pop();
558    } else if s.ends_with('y') {
559        s.pop();
560        s.push('i');
561    }
562    s
563}
564
565/// [`stem_word`] of every word of a normalised key or message, joined by
566/// single spaces (the form both sides of the stem index hash).
567pub fn stem_key(norm: &str) -> String {
568    let mut out = String::with_capacity(norm.len());
569    for w in norm.split(' ').filter(|w| !w.is_empty()) {
570        if !out.is_empty() {
571            out.push(' ');
572        }
573        out.push_str(&stem_word(w));
574    }
575    out
576}
577
578/// Byte spans of the words of a normalised (single-spaced) string.
579fn word_spans(norm: &str) -> Vec<(usize, usize)> {
580    let mut out = Vec::new();
581    let mut start = None;
582    for (i, c) in norm.char_indices() {
583        if c == ' ' {
584            if let Some(s) = start.take() {
585                out.push((s, i));
586            }
587        } else if start.is_none() {
588            start = Some(i);
589        }
590    }
591    if let Some(s) = start {
592        out.push((s, norm.len()));
593    }
594    out
595}
596
597/// The second, sorted hash index of a table: hashes of the STEMMED key
598/// texts ([`stem_key`]), each mapped to its entry. A stem two keys of
599/// DIFFERENT entries share is ambiguous and is left out (`ambiguous`);
600/// the same stem from several keys of one entry (`ромашка аптечная`,
601/// `ромашки аптечной`) is one row. Consulted only when the exact index
602/// finds nothing.
603#[derive(Debug, Clone, Default, PartialEq, Eq)]
604pub struct StemIndex {
605    hashes: Vec<u64>,
606    entry_of: Vec<u32>,
607    /// Stems dropped because they named more than one entry.
608    pub ambiguous: usize,
609    /// Keys the index was built from.
610    pub keys_in: usize,
611}
612
613impl StemIndex {
614    /// From `(normalised key, entry)` pairs.
615    pub fn from_keys<'a>(keys: impl IntoIterator<Item = (&'a str, u32)>) -> Self {
616        let mut pairs: Vec<(u64, u32)> = keys
617            .into_iter()
618            .map(|(k, e)| (normalized_key_hash(&stem_key(k)), e))
619            .collect();
620        let keys_in = pairs.len();
621        pairs.sort_unstable();
622        pairs.dedup();
623        let mut hashes = Vec::with_capacity(pairs.len());
624        let mut entry_of = Vec::with_capacity(pairs.len());
625        let mut ambiguous = 0;
626        let mut i = 0;
627        while i < pairs.len() {
628            let mut j = i;
629            while j < pairs.len() && pairs[j].0 == pairs[i].0 {
630                j += 1;
631            }
632            if j - i == 1 {
633                hashes.push(pairs[i].0);
634                entry_of.push(pairs[i].1);
635            } else {
636                ambiguous += 1;
637            }
638            i = j;
639        }
640        Self {
641            hashes,
642            entry_of,
643            ambiguous,
644            keys_in,
645        }
646    }
647
648    /// The entry of stem hash `h` (binary search).
649    pub fn find(&self, h: u64) -> Option<u32> {
650        self.hashes
651            .binary_search(&h)
652            .ok()
653            .map(|i| self.entry_of[i])
654    }
655
656    /// Stems in the index.
657    pub fn len(&self) -> usize {
658        self.hashes.len()
659    }
660
661    pub fn is_empty(&self) -> bool {
662        self.hashes.is_empty()
663    }
664}
665
666/// The key texts a table's cards spell — the file stores only key hashes,
667/// so this is where the stem index gets its words from: every
668/// 1..=[`MAX_NGRAM`]-word n-gram of every slot's text (the raw JSON of the
669/// card and its fields) whose hash the exact index knows, once per key,
670/// with the entry the INDEX maps it to (a card that mentions another
671/// entry's plant recovers that plant's key correctly). A key no card
672/// spells — a synonym only the corpus knew — is not recovered and has no
673/// stem; the exact index still serves it. O(blob) at open.
674pub fn recover_key_texts(
675    hashes: &[u64],
676    entry_of: &[u32],
677    blob: &[u8],
678    offsets: &[u64],
679) -> Vec<(String, u32)> {
680    let find = |h: u64| hashes.binary_search(&h).ok().map(|i| entry_of[i]);
681    let mut seen: BTreeMap<u64, (String, u32)> = BTreeMap::new();
682    'slots: for w in offsets.windows(2) {
683        let (a, b) = (w[0] as usize, w[1] as usize);
684        if a > b || b > blob.len() {
685            continue;
686        }
687        let Ok(text) = std::str::from_utf8(&blob[a..b]) else {
688            continue;
689        };
690        let norm = normalize_key(text);
691        let spans = word_spans(&norm);
692        for n in 1..=MAX_NGRAM.min(spans.len()) {
693            for start in 0..=spans.len() - n {
694                let g = &norm[spans[start].0..spans[start + n - 1].1];
695                let h = normalized_key_hash(g);
696                if seen.contains_key(&h) {
697                    continue;
698                }
699                if let Some(e) = find(h) {
700                    seen.insert(h, (g.to_string(), e));
701                    if seen.len() == hashes.len() {
702                        break 'slots;
703                    }
704                }
705            }
706        }
707    }
708    seen.into_values().collect()
709}
710
711/// [`extract_key_with`] against the exact index only (no stem index).
712pub fn extract_key(message: &str, find: &dyn Fn(u64) -> Option<u32>) -> Option<KeyHit> {
713    extract_key_with(message, find, None)
714}
715
716/// The key of `message`, O(len): the STRONGEST key when the message holds
717/// one ([`extract_strong_key_with`]), else the first key of any strength.
718/// One rule for every path — the φ router's pick of a table, the
719/// `key_first` policy and the conversation memory resolve one message to
720/// one entry (review KF-3: `чай из ромашки аптечной` is chamomile whoever
721/// decided, not tea on the router's path and chamomile on key_first's).
722///
723/// The candidates, in order: a Latin group in parentheses (the binomial of
724/// a plant name — the innermost group of a nested one, then the whole
725/// group — 1..=[`MAX_NGRAM`] Latin words); a capitalised `Genus species`
726/// pair anywhere in the original message ([`capitalised_binomials`]);
727/// every n-gram of the `cmf-key-v2`-normalised message from the longest
728/// down against `find` (the exact index; the first hit at the longest
729/// length wins, ties → the first occurrence); then the STEMMED n-grams
730/// ([`stem_key`]) against `find_stem` (the table's [`StemIndex`]; a
731/// one-word stem must have ≥ [`STEM_UNIGRAM_MIN`] letters). The first
732/// STRONG candidate in that order wins ([`is_strong_key_text`]); without
733/// one, the first candidate of any strength — a weak stem candidate only
734/// when nothing exact was found. `None` = no key: the backbone runs
735/// unchanged.
736pub fn extract_key_with(
737    message: &str,
738    find: &dyn Fn(u64) -> Option<u32>,
739    find_stem: Option<&dyn Fn(u64) -> Option<u32>>,
740) -> Option<KeyHit> {
741    extract(message, find, find_stem, false)
742}
743
744/// A strong key has at least this many words of ≥
745/// [`STRONG_WORD_MIN_LETTERS`] letters ([`is_strong_key_text`]).
746pub const STRONG_KEY_MIN_WORDS: usize = 2;
747
748/// Letters a word needs to count towards [`STRONG_KEY_MIN_WORDS`] (`st`,
749/// `s`, `b`, `pb` never do).
750pub const STRONG_WORD_MIN_LETTERS: usize = 3;
751
752/// Is the normalised key text `norm` STRONG — a name the `key_first`
753/// policy may act on without the router (review KF-5)? No word with a
754/// digit (`sts 135`, `pti 2`, `a 41988`, `5f pb 22`), and at least
755/// [`STRONG_KEY_MIN_WORDS`] purely alphabetic words of ≥
756/// [`STRONG_WORD_MIN_LETTERS`] letters (`ромашки аптечной`, `pot
757/// marigold`, `st john s wort`; not `thc b`, not `чай`). A general phrase
758/// that happens to be a plant's common name (`scrambled eggs`, `gas
759/// plant`, `live forever`) passes this rule — only the builder's general
760/// probe and a stop list remove such a key (`lookup-build
761/// --general-prompts`, `--drop-keys`).
762pub fn is_strong_key_text(norm: &str) -> bool {
763    let mut words = 0usize;
764    for w in norm.split(' ').filter(|w| !w.is_empty()) {
765        if w.chars().any(char::is_numeric) {
766            return false;
767        }
768        if w.chars().count() >= STRONG_WORD_MIN_LETTERS && w.chars().all(char::is_alphabetic) {
769            words += 1;
770        }
771    }
772    words >= STRONG_KEY_MIN_WORDS
773}
774
775/// Is `hit` a STRONG key — one the `key_first` policy acts on without
776/// the router, and one every search prefers over a weak key of the same
777/// message? Its text passes [`is_strong_key_text`], whatever its source:
778/// an exact or stem n-gram of ≥ 2 real words (`ромашки аптечной`, `pot
779/// marigold`), a parenthesised Latin group, a capitalised `Genus species`
780/// pair. The source does not make a key strong: every exact key a
781/// capitalised pair finds is the same text as an n-gram of the message,
782/// so a pair rule stricter than the n-gram rule would protect nothing
783/// (`Common box` in `Why is Common box cutter so popular?` is the n-gram
784/// key `common box` too — only a stop list removes it) and would only
785/// reorder candidates (a binomial the cards spell over the common name
786/// typed first: `Spring vetchling (Lathyrus vernus (L.) Bernh.)` would
787/// then answer from a stub entry of the corpus). A one-word key — `чай`,
788/// `мята`, `календулы`, a lone `(Calendula)` — never is strong: such
789/// words occur in general chat, and only the router's decision may send
790/// them to a table.
791pub fn is_strong_key(hit: &KeyHit) -> bool {
792    hit.strong
793}
794
795/// The STRONGEST key of `message` for the `key_first` policy: the same
796/// search as [`extract_key_with`] (the parenthesised group, the
797/// capitalised binomial, the exact n-grams, then the stem n-grams) over
798/// strong candidates only ([`is_strong_key_text`]) — so a one-word exact
799/// key never hides a two-word stem key of the same message (`чай из
800/// ромашки аптечной`). `None` = no strong key; the result always passes
801/// [`is_strong_key`].
802pub fn extract_strong_key_with(
803    message: &str,
804    find: &dyn Fn(u64) -> Option<u32>,
805    find_stem: Option<&dyn Fn(u64) -> Option<u32>>,
806) -> Option<KeyHit> {
807    extract(message, find, find_stem, true)
808}
809
810/// A strong candidate returns at once; a weak one is kept as the fallback
811/// (the first in rule order) unless only strong keys are wanted.
812fn offer(fallback: &mut Option<KeyHit>, hit: KeyHit, strong_only: bool) -> Option<KeyHit> {
813    if hit.strong {
814        return Some(hit);
815    }
816    if !strong_only && fallback.is_none() {
817        *fallback = Some(hit);
818    }
819    None
820}
821
822/// The one extraction pass behind [`extract_key_with`] (`strong_only`
823/// false: the first strong candidate, else the first of any strength) and
824/// [`extract_strong_key_with`] (`strong_only` true). One scan of each
825/// stage: a candidate that could no longer change the result (weak, with
826/// a fallback already kept or not wanted) is not hashed.
827fn extract(
828    message: &str,
829    find: &dyn Fn(u64) -> Option<u32>,
830    find_stem: Option<&dyn Fn(u64) -> Option<u32>>,
831    strong_only: bool,
832) -> Option<KeyHit> {
833    let mut fallback: Option<KeyHit> = None;
834    let exact_hit = |key: String, hash: u64, entry: u32, words: usize, source, strong| KeyHit {
835        key,
836        hash,
837        entry,
838        words,
839        source,
840        via: MatchVia::Exact,
841        stem: None,
842        turn: 0,
843        strong,
844    };
845    for group in parenthesised(message) {
846        let norm = normalize_key(group);
847        let words: Vec<&str> = norm.split(' ').filter(|w| !w.is_empty()).collect();
848        if words.is_empty() || words.len() > MAX_NGRAM || !words.iter().all(|w| is_latin_word(w)) {
849            continue;
850        }
851        let n_words = words.len();
852        let strong = is_strong_key_text(&norm);
853        if !strong && (strong_only || fallback.is_some()) {
854            continue;
855        }
856        let hash = normalized_key_hash(&norm);
857        if let Some(entry) = find(hash) {
858            let hit = exact_hit(norm, hash, entry, n_words, KeySource::Parenthesised, strong);
859            if let Some(h) = offer(&mut fallback, hit, strong_only) {
860                return Some(h);
861            }
862        }
863    }
864    for pair in capitalised_binomials(message) {
865        let norm = normalize_key(&pair);
866        let n_words = norm.split(' ').filter(|w| !w.is_empty()).count();
867        if n_words == 0 || n_words > MAX_NGRAM {
868            continue;
869        }
870        let strong = is_strong_key_text(&norm);
871        if !strong && (strong_only || fallback.is_some()) {
872            continue;
873        }
874        let hash = normalized_key_hash(&norm);
875        if let Some(entry) = find(hash) {
876            let hit = exact_hit(norm, hash, entry, n_words, KeySource::Binomial, strong);
877            if let Some(h) = offer(&mut fallback, hit, strong_only) {
878                return Some(h);
879            }
880        }
881    }
882    let norm = normalize_key(message);
883    let spans = word_spans(&norm);
884    if spans.is_empty() {
885        return fallback;
886    }
887    let text = |start: usize, n: usize| &norm[spans[start].0..spans[start + n - 1].1];
888    // Exact n-grams, the longest first, ties → the first occurrence.
889    for n in (1..=MAX_NGRAM.min(spans.len())).rev() {
890        for start in 0..=spans.len() - n {
891            let g = text(start, n);
892            let strong = n >= STRONG_KEY_MIN_WORDS && is_strong_key_text(g);
893            if !strong && (strong_only || fallback.is_some()) {
894                continue;
895            }
896            let hash = normalized_key_hash(g);
897            if let Some(entry) = find(hash) {
898                let hit = exact_hit(g.to_string(), hash, entry, n, KeySource::Ngram, strong);
899                if let Some(h) = offer(&mut fallback, hit, strong_only) {
900                    return Some(h);
901                }
902            }
903        }
904    }
905    let Some(find_stem) = find_stem else {
906        return fallback;
907    };
908    let stemmed = stem_key(&norm);
909    let sspans = word_spans(&stemmed);
910    // A stem never empties a word (the minimum-letters floors), so the
911    // words of the two strings are in one-to-one correspondence.
912    debug_assert_eq!(sspans.len(), spans.len());
913    if sspans.len() != spans.len() {
914        return fallback;
915    }
916    // Stem n-grams: the strong ones always (a two-word stem key beats a
917    // one-word exact key), a weak one only when nothing matched at all.
918    for n in (1..=MAX_NGRAM.min(sspans.len())).rev() {
919        for start in 0..=sspans.len() - n {
920            // The strength of a stem candidate is the message's own words.
921            let strong = n >= STRONG_KEY_MIN_WORDS && is_strong_key_text(text(start, n));
922            if !strong && (strong_only || fallback.is_some()) {
923                continue;
924            }
925            let g = &stemmed[sspans[start].0..sspans[start + n - 1].1];
926            if n == 1 && g.chars().count() < STEM_UNIGRAM_MIN {
927                continue;
928            }
929            let hash = normalized_key_hash(g);
930            if let Some(entry) = find_stem(hash) {
931                let hit = KeyHit {
932                    key: text(start, n).to_string(),
933                    hash,
934                    entry,
935                    words: n,
936                    source: KeySource::Ngram,
937                    via: MatchVia::Stem,
938                    stem: Some(g.to_string()),
939                    turn: 0,
940                    strong,
941                };
942                if let Some(h) = offer(&mut fallback, hit, strong_only) {
943                    return Some(h);
944                }
945            }
946        }
947    }
948    fallback
949}
950
951// ───────────────────────── cards ─────────────────────────
952
953/// One slot of the text blob: `{"card": "...", "fields": {name: text}}`.
954#[derive(Debug, Clone, Default, PartialEq, Eq)]
955pub struct Card {
956    pub card: String,
957    pub fields: BTreeMap<String, String>,
958}
959
960impl Card {
961    /// The slot's JSON object (the file validation guarantees an object;
962    /// a missing `card` is empty, a non-string field value is rendered as
963    /// JSON).
964    pub fn parse(text: &str) -> Result<Self, String> {
965        let v: serde_json::Value =
966            serde_json::from_str(text).map_err(|e| format!("card is not JSON: {e}"))?;
967        let obj = v.as_object().ok_or("card is not a JSON object")?;
968        let card = obj
969            .get("card")
970            .map(|c| match c {
971                serde_json::Value::String(s) => s.clone(),
972                other => other.to_string(),
973            })
974            .unwrap_or_default();
975        let mut fields = BTreeMap::new();
976        if let Some(serde_json::Value::Object(f)) = obj.get("fields") {
977            for (k, v) in f {
978                let text = match v {
979                    serde_json::Value::String(s) => s.clone(),
980                    serde_json::Value::Null => continue,
981                    other => other.to_string(),
982                };
983                fields.insert(k.clone(), text);
984            }
985        }
986        Ok(Self { card, fields })
987    }
988
989    /// A slot the builder wrote for a language the entry does not carry
990    /// (`{"card": "", "fields": {}}`), or a card with nothing in it.
991    pub fn is_empty(&self) -> bool {
992        self.card.trim().is_empty() && self.fields.values().all(|t| t.trim().is_empty())
993    }
994
995    /// The first sentence of the card (the name and its one-line
996    /// identity): up to the first `.`, `!` or `?` followed by whitespace
997    /// or the end, skipping a terminator that would leave fewer than
998    /// [`FIRST_SENTENCE_MIN`] chars (`Hypericum perforatum L.`); without
999    /// one, the card up to [`FIRST_SENTENCE_MAX`] chars at a word
1000    /// boundary.
1001    pub fn first_sentence(&self) -> &str {
1002        first_sentence(&self.card)
1003    }
1004}
1005
1006/// [`Card::first_sentence`]: a terminator this early is an abbreviation.
1007pub const FIRST_SENTENCE_MIN: usize = 12;
1008/// [`Card::first_sentence`]: the cap when the card has no terminator.
1009pub const FIRST_SENTENCE_MAX: usize = 240;
1010
1011fn first_sentence(card: &str) -> &str {
1012    let card = card.trim();
1013    let bytes = card.as_bytes();
1014    let mut chars_seen = 0usize;
1015    let mut word_start = 0usize;
1016    for (i, c) in card.char_indices() {
1017        chars_seen += 1;
1018        if c.is_whitespace() {
1019            word_start = i + c.len_utf8();
1020            continue;
1021        }
1022        if matches!(c, '.' | '!' | '?') {
1023            let next = bytes.get(i + 1).copied();
1024            let ends = next.is_none_or(|b| b.is_ascii_whitespace());
1025            // `L.`, `Mill.`-style author abbreviations of a binomial: a
1026            // one-letter word before the period is not a sentence end.
1027            let word = &card[word_start..i];
1028            let abbrev = word.chars().count() == 1 && word.chars().all(char::is_alphabetic);
1029            if ends && !abbrev && chars_seen >= FIRST_SENTENCE_MIN {
1030                return &card[..i + 1];
1031            }
1032        }
1033    }
1034    if card.chars().count() <= FIRST_SENTENCE_MAX {
1035        return card;
1036    }
1037    let cut = card
1038        .char_indices()
1039        .nth(FIRST_SENTENCE_MAX)
1040        .map_or(card.len(), |(i, _)| i);
1041    let head = &card[..cut];
1042    head.rfind(char::is_whitespace)
1043        .map_or(head, |w| &head[..w])
1044        .trim_end()
1045}
1046
1047/// The human label of a card field in the slot language (`context` mode
1048/// writes `{label}: {text}`); an unknown field or language keeps the
1049/// field name.
1050pub fn field_label(field: &str, lang: &str) -> String {
1051    let ru = match field {
1052        "family" => "Семейство",
1053        "parts" => "Части",
1054        "compounds" => "Действующие вещества",
1055        "uses" => "Применение",
1056        "preparations" => "Формы и препараты",
1057        "safety" => "Безопасность",
1058        "evidence" => "Доказательства",
1059        _ => "",
1060    };
1061    let en = match field {
1062        "family" => "Family",
1063        "parts" => "Parts",
1064        "compounds" => "Compounds",
1065        "uses" => "Uses",
1066        "preparations" => "Preparations",
1067        "safety" => "Safety",
1068        "evidence" => "Evidence",
1069        _ => "",
1070    };
1071    match (lang, ru, en) {
1072        ("ru", r, _) if !r.is_empty() => r.to_string(),
1073        ("en", _, e) if !e.is_empty() => e.to_string(),
1074        _ => {
1075            let mut c = field.chars();
1076            match c.next() {
1077                Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
1078                None => String::new(),
1079            }
1080        }
1081    }
1082}
1083
1084/// The text `context` mode prepends for a card: the whole card when no
1085/// field was selected; otherwise its first sentence and the field as
1086/// `{label}: {text}` — a short excerpt that keeps the fact next to the
1087/// question. A bounded-attention genome (swa_sink, window 128) would
1088/// otherwise see the family line of a 1 000-char card hundreds of tokens
1089/// before the question, beyond its window.
1090pub fn context_excerpt(card: &Card, field: Option<&str>, lang: &str) -> String {
1091    match field.and_then(|f| card.fields.get(f).map(|t| (f, t))) {
1092        Some((f, text)) => {
1093            let head = card.first_sentence();
1094            let label = field_label(f, lang);
1095            if head.is_empty() {
1096                format!("{label}: {}", text.trim())
1097            } else {
1098                format!("{head}\n{label}: {}", text.trim())
1099            }
1100        }
1101        None => card.card.clone(),
1102    }
1103}
1104
1105// ───────────────────────── the table ─────────────────────────
1106
1107/// One mounted lookup record.
1108pub struct LookupTable {
1109    pub id: String,
1110    pub info: LookupInfo,
1111    /// `keys.hash`, sorted ascending, unique.
1112    hashes: Vec<u64>,
1113    /// `keys.entry`, parallel to `hashes`.
1114    entry_of: Vec<u32>,
1115    /// `entries.off`: slot `s` is `blob[off[s]..off[s+1]]`.
1116    offsets: Vec<u64>,
1117    /// The stem index over the key texts recovered from the cards
1118    /// ([`recover_key_texts`]); consulted when `hashes` finds nothing.
1119    stems: StemIndex,
1120    /// Key texts recovered from the cards (≤ `keys`).
1121    recovered: usize,
1122    model: Arc<CmfModel>,
1123    text_name: String,
1124}
1125
1126impl LookupTable {
1127    /// The record `id` when it is a lookup record.
1128    pub fn record<'a>(model: &'a CmfModel, id: &str) -> Option<&'a SkillRecord> {
1129        model
1130            .header
1131            .skills
1132            .iter()
1133            .find(|s| s.id == id && s.kind.as_deref() == Some(skill_kind::LOOKUP))
1134    }
1135
1136    pub fn is_lookup(model: &CmfModel, id: &str) -> bool {
1137        Self::record(model, id).is_some()
1138    }
1139
1140    /// Ids of every lookup record of the file, in header order.
1141    pub fn lookup_ids(model: &CmfModel) -> Vec<String> {
1142        model
1143            .header
1144            .skills
1145            .iter()
1146            .filter(|s| s.kind.as_deref() == Some(skill_kind::LOOKUP))
1147            .map(|s| s.id.clone())
1148            .collect()
1149    }
1150
1151    /// Read the record's four tensors (the file's `open()` validated
1152    /// their values; only the shapes are re-checked here). The blob stays
1153    /// in the mapping.
1154    pub fn open(model: &Arc<CmfModel>, id: &str) -> Result<Self, String> {
1155        let rec = Self::record(model, id).ok_or_else(|| {
1156            format!(
1157                "skill '{id}' is not a lookup record (header.skills: {:?})",
1158                model
1159                    .header
1160                    .skills
1161                    .iter()
1162                    .map(|s| format!("{}={}", s.id, s.kind.as_deref().unwrap_or("v1")))
1163                    .collect::<Vec<_>>()
1164            )
1165        })?;
1166        let info = rec
1167            .lookup
1168            .clone()
1169            .ok_or_else(|| format!("skill '{id}': lookup record without `lookup`"))?;
1170        fn bytes<'m>(model: &'m CmfModel, id: &str, leaf: &str) -> Result<&'m [u8], String> {
1171            let name = lookup_tensor_name(id, leaf);
1172            model
1173                .tensor_bytes(&name)
1174                .map_err(|e| format!("skill '{id}': tensor '{name}': {e}"))
1175        }
1176        let hashes = read_u64_le(bytes(model, id, lookup_leaf::KEYS_HASH)?);
1177        let entry_of = read_u32_le(bytes(model, id, lookup_leaf::KEYS_ENTRY)?);
1178        let offsets = read_u64_le(bytes(model, id, lookup_leaf::ENTRIES_OFF)?);
1179        let text_name = lookup_tensor_name(id, lookup_leaf::TEXT);
1180        let blob = bytes(model, id, lookup_leaf::TEXT)?;
1181        let blob_len = blob.len();
1182        if hashes.len() != info.keys || entry_of.len() != info.keys {
1183            return Err(format!(
1184                "skill '{id}': {} hashes / {} entries for lookup.keys {}",
1185                hashes.len(),
1186                entry_of.len(),
1187                info.keys
1188            ));
1189        }
1190        let slots = info
1191            .slots()
1192            .ok_or_else(|| format!("skill '{id}': entries × langs overflows"))?;
1193        if offsets.len() != slots + 1 {
1194            return Err(format!(
1195                "skill '{id}': {} offsets for {slots} slots (+1)",
1196                offsets.len()
1197            ));
1198        }
1199        if offsets.last().is_some_and(|&l| l as usize > blob_len) {
1200            return Err(format!(
1201                "skill '{id}': offsets end beyond the text blob ({blob_len} bytes)"
1202            ));
1203        }
1204        // The second index (no format change): the key texts the cards
1205        // spell, stemmed. Built once per open, O(blob).
1206        let recovered_keys = recover_key_texts(&hashes, &entry_of, blob, &offsets);
1207        let stems = StemIndex::from_keys(recovered_keys.iter().map(|(k, e)| (k.as_str(), *e)));
1208        Ok(Self {
1209            id: id.to_string(),
1210            info,
1211            hashes,
1212            entry_of,
1213            offsets,
1214            stems,
1215            recovered: recovered_keys.len(),
1216            model: model.clone(),
1217            text_name,
1218        })
1219    }
1220
1221    pub fn keys(&self) -> usize {
1222        self.hashes.len()
1223    }
1224
1225    /// Key texts recovered from the cards at open (the stem index is
1226    /// built from these; a key no card spells has no stem).
1227    pub fn recovered_keys(&self) -> usize {
1228        self.recovered
1229    }
1230
1231    /// The stem index (stems, ambiguous stems dropped).
1232    pub fn stems(&self) -> &StemIndex {
1233        &self.stems
1234    }
1235
1236    pub fn entries(&self) -> usize {
1237        self.info.entries
1238    }
1239
1240    pub fn langs(&self) -> &[String] {
1241        &self.info.langs
1242    }
1243
1244    pub fn fields(&self) -> &[String] {
1245        &self.info.fields
1246    }
1247
1248    /// The UTF-8 card blob (zero-copy from the file's mapping).
1249    pub fn blob(&self) -> &[u8] {
1250        self.model
1251            .tensor_bytes(&self.text_name)
1252            .expect("lookup text tensor was read at open")
1253    }
1254
1255    /// The entry of key hash `h` (binary search over the sorted hashes).
1256    pub fn find_hash(&self, h: u64) -> Option<u32> {
1257        self.hashes
1258            .binary_search(&h)
1259            .ok()
1260            .map(|i| self.entry_of[i])
1261    }
1262
1263    /// The entry of stem hash `h` ([`StemIndex::find`]).
1264    pub fn find_stem_hash(&self, h: u64) -> Option<u32> {
1265        self.stems.find(h)
1266    }
1267
1268    /// The raw slot text of `entry` in language index `lang`.
1269    pub fn slot_text(&self, entry: u32, lang: usize) -> Option<&str> {
1270        let s = self.info.slot(entry as usize, lang)?;
1271        let (a, b) = (self.offsets[s] as usize, self.offsets[s + 1] as usize);
1272        std::str::from_utf8(&self.blob()[a..b]).ok()
1273    }
1274
1275    /// The parsed card of `entry` in language index `lang`.
1276    pub fn card(&self, entry: u32, lang: usize) -> Result<Card, String> {
1277        let text = self.slot_text(entry, lang).ok_or_else(|| {
1278            format!(
1279                "skill '{}': no slot for entry {entry}, lang {lang} (entries {}, langs {:?})",
1280                self.id, self.info.entries, self.info.langs
1281            )
1282        })?;
1283        Card::parse(text).map_err(|e| format!("skill '{}': entry {entry}: {e}", self.id))
1284    }
1285
1286    /// [`extract_key_with`] against this table (the exact index, then the
1287    /// stem index): the strongest key of the message, else the first weak
1288    /// one — the rule every path shares (the router's pick, `key_first`,
1289    /// the conversation memory).
1290    pub fn find_key(&self, message: &str) -> Option<KeyHit> {
1291        extract_key_with(message, &|h| self.find_hash(h), Some(&|h| self.find_stem_hash(h)))
1292    }
1293
1294    /// [`extract_strong_key_with`] against this table: the key the
1295    /// `key_first` policy acts on ([`is_strong_key`]).
1296    pub fn find_strong_key(&self, message: &str) -> Option<KeyHit> {
1297        extract_strong_key_with(message, &|h| self.find_hash(h), Some(&|h| self.find_stem_hash(h)))
1298    }
1299
1300    /// The record's routing policy ([`LookupPolicy::of`]: a value this
1301    /// reader does not know is `router_and_key`).
1302    pub fn policy(&self) -> LookupPolicy {
1303        LookupPolicy::of(&self.info)
1304    }
1305
1306    /// The table's answer to `message`: the key, the language by script
1307    /// (an empty slot — the entry does not carry that language — falls
1308    /// back to the first language whose card has text), the field by
1309    /// keywords — its text when the card has it, else the whole card.
1310    /// `None` = no key in the message, or a key whose card is empty in
1311    /// every language ([`Self::find_answer`] tells the two apart).
1312    pub fn answer(&self, message: &str) -> Result<Option<LookupAnswer>, String> {
1313        Ok(match self.find_answer(message)? {
1314            Found::Answer(a) => Some(a),
1315            Found::NoKey | Found::EmptyCard(_) => None,
1316        })
1317    }
1318
1319    /// [`Self::answer`] with the miss reason: no key in the message, or a
1320    /// key found but no card text in any language.
1321    pub fn find_answer(&self, message: &str) -> Result<Found, String> {
1322        self.find_answer_turns(&[message])
1323    }
1324
1325    /// [`Self::find_answer`] with conversation memory: `turns` are the
1326    /// user messages, the LAST one first (the message the request was
1327    /// routed on), then the earlier ones back in time (the caller passes
1328    /// at most [`MEMORY_TURNS`]). The key comes from the first turn that
1329    /// holds one (`KeyHit::turn` = how many turns back) — within a turn
1330    /// by [`Self::find_key`]'s rule, the strongest key first, the same
1331    /// rule `key_first` applies; the language and the field come from
1332    /// `turns[0]` — the question being asked now.
1333    /// An empty `turns` is [`Found::NoKey`].
1334    pub fn find_answer_turns(&self, turns: &[&str]) -> Result<Found, String> {
1335        let Some((turn, mut key)) = turns
1336            .iter()
1337            .enumerate()
1338            .find_map(|(i, t)| self.find_key(t).map(|k| (i, k)))
1339        else {
1340            return Ok(Found::NoKey);
1341        };
1342        key.turn = turn;
1343        self.answer_for_key(key, turns[0])
1344    }
1345
1346    /// The card of a key already found (by [`Self::find_key`],
1347    /// [`Self::find_strong_key`], or in an earlier turn), answering
1348    /// `message`: the language by its script (an empty slot falls back to
1349    /// the first language with text), the field by its keywords.
1350    /// [`Found::EmptyCard`] when the entry has no text in any language.
1351    pub fn answer_for_key(&self, key: KeyHit, message: &str) -> Result<Found, String> {
1352        let want = pick_lang(message, &self.info.langs);
1353        let order = std::iter::once(want).chain((0..self.info.langs.len()).filter(|&l| l != want));
1354        let mut chosen = None;
1355        for lang_i in order {
1356            let card = self.card(key.entry, lang_i)?;
1357            if !card.is_empty() {
1358                chosen = Some((lang_i, card));
1359                break;
1360            }
1361        }
1362        let Some((lang_i, card)) = chosen else {
1363            return Ok(Found::EmptyCard(key));
1364        };
1365        let lang = self.info.langs[lang_i].clone();
1366        let field = select_field(message)
1367            .filter(|f| card.fields.get(*f).is_some_and(|t| !t.trim().is_empty()))
1368            .map(str::to_string);
1369        let text = match &field {
1370            Some(f) => card.fields[f].clone(),
1371            None => card.card.clone(),
1372        };
1373        let context = context_excerpt(&card, field.as_deref(), &lang);
1374        Ok(Found::Answer(LookupAnswer {
1375            id: self.id.clone(),
1376            key,
1377            lang,
1378            field,
1379            text,
1380            card: context,
1381            full_card: card.card,
1382            decided_by: DecidedBy::Router,
1383        }))
1384    }
1385}
1386
1387/// [`LookupTable::find_answer`]: what the table found for a message.
1388#[derive(Debug, Clone, PartialEq, Eq)]
1389pub enum Found {
1390    /// No key in the message.
1391    NoKey,
1392    /// A key, but its entry has no card text in any language (the
1393    /// builder stores `{"card": "", "fields": {}}` for a language the
1394    /// entry does not carry).
1395    EmptyCard(KeyHit),
1396    Answer(LookupAnswer),
1397}
1398
1399/// What the table answered.
1400#[derive(Debug, Clone, PartialEq, Eq)]
1401pub struct LookupAnswer {
1402    /// The record id.
1403    pub id: String,
1404    pub key: KeyHit,
1405    /// The slot language used.
1406    pub lang: String,
1407    /// The field chosen (`None` = the whole card).
1408    pub field: Option<String>,
1409    /// The answer text (`answer` mode).
1410    pub text: String,
1411    /// What `context` mode prepends ([`context_excerpt`]): the whole card
1412    /// when no field was selected, else its first sentence and the field.
1413    pub card: String,
1414    /// The whole card text.
1415    pub full_card: String,
1416    /// Who sent the request to the record: the router's decision, or the
1417    /// `key_first` policy over a backbone decision.
1418    pub decided_by: DecidedBy,
1419}
1420
1421impl LookupAnswer {
1422    /// One human line: `lookup: <id> | key "…" → entry N (W words,
1423    /// <source>, <exact|stem>) [| N turns back] | <lang> | field … |
1424    /// decided by <router|key_first>`.
1425    pub fn describe(&self) -> String {
1426        format!(
1427            "lookup: {} | key {:?}{} → entry {} ({} words, {}, {}){} | {} | field {} | decided by {}",
1428            self.id,
1429            self.key.key,
1430            self.key
1431                .stem
1432                .as_deref()
1433                .map(|s| format!(" (stem {s:?})"))
1434                .unwrap_or_default(),
1435            self.key.entry,
1436            self.key.words,
1437            self.key.source.label(),
1438            self.key.via.label(),
1439            if self.key.turn > 0 {
1440                format!(" | {} turns back", self.key.turn)
1441            } else {
1442                String::new()
1443            },
1444            self.lang,
1445            self.field.as_deref().unwrap_or("— (whole card)"),
1446            self.decided_by.label()
1447        )
1448    }
1449}
1450
1451/// The lookup tables of a file, opened on first use and kept
1452/// (`Sync`: `serve` shares one behind its router).
1453pub struct LookupTables {
1454    model: Arc<CmfModel>,
1455    tables: Mutex<BTreeMap<String, Arc<LookupTable>>>,
1456}
1457
1458impl LookupTables {
1459    pub fn new(model: Arc<CmfModel>) -> Self {
1460        Self {
1461            model,
1462            tables: Mutex::new(BTreeMap::new()),
1463        }
1464    }
1465
1466    pub fn model(&self) -> &Arc<CmfModel> {
1467        &self.model
1468    }
1469
1470    /// Does the file carry at least one lookup record?
1471    pub fn any(&self) -> bool {
1472        !LookupTable::lookup_ids(&self.model).is_empty()
1473    }
1474
1475    /// Ids of the file's lookup records.
1476    pub fn ids(&self) -> Vec<String> {
1477        LookupTable::lookup_ids(&self.model)
1478    }
1479
1480    /// The table of `id`; `None` when `id` is not a lookup record.
1481    pub fn get(&self, id: &str) -> Result<Option<Arc<LookupTable>>, String> {
1482        if !LookupTable::is_lookup(&self.model, id) {
1483            return Ok(None);
1484        }
1485        let mut tables = self.tables.lock().unwrap_or_else(|e| e.into_inner());
1486        if let Some(t) = tables.get(id) {
1487            return Ok(Some(t.clone()));
1488        }
1489        let t = Arc::new(LookupTable::open(&self.model, id)?);
1490        tables.insert(id.to_string(), t.clone());
1491        Ok(Some(t))
1492    }
1493}
1494
1495// ───────────────────────── the routed outcome ─────────────────────────
1496
1497/// What a routed request does once its target is known.
1498#[derive(Debug, Clone, PartialEq, Eq)]
1499pub enum LookupOutcome {
1500    /// The target is the backbone or an ordinary skill: run its lane.
1501    NotLookup,
1502    /// The target is a lookup record but the mode is `off`: the backbone
1503    /// runs the plain message.
1504    Off { id: String },
1505    /// A lookup record, no key in the message: the backbone runs the
1506    /// plain message — unchanged, bit-identical to F0.
1507    Miss { id: String },
1508    /// `answer` mode: the table's text IS the answer; nothing runs.
1509    Answer(LookupAnswer),
1510    /// `context` mode: the backbone runs [`context_prompt`]`(card, message)`.
1511    Context(LookupAnswer),
1512}
1513
1514impl LookupOutcome {
1515    /// The table's answer when a key was found (either mode).
1516    pub fn hit(&self) -> Option<&LookupAnswer> {
1517        match self {
1518            Self::Answer(a) | Self::Context(a) => Some(a),
1519            _ => None,
1520        }
1521    }
1522
1523    pub fn is_hit(&self) -> bool {
1524        self.hit().is_some()
1525    }
1526
1527    /// The text the backbone generates from (`context` mode prepends the
1528    /// card; everything else keeps `user_text`).
1529    pub fn generation_text(&self, user_text: &str) -> String {
1530        match self {
1531            Self::Context(a) => context_prompt(&a.card, user_text),
1532            _ => user_text.to_string(),
1533        }
1534    }
1535
1536    /// One human line for a lookup target (`None` for [`Self::NotLookup`]).
1537    pub fn describe(&self) -> Option<String> {
1538        Some(match self {
1539            Self::NotLookup => return None,
1540            Self::Off { id } => format!("lookup: {id} | mode off — the backbone runs"),
1541            Self::Miss { id } => {
1542                format!("lookup: {id} | no key in the message — the backbone runs unchanged")
1543            }
1544            Self::Answer(a) => format!("{} | mode answer", a.describe()),
1545            Self::Context(a) => format!("{} | mode context", a.describe()),
1546        })
1547    }
1548
1549    /// The lookup record the router decided on (`Off`, `Miss` and the
1550    /// hits), `None` for any other target.
1551    pub fn lookup_id(&self) -> Option<&str> {
1552        match self {
1553            Self::NotLookup => None,
1554            Self::Off { id } | Self::Miss { id } => Some(id),
1555            Self::Answer(a) | Self::Context(a) => Some(&a.id),
1556        }
1557    }
1558
1559    /// Who sent the request to the lookup record (`None` for
1560    /// [`Self::NotLookup`]): a hit says, `Off` / `Miss` come only from the
1561    /// decision the lookup step was handed (the `key_first` policy takes a
1562    /// request only when the table answers it).
1563    pub fn decided_by(&self) -> Option<DecidedBy> {
1564        match self {
1565            Self::NotLookup => None,
1566            Self::Off { .. } | Self::Miss { .. } => Some(DecidedBy::Router),
1567            Self::Answer(a) | Self::Context(a) => Some(a.decided_by),
1568        }
1569    }
1570
1571    /// Adds the lookup fields to a route summary
1572    /// ([`RouteDecision::summary_json`]): `lookup_hit`; whenever the
1573    /// target was a lookup record `lookup_mode`, `decided_target` (the
1574    /// record the decision chose — `target` is the lane that ran, the
1575    /// backbone after a miss) and `decided_by` (`router` | `key_first`);
1576    /// on a hit `lookup_key`, `lookup_entry`, `lookup_lang`, `field`,
1577    /// `lookup_match` (`exact` | `stem`), `lookup_turn` (how many user
1578    /// turns back the key was found; 0 = the message routed on),
1579    /// `lookup_key_words`.
1580    pub fn annotate(&self, summary: &mut serde_json::Value, mode: LookupMode) {
1581        let serde_json::Value::Object(m) = summary else {
1582            return;
1583        };
1584        m.insert("lookup_hit".into(), serde_json::json!(self.is_hit()));
1585        if let Some(id) = self.lookup_id() {
1586            m.insert("lookup_mode".into(), serde_json::json!(mode.label()));
1587            m.insert("decided_target".into(), serde_json::json!(id));
1588        }
1589        if let Some(by) = self.decided_by() {
1590            m.insert("decided_by".into(), serde_json::json!(by.label()));
1591        }
1592        if let Some(a) = self.hit() {
1593            m.insert("lookup_key".into(), serde_json::json!(a.key.key));
1594            m.insert("lookup_entry".into(), serde_json::json!(a.key.entry));
1595            m.insert("lookup_lang".into(), serde_json::json!(a.lang));
1596            m.insert("field".into(), serde_json::json!(a.field));
1597            m.insert("lookup_match".into(), serde_json::json!(a.key.via.label()));
1598            m.insert("lookup_turn".into(), serde_json::json!(a.key.turn));
1599            m.insert("lookup_key_words".into(), serde_json::json!(a.key.words));
1600        }
1601    }
1602}
1603
1604/// The lookup step after the routing decision: a target that is a lookup
1605/// record becomes its outcome under `mode`. No key (or mode `off`) turns
1606/// the decision into the BACKBONE with the reason kept; a hit keeps the
1607/// skill target and notes the key. Any other target passes through as
1608/// [`LookupOutcome::NotLookup`]. One user message, no memory. The
1609/// decision is taken as given — the `key_first` policy needs to know how
1610/// it was made ([`resolve_lookup_gated`]).
1611pub fn resolve_lookup(
1612    tables: &LookupTables,
1613    decision: RouteDecision,
1614    user_text: &str,
1615    mode: LookupMode,
1616) -> Result<(RouteDecision, LookupOutcome), String> {
1617    resolve_lookup_turns(tables, decision, &[user_text], mode)
1618}
1619
1620/// [`resolve_lookup`] with conversation memory: `turns` as
1621/// [`LookupTable::find_answer_turns`] takes them — the message routed on
1622/// first, then the earlier user turns (`serve` passes up to
1623/// [`MEMORY_TURNS`]).
1624pub fn resolve_lookup_turns(
1625    tables: &LookupTables,
1626    decision: RouteDecision,
1627    turns: &[&str],
1628    mode: LookupMode,
1629) -> Result<(RouteDecision, LookupOutcome), String> {
1630    let Some(id) = decision.skill().map(str::to_string) else {
1631        return Ok((decision, LookupOutcome::NotLookup));
1632    };
1633    let Some(table) = tables.get(&id)? else {
1634        return Ok((decision, LookupOutcome::NotLookup));
1635    };
1636    let to_backbone = |d: RouteDecision, why: &str| RouteDecision {
1637        target: RouteTarget::Backbone,
1638        routing: d.routing,
1639        reason: format!("{why} [decision was: {}]", d.reason),
1640    };
1641    if mode == LookupMode::Off {
1642        let d = to_backbone(
1643            decision,
1644            &format!("lookup '{id}' ignored (lookup mode off): the backbone runs"),
1645        );
1646        return Ok((d, LookupOutcome::Off { id }));
1647    }
1648    let answer = match table.find_answer_turns(turns)? {
1649        Found::Answer(a) => a,
1650        Found::NoKey => {
1651            let d = to_backbone(
1652                decision,
1653                &format!("lookup '{id}': no key in the message — the backbone runs unchanged"),
1654            );
1655            return Ok((d, LookupOutcome::Miss { id }));
1656        }
1657        Found::EmptyCard(key) => {
1658            let d = to_backbone(
1659                decision,
1660                &format!(
1661                    "lookup '{id}': key {:?} → entry {} has no card in any language — the \
1662                     backbone runs unchanged",
1663                    key.key, key.entry
1664                ),
1665            );
1666            return Ok((d, LookupOutcome::Miss { id }));
1667        }
1668    };
1669    let mut d = decision;
1670    d.reason = format!(
1671        "{}; lookup key {:?} → entry {}{}{}{}",
1672        d.reason,
1673        answer.key.key,
1674        answer.key.entry,
1675        match answer.key.via {
1676            MatchVia::Exact => "",
1677            MatchVia::Stem => " (stem)",
1678        },
1679        if answer.key.turn > 0 {
1680            format!(" ({} turns back)", answer.key.turn)
1681        } else {
1682            String::new()
1683        },
1684        answer
1685            .field
1686            .as_deref()
1687            .map(|f| format!(", field {f}"))
1688            .unwrap_or_default()
1689    );
1690    let outcome = match mode {
1691        LookupMode::Answer => LookupOutcome::Answer(answer),
1692        LookupMode::Context => LookupOutcome::Context(answer),
1693        LookupMode::Off => unreachable!("handled above"),
1694    };
1695    Ok((d, outcome))
1696}
1697
1698/// What the `key_first` policy needs to take a decision
1699/// ([`resolve_lookup_gated`]): that the decision was the ROUTER's own (a
1700/// caller that pinned the target passes no gate — `--route backbone`,
1701/// `--skill none` and a forced record are never overridden) and the
1702/// conditions the router's own pick of the record would have met.
1703#[derive(Debug, Clone, Copy)]
1704pub struct KeyFirstGate {
1705    /// The routing options of the decision: a record the router could not
1706    /// pick under them ([`router::is_routable`] — `active` with a
1707    /// measured gate, or any non-retired class with `include_quarantine`)
1708    /// is not taken by `key_first` either.
1709    pub opts: RouteOptions,
1710    /// The frame the request generates under, and whether the caller can
1711    /// render the user text as cmf-im-v1: the record's `prompt_contract`
1712    /// is checked exactly as for the router's pick
1713    /// ([`router::enforce_prompt_contract`]).
1714    pub frame: PromptFrame,
1715    pub can_render: bool,
1716}
1717
1718impl KeyFirstGate {
1719    /// A router decision under `opts` for a request framed as `frame`
1720    /// (no rendering).
1721    pub fn new(opts: RouteOptions, frame: PromptFrame) -> Self {
1722        Self {
1723            opts,
1724            frame,
1725            can_render: false,
1726        }
1727    }
1728
1729    /// The caller can render a raw prompt as cmf-im-v1 (`run`).
1730    pub fn can_render(mut self, on: bool) -> Self {
1731        self.can_render = on;
1732        self
1733    }
1734}
1735
1736/// [`resolve_lookup_turns`] with the records' routing policy. `key_first`
1737/// is `Some` when `decision` is the router's own (see [`KeyFirstGate`]).
1738///
1739/// When the router sent the request to the BACKBONE (for whatever reason:
1740/// the backbone nearest, a novel input, the margin not beaten, the
1741/// prompt contract of a record — but NOT the router's fail-closed state:
1742/// no router policy, no calibration, a stale `skills_hash`, no routable
1743/// class, [`router::no_candidate_reason`]), mode is not `off`, and a
1744/// lookup record whose policy is `key_first` — routable under
1745/// `gate.opts`, its prompt contract satisfied by `gate.frame` — finds a
1746/// STRONG key ([`LookupTable::find_strong_key`]) in the LAST user message
1747/// `turns[0]` (never in an earlier turn: a plant named three turns ago
1748/// does not pull an unrelated question into the table) whose card has
1749/// text, the request goes to that record: target the record, the
1750/// router's scores kept, reason `key_first: …`, `decided_by` = `key_first`
1751/// (records in header order, the first that answers wins). Every other
1752/// case is exactly [`resolve_lookup_turns`] — a message without a strong
1753/// key keeps the router's decision, and the backbone lane runs it
1754/// bit-identically to F0. A decision for a record (the router picked it)
1755/// resolves as always (any key, conversation memory), `decided_by` =
1756/// `router`; a decision for another skill is never overridden.
1757pub fn resolve_lookup_gated(
1758    tables: &LookupTables,
1759    decision: RouteDecision,
1760    key_first: Option<KeyFirstGate>,
1761    turns: &[&str],
1762    mode: LookupMode,
1763) -> Result<(RouteDecision, LookupOutcome), String> {
1764    if let (Some(gate), RouteTarget::Backbone, false, Some(message)) = (
1765        key_first,
1766        &decision.target,
1767        mode == LookupMode::Off,
1768        turns.first(),
1769    ) {
1770        if let Some(taken) = key_first_take(tables, &decision, gate, message, mode)? {
1771            return Ok(taken);
1772        }
1773    }
1774    resolve_lookup_turns(tables, decision, turns, mode)
1775}
1776
1777/// The `key_first` step of [`resolve_lookup_gated`] on a backbone
1778/// decision: `Some` when a `key_first` record takes the request.
1779fn key_first_take(
1780    tables: &LookupTables,
1781    decision: &RouteDecision,
1782    gate: KeyFirstGate,
1783    message: &str,
1784    mode: LookupMode,
1785) -> Result<Option<(RouteDecision, LookupOutcome)>, String> {
1786    let model = tables.model().clone();
1787    // The router's own fail-closed state (review KF-4): on a file whose
1788    // router could not pick ANY skill — no policy, no calibration, a stale
1789    // `skills_hash`, no routable class — every request runs the backbone,
1790    // and key_first does not reopen the table behind it.
1791    let mut router_ok: Option<bool> = None;
1792    for id in tables.ids() {
1793        let Some(rec) = LookupTable::record(&model, &id) else {
1794            continue;
1795        };
1796        let Some(info) = rec.lookup.as_ref() else {
1797            continue;
1798        };
1799        if LookupPolicy::of(info) != LookupPolicy::KeyFirst {
1800            continue;
1801        }
1802        let ok = *router_ok.get_or_insert_with(|| key_first_router_ok(&model, gate.opts));
1803        if !ok {
1804            return Ok(None);
1805        }
1806        if !router::is_routable(rec, gate.opts) {
1807            continue;
1808        }
1809        let pick = RouteDecision::forced(RouteTarget::Skill(id.clone()), "key_first");
1810        let (pick, _) =
1811            router::enforce_prompt_contract(&model.header, pick, gate.frame, gate.can_render);
1812        if pick.skill().is_none() {
1813            continue;
1814        }
1815        let Some(table) = tables.get(&id)? else {
1816            continue;
1817        };
1818        let Some(key) = table.find_strong_key(message) else {
1819            continue;
1820        };
1821        let mut answer = match table.answer_for_key(key, message)? {
1822            Found::Answer(a) => a,
1823            Found::NoKey | Found::EmptyCard(_) => continue,
1824        };
1825        answer.decided_by = DecidedBy::KeyFirst;
1826        let reason = format!(
1827            "key_first: lookup '{id}' takes the request on the strong key {:?} ({} words, {}, \
1828             {}) [router decision: {}]; lookup key {:?} → entry {}{}",
1829            answer.key.key,
1830            answer.key.words,
1831            answer.key.source.label(),
1832            answer.key.via.label(),
1833            decision.reason,
1834            answer.key.key,
1835            answer.key.entry,
1836            answer
1837                .field
1838                .as_deref()
1839                .map(|f| format!(", field {f}"))
1840                .unwrap_or_default()
1841        );
1842        let d = RouteDecision {
1843            target: RouteTarget::Skill(id),
1844            routing: decision.routing.clone(),
1845            reason,
1846        };
1847        let outcome = match mode {
1848            LookupMode::Answer => LookupOutcome::Answer(answer),
1849            LookupMode::Context => LookupOutcome::Context(answer),
1850            LookupMode::Off => unreachable!("key_first never acts in mode off"),
1851        };
1852        return Ok(Some((d, outcome)));
1853    }
1854    Ok(None)
1855}
1856
1857/// May `key_first` act on this file at all? Only where the router itself
1858/// could pick a skill: a router-v2 policy, a calibration whose
1859/// `skills_hash` binds, at least one class routable under `opts`
1860/// ([`router::no_candidate_reason`]). Anything else is the router's
1861/// fail-closed state — the backbone for every request.
1862fn key_first_router_ok(model: &CmfModel, opts: RouteOptions) -> bool {
1863    router::is_router_v2(model) && router::no_candidate_reason(&model.header, opts).is_none()
1864}
1865
1866#[cfg(test)]
1867mod tests {
1868    use super::*;
1869    use cortiq_core::knowledge::key_hash;
1870
1871    /// A finder over `(key, entry)` pairs — what the table's binary search
1872    /// answers.
1873    fn finder(keys: &[(&str, u32)]) -> impl Fn(u64) -> Option<u32> {
1874        let m: BTreeMap<u64, u32> = keys.iter().map(|(k, e)| (key_hash(k), *e)).collect();
1875        move |h| m.get(&h).copied()
1876    }
1877
1878    #[test]
1879    fn mode_parses_flag_and_env_spellings() {
1880        assert_eq!(LookupMode::parse("answer").unwrap(), LookupMode::Answer);
1881        assert_eq!(LookupMode::parse(" Context ").unwrap(), LookupMode::Context);
1882        assert_eq!(LookupMode::parse("OFF").unwrap(), LookupMode::Off);
1883        assert_eq!(LookupMode::parse("none").unwrap(), LookupMode::Off);
1884        assert!(LookupMode::parse("maybe").unwrap_err().contains("answer | context | off"));
1885        assert_eq!(LookupMode::resolve(Some("off")).unwrap(), LookupMode::Off);
1886        assert!(LookupMode::resolve(Some("x")).unwrap_err().starts_with("--lookup-mode"));
1887        assert_eq!(LookupMode::default().label(), "answer");
1888    }
1889
1890    #[test]
1891    fn ngram_extraction_takes_the_longest_match_cyrillic_and_latin() {
1892        let find = finder(&[
1893            ("Пихта бальзамическая", 0),
1894            ("пихта", 7),
1895            ("Abies balsamea", 0),
1896            ("balsam fir", 0),
1897            ("Ромашка аптечная", 1),
1898            ("chamomile", 1),
1899        ]);
1900        // Cyrillic, 2 words beat the 1-word key of the same plant.
1901        let h = extract_key("Какое семейство у растения Пихта бальзамическая?", &find).unwrap();
1902        assert_eq!(h.key, "пихта бальзамическая");
1903        assert_eq!((h.entry, h.words, h.source), (0, 2, KeySource::Ngram));
1904        // The 1-word key alone.
1905        let h = extract_key("пихта — что это?", &find).unwrap();
1906        assert_eq!((h.key.as_str(), h.entry, h.words), ("пихта", 7, 1));
1907        // Latin, punctuation and case folded by the normalisation.
1908        let h = extract_key("What is BALSAM-FIR used for?", &find).unwrap();
1909        assert_eq!((h.key.as_str(), h.entry), ("balsam fir", 0));
1910        let h = extract_key("Tell me about chamomile.", &find).unwrap();
1911        assert_eq!((h.key.as_str(), h.entry), ("chamomile", 1));
1912        // No match: nothing.
1913        assert_eq!(extract_key("What is the capital of France?", &find), None);
1914        assert_eq!(extract_key("", &find), None);
1915        assert_eq!(extract_key("   ...  ", &find), None);
1916    }
1917
1918    #[test]
1919    fn ties_go_to_the_first_occurrence() {
1920        let find = finder(&[("balsam fir", 0), ("chamomile", 1)]);
1921        let h = extract_key("chamomile or balsam fir?", &find).unwrap();
1922        assert_eq!(h.entry, 0, "the longer key wins over the earlier shorter one");
1923        let find = finder(&[("balsam fir", 0), ("red pine", 2)]);
1924        let h = extract_key("red pine and balsam fir", &find).unwrap();
1925        assert_eq!(h.entry, 2, "equal length: the first occurrence");
1926    }
1927
1928    #[test]
1929    fn parenthesised_binomial_is_tried_first() {
1930        let find = finder(&[("Abies balsamea", 0), ("пихты", 3)]);
1931        // The declined Cyrillic name is also a key, but the binomial in
1932        // parentheses is resolved first.
1933        let h = extract_key("Какие части пихты (Abies balsamea) используют?", &find).unwrap();
1934        assert_eq!(h.key, "abies balsamea");
1935        assert_eq!((h.entry, h.source), (0, KeySource::Parenthesised));
1936        // A non-Latin or too long group is skipped; the n-gram scan follows.
1937        let h = extract_key("Какие части пихты (см. выше) используют?", &find).unwrap();
1938        assert_eq!((h.key.as_str(), h.source), ("пихты", KeySource::Ngram));
1939        // A group that is not a key falls through to the n-grams.
1940        let find = finder(&[("abies balsamea", 0)]);
1941        let h = extract_key("fir (Pinus sylvestris) vs abies balsamea", &find).unwrap();
1942        assert_eq!((h.entry, h.source), (0, KeySource::Ngram));
1943        assert_eq!(parenthesised("a (b) c (d e) (f"), vec!["b", "d e"]);
1944        // Nested parentheses: the innermost group is tried first, then
1945        // the whole group; the n-grams follow.
1946        assert_eq!(
1947            parenthesised("Репешок (репешок аптечный (Agrimonia eupatoria)?"),
1948            vec!["Agrimonia eupatoria", "репешок аптечный (Agrimonia eupatoria"]
1949        );
1950        assert_eq!(parenthesised("x (a (b (c)) d)"), vec!["c", "a (b (c"]);
1951    }
1952
1953    #[test]
1954    fn nested_and_accented_binomials_resolve_through_the_parentheses() {
1955        // ru-wiki spells the binomial with stress marks; the table's key
1956        // (also accented, or not) normalises to the same plain letters.
1957        let find = finder(&[
1958            ("Oxycóccus", 0),
1959            ("клюква", 9),
1960            ("Agrimonia eupatoria", 1),
1961            ("репешок обыкновенный репешок аптечный", 8),
1962            ("Pinus", 2),
1963            ("сосна", 7),
1964            ("Strychnos nux-vomica", 3),
1965            ("чилибуха", 6),
1966        ]);
1967        let h = extract_key("К какому семейству относится Клюква (Oxycóccus)?", &find).unwrap();
1968        assert_eq!((h.entry, h.source), (0, KeySource::Parenthesised));
1969        assert_eq!(h.key, "oxycoccus");
1970        let h = extract_key("Сосна (Pínus) — семейство?", &find).unwrap();
1971        assert_eq!((h.entry, h.key.as_str()), (2, "pinus"));
1972        let h = extract_key(
1973            "К какому семейству относится Репешок обыкновенный (репешок аптечный (Agrimonia eupatoria)?",
1974            &find,
1975        )
1976        .unwrap();
1977        assert_eq!((h.entry, h.source), (1, KeySource::Parenthesised));
1978        let h = extract_key(
1979            "Чилибуха (Чилибуха обыкновенная (Strychnos nux-vomica) — что это?",
1980            &find,
1981        )
1982        .unwrap();
1983        assert_eq!((h.entry, h.key.as_str()), (3, "strychnos nux vomica"));
1984        // Latin letters the normalisation cannot fold are still Latin;
1985        // digits and Cyrillic are not a binomial.
1986        assert!(is_latin_word("æsculus") && is_latin_word("øst") && is_latin_word("straße"));
1987        assert!(!is_latin_word("клюква") && !is_latin_word("l2") && !is_latin_word(""));
1988        let find = finder(&[("2024", 5), ("клюква", 9)]);
1989        let h = extract_key("Клюква (2024)", &find).unwrap();
1990        assert_eq!((h.entry, h.source), (9, KeySource::Ngram));
1991    }
1992
1993    #[test]
1994    fn field_selection_by_keywords() {
1995        assert_eq!(select_field("К какому семейству относится пихта?"), Some("family"));
1996        assert_eq!(select_field("What family is balsam fir in?"), Some("family"));
1997        assert_eq!(select_field("Какие части растения используют?"), Some("parts"));
1998        assert_eq!(select_field("Which part of the plant is used?"), Some("parts"));
1999        assert_eq!(select_field("Какие действующие вещества?"), Some("compounds"));
2000        assert_eq!(select_field("Main constituents?"), Some("compounds"));
2001        assert_eq!(select_field("Где применяется пихта?"), Some("uses"));
2002        assert_eq!(select_field("What is it used for?"), Some("uses"));
2003        assert_eq!(select_field("Какие формы препаратов бывают?"), Some("preparations"));
2004        assert_eq!(select_field("In what form is it taken?"), Some("preparations"));
2005        assert_eq!(select_field("Есть ли противопоказания?"), Some("safety"));
2006        assert_eq!(select_field("Any side effects?"), Some("safety"));
2007        assert_eq!(select_field("Is it safe?"), Some("safety"));
2008        assert_eq!(select_field("Какая доказательная база?"), Some("evidence"));
2009        assert_eq!(select_field("Is there a study on it?"), Some("evidence"));
2010        // Whole words only for the short Latin ones.
2011        assert_eq!(select_field("Because information matters"), None);
2012        assert_eq!(select_field("Что такое пихта?"), None);
2013        // Rule order: family before parts.
2014        assert_eq!(select_field("Семейство и части растения"), Some("family"));
2015        // Safety of a USE is safety, not uses; a form it is used in is a
2016        // preparation.
2017        assert_eq!(select_field("Is lungwort safe to use today?"), Some("safety"));
2018        assert_eq!(select_field("What safety measures apply to digoxin use?"), Some("safety"));
2019        assert_eq!(
2020            select_field("Is the plant safe, and is its use supported by evidence?"),
2021            Some("safety")
2022        );
2023        assert_eq!(select_field("Насколько безопасно применение жимолости?"), Some("safety"));
2024        assert_eq!(
2025            select_field("Каковы противопоказания и меры безопасности при применении алтея?"),
2026            Some("safety")
2027        );
2028        assert_eq!(select_field("In what form is it used?"), Some("preparations"));
2029        assert_eq!(select_field("Какие части растения используются?"), Some("parts"));
2030        // `част` is a whole-word rule: частуха is a plant, часто an adverb.
2031        assert_eq!(select_field("Как выглядит частуха обыкновенная?"), None);
2032        assert_eq!(select_field("Где растёт частуха обыкновенная?"), None);
2033        assert_eq!(
2034            select_field("Какие сведения о безопасности частухи приводятся?"),
2035            Some("safety")
2036        );
2037        assert_eq!(
2038            select_field("Каково научное название частухи обыкновенной и к какому семейству она относится?"),
2039            Some("family")
2040        );
2041        assert_eq!(select_field("Часто ли её применяют?"), Some("uses"));
2042        assert_eq!(select_field("Какую часть растения собирают?"), Some("parts"));
2043        // The dev-set spellings the first vocabulary missed.
2044        assert_eq!(select_field("Опасно ли это растение?"), Some("safety"));
2045        assert_eq!(select_field("Чем опасна наперстянка шерстистая?"), Some("safety"));
2046        assert_eq!(
2047            select_field("Каковы побочные эффекты и лекарственные взаимодействия галантамина?"),
2048            Some("safety")
2049        );
2050        assert_eq!(select_field("How toxic is Podophyllum peltatum?"), Some("safety"));
2051        assert_eq!(select_field("Is it poisonous to cats?"), Some("safety"));
2052        assert_eq!(select_field("What risks are mentioned?"), Some("safety"));
2053        assert_eq!(
2054            select_field("Какие биологически активные соединения найдены в растении?"),
2055            Some("compounds")
2056        );
2057        assert_eq!(select_field("Каков химический состав?"), Some("compounds"));
2058        assert_eq!(select_field("What are the active ingredients?"), Some("compounds"));
2059        assert_eq!(select_field("Which chemicals does it contain?"), Some("compounds"));
2060        assert_eq!(
2061            select_field("Какие данные исследований приводятся в источнике?"),
2062            Some("evidence")
2063        );
2064        assert_eq!(select_field("Are there clinical trials?"), Some("evidence"));
2065        assert_eq!(select_field("What does the research say?"), Some("evidence"));
2066        assert_eq!(select_field("Какая дозировка?"), Some("preparations"));
2067        assert_eq!(select_field("What is the usual dose?"), Some("preparations"));
2068        assert_eq!(select_field("Recommended dosage?"), Some("preparations"));
2069        assert_eq!(FIELDS, &["family", "parts", "compounds", "safety", "evidence", "preparations", "uses"]);
2070    }
2071
2072    #[test]
2073    fn context_excerpt_keeps_the_first_sentence_and_the_field() {
2074        let c = Card::parse(
2075            r#"{"card":"Пихта бальзамическая (Abies balsamea) — хвойное дерево. Растёт в Канаде. Смола ароматна.","fields":{"family":"Сосновые (Pinaceae)","parts":"хвоя, смола"}}"#,
2076        )
2077        .unwrap();
2078        assert_eq!(c.first_sentence(), "Пихта бальзамическая (Abies balsamea) — хвойное дерево.");
2079        assert_eq!(
2080            context_excerpt(&c, Some("family"), "ru"),
2081            "Пихта бальзамическая (Abies balsamea) — хвойное дерево.\nСемейство: Сосновые (Pinaceae)"
2082        );
2083        assert_eq!(context_excerpt(&c, Some("parts"), "en"), format!("{}\nParts: хвоя, смола", c.first_sentence()));
2084        assert_eq!(context_excerpt(&c, Some("uses"), "ru"), c.card, "a field the card lacks: the whole card");
2085        assert_eq!(context_excerpt(&c, None, "ru"), c.card);
2086        assert_eq!(
2087            context_excerpt(&c, Some("family"), "de"),
2088            format!("{}\nFamily: Сосновые (Pinaceae)", c.first_sentence()),
2089            "an unknown slot language labels in English"
2090        );
2091        // An abbreviation's period is not a sentence end; no terminator →
2092        // the cap at a word boundary.
2093        assert_eq!(first_sentence("Hypericum perforatum L. is a herb. More."), "Hypericum perforatum L. is a herb.");
2094        assert_eq!(first_sentence("Abies balsamea?  yes"), "Abies balsamea?");
2095        assert_eq!(first_sentence("a.b.c end"), "a.b.c end");
2096        let long: String = "слово ".repeat(100);
2097        let head = first_sentence(&long);
2098        assert!(head.chars().count() <= FIRST_SENTENCE_MAX && head.ends_with("слово"), "{head:?}");
2099        assert_eq!(first_sentence("   "), "");
2100        assert_eq!(field_label("family", "ru"), "Семейство");
2101        assert_eq!(field_label("family", "en"), "Family");
2102        assert_eq!(field_label("origin", "ru"), "Origin");
2103        assert!(Card::parse(r#"{"card":"","fields":{}}"#).unwrap().is_empty());
2104        assert!(Card::parse(r#"{"card":" ","fields":{"family":""}}"#).unwrap().is_empty());
2105        assert!(!Card::parse(r#"{"card":"","fields":{"family":"x"}}"#).unwrap().is_empty());
2106    }
2107
2108    #[test]
2109    fn language_by_script_with_fallbacks() {
2110        let ru_en = vec!["ru".to_string(), "en".to_string()];
2111        assert_eq!(pick_lang("Что такое пихта?", &ru_en), 0);
2112        assert_eq!(pick_lang("What is fir?", &ru_en), 1);
2113        let en_only = vec!["en".to_string()];
2114        assert_eq!(pick_lang("Что такое пихта?", &en_only), 0);
2115        let de_ru = vec!["de".to_string(), "ru".to_string()];
2116        assert_eq!(pick_lang("What is fir?", &de_ru), 0, "no en: the first language");
2117        assert_eq!(pick_lang("Пихта", &de_ru), 1);
2118        assert!(has_cyrillic("Ё"));
2119        assert!(!has_cyrillic("fir"));
2120    }
2121
2122    #[test]
2123    fn card_parse_and_context_prompt() {
2124        let c = Card::parse(r#"{"card":"Abies balsamea — a fir.","fields":{"family":"Pinaceae","n":3,"x":null}}"#)
2125            .unwrap();
2126        assert_eq!(c.card, "Abies balsamea — a fir.");
2127        assert_eq!(c.fields["family"], "Pinaceae");
2128        assert_eq!(c.fields["n"], "3");
2129        assert!(!c.fields.contains_key("x"));
2130        assert!(Card::parse("[1]").is_err());
2131        assert_eq!(Card::parse("{}").unwrap(), Card::default());
2132        assert_eq!(
2133            context_prompt("CARD", "Q?"),
2134            "Справочная карточка / Reference card:\nCARD\n\nQ?"
2135        );
2136    }
2137
2138    #[test]
2139    fn outcome_annotation_and_generation_text() {
2140        let a = LookupAnswer {
2141            id: "herbs".into(),
2142            key: KeyHit {
2143                key: "abies balsamea".into(),
2144                hash: 1,
2145                entry: 4,
2146                words: 2,
2147                source: KeySource::Ngram,
2148                via: MatchVia::Exact,
2149                stem: None,
2150                turn: 0,
2151                strong: true,
2152            },
2153            lang: "en".into(),
2154            field: Some("family".into()),
2155            text: "Pinaceae".into(),
2156            card: "CARD".into(),
2157            full_card: "CARD. MORE.".into(),
2158            decided_by: DecidedBy::Router,
2159        };
2160        let mut s = serde_json::json!({"target": "herbs"});
2161        LookupOutcome::Context(a.clone()).annotate(&mut s, LookupMode::Context);
2162        assert_eq!(s["lookup_hit"], true);
2163        assert_eq!(s["lookup_key"], "abies balsamea");
2164        assert_eq!(s["lookup_entry"], 4);
2165        assert_eq!(s["lookup_lang"], "en");
2166        assert_eq!(s["field"], "family");
2167        assert_eq!(s["lookup_mode"], "context");
2168        assert_eq!(s["decided_target"], "herbs");
2169        assert_eq!(s["decided_by"], "router");
2170        assert_eq!(s["lookup_key_words"], 2);
2171        assert_eq!(
2172            LookupOutcome::Context(a.clone()).generation_text("Q?"),
2173            context_prompt("CARD", "Q?")
2174        );
2175        assert_eq!(LookupOutcome::Answer(a.clone()).generation_text("Q?"), "Q?");
2176        let mut s = serde_json::json!({"target": "backbone"});
2177        LookupOutcome::NotLookup.annotate(&mut s, LookupMode::Answer);
2178        assert_eq!(s["lookup_hit"], false);
2179        assert!(s.get("lookup_mode").is_none());
2180        assert!(s.get("decided_target").is_none());
2181        assert!(s.get("decided_by").is_none());
2182        // A key_first hit says so; a miss comes from the handed decision.
2183        let mut kf = a.clone();
2184        kf.decided_by = DecidedBy::KeyFirst;
2185        let mut s = serde_json::json!({"target": "herbs"});
2186        LookupOutcome::Answer(kf.clone()).annotate(&mut s, LookupMode::Answer);
2187        assert_eq!(s["decided_by"], "key_first");
2188        assert!(kf.describe().ends_with("decided by key_first"), "{}", kf.describe());
2189        assert_eq!(
2190            LookupOutcome::Miss { id: "herbs".into() }.decided_by(),
2191            Some(DecidedBy::Router)
2192        );
2193        assert_eq!(LookupOutcome::NotLookup.decided_by(), None);
2194        let mut s = serde_json::json!({"target": "backbone"});
2195        LookupOutcome::Miss { id: "herbs".into() }.annotate(&mut s, LookupMode::Answer);
2196        assert_eq!(s["lookup_hit"], false);
2197        assert_eq!(s["lookup_mode"], "answer");
2198        assert_eq!(s["decided_target"], "herbs", "the router's choice survives the miss");
2199        assert_eq!(s["decided_by"], "router");
2200        assert_eq!(s["target"], "backbone", "the lane that ran");
2201        assert_eq!(LookupOutcome::Off { id: "h".into() }.lookup_id(), Some("h"));
2202        assert_eq!(LookupOutcome::NotLookup.lookup_id(), None);
2203        assert!(LookupOutcome::NotLookup.describe().is_none());
2204        assert!(
2205            LookupOutcome::Miss { id: "herbs".into() }
2206                .describe()
2207                .unwrap()
2208                .contains("no key")
2209        );
2210        // The match kind and the turn are reported; a stem hit two turns
2211        // back says so in the human line too.
2212        let mut s = serde_json::json!({"target": "herbs"});
2213        let mut b = a.clone();
2214        b.key.via = MatchVia::Stem;
2215        b.key.stem = Some("abie balsamea".into());
2216        b.key.turn = 2;
2217        LookupOutcome::Answer(b.clone()).annotate(&mut s, LookupMode::Answer);
2218        assert_eq!(s["lookup_match"], "stem");
2219        assert_eq!(s["lookup_turn"], 2);
2220        let line = b.describe();
2221        assert!(line.contains("stem \"abie balsamea\"") && line.contains("2 turns back"), "{line}");
2222        let mut s = serde_json::json!({});
2223        LookupOutcome::Answer(a).annotate(&mut s, LookupMode::Answer);
2224        assert_eq!(s["lookup_match"], "exact");
2225        assert_eq!(s["lookup_turn"], 0);
2226    }
2227
2228    /// A finder over the STEMS of `(key, entry)` pairs — what the table's
2229    /// stem index answers.
2230    fn stem_finder(keys: &[(&str, u32)]) -> (StemIndex, impl Fn(u64) -> Option<u32>) {
2231        let norm: Vec<(String, u32)> = keys.iter().map(|(k, e)| (normalize_key(k), *e)).collect();
2232        let idx = StemIndex::from_keys(norm.iter().map(|(k, e)| (k.as_str(), *e)));
2233        let idx2 = idx.clone();
2234        (idx, move |h| idx2.find(h))
2235    }
2236
2237    #[test]
2238    fn stemmer_folds_russian_and_english_inflections() {
2239        // The dev-set misses: an inflected name meets its nominative key.
2240        assert_eq!(stem_word("тойона"), "тойон");
2241        assert_eq!(stem_word("тойон"), "тойон");
2242        assert_eq!(stem_key("магнолии лекарственной"), "магнол лекарственн");
2243        assert_eq!(stem_key("магнолия лекарственная"), "магнол лекарственн");
2244        assert_eq!(stem_key("кора магнолии лекарственной"), "кор магнол лекарственн");
2245        assert_eq!(stem_word("горопито"), "горопит");
2246        assert_eq!(stem_word("горопито"), stem_word("горопито"));
2247        // Every case of a noun and an adjective lands on one stem.
2248        for w in ["ромашка", "ромашки", "ромашке", "ромашку", "ромашкой", "ромашками", "ромашках"] {
2249            assert_eq!(stem_word(w), "ромашк", "{w}");
2250        }
2251        for w in ["лекарственный", "лекарственная", "лекарственное", "лекарственного", "лекарственному", "лекарственным", "лекарственными", "лекарственных", "лекарственные", "лекарственную"] {
2252            assert_eq!(stem_word(w), "лекарственн", "{w}");
2253        }
2254        // Nouns in -ой / -ей / -ь: the ending and the vowel before it go.
2255        assert_eq!(stem_word("зверобой"), stem_word("зверобоя"));
2256        assert_eq!(stem_word("зверобоя"), "звероб");
2257        assert_eq!(stem_word("шалфей"), stem_word("шалфея"));
2258        assert_eq!(stem_word("полынь"), stem_word("полыни"));
2259        assert_eq!(stem_word("растения"), "растен");
2260        // Short words keep ≥ 3 letters: `чай`, `вид`, `дуб` are never cut.
2261        assert_eq!(stem_word("чай"), "чай");
2262        assert_eq!(stem_word("чая"), "чая");
2263        assert_eq!(stem_word("вид"), "вид");
2264        assert_eq!(stem_word("виды"), "вид");
2265        assert_eq!(stem_word("дуба"), "дуб");
2266        // English plurals; short Latin words untouched; binomials mostly
2267        // untouched; `y` → `i` meets `ies` → `i`.
2268        assert_eq!(stem_word("herbs"), "herb");
2269        assert_eq!(stem_word("roses"), "rose");
2270        assert_eq!(stem_word("berries"), "berri");
2271        assert_eq!(stem_word("berry"), "berri");
2272        assert_eq!(stem_word("daisies"), stem_word("daisy"));
2273        assert_eq!(stem_word("grasses"), "grass");
2274        assert_eq!(stem_word("grass"), "grass");
2275        assert_eq!(stem_word("sage"), "sage");
2276        assert_eq!(stem_word("uses"), "uses");
2277        assert_eq!(stem_word("wort"), "wort");
2278        assert_eq!(stem_word("pinus"), "pinus");
2279        assert_eq!(stem_word("officinalis"), "officinalis");
2280        assert_eq!(stem_word("balsamea"), "balsamea");
2281        assert_eq!(stem_key("st john s wort"), "st john s wort");
2282        // Digits and the empty word are left alone.
2283        assert_eq!(stem_word("2024"), "2024");
2284        assert_eq!(stem_word("l2"), "l2");
2285        assert_eq!(stem_word(""), "");
2286        assert_eq!(stem_key("  "), "");
2287        // The suffix list is ordered longest first (the longest ending is
2288        // the one stripped).
2289        let lens: Vec<usize> = RU_SUFFIXES.iter().map(|s| s.chars().count()).collect();
2290        assert!(lens.windows(2).all(|w| w[0] >= w[1]), "{lens:?}");
2291    }
2292
2293    #[test]
2294    fn stem_index_serves_inflected_names_after_the_exact_index_fails() {
2295        let keys = [
2296            ("тойон", 0u32),
2297            ("магнолия лекарственная", 1),
2298            ("магнолия", 2),
2299            ("чай", 3),
2300            ("вид", 4),
2301            ("укроп", 5),
2302            ("Abies balsamea", 6),
2303        ];
2304        let find = finder(&keys);
2305        let (idx, find_stem) = stem_finder(&keys);
2306        assert_eq!((idx.len(), idx.ambiguous, idx.keys_in), (7, 0, 7));
2307        // An inflected one-word name (stem ≥ 5 letters).
2308        let h = extract_key_with("традиционные применения тойона", &find, Some(&find_stem)).unwrap();
2309        assert_eq!((h.entry, h.via, h.source, h.words), (0, MatchVia::Stem, KeySource::Ngram, 1));
2310        assert_eq!((h.key.as_str(), h.stem.as_deref()), ("тойона", Some("тойон")));
2311        assert_eq!(h.hash, key_hash("тойон"), "the stem's hash");
2312        // An inflected two-word name: the longest stem n-gram wins over
2313        // the one-word stem of the same message.
2314        let h = extract_key_with("кора магнолии лекарственной", &find, Some(&find_stem)).unwrap();
2315        assert_eq!((h.entry, h.via, h.words), (1, MatchVia::Stem, 2));
2316        assert_eq!((h.key.as_str(), h.stem.as_deref()), ("магнолии лекарственной", Some("магнол лекарственн")));
2317        let h = extract_key_with("настойка магнолии", &find, Some(&find_stem)).unwrap();
2318        assert_eq!((h.entry, h.via), (2, MatchVia::Stem));
2319        let h = extract_key_with("семена укропа", &find, Some(&find_stem)).unwrap();
2320        assert_eq!((h.entry, h.via), (5, MatchVia::Stem));
2321        // A strong key first (review KF-3): the two-word stem n-gram beats
2322        // the exact one-word key of the same message — the rule key_first
2323        // uses, so every path names one entry.
2324        let h = extract_key_with("магнолия лекарственной", &find, Some(&find_stem)).unwrap();
2325        assert_eq!((h.entry, h.via, h.words), (1, MatchVia::Stem, 2));
2326        assert!(is_strong_key(&h));
2327        // Among weak keys, exact before stem, as before.
2328        let h = extract_key_with("магнолия и тойона", &find, Some(&find_stem)).unwrap();
2329        assert_eq!((h.entry, h.via, h.words), (2, MatchVia::Exact, 1));
2330        let h = extract_key_with("тойон — что это?", &find, Some(&find_stem)).unwrap();
2331        assert_eq!((h.entry, h.via, h.stem), (0, MatchVia::Exact, None));
2332        // Never by stem: a one-word stem under 5 letters (`вид`, `чай`);
2333        // the exact forms still match.
2334        assert_eq!(extract_key_with("какие виды бывают?", &find, Some(&find_stem)), None);
2335        assert_eq!(extract_key_with("чашка чая и чаёв", &find, Some(&find_stem)), None);
2336        assert_eq!(extract_key_with("вида", &find, Some(&find_stem)), None);
2337        let h = extract_key_with("какой вид чая лучше?", &find, Some(&find_stem)).unwrap();
2338        assert_eq!((h.entry, h.via), (4, MatchVia::Exact));
2339        // Without a stem index the inflected forms are misses, as before.
2340        assert_eq!(extract_key("кора магнолии лекарственной", &find), None);
2341        assert_eq!(extract_key_with("кора магнолии лекарственной", &find, None), None);
2342        // Nothing at all.
2343        assert_eq!(extract_key_with("What is the capital of France?", &find, Some(&find_stem)), None);
2344        assert_eq!(extract_key_with("", &find, Some(&find_stem)), None);
2345        // A stem two entries share is dropped (ambiguous), the rest stay.
2346        let (idx, find_stem) = stem_finder(&[("пихта", 7), ("пихты", 3), ("ромашка", 1), ("ромашки", 1)]);
2347        assert_eq!((idx.len(), idx.ambiguous, idx.keys_in), (1, 1, 4));
2348        assert_eq!(find_stem(key_hash("пихт")), None);
2349        assert_eq!(find_stem(key_hash("ромашк")), Some(1));
2350        assert!(!idx.is_empty() && StemIndex::default().is_empty());
2351    }
2352
2353    #[test]
2354    fn capitalised_binomial_anywhere_is_tried_before_the_ngrams() {
2355        let find = finder(&[
2356            ("Abies balsamea", 0),
2357            ("Strychnos nux-vomica", 3),
2358            ("пихта", 7),
2359            ("balsam fir", 8),
2360        ]);
2361        let h = extract_key("Чем полезна Abies balsamea?", &find).unwrap();
2362        assert_eq!((h.entry, h.source, h.via), (0, KeySource::Binomial, MatchVia::Exact));
2363        assert_eq!((h.key.as_str(), h.words), ("abies balsamea", 2));
2364        let h = extract_key("Is Strychnos nux-vomica toxic?", &find).unwrap();
2365        assert_eq!((h.entry, h.key.as_str(), h.words), (3, "strychnos nux vomica", 3));
2366        // Before the n-grams: the binomial beats the Cyrillic name that
2367        // comes first in the text; the parenthesised path still comes
2368        // before both.
2369        let h = extract_key("Пихта, то есть Abies balsamea, — семейство?", &find).unwrap();
2370        assert_eq!((h.entry, h.source), (0, KeySource::Binomial));
2371        let h = extract_key("Пихта (Abies balsamea) — семейство?", &find).unwrap();
2372        assert_eq!((h.entry, h.source), (0, KeySource::Parenthesised));
2373        // A genus alone is never a key candidate; a lowercase pair is an
2374        // ordinary n-gram; an English capitalised pair is harmless.
2375        assert_eq!(extract_key("What plant is Abies?", &find), None);
2376        let h = extract_key("what is abies balsamea", &find).unwrap();
2377        assert_eq!(h.source, KeySource::Ngram);
2378        let h = extract_key("Balsam fir — what is it?", &find).unwrap();
2379        assert_eq!((h.entry, h.source), (8, KeySource::Binomial));
2380        assert_eq!(
2381            capitalised_binomials("Tell me about Abies balsamea and Pinus sylvestris (Pínus)."),
2382            vec!["Abies balsamea", "Pinus sylvestris"],
2383            "`me` is too short for an epithet; a lowercase genus is none"
2384        );
2385        assert_eq!(capitalised_binomials("Strychnos nux-vomica) L."), vec!["Strychnos nux-vomica"]);
2386        assert_eq!(capitalised_binomials("St. John's wort; ABIES balsamea; Abies B."), Vec::<String>::new());
2387        assert_eq!(capitalised_binomials("Пихта бальзамическая"), Vec::<String>::new());
2388        assert_eq!(capitalised_binomials("Abies -balsamea- x"), vec!["Abies balsamea"]);
2389    }
2390
2391    #[test]
2392    fn policy_parses_the_two_values_and_defaults_to_router_and_key() {
2393        assert_eq!(LookupPolicy::default(), LookupPolicy::RouterAndKey);
2394        assert_eq!(LookupPolicy::parse("router_and_key").unwrap(), LookupPolicy::RouterAndKey);
2395        assert_eq!(LookupPolicy::parse("key_first").unwrap(), LookupPolicy::KeyFirst);
2396        assert!(LookupPolicy::parse("Key_First").unwrap_err().contains("router_and_key | key_first"));
2397        assert!(LookupPolicy::parse("").is_err());
2398        let mut info = LookupInfo {
2399            entries: 1,
2400            keys: 1,
2401            key_norm: cortiq_core::knowledge::KEY_NORM.into(),
2402            langs: vec!["ru".into()],
2403            fields: Vec::new(),
2404            policy: None,
2405        };
2406        assert_eq!(LookupPolicy::of(&info), LookupPolicy::RouterAndKey);
2407        assert!(LookupPolicy::is_known(&info));
2408        info.policy = Some("key_first".into());
2409        assert_eq!(LookupPolicy::of(&info), LookupPolicy::KeyFirst);
2410        // A value this reader does not know (a newer writer, a hand edit):
2411        // the conservative router_and_key, never an error (review KF-6).
2412        for unknown in ["key_only", "key_first ", "KEY_FIRST", ""] {
2413            info.policy = Some(unknown.into());
2414            assert_eq!(LookupPolicy::of(&info), LookupPolicy::RouterAndKey, "{unknown:?}");
2415            assert!(!LookupPolicy::is_known(&info), "{unknown:?}");
2416        }
2417        assert_eq!(LookupPolicy::KeyFirst.label(), "key_first");
2418        assert_eq!(LookupPolicy::RouterAndKey.label(), "router_and_key");
2419        assert_eq!(DecidedBy::default().label(), "router");
2420        assert_eq!(DecidedBy::KeyFirst.label(), "key_first");
2421    }
2422
2423    #[test]
2424    fn strong_keys_are_two_words_or_a_binomial_and_one_word_keys_never_are() {
2425        let keys = [
2426            ("ромашка аптечная", 0u32),
2427            ("ромашки аптечной", 0),
2428            ("Matricaria chamomilla", 0),
2429            ("chamomile", 0),
2430            ("календула", 1),
2431            ("календулы", 1),
2432            ("Calendula officinalis", 1),
2433            ("calendula", 1),
2434            ("pot marigold", 1),
2435            ("чай", 2),
2436            ("мята", 3),
2437            ("мята перечная", 3),
2438            ("магнолия лекарственная", 4),
2439            ("Strychnos nux-vomica", 5),
2440        ];
2441        let find = finder(&keys);
2442        let (_, find_stem) = stem_finder(&keys);
2443        let strong = |m: &str| extract_strong_key_with(m, &find, Some(&find_stem));
2444        let any = |m: &str| extract_key_with(m, &find, Some(&find_stem));
2445        // An exact n-gram of two words.
2446        let h = strong("Какие лечебные свойства у ромашки аптечной?").unwrap();
2447        assert_eq!((h.entry, h.words, h.via, h.source), (0, 2, MatchVia::Exact, KeySource::Ngram));
2448        assert!(is_strong_key(&h));
2449        let h = strong("Tell me about pot marigold tea").unwrap();
2450        assert_eq!((h.entry, h.words), (1, 2));
2451        // A stem n-gram of two words.
2452        let h = strong("Кора магнолии лекарственной — от чего?").unwrap();
2453        assert_eq!((h.entry, h.words, h.via), (4, 2, MatchVia::Stem));
2454        assert!(is_strong_key(&h));
2455        // A capitalised binomial anywhere, a parenthesised one, a
2456        // hyphenated epithet (three normalised words).
2457        let h = strong("What family is Matricaria chamomilla in?").unwrap();
2458        assert_eq!((h.entry, h.source, h.words), (0, KeySource::Binomial, 2));
2459        let h = strong("Ноготки (Calendula officinalis): семейство?").unwrap();
2460        assert_eq!((h.entry, h.source, h.words), (1, KeySource::Parenthesised, 2));
2461        let h = strong("Is Strychnos nux-vomica toxic?").unwrap();
2462        assert_eq!((h.entry, h.words), (5, 3));
2463        // One-word keys are never strong — exact, stemmed or in
2464        // parentheses — though the ordinary search still finds them.
2465        for m in [
2466            "Какое семейство у календулы?",
2467            "Хочу чай с мятой",
2468            "Налей чай",
2469            "мята",
2470            "What is calendula?",
2471            "A tea of chamomile, please",
2472            "Ноготки (Calendula) — что это?",
2473            "What is the capital of France?",
2474            "",
2475        ] {
2476            assert_eq!(strong(m), None, "{m:?}");
2477        }
2478        let h = any("Какое семейство у календулы?").unwrap();
2479        assert!(!is_strong_key(&h) && h.words == 1);
2480        let h = any("Ноготки (Calendula) — что это?").unwrap();
2481        assert!(!is_strong_key(&h));
2482        assert_eq!(any("Налей чай").unwrap().entry, 2);
2483        // A one-word exact key does not hide a two-word key of the same
2484        // message — in the strong search AND in the ordinary one (one rule
2485        // for the router's path and key_first's, review KF-3).
2486        for m in ["чай из ромашки аптечная", "Как заварить чай из ромашки аптечная?"] {
2487            let h = strong(m).unwrap();
2488            assert_eq!((h.entry, h.words, h.via), (0, 2, MatchVia::Stem), "{m}");
2489            assert_eq!(any(m), Some(h), "{m}");
2490        }
2491        let h = any("чай с мятой перечная").unwrap();
2492        assert_eq!((h.entry, h.via), (3, MatchVia::Stem), "not the exact `чай`");
2493        // The longest strong key wins, as in the ordinary search.
2494        let h = strong("мята перечная и ромашка аптечная").unwrap();
2495        assert_eq!((h.entry, h.key.as_str()), (3, "мята перечная"), "ties → first occurrence");
2496    }
2497
2498    #[test]
2499    fn strong_keys_need_two_real_words_without_digits() {
2500        // Review KF-5: a digit, a one- or two-letter word does not make a
2501        // name — `STS-135`, `PTI-2`, `5F-PB-22`, `THC-B` split into
2502        // several normalised "words" but none is strong.
2503        for k in ["sts 135", "pti 2", "a 41988", "5f pb 22", "thc b", "чай", "b 12 vitamin"] {
2504            assert!(!is_strong_key_text(k), "{k}");
2505        }
2506        for k in ["st john s wort", "pot marigold", "ромашки аптечной", "strychnos nux vomica", "five finger"] {
2507            assert!(is_strong_key_text(k), "{k}");
2508        }
2509        let keys = [("STS-135", 0u32), ("PTI-2", 1), ("5F-PB-22", 2), ("THC-B", 3), ("St. John's wort", 4), ("мята", 5)];
2510        let find = finder(&keys);
2511        let (_, find_stem) = stem_finder(&keys);
2512        let msg = "Tell me about the STS-135 mission of Space Shuttle Atlantis";
2513        assert_eq!(extract_strong_key_with(msg, &find, Some(&find_stem)), None);
2514        let h = extract_key_with(msg, &find, Some(&find_stem)).unwrap();
2515        assert_eq!((h.entry, h.key.as_str(), h.words, h.strong), (0, "sts 135", 2, false));
2516        for m in ["Is PTI-2 legal?", "5F-PB-22 effects", "What is THC-B?"] {
2517            assert_eq!(extract_strong_key_with(m, &find, Some(&find_stem)), None, "{m}");
2518            assert!(extract_key_with(m, &find, Some(&find_stem)).is_some_and(|h| !h.strong), "{m}");
2519        }
2520        let h = extract_strong_key_with("Is St. John's wort safe?", &find, Some(&find_stem)).unwrap();
2521        assert_eq!((h.entry, h.words, h.strong), (4, 4, true));
2522        // A parenthesised group of such words is strong as well.
2523        let h = extract_key_with("мята (St. John's wort)", &find, Some(&find_stem)).unwrap();
2524        assert_eq!((h.entry, h.source), (4, KeySource::Parenthesised));
2525    }
2526
2527    #[test]
2528    fn a_capitalised_pair_is_judged_by_its_words_like_an_n_gram() {
2529        // The G5 row `Spring vetchling (Lathyrus vernus (L.) Bernh.)` of the
2530        // real corpus: the common name typed first names the full entry,
2531        // the binomial a stub duplicate. Both are strong by their words;
2532        // the first in rule order wins — on every path.
2533        let keys = [("Lathyrus vernus", 0u32), ("Spring vetchling", 1), ("Common box", 2)];
2534        let find = finder(&keys);
2535        let m = "Which plant family does Spring vetchling (Lathyrus vernus (L.) Bernh.) belong to?";
2536        let h = extract_strong_key_with(m, &find, None).unwrap();
2537        assert_eq!((h.entry, h.source, h.strong), (1, KeySource::Binomial, true));
2538        assert_eq!(extract_key_with(m, &find, None), Some(h));
2539        // `Common box`: a general phrase that is a common name — strong
2540        // as a pair and as an n-gram alike (review KF-5); the stop list of
2541        // the builder's general probe removes it (KF-1).
2542        let h = extract_strong_key_with("Why is Common box cutter so popular?", &find, None).unwrap();
2543        assert_eq!((h.entry, h.source), (2, KeySource::Binomial));
2544        let h = extract_strong_key_with("why is common box cutter so popular", &find, None).unwrap();
2545        assert_eq!((h.entry, h.source), (2, KeySource::Ngram));
2546        // A key with a one-letter word is weak, typed as a pair or not.
2547        let find = finder(&[("Vitamin b", 3)]);
2548        assert_eq!(extract_strong_key_with("Is Vitamin b safe?", &find, None), None);
2549        let h = extract_key_with("Is Vitamin b safe?", &find, None);
2550        assert_eq!(h.map(|k| (k.entry, k.strong, k.source)), Some((3, false, KeySource::Ngram)));
2551    }
2552
2553    #[test]
2554    fn extraction_stays_linear_in_the_message() {
2555        use std::cell::Cell;
2556        let exact_calls = Cell::new(0usize);
2557        let stem_calls = Cell::new(0usize);
2558        let find = |_h: u64| {
2559            exact_calls.set(exact_calls.get() + 1);
2560            None
2561        };
2562        let find_stem = |_h: u64| {
2563            stem_calls.set(stem_calls.get() + 1);
2564            None
2565        };
2566        let words = 300usize;
2567        let msg: String = (0..words)
2568            .map(|i| if i % 3 == 0 { "Magnolia" } else { "лекарственной" })
2569            .collect::<Vec<_>>()
2570            .join(" ");
2571        assert_eq!(extract_key_with(&msg, &find, Some(&find_stem)), None);
2572        // ≤ MAX_NGRAM hashes per word for the n-grams, plus one per
2573        // capitalised pair: linear, and the stem pass costs the same again.
2574        assert!(exact_calls.get() <= (MAX_NGRAM + 1) * words, "{}", exact_calls.get());
2575        assert!(stem_calls.get() <= MAX_NGRAM * words, "{}", stem_calls.get());
2576        assert!(stem_calls.get() >= words, "the stem pass ran: {}", stem_calls.get());
2577    }
2578
2579    #[test]
2580    fn key_texts_are_recovered_from_the_cards() {
2581        // A table as the file holds it: sorted hashes, entries, the slot
2582        // blob. Keys 0 and 1 name entry 0; entry 1's card mentions entry
2583        // 0's plant; key `mint` is spelled by no card.
2584        let keys = [("ромашка аптечная", 0u32), ("chamomile", 0), ("шалфей", 1), ("mint", 2)];
2585        let mut pairs: Vec<(u64, u32)> = keys.iter().map(|(k, e)| (key_hash(k), *e)).collect();
2586        pairs.sort_unstable();
2587        let hashes: Vec<u64> = pairs.iter().map(|p| p.0).collect();
2588        let entry_of: Vec<u32> = pairs.iter().map(|p| p.1).collect();
2589        let slots = [
2590            r#"{"card":"Ромашка аптечная — однолетник.","fields":{"uses":"чай"}}"#,
2591            r#"{"card":"Chamomile is an annual.","fields":{}}"#,
2592            r#"{"card":"Шалфей — полукустарник; сочетают с ромашкой аптечной.","fields":{}}"#,
2593            r#"{"card":"","fields":{}}"#,
2594            r#"{"card":"","fields":{}}"#,
2595            r#"{"card":"","fields":{}}"#,
2596        ];
2597        let mut blob = Vec::new();
2598        let mut offsets = vec![0u64];
2599        for s in slots {
2600            blob.extend_from_slice(s.as_bytes());
2601            offsets.push(blob.len() as u64);
2602        }
2603        let mut got = recover_key_texts(&hashes, &entry_of, &blob, &offsets);
2604        got.sort();
2605        assert_eq!(
2606            got,
2607            vec![
2608                ("chamomile".to_string(), 0),
2609                ("ромашка аптечная".to_string(), 0),
2610                ("шалфей".to_string(), 1),
2611            ]
2612        );
2613        let idx = StemIndex::from_keys(got.iter().map(|(k, e)| (k.as_str(), *e)));
2614        assert_eq!(idx.find(key_hash("ромашк аптечн")), Some(0));
2615        assert_eq!(idx.find(key_hash("шалф")), Some(1));
2616        assert_eq!(idx.find(key_hash("mint")), None, "spelled by no card: no stem");
2617        // A broken offset or a non-UTF-8 slot is skipped, not a panic.
2618        let bad = [0u64, 5, 3, blob.len() as u64 + 10];
2619        let _ = recover_key_texts(&hashes, &entry_of, &blob, &bad);
2620        assert_eq!(recover_key_texts(&hashes, &entry_of, &[0xFF, 0xFE], &[0, 2]), vec![]);
2621    }
2622}