Skip to content

Commit 65756b9

Browse files
authored
Merge pull request #456 from AdaWorldAPI/feat/pearl-junction-classifier
feat(hhtl + pearl_junction): NiblePath utility methods + Pearl-junction figure classifier
2 parents d790835 + 4faa7e4 commit 65756b9

3 files changed

Lines changed: 413 additions & 0 deletions

File tree

crates/lance-graph-contract/src/hhtl.rs

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,93 @@ impl NiblePath {
187187
pub const fn packed(self) -> (u64, u8) {
188188
(self.path, self.depth)
189189
}
190+
191+
/// Is this path a descendant-or-equal of `other`? — the symmetric form of
192+
/// [`is_ancestor_of`]. `self.is_descendant_of(other)` is equivalent to
193+
/// `other.is_ancestor_of(self)` BUT the form is sometimes more natural at
194+
/// the call site (e.g. iterating over candidate ancestors).
195+
///
196+
/// Like [`is_ancestor_of`], the empty path is never a descendant of
197+
/// anything.
198+
#[must_use]
199+
pub const fn is_descendant_of(self, other: Self) -> bool {
200+
other.is_ancestor_of(self)
201+
}
202+
203+
/// Are `self` and `other` siblings — distinct paths that share the SAME
204+
/// parent (and thus the same depth)? Returns `false` if either is the
205+
/// basin (depth 1 — basins have no parent in this tree), if the depths
206+
/// differ, or if the paths are equal.
207+
///
208+
/// Together with [`is_ancestor_of`] / [`is_descendant_of`] this exposes
209+
/// the three structural relations the Pearl-junction classifier
210+
/// (`crate::pearl_junction`) needs without forcing the caller to do its
211+
/// own bit-shift arithmetic.
212+
#[must_use]
213+
pub const fn is_sibling_of(self, other: Self) -> bool {
214+
if self.depth != other.depth || self.depth <= 1 || self.path == other.path {
215+
return false;
216+
}
217+
// Same depth + same parent ⇔ matching top (depth−1) nibbles ⇔
218+
// matching all bits except the low 4 (the leaf nibble).
219+
const LEAF_MASK: u64 = !0x0F_u64;
220+
(self.path & LEAF_MASK) == (other.path & LEAF_MASK)
221+
}
222+
223+
/// The longest common ancestor path — the longest prefix shared by
224+
/// `self` and `other`. `None` if the two paths share no basin (they
225+
/// live in disjoint DOLCE-facet subtrees, OR either is the empty path).
226+
///
227+
/// Symmetric in its arguments: `a.common_ancestor(b) == b.common_ancestor(a)`.
228+
///
229+
/// O(depth) — at most `MAX_DEPTH` nibble-shifts in the worst case.
230+
#[must_use]
231+
pub const fn common_ancestor(self, other: Self) -> Option<Self> {
232+
if self.depth == 0 || other.depth == 0 {
233+
return None;
234+
}
235+
// Align both paths to the shallower depth, then walk up until the
236+
// packed prefixes agree. Once we reach depth 0 without a match,
237+
// the two paths share no basin.
238+
let mut a_path = self.path;
239+
let mut a_depth = self.depth;
240+
let mut b_path = other.path;
241+
let mut b_depth = other.depth;
242+
while a_depth > b_depth {
243+
a_path >>= 4;
244+
a_depth -= 1;
245+
}
246+
while b_depth > a_depth {
247+
b_path >>= 4;
248+
b_depth -= 1;
249+
}
250+
// Same depth now. Walk up until the bits match.
251+
while a_path != b_path {
252+
if a_depth <= 1 {
253+
// Reaching depth 0 means the paths share no basin; reaching
254+
// depth 1 with no match means the basins themselves differ.
255+
if a_depth == 1 {
256+
return None;
257+
}
258+
a_path >>= 4;
259+
b_path >>= 4;
260+
a_depth -= 1;
261+
continue;
262+
}
263+
a_path >>= 4;
264+
b_path >>= 4;
265+
a_depth -= 1;
266+
}
267+
if a_depth == 0 {
268+
None
269+
} else {
270+
Some(Self {
271+
path: a_path,
272+
depth: a_depth,
273+
})
274+
}
275+
}
276+
190277
}
191278

192279
#[cfg(test)]
@@ -323,4 +410,76 @@ mod tests {
323410
"out-of-range nibble is None too"
324411
);
325412
}
413+
414+
#[test]
415+
fn is_descendant_of_inverse_of_is_ancestor_of() {
416+
let mammal = NiblePath::root(0x1);
417+
let dog = NiblePath::root(0x1).child(0x1);
418+
let cat = NiblePath::root(0x2);
419+
assert!(dog.is_descendant_of(mammal));
420+
assert!(!mammal.is_descendant_of(dog));
421+
assert!(!dog.is_descendant_of(cat));
422+
// empty path is never a descendant of anything
423+
assert!(!NiblePath::EMPTY.is_descendant_of(mammal));
424+
}
425+
426+
#[test]
427+
fn is_sibling_of_requires_same_parent_distinct_paths() {
428+
let dog = NiblePath::root(0x1).child(0x1);
429+
let cat = NiblePath::root(0x1).child(0x2);
430+
let lance = NiblePath::root(0x1).child(0x1);
431+
// siblings: same parent (mammal), distinct leaf nibbles
432+
assert!(dog.is_sibling_of(cat));
433+
assert!(cat.is_sibling_of(dog));
434+
// not siblings: equal paths
435+
assert!(!dog.is_sibling_of(lance));
436+
// not siblings: different depth
437+
let mammal = NiblePath::root(0x1);
438+
assert!(!dog.is_sibling_of(mammal));
439+
// not siblings: different parent
440+
let plant = NiblePath::root(0x2).child(0x1);
441+
assert!(!dog.is_sibling_of(plant));
442+
// basins themselves are not siblings (depth 1, no parent)
443+
let b1 = NiblePath::root(0x1);
444+
let b2 = NiblePath::root(0x2);
445+
assert!(!b1.is_sibling_of(b2));
446+
}
447+
448+
#[test]
449+
fn common_ancestor_returns_longest_shared_prefix() {
450+
// (1)(2)(3)(4) and (1)(2)(5)(6) share (1)(2)
451+
let a = NiblePath::root(0x1).child(0x2).child(0x3).child(0x4);
452+
let b = NiblePath::root(0x1).child(0x2).child(0x5).child(0x6);
453+
let lca = a.common_ancestor(b).unwrap();
454+
assert_eq!(lca.depth(), 2);
455+
assert_eq!(lca.basin(), Some(0x1));
456+
assert_eq!(lca.leaf(), Some(0x2));
457+
// symmetric
458+
assert_eq!(b.common_ancestor(a), Some(lca));
459+
}
460+
461+
#[test]
462+
fn common_ancestor_handles_different_depths() {
463+
// (1)(2) is an ancestor of (1)(2)(3); LCA should be (1)(2)
464+
let shallow = NiblePath::root(0x1).child(0x2);
465+
let deep = NiblePath::root(0x1).child(0x2).child(0x3);
466+
assert_eq!(shallow.common_ancestor(deep), Some(shallow));
467+
assert_eq!(deep.common_ancestor(shallow), Some(shallow));
468+
}
469+
470+
#[test]
471+
fn common_ancestor_disjoint_basins_returns_none() {
472+
// different basins → no common ancestor in this tree
473+
let a = NiblePath::root(0x1).child(0x2);
474+
let b = NiblePath::root(0x3).child(0x4);
475+
assert_eq!(a.common_ancestor(b), None);
476+
assert_eq!(b.common_ancestor(a), None);
477+
}
478+
479+
#[test]
480+
fn common_ancestor_empty_path_returns_none() {
481+
let a = NiblePath::root(0x1);
482+
assert_eq!(a.common_ancestor(NiblePath::EMPTY), None);
483+
assert_eq!(NiblePath::EMPTY.common_ancestor(a), None);
484+
}
326485
}

crates/lance-graph-contract/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ pub mod ocr;
7373
pub mod ontology;
7474
pub mod orchestration;
7575
pub mod orchestration_mode;
76+
pub mod pearl_junction;
7677
pub mod persona;
7778
pub mod plan;
7879
pub mod property;

0 commit comments

Comments
 (0)