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}