Skip to main content

meta_language/
translation_rules.rs

1use std::collections::BTreeMap;
2use std::error::Error;
3use std::fmt;
4use std::sync::OnceLock;
5
6use crate::{
7    FormalizationLevel, Link, LinkId, LinkMetadata, LinkNetwork, LinkQuery, LinkType,
8    LinoSerializationError, NaturalizationDirection, ParseConfiguration, QueryParseError,
9};
10
11mod renderer;
12
13const RULE_SET_TERM: &str = "translation-rule-set";
14const RULE_TERM: &str = "translation-rule";
15const MATCH_TERM: &str = "translation-rule-match";
16const REFERENCE_CAPTURE_LANGUAGE: &str = "translation-rule-reference-capture";
17const LANGUAGE_FALLBACK_LANGUAGE: &str = "translation-rule-language-fallback";
18const TEMPLATE_DEFINITION: &str = "translation-rule-template";
19const FORMAL_LEXICAL_TARGET: &str = "formal:lexical";
20const FORMAL_CONCEPT_TARGET: &str = "formal:concept";
21const FORMAL_LOGICAL_TARGET: &str = "formal:logical";
22
23/// Ordered collection of named translation rules.
24#[derive(Clone, Debug, PartialEq, Eq)]
25pub struct TranslationRuleSet {
26    name: String,
27    rules: Vec<TranslationRule>,
28    language_fallbacks: BTreeMap<String, Vec<String>>,
29}
30
31impl TranslationRuleSet {
32    /// Creates an empty named rule set.
33    #[must_use]
34    pub fn new(name: impl Into<String>) -> Self {
35        Self {
36            name: name.into(),
37            rules: Vec::new(),
38            language_fallbacks: BTreeMap::new(),
39        }
40    }
41
42    /// Human-readable rule-set name.
43    #[must_use]
44    pub fn name(&self) -> &str {
45        &self.name
46    }
47
48    /// Ordered rules in this set.
49    #[must_use]
50    pub fn rules(&self) -> &[TranslationRule] {
51        &self.rules
52    }
53
54    /// Declared target-language fallback chains.
55    #[must_use]
56    pub const fn language_fallbacks(&self) -> &BTreeMap<String, Vec<String>> {
57        &self.language_fallbacks
58    }
59
60    /// Returns a copy with one rule appended.
61    #[must_use]
62    pub fn with_rule(mut self, rule: TranslationRule) -> Self {
63        self.add_rule(rule);
64        self
65    }
66
67    /// Appends a rule to the end of the ordered rule set.
68    pub fn add_rule(&mut self, rule: TranslationRule) {
69        self.rules.push(rule);
70    }
71
72    /// Adds a fallback used when a rule has no template for `target_language`.
73    #[must_use]
74    pub fn with_language_fallback(
75        mut self,
76        target_language: impl Into<String>,
77        fallback_language: impl Into<String>,
78    ) -> Self {
79        let fallbacks = self
80            .language_fallbacks
81            .entry(target_language.into())
82            .or_default();
83        let fallback_language = fallback_language.into();
84        if !fallbacks.contains(&fallback_language) {
85            fallbacks.push(fallback_language);
86        }
87        self
88    }
89
90    /// Serializes this rule set through the existing canonical `LiNo` network format.
91    #[must_use]
92    pub fn to_lino(&self) -> String {
93        let mut network = LinkNetwork::new();
94        let root = network.insert_link(
95            [],
96            LinkMetadata::new()
97                .with_link_type(LinkType::Semantic)
98                .with_named(true)
99                .with_term(RULE_SET_TERM)
100                .with_definition(&self.name),
101        );
102
103        for (target, fallbacks) in &self.language_fallbacks {
104            for fallback in fallbacks {
105                network.insert_link(
106                    [root],
107                    LinkMetadata::new()
108                        .with_link_type(LinkType::Semantic)
109                        .with_named(true)
110                        .with_term(target)
111                        .with_language(LANGUAGE_FALLBACK_LANGUAGE)
112                        .with_definition(fallback),
113                );
114            }
115        }
116
117        for rule in &self.rules {
118            let rule_link = network.insert_link(
119                [root],
120                LinkMetadata::new()
121                    .with_link_type(LinkType::Semantic)
122                    .with_named(true)
123                    .with_term(RULE_TERM)
124                    .with_definition(rule.name()),
125            );
126            network.insert_link(
127                [rule_link],
128                LinkMetadata::new()
129                    .with_link_type(LinkType::Semantic)
130                    .with_named(true)
131                    .with_term(MATCH_TERM)
132                    .with_definition(query_to_rule_spec(&rule.query)),
133            );
134            for (capture, reference_index) in &rule.reference_captures {
135                network.insert_link(
136                    [rule_link],
137                    LinkMetadata::new()
138                        .with_link_type(LinkType::Semantic)
139                        .with_named(true)
140                        .with_term(capture)
141                        .with_language(REFERENCE_CAPTURE_LANGUAGE)
142                        .with_definition(reference_index.to_string()),
143                );
144            }
145            for (target, template) in &rule.templates {
146                network.insert_link(
147                    [rule_link],
148                    LinkMetadata::new()
149                        .with_link_type(LinkType::Semantic)
150                        .with_named(true)
151                        .with_term(template.source())
152                        .with_language(target)
153                        .with_definition(TEMPLATE_DEFINITION),
154                );
155            }
156        }
157
158        network.to_lino()
159    }
160
161    /// Loads a rule set from `LiNo` text produced by [`TranslationRuleSet::to_lino`].
162    pub fn from_lino(text: &str) -> Result<Self, TranslationRuleSetLoadError> {
163        let network = LinkNetwork::from_lino(text)?;
164        let root = network
165            .links()
166            .find(|link| {
167                link.metadata().link_type() == Some(LinkType::Semantic)
168                    && link.metadata().term() == Some(RULE_SET_TERM)
169            })
170            .ok_or_else(|| {
171                TranslationRuleSetLoadError::Structure(
172                    "missing translation-rule-set root".to_string(),
173                )
174            })?;
175        let mut rules = Vec::new();
176        let mut rule_links = network
177            .links()
178            .filter(|link| {
179                link.references().first().copied() == Some(root.id())
180                    && link.metadata().term() == Some(RULE_TERM)
181            })
182            .collect::<Vec<_>>();
183        rule_links.sort_by_key(|link| link.id());
184
185        for rule_link in rule_links {
186            rules.push(load_rule(&network, rule_link)?);
187        }
188
189        let mut language_fallbacks = BTreeMap::<String, Vec<String>>::new();
190        for link in network.links().filter(|link| {
191            link.references().first().copied() == Some(root.id())
192                && link.metadata().language() == Some(LANGUAGE_FALLBACK_LANGUAGE)
193        }) {
194            let target = link.metadata().term().ok_or_else(|| {
195                TranslationRuleSetLoadError::Structure(
196                    "language fallback is missing a target".to_string(),
197                )
198            })?;
199            let fallback = link.metadata().definition().ok_or_else(|| {
200                TranslationRuleSetLoadError::Structure(
201                    "language fallback is missing a fallback target".to_string(),
202                )
203            })?;
204            language_fallbacks
205                .entry(target.to_string())
206                .or_default()
207                .push(fallback.to_string());
208        }
209
210        Ok(Self {
211            name: root
212                .metadata()
213                .definition()
214                .unwrap_or(RULE_SET_TERM)
215                .to_string(),
216            rules,
217            language_fallbacks,
218        })
219    }
220
221    /// `LiNo` text for the built-in statehood demo rule set.
222    #[must_use]
223    pub fn statehood_demo_lino() -> &'static str {
224        static LINO: OnceLock<String> = OnceLock::new();
225        LINO.get_or_init(|| statehood_demo_rule_set().to_lino())
226    }
227
228    /// Loads the statehood demo from its `LiNo` rule-set representation.
229    #[must_use]
230    pub fn statehood_demo() -> Self {
231        Self::from_lino(Self::statehood_demo_lino())
232            .expect("statehood demo translation rule set must load")
233    }
234
235    pub(crate) fn render(
236        &self,
237        network: &LinkNetwork,
238        target_language: &str,
239        configuration: ParseConfiguration,
240    ) -> Option<String> {
241        let source = network.reconstruct_text();
242        renderer::render_roots(self, network, target_language, configuration)
243            .map(|rendered| with_source_trailing_newline(rendered, &source))
244    }
245
246    /// Renders one network link, recursively applying this rule set to captures.
247    #[must_use]
248    pub fn render_link(
249        &self,
250        network: &LinkNetwork,
251        link_id: LinkId,
252        target_language: &str,
253        configuration: ParseConfiguration,
254    ) -> String {
255        renderer::render_link(self, network, link_id, target_language, configuration)
256    }
257}
258
259/// A named translation rule with one match query and target-language templates.
260#[derive(Clone, Debug, PartialEq, Eq)]
261pub struct TranslationRule {
262    name: String,
263    query: LinkQuery,
264    reference_captures: BTreeMap<String, usize>,
265    templates: BTreeMap<String, TranslationTemplate>,
266}
267
268impl TranslationRule {
269    /// Creates a named rule that matches links selected by `query`.
270    #[must_use]
271    pub fn new(name: impl Into<String>, query: LinkQuery) -> Self {
272        Self {
273            name: name.into(),
274            query,
275            reference_captures: BTreeMap::new(),
276            templates: BTreeMap::new(),
277        }
278    }
279
280    /// Rule name.
281    #[must_use]
282    pub fn name(&self) -> &str {
283        &self.name
284    }
285
286    /// Link query used by the rule.
287    #[must_use]
288    pub const fn query(&self) -> &LinkQuery {
289        &self.query
290    }
291
292    /// Reference-index captures available to templates.
293    #[must_use]
294    pub const fn reference_captures(&self) -> &BTreeMap<String, usize> {
295        &self.reference_captures
296    }
297
298    /// Target templates keyed by language or formal target.
299    #[must_use]
300    pub const fn templates(&self) -> &BTreeMap<String, TranslationTemplate> {
301        &self.templates
302    }
303
304    /// Adds a placeholder binding that captures one reference from the matched link.
305    #[must_use]
306    pub fn with_reference_capture(
307        mut self,
308        name: impl Into<String>,
309        reference_index: usize,
310    ) -> Self {
311        self.reference_captures.insert(name.into(), reference_index);
312        self
313    }
314
315    /// Adds a template for a natural language target such as `English` or `Russian`.
316    #[must_use]
317    pub fn with_template(
318        mut self,
319        target_language: impl Into<String>,
320        template: impl Into<String>,
321    ) -> Self {
322        self.templates.insert(
323            target_language.into(),
324            TranslationTemplate::new(template.into()),
325        );
326        self
327    }
328
329    /// Adds a template for a formalization level.
330    #[must_use]
331    pub fn with_formal_template(
332        mut self,
333        level: FormalizationLevel,
334        template: impl Into<String>,
335    ) -> Self {
336        self.templates.insert(
337            formal_template_target(level).to_string(),
338            TranslationTemplate::new(template.into()),
339        );
340        self
341    }
342
343    fn template_for(
344        &self,
345        target_language: &str,
346        configuration: ParseConfiguration,
347    ) -> Option<&TranslationTemplate> {
348        let level = effective_formalization_level(configuration);
349        if level != FormalizationLevel::Natural {
350            return self.templates.get(formal_template_target(level));
351        }
352
353        self.templates.get(target_language).or_else(|| {
354            canonical_reconstruction_language(target_language)
355                .and_then(|language| self.templates.get(language))
356        })
357    }
358}
359
360/// A quasiquote-style target template with `{placeholder}` substitutions.
361#[derive(Clone, Debug, PartialEq, Eq)]
362pub struct TranslationTemplate {
363    source: String,
364}
365
366impl TranslationTemplate {
367    /// Creates a template from source text.
368    #[must_use]
369    pub fn new(source: impl Into<String>) -> Self {
370        Self {
371            source: source.into(),
372        }
373    }
374
375    /// Template source text.
376    #[must_use]
377    pub fn source(&self) -> &str {
378        &self.source
379    }
380}
381
382/// Runtime registry for selecting and replacing active translation rule sets.
383#[derive(Clone, Debug, Default, PartialEq, Eq)]
384pub struct TranslationRuleRegistry {
385    rule_sets: BTreeMap<String, TranslationRuleSet>,
386    active_rule_set: Option<String>,
387}
388
389impl TranslationRuleRegistry {
390    /// Creates an empty registry.
391    #[must_use]
392    pub fn new() -> Self {
393        Self::default()
394    }
395
396    /// Creates a registry containing the statehood demo rule set.
397    #[must_use]
398    pub fn with_statehood_demo() -> Self {
399        Self::new().with_rule_set(TranslationRuleSet::statehood_demo())
400    }
401
402    /// Returns a copy with a rule set registered.
403    #[must_use]
404    pub fn with_rule_set(mut self, rule_set: TranslationRuleSet) -> Self {
405        self.replace_rule_set(rule_set);
406        self
407    }
408
409    /// Inserts or replaces a rule set. The first registered set becomes active.
410    pub fn replace_rule_set(&mut self, rule_set: TranslationRuleSet) {
411        let name = rule_set.name().to_string();
412        if self.active_rule_set.is_none() {
413            self.active_rule_set = Some(name.clone());
414        }
415        self.rule_sets.insert(name, rule_set);
416    }
417
418    /// Selects the active rule set by name.
419    pub fn set_active_rule_set(&mut self, name: &str) -> bool {
420        if self.rule_sets.contains_key(name) {
421            self.active_rule_set = Some(name.to_string());
422            true
423        } else {
424            false
425        }
426    }
427
428    /// Returns the active rule set.
429    #[must_use]
430    pub fn active_rule_set(&self) -> Option<&TranslationRuleSet> {
431        self.active_rule_set
432            .as_deref()
433            .and_then(|name| self.rule_sets.get(name))
434    }
435
436    /// Looks up a rule set by name.
437    #[must_use]
438    pub fn rule_set(&self, name: &str) -> Option<&TranslationRuleSet> {
439        self.rule_sets.get(name)
440    }
441
442    /// Number of registered rule sets.
443    #[must_use]
444    pub fn len(&self) -> usize {
445        self.rule_sets.len()
446    }
447
448    /// Whether the registry has no rule sets.
449    #[must_use]
450    pub fn is_empty(&self) -> bool {
451        self.rule_sets.is_empty()
452    }
453}
454
455/// Error returned when a rule set cannot be loaded from `LiNo`.
456#[derive(Debug, Clone, PartialEq, Eq)]
457pub enum TranslationRuleSetLoadError {
458    /// The underlying network `LiNo` failed to load.
459    Lino(LinoSerializationError),
460    /// The loaded network does not match the translation rule schema.
461    Structure(String),
462    /// A persisted `LinkQuery` failed to parse.
463    Query(QueryParseError),
464}
465
466impl fmt::Display for TranslationRuleSetLoadError {
467    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
468        match self {
469            Self::Lino(error) => write!(formatter, "{error}"),
470            Self::Structure(message) => {
471                write!(formatter, "translation rule structure error: {message}")
472            }
473            Self::Query(error) => write!(formatter, "translation rule query error: {error}"),
474        }
475    }
476}
477
478impl Error for TranslationRuleSetLoadError {}
479
480impl From<LinoSerializationError> for TranslationRuleSetLoadError {
481    fn from(error: LinoSerializationError) -> Self {
482        Self::Lino(error)
483    }
484}
485
486impl From<QueryParseError> for TranslationRuleSetLoadError {
487    fn from(error: QueryParseError) -> Self {
488        Self::Query(error)
489    }
490}
491
492fn load_rule(
493    network: &LinkNetwork,
494    rule_link: &Link,
495) -> Result<TranslationRule, TranslationRuleSetLoadError> {
496    let name = rule_link.metadata().definition().ok_or_else(|| {
497        TranslationRuleSetLoadError::Structure("rule is missing a name".to_string())
498    })?;
499    let query_source = network
500        .links()
501        .find(|link| {
502            link.references().first().copied() == Some(rule_link.id())
503                && link.metadata().term() == Some(MATCH_TERM)
504        })
505        .and_then(|link| link.metadata().definition())
506        .ok_or_else(|| {
507            TranslationRuleSetLoadError::Structure("rule is missing a match query".to_string())
508        })?;
509    let mut rule = TranslationRule::new(name, query_from_rule_spec(query_source)?);
510    let mut children = network
511        .links()
512        .filter(|link| link.references().first().copied() == Some(rule_link.id()))
513        .collect::<Vec<_>>();
514    children.sort_by_key(|link| link.id());
515
516    for child in children {
517        let metadata = child.metadata();
518        if metadata.term() == Some(MATCH_TERM) {
519            continue;
520        }
521        if metadata.language() == Some(REFERENCE_CAPTURE_LANGUAGE) {
522            let capture = metadata.term().ok_or_else(|| {
523                TranslationRuleSetLoadError::Structure(
524                    "reference capture is missing a capture name".to_string(),
525                )
526            })?;
527            let index = metadata
528                .definition()
529                .ok_or_else(|| {
530                    TranslationRuleSetLoadError::Structure(
531                        "reference capture is missing an index".to_string(),
532                    )
533                })?
534                .parse::<usize>()
535                .map_err(|error| {
536                    TranslationRuleSetLoadError::Structure(format!(
537                        "invalid reference capture index: {error}"
538                    ))
539                })?;
540            rule = rule.with_reference_capture(capture, index);
541        } else if metadata.definition() == Some(TEMPLATE_DEFINITION) {
542            let target = metadata.language().ok_or_else(|| {
543                TranslationRuleSetLoadError::Structure("template is missing a target".to_string())
544            })?;
545            let template = metadata.term().ok_or_else(|| {
546                TranslationRuleSetLoadError::Structure(
547                    "template is missing source text".to_string(),
548                )
549            })?;
550            rule = rule.with_template(target, template);
551        }
552    }
553
554    Ok(rule)
555}
556
557fn query_to_rule_spec(query: &LinkQuery) -> String {
558    let mut object = serde_json::Map::new();
559    if let Some(link_type) = query.link_type_filter() {
560        object.insert("link_type".to_string(), link_type.to_string().into());
561    }
562    if let Some(term) = query.term_filter() {
563        object.insert("term".to_string(), term.into());
564    }
565    if let Some(language) = query.language_filter() {
566        object.insert("language".to_string(), language.into());
567    }
568    if let Some(named) = query.named_filter() {
569        object.insert("named".to_string(), named.into());
570    }
571    if let Some(pattern_source) = query.pattern_source() {
572        object.insert("sexpression".to_string(), pattern_source.into());
573    }
574
575    serde_json::Value::Object(object).to_string()
576}
577
578fn query_from_rule_spec(source: &str) -> Result<LinkQuery, QueryParseError> {
579    let value = serde_json::from_str::<serde_json::Value>(source)
580        .map_err(|error| QueryParseError::new(format!("invalid query spec: {error}")))?;
581    let object = value
582        .as_object()
583        .ok_or_else(|| QueryParseError::new("query spec must be a JSON object"))?;
584
585    let mut query =
586        if let Some(sexpression) = object.get("sexpression").and_then(|value| value.as_str()) {
587            LinkQuery::from_sexpression(sexpression)?
588        } else {
589            LinkQuery::new()
590        };
591
592    if let Some(link_type) = object.get("link_type").and_then(|value| value.as_str()) {
593        query = query.with_link_type(parse_query_link_type(link_type)?);
594    }
595    if let Some(term) = object.get("term").and_then(|value| value.as_str()) {
596        query = query.with_term(term);
597    }
598    if let Some(language) = object.get("language").and_then(|value| value.as_str()) {
599        query = query.with_language(language);
600    }
601    if let Some(named) = object.get("named").and_then(serde_json::Value::as_bool) {
602        query = query.with_named(named);
603    }
604
605    Ok(query)
606}
607
608fn parse_query_link_type(token: &str) -> Result<LinkType, QueryParseError> {
609    Ok(match token {
610        "link" => LinkType::Link,
611        "reference" => LinkType::Reference,
612        "relation" => LinkType::Relation,
613        "language" => LinkType::Language,
614        "grammar" => LinkType::Grammar,
615        "type" => LinkType::Type,
616        "concept" => LinkType::Concept,
617        "syntax" => LinkType::Syntax,
618        "field" => LinkType::Field,
619        "trivia" => LinkType::Trivia,
620        "token" => LinkType::Token,
621        "document" => LinkType::Document,
622        "semantic" => LinkType::Semantic,
623        "region" => LinkType::Region,
624        "object" => LinkType::Object,
625        other => {
626            return Err(QueryParseError::new(format!(
627                "unknown query link type `{other}`"
628            )))
629        }
630    })
631}
632
633fn statehood_demo_rule_set() -> TranslationRuleSet {
634    TranslationRuleSet::new("statehood-demo").with_rule(
635        TranslationRule::new(
636            "statehood proposition",
637            LinkQuery::by_type(LinkType::Semantic).with_term("proposition:statehood"),
638        )
639        .with_reference_capture("subject", 2)
640        .with_reference_capture("object", 3)
641        .with_template("English", "{subject} is a {object}.")
642        .with_template("en", "{subject} is a {object}.")
643        .with_template("Russian", "{subject} это {object}.")
644        .with_template("ru", "{subject} это {object}.")
645        .with_formal_template(
646            FormalizationLevel::Lexical,
647            "statehood({subject}, {object})",
648        )
649        .with_formal_template(
650            FormalizationLevel::Concept,
651            [
652                "statehood(",
653                "{subject:concept}",
654                ", ",
655                "{object:concept}",
656                ")",
657            ]
658            .concat(),
659        )
660        .with_formal_template(
661            FormalizationLevel::Logical,
662            [
663                "(proposition: statehood (subject: ",
664                "{subject:concept}",
665                ") (object: ",
666                "{object:concept}",
667                ") (truth: true))",
668            ]
669            .concat(),
670        ),
671    )
672}
673
674const fn effective_formalization_level(configuration: ParseConfiguration) -> FormalizationLevel {
675    match (
676        configuration.naturalization_direction(),
677        configuration.formalization_level(),
678    ) {
679        (NaturalizationDirection::Formalize, FormalizationLevel::Natural) => {
680            FormalizationLevel::Lexical
681        }
682        (_, level) => level,
683    }
684}
685
686const fn formal_template_target(level: FormalizationLevel) -> &'static str {
687    match level {
688        FormalizationLevel::Natural => "",
689        FormalizationLevel::Lexical => FORMAL_LEXICAL_TARGET,
690        FormalizationLevel::Concept => FORMAL_CONCEPT_TARGET,
691        FormalizationLevel::Logical => FORMAL_LOGICAL_TARGET,
692    }
693}
694
695fn canonical_reconstruction_language(language: &str) -> Option<&'static str> {
696    match language.to_ascii_lowercase().as_str() {
697        "english" | "en" => Some("English"),
698        "russian" | "ru" => Some("Russian"),
699        _ => None,
700    }
701}
702
703fn with_source_trailing_newline(mut body: String, source: &str) -> String {
704    if source.ends_with('\n') {
705        body.push('\n');
706    }
707    body
708}