Skip to main content

lace_sans_io/logic/lacegram/
validation.rs

1//! Rule safety, predicate-role, and variable-kind validation.
2
3use std::collections::{HashMap, HashSet};
4
5use crate::types::{
6    BodyAtom, OrderingExpression, Rule, Term, Variable, VariableKind,
7    is_canonical_unsigned_decimal, is_record_reference_text,
8};
9
10use crate::logic::profile::{self, ArgKind, ProfilePredicate, Role};
11
12use super::parser::{
13    is_record_reference_variable_name, is_reserved_logic_word, is_text_variable_name, render_term,
14};
15
16pub(super) fn check_safety(rules: &[Rule]) -> Result<(), String> {
17    for rule in rules {
18        // Every variable in the head MUST appear in a positive non-builtin
19        // body atom. Anonymous terms are not head terms.
20        if rule.head_terms.iter().any(Term::is_anonymous) {
21            return Err(format!(
22                "anonymous term in head of predicate {}",
23                rule.head_predicate
24            ));
25        }
26        let pos_vars = collect_positive_nonbuiltin_vars(rule);
27        let head_vars = collect_head_vars(rule);
28
29        for hv in &head_vars {
30            if !pos_vars.contains(hv) {
31                return Err(format!(
32                    "unsafe variable {:?} in head of predicate {}",
33                    hv.0, rule.head_predicate
34                ));
35            }
36        }
37
38        // Variables in negated atoms and non-binding builtins
39        // MUST appear in a positive non-builtin body atom. Variables that only
40        // occur inside Cardinality are local to the cardinality test.
41        for atom in &rule.body {
42            let constrained = match atom {
43                BodyAtom::Negated { terms, .. } => collect_vars(terms),
44                BodyAtom::NotEqual(a, b) => {
45                    if a.is_anonymous() || b.is_anonymous() {
46                        return Err("anonymous term in != body atom".into());
47                    }
48                    let all_v = collect_terms_vars(&[a.clone(), b.clone()]);
49                    if all_v.iter().any(|x| !pos_vars.contains(x)) {
50                        return Err("unsafe variable in != body atom".into());
51                    }
52                    continue;
53                }
54                BodyAtom::Compare { left, right, .. } => {
55                    validate_static_int_term(left, "Compare left")?;
56                    validate_static_int_term(right, "Compare right")?;
57                    if left.term().is_anonymous() || right.term().is_anonymous() {
58                        return Err("anonymous term in Compare body atom".into());
59                    }
60                    collect_terms_vars(&[left.term().clone(), right.term().clone()])
61                }
62                BodyAtom::TextMatch { text, .. } => {
63                    if text.is_anonymous() {
64                        return Err("anonymous term in TextMatch body atom".into());
65                    }
66                    collect_vars(std::slice::from_ref(text))
67                }
68                BodyAtom::TextShape {
69                    text, start, end, ..
70                } => {
71                    if text.is_anonymous() || start.is_anonymous() || end.is_anonymous() {
72                        return Err("anonymous term in TextShape body atom".into());
73                    }
74                    collect_terms_vars(&[text.clone(), start.clone(), end.clone()])
75                }
76                BodyAtom::Cardinality {
77                    predicate, terms, ..
78                } => {
79                    if !predicate_allows_cardinality(predicate, terms.len()) {
80                        return Err(format!(
81                            "Cardinality cannot count {}/{}",
82                            predicate,
83                            terms.len()
84                        ));
85                    }
86                    let mut leaking = collect_vars(terms);
87                    leaking.retain(|v| pos_vars.contains(v));
88                    leaking
89                }
90                BodyAtom::TopK {
91                    predicate,
92                    terms,
93                    partition,
94                    order,
95                    ..
96                } => {
97                    validate_static_int_term(order, "TopK ordering")?;
98                    if !predicate_allows_topk(predicate, terms.len()) {
99                        return Err(format!("TopK cannot select {}/{}", predicate, terms.len()));
100                    }
101                    validate_topk_partition(partition, terms)?;
102                    if order.term().is_anonymous() || !terms.iter().any(|term| term == order.term())
103                    {
104                        return Err("TopK ordering term must occur in the candidate atom and must not be anonymous".into());
105                    }
106                    collect_vars(terms)
107                }
108                _ => continue,
109            };
110            for v in &constrained {
111                if !pos_vars.contains(v) {
112                    return Err(format!(
113                        "unsafe variable {:?} in body of rule for {}",
114                        v.0, rule.head_predicate
115                    ));
116                }
117            }
118        }
119    }
120
121    validate_predicate_names_and_shadowing(rules)?;
122    check_variable_kinds(rules)?;
123
124    Ok(())
125}
126
127fn validate_static_int_term(expression: &OrderingExpression, context: &str) -> Result<(), String> {
128    let OrderingExpression::Int(Term::Const(value)) = expression else {
129        return Ok(());
130    };
131    if is_canonical_unsigned_decimal(value) {
132        Ok(())
133    } else {
134        Err(format!(
135            "{context} Int constant must be canonical unsigned decimal text"
136        ))
137    }
138}
139
140fn validate_predicate_names_and_shadowing(rules: &[Rule]) -> Result<(), String> {
141    let definitions = rules
142        .iter()
143        .map(|rule| (rule.head_predicate.as_str(), rule.head_terms.len()))
144        .collect::<HashSet<_>>();
145    for rule in rules {
146        validate_head_predicate(rule.head_predicate.as_str(), rule.head_terms.len())?;
147        validate_body_predicate_names(&rule.body)?;
148        for (predicate, terms, _) in rule.body.iter().filter_map(BodyAtom::relation) {
149            if predicate_name_class(predicate) == PredicateNameClass::Lowercase
150                && !definitions.contains(&(predicate.as_str(), terms.len()))
151            {
152                return Err(format!("undefined helper {predicate}/{}", terms.len()));
153            }
154        }
155    }
156    Ok(())
157}
158
159fn validate_body_predicate_names(body: &[BodyAtom]) -> Result<(), String> {
160    for atom in body {
161        match atom {
162            BodyAtom::Positive { predicate, terms } | BodyAtom::Negated { predicate, terms } => {
163                validate_body_predicate(predicate, terms.len())?;
164            }
165            BodyAtom::Cardinality {
166                predicate, terms, ..
167            } => {
168                validate_body_predicate(predicate, terms.len())?;
169                if !predicate_allows_cardinality(predicate, terms.len()) {
170                    return Err(format!(
171                        "Cardinality cannot count {}/{}",
172                        predicate,
173                        terms.len()
174                    ));
175                }
176            }
177            BodyAtom::TopK {
178                predicate, terms, ..
179            } => {
180                validate_body_predicate(predicate, terms.len())?;
181                if !predicate_allows_topk(predicate, terms.len()) {
182                    return Err(format!("TopK cannot select {}/{}", predicate, terms.len()));
183                }
184            }
185            BodyAtom::Compare { .. }
186            | BodyAtom::TextShape { .. }
187            | BodyAtom::TextMatch { .. }
188            | BodyAtom::NotEqual(_, _)
189            | BodyAtom::True => {}
190        }
191    }
192    Ok(())
193}
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196enum KindPosition {
197    Record,
198    RecordReference,
199    TextOnly,
200    GenericText,
201}
202
203#[derive(Debug, Clone, Copy, PartialEq, Eq)]
204enum HelperArgumentKind {
205    Text,
206    RecordReference,
207    GenericText,
208}
209
210type InferredHelperArgumentKinds = HashMap<(String, usize), Vec<Option<HelperArgumentKind>>>;
211
212fn check_variable_kinds(rules: &[Rule]) -> Result<(), String> {
213    infer_helper_kinds(rules).map(|_| ())
214}
215
216fn infer_helper_kinds(rules: &[Rule]) -> Result<InferredHelperArgumentKinds, String> {
217    let mut helper_kinds = InferredHelperArgumentKinds::new();
218    let defined_helpers = rules
219        .iter()
220        .filter(|rule| profile::lookup(rule.head_predicate.as_str()).is_none())
221        .map(|rule| (rule.head_predicate.to_string(), rule.head_terms.len()))
222        .collect::<HashSet<_>>();
223    for rule in rules {
224        if profile::lookup(rule.head_predicate.as_str()).is_none() {
225            helper_kinds
226                .entry((rule.head_predicate.to_string(), rule.head_terms.len()))
227                .or_insert_with(|| vec![None; rule.head_terms.len()]);
228        }
229        for (name, terms, _) in rule.body.iter().filter_map(BodyAtom::relation) {
230            if profile::lookup(name).is_none() {
231                helper_kinds
232                    .entry((name.to_string(), terms.len()))
233                    .or_insert_with(|| vec![None; terms.len()]);
234            }
235        }
236    }
237
238    let mut changed = true;
239    while changed {
240        changed = false;
241        for rule in rules {
242            // Provisional signatures only widen, so a conflict against one may
243            // vanish once a later rule widens it: a head's kinds come from its
244            // body alone, a body that does not yet fit contributes nothing
245            // this pass, and a conflicting contribution is skipped. The final
246            // pass checks every rule against the settled signatures.
247            let mut vars = HashMap::<Variable, HelperArgumentKind>::new();
248            if apply_atom_kind_constraints(&rule.body, &helper_kinds, &mut vars).is_err() {
249                continue;
250            }
251            let head_key = (rule.head_predicate.to_string(), rule.head_terms.len());
252            if let Some(sig) = helper_kinds.get_mut(&head_key) {
253                for (idx, term) in rule.head_terms.iter().enumerate() {
254                    if let Some(kind) = term_inferred_kind(term, &vars)
255                        && merge_kind(&mut sig[idx], kind)
256                    {
257                        changed = true;
258                    }
259                }
260            }
261        }
262    }
263
264    for (key, sig) in helper_kinds.iter_mut() {
265        if !defined_helpers.contains(key) {
266            continue;
267        }
268        for kind in sig.iter_mut() {
269            if kind.is_none() {
270                *kind = Some(HelperArgumentKind::Text);
271            }
272        }
273    }
274
275    for rule in rules {
276        let mut vars = HashMap::<Variable, HelperArgumentKind>::new();
277        constrain_predicate_terms(
278            rule.head_predicate.as_str(),
279            &rule.head_terms,
280            &helper_kinds,
281            &mut vars,
282        )?;
283        apply_atom_kind_constraints(&rule.body, &helper_kinds, &mut vars)?;
284    }
285    Ok(helper_kinds)
286}
287
288/// Variable kinds that preserve complete candidate tuples in generated rules.
289/// Generic positions use text variables so record-shaped values are not required.
290pub fn predicate_variable_kinds(
291    rules: &[Rule],
292) -> Result<HashMap<(String, usize), Vec<VariableKind>>, String> {
293    let mut kinds = infer_helper_kinds(rules)?
294        .into_iter()
295        .map(|(key, kinds)| {
296            let kinds = kinds
297                .into_iter()
298                .map(|kind| match kind {
299                    Some(HelperArgumentKind::RecordReference) => VariableKind::Record,
300                    _ => VariableKind::Text,
301                })
302                .collect();
303            (key, kinds)
304        })
305        .collect::<HashMap<_, _>>();
306    for rule in rules {
307        for (name, terms, _) in rule.body.iter().filter_map(BodyAtom::relation) {
308            // Use unconstrained positions: a call-site field-name constant must
309            // not narrow the kind of a complete Field candidate relation.
310            let anonymous = vec![Term::Anonymous; terms.len()];
311            if let Some(mark) = standard_predicate_mark(name, terms.len(), &anonymous) {
312                kinds.insert(
313                    (name.to_string(), terms.len()),
314                    mark.into_iter()
315                        .map(|kind| match kind {
316                            KindPosition::Record | KindPosition::RecordReference => {
317                                VariableKind::Record
318                            }
319                            _ => VariableKind::Text,
320                        })
321                        .collect(),
322                );
323            }
324        }
325    }
326    Ok(kinds)
327}
328
329fn apply_atom_kind_constraints(
330    body: &[BodyAtom],
331    helper_kinds: &InferredHelperArgumentKinds,
332    vars: &mut HashMap<Variable, HelperArgumentKind>,
333) -> Result<(), String> {
334    for atom in body {
335        if let Some((predicate, terms, _)) = atom.relation() {
336            constrain_predicate_terms(predicate, terms, helper_kinds, vars)?;
337        }
338        match atom {
339            BodyAtom::TopK { order, .. } => {
340                let position = match order {
341                    OrderingExpression::Lex(_) => KindPosition::GenericText,
342                    OrderingExpression::Int(_) => KindPosition::TextOnly,
343                    OrderingExpression::AdmitOrder(_) => KindPosition::Record,
344                };
345                constrain_term(order.term(), position, vars)?;
346            }
347            BodyAtom::Compare { left, right, .. } => {
348                let position = match left {
349                    OrderingExpression::Lex(_) => KindPosition::GenericText,
350                    OrderingExpression::Int(_) => KindPosition::TextOnly,
351                    OrderingExpression::AdmitOrder(_) => KindPosition::Record,
352                };
353                constrain_term(left.term(), position, vars)?;
354                constrain_term(right.term(), position, vars)?;
355            }
356            BodyAtom::TextMatch { text, .. } => {
357                constrain_term(text, KindPosition::GenericText, vars)?;
358            }
359            BodyAtom::TextShape {
360                text, start, end, ..
361            } => {
362                constrain_term(text, KindPosition::GenericText, vars)?;
363                constrain_term(start, KindPosition::GenericText, vars)?;
364                constrain_term(end, KindPosition::GenericText, vars)?;
365            }
366            BodyAtom::NotEqual(a, b) => {
367                if let (Term::Var(left), Term::Var(right)) = (a, b)
368                    && left.kind() != right.kind()
369                {
370                    return Err("!= compares incompatible variable kinds".into());
371                }
372            }
373            BodyAtom::Positive { .. }
374            | BodyAtom::Negated { .. }
375            | BodyAtom::Cardinality { .. }
376            | BodyAtom::True => {}
377        }
378    }
379    Ok(())
380}
381
382fn constrain_predicate_terms(
383    name: &str,
384    terms: &[Term],
385    helper_kinds: &InferredHelperArgumentKinds,
386    vars: &mut HashMap<Variable, HelperArgumentKind>,
387) -> Result<(), String> {
388    if let Some(mark) = standard_predicate_mark(name, terms.len(), terms) {
389        for (term, position) in terms.iter().zip(mark) {
390            constrain_term(term, position, vars)?;
391        }
392        return Ok(());
393    }
394    if let Some(mark) = helper_kinds.get(&(name.to_string(), terms.len())) {
395        for (term, kind) in terms.iter().zip(mark) {
396            if let Some(kind) = kind {
397                constrain_term(term, helper_argument_position(*kind), vars)?;
398            }
399        }
400    }
401    Ok(())
402}
403
404fn helper_argument_position(kind: HelperArgumentKind) -> KindPosition {
405    match kind {
406        HelperArgumentKind::Text => KindPosition::TextOnly,
407        HelperArgumentKind::RecordReference => KindPosition::RecordReference,
408        HelperArgumentKind::GenericText => KindPosition::GenericText,
409    }
410}
411
412fn constrain_term(
413    term: &Term,
414    position: KindPosition,
415    vars: &mut HashMap<Variable, HelperArgumentKind>,
416) -> Result<(), String> {
417    match term {
418        Term::Anonymous => Ok(()),
419        Term::Const(value) => {
420            match position {
421                KindPosition::Record => {
422                    crate::records::RecordHash::parse(value).map_err(|_| {
423                        format!("record-position constant is not a supported record hash: {value}")
424                    })?;
425                }
426                KindPosition::RecordReference => {
427                    if !is_record_reference_text(value) {
428                        return Err(format!(
429                            "record-reference constant is not record-reference text: {value}"
430                        ));
431                    }
432                }
433                KindPosition::TextOnly | KindPosition::GenericText => {}
434            }
435            Ok(())
436        }
437        Term::Var(variable) => {
438            let required = match position {
439                KindPosition::Record | KindPosition::RecordReference => {
440                    if variable.kind() != VariableKind::Record {
441                        return Err(format!(
442                            "{} is text; this position expects a record-reference variable such as `Record`",
443                            variable.name()
444                        ));
445                    }
446                    HelperArgumentKind::RecordReference
447                }
448                KindPosition::TextOnly => {
449                    if variable.kind() != VariableKind::Text {
450                        return Err(format!(
451                            "{} is a record-reference variable; this position expects text",
452                            render_term(term)
453                        ));
454                    }
455                    HelperArgumentKind::Text
456                }
457                KindPosition::GenericText => HelperArgumentKind::GenericText,
458            };
459            merge_variable_kind(vars, variable.clone(), required)
460        }
461    }
462}
463
464fn term_inferred_kind(
465    term: &Term,
466    vars: &HashMap<Variable, HelperArgumentKind>,
467) -> Option<HelperArgumentKind> {
468    match term {
469        Term::Var(variable) => vars.get(variable).copied().or(Some(match variable.kind() {
470            VariableKind::Record => HelperArgumentKind::RecordReference,
471            VariableKind::Text => HelperArgumentKind::Text,
472        })),
473        Term::Const(value) => {
474            is_record_reference_text(value).then_some(HelperArgumentKind::GenericText)
475        }
476        Term::Anonymous => None,
477    }
478}
479
480fn merge_variable_kind(
481    vars: &mut HashMap<Variable, HelperArgumentKind>,
482    variable: Variable,
483    kind: HelperArgumentKind,
484) -> Result<(), String> {
485    match vars.get_mut(&variable) {
486        Some(existing) => merge_variable_kind_value(existing, kind).map(|_| ()),
487        None => {
488            vars.insert(variable, kind);
489            Ok(())
490        }
491    }
492}
493
494fn merge_variable_kind_value(
495    existing: &mut HelperArgumentKind,
496    incoming: HelperArgumentKind,
497) -> Result<bool, String> {
498    match (*existing, incoming) {
499        (left, right) if left == right => Ok(false),
500        (HelperArgumentKind::GenericText, specific) => {
501            *existing = specific;
502            Ok(true)
503        }
504        (_, HelperArgumentKind::GenericText) => Ok(false),
505        _ => Err("variable used with incompatible kinds".into()),
506    }
507}
508
509/// Widens a provisional helper slot by one rule's contribution, reporting
510/// whether it changed. A conflicting contribution leaves the slot as it is.
511fn merge_kind(slot: &mut Option<HelperArgumentKind>, kind: HelperArgumentKind) -> bool {
512    match (*slot, kind) {
513        (None, _) => {
514            *slot = Some(kind);
515            true
516        }
517        (Some(HelperArgumentKind::GenericText), _) => false,
518        (Some(existing), _) if existing == kind => false,
519        (Some(_), HelperArgumentKind::GenericText) => {
520            *slot = Some(HelperArgumentKind::GenericText);
521            true
522        }
523        (Some(_), _) => false,
524    }
525}
526
527#[derive(Debug, Clone, Copy, PartialEq, Eq)]
528enum PredicateNameClass {
529    Uppercase,
530    Lowercase,
531    Other,
532}
533
534fn predicate_name_class(name: &str) -> PredicateNameClass {
535    if is_record_reference_variable_name(name) {
536        PredicateNameClass::Uppercase
537    } else if is_text_variable_name(name) {
538        PredicateNameClass::Lowercase
539    } else {
540        PredicateNameClass::Other
541    }
542}
543
544fn validate_head_predicate(name: &str, arity: usize) -> Result<(), String> {
545    let Some(predicate) = profile_predicate(name, arity)? else {
546        return Ok(());
547    };
548    if predicate.role != Role::Facet {
549        return Err(format!(
550            "derived rule shadows profile predicate {name}/{arity}"
551        ));
552    }
553    Ok(())
554}
555
556/// A body may reference profile base facts and helpers, never a facet.
557fn validate_body_predicate(name: &str, arity: usize) -> Result<(), String> {
558    match profile_predicate(name, arity)? {
559        Some(predicate) if predicate.role == Role::Facet => Err(format!(
560            "rule bodies must not reference output facet {name}/{arity}"
561        )),
562        _ => Ok(()),
563    }
564}
565
566/// The declared profile predicate for a capitalized name, or `None` for a
567/// lowercase helper. Capitalized names are reserved for the profile.
568fn profile_predicate(
569    name: &str,
570    arity: usize,
571) -> Result<Option<&'static ProfilePredicate>, String> {
572    if is_reserved_logic_word(name) {
573        return Err(format!(
574            "`{name}` is reserved logic syntax, not a predicate"
575        ));
576    }
577    match predicate_name_class(name) {
578        PredicateNameClass::Uppercase => {
579            let Some(predicate) = profile::lookup(name) else {
580                return Err(format!(
581                    "unknown capitalized profile predicate {name}/{arity}; capitalized names are reserved for the profile"
582                ));
583            };
584            if predicate.arity() != arity {
585                return Err(format!(
586                    "{name} expects arity {}, got {arity}",
587                    predicate.arity()
588                ));
589            }
590            Ok(Some(predicate))
591        }
592        PredicateNameClass::Lowercase => Ok(None),
593        PredicateNameClass::Other => Err(format!("invalid predicate name {name}/{arity}")),
594    }
595}
596
597fn predicate_allows_cardinality(name: &str, arity: usize) -> bool {
598    match profile::lookup(name) {
599        Some(predicate) => {
600            predicate.arity() == arity && matches!(predicate.role, Role::Base { .. })
601        }
602        None => predicate_name_class(name) == PredicateNameClass::Lowercase,
603    }
604}
605
606fn predicate_allows_topk(name: &str, arity: usize) -> bool {
607    predicate_allows_cardinality(name, arity)
608}
609
610fn standard_predicate_mark(name: &str, arity: usize, terms: &[Term]) -> Option<Vec<KindPosition>> {
611    let predicate = profile::lookup(name).filter(|predicate| predicate.arity() == arity)?;
612    Some(
613        predicate
614            .kinds
615            .iter()
616            .map(|kind| match kind {
617                ArgKind::Record => KindPosition::Record,
618                ArgKind::RecordReference => KindPosition::RecordReference,
619                ArgKind::Text => KindPosition::TextOnly,
620                ArgKind::GenericText => KindPosition::GenericText,
621                ArgKind::FieldValue => match terms.get(1).and_then(Term::as_const) {
622                    Some(":Type" | ":Length" | "Data-Length" | "By" | "Mark") => {
623                        KindPosition::TextOnly
624                    }
625                    _ => KindPosition::GenericText,
626                },
627            })
628            .collect(),
629    )
630}
631
632fn validate_topk_partition(partition: &[Variable], terms: &[Term]) -> Result<(), String> {
633    let mut seen = HashSet::new();
634    for variable in partition {
635        if !seen.insert(variable) {
636            return Err("TopK PartitionBy variables must be distinct".into());
637        }
638        if !terms
639            .iter()
640            .any(|term| term == &Term::Var(variable.clone()))
641        {
642            return Err("TopK PartitionBy variable must occur in the candidate atom".into());
643        }
644    }
645    Ok(())
646}
647
648fn collect_positive_nonbuiltin_vars(rule: &Rule) -> HashSet<Variable> {
649    let mut vars = HashSet::new();
650    for atom in &rule.body {
651        match atom {
652            BodyAtom::Positive { terms, .. } | BodyAtom::TopK { terms, .. } => {
653                vars.extend(collect_vars(terms));
654            }
655            _ => {}
656        }
657    }
658    vars
659}
660
661fn collect_head_vars(rule: &Rule) -> HashSet<Variable> {
662    collect_vars(&rule.head_terms)
663}
664
665fn collect_vars(terms: &[Term]) -> HashSet<Variable> {
666    let mut vars = HashSet::new();
667    for t in terms {
668        if let Term::Var(v) = t {
669            vars.insert(v.clone());
670        }
671    }
672    vars
673}
674
675fn collect_terms_vars(terms: &[Term]) -> HashSet<Variable> {
676    collect_vars(terms)
677}