Skip to main content

formal_ai/
intent_formalization.rs

1//! Links-Notation intent formalization and cache.
2//!
3//! The P/Q formalization layer identifies meaning anchors inside a prompt.
4//! This module wraps those anchors in the routing-facing intent record: what
5//! kind of impulse arrived, what is already known, and which handlers or rules
6//! are relevant.
7
8use std::collections::BTreeMap;
9use std::sync::OnceLock;
10
11use lino_objects_codec::format::escape_reference;
12
13use crate::engine::{
14    normalize_prompt, program_language_by_alias, program_spec, stable_id, SelectedRule,
15    WRITE_PROGRAM_INTENT,
16};
17use crate::event_log::EventLog;
18use crate::link_store::{LinkStore, LinkStoreError};
19use crate::links_format::format_lino_record;
20use crate::memory::MemoryEvent;
21use crate::method_registry::MethodRegistry;
22use crate::probability::ProbabilityStore;
23use crate::seed;
24use crate::solver::{ConversationTurn, UniversalSolver};
25use crate::translation::{FormalizationAnchorKind, FormalizationCandidate, FormalizationRole};
26use crate::{concepts, cue_lexicon};
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum IntentKind {
30    Task,
31    Question,
32    Requirement,
33    Statement,
34    Courtesy,
35    Unknown,
36}
37
38impl IntentKind {
39    #[must_use]
40    pub const fn slug(self) -> &'static str {
41        match self {
42            Self::Task => "task",
43            Self::Question => "question",
44            Self::Requirement => "requirement",
45            Self::Statement => "statement",
46            Self::Courtesy => "courtesy",
47            Self::Unknown => "unknown",
48        }
49    }
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct IntentFormalization {
54    pub impulse_id: String,
55    pub source_text: String,
56    pub normalized_text: String,
57    pub language: String,
58    pub kind: IntentKind,
59    pub knowns: Vec<String>,
60    pub relevants: Vec<String>,
61    pub parameters: BTreeMap<String, String>,
62    pub route: Option<String>,
63    pub response_link: Option<String>,
64}
65
66impl IntentFormalization {
67    /// Render this record as Links Notation.
68    ///
69    /// `source_text` is the user's own prompt, so a value carrying a quote is
70    /// ordinary rather than exotic. This used to hand-roll a C-style backslash
71    /// escape, which Links Notation does not define: notation escapes a quote by
72    /// *doubling* it, so a backslash left the quote visible to the reader, which
73    /// ended the value early and silently dropped the field — and once a value
74    /// has ended early, a quoted code fragment's `(` is an unclosed group rather
75    /// than text, so the whole document failed to parse. Delegating to
76    /// `format_lino_record`, which the rest of the codebase already publishes
77    /// records through, keeps the notation's definition and this encoder from
78    /// drifting apart again.
79    #[must_use]
80    pub fn to_links_notation(&self) -> String {
81        let mut pairs = vec![
82            ("impulse_id", self.impulse_id.clone()),
83            ("source_text", self.source_text.clone()),
84            ("normalized_text", self.normalized_text.clone()),
85            ("language", self.language.clone()),
86            ("kind", self.kind.slug().to_owned()),
87        ];
88        if let Some(route) = &self.route {
89            pairs.push(("route", route.clone()));
90        }
91        if let Some(response_link) = &self.response_link {
92            pairs.push(("response_link", response_link.clone()));
93        }
94        for (name, value) in &self.parameters {
95            // `name=value` is one reference, so it is escaped once as a whole.
96            // Escaping the halves separately would put delimiters *inside* the
97            // reference and end it at the `=`.
98            pairs.push(("parameter", format!("{name}={value}")));
99        }
100        for known in &self.knowns {
101            pairs.push(("known", known.clone()));
102        }
103        for relevant in &self.relevants {
104            pairs.push(("relevant", relevant.clone()));
105        }
106
107        // The header names the impulse the record is about, so it is a two-token
108        // link rather than a bare id; `format_lino_record` writes the id line
109        // verbatim, so that token is escaped here.
110        let mut out = format_lino_record(
111            &format!(
112                "intent_formalization {}",
113                escape_reference(&self.impulse_id)
114            ),
115            &pairs,
116        );
117        out.push('\n');
118        out
119    }
120
121    #[must_use]
122    pub fn has_relevant_handler(&self, name: &str) -> bool {
123        self.relevants
124            .iter()
125            .any(|relevant| relevant == &format!("handler:{name}"))
126    }
127}
128
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct IntentFormalizationCacheEntry {
131    pub formalization: IntentFormalization,
132    pub cache_hit: bool,
133}
134
135#[derive(Debug, Default, Clone)]
136pub struct IntentFormalizationCache {
137    records: BTreeMap<String, IntentFormalization>,
138}
139
140impl IntentFormalizationCache {
141    #[must_use]
142    pub const fn new() -> Self {
143        Self {
144            records: BTreeMap::new(),
145        }
146    }
147
148    #[must_use]
149    pub fn len(&self) -> usize {
150        self.records.len()
151    }
152
153    #[must_use]
154    pub fn is_empty(&self) -> bool {
155        self.records.is_empty()
156    }
157
158    #[must_use]
159    pub fn get(&self, prompt: &str) -> Option<&IntentFormalization> {
160        let id = impulse_id_for(prompt);
161        self.records.get(&id)
162    }
163
164    pub fn formalize_or_insert(
165        &mut self,
166        prompt: &str,
167        language: &str,
168        candidate: Option<&FormalizationCandidate>,
169    ) -> IntentFormalizationCacheEntry {
170        let id = impulse_id_for(prompt);
171        if let Some(formalization) = self.records.get(&id) {
172            return IntentFormalizationCacheEntry {
173                formalization: formalization.clone(),
174                cache_hit: true,
175            };
176        }
177
178        let formalization = formalize_intent(prompt, language, candidate);
179        self.records.insert(id, formalization.clone());
180        IntentFormalizationCacheEntry {
181            formalization,
182            cache_hit: false,
183        }
184    }
185
186    pub fn append_to_link_store<S: LinkStore>(
187        &self,
188        store: &mut S,
189    ) -> Result<usize, LinkStoreError> {
190        let mut inserted = 0;
191        for formalization in self.records.values() {
192            store.append_memory_event(MemoryEvent {
193                id: formalization.impulse_id.clone(),
194                kind: Some(String::from("intent_formalization")),
195                content: Some(formalization.to_links_notation()),
196                evidence: vec![format!("intent_formalization:{}", formalization.impulse_id)],
197                ..MemoryEvent::default()
198            })?;
199            inserted += 1;
200        }
201        Ok(inserted)
202    }
203}
204
205impl UniversalSolver {
206    #[must_use]
207    pub fn solve_with_intent_cache(
208        &self,
209        prompt: &str,
210        intent_cache: &mut IntentFormalizationCache,
211    ) -> crate::engine::SymbolicAnswer {
212        self.solve_with_history_probability_store_and_intent_cache(
213            prompt,
214            &[],
215            &ProbabilityStore::new(),
216            intent_cache,
217        )
218    }
219}
220
221#[must_use]
222pub fn impulse_id_for(prompt: &str) -> String {
223    stable_id("impulse", &normalize_prompt(prompt))
224}
225
226#[must_use]
227pub fn formalize_intent(
228    prompt: &str,
229    language: &str,
230    candidate: Option<&FormalizationCandidate>,
231) -> IntentFormalization {
232    let normalized = normalize_prompt(prompt);
233    let route = route_for_prompt(&normalized);
234    let parameters = write_program_parameters(&normalized).unwrap_or_default();
235    let mut knowns = vec![
236        format!("impulse:{}", impulse_id_for(prompt)),
237        format!("language:{language}"),
238    ];
239    let mut relevants = Vec::new();
240
241    if let Some(candidate) = candidate {
242        append_candidate_knowns(candidate, &mut knowns, &mut relevants);
243    }
244    for (name, value) in &parameters {
245        push_unique(&mut knowns, format!("parameter:{name}:{value}"));
246    }
247    append_prompt_relevants(prompt, &normalized, &mut relevants);
248
249    let route_slug = route
250        .as_ref()
251        .map(|matched| matched.slug.clone())
252        .or_else(|| route_from_relevants(&relevants));
253    if let Some(route_slug) = &route_slug {
254        push_unique(&mut relevants, format!("route:{route_slug}"));
255        if MethodRegistry::from_dispatch()
256            .method_for_route(route_slug)
257            .is_some()
258        {
259            push_unique(&mut relevants, format!("handler:{route_slug}"));
260        }
261    }
262
263    IntentFormalization {
264        impulse_id: impulse_id_for(prompt),
265        source_text: prompt.to_owned(),
266        normalized_text: normalized.clone(),
267        language: language.to_owned(),
268        kind: infer_kind(prompt, &normalized, route_slug.as_deref(), candidate),
269        knowns,
270        relevants,
271        parameters,
272        response_link: route.map(|matched| matched.response_link),
273        route: route_slug,
274    }
275}
276
277pub(crate) fn record_intent_formalization(
278    log: &mut EventLog,
279    entry: &IntentFormalizationCacheEntry,
280) {
281    let formalization = &entry.formalization;
282    let cache_state = if entry.cache_hit { "hit" } else { "miss" };
283    log.append(
284        "intent_formalization_cache",
285        format!("{cache_state} {}", formalization.impulse_id),
286    );
287    if entry.cache_hit {
288        log.append(
289            "cache_hit",
290            format!("intent_formalization:{}", formalization.impulse_id),
291        );
292    } else {
293        log.append("intent_formalization", formalization.to_links_notation());
294    }
295    log.append(
296        "intent_formalization:kind",
297        formalization.kind.slug().to_owned(),
298    );
299    if let Some(route) = &formalization.route {
300        log.append("intent_formalization:route", route.clone());
301    }
302    for relevant in &formalization.relevants {
303        log.append("intent_formalization:relevant", relevant.clone());
304    }
305}
306
307#[must_use]
308pub(crate) fn select_rule_for_intent(intent: &IntentFormalization) -> SelectedRule {
309    match intent.route.as_deref() {
310        Some("greeting") => SelectedRule::Greeting,
311        Some("wellbeing") => SelectedRule::Wellbeing,
312        Some("farewell") => SelectedRule::Farewell,
313        Some("test_status") => SelectedRule::TestStatus,
314        Some("courtesy_response") => SelectedRule::CourtesyResponse,
315        Some("assistant_free_time") => SelectedRule::AssistantFreeTime,
316        Some("assistant_name") => SelectedRule::AssistantName,
317        Some("identity") => SelectedRule::Identity,
318        Some(WRITE_PROGRAM_INTENT) => write_program_rule_for_intent(intent),
319        _ => SelectedRule::Unknown,
320    }
321}
322
323#[derive(Debug, Clone)]
324struct MatchedRoute {
325    slug: String,
326    response_link: String,
327}
328
329fn route_for_prompt(normalized: &str) -> Option<MatchedRoute> {
330    if write_program_parameters(normalized).is_some() {
331        return Some(MatchedRoute {
332            slug: String::from(WRITE_PROGRAM_INTENT),
333            response_link: String::from("response:write_program"),
334        });
335    }
336    seed::intent_routing()
337        .intents
338        .into_iter()
339        .find(|route| matches_route(normalized, route))
340        .map(|route| MatchedRoute {
341            slug: route.slug,
342            response_link: route.response_link,
343        })
344}
345
346fn matches_route(normalized: &str, route: &seed::IntentRoute) -> bool {
347    route.keywords.iter().any(|keyword| normalized == keyword)
348        || route.phrases.iter().any(|phrase| normalized == phrase)
349        || route
350            .tokens
351            .iter()
352            .any(|token| contains_token(normalized, token))
353        || route.combos.iter().any(|combo| {
354            !combo.is_empty() && combo.iter().all(|token| contains_token(normalized, token))
355        })
356}
357
358fn contains_token(normalized: &str, expected: &str) -> bool {
359    // CJK scripts have no inter-word spaces, so match those aliases by substring
360    // (see `coding::catalog::contains_cjk`). Latin/Cyrillic keep strict
361    // whitespace boundaries so short tokens never match inside larger words.
362    if crate::coding::contains_cjk(expected) {
363        return normalized.contains(expected);
364    }
365    normalized.split_whitespace().any(|token| token == expected)
366}
367
368fn write_program_rule_for_intent(intent: &IntentFormalization) -> SelectedRule {
369    let task = intent.parameters.get("task").cloned();
370    let language = intent.parameters.get("language").cloned();
371    if let (Some(task_slug), Some(language_slug)) = (task.as_deref(), language.as_deref()) {
372        if let Some(spec) = program_spec(task_slug, language_slug) {
373            return SelectedRule::WriteProgram(spec);
374        }
375    }
376    SelectedRule::UnsupportedWriteProgram { task, language }
377}
378
379/// Outcome of trying to complete a follow-up `write_program` request from the
380/// conversation so far (issue #324).
381pub(crate) struct WriteProgramRecovery {
382    /// The rule after recovery — upgraded to [`SelectedRule::WriteProgram`] when
383    /// enough context was found, otherwise the original unsupported rule with any
384    /// parameters we managed to fill in.
385    pub rule: SelectedRule,
386    /// A short trace describing what was carried over, for the event log. `None`
387    /// when nothing was recovered.
388    pub trace: Option<String>,
389    /// The program-modification plan as Links Notation, surfaced when a modifier
390    /// rewrote the task via the substitution pipeline (issue #324 R4/R6). `None`
391    /// when no modification rule fired.
392    pub plan: Option<String>,
393}
394
395/// Outcome of recognizing a bare imperative as a follow-up that refers to the
396/// active program artifact rather than a standalone algorithm/text request.
397pub(crate) struct ProgramCoreferenceRewrite {
398    pub rule: SelectedRule,
399    pub trace: String,
400}
401
402/// Issue #357: after a program has been generated, users often ask bare
403/// imperative follow-ups such as "Sort the results in reverse order" or
404/// "Сделай сортировку результатов..." without repeating "program". Those turns
405/// may route to another handler (or to unknown) even though "results" refers to
406/// the active program artifact. Reclassify only that narrow shape as an
407/// unsupported `write_program` request, then let the existing context recovery
408/// bind the concrete task and language.
409#[must_use]
410pub(crate) fn rewrite_bare_program_coreference_rule(
411    rule: &SelectedRule,
412    follow_up: &str,
413    history: &[ConversationTurn],
414) -> Option<ProgramCoreferenceRewrite> {
415    if matches!(
416        rule,
417        SelectedRule::WriteProgram(_) | SelectedRule::UnsupportedWriteProgram { .. }
418    ) {
419        return None;
420    }
421
422    let normalized = normalize_prompt(follow_up);
423    if !crate::program_coreference::looks_like_bare_program_artifact_follow_up(&normalized) {
424        return None;
425    }
426
427    let context = active_program_context(history)?;
428    let trace = format!(
429        "referent=active_program_artifact task={} language={}",
430        context.task, context.language
431    );
432    Some(ProgramCoreferenceRewrite {
433        rule: SelectedRule::UnsupportedWriteProgram {
434            task: Some(context.task),
435            language: Some(context.language),
436        },
437        trace,
438    })
439}
440
441pub(crate) struct ActiveProgramContext {
442    pub(crate) task: String,
443    pub(crate) language: String,
444}
445
446pub(crate) fn active_program_context(history: &[ConversationTurn]) -> Option<ActiveProgramContext> {
447    let mut task = None;
448    let mut language = None;
449    for turn in history.iter().rev() {
450        let normalized = normalize_prompt(&turn.content);
451        let Some(parameters) = write_program_parameters(&normalized) else {
452            continue;
453        };
454        if task.is_none() {
455            task = parameters.get("task").cloned();
456        }
457        if language.is_none() {
458            language = parameters.get("language").cloned();
459        }
460        if task.is_some() && language.is_some() {
461            break;
462        }
463    }
464    Some(ActiveProgramContext {
465        task: task?,
466        language: language?,
467    })
468}
469
470/// Issue #324: a follow-up such as "Сделай так, чтобы программа принимала путь
471/// как аргумент" ("make the program accept a path as an argument") routes to
472/// `write_program` because it pairs a program noun with an imperative verb, yet
473/// it names neither a concrete task nor a language — both came from the previous
474/// turn. Without conversation context this surfaced the user-reported error
475/// ("I do not have a template for language `missing` and task `missing`").
476///
477/// When the selected rule is [`SelectedRule::UnsupportedWriteProgram`] we recover
478/// the missing task and language from the most recent prior turn that named them
479/// and apply any data-defined modification modifier present in the follow-up.
480/// If the recovered `(task, language)` pair has a template we upgrade the rule
481/// to a concrete program; otherwise we return the rule with whatever we could
482/// fill in so the unsupported message is still as specific as possible.
483#[must_use]
484pub(crate) fn recover_write_program_rule(
485    rule: SelectedRule,
486    follow_up: &str,
487    history: &[ConversationTurn],
488) -> WriteProgramRecovery {
489    let SelectedRule::UnsupportedWriteProgram { task, language } = &rule else {
490        return WriteProgramRecovery {
491            rule,
492            trace: None,
493            plan: None,
494        };
495    };
496
497    let mut recovered_task = task.clone();
498    let mut recovered_language = language.clone();
499
500    if recovered_task.is_none() || recovered_language.is_none() {
501        for turn in history.iter().rev() {
502            let normalized = normalize_prompt(&turn.content);
503            let Some(parameters) = write_program_parameters(&normalized) else {
504                continue;
505            };
506            if recovered_task.is_none() {
507                recovered_task = parameters.get("task").cloned();
508            }
509            if recovered_language.is_none() {
510                recovered_language = parameters.get("language").cloned();
511            }
512            if recovered_task.is_some() && recovered_language.is_some() {
513                break;
514            }
515        }
516    }
517
518    // A modification follow-up lowers the recovered base task through the Links
519    // Notation substitution pipeline, which rewrites e.g. `list_files ->
520    // list_files_arg` or `list_files_arg -> list_files_arg_reverse_sort`. The
521    // plan is captured as Links Notation for transparent tracing.
522    let normalized_follow_up = normalize_prompt(follow_up);
523    let modifiers = detected_program_modifiers(&normalized_follow_up);
524    let mut plan = None;
525    if !modifiers.is_empty() {
526        if let Some(base) = recovered_task.as_deref() {
527            let lowered = crate::program_plan::lower(base, &modifiers);
528            if lowered.was_modified() {
529                plan = Some(lowered.links_notation());
530            }
531            recovered_task = Some(lowered.resolved_task);
532        }
533    }
534
535    if let (Some(task_slug), Some(language_slug)) =
536        (recovered_task.as_deref(), recovered_language.as_deref())
537    {
538        if let Some(spec) = program_spec(task_slug, language_slug) {
539            let trace = format!("write_program task={task_slug} language={language_slug}");
540            return WriteProgramRecovery {
541                rule: SelectedRule::WriteProgram(spec),
542                trace: Some(trace),
543                plan,
544            };
545        }
546    }
547
548    WriteProgramRecovery {
549        rule: SelectedRule::UnsupportedWriteProgram {
550            task: recovered_task,
551            language: recovered_language,
552        },
553        trace: None,
554        plan,
555    }
556}
557
558fn operation_vocabulary() -> &'static seed::OperationVocabulary {
559    static VOCABULARY: OnceLock<seed::OperationVocabulary> = OnceLock::new();
560    VOCABULARY.get_or_init(seed::operation_vocabulary)
561}
562
563/// Detect the modification modifiers present in a (normalized) request, returned
564/// as the slugs the substitution pipeline keys on.
565///
566/// Recognition is data-driven in two stages: `operation-vocabulary.lino` owns
567/// natural-language trigger phrases, and `program-plan-rules.lino` decides which
568/// operation slugs are valid program modifiers by declaring
569/// `request:modifier -> <slug>` conditions.
570pub(crate) fn detected_program_modifiers(normalized: &str) -> Vec<String> {
571    let program_modifiers = crate::program_plan::modifier_slugs();
572    operation_vocabulary()
573        .detect(normalized)
574        .into_iter()
575        .filter(|slug| program_modifiers.contains(slug.as_str()))
576        .collect()
577}
578
579fn write_program_parameters(normalized: &str) -> Option<BTreeMap<String, String>> {
580    let task = crate::coding::program_task_by_alias(normalized);
581    let language = requested_program_language(normalized);
582    // Issue #386: "write a <program>" is recognised by *meaning*, not a hardcoded
583    // per-language word list. The prompt asks for a program when it evidences a
584    // `program_kind` meaning (the artefact: program / script / code / function /
585    // class) *and* a `program_request` meaning (the verb: write / create / show /
586    // generate / make / build). The surface words for every language live once,
587    // in `data/seed/meanings.lino`; this code understands the concepts.
588    let lexicon = crate::seed::lexicon();
589    let mentions_program_request =
590        lexicon.mentions_role(crate::seed::ROLE_PROGRAM_REQUEST, normalized);
591    let asks_for_program = lexicon.mentions_role(crate::seed::ROLE_PROGRAM_KIND, normalized)
592        && mentions_program_request;
593    let asks_for_known_language_program = language
594        .as_deref()
595        .is_some_and(|language| mentions_program_request && known_write_program_language(language));
596    if task.is_none() && !asks_for_program && !asks_for_known_language_program {
597        return None;
598    }
599    let mut parameters = BTreeMap::new();
600    if let Some(task) = task {
601        // Issue #358: modification phrases in the same turn lower the base task
602        // through the data-backed substitution pipeline so composed requests can
603        // resolve directly.
604        let modifiers = detected_program_modifiers(normalized);
605        let task_slug = crate::program_plan::resolve_task(task.slug, &modifiers);
606        parameters.insert(String::from("task"), task_slug);
607    }
608    if let Some(language) = language {
609        parameters.insert(String::from("language"), language);
610    }
611    Some(parameters)
612}
613
614fn known_write_program_language(language: &str) -> bool {
615    crate::coding::program_language_by_slug(language).is_some()
616        || crate::knowledge::CodingOracle::knows_language(language)
617}
618
619fn requested_program_language(normalized: &str) -> Option<String> {
620    if let Some(language) = program_language_by_alias(normalized) {
621        return Some(String::from(language.slug));
622    }
623    // Issue #386: the function words that introduce an *unknown* implementation
624    // language ("write a program in <name>", "на языке <name>") are seed data,
625    // not literals baked into the parser. Source the head-initial English/Russian
626    // surfaces of the target-preposition and "language" noun roles from the
627    // lexicon so this positional extractor reasons over the ontology instead of a
628    // hardcoded `matches!` list. The catalog-driven `program_language_by_alias`
629    // above already resolves every *known* language across all four supported
630    // languages; this fallback only reads the bare name trailing the marker, so
631    // it consults the two head-initial languages whose name follows the marker
632    // (the head-final Hindi/Chinese forms are carried in the seed for coverage
633    // but place the name before the marker, which this scan does not chase).
634    let lexicon = crate::seed::lexicon();
635    let preposition_surfaces = lexicon.words_for_role_in_languages(
636        crate::seed::ROLE_IMPLEMENTATION_LANGUAGE_PREPOSITION,
637        &["en", "ru"],
638    );
639    let language_noun_surfaces = lexicon.words_for_role_in_languages(
640        crate::seed::ROLE_IMPLEMENTATION_LANGUAGE_NOUN,
641        &["en", "ru"],
642    );
643    let tokens = normalized.split_whitespace().collect::<Vec<_>>();
644    for (index, token) in tokens.iter().enumerate() {
645        if !preposition_surfaces
646            .iter()
647            .any(|surface| surface.as_str() == *token)
648        {
649            continue;
650        }
651        let Some(next) = tokens.get(index + 1) else {
652            continue;
653        };
654        if language_noun_surfaces
655            .iter()
656            .any(|surface| surface.as_str() == *next)
657        {
658            if let Some(after_language_word) = tokens.get(index + 2) {
659                return Some((*after_language_word).to_owned());
660            }
661            continue;
662        }
663        return Some((*next).to_owned());
664    }
665    None
666}
667
668fn append_candidate_knowns(
669    candidate: &FormalizationCandidate,
670    knowns: &mut Vec<String>,
671    relevants: &mut Vec<String>,
672) {
673    for slot in &candidate.slots {
674        push_unique(
675            knowns,
676            slot_known_link(slot.role, slot.anchor.kind, &slot.anchor.id),
677        );
678        if slot.role == FormalizationRole::Predicate && slot.anchor.id == "wikidata:P5972" {
679            push_unique(relevants, String::from("handler:translation"));
680            push_unique(relevants, String::from("route:translation"));
681        }
682    }
683    for term in &candidate.unresolved_terms {
684        push_unique(knowns, format!("formalization_unresolved:{term}"));
685    }
686}
687
688fn slot_known_link(role: FormalizationRole, kind: FormalizationAnchorKind, id: &str) -> String {
689    match (role, kind) {
690        (FormalizationRole::Subject, FormalizationAnchorKind::WikidataItem) => {
691            format!("formalization:subject_q:{id}")
692        }
693        (FormalizationRole::Predicate, FormalizationAnchorKind::WikidataProperty) => {
694            format!("formalization:predicate_p:{id}")
695        }
696        (FormalizationRole::Object, FormalizationAnchorKind::WikidataItem) => {
697            format!("formalization:object_q:{id}")
698        }
699        (_, FormalizationAnchorKind::WikidataItem) => {
700            format!("formalization:item_q:{id}")
701        }
702        (_, FormalizationAnchorKind::WikidataProperty) => {
703            format!("formalization:property_p:{id}")
704        }
705        (
706            _,
707            FormalizationAnchorKind::WikipediaArticle | FormalizationAnchorKind::WiktionaryEntry,
708        ) => {
709            format!("formalization:fallback:{id}")
710        }
711        (_, FormalizationAnchorKind::RawText) => format!("formalization:raw:{id}"),
712    }
713}
714
715fn append_prompt_relevants(prompt: &str, normalized: &str, relevants: &mut Vec<String>) {
716    let lower_prompt = prompt.to_ascii_lowercase();
717    let operation_view = seed::operation_vocabulary().canonicalized_prompt(normalized);
718    let handlers = [
719        (
720            "handler:execution_failure",
721            cue_lexicon::matches("execution_failure_prompt", &lower_prompt)
722                || cue_lexicon::matches("execution_failure_normalized", normalized),
723        ),
724        ("handler:arithmetic", looks_arithmetic(prompt, normalized)),
725        (
726            "handler:web_search",
727            cue_lexicon::matches("web_search", normalized)
728                || looks_like_latest_news_search(normalized)
729                || looks_like_records_information_search(normalized),
730        ),
731        (
732            "handler:procedural_how_to",
733            cue_lexicon::matches("procedural_how_to", normalized)
734                || crate::solver_handler_how::looks_like_procedural_how_to(normalized),
735        ),
736        (
737            "handler:proof_request",
738            cue_lexicon::matches("proof_request", normalized),
739        ),
740        (
741            "handler:write_script",
742            cue_lexicon::matches("write_script", normalized),
743        ),
744        (
745            "handler:write_program",
746            write_program_parameters(normalized).is_some(),
747        ),
748        (
749            "handler:program_synthesis",
750            looks_like_program_synthesis(&operation_view),
751        ),
752        (
753            "handler:text_manipulation",
754            looks_like_text_manipulation(&operation_view),
755        ),
756        (
757            "handler:software_project",
758            cue_lexicon::matches("software_project", normalized),
759        ),
760        (
761            "handler:meta_explanation",
762            seed::lexicon().mentions_role_raw(seed::ROLE_ASSISTANT_MECHANISM_INQUIRY, normalized),
763        ),
764        (
765            "handler:concept_lookup",
766            cue_lexicon::matches("concept_lookup", normalized)
767                || looks_like_single_concept_lookup(prompt),
768        ),
769        ("handler:calendar_create_event", {
770            let lex = seed::lexicon();
771            let has_day_ref = lex
772                .words_for_role(seed::ROLE_CALENDAR_DAY_REFERENCE)
773                .iter()
774                .any(|w| contains_term_for_relevants(normalized, w));
775            let has_digit = normalized.chars().any(|c| c.is_ascii_digit());
776            let has_date_signal = has_day_ref || has_digit;
777            let has_schedule = lex
778                .words_for_role(seed::ROLE_CALENDAR_SCHEDULE_ACTION)
779                .iter()
780                .any(|w| contains_term_for_relevants(normalized, w))
781                || lex
782                    .words_for_role(seed::ROLE_CALENDAR_EVENT)
783                    .iter()
784                    .any(|w| contains_term_for_relevants(normalized, w));
785            // Fallback cues for classic phrasing (RU from the bug report + EN "schedule ... 18th").
786            // The verb cues use token-boundary matching (cue-lexicon `match "token"`) so that
787            // unrelated text such as "free-programming-books" (the word "book" embedded in
788            // "books") cannot masquerade as a schedule verb. The "число"/"в "/":" glue stays in
789            // code as a structural composite: a date marker conjoined with a preposition or a
790            // time separator.
791            let fallback_cue = cue_lexicon::matches("calendar_fallback_verbs", normalized)
792                || (cue_lexicon::matches("calendar_ru_date_marker", normalized)
793                    && (normalized.contains("в ") || normalized.contains(':')))
794                || (has_digit && cue_lexicon::matches("calendar_digit_actions", normalized));
795            has_date_signal && (has_schedule || fallback_cue)
796        }),
797    ];
798    for (handler, matches) in handlers {
799        if matches {
800            push_unique(relevants, String::from(handler));
801        }
802    }
803}
804
805fn looks_like_single_concept_lookup(prompt: &str) -> bool {
806    concepts::extract_concept_query(prompt).is_some_and(|query| {
807        !query
808            .term
809            .chars()
810            .any(|character| matches!(character, ',' | ';' | ',' | '、'))
811    })
812}
813
814fn looks_arithmetic(prompt: &str, normalized: &str) -> bool {
815    // Structural composite: a digit must be present, and an arithmetic operator must
816    // appear in either the raw lowercased prompt or the normalized view. The operator
817    // cue list lives in the cue lexicon (`arithmetic_operators`, substring match); the
818    // digit-plus-either-input glue stays here.
819    let raw = prompt.to_ascii_lowercase();
820    raw.chars().any(|c| c.is_ascii_digit())
821        && cue_lexicon::cues("arithmetic_operators")
822            .iter()
823            .any(|operator| raw.contains(operator) || normalized.contains(operator))
824}
825
826fn looks_like_latest_news_search(normalized: &str) -> bool {
827    let padded = format!(" {normalized} ");
828    let lexicon = seed::lexicon();
829    lexicon.mentions_role_raw(seed::ROLE_WEB_SEARCH_NEWS_SUBJECT, &padded)
830        && lexicon.mentions_role_raw(seed::ROLE_WEB_SEARCH_NEWS_RECENCY, &padded)
831}
832
833/// Routing mirror of `web_search_intent::extract_records_information_request`: a
834/// verbless "records about a subject" request names a record subject
835/// ([`ROLE_WEB_SEARCH_RECORDS_SUBJECT`]) tied to that subject by a topic
836/// connective ([`ROLE_WEB_SEARCH_TOPIC_MARKER`]).
837fn looks_like_records_information_search(normalized: &str) -> bool {
838    let padded = format!(" {normalized} ");
839    let lexicon = seed::lexicon();
840    lexicon.mentions_role_raw(seed::ROLE_WEB_SEARCH_RECORDS_SUBJECT, &padded)
841        && lexicon.mentions_role_raw(seed::ROLE_WEB_SEARCH_TOPIC_MARKER, &padded)
842}
843
844fn looks_like_program_synthesis(normalized: &str) -> bool {
845    // Routing mirror of `crate::solver_handlers::program_synthesis`'s gate, over
846    // the canonicalized view: a function *subject*, a *domain* signal (Python or
847    // a data kind) or the similar-elements task signal, and a request *action*
848    // verb. Every surface word comes from the meaning lexicon, not from literals.
849    let lexicon = crate::seed::lexicon();
850    let similar_elements = lexicon
851        .meaning("signal_similar_elements")
852        .is_some_and(|signal| signal.evidenced_in(normalized));
853    lexicon.mentions_role(crate::seed::ROLE_PROGRAM_SYNTHESIS_SUBJECT, normalized)
854        && (lexicon.mentions_role(crate::seed::ROLE_PROGRAM_SYNTHESIS_DOMAIN, normalized)
855            || similar_elements)
856        && lexicon.mentions_role(crate::seed::ROLE_PROGRAM_SYNTHESIS_ACTION, normalized)
857}
858
859fn looks_like_text_manipulation(normalized: &str) -> bool {
860    // The fourteen text-manipulation operation cues live in the cue lexicon
861    // (`text_manipulation`, substring match), not as a Rust string literal.
862    cue_lexicon::matches("text_manipulation", normalized)
863}
864
865fn has_any_token(normalized: &str, tokens: &[&str]) -> bool {
866    tokens.iter().any(|token| contains_token(normalized, token))
867}
868
869// Loose term check used only inside append_prompt_relevants for calendar create
870// promotion (the real strict boundary-aware version lives in the calendar handler).
871fn contains_term_for_relevants(haystack: &str, needle: &str) -> bool {
872    if needle.is_empty() {
873        return false;
874    }
875    haystack.contains(needle)
876}
877
878fn route_from_relevants(relevants: &[String]) -> Option<String> {
879    let registry = MethodRegistry::from_dispatch();
880    relevants.iter().find_map(|relevant| {
881        let slug = relevant
882            .strip_prefix("route:")
883            .or_else(|| relevant.strip_prefix("handler:"))
884            .filter(|slug| registry.method_for_route(slug).is_some())?;
885        Some(slug.to_owned())
886    })
887}
888
889fn infer_kind(
890    prompt: &str,
891    normalized: &str,
892    route: Option<&str>,
893    candidate: Option<&FormalizationCandidate>,
894) -> IntentKind {
895    match route {
896        Some("greeting" | "wellbeing" | "farewell" | "courtesy_response") => IntentKind::Courtesy,
897        Some("assistant_name" | "identity") => IntentKind::Question,
898        Some(
899            "translation"
900            | "algorithm"
901            | "write_program"
902            | "text_manipulation"
903            | "software_project_plan"
904            | "software_project_implementation",
905        ) => IntentKind::Task,
906        _ if contains_question_mark(prompt) || starts_with_question_word(normalized) => {
907            IntentKind::Question
908        }
909        _ if has_any_token(normalized, &["must", "should", "require", "requires"]) => {
910            IntentKind::Requirement
911        }
912        _ if has_any_token(
913            normalized,
914            &[
915                "translate",
916                "write",
917                "calculate",
918                "search",
919                "find",
920                "prove",
921                "define",
922            ],
923        ) =>
924        {
925            IntentKind::Task
926        }
927        _ if candidate.is_some_and(|candidate| !candidate.slots.is_empty()) => {
928            IntentKind::Statement
929        }
930        _ => IntentKind::Unknown,
931    }
932}
933
934fn starts_with_question_word(normalized: &str) -> bool {
935    // The interrogative openers (the wh-words) are carried by the
936    // `interrogative_opener` meaning in the seed, not hardcoded here. English and
937    // Russian are head-initial, so the opener fronts the prompt and a prefix match
938    // — the bare word followed immediately by a space — detects it; the head-final
939    // Hindi and Chinese surfaces are carried for coverage but place the question
940    // word later, which this front scan does not chase.
941    crate::seed::lexicon()
942        .words_for_role_in_languages(crate::seed::ROLE_INTERROGATIVE_OPENER, &["en", "ru"])
943        .iter()
944        .any(|word| {
945            normalized
946                .strip_prefix(word.as_str())
947                .is_some_and(|rest| rest.starts_with(' '))
948        })
949}
950
951fn contains_question_mark(prompt: &str) -> bool {
952    prompt.contains('?') || prompt.contains('?')
953}
954
955fn push_unique(values: &mut Vec<String>, value: String) {
956    if !values.contains(&value) {
957        values.push(value);
958    }
959}