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