Skip to main content

triblespace_core/query/
regularpathconstraint.rs

1use std::collections::HashSet;
2use std::collections::VecDeque;
3
4use crate::id::id_from_value;
5use crate::id::id_into_value;
6use crate::id::RawId;
7use crate::id::ID_LEN;
8use crate::query::intersectionconstraint::IntersectionConstraint;
9use crate::query::Binding;
10use crate::query::Constraint;
11use crate::query::Query;
12use crate::query::TriblePattern;
13use crate::query::Variable;
14use crate::query::VariableContext;
15use crate::query::VariableId;
16use crate::query::VariableSet;
17use crate::trible::TribleSet;
18use crate::inline::encodings::genid::GenId;
19use crate::inline::Inline;
20use crate::inline::RawInline;
21use crate::inline::IntoInline;
22
23// ── Path expression types ────────────────────────────────────────────────
24
25/// Postfix-encoded path operations (used by the [`path!`](crate::macros::path) macro).
26///
27/// The macro compiles a path expression into a sequence of these
28/// operations. [`RegularPathConstraint::new`] converts the postfix
29/// sequence into a tree for evaluation.
30#[derive(Clone)]
31pub enum PathOp {
32    /// Single-attribute hop: traverse the given attribute.
33    Attr(RawId),
34    /// **Negated** single-attribute hop: traverse *any* attribute
35    /// other than the given one (corresponds to SPARQL 1.1 §9.4's
36    /// negated property set `!p`). Used in `(!p)+` / `(!p)*` to
37    /// enumerate reachability under "anything but this predicate"
38    /// edges.
39    NotAttr(RawId),
40    /// Concatenation: compose the two preceding sub-expressions.
41    Concat,
42    /// Alternation: match either of the two preceding sub-expressions.
43    Union,
44    /// Reflexive-transitive closure (`*`): zero or more repetitions.
45    Star,
46    /// Transitive closure (`+`): one or more repetitions.
47    Plus,
48    /// Zero-or-one (`?`): match the preceding sub-expression once or
49    /// not at all. Semantically `Optional(p) ↔ Union(Identity, p)`,
50    /// but recognised inline so the zero-step branch reuses the
51    /// bound start node directly instead of materialising every node.
52    Optional,
53    /// Inverse (`^`): reverse the direction of the preceding sub-
54    /// expression. `^p` traverses `p` backwards (object → subject).
55    /// Compound expressions (`^(a/b)`, `^(a+)`) are normalised at
56    /// `from_postfix` time: Inverse is pushed down to `Attr` leaves
57    /// via the standard rewrites
58    /// `^(a/b) ↔ ^b/^a`, `^(a|b) ↔ ^a|^b`, `^(a+) ↔ (^a)+`, etc.
59    Inverse,
60}
61
62/// Tree-structured path expression for recursive evaluation.
63#[derive(Clone)]
64enum PathExpr {
65    Attr(RawId),
66    /// `^p` — single-attribute hop in reverse (object → subject).
67    /// Always a leaf after `from_postfix` normalisation; inverse over
68    /// compound expressions is rewritten down to leaves.
69    InverseAttr(RawId),
70    /// `!p` — any attribute other than `p` (forward direction).
71    NotAttr(RawId),
72    /// `^!p` — any attribute other than `p`, reversed.
73    InverseNotAttr(RawId),
74    Concat(Box<PathExpr>, Box<PathExpr>),
75    Union(Box<PathExpr>, Box<PathExpr>),
76    Star(Box<PathExpr>),
77    Plus(Box<PathExpr>),
78    Optional(Box<PathExpr>),
79}
80
81impl PathExpr {
82    fn from_postfix(ops: &[PathOp]) -> Self {
83        let mut stack: Vec<PathExpr> = Vec::new();
84        for op in ops {
85            match op {
86                PathOp::Attr(id) => stack.push(PathExpr::Attr(*id)),
87                PathOp::NotAttr(id) => stack.push(PathExpr::NotAttr(*id)),
88                PathOp::Concat => {
89                    let b = stack.pop().unwrap();
90                    let a = stack.pop().unwrap();
91                    stack.push(PathExpr::Concat(Box::new(a), Box::new(b)));
92                }
93                PathOp::Union => {
94                    let b = stack.pop().unwrap();
95                    let a = stack.pop().unwrap();
96                    stack.push(PathExpr::Union(Box::new(a), Box::new(b)));
97                }
98                PathOp::Star => {
99                    let a = stack.pop().unwrap();
100                    stack.push(PathExpr::Star(Box::new(a)));
101                }
102                PathOp::Plus => {
103                    let a = stack.pop().unwrap();
104                    stack.push(PathExpr::Plus(Box::new(a)));
105                }
106                PathOp::Optional => {
107                    let a = stack.pop().unwrap();
108                    stack.push(PathExpr::Optional(Box::new(a)));
109                }
110                PathOp::Inverse => {
111                    let a = stack.pop().unwrap();
112                    stack.push(invert(a));
113                }
114            }
115        }
116        // Distribute `Optional` and `Union` out of `Concat` so the
117        // tail-of-Concat-is-a-closure case (e.g. `p / q?`) becomes a
118        // `Union` of pure-Concat branches. The build_constraint arm
119        // for Concat assumes Attr-only descent — without this rewrite
120        // shapes like `Concat(Attr, Optional(Attr))` would hit the
121        // unreachable!() arm. Star/Plus inside Concat are still
122        // unsupported (their unbounded nature can't be folded into a
123        // finite Union); they remain a future-work limitation.
124        normalize(stack.pop().unwrap())
125    }
126
127    /// Build constraints for this expression, returning the destination variable.
128    /// Allocates fresh variables from `ctx` and pushes constraints.
129    fn build_constraint(
130        &self,
131        set: &TribleSet,
132        ctx: &mut VariableContext,
133        start: Variable<GenId>,
134        constraints: &mut Vec<Box<dyn Constraint<'static> + 'static>>,
135    ) -> Variable<GenId> {
136        match self {
137            PathExpr::Attr(attr_id) => {
138                let a = ctx.next_variable::<GenId>();
139                let dest = ctx.next_variable::<GenId>();
140                constraints.push(Box::new(a.is(attr_id.to_inline())));
141                constraints.push(Box::new(set.pattern(start, a, dest)));
142                dest
143            }
144            PathExpr::InverseAttr(attr_id) => {
145                // ^p: dest p start (subject and value swap)
146                let a = ctx.next_variable::<GenId>();
147                let dest = ctx.next_variable::<GenId>();
148                constraints.push(Box::new(a.is(attr_id.to_inline())));
149                constraints.push(Box::new(set.pattern(dest, a, start)));
150                dest
151            }
152            PathExpr::NotAttr(_) | PathExpr::InverseNotAttr(_) => {
153                // Negated-attribute hops aren't expressible as a
154                // single TribleSet pattern constraint (the engine has
155                // no "attribute ≠ x" primitive). Treat them like
156                // closures: the caller wraps them in eval_from /
157                // has_path, which scans the set directly. The
158                // build_constraint path is only used for
159                // pure-Attr/InverseAttr Concat chains.
160                unreachable!("negated-attribute hops handled at eval_from level")
161            }
162            PathExpr::Concat(lhs, rhs) => {
163                let mid = lhs.build_constraint(set, ctx, start, constraints);
164                rhs.build_constraint(set, ctx, mid, constraints)
165            }
166            PathExpr::Union(..)
167            | PathExpr::Star(..)
168            | PathExpr::Plus(..)
169            | PathExpr::Optional(..) => {
170                unreachable!("closures, unions, and optionals handled at eval_from level")
171            }
172        }
173    }
174}
175
176/// Push `Inverse` down to `Attr` leaves via the standard reversal
177/// rewrites: `^(a/b) ↔ ^b/^a` (sequence reverses), `^(a|b) ↔ ^a|^b`,
178/// `^(a*) ↔ (^a)*`, `^(a+) ↔ (^a)+`, `^(a?) ↔ (^a)?`, `^^a ↔ a`.
179/// Result tree has `InverseAttr` only at leaves; no `Inverse` node is
180/// ever stored.
181fn invert(expr: PathExpr) -> PathExpr {
182    match expr {
183        PathExpr::Attr(a) => PathExpr::InverseAttr(a),
184        PathExpr::InverseAttr(a) => PathExpr::Attr(a),
185        PathExpr::NotAttr(a) => PathExpr::InverseNotAttr(a),
186        PathExpr::InverseNotAttr(a) => PathExpr::NotAttr(a),
187        // Sequence reverses: ^(a / b) = ^b / ^a
188        PathExpr::Concat(lhs, rhs) => PathExpr::Concat(Box::new(invert(*rhs)), Box::new(invert(*lhs))),
189        PathExpr::Union(lhs, rhs) => PathExpr::Union(Box::new(invert(*lhs)), Box::new(invert(*rhs))),
190        PathExpr::Star(body) => PathExpr::Star(Box::new(invert(*body))),
191        PathExpr::Plus(body) => PathExpr::Plus(Box::new(invert(*body))),
192        PathExpr::Optional(body) => PathExpr::Optional(Box::new(invert(*body))),
193    }
194}
195
196/// Distribute `Optional` and `Union` out of `Concat` so that
197/// `Concat(_, Optional(_))` and `Concat(Union(_,_), _)` become a top-
198/// level `Union` of pure-Concat branches, which the `build_constraint`
199/// machinery handles via the WCO sweep. Idempotent on already-normal
200/// trees. `Star`/`Plus` inside a Concat are NOT distributed —
201/// unbounded closures would expand to an infinite Union — so those
202/// shapes remain unsupported.
203fn normalize(expr: PathExpr) -> PathExpr {
204    match expr {
205        PathExpr::Attr(a) => PathExpr::Attr(a),
206        PathExpr::InverseAttr(a) => PathExpr::InverseAttr(a),
207        PathExpr::NotAttr(a) => PathExpr::NotAttr(a),
208        PathExpr::InverseNotAttr(a) => PathExpr::InverseNotAttr(a),
209        PathExpr::Concat(lhs, rhs) => {
210            let l = normalize(*lhs);
211            let r = normalize(*rhs);
212            distribute_concat(l, r)
213        }
214        PathExpr::Union(lhs, rhs) => {
215            PathExpr::Union(Box::new(normalize(*lhs)), Box::new(normalize(*rhs)))
216        }
217        PathExpr::Star(body) => PathExpr::Star(Box::new(normalize(*body))),
218        PathExpr::Plus(body) => PathExpr::Plus(Box::new(normalize(*body))),
219        PathExpr::Optional(body) => PathExpr::Optional(Box::new(normalize(*body))),
220    }
221}
222
223/// Build a `Concat(l, r)`, distributing `Optional`/`Union` from
224/// either side outward so the result has only pure-Attr/Concat
225/// chains under top-level `Union`/closure operations.
226fn distribute_concat(l: PathExpr, r: PathExpr) -> PathExpr {
227    match (l, r) {
228        // (a | b) / c  ↦  (a / c) | (b / c)
229        (PathExpr::Union(a, b), c) => PathExpr::Union(
230            Box::new(distribute_concat(*a, c.clone())),
231            Box::new(distribute_concat(*b, c)),
232        ),
233        // a / (b | c)  ↦  (a / b) | (a / c)
234        (a, PathExpr::Union(b, c)) => PathExpr::Union(
235            Box::new(distribute_concat(a.clone(), *b)),
236            Box::new(distribute_concat(a, *c)),
237        ),
238        // a? / c  ↦  c | (a / c)
239        (PathExpr::Optional(a), c) => PathExpr::Union(
240            Box::new(c.clone()),
241            Box::new(distribute_concat(*a, c)),
242        ),
243        // a / b?  ↦  a | (a / b)
244        (a, PathExpr::Optional(b)) => PathExpr::Union(
245            Box::new(a.clone()),
246            Box::new(distribute_concat(a, *b)),
247        ),
248        // Pure pattern: build the Concat directly.
249        (l, r) => PathExpr::Concat(Box::new(l), Box::new(r)),
250    }
251}
252
253/// Build the WCO join constraint for a non-closure expression with a bound start,
254/// returning the constraint and the destination variable index.
255fn build_join(
256    set: &TribleSet,
257    expr: &PathExpr,
258    start: &RawId,
259) -> (
260    IntersectionConstraint<Box<dyn Constraint<'static>>>,
261    VariableId,
262) {
263    let mut ctx = VariableContext::new();
264    let start_var = ctx.next_variable::<GenId>();
265    let mut constraints: Vec<Box<dyn Constraint<'static> + 'static>> = Vec::new();
266    constraints.push(Box::new(start_var.is(start.to_inline())));
267    let dest_var = expr.build_constraint(set, &mut ctx, start_var, &mut constraints);
268    (IntersectionConstraint::new(constraints), dest_var.index)
269}
270
271// ── Recursive path evaluator ─────────────────────────────────────────────
272
273/// Evaluate a path expression from a bound start node, returning all
274/// reachable endpoints. Uses the WCO join engine for Attr/Concat bodies
275/// and BFS for transitive closures.
276/// Single-attribute hop via direct index scan. No query engine overhead.
277fn eval_attr(set: &TribleSet, attr: &RawId, start: &RawId) -> HashSet<RawId> {
278    let mut results = HashSet::new();
279    let mut prefix = [0u8; ID_LEN * 2];
280    prefix[..ID_LEN].copy_from_slice(start);
281    prefix[ID_LEN..].copy_from_slice(attr);
282    set.eav
283        .infixes::<{ ID_LEN * 2 }, 32, _>(&prefix, |value: &[u8; 32]| {
284            if value[..ID_LEN] == [0; ID_LEN] {
285                let dest: RawId = value[ID_LEN..].try_into().unwrap();
286                results.insert(dest);
287            }
288        });
289    results
290}
291
292/// Negated-attribute hop: enumerate destinations reachable from
293/// `start` via any attribute other than `excluded`. Two-step scan
294/// because PATCH `infixes` requires whole-segment outputs:
295///   1. Enumerate attributes outgoing from `start` via EAV prefix
296///      `[start]`, filter out `excluded`.
297///   2. For each surviving attribute, enumerate GenId-encoded
298///      values via EAV prefix `[start, attr]` and collect their
299///      id-portion as the destination.
300fn eval_not_attr(set: &TribleSet, excluded: &RawId, start: &RawId) -> HashSet<RawId> {
301    let mut results = HashSet::new();
302    let mut e_prefix = [0u8; ID_LEN];
303    e_prefix.copy_from_slice(start);
304    // Step 1: enumerate distinct attributes from this entity.
305    let mut attrs: Vec<RawId> = Vec::new();
306    set.eav.infixes::<{ ID_LEN }, ID_LEN, _>(&e_prefix, |a: &[u8; ID_LEN]| {
307        if a == excluded {
308            return;
309        }
310        attrs.push(*a);
311    });
312    // Step 2: enumerate values per surviving attribute.
313    for attr in attrs {
314        let mut ea_prefix = [0u8; ID_LEN * 2];
315        ea_prefix[..ID_LEN].copy_from_slice(start);
316        ea_prefix[ID_LEN..].copy_from_slice(&attr);
317        set.eav
318            .infixes::<{ ID_LEN * 2 }, 32, _>(&ea_prefix, |value: &[u8; 32]| {
319                if value[..ID_LEN] == [0; ID_LEN] {
320                    let dest: RawId = value[ID_LEN..].try_into().unwrap();
321                    results.insert(dest);
322                }
323            });
324    }
325    results
326}
327
328/// Inverse negated-attribute hop: enumerate subjects `s` such that
329/// `s attr start` holds for some `attr ≠ excluded`. Two-step scan
330/// using the VAE index: enumerate attributes via prefix
331/// `[start_as_value]`, then enumerate entities per surviving
332/// attribute via `[start_as_value, attr]`.
333fn eval_not_attr_inverse(set: &TribleSet, excluded: &RawId, start: &RawId) -> HashSet<RawId> {
334    let mut results = HashSet::new();
335    let start_value = id_into_value(start);
336    let mut v_prefix = [0u8; 32];
337    v_prefix.copy_from_slice(&start_value);
338    let mut attrs: Vec<RawId> = Vec::new();
339    set.vae.infixes::<32, ID_LEN, _>(&v_prefix, |a: &[u8; ID_LEN]| {
340        if a == excluded {
341            return;
342        }
343        attrs.push(*a);
344    });
345    for attr in attrs {
346        let mut va_prefix = [0u8; 32 + ID_LEN];
347        va_prefix[..32].copy_from_slice(&start_value);
348        va_prefix[32..].copy_from_slice(&attr);
349        set.vae
350            .infixes::<{ 32 + ID_LEN }, ID_LEN, _>(&va_prefix, |entity: &[u8; ID_LEN]| {
351                results.insert(*entity);
352            });
353    }
354    results
355}
356
357/// Inverse single-attribute hop: enumerate subjects `s` such that
358/// `s attr start` holds. Uses the VAE index (Inline, Attribute,
359/// Entity ordering) so the prefix `[start_as_value (32B), attr
360/// (16B)]` lands directly at the slice of matching entity bytes.
361fn eval_attr_inverse(set: &TribleSet, attr: &RawId, start: &RawId) -> HashSet<RawId> {
362    let mut results = HashSet::new();
363    let start_value = id_into_value(start);
364    let mut prefix = [0u8; 32 + ID_LEN];
365    prefix[..32].copy_from_slice(&start_value);
366    prefix[32..].copy_from_slice(attr);
367    set.vae
368        .infixes::<{ 32 + ID_LEN }, ID_LEN, _>(&prefix, |entity: &[u8; ID_LEN]| {
369            results.insert(*entity);
370        });
371    results
372}
373
374/// Does this expression contain a transitive closure (Plus or Star)
375/// anywhere in its subtree? Concat-with-closure can't go through the
376/// WCO sweep because `build_constraint` doesn't have a Plus/Star
377/// arm — we fall back to per-mid evaluation instead.
378/// Returns true if this subtree must be evaluated via the per-mid
379/// `eval_from` fallback rather than through the WCO sweep on a
380/// composed pattern constraint. Includes both unbounded closures
381/// (`Plus`/`Star` — the original reason for the fallback) and
382/// negated-attribute hops (which have no native pattern-constraint
383/// equivalent because triblespace lacks an "attribute ≠ x"
384/// primitive).
385fn has_unbounded_closure(expr: &PathExpr) -> bool {
386    match expr {
387        PathExpr::Plus(_) | PathExpr::Star(_) => true,
388        PathExpr::NotAttr(_) | PathExpr::InverseNotAttr(_) => true,
389        PathExpr::Attr(_) | PathExpr::InverseAttr(_) => false,
390        PathExpr::Concat(a, b) | PathExpr::Union(a, b) => {
391            has_unbounded_closure(a) || has_unbounded_closure(b)
392        }
393        PathExpr::Optional(body) => has_unbounded_closure(body),
394    }
395}
396
397fn eval_from(set: &TribleSet, expr: &PathExpr, start: &RawId) -> HashSet<RawId> {
398    match expr {
399        PathExpr::Attr(attr) => eval_attr(set, attr, start),
400        PathExpr::InverseAttr(attr) => eval_attr_inverse(set, attr, start),
401        PathExpr::NotAttr(excluded) => eval_not_attr(set, excluded, start),
402        PathExpr::InverseNotAttr(excluded) => eval_not_attr_inverse(set, excluded, start),
403        PathExpr::Concat(lhs, rhs) => {
404            if has_unbounded_closure(lhs) || has_unbounded_closure(rhs) {
405                // Per-mid fallback: eval lhs from start, then for
406                // each mid value run eval_from(rhs, mid). Avoids
407                // build_constraint's `unreachable!()` arm for
408                // Plus/Star inside Concat.
409                let mut results = HashSet::new();
410                for mid in eval_from(set, lhs, start) {
411                    results.extend(eval_from(set, rhs, &mid));
412                }
413                return results;
414            }
415            let (constraint, dest_idx) = build_join(set, expr, start);
416            Query::new(constraint, move |binding: &Binding| {
417                let raw = binding.get(dest_idx)?;
418                id_from_value(raw)
419            })
420            .collect()
421        }
422        PathExpr::Union(lhs, rhs) => {
423            let mut results = eval_from(set, lhs, start);
424            results.extend(eval_from(set, rhs, start));
425            results
426        }
427        PathExpr::Plus(body) => {
428            let mut visited: HashSet<RawId> = HashSet::new();
429            let mut results: HashSet<RawId> = HashSet::new();
430            let mut frontier: VecDeque<RawId> = VecDeque::new();
431            frontier.push_back(*start);
432            visited.insert(*start);
433
434            while let Some(node) = frontier.pop_front() {
435                for dest in eval_from(set, body, &node) {
436                    results.insert(dest);
437                    if visited.insert(dest) {
438                        frontier.push_back(dest);
439                    }
440                }
441            }
442            results
443        }
444        PathExpr::Star(body) => {
445            let mut results = eval_from(set, &PathExpr::Plus(body.clone()), start);
446            results.insert(*start);
447            results
448        }
449        PathExpr::Optional(body) => {
450            let mut results = eval_from(set, body, start);
451            results.insert(*start);
452            results
453        }
454    }
455}
456
457fn has_path(set: &TribleSet, expr: &PathExpr, from: &RawId, to: &RawId) -> bool {
458    match expr {
459        PathExpr::Attr(attr) => eval_attr(set, attr, from).contains(to),
460        PathExpr::InverseAttr(attr) => eval_attr_inverse(set, attr, from).contains(to),
461        PathExpr::NotAttr(excluded) => eval_not_attr(set, excluded, from).contains(to),
462        PathExpr::InverseNotAttr(excluded) => eval_not_attr_inverse(set, excluded, from).contains(to),
463        PathExpr::Concat(lhs, rhs) if has_unbounded_closure(lhs) || has_unbounded_closure(rhs) => {
464            // Per-mid fallback (matches eval_from arm).
465            for mid in eval_from(set, lhs, from) {
466                if has_path(set, rhs, &mid, to) {
467                    return true;
468                }
469            }
470            false
471        }
472        PathExpr::Concat(_, _) => {
473            let (constraint, dest_idx) = build_join(set, expr, from);
474            Query::new(constraint, move |binding: &Binding| {
475                let raw = binding.get(dest_idx)?;
476                id_from_value(raw)
477            })
478            .any(|dest| dest == *to)
479        }
480        PathExpr::Union(lhs, rhs) => has_path(set, lhs, from, to) || has_path(set, rhs, from, to),
481        PathExpr::Plus(body) => {
482            let mut visited: HashSet<RawId> = HashSet::new();
483            let mut frontier: VecDeque<RawId> = VecDeque::new();
484            frontier.push_back(*from);
485            visited.insert(*from);
486
487            while let Some(node) = frontier.pop_front() {
488                for dest in eval_from(set, body, &node) {
489                    if dest == *to {
490                        return true;
491                    }
492                    if visited.insert(dest) {
493                        frontier.push_back(dest);
494                    }
495                }
496            }
497            false
498        }
499        PathExpr::Star(body) => {
500            if from == to {
501                return true;
502            }
503            has_path(set, &PathExpr::Plus(body.clone()), from, to)
504        }
505        PathExpr::Optional(body) => {
506            if from == to {
507                return true;
508            }
509            has_path(set, body, from, to)
510        }
511    }
512}
513
514/// Default depth bound for closure-cardinality estimation when shallow
515/// estimation doesn't apply (per Karalis et al. ESWC 2024 §4.3 default
516/// estimation). Five closure iterations is enough to distinguish dense
517/// from sparse expansion for variable-ordering purposes without paying
518/// the cost of full materialisation.
519const RPQ_ESTIMATE_DEPTH: usize = 5;
520
521/// Like `eval_from` but caps closure (Plus/Star) iterations at
522/// `depth` levels. Used for cardinality estimation only — the result
523/// is a lower bound on the true closure reachability, sufficient for
524/// driving the WCO planner's variable ordering. Non-closure
525/// expressions (Attr/InverseAttr/Concat/Union) don't consume depth.
526///
527/// Nested closures multiply: `Plus(Plus(q))` will run the inner Plus
528/// to `depth` steps for each of the outer Plus's `depth` steps, so
529/// total work is `O(depth^k)` for closure-nesting depth `k`. In
530/// practice path expressions rarely nest beyond one closure.
531fn bounded_eval_from(
532    set: &TribleSet,
533    expr: &PathExpr,
534    start: &RawId,
535    depth: usize,
536) -> HashSet<RawId> {
537    match expr {
538        PathExpr::Attr(attr) => eval_attr(set, attr, start),
539        PathExpr::InverseAttr(attr) => eval_attr_inverse(set, attr, start),
540        PathExpr::NotAttr(excluded) => eval_not_attr(set, excluded, start),
541        PathExpr::InverseNotAttr(excluded) => eval_not_attr_inverse(set, excluded, start),
542        PathExpr::Concat(lhs, rhs) => {
543            let mut results = HashSet::new();
544            for mid in bounded_eval_from(set, lhs, start, depth) {
545                results.extend(bounded_eval_from(set, rhs, &mid, depth));
546            }
547            results
548        }
549        PathExpr::Union(lhs, rhs) => {
550            let mut results = bounded_eval_from(set, lhs, start, depth);
551            results.extend(bounded_eval_from(set, rhs, start, depth));
552            results
553        }
554        PathExpr::Plus(body) => {
555            let mut results: HashSet<RawId> = HashSet::new();
556            let mut visited: HashSet<RawId> = HashSet::new();
557            let mut frontier: Vec<RawId> = vec![*start];
558            visited.insert(*start);
559            for _ in 0..depth {
560                let mut next: Vec<RawId> = Vec::new();
561                for node in &frontier {
562                    for dest in bounded_eval_from(set, body, node, depth) {
563                        results.insert(dest);
564                        if visited.insert(dest) {
565                            next.push(dest);
566                        }
567                    }
568                }
569                if next.is_empty() {
570                    break;
571                }
572                frontier = next;
573            }
574            results
575        }
576        PathExpr::Star(body) => {
577            let mut results = bounded_eval_from(
578                set,
579                &PathExpr::Plus(body.clone()),
580                start,
581                depth,
582            );
583            results.insert(*start);
584            results
585        }
586        PathExpr::Optional(body) => {
587            let mut results = bounded_eval_from(set, body, start, depth);
588            results.insert(*start);
589            results
590        }
591    }
592}
593
594/// Shallow estimate: build the one-step constraint and ask it for the
595/// destination variable's cardinality with the start bound.
596fn estimate_from(set: &TribleSet, expr: &PathExpr, start: &RawId) -> usize {
597    // Unwrap closure to get the body for estimation.
598    let body = match expr {
599        PathExpr::Star(inner) | PathExpr::Plus(inner) | PathExpr::Optional(inner) => {
600            inner.as_ref()
601        }
602        other => other,
603    };
604    match body {
605        PathExpr::Attr(attr) => {
606            let mut prefix = [0u8; ID_LEN * 2];
607            prefix[..ID_LEN].copy_from_slice(start);
608            prefix[ID_LEN..].copy_from_slice(attr);
609            set.eav.segmented_len(&prefix) as usize
610        }
611        PathExpr::InverseAttr(attr) => {
612            let start_value = id_into_value(start);
613            let mut prefix = [0u8; 32 + ID_LEN];
614            prefix[..32].copy_from_slice(&start_value);
615            prefix[32..].copy_from_slice(attr);
616            set.vae.segmented_len(&prefix) as usize
617        }
618        PathExpr::Union(lhs, rhs) => {
619            estimate_from(set, lhs, start) + estimate_from(set, rhs, start)
620        }
621        // Concat with a Plus/Star sub-tree can't go through
622        // build_join (the per-mid fallback in eval_from is what
623        // makes it work). Karalis et al. ESWC 2024 §4.3: when
624        // shallow estimation doesn't apply, evaluate the closure
625        // up to `RPQ_ESTIMATE_DEPTH` and use the partial count as
626        // the estimate — bounded depth → bounded estimate cost,
627        // sufficient for driving variable-ordering decisions.
628        // (The full-materialisation fallback that used to live
629        // here scaled with the actual closure size, defeating the
630        // purpose of having a cheap estimate.)
631        _ if has_unbounded_closure(body) => {
632            bounded_eval_from(set, body, start, RPQ_ESTIMATE_DEPTH).len()
633        }
634        _ => {
635            let (constraint, dest_idx) = build_join(set, body, start);
636            let mut binding = Binding::default();
637            let start_inline: Inline<GenId> = start.to_inline();
638            binding.set(0, &start_inline.raw);
639            constraint.estimate(dest_idx, &binding).unwrap_or(0)
640        }
641    }
642}
643
644// ── Constraint ───────────────────────────────────────────────────────────
645
646/// Constrains two variables to be connected by a regular path expression.
647///
648/// Created by the [`path!`](crate::macros::path) macro. The path expression
649/// supports concatenation, alternation (`|`), transitive closure (`+`),
650/// and reflexive-transitive closure (`*`). Single-attribute hops use
651/// direct index scans; multi-step paths use the WCO join engine for
652/// concatenation and BFS for closures.
653///
654/// When the start variable is bound, propose enumerates all reachable
655/// endpoints. When the end is bound, confirm checks reachability.
656pub struct RegularPathConstraint {
657    start: VariableId,
658    end: VariableId,
659    expr: PathExpr,
660    /// `invert(expr)` — cached so end-bound proposals can BFS
661    /// backward via `eval_from` symmetrically to start-bound
662    /// proposals. `invert` is pure and the constraint is reused
663    /// across many estimate/propose calls per query, so the
664    /// one-time clone-and-invert at construction pays for
665    /// itself.
666    inverse_expr: PathExpr,
667    set: TribleSet,
668}
669
670impl RegularPathConstraint {
671    /// Creates a path constraint from `start` to `end` over the given
672    /// postfix-encoded path operations.
673    pub fn new(
674        set: TribleSet,
675        start: Variable<GenId>,
676        end: Variable<GenId>,
677        ops: &[PathOp],
678    ) -> Self {
679        let expr = PathExpr::from_postfix(ops);
680        let inverse_expr = invert(expr.clone());
681        RegularPathConstraint {
682            start: start.index,
683            end: end.index,
684            expr,
685            inverse_expr,
686            set,
687        }
688    }
689
690    /// Lazily collect all GenId nodes in the TribleSet.
691    /// Only called when neither start nor end is bound.
692    fn all_nodes(&self) -> Vec<RawInline> {
693        let mut node_set: HashSet<RawInline> = HashSet::new();
694        for t in self.set.iter() {
695            let v = &t.data[32..64];
696            if v[..ID_LEN] == [0; ID_LEN] {
697                let dest: RawId = v[ID_LEN..].try_into().unwrap();
698                node_set.insert(id_into_value(&dest));
699                let e: RawId = t.data[..ID_LEN].try_into().unwrap();
700                node_set.insert(id_into_value(&e));
701            }
702        }
703        node_set.into_iter().collect()
704    }
705}
706
707impl<'a> Constraint<'a> for RegularPathConstraint {
708    fn variables(&self) -> VariableSet {
709        let mut vars = VariableSet::new_empty();
710        vars.set(self.start);
711        vars.set(self.end);
712        vars
713    }
714
715    fn estimate(&self, variable: VariableId, binding: &Binding) -> Option<usize> {
716        // Same-Variable case: rough upper bound is set size; the
717        // exact count requires scanning self-loops. Conservative
718        // estimate avoids the O(N) scan on every call.
719        if self.start == self.end && variable == self.start {
720            return Some(self.set.len());
721        }
722        if variable == self.end {
723            if let Some(start_val) = binding.get(self.start) {
724                if let Some(start_id) = id_from_value(start_val) {
725                    return Some(estimate_from(&self.set, &self.expr, &start_id).max(1));
726                }
727                return Some(0);
728            }
729            Some(self.set.len())
730        } else if variable == self.start {
731            if let Some(end_val) = binding.get(self.end) {
732                if let Some(end_id) = id_from_value(end_val) {
733                    // Symmetric to the start-bound case: BFS
734                    // backward via the inverted expression from
735                    // end_id, giving a tight estimate instead of
736                    // the conservative set-len fallback.
737                    return Some(estimate_from(&self.set, &self.inverse_expr, &end_id).max(1));
738                }
739                return Some(0);
740            }
741            Some(self.set.len())
742        } else {
743            None
744        }
745    }
746
747    fn propose(&self, variable: VariableId, binding: &Binding, proposals: &mut Vec<RawInline>) {
748        // Same-Variable case: `?x P+ ?x` (start and end map to
749        // the same VariableId). Enumerate only nodes with a
750        // self-loop via the path, rather than the cross-product
751        // of all reachable (start, end) pairs.
752        if self.start == self.end && variable == self.start {
753            let candidates = self.all_nodes();
754            proposals.extend(candidates.into_iter().filter(|v| {
755                id_from_value(v)
756                    .map_or(false, |id| has_path(&self.set, &self.expr, &id, &id))
757            }));
758            return;
759        }
760        if variable == self.end {
761            if let Some(start_val) = binding.get(self.start) {
762                if let Some(start_id) = id_from_value(start_val) {
763                    let reachable = eval_from(&self.set, &self.expr, &start_id);
764                    proposals.extend(reachable.iter().map(id_into_value));
765                }
766                return;
767            }
768        }
769        if variable == self.start {
770            if let Some(end_val) = binding.get(self.end) {
771                // End is bound; propose only those start nodes that
772                // actually reach `end` via `expr`. Symmetric to the
773                // start-bound case: one BFS backward via the
774                // inverted expression from `end_id` enumerates
775                // every valid start, with dedup falling out of
776                // `eval_from`'s internal HashSet and the
777                // reflexive-path rule (`end_id` is a valid start
778                // for `(p)*` / `(p)?`) handled inside Star/Optional.
779                if let Some(end_id) = id_from_value(end_val) {
780                    let reachable = eval_from(&self.set, &self.inverse_expr, &end_id);
781                    proposals.extend(reachable.iter().map(id_into_value));
782                }
783                return;
784            }
785        }
786        if variable == self.start || variable == self.end {
787            proposals.extend(self.all_nodes());
788        }
789    }
790
791    fn confirm(&self, variable: VariableId, binding: &Binding, proposals: &mut Vec<RawInline>) {
792        // Same-Variable case: filter proposals to those with a
793        // self-loop via the path expression.
794        if self.start == self.end && variable == self.start {
795            proposals.retain(|v| {
796                id_from_value(v)
797                    .map_or(false, |id| has_path(&self.set, &self.expr, &id, &id))
798            });
799            return;
800        }
801        if variable == self.start {
802            if let Some(end_val) = binding.get(self.end) {
803                if let Some(end_id) = id_from_value(end_val) {
804                    proposals.retain(|v| {
805                        id_from_value(v)
806                            .map_or(false, |sid| has_path(&self.set, &self.expr, &sid, &end_id))
807                    });
808                } else {
809                    proposals.clear();
810                }
811            }
812        } else if variable == self.end {
813            if let Some(start_val) = binding.get(self.start) {
814                if let Some(start_id) = id_from_value(start_val) {
815                    proposals.retain(|v| {
816                        id_from_value(v).map_or(false, |eid| {
817                            has_path(&self.set, &self.expr, &start_id, &eid)
818                        })
819                    });
820                } else {
821                    proposals.clear();
822                }
823            }
824        }
825    }
826}