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.
148pub fn evaluate_fixpoint_typed(
149    rules: &[Rule],
150    base_relations: &RefRelationStore,
151    target_predicate: &str,
152    order: &dyn VariableOrder,
153    config: &FixpointConfig,
154) -> Result<RefRelation, FixpointError> {
155    // ### Structural precedence pre-flight (PR 9 contract repair)
156    //
157    // Inference back-propagates from each rule's head into its
158    // group key — if any rule in the input is misgrouped relative
159    // to `target_predicate`, that back-propagation could surface
160    // as `InferenceConflict` before structural validation has a
161    // chance to emit `RuleNotForTarget`. Defer to the structural
162    // evaluator BEFORE running inference, so the diagnostic order
163    // matches PR 5/PR 6 expectations.
164    if rules.iter().any(|r| r.head.predicate != target_predicate) {
165        return evaluate_fixpoint(rules, base_relations, target_predicate, order, config);
166    }
167    // Inference treats target_predicate as a single-element SCC
168    // group so the same machinery covers single-target fixpoint
169    // and full SCC fixpoint.
170    let mut group: BTreeMap<String, Vec<Rule>> = BTreeMap::new();
171    group.insert(target_predicate.to_string(), rules.to_vec());
172    let inferred = match infer_scc_predicate_schemas(&group, base_relations) {
173        Ok(s) => s,
174        Err(InferenceError::ConflictingPredicateColumnType {
175            predicate,
176            column,
177            first_rule_index,
178            first_type,
179            second_rule_index,
180            second_type,
181        }) => {
182            // Map the per-group rule index back to the input slice's
183            // rule index so the caller's `rule_index` field stays
184            // aligned with `rules`.
185            let mapped_rule_index =
186                nth_rule_index_with_head(rules, target_predicate, second_rule_index);
187            return Err(FixpointError::RuleEval {
188                rule_index: mapped_rule_index,
189                source: RefEvalError::InferenceConflict {
190                    predicate,
191                    column,
192                    first_rule_index,
193                    first_type,
194                    second_rule_index,
195                    second_type,
196                },
197            });
198        }
199    };
200    for (rule_index, rule) in rules.iter().enumerate() {
201        // Pre-flight has guaranteed every rule heads target_predicate.
202        if let Err(source) = typed_gate_with_inference(rule, base_relations, &inferred) {
203            return Err(FixpointError::RuleEval { rule_index, source });
204        }
205    }
206    evaluate_fixpoint(rules, base_relations, target_predicate, order, config)
207}
208
209/// Evaluate a multi-predicate SCC fixpoint with typed gating
210/// applied to every rule against `base_relations` at function
211/// entry.
212///
213/// Failures from the gate surface as
214/// [`SccFixpointError::RuleEval { predicate, rule_index, source }`]
215/// where `source` is either [`RefEvalError::Ineligible`] or
216/// [`RefEvalError::ConflictingVariableType`]. Same wrapping
217/// pattern as [`evaluate_fixpoint_typed`].
218///
219/// ## Transitive type inference
220///
221/// Runs [`infer_scc_predicate_schemas`] over the full rule group
222/// at entry. For each rule, the typed gate consults
223/// `base_relations` AND the inferred schema of any SCC predicate
224/// referenced in its body. Variables anchored only via SCC
225/// predicates get their inferred types and flow through
226/// [`analyze_typed`] like any other typed vertex. Cyclic-only
227/// predicates (no base anchor anywhere) produce all-`None`
228/// inferred schemas and pass the gate per the locked
229/// "unknowable-after-inference ≠ unsupported" policy.
230///
231/// ## Structural-error precedence
232///
233/// The typed gate is skipped for any rule whose head predicate
234/// does not equal its `BTreeMap` group key. Such a misgrouped
235/// rule would be rejected by the structural
236/// [`evaluate_scc_fixpoint`] with
237/// [`SccFixpointError::RuleHeadPredicateMismatch`] — running the
238/// typed gate first would mask that diagnostic. Other structural
239/// entry-validation errors (predicate-in-base, max-iterations,
240/// schema-indeterminable, head-arity mismatch) are surfaced by
241/// the delegation at the end of this function.
242#[expect(
243    clippy::result_large_err,
244    reason = "the public typed diagnostic preserves its structured rule-evaluation source without a second heap allocation"
245)]
246pub fn evaluate_scc_fixpoint_typed(
247    rules: &BTreeMap<String, Vec<Rule>>,
248    base_relations: &RefRelationStore,
249    order: &dyn VariableOrder,
250    config: &FixpointConfig,
251) -> Result<RefRelationStore, SccFixpointError> {
252    // ### Structural precedence pre-flight (PR 9 contract repair)
253    //
254    // Inference back-propagates from each rule's head into its
255    // group key. If any rule is misgrouped (its head predicate
256    // doesn't equal its `BTreeMap` group key), the back-prop
257    // could surface as `InferenceConflict` before structural
258    // validation has a chance to emit `RuleHeadPredicateMismatch`.
259    // Defer to the structural evaluator BEFORE running inference,
260    // so the diagnostic order matches PR 5/PR 6 expectations.
261    if rules
262        .iter()
263        .any(|(predicate, group)| group.iter().any(|r| &r.head.predicate != predicate))
264    {
265        return evaluate_scc_fixpoint(rules, base_relations, order, config);
266    }
267    let inferred = match infer_scc_predicate_schemas(rules, base_relations) {
268        Ok(s) => s,
269        Err(InferenceError::ConflictingPredicateColumnType {
270            predicate,
271            column,
272            first_rule_index,
273            first_type,
274            second_rule_index,
275            second_type,
276        }) => {
277            return Err(SccFixpointError::RuleEval {
278                predicate: predicate.clone(),
279                rule_index: second_rule_index,
280                source: RefEvalError::InferenceConflict {
281                    predicate,
282                    column,
283                    first_rule_index,
284                    first_type,
285                    second_rule_index,
286                    second_type,
287                },
288            });
289        }
290    };
291    for (predicate, group) in rules.iter() {
292        for (rule_index, rule) in group.iter().enumerate() {
293            // Pre-flight has guaranteed every rule heads its group key.
294            if let Err(source) = typed_gate_with_inference(rule, base_relations, &inferred) {
295                return Err(SccFixpointError::RuleEval {
296                    predicate: predicate.clone(),
297                    rule_index,
298                    source,
299                });
300            }
301        }
302    }
303    evaluate_scc_fixpoint(rules, base_relations, order, config)
304}
305
306/// Shared typed-gate check that consults inferred SCC schemas
307/// alongside `base_relations`. On the gate phase only — does NOT
308/// evaluate the rule.
309///
310/// **Within-gate precedence.** Type conflicts (from
311/// [`derive_vertex_types_with_inference`]) are reported before
312/// [`super::Boundary`] verdicts (from [`analyze_typed`]); see the
313/// `evaluate_rule_typed` doc comment for the rationale.
314fn typed_gate_with_inference(
315    rule: &Rule,
316    relations: &RefRelationStore,
317    inferred: &InferredSchemas,
318) -> Result<(), RefEvalError> {
319    let vertex_types = derive_vertex_types_with_inference(rule, relations, inferred)?;
320    let hg = HypergraphRule::from_rule(rule);
321    if let Eligibility::Ineligible(bs) =
322        analyze_typed(&hg, &vertex_types, ExecutorContext::HashFallback)
323    {
324        return Err(RefEvalError::Ineligible(bs));
325    }
326    Ok(())
327}
328
329/// Map an SCC-group rule index back to the position in a flat
330/// `&[Rule]` slice for [`evaluate_fixpoint_typed`]'s error
331/// reporting. `n` is the index within the target predicate's
332/// group; we walk the slice and return the position of the n-th
333/// rule whose head matches `target`.
334fn nth_rule_index_with_head(rules: &[Rule], target: &str, n: usize) -> usize {
335    let mut counter = 0usize;
336    for (idx, rule) in rules.iter().enumerate() {
337        if rule.head.predicate == target {
338            if counter == n {
339                return idx;
340            }
341            counter += 1;
342        }
343    }
344    // Fallback: if mapping fails (shouldn't, by construction),
345    // return 0 so the error still surfaces. The structural
346    // evaluator will catch any actual mismatch.
347    0
348}
349
350/// Shared typed-gate check (base-only path). Used by
351/// [`evaluate_rule_typed`].
352///
353/// Returns `Ok(())` when the rule passes the typed gate (no
354/// cross-atom type conflict, no unsupported join-key types known
355/// from base relations). Returns `Err(RefEvalError::*)` otherwise.
356///
357/// **Within-gate precedence.** Type conflicts (from
358/// [`derive_vertex_types`]) are reported before
359/// [`super::Boundary`] verdicts (from [`analyze_typed`]); see the
360/// `evaluate_rule_typed` doc comment for the rationale.
361fn typed_gate(rule: &Rule, relations: &RefRelationStore) -> Result<(), RefEvalError> {
362    let vertex_types = derive_vertex_types(rule, relations)?;
363    let hg = HypergraphRule::from_rule(rule);
364    if let Eligibility::Ineligible(bs) =
365        analyze_typed(&hg, &vertex_types, ExecutorContext::HashFallback)
366    {
367        return Err(RefEvalError::Ineligible(bs));
368    }
369    Ok(())
370}
371
372/// Derive variable types from the schemas of body-atom relations.
373///
374/// Only [`BodyLiteral::Positive`] atoms whose predicate is present
375/// in `relations` contribute. For each such atom, every
376/// [`Term::Variable`] argument is unified with the relation's
377/// `schema[position]`. Variables that appear only through
378/// predicates absent from `relations` (or only through non-positive
379/// literals) do not appear in the returned map; per the locked
380/// policy, [`analyze_typed`] does not flag them.
381///
382/// On the first atom that types a given variable, the type is
383/// recorded together with the (predicate, position) pair. A later
384/// atom that types the same variable to a *different* type
385/// surfaces as [`RefEvalError::ConflictingVariableType`] with both
386/// triples populated. Subsequent agreeing atoms are silent.
387///
388/// `pub(super)` so [`super::plan`] can reuse the same conflict
389/// detection without duplicating the source-walk logic.
390pub(super) fn derive_vertex_types(
391    rule: &Rule,
392    relations: &RefRelationStore,
393) -> Result<BTreeMap<String, ScalarType>, RefEvalError> {
394    /// First-recorded site for a variable. Stored separately from
395    /// the public type map so the conflict report can name the
396    /// originating atom.
397    struct FirstSite {
398        predicate: String,
399        position: usize,
400        ty: ScalarType,
401    }
402    let mut sites: BTreeMap<String, FirstSite> = BTreeMap::new();
403    for literal in &rule.body {
404        let atom = match literal {
405            BodyLiteral::Positive(a) => a,
406            // Comparisons, negations, and is-expressions don't
407            // constrain types via relation schema. Negation is also
408            // a structural boundary that analyze_typed will reject;
409            // is-expr likewise. We don't pre-empt those here.
410            _ => continue,
411        };
412        let relation = match relations.get(&atom.predicate) {
413            Some(r) => r,
414            None => continue, // unknown predicate: no type info
415        };
416        // Defensive: an atom whose arity disagrees with its
417        // relation's schema is rejected by `evaluate_rule`'s
418        // `AtomSpec::build` with `RelationArityMismatch`. The gate
419        // intentionally does NOT pre-empt that — leaving arity
420        // checks to one place keeps the error surface minimal.
421        // We just stop deriving from positions past the schema.
422        let limit = atom.terms.len().min(relation.schema.len());
423        for (position, term) in atom.terms[..limit].iter().enumerate() {
424            let var_name = match term {
425                Term::Variable(name) => name.clone(),
426                _ => continue,
427            };
428            let ty = relation.schema[position];
429            match sites.get(&var_name) {
430                None => {
431                    sites.insert(
432                        var_name,
433                        FirstSite {
434                            predicate: atom.predicate.clone(),
435                            position,
436                            ty,
437                        },
438                    );
439                }
440                Some(prior) if prior.ty == ty => {
441                    // Agreeing repeat — silent.
442                }
443                Some(prior) => {
444                    return Err(RefEvalError::ConflictingVariableType {
445                        var: var_name,
446                        first_predicate: prior.predicate.clone(),
447                        first_position: prior.position,
448                        first_type: prior.ty,
449                        second_predicate: atom.predicate.clone(),
450                        second_position: position,
451                        second_type: ty,
452                    });
453                }
454            }
455        }
456    }
457    Ok(sites
458        .into_iter()
459        .map(|(name, site)| (name, site.ty))
460        .collect())
461}