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