Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

118 changes: 118 additions & 0 deletions crates/causal-edge/src/edge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,37 @@ impl CausalEdge64 {
| (((m as u8 as u64) & BITS3_MASK) << CAUSAL_SHIFT);
}

/// Match if this edge's causal mask contains AT LEAST the bits in `query_mask`.
///
/// `query_mask` is interpreted as the low 3 bits of the Pearl 2³ packing
/// (S=0b100, P=0b010, O=0b001 — see [`CausalMask`]). Higher bits are ignored.
///
/// This is the query-side predicate used by graph WHERE clauses to filter
/// edges by causal type. For example, `query_mask = CausalMask::PO as u8`
/// (`0b011`) matches every edge whose causal mask has at least the P and O
/// planes active — i.e. interventional edges (`PO`) and counterfactual
/// edges (`SPO`), but not pure association (`SO`).
///
/// Semantics:
/// - `query_mask == edge_mask`: full match
/// - `query_mask` is a subset of `edge_mask`: subset match
/// - `query_mask` and `edge_mask` are disjoint (sharing no required bits):
/// no match
/// - `query_mask == 0` (`CausalMask::None`): matches every edge — there
/// are no required bits, so the predicate is vacuously satisfied.
#[inline(always)]
pub const fn matches_causal(&self, query_mask: u8) -> bool {
let q = query_mask & 0b111;
let edge_mask = ((self.0 >> CAUSAL_SHIFT) & BITS3_MASK) as u8;
(edge_mask & q) == q
}

/// Type-safe variant of [`Self::matches_causal`] taking a [`CausalMask`].
#[inline(always)]
pub fn matches_causal_mask(&self, query_mask: CausalMask) -> bool {
self.matches_causal(query_mask as u8)
}

/// Is the S-plane active in the current causal projection?
#[inline(always)]
pub fn s_active(self) -> bool { (self.0 >> CAUSAL_SHIFT) & 0b100 != 0 }
Expand Down Expand Up @@ -635,4 +666,91 @@ mod tests {
assert_eq!(std::mem::size_of::<CausalEdge64>(), 8,
"CausalEdge64 must be exactly 8 bytes");
}

// ─── matches_causal: query-side Pearl 2³ predicate (TD-INT-7) ────

fn make_edge(mask: CausalMask) -> CausalEdge64 {
CausalEdge64::pack(
10, 20, 30, 200, 200,
mask, 0, InferenceType::Deduction,
PlasticityState::ALL_FROZEN, 0,
)
}

#[test]
fn test_matches_causal_full_match() {
// query_mask == edge_mask: must match.
let edge = make_edge(CausalMask::PO);
assert!(edge.matches_causal(CausalMask::PO as u8));
assert!(edge.matches_causal_mask(CausalMask::PO));

let edge_spo = make_edge(CausalMask::SPO);
assert!(edge_spo.matches_causal(CausalMask::SPO as u8));
}

#[test]
fn test_matches_causal_subset_match() {
// query_mask is a strict subset of edge_mask: must match.
// SPO (0b111) contains PO (0b011), SO (0b101), SP (0b110), S, P, O.
let edge = make_edge(CausalMask::SPO);
assert!(edge.matches_causal(CausalMask::PO as u8),
"SPO edge should match PO query (PO bits are subset of SPO)");
assert!(edge.matches_causal(CausalMask::SO as u8),
"SPO edge should match SO query");
assert!(edge.matches_causal(CausalMask::P as u8),
"SPO edge should match single-plane P query");
assert!(edge.matches_causal_mask(CausalMask::S));

// PO (0b011) contains O (0b001) and P (0b010), but NOT S (0b100).
let edge_po = make_edge(CausalMask::PO);
assert!(edge_po.matches_causal(CausalMask::O as u8));
assert!(edge_po.matches_causal(CausalMask::P as u8));
}

#[test]
fn test_matches_causal_non_match() {
// query_mask requires bits the edge does not have: must NOT match.
// SO edge (0b101) does NOT have the P plane (0b010).
let edge_so = make_edge(CausalMask::SO);
assert!(!edge_so.matches_causal(CausalMask::P as u8));
assert!(!edge_so.matches_causal(CausalMask::PO as u8),
"SO edge must not match PO query — P bit is missing");
assert!(!edge_so.matches_causal_mask(CausalMask::SPO),
"SO edge must not match SPO query — P bit is missing");

// P-only edge (0b010) does NOT match SO query (0b101).
let edge_p = make_edge(CausalMask::P);
assert!(!edge_p.matches_causal(CausalMask::SO as u8));
assert!(!edge_p.matches_causal(CausalMask::S as u8));
assert!(!edge_p.matches_causal(CausalMask::O as u8));
}

#[test]
fn test_matches_causal_zero_mask_matches_anything() {
// query_mask == 0 has no required bits → vacuously matches every edge.
// This is the documented semantics: zero is the predicate-true element
// of the bit lattice (no requirements means nothing to fail).
for variant in [
CausalMask::None, CausalMask::O, CausalMask::P, CausalMask::PO,
CausalMask::S, CausalMask::SO, CausalMask::SP, CausalMask::SPO,
] {
let edge = make_edge(variant);
assert!(edge.matches_causal(0),
"zero query_mask must match edge with mask {variant:?}");
assert!(edge.matches_causal_mask(CausalMask::None),
"CausalMask::None query must match edge with mask {variant:?}");
}
}

#[test]
fn test_matches_causal_high_bits_ignored() {
// matches_causal must mask query down to the low 3 bits, so callers
// passing a u8 with stray high bits get the same result as passing
// the cleaned value.
let edge = make_edge(CausalMask::PO);
// 0b1111_0011 → low 3 bits = 0b011 = PO.
assert!(edge.matches_causal(0b1111_0011));
// 0b1111_0100 → low 3 bits = 0b100 = S — not present in PO edge.
assert!(!edge.matches_causal(0b1111_0100));
}
}
39 changes: 39 additions & 0 deletions crates/cognitive-shader-driver/src/cypher_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,35 @@
//! - Anything else — `StepStatus::Skipped` with "unsupported cypher
//! construct" reasoning. No failure: the downstream can plan around it.

use lance_graph_contract::crystal::fingerprint::CrystalFingerprint;
use lance_graph_contract::grammar::context_chain::{ContextChain, DisambiguationResult};
use lance_graph_contract::nars::InferenceType;
use lance_graph_contract::orchestration::{
OrchestrationBridge, OrchestrationError, StepDomain, StepStatus, UnifiedStep,
};
use lance_graph_contract::plan::ThinkingContext;
use lance_graph_contract::thinking::ThinkingStyle;

/// TD-INT-6 — disambiguation hook for multi-candidate Cypher parses.
///
/// When a real parser returns N candidate parse trees for an ambiguous
/// query, this helper consults the live `ContextChain` to pick the
/// candidate whose insertion-coherence at position `i` is highest.
/// Today's regex stub returns a single candidate, so this is a dormant
/// call site — wire in place; activation lives at parser commit time.
pub fn disambiguate_parse_candidates(
chain: &ContextChain,
position: usize,
candidates: Vec<CrystalFingerprint>,
) -> Result<CrystalFingerprint, DisambiguationResult> {
let result = chain.disambiguate(position, candidates);
if result.escalate_to_llm {
Err(result)
} else {
Ok(result.chosen.clone())
}
}

/// Bridge for `lg.cypher` step_types. Stateless in Phase 1; an SPO store
/// handle slots in here when Phase 2 wires the real parser + BindSpace.
pub struct CypherBridge;
Expand Down Expand Up @@ -219,4 +241,21 @@ mod tests {
other => panic!("expected DomainUnavailable, got {:?}", other),
}
}

/// TD-INT-6 — empty candidate list escalates.
#[test]
fn disambiguate_empty_candidates_escalates() {
let chain = ContextChain::new(8);
let result = disambiguate_parse_candidates(&chain, 0, Vec::new());
assert!(result.is_err(), "empty candidates must escalate");
}

/// TD-INT-6 — single candidate escalates (margin = 0).
#[test]
fn disambiguate_single_candidate_escalates() {
let chain = ContextChain::new(8);
let cand = CrystalFingerprint::Binary16K(Box::new([0u64; 256]));
let result = disambiguate_parse_candidates(&chain, 0, vec![cand]);
assert!(result.is_err(), "single candidate must escalate");
}
}
Loading
Loading