Skip to main content

xlog_logic/hypergraph/
scc.rs

1//! Multi-predicate SCC fixpoint evaluator.
2//!
3//! Extends the single-target [`super::evaluate_fixpoint`] to a
4//! mutually-recursive SCC: rules grouped by their target predicate,
5//! evaluated jointly so each predicate's body can reference any
6//! other predicate in the SCC. Naive evaluation: one iteration runs
7//! every rule for every predicate; convergence is the global
8//! "no relation grew this iteration" check.
9//!
10//! Pure-Rust, deterministic, set-semantics. Built to be the
11//! recursive-multi-predicate WCOJ correctness oracle for PR 5+
12//! mixed-execution kernels. Not optimized — semi-naive
13//! delta-driven SCC fixpoint is a separate concern.
14//!
15//! ## Determinism
16//!
17//! * Rules grouped under each predicate are evaluated in input
18//!   order (slice order). Rule order does NOT affect the result
19//!   (locked by test) — it's an efficiency knob, not a semantic
20//!   one.
21//! * Predicates are iterated in [`BTreeMap`]'s sorted-by-key
22//!   order. Predicate order does NOT affect the result (locked
23//!   by test).
24//! * Output rows for each predicate are sorted lexicographically
25//!   and deduplicated.
26//!
27//! ## Schema management
28//!
29//! Per-predicate schemas are frozen from the first iteration
30//! that produces non-empty rows for that predicate. Subsequent
31//! iterations validate every newly-produced row's variants against
32//! the frozen schema; mismatches surface as
33//! [`SccFixpointError::InconsistentHeadValueTypes`] before the
34//! row is unioned. Row arity drift surfaces as
35//! [`SccFixpointError::HeadArityMismatch`] at function entry —
36//! checked once per predicate group across its rules.
37
38use super::{
39    evaluate_rule, FixpointConfig, RefEvalError, RefRelation, RefRelationStore, RefValue,
40    VariableOrder,
41};
42use crate::ast::Rule;
43use std::collections::BTreeMap;
44use xlog_core::ScalarType;
45
46/// Errors surfaced by [`evaluate_scc_fixpoint`].
47#[derive(Debug, Clone, PartialEq)]
48pub enum SccFixpointError {
49    /// A rule grouped under predicate `key` heads a different
50    /// predicate. The grouping invariant — every rule's head
51    /// predicate equals its `BTreeMap` key — is checked at
52    /// function entry.
53    RuleHeadPredicateMismatch {
54        /// `BTreeMap` key under which the rule was grouped.
55        group_key: String,
56        /// Index of the rule within that group.
57        rule_index: usize,
58        /// Head predicate observed on the rule.
59        observed: String,
60    },
61    /// Two rules grouped under the same predicate disagree on
62    /// head arity.
63    HeadArityMismatch {
64        /// Predicate name.
65        predicate: String,
66        /// Index of the offending rule within its group.
67        rule_index: usize,
68        /// Head arity observed on this rule.
69        observed_arity: usize,
70        /// Head arity established by the first non-empty-head
71        /// rule in the same group.
72        expected_arity: usize,
73    },
74    /// A predicate's rules produced rows whose [`RefValue`]
75    /// variants disagree across iterations or across rules
76    /// within an iteration. Detected by validating each newly
77    /// produced row's variant tuple against the predicate's
78    /// frozen schema before unioning.
79    InconsistentHeadValueTypes {
80        /// Predicate name.
81        predicate: String,
82        /// Column index where the mismatch was first observed.
83        column: usize,
84        /// Schema-frozen scalar type at that column.
85        expected: ScalarType,
86        /// String description of the offending value.
87        got: String,
88    },
89    /// `target_predicate` was already present in `base_relations`.
90    /// SCC predicates are constructed by the fixpoint; allowing
91    /// `base_relations` to seed any of them would silently shadow
92    /// the caller's seed.
93    PredicateInBaseRelations {
94        /// The SCC predicate name as supplied.
95        name: String,
96    },
97    /// A rule failed evaluation. Wraps the per-rule error with
98    /// (predicate, rule_index) so the caller can pinpoint which
99    /// rule of which group failed.
100    RuleEval {
101        /// Predicate group of the offending rule.
102        predicate: String,
103        /// Index within that group.
104        rule_index: usize,
105        /// The wrapped per-rule error.
106        source: RefEvalError,
107    },
108    /// The SCC fixpoint did not converge within
109    /// [`FixpointConfig::max_iterations`].
110    MaxIterationsExceeded {
111        /// The configured cap.
112        limit: usize,
113        /// Number of predicates in the SCC.
114        predicate_count: usize,
115        /// Total derived rows summed across all SCC predicates
116        /// at the cap.
117        total_observed_rows: usize,
118    },
119    /// At least one predicate had no rules with a non-empty head,
120    /// so its arity could not be inferred.
121    SchemaIndeterminable {
122        /// Predicate name whose arity could not be inferred.
123        predicate: String,
124    },
125    /// `max_iterations` was zero. Must be ≥ 1.
126    InvalidMaxIterations,
127}
128
129/// Evaluate a mutually-recursive SCC of predicates to a fixpoint.
130///
131/// `rules` maps each predicate name to the list of rules deriving
132/// it. Every rule's head predicate must equal its group key
133/// (validated at entry). `base_relations` carries non-SCC
134/// predicates referenced in rule bodies (e.g. EDB facts). The
135/// SCC predicates must NOT appear in `base_relations`.
136///
137/// Returns a [`RefRelationStore`] whose keys are exactly the
138/// keys of `rules`, each mapped to the converged relation. Set
139/// semantics: rows sorted lexicographically, deduplicated.
140#[expect(
141    clippy::result_large_err,
142    reason = "the public typed diagnostic preserves its structured rule-evaluation source without a second heap allocation"
143)]
144pub fn evaluate_scc_fixpoint(
145    rules: &BTreeMap<String, Vec<Rule>>,
146    base_relations: &RefRelationStore,
147    order: &dyn VariableOrder,
148    config: &FixpointConfig,
149) -> Result<RefRelationStore, SccFixpointError> {
150    if config.max_iterations == 0 {
151        return Err(SccFixpointError::InvalidMaxIterations);
152    }
153
154    // Entry validation: per-predicate group invariants.
155    let mut arities: BTreeMap<String, usize> = BTreeMap::new();
156    for (predicate, group) in rules.iter() {
157        if base_relations.contains_key(predicate) {
158            return Err(SccFixpointError::PredicateInBaseRelations {
159                name: predicate.clone(),
160            });
161        }
162        for (idx, rule) in group.iter().enumerate() {
163            if rule.head.predicate != *predicate {
164                return Err(SccFixpointError::RuleHeadPredicateMismatch {
165                    group_key: predicate.clone(),
166                    rule_index: idx,
167                    observed: rule.head.predicate.clone(),
168                });
169            }
170        }
171        // Establish arity from the first non-empty-head rule.
172        let arity = group
173            .iter()
174            .find(|r| !r.head.terms.is_empty())
175            .map(|r| r.head.terms.len())
176            .ok_or_else(|| SccFixpointError::SchemaIndeterminable {
177                predicate: predicate.clone(),
178            })?;
179        for (idx, rule) in group.iter().enumerate() {
180            if rule.head.terms.is_empty() {
181                continue;
182            }
183            if rule.head.terms.len() != arity {
184                return Err(SccFixpointError::HeadArityMismatch {
185                    predicate: predicate.clone(),
186                    rule_index: idx,
187                    observed_arity: rule.head.terms.len(),
188                    expected_arity: arity,
189                });
190            }
191        }
192        arities.insert(predicate.clone(), arity);
193    }
194
195    // Per-predicate frozen schema and derived rows. Schemas seed
196    // as `[U32; arity]` (matches PR 3); first non-empty iter
197    // freezes from row variants.
198    let mut frozen_schemas: BTreeMap<String, Option<Vec<ScalarType>>> = BTreeMap::new();
199    let mut derived: BTreeMap<String, Vec<Vec<RefValue>>> = BTreeMap::new();
200    for predicate in rules.keys() {
201        frozen_schemas.insert(predicate.clone(), None);
202        derived.insert(predicate.clone(), Vec::new());
203    }
204
205    for _iter in 0..config.max_iterations {
206        // Build the per-iter store: base ∪ {predicate → derived}.
207        let mut store = base_relations.clone();
208        for (predicate, rows) in derived.iter() {
209            let schema = frozen_schemas
210                .get(predicate)
211                .and_then(|s| s.clone())
212                .unwrap_or_else(|| vec![ScalarType::U32; arities[predicate]]);
213            store.insert(
214                predicate.clone(),
215                RefRelation {
216                    schema,
217                    rows: rows.clone(),
218                },
219            );
220        }
221
222        // For every predicate in sorted-key order, run every rule
223        // and union new tuples into a per-predicate scratch buffer.
224        let mut next: BTreeMap<String, Vec<Vec<RefValue>>> = derived.clone();
225        for (predicate, group) in rules.iter() {
226            let mut produced: Vec<Vec<RefValue>> = Vec::new();
227            for (rule_index, rule) in group.iter().enumerate() {
228                let rows =
229                    evaluate_rule(rule, &store, order).map_err(|e| SccFixpointError::RuleEval {
230                        predicate: predicate.clone(),
231                        rule_index,
232                        source: e,
233                    })?;
234                produced.extend(rows);
235            }
236            // Freeze the schema from the first iteration that
237            // produces non-empty rows for THIS predicate; once
238            // frozen, validate every newly-produced row's
239            // variant tuple.
240            let frozen_entry = frozen_schemas.get_mut(predicate).expect("inserted above");
241            if frozen_entry.is_none() {
242                if let Some(first) = produced.first() {
243                    *frozen_entry = Some(infer_schema(first));
244                }
245            }
246            if let Some(schema) = frozen_entry.as_ref() {
247                for row in &produced {
248                    if let Some((column, expected, got)) = first_type_mismatch(row, schema) {
249                        return Err(SccFixpointError::InconsistentHeadValueTypes {
250                            predicate: predicate.clone(),
251                            column,
252                            expected,
253                            got,
254                        });
255                    }
256                }
257            }
258            let target = next.get_mut(predicate).expect("predicate present");
259            target.extend(produced);
260            target.sort();
261            target.dedup();
262        }
263
264        if next == derived {
265            // Converged. Build the final RefRelationStore.
266            let mut out: RefRelationStore = BTreeMap::new();
267            for (predicate, rows) in derived.into_iter() {
268                let schema = frozen_schemas
269                    .get(&predicate)
270                    .and_then(|s| s.clone())
271                    .unwrap_or_else(|| vec![ScalarType::U32; arities[&predicate]]);
272                out.insert(predicate, RefRelation { schema, rows });
273            }
274            return Ok(out);
275        }
276        derived = next;
277    }
278
279    let total: usize = derived.values().map(|v| v.len()).sum();
280    Err(SccFixpointError::MaxIterationsExceeded {
281        limit: config.max_iterations,
282        predicate_count: rules.len(),
283        total_observed_rows: total,
284    })
285}
286
287fn infer_schema(row: &[RefValue]) -> Vec<ScalarType> {
288    row.iter()
289        .map(|v| match v {
290            RefValue::U32(_) => ScalarType::U32,
291            RefValue::U64(_) => ScalarType::U64,
292            RefValue::I32(_) => ScalarType::I32,
293            RefValue::I64(_) => ScalarType::I64,
294            RefValue::Bool(_) => ScalarType::Bool,
295            RefValue::Symbol(_) => ScalarType::Symbol,
296        })
297        .collect()
298}
299
300/// Return the first column where `row`'s [`RefValue`] variant does
301/// not match `schema`'s [`ScalarType`]. Mirrors PR 2's row-level
302/// `ref_value_matches_scalar_type`, applied at row produced (not
303/// row stored) time so caller-facing errors point at rule output,
304/// not at downstream relation validation.
305///
306/// Row arity is enforced upstream by [`SccFixpointError::HeadArityMismatch`]
307/// at function entry; if a row of mismatched length somehow escapes
308/// that check, it surfaces downstream as
309/// [`RefEvalError::RelationRowArityMismatch`] from PR 2's validation
310/// — which is honest about what it found. We do NOT add a synthetic
311/// arity-mismatch arm here with a placeholder `ScalarType` (that
312/// pattern was the silent-skip bug class PR 2's validator made us
313/// fix in `RefEvalError::ConstantTypeMismatch`).
314fn first_type_mismatch(
315    row: &[RefValue],
316    schema: &[ScalarType],
317) -> Option<(usize, ScalarType, String)> {
318    for (i, (val, ty)) in row.iter().zip(schema.iter()).enumerate() {
319        let ok = matches!(
320            (val, ty),
321            (RefValue::U32(_), ScalarType::U32)
322                | (RefValue::U64(_), ScalarType::U64)
323                | (RefValue::I32(_), ScalarType::I32)
324                | (RefValue::I64(_), ScalarType::I64)
325                | (RefValue::Bool(_), ScalarType::Bool)
326                | (RefValue::Symbol(_), ScalarType::Symbol)
327        );
328        if !ok {
329            return Some((i, *ty, format!("{val:?}")));
330        }
331    }
332    None
333}