From 4faa7e471d3a60b62ef3a8d1de669447d03ececc Mon Sep 17 00:00:00 2001 From: "jan (via bardioc)" Date: Wed, 3 Jun 2026 12:50:59 +0000 Subject: [PATCH] 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. --- crates/lance-graph-contract/src/hhtl.rs | 159 +++++++++++ crates/lance-graph-contract/src/lib.rs | 1 + .../src/pearl_junction.rs | 253 ++++++++++++++++++ 3 files changed, 413 insertions(+) create mode 100644 crates/lance-graph-contract/src/pearl_junction.rs diff --git a/crates/lance-graph-contract/src/hhtl.rs b/crates/lance-graph-contract/src/hhtl.rs index 9cf1bb9e..06533bc0 100644 --- a/crates/lance-graph-contract/src/hhtl.rs +++ b/crates/lance-graph-contract/src/hhtl.rs @@ -187,6 +187,93 @@ impl NiblePath { pub const fn packed(self) -> (u64, u8) { (self.path, self.depth) } + + /// Is this path a descendant-or-equal of `other`? — the symmetric form of + /// [`is_ancestor_of`]. `self.is_descendant_of(other)` is equivalent to + /// `other.is_ancestor_of(self)` BUT the form is sometimes more natural at + /// the call site (e.g. iterating over candidate ancestors). + /// + /// Like [`is_ancestor_of`], the empty path is never a descendant of + /// anything. + #[must_use] + pub const fn is_descendant_of(self, other: Self) -> bool { + other.is_ancestor_of(self) + } + + /// Are `self` and `other` siblings — distinct paths that share the SAME + /// parent (and thus the same depth)? Returns `false` if either is the + /// basin (depth 1 — basins have no parent in this tree), if the depths + /// differ, or if the paths are equal. + /// + /// Together with [`is_ancestor_of`] / [`is_descendant_of`] this exposes + /// the three structural relations the Pearl-junction classifier + /// (`crate::pearl_junction`) needs without forcing the caller to do its + /// own bit-shift arithmetic. + #[must_use] + pub const fn is_sibling_of(self, other: Self) -> bool { + if self.depth != other.depth || self.depth <= 1 || self.path == other.path { + return false; + } + // Same depth + same parent ⇔ matching top (depth−1) nibbles ⇔ + // matching all bits except the low 4 (the leaf nibble). + const LEAF_MASK: u64 = !0x0F_u64; + (self.path & LEAF_MASK) == (other.path & LEAF_MASK) + } + + /// The longest common ancestor path — the longest prefix shared by + /// `self` and `other`. `None` if the two paths share no basin (they + /// live in disjoint DOLCE-facet subtrees, OR either is the empty path). + /// + /// Symmetric in its arguments: `a.common_ancestor(b) == b.common_ancestor(a)`. + /// + /// O(depth) — at most `MAX_DEPTH` nibble-shifts in the worst case. + #[must_use] + pub const fn common_ancestor(self, other: Self) -> Option { + if self.depth == 0 || other.depth == 0 { + return None; + } + // Align both paths to the shallower depth, then walk up until the + // packed prefixes agree. Once we reach depth 0 without a match, + // the two paths share no basin. + let mut a_path = self.path; + let mut a_depth = self.depth; + let mut b_path = other.path; + let mut b_depth = other.depth; + while a_depth > b_depth { + a_path >>= 4; + a_depth -= 1; + } + while b_depth > a_depth { + b_path >>= 4; + b_depth -= 1; + } + // Same depth now. Walk up until the bits match. + while a_path != b_path { + if a_depth <= 1 { + // Reaching depth 0 means the paths share no basin; reaching + // depth 1 with no match means the basins themselves differ. + if a_depth == 1 { + return None; + } + a_path >>= 4; + b_path >>= 4; + a_depth -= 1; + continue; + } + a_path >>= 4; + b_path >>= 4; + a_depth -= 1; + } + if a_depth == 0 { + None + } else { + Some(Self { + path: a_path, + depth: a_depth, + }) + } + } + } #[cfg(test)] @@ -323,4 +410,76 @@ mod tests { "out-of-range nibble is None too" ); } + + #[test] + fn is_descendant_of_inverse_of_is_ancestor_of() { + let mammal = NiblePath::root(0x1); + let dog = NiblePath::root(0x1).child(0x1); + let cat = NiblePath::root(0x2); + assert!(dog.is_descendant_of(mammal)); + assert!(!mammal.is_descendant_of(dog)); + assert!(!dog.is_descendant_of(cat)); + // empty path is never a descendant of anything + assert!(!NiblePath::EMPTY.is_descendant_of(mammal)); + } + + #[test] + fn is_sibling_of_requires_same_parent_distinct_paths() { + let dog = NiblePath::root(0x1).child(0x1); + let cat = NiblePath::root(0x1).child(0x2); + let lance = NiblePath::root(0x1).child(0x1); + // siblings: same parent (mammal), distinct leaf nibbles + assert!(dog.is_sibling_of(cat)); + assert!(cat.is_sibling_of(dog)); + // not siblings: equal paths + assert!(!dog.is_sibling_of(lance)); + // not siblings: different depth + let mammal = NiblePath::root(0x1); + assert!(!dog.is_sibling_of(mammal)); + // not siblings: different parent + let plant = NiblePath::root(0x2).child(0x1); + assert!(!dog.is_sibling_of(plant)); + // basins themselves are not siblings (depth 1, no parent) + let b1 = NiblePath::root(0x1); + let b2 = NiblePath::root(0x2); + assert!(!b1.is_sibling_of(b2)); + } + + #[test] + fn common_ancestor_returns_longest_shared_prefix() { + // (1)(2)(3)(4) and (1)(2)(5)(6) share (1)(2) + let a = NiblePath::root(0x1).child(0x2).child(0x3).child(0x4); + let b = NiblePath::root(0x1).child(0x2).child(0x5).child(0x6); + let lca = a.common_ancestor(b).unwrap(); + assert_eq!(lca.depth(), 2); + assert_eq!(lca.basin(), Some(0x1)); + assert_eq!(lca.leaf(), Some(0x2)); + // symmetric + assert_eq!(b.common_ancestor(a), Some(lca)); + } + + #[test] + fn common_ancestor_handles_different_depths() { + // (1)(2) is an ancestor of (1)(2)(3); LCA should be (1)(2) + let shallow = NiblePath::root(0x1).child(0x2); + let deep = NiblePath::root(0x1).child(0x2).child(0x3); + assert_eq!(shallow.common_ancestor(deep), Some(shallow)); + assert_eq!(deep.common_ancestor(shallow), Some(shallow)); + } + + #[test] + fn common_ancestor_disjoint_basins_returns_none() { + // different basins → no common ancestor in this tree + let a = NiblePath::root(0x1).child(0x2); + let b = NiblePath::root(0x3).child(0x4); + assert_eq!(a.common_ancestor(b), None); + assert_eq!(b.common_ancestor(a), None); + } + + #[test] + fn common_ancestor_empty_path_returns_none() { + let a = NiblePath::root(0x1); + assert_eq!(a.common_ancestor(NiblePath::EMPTY), None); + assert_eq!(NiblePath::EMPTY.common_ancestor(a), None); + } } diff --git a/crates/lance-graph-contract/src/lib.rs b/crates/lance-graph-contract/src/lib.rs index 09c94a5c..e2a7e2c7 100644 --- a/crates/lance-graph-contract/src/lib.rs +++ b/crates/lance-graph-contract/src/lib.rs @@ -73,6 +73,7 @@ pub mod ocr; pub mod ontology; pub mod orchestration; pub mod orchestration_mode; +pub mod pearl_junction; pub mod persona; pub mod plan; pub mod property; diff --git a/crates/lance-graph-contract/src/pearl_junction.rs b/crates/lance-graph-contract/src/pearl_junction.rs new file mode 100644 index 00000000..c05b5cb5 --- /dev/null +++ b/crates/lance-graph-contract/src/pearl_junction.rs @@ -0,0 +1,253 @@ +//! # `pearl_junction` — Pearl's three causal junctions over HHTL identity +//! +//! Classifies a pair of SPO edges into one of Pearl's three causal junctions +//! (chain / fork / collider) plus the reverse chain. The classification is +//! a pure function of identity equality between the four `NiblePath` +//! endpoints (subject and object of each edge); no graph walk is required. +//! +//! ## The mapping (E-4 corrected, per `bardioc/.claude/EPIPHANIES.md`) +//! +//! Reading `s -> o` as `s subClassOf o` (or any other transitive edge): +//! +//! | Junction | Shared term | Example | NARS rule | ΔHHTL signature | +//! |----------------|---------------------|----------------------------------|------------|------------------------------| +//! | **Chain** | `o1 == s2` | `dog -> mammal -> animal` | Deduction | small, along one lineage | +//! | **ChainRev** | `s1 == o2` | reverse of Chain | Deduction | small, along one lineage | +//! | **Fork** | `s1 == s2` (child) | `dog -> mammal`, `dog -> pet` | Induction | 2× up to common child | +//! | **Collider** | `o1 == o2` (parent) | `dog -> mammal`, `cat -> mammal` | Abduction | 2× up to common parent | +//! | **Unrelated** | (no shared term) | — | — | — | +//! +//! Anti-swap guard (per peer-review round-2 — the earlier `SharedSubject = +//! sibling-via-parent` / `SharedObject = sibling-via-child` framing inverted +//! the induction⇄abduction chirality; this module's tests use the +//! `dog/cat/mammal` example as the canonical anti-swap guard). +//! +//! ## Why this is in the contract crate +//! +//! The classifier is pure-function — it does NOT touch storage, indexes, +//! or any planner state. It IS the bridge between SPO grammar (figure +//! rules) and HHTL identity addressing. Per the Morris semiotic trichotomy +//! mapped to lance-graph code (see `EPIPHANIES.md`), this is **syntax** +//! (figure rules) operating over **semantics** (HHTL nodes); pragmatics +//! (the cascade fold) consumes the classification at runtime. + +use crate::hhtl::NiblePath; + +/// Pearl's causal-junction taxonomy applied to a pair of SPO edges. +/// +/// The classification is determined by identity equality between the +/// four endpoints (`s1`, `o1`, `s2`, `o2`); no graph walk is required. +/// See module docstring for the canonical mapping + examples. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum PearlJunction { + /// `o1 == s2` — chain: `s1 -> o1=s2 -> o2`. Head-to-tail. Deduction. + Chain, + /// `s1 == o2` — reverse chain: `o1 <- s1=o2 <- s2`. Head-to-tail (other + /// direction). Deduction. + ChainRev, + /// `s1 == s2` — fork (common cause): the shared subject is the + /// **child**; `o1` and `o2` are co-parents reachable via one common + /// descendant. Conclusion `o1 -> o2` is Induction. + Fork, + /// `o1 == o2` — collider (explaining-away): the shared object is the + /// **parent**; `s1` and `s2` are siblings under one common ancestor. + /// Conclusion `s1 -> s2` is Abduction. + Collider, + /// No shared term between the two edges. + Unrelated, +} + +impl PearlJunction { + /// Stable label for reports / logs / diff dimensions. + pub const fn label(self) -> &'static str { + match self { + Self::Chain => "chain", + Self::ChainRev => "chain_rev", + Self::Fork => "fork", + Self::Collider => "collider", + Self::Unrelated => "unrelated", + } + } + + /// The NARS-style inference rule the junction selects. `None` for + /// `Unrelated`. (Chain / ChainRev select Deduction; Fork selects + /// Induction; Collider selects Abduction.) + pub const fn nars_rule(self) -> Option { + match self { + Self::Chain | Self::ChainRev => Some(NarsRule::Deduction), + Self::Fork => Some(NarsRule::Induction), + Self::Collider => Some(NarsRule::Abduction), + Self::Unrelated => None, + } + } +} + +/// The NARS-style inference rule a Pearl junction selects. +/// +/// Mirrors the canonical NARS rule taxonomy (Deduction / Induction / +/// Abduction). The `lance-graph-contract::nars` module owns the full +/// `InferenceType` enum (5 variants); this enum names only the three +/// rules that arise from Pearl-junction classification. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum NarsRule { + /// Chain figure: `M -> P`, `S -> M` ⊢ `S -> P` (or the reverse). + Deduction, + /// Fork figure (common cause): `M -> P`, `M -> S` ⊢ `S -> P` (with + /// confidence calibrated by Pearl's induction discounting). + Induction, + /// Collider figure (explaining-away): `P -> M`, `S -> M` ⊢ `S -> P` + /// (with confidence calibrated by Pearl's abduction discounting). + Abduction, +} + +/// Classify a pair of SPO edges by Pearl-junction taxonomy. +/// +/// The four arguments are the subject and object identities of each edge. +/// The predicate is intentionally not in the classifier — the junction +/// type is determined by the topology of identity equality, not by which +/// relation each edge represents. Consumers that need predicate-aware +/// dispatch (e.g. weighting predicates differently) layer that on top. +/// +/// The classifier checks for shared identity in this order: +/// 1. `Chain` (`o1 == s2`) +/// 2. `ChainRev` (`s1 == o2`) +/// 3. `Fork` (`s1 == s2`) +/// 4. `Collider` (`o1 == o2`) +/// 5. otherwise `Unrelated` +/// +/// When two edges share BOTH endpoints (e.g. `s1 == s2` AND `o1 == o2`), +/// the classifier returns `Chain` only if the chain check fires first; +/// otherwise it follows the order above. Duplicate edges should be +/// deduplicated by the caller before classification. +pub const fn classify_junction( + s1: NiblePath, + o1: NiblePath, + s2: NiblePath, + o2: NiblePath, +) -> PearlJunction { + if niblepath_eq(o1, s2) { + return PearlJunction::Chain; + } + if niblepath_eq(s1, o2) { + return PearlJunction::ChainRev; + } + if niblepath_eq(s1, s2) { + return PearlJunction::Fork; + } + if niblepath_eq(o1, o2) { + return PearlJunction::Collider; + } + PearlJunction::Unrelated +} + +/// `const fn` equality for [`NiblePath`] — needed because `PartialEq` for +/// user types is not `const` in stable Rust 1.95. Two paths are equal iff +/// their packed `(path, depth)` agree. +const fn niblepath_eq(a: NiblePath, b: NiblePath) -> bool { + let (ap, ad) = a.packed(); + let (bp, bd) = b.packed(); + ap == bp && ad == bd +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The dog/cat/mammal canonical example — the anti-swap guard. + /// + /// Two `subClassOf` edges share the same OBJECT (`mammal`). The shared + /// term is the parent; the two subjects (`dog`, `cat`) are siblings + /// under it; the conclusion `dog -> cat` is Abduction. This is the + /// COLLIDER pattern, not the Fork pattern (the earlier incorrect + /// framing inverted these). + #[test] + fn collider_is_dog_cat_mammal_with_shared_object() { + let dog = NiblePath::root(0x1).child(0x1); + let cat = NiblePath::root(0x1).child(0x2); + let mammal = NiblePath::root(0x1); + + // dog -> mammal, cat -> mammal: shared object (mammal = parent), + // distinct subjects (dog, cat = siblings). + let j = classify_junction(dog, mammal, cat, mammal); + assert_eq!(j, PearlJunction::Collider); + assert_eq!(j.nars_rule(), Some(NarsRule::Abduction)); + assert_eq!(j.label(), "collider"); + } + + /// The dog->mammal / dog->pet example — the Fork canonical. + /// + /// Two edges share the same SUBJECT (`dog`). The shared term is the + /// child; the two objects (`mammal`, `pet`) are co-parents + /// reachable via the common descendant; the conclusion `mammal -> pet` + /// is Induction. + #[test] + fn fork_is_dog_mammal_pet_with_shared_subject() { + let dog = NiblePath::root(0x1).child(0x1); + let mammal = NiblePath::root(0x1); + let pet = NiblePath::root(0x2); + + let j = classify_junction(dog, mammal, dog, pet); + assert_eq!(j, PearlJunction::Fork); + assert_eq!(j.nars_rule(), Some(NarsRule::Induction)); + assert_eq!(j.label(), "fork"); + } + + /// Chain: `dog -> mammal -> animal`. `o1 == s2`. + #[test] + fn chain_is_dog_mammal_animal_head_to_tail() { + let dog = NiblePath::root(0x1).child(0x1); + let mammal = NiblePath::root(0x1); + let animal = NiblePath::root(0x0); + // dog -> mammal, mammal -> animal: o1 (mammal) == s2 (mammal) + let j = classify_junction(dog, mammal, mammal, animal); + assert_eq!(j, PearlJunction::Chain); + assert_eq!(j.nars_rule(), Some(NarsRule::Deduction)); + } + + /// ChainRev: `s1 == o2`. + #[test] + fn chain_rev_is_when_s1_equals_o2() { + let a = NiblePath::root(0x1); + let b = NiblePath::root(0x2); + let c = NiblePath::root(0x3); + // a -> b, c -> a: s1 (a) == o2 (a) + let j = classify_junction(a, b, c, a); + assert_eq!(j, PearlJunction::ChainRev); + assert_eq!(j.nars_rule(), Some(NarsRule::Deduction)); + } + + /// Unrelated: no shared term. + #[test] + fn unrelated_when_no_shared_term() { + let a = NiblePath::root(0x1); + let b = NiblePath::root(0x2); + let c = NiblePath::root(0x3); + let d = NiblePath::root(0x4); + let j = classify_junction(a, b, c, d); + assert_eq!(j, PearlJunction::Unrelated); + assert_eq!(j.nars_rule(), None); + } + + /// Order-of-checks: when multiple endpoints match, Chain wins first. + /// Documents the deterministic behavior for callers. + #[test] + fn chain_check_fires_before_other_matches() { + let x = NiblePath::root(0x1); + let y = NiblePath::root(0x2); + // edges x->y and y->x: o1 (y) == s2 (y) → Chain + // (also s1 == o2 → would-be ChainRev; Chain check fires first) + let j = classify_junction(x, y, y, x); + assert_eq!(j, PearlJunction::Chain); + } + + #[test] + fn const_eq_works_in_classify() { + // const-context test for the classifier (proves const fn nature) + const A: NiblePath = NiblePath::root(0x1); + const B: NiblePath = NiblePath::root(0x2); + const C: NiblePath = NiblePath::root(0x3); + // a->b, b->c (Chain) + const J: PearlJunction = classify_junction(A, B, B, C); + assert_eq!(J, PearlJunction::Chain); + } +}