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}