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}