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}