Skip to main content

xlog_logic/hypergraph/
typed.rs

1//! Typed oracle gate.
2//!
3//! Wraps the structural [`super::evaluate_rule`],
4//! [`super::evaluate_fixpoint`], and [`super::evaluate_scc_fixpoint`]
5//! oracles with a relation-schema-driven type gate. For each rule:
6//!
7//! 1. Walk positive body atoms; for each `Term::Variable` whose
8//!    predicate is present in `base_relations`, look up
9//!    `relation.schema[position]` and unify into a per-rule
10//!    `BTreeMap<String, ScalarType>`. Cross-atom disagreement is a
11//!    [`RefEvalError::ConflictingVariableType`].
12//! 2. Run [`super::analyze_typed`] with that map. Any
13//!    [`Boundary::UnsupportedKeyType`](super::Boundary) for a
14//!    join-key vertex becomes an [`RefEvalError::Ineligible`].
15//! 3. Delegate to the structural evaluator.
16//!
17//! ## Locked policy: unknowable-after-inference ≠ unsupported
18//!
19//! A "missing vertex type" means *not derivable from base relation
20//! schemas* AND *not derivable from PR 8 transitive SCC type
21//! inference*, **not** "supported." The typed gate rejects only
22//! known-unsupported join-key types.
23//!
24//! For the **single-rule** entry point ([`evaluate_rule_typed`]),
25//! types come from `base_relations` only — there is no group
26//! context for inference. A self-referencing or recursive rule
27//! used through this path therefore retains the original
28//! "unknown ≠ unsupported" behavior on its recursive body atoms;
29//! callers needing inference-aware typing should drive
30//! [`evaluate_fixpoint_typed`] or [`evaluate_scc_fixpoint_typed`].
31//!
32//! For the **group-aware** entry points ([`evaluate_fixpoint_typed`],
33//! [`evaluate_scc_fixpoint_typed`]), PR 8 inference runs at entry
34//! and feeds inferred SCC predicate schemas alongside
35//! `base_relations` into per-rule type derivation. Cyclic-only
36//! predicates (no base anchor anywhere in the rule graph) produce
37//! all-`None` inferred schemas and pass the gate; this is the
38//! narrowed policy now locked by
39//! `cyclic_only_predicate_still_passes_typed_gate_locked_policy`.
40//!
41//! ## Why a separate module
42//!
43//! The structural evaluators (PR 2/3/4) take no type map. Wiring
44//! type derivation into each evaluator's signature would either
45//! (a) force callers to pre-compute the map even when they want
46//! the structural-only behavior or (b) introduce a parallel
47//! "with-types" code path inside each evaluator. Keeping the gate
48//! as a thin orchestration layer above the existing oracles
49//! preserves the structural API and makes the typed contract
50//! testable in one place.
51
52use super::inference::{
53    derive_vertex_types_with_inference, infer_scc_predicate_schemas, InferenceError,
54    InferredSchemas,
55};
56use super::{
57    analyze_typed, evaluate_fixpoint, evaluate_rule, evaluate_scc_fixpoint, Eligibility,
58    ExecutorContext, FixpointConfig, FixpointError, HypergraphRule, RefEvalError, RefRelation,
59    RefRelationStore, RefValue, SccFixpointError, VariableOrder,
60};
61use crate::ast::{BodyLiteral, Rule, Term};
62use std::collections::BTreeMap;
63use xlog_core::ScalarType;
64
65/// Evaluate a single rule with relation-schema-driven type gating.
66///
67/// Equivalent to [`evaluate_rule`] except that the rule is first
68/// run through the typed gate: vertex types are derived from
69/// `relations` (see module-level docs), then [`analyze_typed`] is
70/// run; any [`super::Boundary::UnsupportedKeyType`] surfaces as
71/// [`RefEvalError::Ineligible`]. Cross-atom type conflicts surface
72/// as [`RefEvalError::ConflictingVariableType`].
73///
74/// Structural boundaries (negation, aggregation, ground fact, key
75/// limit) flow through `analyze_typed` unchanged.
76///
77/// ## Within-gate precedence: conflict before boundaries
78///
79/// Variable-type derivation runs before [`analyze_typed`]. A rule
80/// that would fail *both* checks (e.g. a negated body **and** a
81/// cross-atom type conflict on a positive atom) surfaces as
82/// [`RefEvalError::ConflictingVariableType`], not as
83/// [`RefEvalError::Ineligible`]. Type conflicts indicate the
84/// fixture supplied a contradiction the analyzer cannot resolve;
85/// reporting that first guides the caller to a fixable input.
86/// Locked by `typed_gate_conflict_precedes_structural_boundary`.
87///
88/// On a successful gate, delegates to [`evaluate_rule`] for the
89/// actual computation. The structural analyzer there re-checks
90/// boundaries (cheap, defensive), and returns the same row set
91/// that [`evaluate_rule`] would have returned for an Eligible rule.
92///
93/// ## SCC type inference is NOT engaged here
94///
95/// The PR 8 transitive type inference pass
96/// ([`super::infer_scc_predicate_schemas`]) requires a rule
97/// group as input. This single-rule entry point has no such
98/// group, so a self-referencing rule's recursive body atom
99/// contributes no type info. For that case, use
100/// [`evaluate_fixpoint_typed`] (treating the rule as the target
101/// of a single-rule fixpoint) or [`evaluate_scc_fixpoint_typed`].
102pub fn evaluate_rule_typed(
103    rule: &Rule,
104    relations: &RefRelationStore,
105    order: &dyn VariableOrder,
106) -> Result<Vec<Vec<RefValue>>, RefEvalError> {
107    typed_gate(rule, relations)?;
108    evaluate_rule(rule, relations, order)
109}
110
111/// Evaluate a recursive fixpoint with typed gating applied to every
112/// rule against `base_relations` at function entry.
113///
114/// Failures from the gate surface as
115/// [`FixpointError::RuleEval { rule_index, source }`] with `source`
116/// being either [`RefEvalError::Ineligible`] or
117/// [`RefEvalError::ConflictingVariableType`]. This matches the
118/// per-rule error wrapping the structural [`evaluate_fixpoint`]
119/// already uses, so callers do not need a separate error path for
120/// the typed gate.
121///
122/// ## Transitive type inference
123///
124/// Treats `target_predicate` as a single-element rule group and
125/// runs [`infer_scc_predicate_schemas`] before per-rule typed
126/// gating. The inferred schema for `target_predicate` is consulted
127/// alongside `base_relations` when typing variables in body atoms
128/// — so a variable anchored only via the recursive
129/// `target_predicate` body atom now receives the inferred type
130/// rather than staying untyped. Inference conflicts surface as
131/// [`FixpointError::RuleEval { source: ConflictingVariableType }`]
132/// (within-rule conflict) or as the new
133/// [`FixpointError::RuleEval`]-wrapped synthetic conflict from
134/// inference (back-prop conflict; same `RefEvalError` shape).
135///
136/// ## Structural-error precedence
137///
138/// The typed gate is skipped for any rule whose head predicate
139/// does not equal `target_predicate`. Such a rule would be
140/// rejected by the structural [`evaluate_fixpoint`] with
141/// [`FixpointError::RuleNotForTarget`] — running the typed gate
142/// first would mask that more precise diagnostic. Other structural
143/// entry-validation errors (target-in-base, max-iterations,
144/// schema-indeterminable, head-arity mismatch) are surfaced by
145/// the delegation at the end of this function and so naturally
146/// take precedence over typed-gate failures on rules that survive
147/// the per-rule head-match filter.
148#[allow(clippy::result_large_err)]
149pub fn evaluate_fixpoint_typed(
150    rules: &[Rule],
151    base_relations: &RefRelationStore,
152    target_predicate: &str,
153    order: &dyn VariableOrder,
154    config: &FixpointConfig,
155) -> Result<RefRelation, FixpointError> {
156    // ### Structural precedence pre-flight (PR 9 contract repair)
157    //
158    // Inference back-propagates from each rule's head into its
159    // group key — if any rule in the input is misgrouped relative
160    // to `target_predicate`, that back-propagation could surface
161    // as `InferenceConflict` before structural validation has a
162    // chance to emit `RuleNotForTarget`. Defer to the structural
163    // evaluator BEFORE running inference, so the diagnostic order
164    // matches PR 5/PR 6 expectations.
165    if rules.iter().any(|r| r.head.predicate != target_predicate) {
166        return evaluate_fixpoint(rules, base_relations, target_predicate, order, config);
167    }
168    // Inference treats target_predicate as a single-element SCC
169    // group so the same machinery covers single-target fixpoint
170    // and full SCC fixpoint.
171    let mut group: BTreeMap<String, Vec<Rule>> = BTreeMap::new();
172    group.insert(target_predicate.to_string(), rules.to_vec());
173    let inferred = match infer_scc_predicate_schemas(&group, base_relations) {
174        Ok(s) => s,
175        Err(InferenceError::ConflictingPredicateColumnType {
176            predicate,
177            column,
178            first_rule_index,
179            first_type,
180            second_rule_index,
181            second_type,
182        }) => {
183            // Map the per-group rule index back to the input slice's
184            // rule index so the caller's `rule_index` field stays
185            // aligned with `rules`.
186            let mapped_rule_index =
187                nth_rule_index_with_head(rules, target_predicate, second_rule_index);
188            return Err(FixpointError::RuleEval {
189                rule_index: mapped_rule_index,
190                source: RefEvalError::InferenceConflict {
191                    predicate,
192                    column,
193                    first_rule_index,
194                    first_type,
195                    second_rule_index,
196                    second_type,
197                },
198            });
199        }
200    };
201    for (rule_index, rule) in rules.iter().enumerate() {
202        // Pre-flight has guaranteed every rule heads target_predicate.
203        if let Err(source) = typed_gate_with_inference(rule, base_relations, &inferred) {
204            return Err(FixpointError::RuleEval { rule_index, source });
205        }
206    }
207    evaluate_fixpoint(rules, base_relations, target_predicate, order, config)
208}
209
210/// Evaluate a multi-predicate SCC fixpoint with typed gating
211/// applied to every rule against `base_relations` at function
212/// entry.
213///
214/// Failures from the gate surface as
215/// [`SccFixpointError::RuleEval { predicate, rule_index, source }`]
216/// where `source` is either [`RefEvalError::Ineligible`] or
217/// [`RefEvalError::ConflictingVariableType`]. Same wrapping
218/// pattern as [`evaluate_fixpoint_typed`].
219///
220/// ## Transitive type inference
221///
222/// Runs [`infer_scc_predicate_schemas`] over the full rule group
223/// at entry. For each rule, the typed gate consults
224/// `base_relations` AND the inferred schema of any SCC predicate
225/// referenced in its body. Variables anchored only via SCC
226/// predicates get their inferred types and flow through
227/// [`analyze_typed`] like any other typed vertex. Cyclic-only
228/// predicates (no base anchor anywhere) produce all-`None`
229/// inferred schemas and pass the gate per the locked
230/// "unknowable-after-inference ≠ unsupported" policy.
231///
232/// ## Structural-error precedence
233///
234/// The typed gate is skipped for any rule whose head predicate
235/// does not equal its `BTreeMap` group key. Such a misgrouped
236/// rule would be rejected by the structural
237/// [`evaluate_scc_fixpoint`] with
238/// [`SccFixpointError::RuleHeadPredicateMismatch`] — running the
239/// typed gate first would mask that diagnostic. Other structural
240/// entry-validation errors (predicate-in-base, max-iterations,
241/// schema-indeterminable, head-arity mismatch) are surfaced by
242/// the delegation at the end of this function.
243#[allow(clippy::result_large_err)]
244pub fn evaluate_scc_fixpoint_typed(
245    rules: &BTreeMap<String, Vec<Rule>>,
246    base_relations: &RefRelationStore,
247    order: &dyn VariableOrder,
248    config: &FixpointConfig,
249) -> Result<RefRelationStore, SccFixpointError> {
250    // ### Structural precedence pre-flight (PR 9 contract repair)
251    //
252    // Inference back-propagates from each rule's head into its
253    // group key. If any rule is misgrouped (its head predicate
254    // doesn't equal its `BTreeMap` group key), the back-prop
255    // could surface as `InferenceConflict` before structural
256    // validation has a chance to emit `RuleHeadPredicateMismatch`.
257    // Defer to the structural evaluator BEFORE running inference,
258    // so the diagnostic order matches PR 5/PR 6 expectations.
259    if rules
260        .iter()
261        .any(|(predicate, group)| group.iter().any(|r| &r.head.predicate != predicate))
262    {
263        return evaluate_scc_fixpoint(rules, base_relations, order, config);
264    }
265    let inferred = match infer_scc_predicate_schemas(rules, base_relations) {
266        Ok(s) => s,
267        Err(InferenceError::ConflictingPredicateColumnType {
268            predicate,
269            column,
270            first_rule_index,
271            first_type,
272            second_rule_index,
273            second_type,
274        }) => {
275            return Err(SccFixpointError::RuleEval {
276                predicate: predicate.clone(),
277                rule_index: second_rule_index,
278                source: RefEvalError::InferenceConflict {
279                    predicate,
280                    column,
281                    first_rule_index,
282                    first_type,
283                    second_rule_index,
284                    second_type,
285                },
286            });
287        }
288    };
289    for (predicate, group) in rules.iter() {
290        for (rule_index, rule) in group.iter().enumerate() {
291            // Pre-flight has guaranteed every rule heads its group key.
292            if let Err(source) = typed_gate_with_inference(rule, base_relations, &inferred) {
293                return Err(SccFixpointError::RuleEval {
294                    predicate: predicate.clone(),
295                    rule_index,
296                    source,
297                });
298            }
299        }
300    }
301    evaluate_scc_fixpoint(rules, base_relations, order, config)
302}
303
304/// Shared typed-gate check that consults inferred SCC schemas
305/// alongside `base_relations`. On the gate phase only — does NOT
306/// evaluate the rule.
307///
308/// **Within-gate precedence.** Type conflicts (from
309/// [`derive_vertex_types_with_inference`]) are reported before
310/// [`super::Boundary`] verdicts (from [`analyze_typed`]); see the
311/// `evaluate_rule_typed` doc comment for the rationale.
312fn typed_gate_with_inference(
313    rule: &Rule,
314    relations: &RefRelationStore,
315    inferred: &InferredSchemas,
316) -> Result<(), RefEvalError> {
317    let vertex_types = derive_vertex_types_with_inference(rule, relations, inferred)?;
318    let hg = HypergraphRule::from_rule(rule);
319    if let Eligibility::Ineligible(bs) =
320        analyze_typed(&hg, &vertex_types, ExecutorContext::HashFallback)
321    {
322        return Err(RefEvalError::Ineligible(bs));
323    }
324    Ok(())
325}
326
327/// Map an SCC-group rule index back to the position in a flat
328/// `&[Rule]` slice for [`evaluate_fixpoint_typed`]'s error
329/// reporting. `n` is the index within the target predicate's
330/// group; we walk the slice and return the position of the n-th
331/// rule whose head matches `target`.
332fn nth_rule_index_with_head(rules: &[Rule], target: &str, n: usize) -> usize {
333    let mut counter = 0usize;
334    for (idx, rule) in rules.iter().enumerate() {
335        if rule.head.predicate == target {
336            if counter == n {
337                return idx;
338            }
339            counter += 1;
340        }
341    }
342    // Fallback: if mapping fails (shouldn't, by construction),
343    // return 0 so the error still surfaces. The structural
344    // evaluator will catch any actual mismatch.
345    0
346}
347
348/// Shared typed-gate check (base-only path). Used by
349/// [`evaluate_rule_typed`].
350///
351/// Returns `Ok(())` when the rule passes the typed gate (no
352/// cross-atom type conflict, no unsupported join-key types known
353/// from base relations). Returns `Err(RefEvalError::*)` otherwise.
354///
355/// **Within-gate precedence.** Type conflicts (from
356/// [`derive_vertex_types`]) are reported before
357/// [`super::Boundary`] verdicts (from [`analyze_typed`]); see the
358/// `evaluate_rule_typed` doc comment for the rationale.
359fn typed_gate(rule: &Rule, relations: &RefRelationStore) -> Result<(), RefEvalError> {
360    let vertex_types = derive_vertex_types(rule, relations)?;
361    let hg = HypergraphRule::from_rule(rule);
362    if let Eligibility::Ineligible(bs) =
363        analyze_typed(&hg, &vertex_types, ExecutorContext::HashFallback)
364    {
365        return Err(RefEvalError::Ineligible(bs));
366    }
367    Ok(())
368}
369
370/// Derive variable types from the schemas of body-atom relations.
371///
372/// Only [`BodyLiteral::Positive`] atoms whose predicate is present
373/// in `relations` contribute. For each such atom, every
374/// [`Term::Variable`] argument is unified with the relation's
375/// `schema[position]`. Variables that appear only through
376/// predicates absent from `relations` (or only through non-positive
377/// literals) do not appear in the returned map; per the locked
378/// policy, [`analyze_typed`] does not flag them.
379///
380/// On the first atom that types a given variable, the type is
381/// recorded together with the (predicate, position) pair. A later
382/// atom that types the same variable to a *different* type
383/// surfaces as [`RefEvalError::ConflictingVariableType`] with both
384/// triples populated. Subsequent agreeing atoms are silent.
385///
386/// `pub(super)` so [`super::plan`] can reuse the same conflict
387/// detection without duplicating the source-walk logic.
388pub(super) fn derive_vertex_types(
389    rule: &Rule,
390    relations: &RefRelationStore,
391) -> Result<BTreeMap<String, ScalarType>, RefEvalError> {
392    /// First-recorded site for a variable. Stored separately from
393    /// the public type map so the conflict report can name the
394    /// originating atom.
395    struct FirstSite {
396        predicate: String,
397        position: usize,
398        ty: ScalarType,
399    }
400    let mut sites: BTreeMap<String, FirstSite> = BTreeMap::new();
401    for literal in &rule.body {
402        let atom = match literal {
403            BodyLiteral::Positive(a) => a,
404            // Comparisons, negations, and is-expressions don't
405            // constrain types via relation schema. Negation is also
406            // a structural boundary that analyze_typed will reject;
407            // is-expr likewise. We don't pre-empt those here.
408            _ => continue,
409        };
410        let relation = match relations.get(&atom.predicate) {
411            Some(r) => r,
412            None => continue, // unknown predicate: no type info
413        };
414        // Defensive: an atom whose arity disagrees with its
415        // relation's schema is rejected by `evaluate_rule`'s
416        // `AtomSpec::build` with `RelationArityMismatch`. The gate
417        // intentionally does NOT pre-empt that — leaving arity
418        // checks to one place keeps the error surface minimal.
419        // We just stop deriving from positions past the schema.
420        let limit = atom.terms.len().min(relation.schema.len());
421        for (position, term) in atom.terms[..limit].iter().enumerate() {
422            let var_name = match term {
423                Term::Variable(name) => name.clone(),
424                _ => continue,
425            };
426            let ty = relation.schema[position];
427            match sites.get(&var_name) {
428                None => {
429                    sites.insert(
430                        var_name,
431                        FirstSite {
432                            predicate: atom.predicate.clone(),
433                            position,
434                            ty,
435                        },
436                    );
437                }
438                Some(prior) if prior.ty == ty => {
439                    // Agreeing repeat — silent.
440                }
441                Some(prior) => {
442                    return Err(RefEvalError::ConflictingVariableType {
443                        var: var_name,
444                        first_predicate: prior.predicate.clone(),
445                        first_position: prior.position,
446                        first_type: prior.ty,
447                        second_predicate: atom.predicate.clone(),
448                        second_position: position,
449                        second_type: ty,
450                    });
451                }
452            }
453        }
454    }
455    Ok(sites
456        .into_iter()
457        .map(|(name, site)| (name, site.ty))
458        .collect())
459}