1use 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
59pub const LOOKUP_MODE_ENV: &str = "CMF_LOOKUP_MODE";
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
66pub enum LookupMode {
67 #[default]
70 Answer,
71 Context,
74 Off,
76}
77
78impl LookupMode {
79 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 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 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
118pub const CONTEXT_HEADER: &str = "Справочная карточка / Reference card:";
120
121pub fn context_prompt(card: &str, user_text: &str) -> String {
123 format!("{CONTEXT_HEADER}\n{card}\n\n{user_text}")
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
131pub enum LookupPolicy {
132 #[default]
137 RouterAndKey,
138 KeyFirst,
143}
144
145impl LookupPolicy {
146 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 pub fn of(info: &LookupInfo) -> Self {
165 Self::parse(info.policy_label()).unwrap_or_default()
166 }
167
168 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
185pub enum DecidedBy {
186 #[default]
189 Router,
190 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
204pub const FIELDS: &[&str] = &[
211 "family",
212 "parts",
213 "compounds",
214 "safety",
215 "evidence",
216 "preparations",
217 "uses",
218];
219
220pub 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
321pub fn has_cyrillic(s: &str) -> bool {
323 s.chars().any(|c| ('\u{0400}'..='\u{052F}').contains(&c))
324}
325
326pub 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
338pub const MAX_NGRAM: usize = LOOKUP_MAX_NGRAM;
344
345pub const MEMORY_TURNS: usize = 6;
349
350#[derive(Debug, Clone, Copy, PartialEq, Eq)]
352pub enum KeySource {
353 Parenthesised,
355 Binomial,
358 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#[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#[derive(Debug, Clone, PartialEq, Eq)]
392pub struct KeyHit {
393 pub key: String,
396 pub hash: u64,
399 pub entry: u32,
401 pub words: usize,
403 pub source: KeySource,
404 pub via: MatchVia,
405 pub stem: Option<String>,
407 pub turn: usize,
410 pub strong: bool,
413}
414
415fn 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
438fn 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
445fn is_latin_word(w: &str) -> bool {
449 !w.is_empty() && w.chars().all(is_latin_char)
450}
451
452fn 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
457pub 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
485pub const RU_SUFFIXES: &[&str] = &[
491 "ого", "его", "ому", "ему", "ыми", "ими", "ами", "ями", "ая", "яя", "ой", "ей", "ий", "ый", "ое", "ее", "ые", "ых", "их", "ую", "юю", "ою", "ею",
493 "ам", "ям", "ах", "ях", "ом", "ем", "ым", "им", "ов", "ев", "ии", "ия", "ие", "ье", "ья",
494 "ью", "а", "я", "ы", "и", "у", "ю", "о", "е", "ь", "й", ];
497pub const RU_STEM_MIN: usize = 3;
500pub const EN_STEM_MIN: usize = 4;
502pub const LATIN_STEM_MIN_WORD: usize = 5;
505pub const STEM_UNIGRAM_MIN: usize = 5;
509
510pub 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
576pub 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
589fn 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
615pub struct StemIndex {
616 hashes: Vec<u64>,
617 entry_of: Vec<u32>,
618 pub ambiguous: usize,
620 pub keys_in: usize,
622}
623
624impl StemIndex {
625 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 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 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
677pub 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
722pub fn extract_key(message: &str, find: &dyn Fn(u64) -> Option<u32>) -> Option<KeyHit> {
724 extract_key_with(message, find, None)
725}
726
727pub 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
755pub const STRONG_KEY_MIN_WORDS: usize = 2;
758
759pub const STRONG_WORD_MIN_LETTERS: usize = 3;
762
763pub 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
786pub fn is_strong_key(hit: &KeyHit) -> bool {
803 hit.strong
804}
805
806pub 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
821fn 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
833fn 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 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 debug_assert_eq!(sspans.len(), spans.len());
924 if sspans.len() != spans.len() {
925 return fallback;
926 }
927 for n in (1..=MAX_NGRAM.min(sspans.len())).rev() {
930 for start in 0..=sspans.len() - n {
931 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
966pub struct Card {
967 pub card: String,
968 pub fields: BTreeMap<String, String>,
969}
970
971impl Card {
972 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 pub fn is_empty(&self) -> bool {
1003 self.card.trim().is_empty() && self.fields.values().all(|t| t.trim().is_empty())
1004 }
1005
1006 pub fn first_sentence(&self) -> &str {
1013 first_sentence(&self.card)
1014 }
1015}
1016
1017pub const FIRST_SENTENCE_MIN: usize = 12;
1019pub 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 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
1058pub 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
1095pub 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
1116pub struct LookupTable {
1120 pub id: String,
1121 pub info: LookupInfo,
1122 hashes: Vec<u64>,
1124 entry_of: Vec<u32>,
1126 offsets: Vec<u64>,
1128 stems: StemIndex,
1131 recovered: usize,
1133 model: Arc<CmfModel>,
1134 text_name: String,
1135}
1136
1137impl LookupTable {
1138 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 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 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 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 pub fn recovered_keys(&self) -> usize {
1239 self.recovered
1240 }
1241
1242 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 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 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 pub fn find_stem_hash(&self, h: u64) -> Option<u32> {
1276 self.stems.find(h)
1277 }
1278
1279 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 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 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 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 pub fn policy(&self) -> LookupPolicy {
1314 LookupPolicy::of(&self.info)
1315 }
1316
1317 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 pub fn find_answer(&self, message: &str) -> Result<Found, String> {
1333 self.find_answer_turns(&[message])
1334 }
1335
1336 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 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#[derive(Debug, Clone, PartialEq, Eq)]
1400pub enum Found {
1401 NoKey,
1403 EmptyCard(KeyHit),
1407 Answer(LookupAnswer),
1408}
1409
1410#[derive(Debug, Clone, PartialEq, Eq)]
1412pub struct LookupAnswer {
1413 pub id: String,
1415 pub key: KeyHit,
1416 pub lang: String,
1418 pub field: Option<String>,
1420 pub text: String,
1422 pub card: String,
1425 pub full_card: String,
1427 pub decided_by: DecidedBy,
1430}
1431
1432impl LookupAnswer {
1433 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
1462pub 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 pub fn any(&self) -> bool {
1483 !LookupTable::lookup_ids(&self.model).is_empty()
1484 }
1485
1486 pub fn ids(&self) -> Vec<String> {
1488 LookupTable::lookup_ids(&self.model)
1489 }
1490
1491 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 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
1523pub const KEY_WINDOW_MAX: usize = 96;
1525
1526pub 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
1546pub 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
1556pub 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
1566pub 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
1582pub 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
1598pub fn message_names_key(model: &Arc<CmfModel>, text: &str) -> bool {
1600 tables_of(model).find_key_any(text).is_some()
1601}
1602
1603pub 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 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#[derive(Debug, Clone, PartialEq, Eq)]
1662pub enum LookupOutcome {
1663 NotLookup,
1665 Off { id: String },
1668 Miss { id: String },
1671 Answer(LookupAnswer),
1673 Context(LookupAnswer),
1675}
1676
1677impl LookupOutcome {
1678 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 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 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 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 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 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
1767pub 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
1783pub 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#[derive(Debug, Clone, Copy)]
1883pub struct KeyFirstGate {
1884 pub opts: RouteOptions,
1889 pub frame: PromptFrame,
1894 pub can_render: bool,
1895}
1896
1897impl KeyFirstGate {
1898 pub fn new(opts: RouteOptions, frame: PromptFrame) -> Self {
1901 Self {
1902 opts,
1903 frame,
1904 can_render: false,
1905 }
1906 }
1907
1908 pub fn can_render(mut self, on: bool) -> Self {
1910 self.can_render = on;
1911 self
1912 }
1913}
1914
1915pub 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
1956fn 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 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
2036fn 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 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 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 let h = extract_key("пихта — что это?", &find).unwrap();
2085 assert_eq!((h.key.as_str(), h.entry, h.words), ("пихта", 7, 1));
2086 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 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 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 let h = extract_key("Какие части пихты (см. выше) используют?", &find).unwrap();
2117 assert_eq!((h.key.as_str(), h.source), ("пихты", KeySource::Ngram));
2118 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 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 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 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 assert_eq!(select_field("Because information matters"), None);
2191 assert_eq!(select_field("Что такое пихта?"), None);
2192 assert_eq!(select_field("Семейство и части растения"), Some("family"));
2194 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 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 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 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 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 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 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 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 for w in ["ромашка", "ромашки", "ромашке", "ромашку", "ромашкой", "ромашками", "ромашках"] {
2428 assert_eq!(stem_word(w), "ромашк", "{w}");
2429 }
2430 for w in ["лекарственный", "лекарственная", "лекарственное", "лекарственного", "лекарственному", "лекарственным", "лекарственными", "лекарственных", "лекарственные", "лекарственную"] {
2431 assert_eq!(stem_word(w), "лекарственн", "{w}");
2432 }
2433 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 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 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 assert_eq!(stem_word("2024"), "2024");
2463 assert_eq!(stem_word("l2"), "l2");
2464 assert_eq!(stem_word(""), "");
2465 assert_eq!(stem_key(" "), "");
2466 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 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 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 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 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 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 assert_eq!(extract_key("кора магнолии лекарственной", &find), None);
2520 assert_eq!(extract_key_with("кора магнолии лекарственной", &find, None), None);
2521 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 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 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 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 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 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 let h = strong("Кора магнолии лекарственной — от чего?").unwrap();
2635 assert_eq!((h.entry, h.words, h.via), (4, 2, MatchVia::Stem));
2636 assert!(is_strong_key(&h));
2637 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 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 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 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 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 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 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 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 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 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 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 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}