Skip to main content

rustev_backend_rules/
program.rs

1//! The rules program, `rustev.rules/1` (spec 005, 3.2 and 3.3): the
2//! document a host supplies, its validation and the checked form the
3//! evaluator runs.
4
5use std::collections::{BTreeMap, BTreeSet};
6use std::fmt;
7
8use rustev_contract::canonical::{CanonicalError, tagged_digest};
9use rustev_contract::decimal::Decimal;
10use rustev_contract::definition::Operation;
11use rustev_contract::ids::ArtifactId;
12use rustev_contract::limits::DESCRIPTOR_V1;
13use rustev_contract::{Document, DocumentError};
14use serde::{Deserialize, Serialize};
15
16/// The schema string of a rules program.
17pub const SCHEMA: &str = "rustev.rules/1";
18
19/// The limits of schema version 1 (spec 005, 3.3).
20pub mod limits {
21    pub const BACKEND_ID_BYTES: usize = 64;
22    pub const MAX_PROJECTION_BYTES: u64 = 65_536;
23    pub const TASKS: usize = 32;
24    pub const TASK_BYTES: usize = 256;
25    pub const QUESTION_BYTES: usize = 4_096;
26    pub const OPTIONS_MIN: usize = 2;
27    pub const OPTIONS: usize = 64;
28    pub const OPTION_BYTES: usize = 256;
29    pub const INTERPRETATION_BYTES: usize = 1_024;
30    pub const FIELDS: usize = 16;
31    pub const NAME_BYTES: usize = 64;
32    pub const RULES: usize = 128;
33    pub const CONDITION_NODES: usize = 32;
34    pub const CONDITION_DEPTH: usize = 4;
35    pub const CONDITION_MEMBERS: usize = 16;
36    pub const TERMS: usize = 32;
37    pub const TERM_BYTES: usize = 64;
38    pub const TERMS_PER_TASK: usize = 1_024;
39    pub const CONTRIBUTIONS: usize = 8;
40    pub const LOOKUP_ENTRIES: usize = 256;
41    pub const LOOKUP_KEY_BYTES: usize = 256;
42    pub const LOOKUP_ENTRIES_PER_TASK: usize = 1_024;
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(deny_unknown_fields)]
47pub struct RulesProgram {
48    pub schema: String,
49    pub backend_id: String,
50    pub limits: ProgramLimits,
51    pub cost: ProgramCost,
52    pub tasks: Vec<Task>,
53}
54
55impl Document for RulesProgram {
56    const SCHEMA: &'static str = SCHEMA;
57    const LIMITS: rustev_contract::bounded::ParseLimits = DESCRIPTOR_V1;
58    fn schema(&self) -> &str {
59        &self.schema
60    }
61}
62
63impl RulesProgram {
64    /// The program's identity, which is the artifact identity of the backend
65    /// it configures: the contract's tagged digest of its canonical bytes
66    /// (spec 005, 3.10).
67    pub fn artifact_id(&self) -> Result<ArtifactId, CanonicalError> {
68        let digest = tagged_digest(SCHEMA, &self.canonical()?);
69        ArtifactId::parse(&digest).map_err(|e| CanonicalError::Serialize {
70            message: e.to_string(),
71        })
72    }
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(deny_unknown_fields)]
77pub struct ProgramLimits {
78    /// Largest projection accepted, in bytes; larger ones are refused.
79    pub max_projection_bytes: u64,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(deny_unknown_fields)]
84pub struct ProgramCost {
85    /// Metered logical units charged per call (spec 005, 3.12). Not money.
86    pub units_per_call: u64,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(deny_unknown_fields)]
91pub struct Task {
92    pub operation: Operation,
93    pub task: String,
94    pub question: String,
95    pub options: Vec<String>,
96    /// What the logits mean, authored (spec 005, 3.8).
97    pub interpretation: String,
98    pub fields: Vec<Field>,
99    pub base: BTreeMap<String, Decimal>,
100    pub rules: Vec<Rule>,
101    pub on_no_match: NoMatch,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
105#[serde(deny_unknown_fields)]
106pub struct Field {
107    pub name: String,
108    #[serde(rename = "ref")]
109    pub reference: String,
110    #[serde(rename = "type")]
111    pub ty: FieldType,
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
115#[serde(rename_all = "snake_case")]
116pub enum FieldType {
117    Text,
118    Bool,
119    Integer,
120    Decimal,
121}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
124#[serde(rename_all = "snake_case")]
125pub enum NoMatch {
126    Base,
127    Fail,
128}
129
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131#[serde(deny_unknown_fields)]
132pub struct Rule {
133    pub name: String,
134    pub when: Condition,
135    pub then: Vec<Contribution>,
136    pub stop: bool,
137}
138
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140#[serde(rename_all = "snake_case", deny_unknown_fields)]
141pub enum Condition {
142    Always,
143    ContainsAny {
144        field: String,
145        terms: Vec<String>,
146    },
147    Equals {
148        field: String,
149        value: String,
150    },
151    Compare {
152        field: String,
153        op: CompareOp,
154        value: Decimal,
155    },
156    All(Vec<Condition>),
157    Any(Vec<Condition>),
158    Not(Box<Condition>),
159}
160
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
162#[serde(rename_all = "snake_case")]
163pub enum CompareOp {
164    Lt,
165    Le,
166    Ge,
167    Gt,
168}
169
170#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171#[serde(rename_all = "snake_case", deny_unknown_fields)]
172pub enum Contribution {
173    Add(BTreeMap<String, Decimal>),
174    Linear {
175        field: String,
176        coefficients: BTreeMap<String, Decimal>,
177    },
178    Lookup {
179        field: String,
180        entries: Vec<LookupEntry>,
181        on_missing: OnMissing,
182    },
183}
184
185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
186#[serde(deny_unknown_fields)]
187pub struct LookupEntry {
188    pub key: String,
189    pub add: BTreeMap<String, Decimal>,
190}
191
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
193#[serde(rename_all = "snake_case")]
194pub enum OnMissing {
195    Skip,
196    Fail,
197}
198
199/// Why a program was refused (spec 005, 3.3). `at` locates the item, for
200/// example `tasks[0].rules[3].then[1]`.
201#[derive(Debug, Clone, PartialEq, Eq)]
202pub struct ProgramError {
203    pub at: String,
204    pub kind: ProgramErrorKind,
205}
206
207#[derive(Debug, Clone, PartialEq, Eq)]
208pub enum ProgramErrorKind {
209    /// The bytes are not a `rustev.rules/1` document.
210    Document(DocumentError),
211    /// The program has no canonical form.
212    Canonical(CanonicalError),
213    /// A count or length outside its bound.
214    Bound {
215        what: &'static str,
216        min: usize,
217        max: usize,
218        found: usize,
219    },
220    BackendId,
221    /// `rank`, which this backend does not answer.
222    UnsupportedOperation,
223    /// A proposition's options are not `["false", "true"]`.
224    PropositionOptions,
225    Duplicate {
226        what: &'static str,
227        name: String,
228    },
229    UnknownOption(String),
230    UnknownField(String),
231    /// A field `ref` is not `input:<name>` or `bind:<name>`, with at most one
232    /// `/<member>`.
233    Reference(String),
234    /// A field of a type the condition or contribution does not accept.
235    FieldType {
236        field: String,
237        found: FieldType,
238    },
239    /// An `equals` value that is not the canonical text of its field type.
240    EqualsValue(String),
241    /// Blank interpretation.
242    Interpretation,
243}
244
245impl fmt::Display for ProgramError {
246    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
247        write!(f, "{}: {:?}", self.at, self.kind)
248    }
249}
250
251impl std::error::Error for ProgramError {}
252
253fn err(at: impl Into<String>, kind: ProgramErrorKind) -> ProgramError {
254    ProgramError {
255        at: at.into(),
256        kind,
257    }
258}
259
260fn bound(
261    at: &str,
262    what: &'static str,
263    found: usize,
264    min: usize,
265    max: usize,
266) -> Result<(), ProgramError> {
267    if found < min || found > max {
268        return Err(err(
269            at,
270            ProgramErrorKind::Bound {
271                what,
272                min,
273                max,
274                found,
275            },
276        ));
277    }
278    Ok(())
279}
280
281// ---------------------------------------------------------------------------
282// The checked form.
283
284/// A validated task, with names resolved to indices.
285#[derive(Debug, Clone)]
286pub(crate) struct CTask {
287    pub operation: Operation,
288    pub name: String,
289    pub question: String,
290    pub options: Vec<String>,
291    pub fields: Vec<CField>,
292    pub base: Vec<Decimal>,
293    pub rules: Vec<CRule>,
294    pub on_no_match: NoMatch,
295}
296
297#[derive(Debug, Clone)]
298pub(crate) struct CField {
299    pub name: String,
300    /// The projection `values` key (`input:<name>` or `bind:<name>`).
301    pub key: String,
302    pub member: Option<String>,
303    pub ty: FieldType,
304}
305
306#[derive(Debug, Clone)]
307pub(crate) struct CRule {
308    pub name: String,
309    pub when: CCond,
310    pub then: Vec<CContrib>,
311    pub stop: bool,
312}
313
314#[derive(Debug, Clone)]
315pub(crate) enum CCond {
316    Always,
317    /// Terms are already ASCII lower-cased.
318    ContainsAny {
319        field: usize,
320        terms: Vec<String>,
321    },
322    Equals {
323        field: usize,
324        value: String,
325    },
326    Compare {
327        field: usize,
328        op: CompareOp,
329        value: Decimal,
330    },
331    All(Vec<CCond>),
332    Any(Vec<CCond>),
333    Not(Box<CCond>),
334}
335
336/// Option-indexed constants, in option-name order.
337pub(crate) type Adds = Vec<(usize, Decimal)>;
338
339#[derive(Debug, Clone)]
340pub(crate) enum CContrib {
341    Add(Adds),
342    Linear {
343        field: usize,
344        coefficients: Adds,
345    },
346    Lookup {
347        field: usize,
348        table: BTreeMap<String, Adds>,
349        on_missing: OnMissing,
350    },
351}
352
353/// Validate a program into its checked form (spec 005, 3.3).
354pub(crate) fn check(p: &RulesProgram) -> Result<Vec<CTask>, ProgramError> {
355    if p.schema != SCHEMA {
356        return Err(err(
357            "schema",
358            ProgramErrorKind::Document(DocumentError::Schema {
359                expected: SCHEMA,
360                found: p.schema.chars().take(80).collect(),
361            }),
362        ));
363    }
364    let id_ok = !p.backend_id.is_empty()
365        && p.backend_id.len() <= limits::BACKEND_ID_BYTES
366        && p.backend_id
367            .bytes()
368            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b"._-".contains(&b));
369    if !id_ok {
370        return Err(err("backend_id", ProgramErrorKind::BackendId));
371    }
372    let max = p.limits.max_projection_bytes;
373    if max == 0 || max > limits::MAX_PROJECTION_BYTES {
374        return Err(err(
375            "limits.max_projection_bytes",
376            ProgramErrorKind::Bound {
377                what: "max_projection_bytes",
378                min: 1,
379                max: limits::MAX_PROJECTION_BYTES as usize,
380                found: usize::try_from(max).unwrap_or(usize::MAX),
381            },
382        ));
383    }
384    bound("tasks", "tasks", p.tasks.len(), 1, limits::TASKS)?;
385    let mut names = BTreeSet::new();
386    let mut out = Vec::with_capacity(p.tasks.len());
387    for (i, t) in p.tasks.iter().enumerate() {
388        let at = format!("tasks[{i}]");
389        if !names.insert(t.task.as_str()) {
390            return Err(err(
391                at,
392                ProgramErrorKind::Duplicate {
393                    what: "task",
394                    name: t.task.clone(),
395                },
396            ));
397        }
398        out.push(check_task(t, &at)?);
399    }
400    Ok(out)
401}
402
403fn check_task(t: &Task, at: &str) -> Result<CTask, ProgramError> {
404    if t.operation == Operation::Rank {
405        return Err(err(
406            format!("{at}.operation"),
407            ProgramErrorKind::UnsupportedOperation,
408        ));
409    }
410    bound(at, "task bytes", t.task.len(), 1, limits::TASK_BYTES)?;
411    bound(
412        at,
413        "question bytes",
414        t.question.len(),
415        1,
416        limits::QUESTION_BYTES,
417    )?;
418    bound(
419        &format!("{at}.options"),
420        "options",
421        t.options.len(),
422        limits::OPTIONS_MIN,
423        limits::OPTIONS,
424    )?;
425    let mut options = BTreeMap::new();
426    for (k, o) in t.options.iter().enumerate() {
427        bound(
428            &format!("{at}.options[{k}]"),
429            "option bytes",
430            o.len(),
431            1,
432            limits::OPTION_BYTES,
433        )?;
434        if options.insert(o.clone(), k).is_some() {
435            return Err(err(
436                format!("{at}.options"),
437                ProgramErrorKind::Duplicate {
438                    what: "option",
439                    name: o.clone(),
440                },
441            ));
442        }
443    }
444    if t.operation == Operation::Proposition && t.options != ["false", "true"] {
445        return Err(err(
446            format!("{at}.options"),
447            ProgramErrorKind::PropositionOptions,
448        ));
449    }
450    bound(
451        &format!("{at}.interpretation"),
452        "interpretation bytes",
453        t.interpretation.len(),
454        1,
455        limits::INTERPRETATION_BYTES,
456    )?;
457    if t.interpretation.trim().is_empty() {
458        return Err(err(
459            format!("{at}.interpretation"),
460            ProgramErrorKind::Interpretation,
461        ));
462    }
463    bound(
464        &format!("{at}.fields"),
465        "fields",
466        t.fields.len(),
467        0,
468        limits::FIELDS,
469    )?;
470    let mut fields = Vec::with_capacity(t.fields.len());
471    let mut field_index = BTreeMap::new();
472    for (k, f) in t.fields.iter().enumerate() {
473        let fat = format!("{at}.fields[{k}]");
474        bound(
475            &fat,
476            "field name bytes",
477            f.name.len(),
478            1,
479            limits::NAME_BYTES,
480        )?;
481        if field_index.insert(f.name.clone(), k).is_some() {
482            return Err(err(
483                fat,
484                ProgramErrorKind::Duplicate {
485                    what: "field",
486                    name: f.name.clone(),
487                },
488            ));
489        }
490        let (key, member) = parse_ref(&f.reference)
491            .ok_or_else(|| err(&fat, ProgramErrorKind::Reference(f.reference.clone())))?;
492        fields.push(CField {
493            name: f.name.clone(),
494            key,
495            member,
496            ty: f.ty,
497        });
498    }
499    let base = {
500        let mut v = vec![Decimal::from_i64(0); t.options.len()];
501        for (k, d) in adds(&t.base, &options, &format!("{at}.base"), true)? {
502            v[k] = d;
503        }
504        v
505    };
506    bound(
507        &format!("{at}.rules"),
508        "rules",
509        t.rules.len(),
510        0,
511        limits::RULES,
512    )?;
513    let mut cx = TaskCx {
514        options: &options,
515        fields: &fields,
516        field_index: &field_index,
517        terms: 0,
518        entries: 0,
519    };
520    let mut rule_names = BTreeSet::new();
521    let mut rules = Vec::with_capacity(t.rules.len());
522    for (k, r) in t.rules.iter().enumerate() {
523        let rat = format!("{at}.rules[{k}]");
524        bound(&rat, "rule name bytes", r.name.len(), 1, limits::NAME_BYTES)?;
525        if !rule_names.insert(r.name.as_str()) {
526            return Err(err(
527                rat,
528                ProgramErrorKind::Duplicate {
529                    what: "rule",
530                    name: r.name.clone(),
531                },
532            ));
533        }
534        let mut nodes = 0;
535        let when = cx.condition(&r.when, &format!("{rat}.when"), 0, &mut nodes)?;
536        bound(
537            &format!("{rat}.when"),
538            "condition nodes",
539            nodes,
540            1,
541            limits::CONDITION_NODES,
542        )?;
543        bound(
544            &format!("{rat}.then"),
545            "contributions",
546            r.then.len(),
547            0,
548            limits::CONTRIBUTIONS,
549        )?;
550        let mut then = Vec::with_capacity(r.then.len());
551        for (c, contribution) in r.then.iter().enumerate() {
552            then.push(cx.contribution(contribution, &format!("{rat}.then[{c}]"))?);
553        }
554        rules.push(CRule {
555            name: r.name.clone(),
556            when,
557            then,
558            stop: r.stop,
559        });
560    }
561    bound(
562        at,
563        "contains_any terms per task",
564        cx.terms,
565        0,
566        limits::TERMS_PER_TASK,
567    )?;
568    bound(
569        at,
570        "lookup entries per task",
571        cx.entries,
572        0,
573        limits::LOOKUP_ENTRIES_PER_TASK,
574    )?;
575    Ok(CTask {
576        operation: t.operation,
577        name: t.task.clone(),
578        question: t.question.clone(),
579        options: t.options.clone(),
580        fields,
581        base,
582        rules,
583        on_no_match: t.on_no_match,
584    })
585}
586
587/// `input:<name>` or `bind:<name>`, optionally `/<member>`.
588fn parse_ref(r: &str) -> Option<(String, Option<String>)> {
589    let (key, member) = match r.split_once('/') {
590        Some((k, m)) => (k, Some(m)),
591        None => (r, None),
592    };
593    let name = key
594        .strip_prefix("input:")
595        .or_else(|| key.strip_prefix("bind:"))?;
596    let simple = |s: &str| !s.is_empty() && !s.contains('/') && !s.contains(':');
597    if !simple(name) || member.is_some_and(|m| !simple(m)) {
598        return None;
599    }
600    Some((key.to_string(), member.map(str::to_string)))
601}
602
603fn adds(
604    m: &BTreeMap<String, Decimal>,
605    options: &BTreeMap<String, usize>,
606    at: &str,
607    may_be_empty: bool,
608) -> Result<Adds, ProgramError> {
609    if m.is_empty() && !may_be_empty {
610        return Err(err(
611            at,
612            ProgramErrorKind::Bound {
613                what: "option constants",
614                min: 1,
615                max: limits::OPTIONS,
616                found: 0,
617            },
618        ));
619    }
620    m.iter()
621        .map(|(o, d)| match options.get(o) {
622            Some(k) => Ok((*k, *d)),
623            None => Err(err(at, ProgramErrorKind::UnknownOption(o.clone()))),
624        })
625        .collect()
626}
627
628struct TaskCx<'a> {
629    options: &'a BTreeMap<String, usize>,
630    fields: &'a [CField],
631    field_index: &'a BTreeMap<String, usize>,
632    terms: usize,
633    entries: usize,
634}
635
636impl TaskCx<'_> {
637    fn field(&self, name: &str, at: &str, accepts: &[FieldType]) -> Result<usize, ProgramError> {
638        let k = *self
639            .field_index
640            .get(name)
641            .ok_or_else(|| err(at, ProgramErrorKind::UnknownField(name.to_string())))?;
642        let found = self.fields[k].ty;
643        if !accepts.contains(&found) {
644            return Err(err(
645                at,
646                ProgramErrorKind::FieldType {
647                    field: name.to_string(),
648                    found,
649                },
650            ));
651        }
652        Ok(k)
653    }
654
655    fn condition(
656        &mut self,
657        c: &Condition,
658        at: &str,
659        depth: usize,
660        nodes: &mut usize,
661    ) -> Result<CCond, ProgramError> {
662        *nodes += 1;
663        if *nodes > limits::CONDITION_NODES {
664            return Err(err(
665                at,
666                ProgramErrorKind::Bound {
667                    what: "condition nodes",
668                    min: 1,
669                    max: limits::CONDITION_NODES,
670                    found: *nodes,
671                },
672            ));
673        }
674        Ok(match c {
675            Condition::Always => CCond::Always,
676            Condition::ContainsAny { field, terms } => {
677                let field = self.field(field, at, &[FieldType::Text])?;
678                bound(at, "terms", terms.len(), 1, limits::TERMS)?;
679                for t in terms {
680                    bound(at, "term bytes", t.len(), 1, limits::TERM_BYTES)?;
681                }
682                self.terms += terms.len();
683                CCond::ContainsAny {
684                    field,
685                    terms: terms.iter().map(|t| t.to_ascii_lowercase()).collect(),
686                }
687            }
688            Condition::Equals { field, value } => {
689                let k = self.field(
690                    field,
691                    at,
692                    &[FieldType::Text, FieldType::Bool, FieldType::Integer],
693                )?;
694                if !canonical_for(self.fields[k].ty, value) {
695                    return Err(err(at, ProgramErrorKind::EqualsValue(value.clone())));
696                }
697                CCond::Equals {
698                    field: k,
699                    value: value.clone(),
700                }
701            }
702            Condition::Compare { field, op, value } => CCond::Compare {
703                field: self.field(field, at, &[FieldType::Integer, FieldType::Decimal])?,
704                op: *op,
705                value: *value,
706            },
707            Condition::All(members) | Condition::Any(members) => {
708                if depth + 1 > limits::CONDITION_DEPTH {
709                    return Err(err(
710                        at,
711                        ProgramErrorKind::Bound {
712                            what: "condition depth",
713                            min: 0,
714                            max: limits::CONDITION_DEPTH,
715                            found: depth + 1,
716                        },
717                    ));
718                }
719                bound(
720                    at,
721                    "condition members",
722                    members.len(),
723                    1,
724                    limits::CONDITION_MEMBERS,
725                )?;
726                let mut v = Vec::with_capacity(members.len());
727                for (k, m) in members.iter().enumerate() {
728                    v.push(self.condition(m, &format!("{at}[{k}]"), depth + 1, nodes)?);
729                }
730                if matches!(c, Condition::All(_)) {
731                    CCond::All(v)
732                } else {
733                    CCond::Any(v)
734                }
735            }
736            Condition::Not(inner) => {
737                if depth + 1 > limits::CONDITION_DEPTH {
738                    return Err(err(
739                        at,
740                        ProgramErrorKind::Bound {
741                            what: "condition depth",
742                            min: 0,
743                            max: limits::CONDITION_DEPTH,
744                            found: depth + 1,
745                        },
746                    ));
747                }
748                CCond::Not(Box::new(self.condition(
749                    inner,
750                    &format!("{at}.not"),
751                    depth + 1,
752                    nodes,
753                )?))
754            }
755        })
756    }
757
758    fn contribution(&mut self, c: &Contribution, at: &str) -> Result<CContrib, ProgramError> {
759        Ok(match c {
760            Contribution::Add(m) => CContrib::Add(adds(m, self.options, at, false)?),
761            Contribution::Linear {
762                field,
763                coefficients,
764            } => CContrib::Linear {
765                field: self.field(field, at, &[FieldType::Integer, FieldType::Decimal])?,
766                coefficients: adds(coefficients, self.options, at, false)?,
767            },
768            Contribution::Lookup {
769                field,
770                entries,
771                on_missing,
772            } => {
773                let k = self.field(
774                    field,
775                    at,
776                    &[FieldType::Text, FieldType::Bool, FieldType::Integer],
777                )?;
778                bound(
779                    at,
780                    "lookup entries",
781                    entries.len(),
782                    1,
783                    limits::LOOKUP_ENTRIES,
784                )?;
785                self.entries += entries.len();
786                let mut table = BTreeMap::new();
787                for (e, entry) in entries.iter().enumerate() {
788                    let eat = format!("{at}.entries[{e}]");
789                    bound(
790                        &eat,
791                        "lookup key bytes",
792                        entry.key.len(),
793                        1,
794                        limits::LOOKUP_KEY_BYTES,
795                    )?;
796                    let a = adds(&entry.add, self.options, &eat, false)?;
797                    // A duplicate key is refused, never resolved by position.
798                    if table.insert(entry.key.clone(), a).is_some() {
799                        return Err(err(
800                            eat,
801                            ProgramErrorKind::Duplicate {
802                                what: "lookup key",
803                                name: entry.key.clone(),
804                            },
805                        ));
806                    }
807                }
808                CContrib::Lookup {
809                    field: k,
810                    table,
811                    on_missing: *on_missing,
812                }
813            }
814        })
815    }
816}
817
818/// Whether `v` is the canonical text of some value of type `ty` (3.4.3).
819fn canonical_for(ty: FieldType, v: &str) -> bool {
820    match ty {
821        FieldType::Text => true,
822        FieldType::Bool => v == "true" || v == "false",
823        FieldType::Integer => v.parse::<i64>().is_ok_and(|i| i.to_string() == v),
824        FieldType::Decimal => false,
825    }
826}