Skip to main content

xlog_logic/hypergraph/
inference.rs

1//! Transitive type inference across SCC predicates.
2//!
3//! When a join-key vertex is anchored only through strongly connected
4//! component (SCC) recursion, the typed gate cannot obtain its type
5//! from a base relation directly. This module propagates
6//! types through the rule graph — body atoms type variables, head
7//! atoms back-propagate to head-predicate columns, iterate to
8//! fixpoint — so the typed gate has full type information when it
9//! consults [`super::analyze_typed`].
10//!
11//! ## Where inference is engaged
12//!
13//! Only the **group-aware** typed entry points engage inference:
14//!
15//! * [`super::evaluate_scc_fixpoint_typed`] runs inference once at
16//!   entry, then types each rule's body using the inferred schemas
17//!   plus `base_relations`.
18//! * [`super::evaluate_fixpoint_typed`] treats `target_predicate`
19//!   as a single-element rule group and runs the same inference.
20//!
21//! The single-rule entry points retain the base-only typing policy
22//! because they have no SCC structure to propagate over:
23//!
24//! * [`super::evaluate_rule_typed`] takes one rule.
25//! * [`super::plan_rule`] / [`super::plan_rules`] plan per-rule.
26//!
27//! Callers that want SCC-aware planning should drive
28//! [`super::evaluate_scc_fixpoint_typed`] directly or build their
29//! own inference pass via [`infer_scc_predicate_schemas`].
30//!
31//! ## Conflict layering
32//!
33//! Inference detects only **back-propagation conflicts**: e.g.,
34//! predicate `p`'s column 0 is `U32` from rule A's head and
35//! `Symbol` from rule B's head → [`InferenceError::ConflictingPredicateColumnType`].
36//! Within-rule body conflicts (variable `X` typed `U32` in one body
37//! atom and `Symbol` in another) stay in the existing
38//! [`super::typed`] flow and surface as
39//! [`super::RefEvalError::ConflictingVariableType`]. Each conflict
40//! type is detected at exactly one layer.
41//!
42//! ## Cyclic-only predicates
43//!
44//! When an SCC has no base anchor anywhere (e.g., `a(X) :- b(X),
45//! b(X) :- a(X)` with no rule referencing `base_relations`), every
46//! column converges to `None`. The typed gate must NOT reject such
47//! rules: the policy narrows from "unknown ≠ unsupported" to
48//! "unknowable-after-inference ≠ unsupported." Locked by
49//! `cyclic_only_predicate_still_passes_typed_gate_locked_policy`.
50//!
51//! ## Strict-correctness behavior change
52//!
53//! Fixtures whose base-relation schemas disagreed but whose actual
54//! rows happened to agree at runtime were previously silent (the
55//! typed gate types each body atom independently). They now surface
56//! as [`InferenceError::ConflictingPredicateColumnType`] when
57//! back-propagating to a head predicate. That is a strict
58//! correctness win, not a regression — fixtures with internally
59//! contradictory schemas are now caught before evaluation rather
60//! than silently corrupting downstream comparisons.
61
62use super::reference::RefRelationStore;
63use crate::ast::{BodyLiteral, Rule, Term};
64use std::collections::BTreeMap;
65use xlog_core::ScalarType;
66
67/// Errors surfaced by [`infer_scc_predicate_schemas`].
68#[derive(Debug, Clone, PartialEq)]
69pub enum InferenceError {
70    /// Two rules contributing to the same head predicate disagree
71    /// on the type of the same column. The first rule that types
72    /// the column wins `first_*`; the rule that disagrees wins
73    /// `second_*`.
74    ConflictingPredicateColumnType {
75        /// Head predicate name where the conflict was detected.
76        predicate: String,
77        /// 0-based column index where types disagree.
78        column: usize,
79        /// Rule index (within the predicate's rule group) that
80        /// first typed the column.
81        first_rule_index: usize,
82        /// Type derived from the first rule's body for the head
83        /// variable at this column.
84        first_type: ScalarType,
85        /// Rule index (within the predicate's rule group) whose
86        /// derivation conflicts.
87        second_rule_index: usize,
88        /// Type derived from the conflicting rule's body for the
89        /// head variable at this column.
90        second_type: ScalarType,
91    },
92}
93
94/// Per-predicate inferred schema. `Vec` length equals the head
95/// arity; each element is `Some(t)` if inference established the
96/// column's type, or `None` if the column remains unknowable
97/// (e.g., cyclic-only predicate, or a head term whose body atoms
98/// don't type the corresponding variable).
99pub type InferredSchemas = BTreeMap<String, Vec<Option<ScalarType>>>;
100
101/// Infer per-predicate schemas for a rule group via constraint
102/// propagation through the rule graph.
103///
104/// Algorithm:
105///
106/// 1. Determine head arity per predicate from the first rule with
107///    a non-empty head. (Predicates whose every rule has an empty
108///    head are treated as 0-arity; in practice this is rare.)
109/// 2. Initialize each predicate's schema as `vec![None; arity]`.
110/// 3. Iterate: for each rule, compute a per-rule variable-to-type
111///    map by walking body atoms (typing vars from
112///    `base_relations` schemas first, then from currently-inferred
113///    SCC predicate schemas where columns are `Some`). Then
114///    back-propagate: for each `Term::Variable` in the head at
115///    column `i`, if the variable has a derived type, propose it
116///    as the type for `head_predicate.schema[i]`. Conflict if a
117///    column has been previously typed differently.
118/// 4. Stop when no schema column changes between iterations.
119///
120/// Within-rule body conflicts are NOT detected here; they are
121/// caught by the existing [`super::typed`] gate during its own
122/// per-rule type-derivation walk. See module docs for the
123/// conflict-layering split.
124pub fn infer_scc_predicate_schemas(
125    rules: &BTreeMap<String, Vec<Rule>>,
126    base_relations: &RefRelationStore,
127) -> Result<InferredSchemas, InferenceError> {
128    // Step 1+2: arity + initial schemas.
129    let mut schemas: InferredSchemas = BTreeMap::new();
130    for (predicate, group) in rules.iter() {
131        let arity = group
132            .iter()
133            .find(|r| !r.head.terms.is_empty())
134            .map(|r| r.head.terms.len())
135            .unwrap_or(0);
136        schemas.insert(predicate.clone(), vec![None; arity]);
137    }
138    // Track the rule index that first typed each column so the
139    // conflict report can name both contributors.
140    let mut origins: BTreeMap<(String, usize), usize> = BTreeMap::new();
141    // Inference is monotonic: every iteration that changes
142    // anything replaces a `None` with a `Some(_)`. The total
143    // number of column slots across all SCC predicates is the
144    // strict upper bound on iterations that produce change. We
145    // add 1 to allow for the final no-change iteration that
146    // detects convergence.
147    let total_columns: usize = schemas.values().map(|s| s.len()).sum();
148    let max_iterations = total_columns + 1;
149    let mut converged = false;
150    for _ in 0..max_iterations {
151        let mut changed = false;
152        for (predicate, group) in rules.iter() {
153            for (rule_index, rule) in group.iter().enumerate() {
154                // Back-propagate from head terms to head-predicate
155                // columns.
156                for (col, term) in rule.head.terms.iter().enumerate() {
157                    let name = match term {
158                        Term::Variable(n) => n,
159                        // Head constants / aggregates / wildcards do
160                        // not constrain a column type via inference.
161                        // Their type would be locked by the value
162                        // itself at evaluation time.
163                        _ => continue,
164                    };
165                    let Some(derived) =
166                        rule.inferred_head_variable_type(name, |atom, index, negated| {
167                            if negated {
168                                return None;
169                            }
170                            base_relations
171                                .get(&atom.predicate)
172                                .and_then(|relation| relation.schema.get(index).copied())
173                                .or_else(|| {
174                                    schemas
175                                        .get(&atom.predicate)
176                                        .and_then(|schema| schema.get(index).copied().flatten())
177                                })
178                        })
179                    else {
180                        continue;
181                    };
182                    let schema = schemas
183                        .get_mut(predicate)
184                        .expect("predicate in initialized schemas");
185                    if col >= schema.len() {
186                        // Head arity drift across rules — let the
187                        // structural SCC fixpoint surface this as
188                        // HeadArityMismatch. Inference doesn't
189                        // pre-empt; just skip this column.
190                        continue;
191                    }
192                    match schema[col] {
193                        None => {
194                            schema[col] = Some(derived);
195                            origins.insert((predicate.clone(), col), rule_index);
196                            changed = true;
197                        }
198                        Some(existing) if existing == derived => {
199                            // Agreement — silent.
200                        }
201                        Some(existing) => {
202                            let first_rule_index =
203                                origins.get(&(predicate.clone(), col)).copied().unwrap_or(0);
204                            return Err(InferenceError::ConflictingPredicateColumnType {
205                                predicate: predicate.clone(),
206                                column: col,
207                                first_rule_index,
208                                first_type: existing,
209                                second_rule_index: rule_index,
210                                second_type: derived,
211                            });
212                        }
213                    }
214                }
215            }
216        }
217        if !changed {
218            converged = true;
219            break;
220        }
221    }
222    // Monotonic invariant: every iteration that changed something
223    // replaced a None with a Some(_). The bound `total_columns + 1`
224    // strictly exceeds the number of such iterations possible, so
225    // failing to converge here indicates a future code change has
226    // broken the monotonicity guarantee — a programmer error, not
227    // a data error.
228    debug_assert!(
229        converged,
230        "type inference failed to converge within {max_iterations} iterations \
231         (monotonicity invariant violated)"
232    );
233    Ok(schemas)
234}
235
236/// Build the typed-gate input map for a single rule using
237/// inferred SCC schemas alongside base relations.
238///
239/// Mirrors [`super::typed::derive_vertex_types`]'s contract — same
240/// conflict surface ([`super::RefEvalError::ConflictingVariableType`])
241/// — but consults `inferred_schemas` whenever a body atom's
242/// predicate is not in `base_relations`. Inferred columns marked
243/// `None` are treated identically to "predicate absent": they
244/// don't type the variable at that position.
245///
246/// Used by [`super::evaluate_scc_fixpoint_typed`] and
247/// [`super::evaluate_fixpoint_typed`] inside their per-rule typed
248/// gate to give [`super::analyze_typed`] full type information.
249pub(super) fn derive_vertex_types_with_inference(
250    rule: &Rule,
251    base_relations: &RefRelationStore,
252    inferred_schemas: &InferredSchemas,
253) -> Result<BTreeMap<String, ScalarType>, super::RefEvalError> {
254    /// First-recorded site for a variable; used to populate the
255    /// `ConflictingVariableType` report when a second body atom
256    /// types the variable differently.
257    struct FirstSite {
258        predicate: String,
259        position: usize,
260        ty: ScalarType,
261    }
262    let mut sites: BTreeMap<String, FirstSite> = BTreeMap::new();
263    for literal in &rule.body {
264        let body_atom = match literal {
265            BodyLiteral::Positive(a) => a,
266            _ => continue,
267        };
268        // Type each position. Base relation wins if both are
269        // present (cannot happen — `base_relations` and
270        // `inferred_schemas` keys are disjoint by construction in
271        // the typed evaluators).
272        let position_types: Vec<Option<ScalarType>> =
273            if let Some(rel) = base_relations.get(&body_atom.predicate) {
274                let limit = body_atom.terms.len().min(rel.schema.len());
275                let mut v: Vec<Option<ScalarType>> = vec![None; body_atom.terms.len()];
276                for (pos_idx, slot) in v.iter_mut().enumerate().take(limit) {
277                    *slot = Some(rel.schema[pos_idx]);
278                }
279                v
280            } else if let Some(schema) = inferred_schemas.get(&body_atom.predicate) {
281                let limit = body_atom.terms.len().min(schema.len());
282                let mut v: Vec<Option<ScalarType>> = vec![None; body_atom.terms.len()];
283                for (pos_idx, slot) in v.iter_mut().enumerate().take(limit) {
284                    *slot = schema[pos_idx];
285                }
286                v
287            } else {
288                continue; // predicate unknown, no type info
289            };
290        for (position, term) in body_atom.terms.iter().enumerate() {
291            let var_name = match term {
292                Term::Variable(name) => name.clone(),
293                _ => continue,
294            };
295            let Some(ty) = position_types[position] else {
296                continue;
297            };
298            match sites.get(&var_name) {
299                None => {
300                    sites.insert(
301                        var_name,
302                        FirstSite {
303                            predicate: body_atom.predicate.clone(),
304                            position,
305                            ty,
306                        },
307                    );
308                }
309                Some(prior) if prior.ty == ty => {
310                    // Agreeing repeat — silent.
311                }
312                Some(prior) => {
313                    return Err(super::RefEvalError::ConflictingVariableType {
314                        var: var_name,
315                        first_predicate: prior.predicate.clone(),
316                        first_position: prior.position,
317                        first_type: prior.ty,
318                        second_predicate: body_atom.predicate.clone(),
319                        second_position: position,
320                        second_type: ty,
321                    });
322                }
323            }
324        }
325    }
326    Ok(sites
327        .into_iter()
328        .map(|(name, site)| (name, site.ty))
329        .collect())
330}