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 && t.options != ["score"] {
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        if t.operation == Operation::Rank {
423            1
424        } else {
425            limits::OPTIONS_MIN
426        },
427        limits::OPTIONS,
428    )?;
429    let mut options = BTreeMap::new();
430    for (k, o) in t.options.iter().enumerate() {
431        bound(
432            &format!("{at}.options[{k}]"),
433            "option bytes",
434            o.len(),
435            1,
436            limits::OPTION_BYTES,
437        )?;
438        if options.insert(o.clone(), k).is_some() {
439            return Err(err(
440                format!("{at}.options"),
441                ProgramErrorKind::Duplicate {
442                    what: "option",
443                    name: o.clone(),
444                },
445            ));
446        }
447    }
448    if t.operation == Operation::Proposition && t.options != ["false", "true"] {
449        return Err(err(
450            format!("{at}.options"),
451            ProgramErrorKind::PropositionOptions,
452        ));
453    }
454    bound(
455        &format!("{at}.interpretation"),
456        "interpretation bytes",
457        t.interpretation.len(),
458        1,
459        limits::INTERPRETATION_BYTES,
460    )?;
461    if t.interpretation.trim().is_empty() {
462        return Err(err(
463            format!("{at}.interpretation"),
464            ProgramErrorKind::Interpretation,
465        ));
466    }
467    bound(
468        &format!("{at}.fields"),
469        "fields",
470        t.fields.len(),
471        0,
472        limits::FIELDS,
473    )?;
474    let mut fields = Vec::with_capacity(t.fields.len());
475    let mut field_index = BTreeMap::new();
476    for (k, f) in t.fields.iter().enumerate() {
477        let fat = format!("{at}.fields[{k}]");
478        bound(
479            &fat,
480            "field name bytes",
481            f.name.len(),
482            1,
483            limits::NAME_BYTES,
484        )?;
485        if field_index.insert(f.name.clone(), k).is_some() {
486            return Err(err(
487                fat,
488                ProgramErrorKind::Duplicate {
489                    what: "field",
490                    name: f.name.clone(),
491                },
492            ));
493        }
494        let (key, member) = parse_ref(&f.reference)
495            .ok_or_else(|| err(&fat, ProgramErrorKind::Reference(f.reference.clone())))?;
496        fields.push(CField {
497            name: f.name.clone(),
498            key,
499            member,
500            ty: f.ty,
501        });
502    }
503    let base = {
504        let mut v = vec![Decimal::from_i64(0); t.options.len()];
505        for (k, d) in adds(&t.base, &options, &format!("{at}.base"), true)? {
506            v[k] = d;
507        }
508        v
509    };
510    bound(
511        &format!("{at}.rules"),
512        "rules",
513        t.rules.len(),
514        0,
515        limits::RULES,
516    )?;
517    let mut cx = TaskCx {
518        options: &options,
519        fields: &fields,
520        field_index: &field_index,
521        terms: 0,
522        entries: 0,
523    };
524    let mut rule_names = BTreeSet::new();
525    let mut rules = Vec::with_capacity(t.rules.len());
526    for (k, r) in t.rules.iter().enumerate() {
527        let rat = format!("{at}.rules[{k}]");
528        bound(&rat, "rule name bytes", r.name.len(), 1, limits::NAME_BYTES)?;
529        if !rule_names.insert(r.name.as_str()) {
530            return Err(err(
531                rat,
532                ProgramErrorKind::Duplicate {
533                    what: "rule",
534                    name: r.name.clone(),
535                },
536            ));
537        }
538        let mut nodes = 0;
539        let when = cx.condition(&r.when, &format!("{rat}.when"), 0, &mut nodes)?;
540        bound(
541            &format!("{rat}.when"),
542            "condition nodes",
543            nodes,
544            1,
545            limits::CONDITION_NODES,
546        )?;
547        bound(
548            &format!("{rat}.then"),
549            "contributions",
550            r.then.len(),
551            0,
552            limits::CONTRIBUTIONS,
553        )?;
554        let mut then = Vec::with_capacity(r.then.len());
555        for (c, contribution) in r.then.iter().enumerate() {
556            then.push(cx.contribution(contribution, &format!("{rat}.then[{c}]"))?);
557        }
558        rules.push(CRule {
559            name: r.name.clone(),
560            when,
561            then,
562            stop: r.stop,
563        });
564    }
565    bound(
566        at,
567        "contains_any terms per task",
568        cx.terms,
569        0,
570        limits::TERMS_PER_TASK,
571    )?;
572    bound(
573        at,
574        "lookup entries per task",
575        cx.entries,
576        0,
577        limits::LOOKUP_ENTRIES_PER_TASK,
578    )?;
579    Ok(CTask {
580        operation: t.operation,
581        name: t.task.clone(),
582        question: t.question.clone(),
583        options: t.options.clone(),
584        fields,
585        base,
586        rules,
587        on_no_match: t.on_no_match,
588    })
589}
590
591/// `input:<name>` or `bind:<name>`, optionally `/<member>`.
592fn parse_ref(r: &str) -> Option<(String, Option<String>)> {
593    let (key, member) = match r.split_once('/') {
594        Some((k, m)) => (k, Some(m)),
595        None => (r, None),
596    };
597    let name = key
598        .strip_prefix("input:")
599        .or_else(|| key.strip_prefix("bind:"))?;
600    let simple = |s: &str| !s.is_empty() && !s.contains('/') && !s.contains(':');
601    if !simple(name) || member.is_some_and(|m| !simple(m)) {
602        return None;
603    }
604    Some((key.to_string(), member.map(str::to_string)))
605}
606
607fn adds(
608    m: &BTreeMap<String, Decimal>,
609    options: &BTreeMap<String, usize>,
610    at: &str,
611    may_be_empty: bool,
612) -> Result<Adds, ProgramError> {
613    if m.is_empty() && !may_be_empty {
614        return Err(err(
615            at,
616            ProgramErrorKind::Bound {
617                what: "option constants",
618                min: 1,
619                max: limits::OPTIONS,
620                found: 0,
621            },
622        ));
623    }
624    m.iter()
625        .map(|(o, d)| match options.get(o) {
626            Some(k) => Ok((*k, *d)),
627            None => Err(err(at, ProgramErrorKind::UnknownOption(o.clone()))),
628        })
629        .collect()
630}
631
632struct TaskCx<'a> {
633    options: &'a BTreeMap<String, usize>,
634    fields: &'a [CField],
635    field_index: &'a BTreeMap<String, usize>,
636    terms: usize,
637    entries: usize,
638}
639
640impl TaskCx<'_> {
641    fn field(&self, name: &str, at: &str, accepts: &[FieldType]) -> Result<usize, ProgramError> {
642        let k = *self
643            .field_index
644            .get(name)
645            .ok_or_else(|| err(at, ProgramErrorKind::UnknownField(name.to_string())))?;
646        let found = self.fields[k].ty;
647        if !accepts.contains(&found) {
648            return Err(err(
649                at,
650                ProgramErrorKind::FieldType {
651                    field: name.to_string(),
652                    found,
653                },
654            ));
655        }
656        Ok(k)
657    }
658
659    fn condition(
660        &mut self,
661        c: &Condition,
662        at: &str,
663        depth: usize,
664        nodes: &mut usize,
665    ) -> Result<CCond, ProgramError> {
666        *nodes += 1;
667        if *nodes > limits::CONDITION_NODES {
668            return Err(err(
669                at,
670                ProgramErrorKind::Bound {
671                    what: "condition nodes",
672                    min: 1,
673                    max: limits::CONDITION_NODES,
674                    found: *nodes,
675                },
676            ));
677        }
678        Ok(match c {
679            Condition::Always => CCond::Always,
680            Condition::ContainsAny { field, terms } => {
681                let field = self.field(field, at, &[FieldType::Text])?;
682                bound(at, "terms", terms.len(), 1, limits::TERMS)?;
683                for t in terms {
684                    bound(at, "term bytes", t.len(), 1, limits::TERM_BYTES)?;
685                }
686                self.terms += terms.len();
687                CCond::ContainsAny {
688                    field,
689                    terms: terms.iter().map(|t| t.to_ascii_lowercase()).collect(),
690                }
691            }
692            Condition::Equals { field, value } => {
693                let k = self.field(
694                    field,
695                    at,
696                    &[FieldType::Text, FieldType::Bool, FieldType::Integer],
697                )?;
698                if !canonical_for(self.fields[k].ty, value) {
699                    return Err(err(at, ProgramErrorKind::EqualsValue(value.clone())));
700                }
701                CCond::Equals {
702                    field: k,
703                    value: value.clone(),
704                }
705            }
706            Condition::Compare { field, op, value } => CCond::Compare {
707                field: self.field(field, at, &[FieldType::Integer, FieldType::Decimal])?,
708                op: *op,
709                value: *value,
710            },
711            Condition::All(members) | Condition::Any(members) => {
712                if depth + 1 > limits::CONDITION_DEPTH {
713                    return Err(err(
714                        at,
715                        ProgramErrorKind::Bound {
716                            what: "condition depth",
717                            min: 0,
718                            max: limits::CONDITION_DEPTH,
719                            found: depth + 1,
720                        },
721                    ));
722                }
723                bound(
724                    at,
725                    "condition members",
726                    members.len(),
727                    1,
728                    limits::CONDITION_MEMBERS,
729                )?;
730                let mut v = Vec::with_capacity(members.len());
731                for (k, m) in members.iter().enumerate() {
732                    v.push(self.condition(m, &format!("{at}[{k}]"), depth + 1, nodes)?);
733                }
734                if matches!(c, Condition::All(_)) {
735                    CCond::All(v)
736                } else {
737                    CCond::Any(v)
738                }
739            }
740            Condition::Not(inner) => {
741                if depth + 1 > limits::CONDITION_DEPTH {
742                    return Err(err(
743                        at,
744                        ProgramErrorKind::Bound {
745                            what: "condition depth",
746                            min: 0,
747                            max: limits::CONDITION_DEPTH,
748                            found: depth + 1,
749                        },
750                    ));
751                }
752                CCond::Not(Box::new(self.condition(
753                    inner,
754                    &format!("{at}.not"),
755                    depth + 1,
756                    nodes,
757                )?))
758            }
759        })
760    }
761
762    fn contribution(&mut self, c: &Contribution, at: &str) -> Result<CContrib, ProgramError> {
763        Ok(match c {
764            Contribution::Add(m) => CContrib::Add(adds(m, self.options, at, false)?),
765            Contribution::Linear {
766                field,
767                coefficients,
768            } => CContrib::Linear {
769                field: self.field(field, at, &[FieldType::Integer, FieldType::Decimal])?,
770                coefficients: adds(coefficients, self.options, at, false)?,
771            },
772            Contribution::Lookup {
773                field,
774                entries,
775                on_missing,
776            } => {
777                let k = self.field(
778                    field,
779                    at,
780                    &[FieldType::Text, FieldType::Bool, FieldType::Integer],
781                )?;
782                bound(
783                    at,
784                    "lookup entries",
785                    entries.len(),
786                    1,
787                    limits::LOOKUP_ENTRIES,
788                )?;
789                self.entries += entries.len();
790                let mut table = BTreeMap::new();
791                for (e, entry) in entries.iter().enumerate() {
792                    let eat = format!("{at}.entries[{e}]");
793                    bound(
794                        &eat,
795                        "lookup key bytes",
796                        entry.key.len(),
797                        1,
798                        limits::LOOKUP_KEY_BYTES,
799                    )?;
800                    let a = adds(&entry.add, self.options, &eat, false)?;
801                    // A duplicate key is refused, never resolved by position.
802                    if table.insert(entry.key.clone(), a).is_some() {
803                        return Err(err(
804                            eat,
805                            ProgramErrorKind::Duplicate {
806                                what: "lookup key",
807                                name: entry.key.clone(),
808                            },
809                        ));
810                    }
811                }
812                CContrib::Lookup {
813                    field: k,
814                    table,
815                    on_missing: *on_missing,
816                }
817            }
818        })
819    }
820}
821
822/// Whether `v` is the canonical text of some value of type `ty` (3.4.3).
823fn canonical_for(ty: FieldType, v: &str) -> bool {
824    match ty {
825        FieldType::Text => true,
826        FieldType::Bool => v == "true" || v == "false",
827        FieldType::Integer => v.parse::<i64>().is_ok_and(|i| i.to_string() == v),
828        FieldType::Decimal => false,
829    }
830}