Skip to content

Commit 4faa7e4

Browse files
committed
feat(hhtl + pearl_junction): NiblePath utility methods + Pearl-junction figure classifier
Adds the small surface that retires the "thinking = addressing" conjecture (E-4 in bardioc's EPIPHANIES.md) into committed code, plus three utility methods on NiblePath that the classifier and other downstream consumers need. NiblePath additions (crates/lance-graph-contract/src/hhtl.rs): - is_descendant_of(other) - symmetric companion to existing is_ancestor_of; equivalent to other.is_ancestor_of(self) but reads more naturally at some call sites. - is_sibling_of(other) - distinct paths sharing the same parent (and thus the same depth). False for basins (no parent), differing depths, or equal paths. - common_ancestor(other) - longest common prefix; None if the paths share no basin or either is empty. Symmetric. All three are const fn + #[must_use], matching the existing NiblePath surface conventions. Pure bit-shift arithmetic; O(depth) in the worst case for common_ancestor; tests cover roundtrip + symmetry + the empty-path / basin-mismatch edges. NEW module: crates/lance-graph-contract/src/pearl_junction.rs Classifies a pair of SPO edges (s1->o1, s2->o2) into Pearl's three causal junctions plus the reverse chain: Chain (o1 == s2) - head-to-tail; Deduction ChainRev (s1 == o2) - reverse chain; Deduction Fork (s1 == s2) - common cause (shared subject is the child); Induction Collider (o1 == o2) - explaining-away (shared object is the parent); Abduction Unrelated - no shared term Pure-function classifier; no graph walk; const fn end-to-end (uses a const-context NiblePath equality helper because PartialEq is not const in stable Rust 1.95). Anti-swap guard: the canonical dog/cat/mammal example is the first test and is enumerated in the module docstring. The earlier framing in the peer-session reviews had SharedSubject / SharedObject inverted relative to the induction/abduction chirality; the corrected mapping ships here with the dog/cat example so the inversion cannot recur silently. Tests cover: collider (dog/cat/mammal), fork (dog->mammal, dog->pet), chain (dog->mammal->animal), ChainRev, Unrelated, the deterministic order-of-checks (Chain fires before ChainRev when both would match), and a const-context invocation proving the classifier works at compile time. Sister module to nars (which owns the full InferenceType taxonomy); PearlJunction::nars_rule() returns the subset (Deduction/Induction/ Abduction) that arises from the three-junction classification. Provenance: bardioc EPIPHANIES.md entry E-4 (corrected), peer-session review rounds 1+2 on bardioc PR #15, and the lance-graph/.claude/plans/* references to FIGURE_RULES that name the same classification structurally.
1 parent 2d78b85 commit 4faa7e4

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)