Skip to main content

xlog_logic/hypergraph/
reference.rs

1//! CPU reference evaluator for hypergraph rules.
2//!
3//! Pure-Rust, deterministic, deduplicated. Built to be the WCOJ
4//! oracle that PR 3+ GPU kernels are validated against, NOT to be
5//! fast. Single-rule semantics only — recursive fixpoint is out of
6//! scope for PR 2 (planned for the next slice).
7//!
8//! ## Algorithm
9//!
10//! For an [`Eligible`](super::Eligibility::Eligible) rule:
11//!   1. Build the [`HypergraphRule`] from the AST [`Rule`].
12//!   2. For each vertex, compute its **domain**: the union of values
13//!      observed at that vertex's positions across all hyperedges'
14//!      relations. Vertices appearing in zero hyperedges (impossible
15//!      for an eligible rule, but checked) get an empty domain.
16//!   3. Recurse over vertices in the order produced by the supplied
17//!      [`VariableOrder`]. At each step, bind the next variable to
18//!      one of its domain values.
19//!   4. At full assignment, verify every positive hyperedge has at
20//!      least one matching row — accounting for any constants and
21//!      anonymous wildcards in the source atom.
22//!   5. Apply [`BodyLiteral::Comparison`] filters.
23//!   6. Project the head's terms into a [`Vec<RefValue>`] result row.
24//!   7. Sort + dedupe the final row collection (stable, set semantics).
25//!
26//! Ineligible rules return an evaluator error — callers must gate on
27//! [`super::analyze`] / [`super::analyze_typed`] before evaluation.
28//! See [`evaluate_rule`] for the full contract.
29
30use super::ir::{Hyperedge, HypergraphRule, VertexId};
31use super::var_order::VariableOrder;
32use super::{analyze, Eligibility, ExecutorContext};
33use crate::ast::{Atom, BodyLiteral, CompOp, Comparison, Rule, Term};
34use std::collections::BTreeMap;
35use xlog_core::ScalarType;
36
37/// A typed scalar value produced or consumed by the reference evaluator.
38///
39/// Mirrors [`xlog_core::ScalarType`] except for floating-point types,
40/// which are deliberately omitted: PR 2's WCOJ supported set is
41/// `{U32, U64, Symbol}` (see
42/// [`super::WCOJ_SUPPORTED_KEY_TYPES`]); the wider non-float types
43/// (`I32`, `I64`, `Bool`) are included so the evaluator can hold
44/// non-key column values from rules where projections include them.
45///
46/// `RefValue::Symbol` carries an owned `String` rather than the AST's
47/// interned `u32` — the evaluator is decoupled from
48/// [`xlog_core::symbol`]'s global interner so tests stay hermetic.
49/// Integration into a pipeline that uses interned symbols (PR 4+) is
50/// expected to translate at the boundary.
51#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
52pub enum RefValue {
53    /// Unsigned 32-bit value.
54    U32(u32),
55    /// Unsigned 64-bit value.
56    U64(u64),
57    /// Signed 32-bit value.
58    I32(i32),
59    /// Signed 64-bit value.
60    I64(i64),
61    /// Boolean.
62    Bool(bool),
63    /// String-encoded symbol (decoupled from the symbol interner —
64    /// see type-level docs).
65    Symbol(String),
66}
67
68/// A relation: schema (column types) + rows (lists of typed values).
69///
70/// Tests build these directly. PR 4+ may add construction from
71/// `xlog-cuda` buffers or Arrow batches.
72#[derive(Debug, Clone, PartialEq)]
73pub struct RefRelation {
74    /// One [`ScalarType`] per column.
75    pub schema: Vec<ScalarType>,
76    /// Rows; each inner vec has length `schema.len()`. Values may
77    /// be of any [`RefValue`] variant in PR 2 (the evaluator does
78    /// not enforce schema/value type agreement at construction —
79    /// only at match time).
80    pub rows: Vec<Vec<RefValue>>,
81}
82
83/// Map from predicate name to relation. Sorted iteration is
84/// inherent to [`BTreeMap`] and contributes to evaluator
85/// determinism.
86pub type RefRelationStore = BTreeMap<String, RefRelation>;
87
88/// Errors surfaced by [`evaluate_rule`].
89#[derive(Debug, Clone, PartialEq)]
90pub enum RefEvalError {
91    /// The rule was [`Eligibility::Ineligible`] — callers must gate
92    /// on [`super::analyze`] / [`super::analyze_typed`] before
93    /// evaluation. Carries the boundary list for diagnostic use.
94    Ineligible(Vec<super::Boundary>),
95    /// A predicate referenced by the rule body was not present in
96    /// the supplied [`RefRelationStore`].
97    MissingRelation(String),
98    /// A body atom's arity does not match the supplied relation's
99    /// schema arity. The evaluator is the WCOJ correctness oracle —
100    /// arity drift between rule and fixture must surface as a
101    /// loud failure, not a silent skip.
102    RelationArityMismatch {
103        /// Predicate name being looked up.
104        predicate: String,
105        /// Number of terms in the body atom.
106        atom_arity: usize,
107        /// Number of columns in the relation schema.
108        relation_arity: usize,
109    },
110    /// A relation row's length does not equal the schema's
111    /// column count. Rejected before evaluation begins so a
112    /// malformed fixture fails on entry rather than producing
113    /// misleading partial-row matches.
114    RelationRowArityMismatch {
115        /// Predicate name owning the malformed row.
116        predicate: String,
117        /// Index of the offending row within the relation.
118        row_index: usize,
119        /// Observed length of the row.
120        row_len: usize,
121        /// Expected length per the schema.
122        schema_len: usize,
123    },
124    /// A relation row carries a [`RefValue`] whose variant does not
125    /// match the schema's [`ScalarType`] at that column. Detected
126    /// before evaluation so type drift in fixtures fails loudly
127    /// rather than silently corrupting GPU oracle comparisons.
128    RelationValueTypeMismatch {
129        /// Predicate name owning the malformed row.
130        predicate: String,
131        /// Row index within the relation.
132        row_index: usize,
133        /// Column index within the row.
134        column: usize,
135        /// Schema-declared type at that column.
136        expected: ScalarType,
137        /// String description of the actual value.
138        got: String,
139    },
140    /// A constant in a body atom could not be coerced to a value
141    /// compatible with the relation's column type at that position.
142    ConstantTypeMismatch {
143        /// Predicate name where the mismatch occurred.
144        predicate: String,
145        /// Argument position (0-based) within the predicate.
146        position: usize,
147        /// Expected scalar type per the relation's schema.
148        expected: ScalarType,
149        /// String description of the constant for diagnostic use.
150        got: String,
151    },
152    /// A [`Comparison`] body literal could not be evaluated because
153    /// its operands were of incompatible types.
154    ComparisonTypeMismatch {
155        /// Left-hand operand description.
156        left: String,
157        /// Right-hand operand description.
158        right: String,
159        /// Operator that was being applied.
160        op: CompOp,
161    },
162    /// The rule head referenced a variable that is not bound by any
163    /// body atom (a free variable in the head).
164    UnboundHeadVariable(String),
165    /// The rule body contained an [`crate::ast::IsExpr`] — PR 2
166    /// rejects rules containing computed bindings via the
167    /// `BodyIsExpr` boundary, so this is a defensive arm; if it
168    /// fires the analyzer let an `IsExpr` rule through erroneously.
169    IsExprNotSupported,
170    /// A variable appears in two body atoms whose relation schemas
171    /// disagree on the column type at the variable's position.
172    ///
173    /// Detected by [`super::evaluate_rule_typed`] (and the typed
174    /// fixpoint variants) at the typed-gate phase, before
175    /// evaluation. Without this check, an evaluator would either
176    /// silently coerce values across types or fail later with a
177    /// less precise error pointing at a row, not at the rule.
178    ///
179    /// The two `(predicate, position, type)` triples are recorded
180    /// in *first-encountered* order: walking body atoms in source
181    /// order, the first atom that types this variable wins
182    /// `first_*`; the second atom whose type differs gets
183    /// `second_*`. Subsequent agreeing atoms are silent.
184    ConflictingVariableType {
185        /// Variable name as it appears in the source rule.
186        var: String,
187        /// Predicate of the first atom that typed `var`.
188        first_predicate: String,
189        /// 0-based argument position within `first_predicate`.
190        first_position: usize,
191        /// Schema type at `(first_predicate, first_position)`.
192        first_type: ScalarType,
193        /// Predicate of the conflicting atom.
194        second_predicate: String,
195        /// 0-based argument position within `second_predicate`.
196        second_position: usize,
197        /// Schema type at `(second_predicate, second_position)`.
198        second_type: ScalarType,
199    },
200    /// Two rules contributing to the same head predicate disagree
201    /// on the type of the same column under the PR 8 SCC type
202    /// inference pass.
203    ///
204    /// Distinct from [`Self::ConflictingVariableType`], which is a
205    /// within-rule body conflict (one rule, two body atoms typing
206    /// the same variable differently). This variant is a
207    /// cross-rule head-column conflict, detected by
208    /// [`super::infer_scc_predicate_schemas`] when back-propagating
209    /// from rule heads to head-predicate column types.
210    ///
211    /// Surfaced through the typed evaluators via
212    /// [`super::FixpointError::RuleEval`] /
213    /// [`super::SccFixpointError::RuleEval`]; their `rule_index`
214    /// field carries `second_rule_index` (the rule whose typing
215    /// caused the conflict to be detected; the first rule's typing
216    /// was already accepted by then).
217    InferenceConflict {
218        /// Head predicate name where the conflict was detected.
219        predicate: String,
220        /// 0-based column index where types disagree.
221        column: usize,
222        /// Rule index (within the predicate's group) that first
223        /// typed the column.
224        first_rule_index: usize,
225        /// Type derived from the first rule's body.
226        first_type: ScalarType,
227        /// Rule index (within the predicate's group) that
228        /// disagrees.
229        second_rule_index: usize,
230        /// Type derived from the conflicting rule's body.
231        second_type: ScalarType,
232    },
233}
234
235/// Evaluate `rule` against `relations` using `order` for variable
236/// binding. Returns the result rows (one inner [`Vec<RefValue>`]
237/// per row, with one element per term in the rule head) sorted
238/// lexicographically and deduplicated.
239///
240/// ## Eligibility gating
241///
242/// The rule is structurally analyzed via [`super::analyze`] before
243/// any evaluation work; if it is [`Eligibility::Ineligible`] the
244/// function returns [`RefEvalError::Ineligible`] carrying the
245/// boundary list. Callers wanting type-aware gating should run
246/// [`super::analyze_typed`] in addition and translate the verdict
247/// before calling this function — the evaluator itself does not
248/// take a type map (the supplied [`RefRelationStore`] is the
249/// authoritative type source for the values it operates on).
250///
251/// ## Determinism contract
252///
253/// Same `(rule, relations, order)` → same output. Output rows are
254/// sorted (lexicographic over [`RefValue`]'s [`Ord`] impl) and
255/// deduplicated. The variable order affects work done internally
256/// but NOT the result set (locked by a test).
257pub fn evaluate_rule(
258    rule: &Rule,
259    relations: &RefRelationStore,
260    order: &dyn VariableOrder,
261) -> Result<Vec<Vec<RefValue>>, RefEvalError> {
262    let hg = HypergraphRule::from_rule(rule);
263    match analyze(&hg, ExecutorContext::HashFallback) {
264        Eligibility::Eligible => {}
265        Eligibility::Ineligible(bs) => return Err(RefEvalError::Ineligible(bs)),
266    }
267
268    // Defensive: BodyIsExpr is one of the analyze() boundaries, so
269    // an Eligible rule cannot contain an IsExpr. If one slipped
270    // through, fail loudly.
271    if rule
272        .body
273        .iter()
274        .any(|l| matches!(l, BodyLiteral::IsExpr(_) | BodyLiteral::Univ(_)))
275    {
276        return Err(RefEvalError::IsExprNotSupported);
277    }
278
279    let positive_atoms: Vec<&Atom> = rule
280        .body
281        .iter()
282        .filter_map(|l| match l {
283            BodyLiteral::Positive(a) => Some(a),
284            _ => None,
285        })
286        .collect();
287
288    let comparisons: Vec<&Comparison> = rule
289        .body
290        .iter()
291        .filter_map(|l| match l {
292            BodyLiteral::Comparison(c) => Some(c),
293            _ => None,
294        })
295        .collect();
296
297    // Validate that every referenced relation exists and rebuild
298    // each atom's column-aware metadata once for downstream use.
299    // The evaluator is the WCOJ correctness oracle — fixture
300    // problems (missing relations, malformed rows, schema/value
301    // type drift) must surface here rather than silently corrupt
302    // downstream kernel comparisons.
303    let mut atom_specs: Vec<AtomSpec> = Vec::with_capacity(positive_atoms.len());
304    for (atom, edge) in positive_atoms.iter().zip(hg.hyperedges.iter()) {
305        let rel = relations
306            .get(&atom.predicate)
307            .ok_or_else(|| RefEvalError::MissingRelation(atom.predicate.clone()))?;
308        validate_relation_rows(&atom.predicate, rel)?;
309        let spec = AtomSpec::build(atom, edge, rel)?;
310        atom_specs.push(spec);
311    }
312
313    // Build per-vertex domain. A vertex's domain is the set of
314    // values it can possibly take, computed as the union over all
315    // its occurrences in the hyperedges. PR 2 takes the trivial
316    // simple-union (no intersection refinement); the recursive
317    // search below filters down to consistent assignments.
318    let domains: Vec<Vec<RefValue>> = build_domains(&hg, &atom_specs);
319
320    // Resolve the variable order into a [VertexId] sequence.
321    let var_order: Vec<VertexId> = order.order(&hg);
322    debug_assert_eq!(var_order.len(), hg.vertex_count());
323
324    // Recurse and project.
325    let mut binding: BTreeMap<VertexId, RefValue> = BTreeMap::new();
326    let mut results: Vec<Vec<RefValue>> = Vec::new();
327    enumerate(
328        &hg,
329        &atom_specs,
330        &comparisons,
331        &domains,
332        &var_order,
333        0,
334        &mut binding,
335        &rule.head,
336        &mut results,
337    )?;
338
339    results.sort();
340    results.dedup();
341    Ok(results)
342}
343
344/// Per-atom metadata derived once at evaluator entry: which arg
345/// positions match against constants vs vertices vs anonymous
346/// wildcards, plus the atom's relation reference.
347struct AtomSpec<'a> {
348    relation: &'a RefRelation,
349    /// Position descriptors, one per atom argument.
350    positions: Vec<PositionSpec>,
351}
352
353enum PositionSpec {
354    /// Atom argument is a variable bound to this vertex.
355    Vertex(VertexId),
356    /// Atom argument is a constant — already coerced against the
357    /// relation's column type at this position.
358    Constant(RefValue),
359    /// Atom argument is `Term::Anonymous` — matches anything.
360    Wildcard,
361}
362
363impl<'a> AtomSpec<'a> {
364    fn build(
365        atom: &Atom,
366        edge: &Hyperedge,
367        relation: &'a RefRelation,
368    ) -> Result<Self, RefEvalError> {
369        if atom.terms.len() != relation.schema.len() {
370            return Err(RefEvalError::RelationArityMismatch {
371                predicate: atom.predicate.clone(),
372                atom_arity: atom.terms.len(),
373                relation_arity: relation.schema.len(),
374            });
375        }
376        let mut positions = Vec::with_capacity(atom.terms.len());
377        for (i, term) in atom.terms.iter().enumerate() {
378            let p = match term {
379                Term::Variable(_) => {
380                    // Vertex assignment matches whatever the IR
381                    // built for this position.
382                    match edge.vertex_positions.get(i).and_then(|p| *p) {
383                        Some(vid) => PositionSpec::Vertex(vid),
384                        None => {
385                            // IR builder didn't tag a vertex here —
386                            // shouldn't happen for `Term::Variable`,
387                            // but treat as wildcard defensively.
388                            PositionSpec::Wildcard
389                        }
390                    }
391                }
392                Term::Anonymous => PositionSpec::Wildcard,
393                Term::Integer(n) => {
394                    let coerced =
395                        coerce_integer_constant(*n, relation.schema[i], &atom.predicate, i)?;
396                    PositionSpec::Constant(coerced)
397                }
398                Term::String(s) => match relation.schema[i] {
399                    ScalarType::Symbol => PositionSpec::Constant(RefValue::Symbol(s.clone())),
400                    other => {
401                        return Err(RefEvalError::ConstantTypeMismatch {
402                            predicate: atom.predicate.clone(),
403                            position: i,
404                            expected: other,
405                            got: format!("string {s:?}"),
406                        });
407                    }
408                },
409                Term::Symbol(_id) => {
410                    // Interned symbols are not used by PR 2 tests
411                    // (they construct via `Term::String` against a
412                    // Symbol column). Reject explicitly so a future
413                    // user sees a clear failure.
414                    return Err(RefEvalError::ConstantTypeMismatch {
415                        predicate: atom.predicate.clone(),
416                        position: i,
417                        expected: relation.schema[i],
418                        got: "interned Symbol(u32) — use Term::String against a Symbol column"
419                            .to_string(),
420                    });
421                }
422                Term::Float(f) => {
423                    return Err(RefEvalError::ConstantTypeMismatch {
424                        predicate: atom.predicate.clone(),
425                        position: i,
426                        expected: relation.schema[i],
427                        got: format!("float {f}"),
428                    });
429                }
430                Term::Aggregate(_) => {
431                    // Aggregates appear only in heads; eligibility
432                    // analyzer rejects head-aggregation rules. If we
433                    // see one in a body atom, it's a parser-level
434                    // surprise.
435                    return Err(RefEvalError::ConstantTypeMismatch {
436                        predicate: atom.predicate.clone(),
437                        position: i,
438                        expected: relation.schema[i],
439                        got: "aggregate in body atom".to_string(),
440                    });
441                }
442                Term::List(_) | Term::Cons { .. } | Term::Compound { .. } | Term::PredRef(_) => {
443                    return Err(RefEvalError::ConstantTypeMismatch {
444                        predicate: atom.predicate.clone(),
445                        position: i,
446                        expected: relation.schema[i],
447                        got: format!("unsupported v0.8.5 term in body atom: {term:?}"),
448                    });
449                }
450            };
451            positions.push(p);
452        }
453        Ok(AtomSpec {
454            relation,
455            positions,
456        })
457    }
458}
459
460fn coerce_integer_constant(
461    n: i64,
462    target: ScalarType,
463    predicate: &str,
464    position: usize,
465) -> Result<RefValue, RefEvalError> {
466    let mismatch = |got: String| RefEvalError::ConstantTypeMismatch {
467        predicate: predicate.to_string(),
468        position,
469        expected: target,
470        got,
471    };
472    match target {
473        ScalarType::U32 => {
474            if n < 0 || n > u32::MAX as i64 {
475                Err(mismatch(format!("integer {n} out of range for U32")))
476            } else {
477                Ok(RefValue::U32(n as u32))
478            }
479        }
480        ScalarType::U64 => {
481            if n < 0 {
482                Err(mismatch(format!("negative integer {n} for U64")))
483            } else {
484                Ok(RefValue::U64(n as u64))
485            }
486        }
487        ScalarType::I32 => {
488            if !(i32::MIN as i64..=i32::MAX as i64).contains(&n) {
489                Err(mismatch(format!("integer {n} out of range for I32")))
490            } else {
491                Ok(RefValue::I32(n as i32))
492            }
493        }
494        ScalarType::I64 => Ok(RefValue::I64(n)),
495        ScalarType::Bool | ScalarType::F32 | ScalarType::F64 | ScalarType::Symbol => {
496            Err(mismatch(format!("integer {n} for {target:?}")))
497        }
498    }
499}
500
501/// Validate that every row in `relation` has length equal to its
502/// schema's column count and that every cell's [`RefValue`] variant
503/// matches the schema's [`ScalarType`] at that column.
504///
505/// Returns the first encountered mismatch as a structured error.
506/// Called once per (predicate, atom) pair before evaluation; the
507/// per-atom redundancy is cheap and means the failure mentions the
508/// predicate the rule actually referenced.
509fn validate_relation_rows(predicate: &str, relation: &RefRelation) -> Result<(), RefEvalError> {
510    let schema_len = relation.schema.len();
511    for (row_index, row) in relation.rows.iter().enumerate() {
512        if row.len() != schema_len {
513            return Err(RefEvalError::RelationRowArityMismatch {
514                predicate: predicate.to_string(),
515                row_index,
516                row_len: row.len(),
517                schema_len,
518            });
519        }
520        for (column, value) in row.iter().enumerate() {
521            let expected = relation.schema[column];
522            if !ref_value_matches_scalar_type(value, expected) {
523                return Err(RefEvalError::RelationValueTypeMismatch {
524                    predicate: predicate.to_string(),
525                    row_index,
526                    column,
527                    expected,
528                    got: format!("{value:?}"),
529                });
530            }
531        }
532    }
533    Ok(())
534}
535
536/// True when `value`'s variant lines up with `expected`'s
537/// [`ScalarType`]. The `RefValue::Symbol` variant matches the
538/// `Symbol` schema type; integer / boolean variants match their
539/// same-name `ScalarType`s. `F32` / `F64` schemas have no
540/// matching `RefValue` variant (PR 2 deliberately excludes
541/// floating-point values from the evaluator), so any value
542/// against an `F*` schema column is a type mismatch.
543fn ref_value_matches_scalar_type(value: &RefValue, expected: ScalarType) -> bool {
544    matches!(
545        (value, expected),
546        (RefValue::U32(_), ScalarType::U32)
547            | (RefValue::U64(_), ScalarType::U64)
548            | (RefValue::I32(_), ScalarType::I32)
549            | (RefValue::I64(_), ScalarType::I64)
550            | (RefValue::Bool(_), ScalarType::Bool)
551            | (RefValue::Symbol(_), ScalarType::Symbol)
552    )
553}
554
555fn build_domains(hg: &HypergraphRule, atom_specs: &[AtomSpec<'_>]) -> Vec<Vec<RefValue>> {
556    let mut domains: Vec<Vec<RefValue>> = vec![Vec::new(); hg.vertex_count()];
557    for spec in atom_specs {
558        for (i, pos) in spec.positions.iter().enumerate() {
559            if let PositionSpec::Vertex(vid) = pos {
560                let VertexId(idx) = *vid;
561                for row in &spec.relation.rows {
562                    // Row length is guaranteed equal to schema_len
563                    // by `validate_relation_rows` upstream — direct
564                    // index is safe.
565                    domains[idx].push(row[i].clone());
566                }
567            }
568        }
569    }
570    for d in domains.iter_mut() {
571        d.sort();
572        d.dedup();
573    }
574    domains
575}
576
577#[allow(clippy::too_many_arguments)]
578fn enumerate(
579    hg: &HypergraphRule,
580    atom_specs: &[AtomSpec<'_>],
581    comparisons: &[&Comparison],
582    domains: &[Vec<RefValue>],
583    var_order: &[VertexId],
584    depth: usize,
585    binding: &mut BTreeMap<VertexId, RefValue>,
586    head: &Atom,
587    results: &mut Vec<Vec<RefValue>>,
588) -> Result<(), RefEvalError> {
589    if depth == var_order.len() {
590        // Full assignment. Verify every positive hyperedge has at
591        // least one matching row given the binding (and any
592        // constants / wildcards from the source atom).
593        for spec in atom_specs {
594            if !atom_has_matching_row(spec, binding) {
595                return Ok(());
596            }
597        }
598        // Apply comparisons.
599        for cmp in comparisons {
600            if !evaluate_comparison(cmp, binding, hg)? {
601                return Ok(());
602            }
603        }
604        // Project head terms.
605        let mut row = Vec::with_capacity(head.terms.len());
606        for term in &head.terms {
607            match term {
608                Term::Variable(name) => {
609                    let vid = match hg.vertices.iter().position(|v| &v.name == name) {
610                        Some(i) => VertexId(i),
611                        None => return Err(RefEvalError::UnboundHeadVariable(name.clone())),
612                    };
613                    let v = binding
614                        .get(&vid)
615                        .ok_or_else(|| RefEvalError::UnboundHeadVariable(name.clone()))?
616                        .clone();
617                    row.push(v);
618                }
619                Term::Integer(n) => row.push(RefValue::I64(*n)),
620                Term::String(s) => row.push(RefValue::Symbol(s.clone())),
621                Term::Anonymous
622                | Term::Symbol(_)
623                | Term::Float(_)
624                | Term::Aggregate(_)
625                | Term::List(_)
626                | Term::Cons { .. }
627                | Term::Compound { .. }
628                | Term::PredRef(_) => {
629                    // Heads with anonymous wildcards / interned
630                    // symbols / floats / aggregates are out of scope
631                    // for PR 2 — eligibility analyzer covers
632                    // aggregates; for the others a future PR widens
633                    // support.
634                    return Err(RefEvalError::UnboundHeadVariable(format!(
635                        "unsupported head term: {term:?}"
636                    )));
637                }
638            }
639        }
640        results.push(row);
641        return Ok(());
642    }
643    let next_vid = var_order[depth];
644    let VertexId(idx) = next_vid;
645    for value in &domains[idx] {
646        binding.insert(next_vid, value.clone());
647        enumerate(
648            hg,
649            atom_specs,
650            comparisons,
651            domains,
652            var_order,
653            depth + 1,
654            binding,
655            head,
656            results,
657        )?;
658        binding.remove(&next_vid);
659    }
660    Ok(())
661}
662
663fn atom_has_matching_row(spec: &AtomSpec<'_>, binding: &BTreeMap<VertexId, RefValue>) -> bool {
664    'rows: for row in &spec.relation.rows {
665        // Row length is guaranteed equal to spec.positions.len() by
666        // `validate_relation_rows` upstream — direct indexing is safe.
667        for (i, pos) in spec.positions.iter().enumerate() {
668            let row_v = &row[i];
669            match pos {
670                PositionSpec::Vertex(vid) => {
671                    let bound = match binding.get(vid) {
672                        Some(b) => b,
673                        None => return false, // unbound at full assignment is a bug
674                    };
675                    if bound != row_v {
676                        continue 'rows;
677                    }
678                }
679                PositionSpec::Constant(c) => {
680                    if c != row_v {
681                        continue 'rows;
682                    }
683                }
684                PositionSpec::Wildcard => {
685                    // Any value matches — no constraint.
686                }
687            }
688        }
689        return true;
690    }
691    false
692}
693
694fn evaluate_comparison(
695    cmp: &Comparison,
696    binding: &BTreeMap<VertexId, RefValue>,
697    hg: &HypergraphRule,
698) -> Result<bool, RefEvalError> {
699    let lhs = resolve_term_for_comparison(&cmp.left, binding, hg)?;
700    let rhs = resolve_term_for_comparison(&cmp.right, binding, hg)?;
701    apply_comparison_op(&lhs, &rhs, cmp.op)
702}
703
704fn resolve_term_for_comparison(
705    term: &Term,
706    binding: &BTreeMap<VertexId, RefValue>,
707    hg: &HypergraphRule,
708) -> Result<RefValue, RefEvalError> {
709    match term {
710        Term::Variable(name) => {
711            let vid = hg
712                .vertices
713                .iter()
714                .position(|v| &v.name == name)
715                .map(VertexId)
716                .ok_or_else(|| RefEvalError::UnboundHeadVariable(name.clone()))?;
717            binding
718                .get(&vid)
719                .cloned()
720                .ok_or_else(|| RefEvalError::UnboundHeadVariable(name.clone()))
721        }
722        Term::Integer(n) => Ok(RefValue::I64(*n)),
723        Term::String(s) => Ok(RefValue::Symbol(s.clone())),
724        Term::Anonymous
725        | Term::Symbol(_)
726        | Term::Float(_)
727        | Term::Aggregate(_)
728        | Term::List(_)
729        | Term::Cons { .. }
730        | Term::Compound { .. }
731        | Term::PredRef(_) => Err(RefEvalError::ComparisonTypeMismatch {
732            left: format!("{term:?}"),
733            right: "<n/a>".to_string(),
734            op: CompOp::Eq,
735        }),
736    }
737}
738
739fn apply_comparison_op(lhs: &RefValue, rhs: &RefValue, op: CompOp) -> Result<bool, RefEvalError> {
740    // Numeric promotion to i128 for cross-type comparisons among
741    // the integer kinds. Same-kind comparisons fall through directly.
742    let to_i128 = |v: &RefValue| -> Option<i128> {
743        match v {
744            RefValue::U32(n) => Some(*n as i128),
745            RefValue::U64(n) => Some(*n as i128),
746            RefValue::I32(n) => Some(*n as i128),
747            RefValue::I64(n) => Some(*n as i128),
748            _ => None,
749        }
750    };
751    let cmp_result = match (lhs, rhs) {
752        (RefValue::Symbol(a), RefValue::Symbol(b)) => match op {
753            CompOp::Eq => Some(a == b),
754            CompOp::Ne => Some(a != b),
755            CompOp::Lt => Some(a < b),
756            CompOp::Le => Some(a <= b),
757            CompOp::Gt => Some(a > b),
758            CompOp::Ge => Some(a >= b),
759        },
760        (RefValue::Bool(a), RefValue::Bool(b)) => match op {
761            CompOp::Eq => Some(a == b),
762            CompOp::Ne => Some(a != b),
763            CompOp::Lt => Some(!a & b),
764            CompOp::Le => Some(*a <= *b),
765            CompOp::Gt => Some(*a & !b),
766            CompOp::Ge => Some(*a >= *b),
767        },
768        _ => match (to_i128(lhs), to_i128(rhs)) {
769            (Some(l), Some(r)) => Some(match op {
770                CompOp::Eq => l == r,
771                CompOp::Ne => l != r,
772                CompOp::Lt => l < r,
773                CompOp::Le => l <= r,
774                CompOp::Gt => l > r,
775                CompOp::Ge => l >= r,
776            }),
777            _ => None,
778        },
779    };
780    cmp_result.ok_or_else(|| RefEvalError::ComparisonTypeMismatch {
781        left: format!("{lhs:?}"),
782        right: format!("{rhs:?}"),
783        op,
784    })
785}