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}