Skip to content

feat(hhtl + pearl_junction): NiblePath utility methods + Pearl-junction figure classifier#456

Merged
AdaWorldAPI merged 1 commit into
mainfrom
feat/pearl-junction-classifier
Jun 3, 2026
Merged

feat(hhtl + pearl_junction): NiblePath utility methods + Pearl-junction figure classifier#456
AdaWorldAPI merged 1 commit into
mainfrom
feat/pearl-junction-classifier

Conversation

@AdaWorldAPI

@AdaWorldAPI AdaWorldAPI commented Jun 3, 2026

Copy link
Copy Markdown
Owner

Summary

Two related additions to lance-graph-contract that retire bardioc EPIPHANIES.md E-4 ("syllogism figures ARE the radix-tree moves; thinking = addressing") into committed code with the corrected Pearl-junction mapping:

  1. Three new NiblePath utility methods (is_descendant_of, is_sibling_of, common_ancestor) — companions to the existing is_ancestor_of. All const fn + #[must_use], pure bit-shift arithmetic, O(depth) worst case.

  2. New pearl_junction module with PearlJunction enum + classify_junction() const-fn classifier mapping a pair of SPO edges to Pearl's three causal junctions (chain / fork / collider) plus the reverse chain. Includes the NARS-rule mapping (Deduction / Induction / Abduction).

The Pearl-junction mapping (E-4 corrected, with anti-swap guard)

For s -> o reading as s subClassOf o:

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

Anti-swap guard

An earlier peer-session framing had SharedSubject = sibling-via-parent / SharedObject = sibling-via-child — that inverted the induction⇄abduction chirality. The bardioc EPIPHANIES.md notes this as a hazard class (it had previously been caught and reverted upstream in #450; recurring it is a known mistake). This module:

  • Lands the corrected mapping (Fork = shared subject = co-parents; Collider = shared object = siblings)
  • Uses the canonical dog/cat/mammal example as the FIRST test
  • Calls out the inversion explicitly in the moduledoc

Why land it now

This PR retires E-4 from CONJECTURE to GROUNDED-in-code while the surrounding architecture docs are still landing (PR #452 / #453 / #454 merged last week; #455 is open). The Pearl-junction classification IS the d-separation navigation primitive these docs cite; having the classifier in lance-graph-contract means downstream consumers can name the navigation step in their own code without re-deriving it.

Sister module to nars (which owns the full InferenceType taxonomy); PearlJunction::nars_rule() returns the subset (Deduction / Induction / Abduction) that arises from three-junction classification.

Tests

  • crates/lance-graph-contract/src/hhtl.rs — 5 new tests covering descendant / sibling / common_ancestor (including the disjoint-basin and empty-path edges)
  • crates/lance-graph-contract/src/pearl_junction.rs — 6 tests: collider (dog/cat/mammal canonical anti-swap), 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 is const fn end-to-end

Local cargo check -p lance-graph-contract blocked on a sibling-path ndarray manifest that the workspace expects; the contract crate itself compiles in isolation — letting upstream CI verify.

What this PR does NOT do

  • Does NOT add a graph-walk-driven figure detector (that would touch the planner). This is the pure-function classifier; planner-side traversal stays in lance-graph-planner.
  • Does NOT change the existing is_ancestor_of / basin / leaf / parent surface.
  • Does NOT introduce a predicate-aware variant (the classification is determined by identity equality between endpoints; predicate-aware dispatch is layered on top by callers).

Provenance

Summary by CodeRabbit

Release Notes

  • New Features
    • Extended path comparison capabilities with new methods for detecting ancestor-descendant relationships and sibling connections in hierarchical graph structures
    • Added a comprehensive junction classification system for analyzing and categorizing edge relationships in graph networks, including support for multiple junction types and associated inference rules

…on 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.
@coderabbitai

coderabbitai Bot commented Jun 3, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR extends the NiblePath routing API with three new const relationship predicates for graph traversal, then introduces a new pearl_junction module implementing Pearl's causal-junction classification for SPO edge pairs based on endpoint identity equality and deterministic priority resolution.

Changes

NiblePath Relationship API and Pearl Junction Classification

Layer / File(s) Summary
NiblePath relationship methods
crates/lance-graph-contract/src/hhtl.rs
Adds is_descendant_of, is_sibling_of, and common_ancestor as const methods. is_descendant_of inverts is_ancestor_of; is_sibling_of verifies shared parent via leaf-nibble masking; common_ancestor aligns depths and iteratively shifts to find the longest shared prefix. Tests validate inverse relationships, sibling semantics with negative cases, and common_ancestor behavior across depth mismatches and disjoint basins.
Pearl junction contracts and types
crates/lance-graph-contract/src/lib.rs, crates/lance-graph-contract/src/pearl_junction.rs
Defines the PearlJunction enum (Chain, ChainRev, Fork, Collider, Unrelated) and NarsRule enum (Deduction, Induction, Abduction) to represent causal relationship categories. Adds label() and nars_rule() methods on PearlJunction for stable string identifiers and NARS inference-rule mapping.
Junction classification logic
crates/lance-graph-contract/src/pearl_junction.rs
Implements classify_junction(s1, o1, s2, o2) as a pure const function that checks the four SPO endpoints in a fixed priority order to resolve ambiguous matches deterministically. Includes niblepath_eq helper for const-context equality via packed representation comparison.
Pearl junction test coverage
crates/lance-graph-contract/src/pearl_junction.rs
Comprehensive tests cover all five junction classifications, verify deterministic priority (e.g., Chain selected before ChainRev when multiple endpoint equalities hold), confirm label and NARS rule outputs, and include a const-context test demonstrating compile-time classifier usage.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

🐰 Graph paths now navigate with grace,
Siblings and ancestors find their place,
Pearl junctions sort their causal tale,
NARS rules prevail without fail!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title directly and concisely describes the two main changes: NiblePath utility methods (is_descendant_of, is_sibling_of, common_ancestor) and the Pearl-junction classifier, matching the core objectives.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/pearl-junction-classifier

Comment @coderabbitai help to get the list of available commands and usage tips.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4faa7e471d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

s2: NiblePath,
o2: NiblePath,
) -> PearlJunction {
if niblepath_eq(o1, s2) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Guard empty paths before comparing endpoint identity

When two edges contain unresolved endpoints represented by NiblePath::EMPTY—for example after NiblePath::root receives an out-of-range basin—these equality checks treat the shared no-route sentinel as a real graph term. As a result, a -> EMPTY plus b -> EMPTY is classified as a Collider, EMPTY -> a plus EMPTY -> b as a Fork, and similar inputs can select a NARS inference rule even though the edges share no known identity. The HHTL API explicitly defines EMPTY as the no-route sentinel and excludes it from ancestry relations, so the classifier should likewise return Unrelated or otherwise reject inputs when any endpoint is empty.

Useful? React with 👍 / 👎.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/lance-graph-contract/src/pearl_junction.rs`:
- Around line 72-80: The method nars_rule currently returns Option<NarsRule>,
introducing a local duplicate type; change its signature to return
Option<crate::nars::InferenceType> and update the match arms in
pearl_junction::nars_rule (and the similar methods around the other affected
match blocks) to map Self::Chain and Self::ChainRev =>
Some(crate::nars::InferenceType::Deduction), Self::Fork =>
Some(crate::nars::InferenceType::Induction), Self::Collider =>
Some(crate::nars::InferenceType::Abduction), and Self::Unrelated => None; ensure
any references to NarsRule in this file are replaced with the canonical
crate::nars::InferenceType and adjust imports/uses accordingly.
- Around line 103-127: Wrap the four NiblePath parameters currently accepted by
the free function classify_junction into a small carrier struct (e.g.,
PearlJunctionCandidates { s1, o1, s2, o2 }: all NiblePath) and convert the
public const fn classify_junction(...) into a method on that struct (e.g., impl
PearlJunctionCandidates { pub const fn classify(&self) -> PearlJunction { ... }
}), preserving the exact classification logic and return type PearlJunction;
make the old free function private or remove it and update all callers to
construct the carrier and call the method; ensure visibility/export changes
reflect the new API surface.
- Around line 122-140: The classify_junction function treats NiblePath::EMPTY as
a regular path and can misclassify unrouted endpoints; update classify_junction
to guard against NiblePath::EMPTY before using niblepath_eq by returning
PearlJunction::Unrelated whenever either of the compared NiblePath operands is
NiblePath::EMPTY (e.g., check o1==NiblePath::EMPTY || s2==NiblePath::EMPTY
before testing niblepath_eq(o1,s2), and similarly for the s1/o2, s1/s2 and o1/o2
comparisons), ensuring classify_junction only returns Chain, ChainRev, Fork, or
Collider when both sides are non-empty.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f8391cf3-d5e7-4e08-b485-0409e41cc5ca

📥 Commits

Reviewing files that changed from the base of the PR and between d790835 and 4faa7e4.

📒 Files selected for processing (3)
  • crates/lance-graph-contract/src/hhtl.rs
  • crates/lance-graph-contract/src/lib.rs
  • crates/lance-graph-contract/src/pearl_junction.rs

Comment on lines +72 to +80
/// 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<NarsRule> {
match self {
Self::Chain | Self::ChainRev => Some(NarsRule::Deduction),
Self::Fork => Some(NarsRule::Induction),
Self::Collider => Some(NarsRule::Abduction),
Self::Unrelated => None,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Use the canonical NARS inference type instead of introducing NarsRule.

This adds a second inference taxonomy in the contract crate, which is exactly the drift the duplication-map rule is trying to prevent. Returning the canonical crate::nars::InferenceType here would keep the classifier aligned with the rest of the workspace.

As per coding guidelines, "NARS InferenceType (3 copies, contract canonical) ... Always use the canonical version to avoid sync issues."

Also applies to: 85-101

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/lance-graph-contract/src/pearl_junction.rs` around lines 72 - 80, The
method nars_rule currently returns Option<NarsRule>, introducing a local
duplicate type; change its signature to return
Option<crate::nars::InferenceType> and update the match arms in
pearl_junction::nars_rule (and the similar methods around the other affected
match blocks) to map Self::Chain and Self::ChainRev =>
Some(crate::nars::InferenceType::Deduction), Self::Fork =>
Some(crate::nars::InferenceType::Induction), Self::Collider =>
Some(crate::nars::InferenceType::Abduction), and Self::Unrelated => None; ensure
any references to NarsRule in this file are replaced with the canonical
crate::nars::InferenceType and adjust imports/uses accordingly.

Comment on lines +103 to +127
/// 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 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Move the classifier behind a carrier type instead of exporting a free function.

Locking in classify_junction(s1, o1, s2, o2) bakes a non-idiomatic public API into the crate. Please wrap the four endpoints in a small carrier struct and expose this as a method before the surface area ossifies.

As per coding guidelines, "**/*.rs: Use only method calls on the carrier struct that holds the state, never free functions.`"

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/lance-graph-contract/src/pearl_junction.rs` around lines 103 - 127,
Wrap the four NiblePath parameters currently accepted by the free function
classify_junction into a small carrier struct (e.g., PearlJunctionCandidates {
s1, o1, s2, o2 }: all NiblePath) and convert the public const fn
classify_junction(...) into a method on that struct (e.g., impl
PearlJunctionCandidates { pub const fn classify(&self) -> PearlJunction { ... }
}), preserving the exact classification logic and return type PearlJunction;
make the old free function private or remove it and update all callers to
construct the carrier and call the method; ensure visibility/export changes
reflect the new API surface.

Comment on lines +122 to +140
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard NiblePath::EMPTY before classifying.

NiblePath::EMPTY is the crate's “no route” sentinel, but these equality checks currently treat matching empties as a real shared term. For example, two unrouted endpoints can classify as Chain or Fork instead of Unrelated.

Proposed fix
 pub const fn classify_junction(
     s1: NiblePath,
     o1: NiblePath,
     s2: NiblePath,
     o2: NiblePath,
 ) -> PearlJunction {
+    if s1.depth() == 0 || o1.depth() == 0 || s2.depth() == 0 || o2.depth() == 0 {
+        return PearlJunction::Unrelated;
+    }
+
     if niblepath_eq(o1, s2) {
         return PearlJunction::Chain;
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
pub const fn classify_junction(
s1: NiblePath,
o1: NiblePath,
s2: NiblePath,
o2: NiblePath,
) -> PearlJunction {
if s1 == NiblePath::EMPTY || o1 == NiblePath::EMPTY || s2 == NiblePath::EMPTY || o2 == NiblePath::EMPTY {
return PearlJunction::Unrelated;
}
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
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/lance-graph-contract/src/pearl_junction.rs` around lines 122 - 140,
The classify_junction function treats NiblePath::EMPTY as a regular path and can
misclassify unrouted endpoints; update classify_junction to guard against
NiblePath::EMPTY before using niblepath_eq by returning PearlJunction::Unrelated
whenever either of the compared NiblePath operands is NiblePath::EMPTY (e.g.,
check o1==NiblePath::EMPTY || s2==NiblePath::EMPTY before testing
niblepath_eq(o1,s2), and similarly for the s1/o2, s1/s2 and o1/o2 comparisons),
ensuring classify_junction only returns Chain, ChainRev, Fork, or Collider when
both sides are non-empty.

@AdaWorldAPI AdaWorldAPI merged commit 65756b9 into main Jun 3, 2026
6 checks passed
AdaWorldAPI added a commit that referenced this pull request Jun 3, 2026
…n-empty-guard

fix(post-merge #456 + #455): EMPTY guard + use crate::nars::InferenceType + EdgePair carrier + async syntax
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant