Skip to main content

formal_ai/
substitution.rs

1//! Data-driven link-pattern substitution rules.
2//!
3//! This module implements the issue #301 `replace x y` primitive over
4//! doublet-shaped link patterns. Rules are stored as Links Notation, sorted by
5//! explicit order and id, attached to CRUD events, and each applied rule
6//! produces an inspectable trace link.
7
8use std::collections::{BTreeMap, BTreeSet};
9use std::error::Error;
10use std::fmt;
11use std::fmt::Write as _;
12
13use lino_objects_codec::format::parse_indented;
14
15use crate::engine::{stable_id, KNOWLEDGE_SCHEMA_VERSION};
16use crate::link_store::{DoubletLink, LinkRecord};
17use crate::links_format::push_lino_node;
18use crate::seed::parser::{parse_lino, LinoNode};
19
20const DEFAULT_MAX_APPLICATIONS: usize = 64;
21
22/// CRUD event that can trigger substitution rules.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
24pub enum CrudEvent {
25    Manual,
26    Create,
27    Read,
28    Update,
29    Delete,
30}
31
32impl CrudEvent {
33    #[must_use]
34    pub const fn as_str(self) -> &'static str {
35        match self {
36            Self::Manual => "manual",
37            Self::Create => "create",
38            Self::Read => "read",
39            Self::Update => "update",
40            Self::Delete => "delete",
41        }
42    }
43
44    fn parse(value: &str) -> Result<Self, SubstitutionRuleError> {
45        match value.trim().to_ascii_lowercase().as_str() {
46            "manual" | "apply" | "learned" => Ok(Self::Manual),
47            "create" | "created" => Ok(Self::Create),
48            "read" | "select" | "query" => Ok(Self::Read),
49            "update" | "updated" => Ok(Self::Update),
50            "delete" | "deleted" => Ok(Self::Delete),
51            other => Err(SubstitutionRuleError::InvalidEvent(other.to_owned())),
52        }
53    }
54}
55
56impl fmt::Display for CrudEvent {
57    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
58        formatter.write_str(self.as_str())
59    }
60}
61
62/// A concrete link in the substitution graph.
63#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
64pub struct SubstitutionLink {
65    pub from: String,
66    pub to: String,
67}
68
69impl SubstitutionLink {
70    #[must_use]
71    pub fn new(from: impl Into<String>, to: impl Into<String>) -> Self {
72        Self {
73            from: from.into(),
74            to: to.into(),
75        }
76    }
77
78    #[must_use]
79    pub fn pattern_text(&self) -> String {
80        format!("{} -> {}", self.from, self.to)
81    }
82}
83
84/// A parsed link pattern. Nodes can be literals (`kind:cat`), whole-node
85/// variables (`$node`), or prefix variables (`assignee:$person`).
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct LinkPattern {
88    from: PatternNode,
89    to: PatternNode,
90}
91
92impl LinkPattern {
93    pub fn parse(text: &str) -> Result<Self, SubstitutionRuleError> {
94        let Some((from, to)) = text.split_once("->") else {
95            return Err(SubstitutionRuleError::InvalidPattern(format!(
96                "expected `from -> to`, got `{text}`"
97            )));
98        };
99        let from = PatternNode::parse(from.trim())?;
100        let to = PatternNode::parse(to.trim())?;
101        Ok(Self { from, to })
102    }
103
104    #[must_use]
105    pub fn literal_pair(&self) -> Option<(&str, &str)> {
106        match (&self.from, &self.to) {
107            (PatternNode::Literal(from), PatternNode::Literal(to)) => Some((from, to)),
108            _ => None,
109        }
110    }
111
112    fn instantiate(&self, bindings: &BTreeMap<String, String>) -> Option<SubstitutionLink> {
113        Some(SubstitutionLink {
114            from: self.from.instantiate(bindings)?,
115            to: self.to.instantiate(bindings)?,
116        })
117    }
118}
119
120impl fmt::Display for LinkPattern {
121    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
122        write!(formatter, "{} -> {}", self.from, self.to)
123    }
124}
125
126#[derive(Debug, Clone, PartialEq, Eq)]
127enum PatternNode {
128    Literal(String),
129    Variable(String),
130    PrefixVariable { prefix: String, variable: String },
131}
132
133impl PatternNode {
134    fn parse(text: &str) -> Result<Self, SubstitutionRuleError> {
135        if text.is_empty() {
136            return Err(SubstitutionRuleError::InvalidPattern(String::from(
137                "pattern node is empty",
138            )));
139        }
140        if let Some(variable) = text.strip_prefix('$') {
141            validate_variable(variable, text)?;
142            return Ok(Self::Variable(variable.to_owned()));
143        }
144        if let Some((prefix, variable)) = text.split_once('$') {
145            validate_variable(variable, text)?;
146            return Ok(Self::PrefixVariable {
147                prefix: prefix.to_owned(),
148                variable: variable.to_owned(),
149            });
150        }
151        Ok(Self::Literal(text.to_owned()))
152    }
153
154    fn instantiate(&self, bindings: &BTreeMap<String, String>) -> Option<String> {
155        match self {
156            Self::Literal(value) => Some(value.clone()),
157            Self::Variable(variable) => bindings.get(variable).cloned(),
158            Self::PrefixVariable { prefix, variable } => bindings
159                .get(variable)
160                .map(|value| format!("{prefix}{value}")),
161        }
162    }
163}
164
165impl fmt::Display for PatternNode {
166    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
167        match self {
168            Self::Literal(value) => formatter.write_str(value),
169            Self::Variable(variable) => write!(formatter, "${variable}"),
170            Self::PrefixVariable { prefix, variable } => write!(formatter, "{prefix}${variable}"),
171        }
172    }
173}
174
175/// One `replace x y` operation inside a substitution rule.
176#[derive(Debug, Clone, PartialEq, Eq)]
177pub struct SubstitutionAction {
178    pub remove: LinkPattern,
179    pub add: Vec<LinkPattern>,
180}
181
182/// A data-defined substitution rule.
183#[derive(Debug, Clone, PartialEq, Eq)]
184pub struct SubstitutionRule {
185    pub id: String,
186    pub order: i64,
187    pub events: Vec<CrudEvent>,
188    pub conditions: Vec<LinkPattern>,
189    pub actions: Vec<SubstitutionAction>,
190}
191
192impl SubstitutionRule {
193    #[must_use]
194    pub fn matches_event(&self, event: CrudEvent) -> bool {
195        self.events.contains(&event)
196    }
197}
198
199/// Ordered collection of substitution rules imported from `.lino` data.
200#[derive(Debug, Clone, PartialEq, Eq)]
201pub struct SubstitutionRuleSet {
202    pub id: String,
203    pub rules: Vec<SubstitutionRule>,
204}
205
206impl SubstitutionRuleSet {
207    pub fn from_links_notation(text: &str) -> Result<Self, SubstitutionRuleError> {
208        let trimmed = text.trim();
209        if trimmed.is_empty() {
210            return Err(SubstitutionRuleError::EmptyDocument);
211        }
212        parse_indented(trimmed)
213            .map_err(|error| SubstitutionRuleError::IllFormedLinksNotation(format!("{error:?}")))?;
214        let tree = parse_lino(trimmed);
215        let root = tree
216            .children
217            .first()
218            .ok_or(SubstitutionRuleError::EmptyDocument)?;
219        if root.name != "substitution_rules" {
220            return Err(SubstitutionRuleError::NotSubstitutionRules(
221                root.name.clone(),
222            ));
223        }
224        let mut rules = root
225            .children
226            .iter()
227            .filter(|child| child.name == "rule")
228            .map(parse_rule)
229            .collect::<Result<Vec<_>, _>>()?;
230        rules.sort_by(|left, right| left.order.cmp(&right.order).then(left.id.cmp(&right.id)));
231        let child_id = root.find_child_value("id");
232        let id = if child_id.is_empty() {
233            root.id.clone()
234        } else {
235            child_id.to_owned()
236        };
237        Ok(Self { id, rules })
238    }
239
240    #[must_use]
241    pub fn links_notation(&self) -> String {
242        let mut out = String::new();
243        push_lino_node(&mut out, 0, "substitution_rules", None);
244        push_lino_node(&mut out, 2, "id", Some(&self.id));
245        for rule in &self.rules {
246            push_lino_node(&mut out, 2, "rule", Some(&rule.id));
247            push_lino_node(&mut out, 4, "order", Some(&rule.order.to_string()));
248            for event in &rule.events {
249                push_lino_node(&mut out, 4, "event", Some(event.as_str()));
250            }
251            for condition in &rule.conditions {
252                push_lino_node(&mut out, 4, "when", Some(&condition.to_string()));
253            }
254            for action in &rule.actions {
255                push_lino_node(&mut out, 4, "replace", Some(&action.remove.to_string()));
256                for add in &action.add {
257                    push_lino_node(&mut out, 6, "with", Some(&add.to_string()));
258                }
259            }
260        }
261        out.trim_end().to_owned()
262    }
263}
264
265/// In-memory links network that applies substitution rules over concrete links.
266#[derive(Debug, Default, Clone, PartialEq, Eq)]
267pub struct SubstitutionGraph {
268    links: BTreeSet<SubstitutionLink>,
269}
270
271impl SubstitutionGraph {
272    #[must_use]
273    pub const fn new() -> Self {
274        Self {
275            links: BTreeSet::new(),
276        }
277    }
278
279    #[must_use]
280    pub fn with_link(mut self, from: &str, to: &str) -> Self {
281        self.insert_link(from, to);
282        self
283    }
284
285    pub fn insert_link(&mut self, from: &str, to: &str) -> bool {
286        self.links.insert(SubstitutionLink::new(from, to))
287    }
288
289    pub fn remove_link(&mut self, from: &str, to: &str) -> bool {
290        self.links.remove(&SubstitutionLink::new(from, to))
291    }
292
293    #[must_use]
294    pub fn contains_link(&self, from: &str, to: &str) -> bool {
295        self.links.contains(&SubstitutionLink::new(from, to))
296    }
297
298    #[must_use]
299    pub fn links(&self) -> Vec<SubstitutionLink> {
300        self.links.iter().cloned().collect()
301    }
302
303    #[must_use]
304    pub fn links_notation(&self) -> String {
305        let mut out = String::new();
306        push_lino_node(&mut out, 0, "substitution_graph", None);
307        push_lino_node(
308            &mut out,
309            2,
310            "id",
311            Some(&stable_id("substitution_graph", &self.canonical_links())),
312        );
313        for link in &self.links {
314            push_lino_node(&mut out, 2, "link", Some(&link.pattern_text()));
315        }
316        out.trim_end().to_owned()
317    }
318
319    #[must_use]
320    pub fn apply_rules(
321        &mut self,
322        rules: &SubstitutionRuleSet,
323        event: CrudEvent,
324    ) -> SubstitutionTraceReport {
325        self.apply_rules_with_limit(rules, event, DEFAULT_MAX_APPLICATIONS)
326    }
327
328    #[must_use]
329    pub fn apply_rules_with_limit(
330        &mut self,
331        rules: &SubstitutionRuleSet,
332        event: CrudEvent,
333        max_applications: usize,
334    ) -> SubstitutionTraceReport {
335        let mut report = SubstitutionTraceReport::new(event);
336        while report.traces.len() < max_applications {
337            let Some(trace) = self.apply_first_rule(rules, event, report.traces.len()) else {
338                return report;
339            };
340            report.traces.push(trace);
341        }
342        let mut probe = self.clone();
343        report.terminated_by_guard = probe
344            .apply_first_rule(rules, event, report.traces.len())
345            .is_some();
346        report
347    }
348
349    #[must_use]
350    pub fn create_link(
351        &mut self,
352        from: &str,
353        to: &str,
354        rules: &SubstitutionRuleSet,
355    ) -> SubstitutionTraceReport {
356        self.insert_link(from, to);
357        self.apply_rules(rules, CrudEvent::Create)
358    }
359
360    #[must_use]
361    pub fn read_link(
362        &mut self,
363        from: &str,
364        to: &str,
365        rules: &SubstitutionRuleSet,
366    ) -> (bool, SubstitutionTraceReport) {
367        let exists = self.contains_link(from, to);
368        let report = self.apply_rules(rules, CrudEvent::Read);
369        (exists, report)
370    }
371
372    #[must_use]
373    pub fn update_link(
374        &mut self,
375        old_from: &str,
376        old_to: &str,
377        new_from: &str,
378        new_to: &str,
379        rules: &SubstitutionRuleSet,
380    ) -> SubstitutionTraceReport {
381        self.remove_link(old_from, old_to);
382        self.insert_link(new_from, new_to);
383        self.apply_rules(rules, CrudEvent::Update)
384    }
385
386    #[must_use]
387    pub fn delete_link(
388        &mut self,
389        from: &str,
390        to: &str,
391        rules: &SubstitutionRuleSet,
392    ) -> SubstitutionTraceReport {
393        self.remove_link(from, to);
394        self.apply_rules(rules, CrudEvent::Delete)
395    }
396
397    fn apply_first_rule(
398        &mut self,
399        rules: &SubstitutionRuleSet,
400        event: CrudEvent,
401        sequence: usize,
402    ) -> Option<SubstitutionTrace> {
403        rules
404            .rules
405            .iter()
406            .filter(|rule| rule.matches_event(event))
407            .find_map(|rule| self.apply_rule(rule, event, sequence))
408    }
409
410    fn apply_rule(
411        &mut self,
412        rule: &SubstitutionRule,
413        event: CrudEvent,
414        sequence: usize,
415    ) -> Option<SubstitutionTrace> {
416        let mut required_patterns = rule.conditions.clone();
417        for action in &rule.actions {
418            required_patterns.push(action.remove.clone());
419        }
420        let bindings = self.find_bindings(&required_patterns)?;
421        let before = self.links.clone();
422        let mut removed = Vec::new();
423        let mut added = Vec::new();
424        for action in &rule.actions {
425            let remove = action.remove.instantiate(&bindings)?;
426            if self.links.remove(&remove) {
427                removed.push(remove);
428            }
429            for add in &action.add {
430                let link = add.instantiate(&bindings)?;
431                if self.links.insert(link.clone()) {
432                    added.push(link);
433                }
434            }
435        }
436        if self.links == before {
437            return None;
438        }
439        Some(SubstitutionTrace::new(
440            sequence, &rule.id, event, bindings, removed, added,
441        ))
442    }
443
444    fn find_bindings(&self, patterns: &[LinkPattern]) -> Option<BTreeMap<String, String>> {
445        self.find_bindings_from(patterns, BTreeMap::new())
446    }
447
448    fn find_bindings_from(
449        &self,
450        patterns: &[LinkPattern],
451        bindings: BTreeMap<String, String>,
452    ) -> Option<BTreeMap<String, String>> {
453        let Some((pattern, remaining)) = patterns.split_first() else {
454            return Some(bindings);
455        };
456        for link in &self.links {
457            let mut candidate = bindings.clone();
458            if pattern_matches_link(pattern, link, &mut candidate) {
459                if let Some(found) = self.find_bindings_from(remaining, candidate) {
460                    return Some(found);
461                }
462            }
463        }
464        None
465    }
466
467    fn canonical_links(&self) -> String {
468        let mut out = String::new();
469        for link in &self.links {
470            let _ = write!(out, "{}->{};", link.from, link.to);
471        }
472        out
473    }
474}
475
476/// One recorded substitution application.
477#[derive(Debug, Clone, PartialEq, Eq)]
478pub struct SubstitutionTrace {
479    pub id: String,
480    pub sequence: usize,
481    pub rule_id: String,
482    pub event: CrudEvent,
483    pub bindings: BTreeMap<String, String>,
484    pub removed: Vec<SubstitutionLink>,
485    pub added: Vec<SubstitutionLink>,
486}
487
488impl SubstitutionTrace {
489    fn new(
490        sequence: usize,
491        rule_id: &str,
492        event: CrudEvent,
493        bindings: BTreeMap<String, String>,
494        removed: Vec<SubstitutionLink>,
495        added: Vec<SubstitutionLink>,
496    ) -> Self {
497        let id = stable_id(
498            "substitution_trace",
499            &canonical_trace(sequence, rule_id, event, &bindings, &removed, &added),
500        );
501        Self {
502            id,
503            sequence,
504            rule_id: rule_id.to_owned(),
505            event,
506            bindings,
507            removed,
508            added,
509        }
510    }
511}
512
513/// Trace report for one substitution rule evaluation.
514#[derive(Debug, Clone, PartialEq, Eq)]
515pub struct SubstitutionTraceReport {
516    pub event: CrudEvent,
517    pub traces: Vec<SubstitutionTrace>,
518    pub terminated_by_guard: bool,
519}
520
521impl SubstitutionTraceReport {
522    #[must_use]
523    pub const fn new(event: CrudEvent) -> Self {
524        Self {
525            event,
526            traces: Vec::new(),
527            terminated_by_guard: false,
528        }
529    }
530
531    #[must_use]
532    pub const fn applied_count(&self) -> usize {
533        self.traces.len()
534    }
535
536    #[must_use]
537    pub fn links_notation(&self) -> String {
538        let mut out = String::new();
539        push_lino_node(&mut out, 0, "substitution_trace_report", None);
540        push_lino_node(
541            &mut out,
542            2,
543            "id",
544            Some(&stable_id("substitution_trace_report", &self.canonical())),
545        );
546        push_lino_node(&mut out, 2, "event", Some(self.event.as_str()));
547        push_lino_node(
548            &mut out,
549            2,
550            "terminated_by_guard",
551            Some(if self.terminated_by_guard {
552                "true"
553            } else {
554                "false"
555            }),
556        );
557        for trace in &self.traces {
558            push_lino_node(&mut out, 2, "trace", Some(&trace.id));
559            push_lino_node(&mut out, 4, "sequence", Some(&trace.sequence.to_string()));
560            push_lino_node(&mut out, 4, "rule_id", Some(&trace.rule_id));
561            push_lino_node(&mut out, 4, "event", Some(trace.event.as_str()));
562            for (name, value) in &trace.bindings {
563                push_lino_node(&mut out, 4, "binding", Some(&format!("{name}={value}")));
564            }
565            for link in &trace.removed {
566                push_lino_node(&mut out, 4, "removed", Some(&link.pattern_text()));
567            }
568            for link in &trace.added {
569                push_lino_node(&mut out, 4, "added", Some(&link.pattern_text()));
570            }
571        }
572        out.trim_end().to_owned()
573    }
574
575    #[must_use]
576    pub fn trace_link_records(&self) -> Vec<LinkRecord> {
577        self.traces.iter().map(trace_link_record).collect()
578    }
579
580    fn canonical(&self) -> String {
581        let mut out = format!(
582            "event={};terminated={};",
583            self.event.as_str(),
584            self.terminated_by_guard
585        );
586        for trace in &self.traces {
587            out.push_str(&trace.id);
588            out.push(';');
589        }
590        out
591    }
592}
593
594/// Errors returned while importing substitution rule data.
595#[derive(Debug, Clone, PartialEq, Eq)]
596pub enum SubstitutionRuleError {
597    EmptyDocument,
598    IllFormedLinksNotation(String),
599    NotSubstitutionRules(String),
600    MissingReplacement(String),
601    InvalidPattern(String),
602    InvalidEvent(String),
603}
604
605impl fmt::Display for SubstitutionRuleError {
606    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
607        match self {
608            Self::EmptyDocument => formatter.write_str("substitution rule document is empty"),
609            Self::IllFormedLinksNotation(message) => {
610                write!(
611                    formatter,
612                    "ill-formed substitution rule Links Notation: {message}"
613                )
614            }
615            Self::NotSubstitutionRules(root) => {
616                write!(formatter, "{root} is not a substitution_rules document")
617            }
618            Self::MissingReplacement(rule_id) => {
619                write!(formatter, "rule {rule_id} has replace without with")
620            }
621            Self::InvalidPattern(message) => write!(formatter, "invalid link pattern: {message}"),
622            Self::InvalidEvent(event) => write!(formatter, "invalid CRUD event: {event}"),
623        }
624    }
625}
626
627impl Error for SubstitutionRuleError {}
628
629fn parse_rule(node: &LinoNode) -> Result<SubstitutionRule, SubstitutionRuleError> {
630    let mut order = 0;
631    let mut events = Vec::new();
632    let mut conditions = Vec::new();
633    let mut actions = Vec::new();
634
635    for child in &node.children {
636        match child.name.as_str() {
637            "order" => {
638                order = child.id.trim().parse::<i64>().unwrap_or(0);
639            }
640            "event" => {
641                events.push(CrudEvent::parse(&child.id)?);
642            }
643            "when" => {
644                conditions.push(LinkPattern::parse(&child.id)?);
645            }
646            "replace" => {
647                let add = child
648                    .children
649                    .iter()
650                    .filter(|grandchild| grandchild.name == "with")
651                    .map(|grandchild| LinkPattern::parse(&grandchild.id))
652                    .collect::<Result<Vec<_>, _>>()?;
653                if add.is_empty() {
654                    return Err(SubstitutionRuleError::MissingReplacement(node.id.clone()));
655                }
656                actions.push(SubstitutionAction {
657                    remove: LinkPattern::parse(&child.id)?,
658                    add,
659                });
660            }
661            _ => {}
662        }
663    }
664    if events.is_empty() {
665        events.push(CrudEvent::Manual);
666    }
667    Ok(SubstitutionRule {
668        id: node.id.clone(),
669        order,
670        events,
671        conditions,
672        actions,
673    })
674}
675
676fn validate_variable(variable: &str, raw: &str) -> Result<(), SubstitutionRuleError> {
677    if variable.is_empty()
678        || !variable
679            .chars()
680            .all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
681    {
682        return Err(SubstitutionRuleError::InvalidPattern(format!(
683            "invalid variable in `{raw}`"
684        )));
685    }
686    Ok(())
687}
688
689fn pattern_matches_link(
690    pattern: &LinkPattern,
691    link: &SubstitutionLink,
692    bindings: &mut BTreeMap<String, String>,
693) -> bool {
694    node_matches(&pattern.from, &link.from, bindings)
695        && node_matches(&pattern.to, &link.to, bindings)
696}
697
698fn node_matches(
699    pattern: &PatternNode,
700    value: &str,
701    bindings: &mut BTreeMap<String, String>,
702) -> bool {
703    match pattern {
704        PatternNode::Literal(literal) => literal == value,
705        PatternNode::Variable(variable) => bind_variable(bindings, variable, value),
706        PatternNode::PrefixVariable { prefix, variable } => {
707            let Some(captured) = value.strip_prefix(prefix) else {
708                return false;
709            };
710            bind_variable(bindings, variable, captured)
711        }
712    }
713}
714
715fn bind_variable(bindings: &mut BTreeMap<String, String>, variable: &str, value: &str) -> bool {
716    if let Some(existing) = bindings.get(variable) {
717        return existing == value;
718    }
719    bindings.insert(variable.to_owned(), value.to_owned());
720    true
721}
722
723fn canonical_trace(
724    sequence: usize,
725    rule_id: &str,
726    event: CrudEvent,
727    bindings: &BTreeMap<String, String>,
728    removed: &[SubstitutionLink],
729    added: &[SubstitutionLink],
730) -> String {
731    let mut out = format!("{sequence}:{}:{rule_id};", event.as_str());
732    for (key, value) in bindings {
733        let _ = write!(out, "binding:{key}={value};");
734    }
735    for link in removed {
736        let _ = write!(out, "removed:{};", link.pattern_text());
737    }
738    for link in added {
739        let _ = write!(out, "added:{};", link.pattern_text());
740    }
741    out
742}
743
744fn trace_link_record(trace: &SubstitutionTrace) -> LinkRecord {
745    let mut links = Vec::new();
746    push_doublet(&mut links, &trace.id, "Type");
747    push_doublet(&mut links, "Type", "SubstitutionTraceLink");
748    push_doublet(&mut links, "SubstitutionTraceLink", "SubType");
749    push_doublet(&mut links, "SubType", trace.event.as_str());
750    push_doublet(&mut links, trace.event.as_str(), "Value");
751    push_doublet(&mut links, &trace.id, &trace.rule_id);
752    push_doublet(
753        &mut links,
754        &trace.id,
755        &format!("schema_version:{KNOWLEDGE_SCHEMA_VERSION}"),
756    );
757    push_field(&mut links, &trace.id, "rule_id", &trace.rule_id);
758    push_field(&mut links, &trace.id, "event", trace.event.as_str());
759    push_field(
760        &mut links,
761        &trace.id,
762        "sequence",
763        &trace.sequence.to_string(),
764    );
765    for (name, value) in &trace.bindings {
766        push_field(&mut links, &trace.id, "binding", &format!("{name}={value}"));
767    }
768    for link in &trace.removed {
769        push_field(&mut links, &trace.id, "removed", &link.pattern_text());
770    }
771    for link in &trace.added {
772        push_field(&mut links, &trace.id, "added", &link.pattern_text());
773    }
774    LinkRecord {
775        stable_id: trace.id.clone(),
776        schema_version: String::from(KNOWLEDGE_SCHEMA_VERSION),
777        record_type: String::from("SubstitutionTraceLink"),
778        source_id: trace.rule_id.clone(),
779        links,
780    }
781}
782
783fn push_field(links: &mut Vec<DoubletLink>, record_id: &str, key: &str, value: &str) {
784    let field = format!("field:{key}");
785    let field_value = format!("value:{value}");
786    push_doublet(links, record_id, &field);
787    push_doublet(links, &field, &field_value);
788}
789
790fn push_doublet(links: &mut Vec<DoubletLink>, from: &str, to: &str) {
791    links.push(DoubletLink {
792        index: stable_id("doublet", &format!("{from}->{to}")),
793        from: from.to_owned(),
794        to: to.to_owned(),
795    });
796}