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}