Skip to main content

formal_ai/
skill_compiler.rs

1//! Natural-language skill compiler.
2//!
3//! The first compiler target is intentionally narrow and deterministic:
4//! trigger/response prose such as `When I say "X", answer "Y"` lowers to a
5//! reviewable associative package containing a trigger rule and compiled
6//! response handler. The solver can replay that package from dialog history
7//! and record the reuse as a `cache_hit`.
8
9use std::error::Error;
10use std::fmt;
11
12use crate::engine::{normalize_prompt, stable_id, KNOWLEDGE_SCHEMA_VERSION};
13use crate::link_store::{DoubletLink, LinkRecord};
14use crate::links_format::{format_lino_record, push_lino_node};
15use crate::seed::{self, Slot};
16
17mod structured;
18
19use structured::{
20    handler_signature, handler_stub_source, parse_structured_skill, structured_canonical,
21    StructuredSkillSpec,
22};
23
24/// A reusable, reviewable package compiled from one natural-language skill.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct CompiledSkillPackage {
27    /// Stable package id used as the cache key.
28    pub id: String,
29    /// Legacy dialog-local behavior-rule id kept for existing UI wording.
30    pub legacy_behavior_rule_id: String,
31    /// Original prose instruction supplied by the user or seed data.
32    pub source_description: String,
33    /// Surface form that triggers the compiled package.
34    pub trigger: String,
35    /// Normalized trigger used by the deterministic replay matcher.
36    pub normalized_trigger: String,
37    /// Response emitted by the compiled handler.
38    pub response: String,
39    /// Stable id of the generated trigger/substitution rule.
40    pub rule_id: String,
41    /// Stable id of the generated compiled handler.
42    pub handler_id: String,
43    /// Human-readable skill name from the structured definition, when present.
44    pub skill_name: String,
45    /// Typed arguments accepted by the compiled skill subset.
46    pub inputs: Vec<CompiledSkillInput>,
47    /// Preconditions checked before a generated handler is considered valid.
48    pub preconditions: Vec<CompiledSkillPrecondition>,
49    /// Ordered procedure steps lowered from the skill definition.
50    pub steps: Vec<CompiledSkillStep>,
51    /// Declared deterministic effects of the procedure.
52    pub effects: Vec<CompiledSkillEffect>,
53    /// Generated replay tests used both as examples and deterministic fixtures.
54    pub expected_tests: Vec<CompiledSkillExpectedTest>,
55    /// Explicit package/tool permissions required by the compiled skill.
56    pub required_permissions: Vec<CompiledSkillPermission>,
57    /// Target-specific handler stubs that can be reviewed before implementation.
58    pub handler_stubs: Vec<CompiledSkillHandlerStub>,
59}
60
61/// Typed argument accepted by the structured skill subset.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct CompiledSkillInput {
64    pub id: String,
65    pub name: String,
66    pub value_type: String,
67}
68
69/// Deterministic precondition from a structured skill definition.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct CompiledSkillPrecondition {
72    pub id: String,
73    pub description: String,
74}
75
76/// Ordered procedure step from a structured skill definition.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct CompiledSkillStep {
79    pub id: String,
80    pub order: usize,
81    pub description: String,
82}
83
84/// Declared effect of a structured skill definition.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct CompiledSkillEffect {
87    pub id: String,
88    pub description: String,
89}
90
91/// Generated expected test for deterministic replay.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct CompiledSkillExpectedTest {
94    pub id: String,
95    pub input: String,
96    pub normalized_input: String,
97    pub expected_output: String,
98    pub trigger_id: String,
99    pub handler_id: String,
100}
101
102/// Explicit package/tool permission required by a structured skill.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct CompiledSkillPermission {
105    pub id: String,
106    pub capability: String,
107    pub description: String,
108}
109
110/// Reviewable generated handler placeholder for a target runtime.
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct CompiledSkillHandlerStub {
113    pub id: String,
114    pub target: String,
115    pub signature: String,
116    pub source: String,
117}
118
119/// Deterministic replay result from a compiled package.
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct CompiledSkillReplay {
122    /// Package that replayed.
123    pub package_id: String,
124    /// Trigger rule selected for this replay.
125    pub rule_id: String,
126    /// Compiled handler selected for this replay.
127    pub handler_id: String,
128    /// User-facing answer projected by the compiled handler.
129    pub answer: String,
130    /// Cache-hit payload that should be appended to the event log.
131    pub cache_hit: String,
132}
133
134/// Error returned when a skill description cannot be lowered safely.
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub enum SkillCompileError {
137    /// The text did not match a supported deterministic skill shape.
138    UnsupportedShape,
139    /// The skill asks for nondeterministic or otherwise unsupported behavior.
140    UnsupportedInstruction { reason: String },
141    /// The skill names a permissioned tool/action without an explicit grant.
142    PermissionRequired { capability: String },
143}
144
145impl fmt::Display for SkillCompileError {
146    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
147        match self {
148            Self::UnsupportedShape => {
149                write!(formatter, "unsupported natural-language skill shape")
150            }
151            Self::UnsupportedInstruction { reason } => {
152                write!(
153                    formatter,
154                    "unsupported natural-language instruction: {reason}"
155                )
156            }
157            Self::PermissionRequired { capability } => {
158                write!(formatter, "skill requires explicit permission {capability}")
159            }
160        }
161    }
162}
163
164impl Error for SkillCompileError {}
165
166/// Compile a natural-language skill into a reusable associative package.
167///
168/// Supported shapes include `When I say "X", answer "Y"`, `If I ask "X",
169/// reply "Y"`, and the multilingual `When "X" then "Y"` forms already used by
170/// behavior-rule teaching.
171pub fn compile_natural_language_skill(
172    description: &str,
173) -> Result<CompiledSkillPackage, SkillCompileError> {
174    if let Some(spec) = parse_structured_skill(description)? {
175        return CompiledSkillPackage::from_structured(description, spec);
176    }
177    let Some((trigger, response)) = extract_trigger_response(description) else {
178        return Err(SkillCompileError::UnsupportedShape);
179    };
180    Ok(CompiledSkillPackage::new(description, &trigger, &response))
181}
182
183/// Knowledge-export record for the natural-language skill compiler.
184pub(crate) fn natural_language_skill_compiler_record() -> String {
185    format_lino_record(
186        "natural_language_skill_compiler",
187        &[
188            ("type", String::from("compiled_handler")),
189            ("rule_shape", String::from("natural_language_skill")),
190            ("package_type", String::from("CompiledSkillPackage")),
191            ("module", String::from("src/skill_compiler.rs")),
192            ("exports", String::from("compile_natural_language_skill")),
193            ("trigger_record", String::from("CompiledSkillTriggerRule")),
194            ("handler_record", String::from("CompiledSkillHandler")),
195            ("cache_event", String::from("cache_hit")),
196            ("source", String::from("ARCHITECTURE.md section 9 #5")),
197        ],
198    )
199}
200
201impl CompiledSkillPackage {
202    /// Create a compiled package from a verified trigger/response pair.
203    #[must_use]
204    pub fn new(source_description: &str, trigger: &str, response: &str) -> Self {
205        let normalized_trigger = normalize_prompt(trigger);
206        let canonical = format!("{normalized_trigger}\n{response}");
207        let id = stable_id("compiled_skill", &canonical);
208        let rule_id = stable_id(
209            "compiled_skill_rule",
210            &format!("{id}:rule:{normalized_trigger}"),
211        );
212        let handler_id = stable_id(
213            "compiled_skill_handler",
214            &format!("{id}:handler:{response}"),
215        );
216        let legacy_behavior_rule_id =
217            stable_id("behavior_rule_runtime", &format!("{trigger}\n{response}"));
218        Self {
219            id,
220            legacy_behavior_rule_id,
221            source_description: source_description.to_owned(),
222            trigger: trigger.to_owned(),
223            normalized_trigger,
224            response: response.to_owned(),
225            rule_id,
226            handler_id,
227            skill_name: String::from("trigger_response_skill"),
228            inputs: Vec::new(),
229            preconditions: Vec::new(),
230            steps: Vec::new(),
231            effects: Vec::new(),
232            expected_tests: Vec::new(),
233            required_permissions: Vec::new(),
234            handler_stubs: Vec::new(),
235        }
236    }
237
238    fn from_structured(
239        source_description: &str,
240        spec: StructuredSkillSpec,
241    ) -> Result<Self, SkillCompileError> {
242        let Some(primary) = spec.primary_replay() else {
243            return Err(SkillCompileError::UnsupportedShape);
244        };
245        let normalized_trigger = normalize_prompt(&primary.trigger);
246        let canonical = structured_canonical(source_description, &spec, &primary);
247        let id = stable_id("compiled_skill", &canonical);
248        let rule_id = stable_id(
249            "compiled_skill_rule",
250            &format!("{id}:rule:{normalized_trigger}"),
251        );
252        let handler_id = stable_id(
253            "compiled_skill_handler",
254            &format!("{id}:handler:{}", primary.response),
255        );
256        let legacy_behavior_rule_id = stable_id(
257            "behavior_rule_runtime",
258            &format!("{}\n{}", primary.trigger, primary.response),
259        );
260        let inputs = spec
261            .inputs
262            .into_iter()
263            .map(|input| {
264                let record_id = stable_id("compiled_skill_input", &format!("{id}:{}", input.name));
265                CompiledSkillInput {
266                    id: record_id,
267                    name: input.name,
268                    value_type: input.value_type,
269                }
270            })
271            .collect::<Vec<_>>();
272        let preconditions = spec
273            .preconditions
274            .into_iter()
275            .map(|description| CompiledSkillPrecondition {
276                id: stable_id(
277                    "compiled_skill_precondition",
278                    &format!("{id}:{description}"),
279                ),
280                description,
281            })
282            .collect();
283        let steps = spec
284            .steps
285            .into_iter()
286            .enumerate()
287            .map(|(index, description)| CompiledSkillStep {
288                id: stable_id(
289                    "compiled_skill_step",
290                    &format!("{id}:{}:{description}", index + 1),
291                ),
292                order: index + 1,
293                description,
294            })
295            .collect::<Vec<_>>();
296        let effects = spec
297            .effects
298            .into_iter()
299            .map(|description| CompiledSkillEffect {
300                id: stable_id("compiled_skill_effect", &format!("{id}:{description}")),
301                description,
302            })
303            .collect();
304        let expected_tests = spec
305            .expected_tests
306            .into_iter()
307            .map(|test| {
308                let normalized_input = normalize_prompt(&test.input);
309                let test_id = stable_id(
310                    "compiled_skill_test",
311                    &format!("{id}:{normalized_input}:{}", test.expected_output),
312                );
313                CompiledSkillExpectedTest {
314                    id: test_id,
315                    trigger_id: stable_id(
316                        "compiled_skill_rule",
317                        &format!("{id}:test_rule:{normalized_input}"),
318                    ),
319                    handler_id: stable_id(
320                        "compiled_skill_handler",
321                        &format!(
322                            "{id}:test_handler:{normalized_input}:{}",
323                            test.expected_output
324                        ),
325                    ),
326                    input: test.input,
327                    normalized_input,
328                    expected_output: test.expected_output,
329                }
330            })
331            .collect::<Vec<_>>();
332        let required_permissions = spec
333            .permissions
334            .into_iter()
335            .map(|permission| CompiledSkillPermission {
336                id: stable_id(
337                    "compiled_skill_permission",
338                    &format!("{id}:{}:{}", permission.capability, permission.description),
339                ),
340                capability: permission.capability,
341                description: permission.description,
342            })
343            .collect::<Vec<_>>();
344        let skill_name = spec.name.clone();
345        let handler_stubs = spec
346            .targets
347            .into_iter()
348            .map(|target| {
349                let signature = handler_signature(&skill_name, &inputs);
350                let source = handler_stub_source(&target, &skill_name, &inputs, &steps, &primary);
351                CompiledSkillHandlerStub {
352                    id: stable_id("compiled_skill_handler_stub", &format!("{id}:{target}")),
353                    target,
354                    signature,
355                    source,
356                }
357            })
358            .collect();
359        Ok(Self {
360            id,
361            legacy_behavior_rule_id,
362            source_description: source_description.to_owned(),
363            trigger: primary.trigger,
364            normalized_trigger,
365            response: primary.response,
366            rule_id,
367            handler_id,
368            skill_name,
369            inputs,
370            preconditions,
371            steps,
372            effects,
373            expected_tests,
374            required_permissions,
375            handler_stubs,
376        })
377    }
378
379    /// Replay the package when `prompt` matches the compiled trigger.
380    #[must_use]
381    pub fn replay(&self, prompt: &str) -> Option<CompiledSkillReplay> {
382        let normalized = normalize_prompt(prompt);
383        if let Some(test) = self
384            .expected_tests
385            .iter()
386            .find(|test| test.normalized_input == normalized)
387        {
388            return Some(CompiledSkillReplay {
389                package_id: self.id.clone(),
390                rule_id: test.trigger_id.clone(),
391                handler_id: test.handler_id.clone(),
392                answer: test.expected_output.clone(),
393                cache_hit: self.id.clone(),
394            });
395        }
396        if normalized != self.normalized_trigger {
397            return None;
398        }
399        Some(CompiledSkillReplay {
400            package_id: self.id.clone(),
401            rule_id: self.rule_id.clone(),
402            handler_id: self.handler_id.clone(),
403            answer: self.response.clone(),
404            cache_hit: self.id.clone(),
405        })
406    }
407
408    /// Export the compiled package as reviewable Links Notation.
409    #[must_use]
410    pub fn links_notation(&self) -> String {
411        let mut out = String::new();
412        push_lino_node(&mut out, 0, &self.id, None);
413        push_lino_node(&mut out, 2, "type", Some("compiled_skill_package"));
414        push_lino_node(
415            &mut out,
416            2,
417            "schema_version",
418            Some(KNOWLEDGE_SCHEMA_VERSION),
419        );
420        push_lino_node(&mut out, 2, "package_kind", Some("associative_package"));
421        push_lino_node(&mut out, 2, "source", Some("natural_language_skill"));
422        push_lino_node(
423            &mut out,
424            2,
425            "source_description",
426            Some(&self.source_description),
427        );
428        push_lino_node(&mut out, 2, "skill_name", Some(&self.skill_name));
429        push_lino_node(&mut out, 2, "trigger_rule", Some(&self.rule_id));
430        push_lino_node(&mut out, 2, "trigger", Some(&self.trigger));
431        push_lino_node(
432            &mut out,
433            2,
434            "normalized_trigger",
435            Some(&self.normalized_trigger),
436        );
437        push_lino_node(&mut out, 2, "compiled_handler", Some(&self.handler_id));
438        push_lino_node(&mut out, 2, "handler_kind", Some("deterministic_response"));
439        push_lino_node(&mut out, 2, "response", Some(&self.response));
440        push_lino_node(&mut out, 2, "replay_mode", Some("exact_normalized_prompt"));
441        push_lino_node(
442            &mut out,
443            2,
444            "legacy_behavior_rule_id",
445            Some(&self.legacy_behavior_rule_id),
446        );
447        for input in &self.inputs {
448            push_lino_node(&mut out, 2, "input", Some(&input.name));
449            push_lino_node(&mut out, 4, "id", Some(&input.id));
450            push_lino_node(&mut out, 4, "type", Some(&input.value_type));
451        }
452        for precondition in &self.preconditions {
453            push_lino_node(&mut out, 2, "precondition", Some(&precondition.id));
454            push_lino_node(&mut out, 4, "description", Some(&precondition.description));
455        }
456        for step in &self.steps {
457            let order = step.order.to_string();
458            push_lino_node(&mut out, 2, "step", Some(&step.id));
459            push_lino_node(&mut out, 4, "order", Some(&order));
460            push_lino_node(&mut out, 4, "description", Some(&step.description));
461        }
462        for effect in &self.effects {
463            push_lino_node(&mut out, 2, "effect", Some(&effect.id));
464            push_lino_node(&mut out, 4, "description", Some(&effect.description));
465        }
466        for test in &self.expected_tests {
467            push_lino_node(&mut out, 2, "expected_test", Some(&test.id));
468            push_lino_node(&mut out, 4, "input", Some(&test.input));
469            push_lino_node(
470                &mut out,
471                4,
472                "normalized_input",
473                Some(&test.normalized_input),
474            );
475            push_lino_node(&mut out, 4, "expected_output", Some(&test.expected_output));
476            push_lino_node(&mut out, 4, "trigger_rule", Some(&test.trigger_id));
477            push_lino_node(&mut out, 4, "compiled_handler", Some(&test.handler_id));
478        }
479        for permission in &self.required_permissions {
480            push_lino_node(&mut out, 2, "permission", Some(&permission.id));
481            push_lino_node(&mut out, 4, "capability", Some(&permission.capability));
482            push_lino_node(&mut out, 4, "description", Some(&permission.description));
483        }
484        for stub in &self.handler_stubs {
485            push_lino_node(&mut out, 2, "handler_stub", Some(&stub.id));
486            push_lino_node(&mut out, 4, "target", Some(&stub.target));
487            push_lino_node(&mut out, 4, "signature", Some(&stub.signature));
488            push_lino_node(&mut out, 4, "source_code", Some(&stub.source));
489        }
490        out.trim_end().to_owned()
491    }
492
493    /// Project the package, trigger rule, and handler to E1-style link records.
494    #[must_use]
495    pub fn link_records(&self) -> Vec<LinkRecord> {
496        let mut records = vec![
497            link_record(
498                &self.id,
499                "CompiledSkillPackage",
500                "associative_package",
501                &stable_id("natural_language_skill", &self.source_description),
502                &[
503                    ("source_description", self.source_description.as_str()),
504                    ("skill_name", self.skill_name.as_str()),
505                    ("trigger_rule", self.rule_id.as_str()),
506                    ("compiled_handler", self.handler_id.as_str()),
507                    ("replay_mode", "exact_normalized_prompt"),
508                ],
509            ),
510            link_record(
511                &self.rule_id,
512                "CompiledSkillTriggerRule",
513                "substitution_rule",
514                &self.id,
515                &[
516                    ("trigger", self.trigger.as_str()),
517                    ("normalized_trigger", self.normalized_trigger.as_str()),
518                    ("handler", self.handler_id.as_str()),
519                ],
520            ),
521            link_record(
522                &self.handler_id,
523                "CompiledSkillHandler",
524                "deterministic_response_handler",
525                &self.id,
526                &[
527                    ("handler_kind", "deterministic_response"),
528                    ("response", self.response.as_str()),
529                ],
530            ),
531        ];
532        for input in &self.inputs {
533            records.push(link_record(
534                &input.id,
535                "CompiledSkillInput",
536                "typed_input",
537                &self.id,
538                &[
539                    ("name", input.name.as_str()),
540                    ("type", input.value_type.as_str()),
541                ],
542            ));
543        }
544        for precondition in &self.preconditions {
545            records.push(link_record(
546                &precondition.id,
547                "CompiledSkillPrecondition",
548                "precondition",
549                &self.id,
550                &[("description", precondition.description.as_str())],
551            ));
552        }
553        for step in &self.steps {
554            let order = step.order.to_string();
555            records.push(link_record(
556                &step.id,
557                "CompiledSkillStep",
558                "procedure_step",
559                &self.id,
560                &[
561                    ("order", order.as_str()),
562                    ("description", step.description.as_str()),
563                ],
564            ));
565        }
566        for effect in &self.effects {
567            records.push(link_record(
568                &effect.id,
569                "CompiledSkillEffect",
570                "declared_effect",
571                &self.id,
572                &[("description", effect.description.as_str())],
573            ));
574        }
575        for test in &self.expected_tests {
576            records.push(link_record(
577                &test.id,
578                "CompiledSkillExpectedTest",
579                "generated_test",
580                &self.id,
581                &[
582                    ("input", test.input.as_str()),
583                    ("expected_output", test.expected_output.as_str()),
584                    ("trigger_rule", test.trigger_id.as_str()),
585                    ("compiled_handler", test.handler_id.as_str()),
586                ],
587            ));
588        }
589        for permission in &self.required_permissions {
590            records.push(link_record(
591                &permission.id,
592                "CompiledSkillPermission",
593                "permission_grant",
594                &self.id,
595                &[
596                    ("capability", permission.capability.as_str()),
597                    ("description", permission.description.as_str()),
598                ],
599            ));
600        }
601        for stub in &self.handler_stubs {
602            records.push(link_record(
603                &stub.id,
604                "CompiledSkillHandlerStub",
605                "generated_handler_stub",
606                &self.id,
607                &[
608                    ("target", stub.target.as_str()),
609                    ("signature", stub.signature.as_str()),
610                    ("source_code", stub.source.as_str()),
611                ],
612            ));
613        }
614        records
615    }
616}
617
618fn extract_trigger_response(description: &str) -> Option<(String, String)> {
619    if !looks_like_skill_description(description) {
620        return None;
621    }
622    let spans = code_spans(description);
623    if spans.len() < 2 {
624        return None;
625    }
626    let trigger = spans[0].trim();
627    let response = spans[1].trim();
628    if trigger.is_empty() || response.is_empty() {
629        return None;
630    }
631    Some((trigger.to_owned(), response.to_owned()))
632}
633
634/// True when `description` reads as a teachable skill — either an explicit
635/// teaching form or a conditional when-then frame that quotes a trigger and reply.
636///
637/// No keyword is named here. The explicit teaching form is read from the
638/// [`skill_teaching_trigger_lead`](seed::ROLE_SKILL_TEACHING_TRIGGER_LEAD),
639/// [`skill_teaching_response_verb`](seed::ROLE_SKILL_TEACHING_RESPONSE_VERB), and
640/// [`behavior_rule_edit_directive`](seed::ROLE_BEHAVIOR_RULE_EDIT_DIRECTIVE) roles;
641/// the when-then frames are read from the
642/// [`skill_when_then_pair`](seed::ROLE_SKILL_WHEN_THEN_PAIR) role. Each frame is a
643/// [`Slot::Circumfix`] surface whose literal before the ellipsis … (U+2026) is the
644/// head clause and whose literal after it is the link clause. A description teaches
645/// a skill when it contains a head, a link following that head, a backtick-quoted
646/// trigger between them, and a backtick-quoted reply after the link — the same
647/// byte test that once ran against a hardcoded keyword-pair table, now covering
648/// every supported language from the data.
649fn looks_like_skill_description(description: &str) -> bool {
650    let lower = description.to_lowercase();
651    if explicit_teaching_form(&lower) {
652        return true;
653    }
654    seed::lexicon()
655        .role_word_forms(seed::ROLE_SKILL_WHEN_THEN_PAIR)
656        .into_iter()
657        .filter(|form| form.slot() == Slot::Circumfix)
658        .any(|form| {
659            let head = form.before_slot();
660            let link = form.after_slot();
661            let Some(head_pos) = lower.find(head) else {
662                return false;
663            };
664            let Some(link_pos) = lower[head_pos + head.len()..].find(link) else {
665                return false;
666            };
667            let absolute_link_pos = head_pos + head.len() + link_pos;
668            let before_link = &description[head_pos..absolute_link_pos];
669            let after_link = &description[absolute_link_pos + link.len()..];
670            before_link.contains('`') && after_link.contains('`')
671        })
672}
673
674/// True when `lower` (an already-lower-cased description) is an explicit teaching
675/// instruction — a trigger lead paired with a response verb, or a standalone
676/// behaviour-rule edit directive.
677///
678/// Every surface is read from the lexicon by meaning rather than named here. A
679/// trigger lead ([`ROLE_SKILL_TEACHING_TRIGGER_LEAD`](seed::ROLE_SKILL_TEACHING_TRIGGER_LEAD))
680/// teaches a skill only when it co-occurs with a response verb
681/// ([`ROLE_SKILL_TEACHING_RESPONSE_VERB`](seed::ROLE_SKILL_TEACHING_RESPONSE_VERB));
682/// an edit directive
683/// ([`ROLE_BEHAVIOR_RULE_EDIT_DIRECTIVE`](seed::ROLE_BEHAVIOR_RULE_EDIT_DIRECTIVE))
684/// is recognised on its own. Each role is matched as a raw substring through
685/// [`Lexicon::mentions_role_raw`](seed::Lexicon::mentions_role_raw) — the surfaces
686/// are stored lower-cased and the caller has already lower-cased the description,
687/// so an inflectable stem (the Russian "ответ") still folds its endings.
688fn explicit_teaching_form(lower: &str) -> bool {
689    let lexicon = seed::lexicon();
690    (lexicon.mentions_role_raw(seed::ROLE_SKILL_TEACHING_TRIGGER_LEAD, lower)
691        && lexicon.mentions_role_raw(seed::ROLE_SKILL_TEACHING_RESPONSE_VERB, lower))
692        || lexicon.mentions_role_raw(seed::ROLE_BEHAVIOR_RULE_EDIT_DIRECTIVE, lower)
693}
694
695fn code_spans(text: &str) -> Vec<String> {
696    text.split('`')
697        .enumerate()
698        .filter_map(|(index, part)| {
699            let trimmed = part.trim();
700            if index % 2 == 1 && !trimmed.is_empty() {
701                Some(trimmed.to_owned())
702            } else {
703                None
704            }
705        })
706        .collect()
707}
708
709fn link_record(
710    record_id: &str,
711    record_type: &str,
712    subtype: &str,
713    source_id: &str,
714    fields: &[(&str, &str)],
715) -> LinkRecord {
716    let mut links = Vec::new();
717    push_doublet(&mut links, record_id, "Type");
718    push_doublet(&mut links, "Type", record_type);
719    push_doublet(&mut links, record_type, "SubType");
720    push_doublet(&mut links, "SubType", subtype);
721    push_doublet(&mut links, subtype, "Value");
722    push_doublet(&mut links, record_id, source_id);
723    push_field(
724        &mut links,
725        record_id,
726        "schema_version",
727        KNOWLEDGE_SCHEMA_VERSION,
728    );
729    for (key, value) in fields {
730        push_field(&mut links, record_id, key, value);
731    }
732    LinkRecord {
733        stable_id: record_id.to_owned(),
734        schema_version: String::from(KNOWLEDGE_SCHEMA_VERSION),
735        record_type: record_type.to_owned(),
736        source_id: source_id.to_owned(),
737        links,
738    }
739}
740
741fn push_field(links: &mut Vec<DoubletLink>, record_id: &str, key: &str, value: &str) {
742    if value.is_empty() {
743        return;
744    }
745    let field = format!("field:{key}");
746    let field_value = format!("value:{value}");
747    push_doublet(links, record_id, &field);
748    push_doublet(links, &field, &field_value);
749}
750
751fn push_doublet(links: &mut Vec<DoubletLink>, from: &str, to: &str) {
752    links.push(DoubletLink {
753        index: stable_id("doublet", &format!("{from}->{to}")),
754        from: from.to_owned(),
755        to: to.to_owned(),
756    });
757}