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#[expect(
578 clippy::too_many_arguments,
579 reason = "the recursive reference evaluator must carry the immutable rule inputs and mutable traversal state explicitly"
580)]
581fn enumerate(
582 hg: &HypergraphRule,
583 atom_specs: &[AtomSpec<'_>],
584 comparisons: &[&Comparison],
585 domains: &[Vec<RefValue>],
586 var_order: &[VertexId],
587 depth: usize,
588 binding: &mut BTreeMap<VertexId, RefValue>,
589 head: &Atom,
590 results: &mut Vec<Vec<RefValue>>,
591) -> Result<(), RefEvalError> {
592 if depth == var_order.len() {
593 // Full assignment. Verify every positive hyperedge has at
594 // least one matching row given the binding (and any
595 // constants / wildcards from the source atom).
596 for spec in atom_specs {
597 if !atom_has_matching_row(spec, binding) {
598 return Ok(());
599 }
600 }
601 // Apply comparisons.
602 for cmp in comparisons {
603 if !evaluate_comparison(cmp, binding, hg)? {
604 return Ok(());
605 }
606 }
607 // Project head terms.
608 let mut row = Vec::with_capacity(head.terms.len());
609 for term in &head.terms {
610 match term {
611 Term::Variable(name) => {
612 let vid = match hg.vertices.iter().position(|v| &v.name == name) {
613 Some(i) => VertexId(i),
614 None => return Err(RefEvalError::UnboundHeadVariable(name.clone())),
615 };
616 let v = binding
617 .get(&vid)
618 .ok_or_else(|| RefEvalError::UnboundHeadVariable(name.clone()))?
619 .clone();
620 row.push(v);
621 }
622 Term::Integer(n) => row.push(RefValue::I64(*n)),
623 Term::String(s) => row.push(RefValue::Symbol(s.clone())),
624 Term::Anonymous
625 | Term::Symbol(_)
626 | Term::Float(_)
627 | Term::Aggregate(_)
628 | Term::List(_)
629 | Term::Cons { .. }
630 | Term::Compound { .. }
631 | Term::PredRef(_) => {
632 // Heads with anonymous wildcards / interned
633 // symbols / floats / aggregates are out of scope
634 // for PR 2 — eligibility analyzer covers
635 // aggregates; for the others a future PR widens
636 // support.
637 return Err(RefEvalError::UnboundHeadVariable(format!(
638 "unsupported head term: {term:?}"
639 )));
640 }
641 }
642 }
643 results.push(row);
644 return Ok(());
645 }
646 let next_vid = var_order[depth];
647 let VertexId(idx) = next_vid;
648 for value in &domains[idx] {
649 binding.insert(next_vid, value.clone());
650 enumerate(
651 hg,
652 atom_specs,
653 comparisons,
654 domains,
655 var_order,
656 depth + 1,
657 binding,
658 head,
659 results,
660 )?;
661 binding.remove(&next_vid);
662 }
663 Ok(())
664}
665
666fn atom_has_matching_row(spec: &AtomSpec<'_>, binding: &BTreeMap<VertexId, RefValue>) -> bool {
667 'rows: for row in &spec.relation.rows {
668 // Row length is guaranteed equal to spec.positions.len() by
669 // `validate_relation_rows` upstream — direct indexing is safe.
670 for (i, pos) in spec.positions.iter().enumerate() {
671 let row_v = &row[i];
672 match pos {
673 PositionSpec::Vertex(vid) => {
674 let bound = match binding.get(vid) {
675 Some(b) => b,
676 None => return false, // unbound at full assignment is a bug
677 };
678 if bound != row_v {
679 continue 'rows;
680 }
681 }
682 PositionSpec::Constant(c) => {
683 if c != row_v {
684 continue 'rows;
685 }
686 }
687 PositionSpec::Wildcard => {
688 // Any value matches — no constraint.
689 }
690 }
691 }
692 return true;
693 }
694 false
695}
696
697fn evaluate_comparison(
698 cmp: &Comparison,
699 binding: &BTreeMap<VertexId, RefValue>,
700 hg: &HypergraphRule,
701) -> Result<bool, RefEvalError> {
702 let lhs = resolve_term_for_comparison(&cmp.left, binding, hg)?;
703 let rhs = resolve_term_for_comparison(&cmp.right, binding, hg)?;
704 apply_comparison_op(&lhs, &rhs, cmp.op)
705}
706
707fn resolve_term_for_comparison(
708 term: &Term,
709 binding: &BTreeMap<VertexId, RefValue>,
710 hg: &HypergraphRule,
711) -> Result<RefValue, RefEvalError> {
712 match term {
713 Term::Variable(name) => {
714 let vid = hg
715 .vertices
716 .iter()
717 .position(|v| &v.name == name)
718 .map(VertexId)
719 .ok_or_else(|| RefEvalError::UnboundHeadVariable(name.clone()))?;
720 binding
721 .get(&vid)
722 .cloned()
723 .ok_or_else(|| RefEvalError::UnboundHeadVariable(name.clone()))
724 }
725 Term::Integer(n) => Ok(RefValue::I64(*n)),
726 Term::String(s) => Ok(RefValue::Symbol(s.clone())),
727 Term::Anonymous
728 | Term::Symbol(_)
729 | Term::Float(_)
730 | Term::Aggregate(_)
731 | Term::List(_)
732 | Term::Cons { .. }
733 | Term::Compound { .. }
734 | Term::PredRef(_) => Err(RefEvalError::ComparisonTypeMismatch {
735 left: format!("{term:?}"),
736 right: "<n/a>".to_string(),
737 op: CompOp::Eq,
738 }),
739 }
740}
741
742fn apply_comparison_op(lhs: &RefValue, rhs: &RefValue, op: CompOp) -> Result<bool, RefEvalError> {
743 // Numeric promotion to i128 for cross-type comparisons among
744 // the integer kinds. Same-kind comparisons fall through directly.
745 let to_i128 = |v: &RefValue| -> Option<i128> {
746 match v {
747 RefValue::U32(n) => Some(*n as i128),
748 RefValue::U64(n) => Some(*n as i128),
749 RefValue::I32(n) => Some(*n as i128),
750 RefValue::I64(n) => Some(*n as i128),
751 _ => None,
752 }
753 };
754 let cmp_result = match (lhs, rhs) {
755 (RefValue::Symbol(a), RefValue::Symbol(b)) => match op {
756 CompOp::Eq => Some(a == b),
757 CompOp::Ne => Some(a != b),
758 CompOp::Lt => Some(a < b),
759 CompOp::Le => Some(a <= b),
760 CompOp::Gt => Some(a > b),
761 CompOp::Ge => Some(a >= b),
762 },
763 (RefValue::Bool(a), RefValue::Bool(b)) => match op {
764 CompOp::Eq => Some(a == b),
765 CompOp::Ne => Some(a != b),
766 CompOp::Lt => Some(!a & b),
767 CompOp::Le => Some(*a <= *b),
768 CompOp::Gt => Some(*a & !b),
769 CompOp::Ge => Some(*a >= *b),
770 },
771 _ => match (to_i128(lhs), to_i128(rhs)) {
772 (Some(l), Some(r)) => Some(match op {
773 CompOp::Eq => l == r,
774 CompOp::Ne => l != r,
775 CompOp::Lt => l < r,
776 CompOp::Le => l <= r,
777 CompOp::Gt => l > r,
778 CompOp::Ge => l >= r,
779 }),
780 _ => None,
781 },
782 };
783 cmp_result.ok_or_else(|| RefEvalError::ComparisonTypeMismatch {
784 left: format!("{lhs:?}"),
785 right: format!("{rhs:?}"),
786 op,
787 })
788}