Skip to main content

xlog_logic/hypergraph/
plan.rs

1//! Mixed plan contract: dispatch each rule into either the future
2//! WCOJ multiway path or the existing binary-fallback lowering.
3//!
4//! Builds on PR 1's [`super::analyze_typed`] and the PR 5 typed
5//! gate's vertex-type derivation to assemble per-rule
6//! [`RulePlan`] values that downstream callers (planner,
7//! mixed-execution evaluator, kernel test harness) consume to
8//! decide which path each rule takes. This slice ships only the
9//! contract — no executor integration, no RIR lowering, no CUDA,
10//! no cost model beyond
11//! [`super::AppearanceOrder`](super::var_order::AppearanceOrder).
12//!
13//! ## Verdicts
14//!
15//! Each rule produces exactly one of:
16//!
17//! * [`RulePlan::MultiwayCandidate`] — cleared the typed gate;
18//!   ready for WCOJ. Carries the [`HypergraphRule`] and the
19//!   [`super::AppearanceOrder`]-resolved variable order so the
20//!   future kernel does not re-derive them.
21//! * [`RulePlan::BinaryFallback`] — failed the typed gate on at
22//!   least one structural / type-coverage [`Boundary`]. Carries
23//!   **every** [`Boundary`] that fired so explain output and
24//!   downstream callers see all reasons rather than only the
25//!   first one.
26//!
27//! Type *conflicts* — a variable that gets contradictory types
28//! from different body atoms — are NOT verdicts. They surface as
29//! [`PlanError::ConflictingVariableType`]; the planner refuses to
30//! plan a rule whose fixture is internally contradictory. Caller
31//! must fix the fixture before re-planning.
32//!
33//! ## Determinism
34//!
35//! [`plan_rule`] / [`plan_rules`] are pure functions of their
36//! inputs. [`explain_plans`] is **canonical**: plans are sorted
37//! by `head_predicate` (lexicographic), with same-head ties
38//! broken by the rendered line content itself — string-lex on
39//! the verdict tag (so `binary-fallback` < `multiway`), then on
40//! the boundary list or variable-order vector. Input position is
41//! never the tie-breaker, so the output is identical for any
42//! permutation of the input, including reversal of same-head
43//! rules. Locked by
44//! `explain_plans_is_canonical_under_same_head_reorder`.
45
46use super::eligibility::{analyze_typed, Boundary, Eligibility, ExecutorContext};
47use super::inference::{
48    derive_vertex_types_with_inference, infer_scc_predicate_schemas, InferenceError,
49};
50use super::ir::{HypergraphRule, VertexId};
51use super::reference::{RefEvalError, RefRelationStore};
52use super::typed::derive_vertex_types;
53use super::var_order::{AppearanceOrder, VariableOrder};
54use crate::ast::Rule;
55use std::collections::BTreeMap;
56use std::fmt::Write;
57use xlog_core::ScalarType;
58
59/// Plan choice for a single rule.
60///
61/// Mirrors the dispatch contract: every rule goes either to the
62/// future multiway WCOJ path or to the existing binary-join
63/// fallback. The variant carries the metadata the downstream
64/// path needs to act on the verdict — for multiway, the
65/// hypergraph and a resolved variable order; for fallback, the
66/// boundary list explaining why.
67#[derive(Debug, Clone, PartialEq)]
68pub enum RulePlan {
69    /// Rule cleared the typed gate. Ready for the future WCOJ
70    /// kernel path. The structural [`super::evaluate_rule`] /
71    /// [`super::evaluate_rule_typed`] would also accept this rule
72    /// today; PR 6 carries no execution itself, just the dispatch
73    /// metadata.
74    MultiwayCandidate {
75        /// Predicate name of the rule head, copied from the
76        /// source [`Rule`] for diagnostic / explain use.
77        head_predicate: String,
78        /// Hypergraph IR built from the rule body — carried so
79        /// the future kernel does not rebuild it.
80        hypergraph: HypergraphRule,
81        /// Variable order produced by
82        /// [`super::AppearanceOrder`](super::var_order::AppearanceOrder).
83        /// Length equals `hypergraph.vertex_count()`.
84        variable_order: Vec<VertexId>,
85    },
86    /// Rule cannot be planned as multiway. Caller must use the
87    /// existing binary-join lowering path. The boundaries vector
88    /// is non-empty and lists every reason the typed gate
89    /// rejected the rule; preserving all of them keeps explain
90    /// output and downstream telemetry honest about cumulative
91    /// fallback drivers.
92    BinaryFallback {
93        /// Predicate name of the rule head.
94        head_predicate: String,
95        /// Every [`Boundary`] that fired for this rule.
96        boundaries: Vec<Boundary>,
97    },
98}
99
100/// Hard errors from [`plan_rule`] / [`plan_rules`] / [`plan_scc_rules`].
101///
102/// Distinct from [`RulePlan::BinaryFallback`]: a fallback verdict
103/// means the rule is plannable, just on a different path. A plan
104/// error means the rule cannot be planned at all under the
105/// current fixture and must be fixed by the caller.
106#[derive(Debug, Clone, PartialEq)]
107pub enum PlanError {
108    /// Same shape as [`RefEvalError::ConflictingVariableType`].
109    /// Mirrored here (rather than re-exporting the eval-error
110    /// variant) so the planner's error type is independent of the
111    /// evaluator's.
112    ConflictingVariableType {
113        /// Variable name as it appears in the source rule.
114        var: String,
115        /// Predicate of the first atom that typed `var`.
116        first_predicate: String,
117        /// 0-based argument position within `first_predicate`.
118        first_position: usize,
119        /// Schema type at `(first_predicate, first_position)`.
120        first_type: ScalarType,
121        /// Predicate of the conflicting atom.
122        second_predicate: String,
123        /// 0-based argument position within `second_predicate`.
124        second_position: usize,
125        /// Schema type at `(second_predicate, second_position)`.
126        second_type: ScalarType,
127    },
128    /// Cross-rule head-column conflict detected during PR 8 SCC
129    /// type inference. Two rules contributing to the same head
130    /// predicate disagree on the type of the same column.
131    ///
132    /// Mirrors [`RefEvalError::InferenceConflict`] so callers
133    /// pattern-matching on plan errors can treat inference and
134    /// eval conflicts symmetrically. Surfaced only by
135    /// [`plan_scc_rules`]; per-rule [`plan_rule`] / [`plan_rules`]
136    /// don't run inference (no group context).
137    InferenceConflict {
138        /// Head predicate name where the conflict was detected.
139        predicate: String,
140        /// 0-based column index where types disagree.
141        column: usize,
142        /// Rule index (within the predicate's group) that first
143        /// typed the column.
144        first_rule_index: usize,
145        /// Type derived from the first rule's body.
146        first_type: ScalarType,
147        /// Rule index (within the predicate's group) that
148        /// disagrees.
149        second_rule_index: usize,
150        /// Type derived from the conflicting rule's body.
151        second_type: ScalarType,
152    },
153    /// A rule grouped under predicate `group_key` heads a
154    /// different predicate. Surfaces from [`plan_scc_rules`] only;
155    /// per-rule [`plan_rule`] / [`plan_rules`] don't have group
156    /// context to validate against.
157    ///
158    /// Mirrors [`super::SccFixpointError::RuleHeadPredicateMismatch`]
159    /// so the planner and the SCC fixpoint evaluator agree on the
160    /// diagnostic for this fixture class. Without symmetry, a
161    /// caller driving plan-then-evaluate would see the planner
162    /// say "MultiwayCandidate" while the evaluator says
163    /// "RuleHeadPredicateMismatch" — the same disagreement
164    /// pattern PR 9 closed for unsupported-key cases.
165    RuleHeadPredicateMismatch {
166        /// `BTreeMap` key under which the rule was grouped.
167        group_key: String,
168        /// Index of the rule within that group.
169        rule_index: usize,
170        /// Head predicate observed on the rule.
171        observed: String,
172    },
173}
174
175/// Plan a single rule. See module-level docs for the contract.
176pub fn plan_rule(rule: &Rule, base_relations: &RefRelationStore) -> Result<RulePlan, PlanError> {
177    let vertex_types = match derive_vertex_types(rule, base_relations) {
178        Ok(map) => map,
179        Err(RefEvalError::ConflictingVariableType {
180            var,
181            first_predicate,
182            first_position,
183            first_type,
184            second_predicate,
185            second_position,
186            second_type,
187        }) => {
188            return Err(PlanError::ConflictingVariableType {
189                var,
190                first_predicate,
191                first_position,
192                first_type,
193                second_predicate,
194                second_position,
195                second_type,
196            });
197        }
198        Err(other) => {
199            // `derive_vertex_types` only ever returns
200            // ConflictingVariableType in the Err arm — the
201            // conflict variant is the only one constructed by the
202            // function body. If a future change broadens the
203            // signature, we'd want a fresh PlanError variant
204            // rather than silently swallow.
205            unreachable!(
206                "derive_vertex_types contract returns only ConflictingVariableType: got {other:?}"
207            );
208        }
209    };
210    let hypergraph = HypergraphRule::from_rule(rule);
211    match analyze_typed(&hypergraph, &vertex_types, ExecutorContext::HashFallback) {
212        Eligibility::Eligible => {
213            let variable_order = AppearanceOrder.order(&hypergraph);
214            Ok(RulePlan::MultiwayCandidate {
215                head_predicate: rule.head.predicate.clone(),
216                hypergraph,
217                variable_order,
218            })
219        }
220        Eligibility::Ineligible(boundaries) => Ok(RulePlan::BinaryFallback {
221            head_predicate: rule.head.predicate.clone(),
222            boundaries,
223        }),
224    }
225}
226
227/// Plan a slice of rules. Order-preserving: `plans[i]` is the
228/// plan for `rules[i]`.
229///
230/// Stops on the first [`PlanError`]. Callers that want
231/// best-effort multi-rule planning should call [`plan_rule`]
232/// per-rule and collect verdicts themselves.
233///
234/// Per-rule typing only — no SCC inference. For mutually
235/// recursive predicate groups whose join keys are anchored only
236/// through SCC body atoms, use [`plan_scc_rules`] so the same
237/// transitive type inference that
238/// [`super::evaluate_scc_fixpoint_typed`] runs is applied
239/// before each rule's verdict.
240pub fn plan_rules(
241    rules: &[Rule],
242    base_relations: &RefRelationStore,
243) -> Result<Vec<RulePlan>, PlanError> {
244    rules.iter().map(|r| plan_rule(r, base_relations)).collect()
245}
246
247/// Plan a mutually-recursive rule group with PR 8 transitive
248/// type inference engaged.
249///
250/// Mirrors the input shape: returns a
251/// `BTreeMap<predicate, Vec<RulePlan>>` where `result[p][i]`
252/// corresponds to `rules[p][i]`. Each rule's verdict is computed
253/// after running [`infer_scc_predicate_schemas`] over the full
254/// group, so a recursive-only join key whose type is established
255/// only via inference is now flagged with
256/// [`super::Boundary::UnsupportedKeyType`] consistent with
257/// [`super::evaluate_scc_fixpoint_typed`].
258///
259/// ## Why this exists separately from [`plan_rules`]
260///
261/// [`plan_rule`] / [`plan_rules`] type variables from
262/// `base_relations` only — they have no group context and can't
263/// run inference. Without [`plan_scc_rules`], a planner driving
264/// the per-rule API would mark `even(X, Y) :- odd(X, Z), odd(Z, Y)`
265/// as [`RulePlan::MultiwayCandidate`] even when `odd`'s schema
266/// (inferred via PR 8) propagates an unsupported type to `Z` —
267/// i.e., the planner and the SCC evaluator would disagree on
268/// the same fixture. PR 9 closes that gap.
269///
270/// ## Errors
271///
272/// Returns [`PlanError::InferenceConflict`] for cross-rule
273/// head-column conflicts detected during inference,
274/// [`PlanError::ConflictingVariableType`] for within-rule body
275/// conflicts (both layered the same way as
276/// [`super::evaluate_scc_fixpoint_typed`]), and
277/// [`PlanError::RuleHeadPredicateMismatch`] for misgrouped rules
278/// (rule's head predicate ≠ its `BTreeMap` group key).
279///
280/// ## Structural-error precedence
281///
282/// Mirrors the [`super::evaluate_scc_fixpoint_typed`] pre-flight
283/// (PR 9): if any rule is misgrouped, the function returns
284/// [`PlanError::RuleHeadPredicateMismatch`] BEFORE running
285/// inference, so a misgrouped rule whose body would also produce
286/// inference conflicts surfaces the structural error first. This
287/// keeps planner and evaluator verdicts symmetric for every
288/// fixture class.
289pub fn plan_scc_rules(
290    rules: &BTreeMap<String, Vec<Rule>>,
291    base_relations: &RefRelationStore,
292) -> Result<BTreeMap<String, Vec<RulePlan>>, PlanError> {
293    // Pre-flight: surface RuleHeadPredicateMismatch before
294    // inference runs (see "Structural-error precedence" above).
295    for (predicate, group) in rules.iter() {
296        for (rule_index, rule) in group.iter().enumerate() {
297            if &rule.head.predicate != predicate {
298                return Err(PlanError::RuleHeadPredicateMismatch {
299                    group_key: predicate.clone(),
300                    rule_index,
301                    observed: rule.head.predicate.clone(),
302                });
303            }
304        }
305    }
306    let inferred = match infer_scc_predicate_schemas(rules, base_relations) {
307        Ok(s) => s,
308        Err(InferenceError::ConflictingPredicateColumnType {
309            predicate,
310            column,
311            first_rule_index,
312            first_type,
313            second_rule_index,
314            second_type,
315        }) => {
316            return Err(PlanError::InferenceConflict {
317                predicate,
318                column,
319                first_rule_index,
320                first_type,
321                second_rule_index,
322                second_type,
323            });
324        }
325    };
326    let mut out: BTreeMap<String, Vec<RulePlan>> = BTreeMap::new();
327    for (predicate, group) in rules.iter() {
328        let mut plans: Vec<RulePlan> = Vec::with_capacity(group.len());
329        for rule in group {
330            let vertex_types =
331                match derive_vertex_types_with_inference(rule, base_relations, &inferred) {
332                    Ok(map) => map,
333                    Err(RefEvalError::ConflictingVariableType {
334                        var,
335                        first_predicate,
336                        first_position,
337                        first_type,
338                        second_predicate,
339                        second_position,
340                        second_type,
341                    }) => {
342                        return Err(PlanError::ConflictingVariableType {
343                            var,
344                            first_predicate,
345                            first_position,
346                            first_type,
347                            second_predicate,
348                            second_position,
349                            second_type,
350                        });
351                    }
352                    Err(other) => unreachable!(
353                        "derive_vertex_types_with_inference contract returns only \
354                         ConflictingVariableType: got {other:?}"
355                    ),
356                };
357            let hypergraph = HypergraphRule::from_rule(rule);
358            let plan =
359                match analyze_typed(&hypergraph, &vertex_types, ExecutorContext::HashFallback) {
360                    Eligibility::Eligible => {
361                        let variable_order = AppearanceOrder.order(&hypergraph);
362                        RulePlan::MultiwayCandidate {
363                            head_predicate: rule.head.predicate.clone(),
364                            hypergraph,
365                            variable_order,
366                        }
367                    }
368                    Eligibility::Ineligible(boundaries) => RulePlan::BinaryFallback {
369                        head_predicate: rule.head.predicate.clone(),
370                        boundaries,
371                    },
372                };
373            plans.push(plan);
374        }
375        out.insert(predicate.clone(), plans);
376    }
377    Ok(out)
378}
379
380/// Render a canonical textual explain of a plan slice.
381///
382/// Plans are sorted by `head_predicate` (lexicographic), with
383/// same-head ties broken by the **rendered line body** itself
384/// under string-lex ordering (so the verdict tag
385/// `binary-fallback` sorts before `multiway`; the boundary list
386/// or variable-order vector breaks remaining ties). Input
387/// position is never consulted, so the output is identical for
388/// any permutation of the input, including reversal of same-head
389/// rules. Locked by
390/// `explain_plans_is_canonical_under_same_head_reorder`.
391///
392/// The displayed per-line index is a **per-head rank** (0-based,
393/// counting only same-head plans encountered earlier in sorted
394/// order) so multiple rules under one head remain distinguishable
395/// without leaking input position into the canonical form.
396///
397/// One line per rule, format:
398///
399/// ```text
400/// {head_predicate}/{per_head_rank}: multiway vars=[X, Y, Z]
401/// {head_predicate}/{per_head_rank}: binary-fallback boundaries=[BodyNegation, ...]
402/// ```
403pub fn explain_plans(plans: &[RulePlan]) -> String {
404    // Pre-render each plan's *body* (everything after the
405    // "head/rank: " prefix). The body is the canonical content
406    // fingerprint we sort by — same head + same body → same
407    // line, regardless of input position.
408    let mut bodies: Vec<(&str, String)> =
409        plans.iter().map(|p| (head_of(p), render_body(p))).collect();
410    bodies.sort_by(|(ha, ba), (hb, bb)| ha.cmp(hb).then_with(|| ba.cmp(bb)));
411    let mut out = String::new();
412    let mut last_head: Option<&str> = None;
413    let mut rank: usize = 0;
414    for (head, body) in &bodies {
415        match last_head {
416            Some(prev) if prev == *head => rank += 1,
417            _ => rank = 0,
418        }
419        last_head = Some(*head);
420        let _ = writeln!(out, "{head}/{rank}: {body}");
421    }
422    out
423}
424
425/// Render the verdict-and-payload portion of a plan's explain
426/// line. Used both for output assembly and (importantly) as the
427/// same-head sort fingerprint in [`explain_plans`].
428///
429/// Under string-lex ordering on rendered bodies, the verdict tag
430/// `binary-fallback` sorts before `multiway` — fallbacks
431/// surface first within a predicate, so a reader scanning the
432/// canonical explain can spot the dispatch obstacles before the
433/// successful candidates.
434fn render_body(plan: &RulePlan) -> String {
435    match plan {
436        RulePlan::MultiwayCandidate {
437            hypergraph,
438            variable_order,
439            ..
440        } => {
441            let names: Vec<&str> = variable_order
442                .iter()
443                .map(|vid| hypergraph.vertex(*vid).name.as_str())
444                .collect();
445            format!("multiway vars=[{}]", names.join(", "))
446        }
447        RulePlan::BinaryFallback { boundaries, .. } => {
448            format!("binary-fallback boundaries={boundaries:?}")
449        }
450    }
451}
452
453fn head_of(plan: &RulePlan) -> &str {
454    match plan {
455        RulePlan::MultiwayCandidate { head_predicate, .. } => head_predicate,
456        RulePlan::BinaryFallback { head_predicate, .. } => head_predicate,
457    }
458}