From 4ad4db6c6eeb5fce9158919b537e647dff8a17bc Mon Sep 17 00:00:00 2001 From: MesTTo Date: Tue, 23 Jun 2026 18:17:54 +1000 Subject: [PATCH 01/13] Add term identity sidecar --- kernel/src/lib.rs | 4 + kernel/src/term_identity.rs | 593 ++++++++++++++++++++++++++++++++++ kernel/tests/term_identity.rs | 43 +++ 3 files changed, 640 insertions(+) create mode 100644 kernel/src/term_identity.rs create mode 100644 kernel/tests/term_identity.rs diff --git a/kernel/src/lib.rs b/kernel/src/lib.rs index b5667595..8680771a 100644 --- a/kernel/src/lib.rs +++ b/kernel/src/lib.rs @@ -8,3 +8,7 @@ pub mod space; mod sources; mod sinks; mod pure; +pub mod term_identity; + +#[doc(hidden)] +pub use mork_expr as __mork_expr; diff --git a/kernel/src/term_identity.rs b/kernel/src/term_identity.rs new file mode 100644 index 00000000..795692b0 --- /dev/null +++ b/kernel/src/term_identity.rs @@ -0,0 +1,593 @@ +use std::collections::HashMap; + +use mork_expr::{maybe_byte_item, Tag}; +use pathmap::PathMap; + +/// Canonical identity for an encoded MORK term or subterm. +#[repr(transparent)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)] +pub struct TermId(pub u64); + +/// Canonical identity for a complete fact present in a pathspace snapshot. +#[repr(transparent)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)] +pub struct FactId(pub u32); + +/// Small structural flags cached for every interned term. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct TermFlags { + /// The term contains no encoded MORK variables. + pub ground: bool, + /// The term contains a `NewVar` or `VarRef` item. + pub contains_vars: bool, +} + +impl TermFlags { + fn ground() -> Self { + Self { + ground: true, + contains_vars: false, + } + } + + fn schematic() -> Self { + Self { + ground: false, + contains_vars: true, + } + } +} + +/// Shape tag for an interned encoded term. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TermKind { + /// Complete symbol item: `SymbolSize(n)` plus payload bytes. + Symbol, + /// Application with the encoded arity. + Application { arity: u8 }, + /// New variable item. + NewVar, + /// Reference to a previously introduced variable level. + VarRef(u8), +} + +/// Interned term metadata. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TermRecord { + /// Canonical term identity. + pub id: TermId, + /// Term shape. + pub kind: TermKind, + /// Cached ground/schematic flags. + pub flags: TermFlags, + /// Deterministic structural hash. Hash equality is only a filter; encoded + /// bytes are compared exactly before identities are reused. + pub structural_hash: u128, + /// Encoded byte length of this exact term. + pub encoded_len: u32, + /// Maximum nested expression depth, counting leaves as depth 1. + pub depth: u16, + /// Number of term nodes in this expression tree. + pub node_count: u32, + encoded: Box<[u8]>, + children: Box<[TermId]>, +} + +impl TermRecord { + /// Exact encoded bytes represented by this term identity. + pub fn encoded(&self) -> &[u8] { + &self.encoded + } + + /// Child term identities for applications; empty for leaves. + pub fn children(&self) -> &[TermId] { + &self.children + } +} + +/// Complete fact metadata derived from a pathspace value. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct FactRecord { + /// Canonical fact identity. + pub id: FactId, + /// Root term for this fact. + pub root: TermId, + /// Structural hash copied from the root term. + pub structural_hash: u128, + /// Root term flags. + pub flags: TermFlags, + /// Sidecar generation at which this fact was inserted. + pub generation: u32, +} + +/// Read-only counters for a [`TermIdentitySidecar`]. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct TermIdentityStats { + /// Number of interned complete terms and subterms. + pub terms: usize, + /// Number of complete facts. + pub facts: usize, + /// Number of facts whose root term is ground. + pub ground_facts: usize, + /// Number of facts whose root term contains variables. + pub schematic_facts: usize, + /// Interned terms that are not complete fact roots. + pub subterms: usize, + /// Bytes retained by exact interned term encodings. + pub encoded_bytes: usize, + /// Maximum observed term depth. + pub max_depth: u16, + /// Number of structural-hash buckets. + pub hash_buckets: usize, + /// Extra candidates inside non-singleton structural-hash buckets. + pub hash_collision_candidates: usize, + /// Current sidecar generation. + pub generation: u32, +} + +/// Parse error for malformed encoded MORK terms. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TermParseError { + /// Byte offset at which parsing failed. + pub offset: usize, + /// Specific parse failure. + pub kind: TermParseErrorKind, +} + +/// Specific encoded-term parse failure. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum TermParseErrorKind { + /// The input ended before another item could be read. + UnexpectedEnd, + /// The byte is reserved by the encoding and is not a valid item tag. + ReservedTag(u8), + /// A symbol item declared more payload bytes than remain in the input. + TruncatedSymbol { len: usize, remaining: usize }, + /// A complete expression was parsed before the end of the provided slice. + TrailingBytes { parsed_len: usize, total_len: usize }, + /// More than `u32::MAX` complete facts were inserted. + TooManyFacts, +} + +/// Derived canonical identity sidecar for encoded MORK facts and subfacts. +#[derive(Clone, Debug, Default)] +pub struct TermIdentitySidecar { + terms: Vec, + facts: Vec, + hash_buckets: HashMap>, + fact_by_term: HashMap, + encoded_bytes: usize, + max_depth: u16, + generation: u32, +} + +impl TermIdentitySidecar { + /// Creates an empty derived identity sidecar. + pub fn new() -> Self { + Self::default() + } + + /// Interns one complete encoded fact and all of its subterms. + /// + /// Re-inserting the same complete fact returns the existing [`FactId`]. + pub fn insert_fact(&mut self, encoded: &[u8]) -> Result { + let mark = self.mark(); + let parsed = match self.intern_at(encoded, 0) { + Ok(parsed) => parsed, + Err(error) => { + self.rollback_to(mark); + return Err(error); + } + }; + if parsed.end != encoded.len() { + self.rollback_to(mark); + return Err(TermParseError { + offset: parsed.end, + kind: TermParseErrorKind::TrailingBytes { + parsed_len: parsed.end, + total_len: encoded.len(), + }, + }); + } + + if let Some(&fact) = self.fact_by_term.get(&parsed.id) { + return Ok(fact); + } + + let fact_index = match u32::try_from(self.facts.len()) { + Ok(fact_index) => fact_index, + Err(_) => { + self.rollback_to(mark); + return Err(TermParseError { + offset: 0, + kind: TermParseErrorKind::TooManyFacts, + }); + } + }; + self.generation = self.generation.saturating_add(1); + + let root = self.term(parsed.id); + let fact = FactRecord { + id: FactId(fact_index), + root: parsed.id, + structural_hash: root.structural_hash, + flags: root.flags, + generation: self.generation, + }; + + self.facts.push(fact); + self.fact_by_term.insert(parsed.id, fact.id); + Ok(fact.id) + } + + /// Interns every value path from a `PathMap<()>` snapshot. + pub fn extend_from_pathmap(&mut self, map: &PathMap<()>) -> Result { + let mut inserted = 0usize; + + map.try_for_each_value(|path, _| { + let before = self.facts.len(); + self.insert_fact(path)?; + inserted += usize::from(self.facts.len() != before); + Ok(()) + })?; + + Ok(inserted) + } + + /// Returns a term record by identity. + pub fn get_term(&self, id: TermId) -> Option<&TermRecord> { + self.terms.get(id.0 as usize) + } + + /// Returns a fact record by identity. + pub fn get_fact(&self, id: FactId) -> Option<&FactRecord> { + self.facts.get(id.0 as usize) + } + + /// Returns the existing identity for `encoded` if it has already been interned. + pub fn term_id_for_encoded(&self, encoded: &[u8]) -> Option { + let hash = structural_hash(encoded); + self.hash_buckets.get(&hash)?.iter().copied().find(|&id| { + self.get_term(id) + .is_some_and(|record| record.encoded() == encoded) + }) + } + + /// Returns sidecar counters without exposing retained encodings. + pub fn stats(&self) -> TermIdentityStats { + let mut stats = TermIdentityStats { + terms: self.terms.len(), + facts: self.facts.len(), + subterms: self.terms.len().saturating_sub(self.facts.len()), + encoded_bytes: self.encoded_bytes, + max_depth: self.max_depth, + hash_buckets: self.hash_buckets.len(), + generation: self.generation, + ..TermIdentityStats::default() + }; + + for fact in &self.facts { + if fact.flags.ground { + stats.ground_facts += 1; + } else { + stats.schematic_facts += 1; + } + } + + stats.hash_collision_candidates = self + .hash_buckets + .values() + .map(|bucket| bucket.len().saturating_sub(1)) + .sum(); + + stats + } + + fn term(&self, id: TermId) -> &TermRecord { + self.get_term(id) + .expect("TermId should refer to an interned record") + } + + fn mark(&self) -> SidecarMark { + SidecarMark { + terms: self.terms.len(), + facts: self.facts.len(), + encoded_bytes: self.encoded_bytes, + max_depth: self.max_depth, + generation: self.generation, + } + } + + fn rollback_to(&mut self, mark: SidecarMark) { + while self.terms.len() > mark.terms { + let Some(record) = self.terms.pop() else { + break; + }; + + let empty = if let Some(bucket) = self.hash_buckets.get_mut(&record.structural_hash) { + bucket.retain(|&id| id != record.id); + bucket.is_empty() + } else { + false + }; + + if empty { + self.hash_buckets.remove(&record.structural_hash); + } + } + + while self.facts.len() > mark.facts { + let Some(fact) = self.facts.pop() else { + break; + }; + self.fact_by_term.remove(&fact.root); + } + + self.encoded_bytes = mark.encoded_bytes; + self.max_depth = mark.max_depth; + self.generation = mark.generation; + } + + fn intern_at(&mut self, encoded: &[u8], offset: usize) -> Result { + let Some(&byte) = encoded.get(offset) else { + return Err(TermParseError { + offset, + kind: TermParseErrorKind::UnexpectedEnd, + }); + }; + + match maybe_byte_item(byte).map_err(|reserved| TermParseError { + offset, + kind: TermParseErrorKind::ReservedTag(reserved), + })? { + Tag::NewVar => { + let id = self.intern_term( + &encoded[offset..offset + 1], + TermKind::NewVar, + TermFlags::schematic(), + Vec::new(), + 1, + 1, + ); + Ok(ParsedTerm { + id, + end: offset + 1, + }) + } + Tag::VarRef(level) => { + let id = self.intern_term( + &encoded[offset..offset + 1], + TermKind::VarRef(level), + TermFlags::schematic(), + Vec::new(), + 1, + 1, + ); + Ok(ParsedTerm { + id, + end: offset + 1, + }) + } + Tag::SymbolSize(len) => { + let len = usize::from(len); + let payload_start = offset + 1; + let end = payload_start + len; + if end > encoded.len() { + return Err(TermParseError { + offset, + kind: TermParseErrorKind::TruncatedSymbol { + len, + remaining: encoded.len().saturating_sub(payload_start), + }, + }); + } + + let id = self.intern_term( + &encoded[offset..end], + TermKind::Symbol, + TermFlags::ground(), + Vec::new(), + 1, + 1, + ); + Ok(ParsedTerm { id, end }) + } + Tag::Arity(arity) => { + let mut cursor = offset + 1; + let mut children = Vec::with_capacity(usize::from(arity)); + let mut flags = TermFlags::ground(); + let mut depth = 1u16; + let mut node_count = 1u32; + + for _ in 0..arity { + let child = self.intern_at(encoded, cursor)?; + cursor = child.end; + + let child_record = self.term(child.id); + flags.contains_vars |= child_record.flags.contains_vars; + flags.ground &= child_record.flags.ground; + depth = depth.max(child_record.depth.saturating_add(1)); + node_count = node_count.saturating_add(child_record.node_count); + children.push(child.id); + } + + let id = self.intern_term( + &encoded[offset..cursor], + TermKind::Application { arity }, + flags, + children, + depth, + node_count, + ); + Ok(ParsedTerm { id, end: cursor }) + } + } + } + + fn intern_term( + &mut self, + encoded: &[u8], + kind: TermKind, + flags: TermFlags, + children: Vec, + depth: u16, + node_count: u32, + ) -> TermId { + let hash = structural_hash(encoded); + if let Some(bucket) = self.hash_buckets.get(&hash) { + for &candidate in bucket { + if self.term(candidate).encoded() == encoded { + return candidate; + } + } + } + + let id = TermId(self.terms.len() as u64); + let encoded_len = u32::try_from(encoded.len()).unwrap_or(u32::MAX); + self.encoded_bytes = self.encoded_bytes.saturating_add(encoded.len()); + self.max_depth = self.max_depth.max(depth); + + self.terms.push(TermRecord { + id, + kind, + flags, + structural_hash: hash, + encoded_len, + depth, + node_count, + encoded: encoded.to_vec().into_boxed_slice(), + children: children.into_boxed_slice(), + }); + self.hash_buckets.entry(hash).or_default().push(id); + + id + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct ParsedTerm { + id: TermId, + end: usize, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct SidecarMark { + terms: usize, + facts: usize, + encoded_bytes: usize, + max_depth: u16, + generation: u32, +} + +/// Deterministic 128-bit hash for encoded terms. +pub fn structural_hash(encoded: &[u8]) -> u128 { + let mut lo = 0xcbf2_9ce4_8422_2325u64; + let mut hi = 0x9e37_79b9_7f4a_7c15u64; + + for &byte in encoded { + lo ^= u64::from(byte); + lo = lo.wrapping_mul(0x0000_0100_0000_01b3); + + hi ^= u64::from(byte).wrapping_add(lo.rotate_left(17)); + hi = hi.wrapping_mul(0x9e37_79b1_85eb_ca87); + } + + (u128::from(hi) << 64) | u128::from(lo) +} + +#[cfg(test)] +mod tests { + use super::*; + use mork_expr::{item_byte, Tag}; + + fn sym(bytes: &[u8]) -> Vec { + let mut out = vec![item_byte(Tag::SymbolSize(bytes.len() as u8))]; + out.extend_from_slice(bytes); + out + } + + #[test] + fn insert_fact_interns_complete_expression_and_subterms() { + let mut encoded = vec![item_byte(Tag::Arity(3))]; + encoded.extend(sym(b"edge")); + encoded.extend(sym(b"Alice")); + let inner_start = encoded.len(); + encoded.push(item_byte(Tag::Arity(2))); + encoded.extend(sym(b"f")); + encoded.extend(sym(b"Bob")); + let inner = encoded[inner_start..].to_vec(); + + let mut sidecar = TermIdentitySidecar::new(); + let fact = sidecar.insert_fact(&encoded).unwrap(); + let root = sidecar.get_fact(fact).unwrap().root; + let root_record = sidecar.get_term(root).unwrap(); + + assert_eq!(root_record.kind, TermKind::Application { arity: 3 }); + assert_eq!(root_record.children().len(), 3); + assert_eq!(root_record.flags, TermFlags::ground()); + assert_eq!(root_record.node_count, 6); + assert!(sidecar.term_id_for_encoded(&inner).is_some()); + assert_eq!( + sidecar.stats(), + TermIdentityStats { + terms: 6, + facts: 1, + ground_facts: 1, + schematic_facts: 0, + subterms: 5, + encoded_bytes: sidecar.stats().encoded_bytes, + max_depth: 3, + hash_buckets: 6, + hash_collision_candidates: 0, + generation: 1, + } + ); + } + + #[test] + fn duplicate_complete_fact_reuses_fact_identity() { + let mut encoded = vec![item_byte(Tag::Arity(2))]; + encoded.extend(sym(b"foo")); + encoded.extend(sym(b"bar")); + + let mut sidecar = TermIdentitySidecar::new(); + let first = sidecar.insert_fact(&encoded).unwrap(); + let second = sidecar.insert_fact(&encoded).unwrap(); + + assert_eq!(first, second); + assert_eq!(sidecar.stats().facts, 1); + assert_eq!(sidecar.stats().terms, 3); + } + + #[test] + fn variable_bearing_fact_is_classified_as_schematic() { + let mut encoded = vec![item_byte(Tag::Arity(2))]; + encoded.extend(sym(b"fact")); + encoded.push(item_byte(Tag::NewVar)); + + let mut sidecar = TermIdentitySidecar::new(); + let fact = sidecar.insert_fact(&encoded).unwrap(); + + assert!(!sidecar.get_fact(fact).unwrap().flags.ground); + assert!(sidecar.get_fact(fact).unwrap().flags.contains_vars); + assert_eq!(sidecar.stats().schematic_facts, 1); + } + + #[test] + fn trailing_bytes_are_rejected() { + let encoded = [item_byte(Tag::SymbolSize(1)), b'x', b'y']; + let mut sidecar = TermIdentitySidecar::new(); + + assert_eq!( + sidecar.insert_fact(&encoded).unwrap_err(), + TermParseError { + offset: 2, + kind: TermParseErrorKind::TrailingBytes { + parsed_len: 2, + total_len: 3, + }, + } + ); + assert_eq!(sidecar.stats(), TermIdentityStats::default()); + } +} diff --git a/kernel/tests/term_identity.rs b/kernel/tests/term_identity.rs new file mode 100644 index 00000000..679028f7 --- /dev/null +++ b/kernel/tests/term_identity.rs @@ -0,0 +1,43 @@ +use mork::__mork_expr::{item_byte, Tag}; +use mork::term_identity::{TermIdentitySidecar, TermIdentityStats}; +use pathmap::PathMap; + +fn sym(bytes: &[u8]) -> Vec { + let mut out = vec![item_byte(Tag::SymbolSize(bytes.len() as u8))]; + out.extend_from_slice(bytes); + out +} + +#[test] +fn term_identity_sidecar_builds_from_pathmap_values() { + let mut ground = vec![item_byte(Tag::Arity(2))]; + ground.extend(sym(b"edge")); + ground.extend(sym(b"Alice")); + + let mut schematic = vec![item_byte(Tag::Arity(2))]; + schematic.extend(sym(b"edge")); + schematic.push(item_byte(Tag::VarRef(0))); + + let mut map = PathMap::new(); + map.insert(&ground, ()); + map.insert(&schematic, ()); + + let mut sidecar = TermIdentitySidecar::new(); + + assert_eq!(sidecar.extend_from_pathmap(&map).unwrap(), 2); + assert_eq!( + sidecar.stats(), + TermIdentityStats { + terms: 5, + facts: 2, + ground_facts: 1, + schematic_facts: 1, + subterms: 3, + encoded_bytes: sidecar.stats().encoded_bytes, + max_depth: 2, + hash_buckets: 5, + hash_collision_candidates: 0, + generation: 2, + } + ); +} From b07605629b33504845d9b643db4238cae73df41e Mon Sep 17 00:00:00 2001 From: MesTTo Date: Tue, 23 Jun 2026 18:28:38 +1000 Subject: [PATCH 02/13] Add fixed binding environment --- kernel/src/binding_env.rs | 424 ++++++++++++++++++++++++++++++++++++ kernel/src/lib.rs | 1 + kernel/tests/binding_env.rs | 66 ++++++ 3 files changed, 491 insertions(+) create mode 100644 kernel/src/binding_env.rs create mode 100644 kernel/tests/binding_env.rs diff --git a/kernel/src/binding_env.rs b/kernel/src/binding_env.rs new file mode 100644 index 00000000..81bde90d --- /dev/null +++ b/kernel/src/binding_env.rs @@ -0,0 +1,424 @@ +use crate::term_identity::{FactId, TermId}; + +/// The current MORK encoding uses six-bit variable references. +pub const MAX_BINDING_SLOTS: usize = 64; + +/// Logical source of a binding row. +#[repr(transparent)] +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Hash, Ord, PartialOrd)] +pub struct BindingSource(pub u32); + +/// One fixed-slot binding entry. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Binding { + /// Canonical term identity bound to the slot. + pub term: TermId, + /// Optional complete fact identity that supplied the term. + pub fact: Option, + /// Source relation or snapshot cursor that produced the binding. + pub source: BindingSource, +} + +impl Binding { + /// Constructs a binding from a canonical term alone. + pub fn from_term(term: TermId) -> Self { + Self { + term, + fact: None, + source: BindingSource::default(), + } + } +} + +impl Default for Binding { + fn default() -> Self { + Self::from_term(TermId(0)) + } +} + +/// Rollback mark for [`BindingEnv`]. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct BindingMark { + trail_len: usize, +} + +/// Captured binding state for explicit dynamic-environment restore. +#[derive(Clone, Debug)] +pub struct BindingSnapshot { + slots: [Binding; MAX_BINDING_SLOTS], + bound_mask: u64, +} + +/// Error returned by fixed-slot binding operations. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BindingEnvError { + /// Slot index was outside the six-bit binding domain. + SlotOutOfRange { slot: u8 }, + /// The mark was created from a different or later environment state. + InvalidMark, +} + +/// Fixed 64-slot binding environment with trail-based rollback. +#[derive(Clone, Debug)] +pub struct BindingEnv { + slots: [Binding; MAX_BINDING_SLOTS], + bound_mask: u64, + trail: Vec, +} + +fn masked_slots_equal( + left: &[Binding; MAX_BINDING_SLOTS], + right: &[Binding; MAX_BINDING_SLOTS], + mut mask: u64, +) -> bool { + while mask != 0 { + let slot = mask.trailing_zeros() as usize; + if left[slot] != right[slot] { + return false; + } + mask &= mask - 1; + } + true +} + +impl PartialEq for BindingSnapshot { + fn eq(&self, other: &Self) -> bool { + self.bound_mask == other.bound_mask + && masked_slots_equal(&self.slots, &other.slots, self.bound_mask) + } +} + +impl Eq for BindingSnapshot {} + +impl PartialEq for BindingEnv { + fn eq(&self, other: &Self) -> bool { + self.bound_mask == other.bound_mask + && self.trail == other.trail + && masked_slots_equal(&self.slots, &other.slots, self.bound_mask) + } +} + +impl Eq for BindingEnv {} + +impl Default for BindingEnv { + fn default() -> Self { + Self { + slots: [Binding::default(); MAX_BINDING_SLOTS], + bound_mask: 0, + trail: Vec::new(), + } + } +} + +impl BindingEnv { + /// Creates an empty binding environment. + pub fn new() -> Self { + Self::default() + } + + /// Returns the current rollback mark. + pub fn mark(&self) -> BindingMark { + BindingMark { + trail_len: self.trail.len(), + } + } + + /// Captures the bound slots as an explicit dynamic environment. + pub fn capture(&self) -> BindingSnapshot { + BindingSnapshot { + slots: self.slots, + bound_mask: self.bound_mask, + } + } + + /// Restores a captured binding snapshot and clears the rollback trail. + pub fn restore(&mut self, snapshot: &BindingSnapshot) { + self.slots = snapshot.slots; + self.bound_mask = snapshot.bound_mask; + self.trail.clear(); + } + + /// Removes every binding. + pub fn clear(&mut self) { + self.bound_mask = 0; + self.trail.clear(); + } + + /// Rolls back bindings introduced after `mark`. + pub fn rollback_to(&mut self, mark: BindingMark) -> Result<(), BindingEnvError> { + if mark.trail_len > self.trail.len() { + return Err(BindingEnvError::InvalidMark); + } + + while self.trail.len() > mark.trail_len { + let Some(slot) = self.trail.pop() else { + break; + }; + self.bound_mask &= !(1_u64 << slot); + } + + Ok(()) + } + + /// Runs `f` in a temporary binding extent and rolls back new bindings. + /// + /// Existing bindings remain visible to `f`; only bindings introduced after + /// this method's entry mark are removed before returning. If the closure + /// needs to report failure, return a `Result` as its own value. + pub fn with_rollback( + &mut self, + f: impl FnOnce(&mut Self) -> R, + ) -> Result { + let mark = self.mark(); + let output = f(self); + self.rollback_to(mark)?; + Ok(output) + } + + /// Binds `slot` to `binding`. + /// + /// Returns `Ok(true)` when the slot is newly bound or already carries the + /// same term, and `Ok(false)` when a repeated variable conflicts. + pub fn bind(&mut self, slot: u8, binding: Binding) -> Result { + let index = Self::slot_index(slot)?; + let bit = 1_u64 << slot; + + if self.bound_mask & bit == 0 { + self.slots[index] = binding; + self.bound_mask |= bit; + self.trail.push(slot); + return Ok(true); + } + + Ok(self.slots[index].term == binding.term) + } + + /// Binds `slot` to `term`. + pub fn bind_term(&mut self, slot: u8, term: TermId) -> Result { + self.bind(slot, Binding::from_term(term)) + } + + /// Returns the binding for `slot`, if present. + pub fn get(&self, slot: u8) -> Result, BindingEnvError> { + let index = Self::slot_index(slot)?; + if self.bound_mask & (1_u64 << slot) != 0 { + Ok(Some(self.slots[index])) + } else { + Ok(None) + } + } + + /// Returns true when `slot` currently has a binding. + pub fn is_bound(&self, slot: u8) -> Result { + Ok(self.bound_mask & (1_u64 << slot) != 0) + } + + /// Bitmask of bound slots. + pub fn bound_mask(&self) -> u64 { + self.bound_mask + } + + /// Number of currently bound slots. + pub fn len(&self) -> usize { + self.bound_mask.count_ones() as usize + } + + /// Returns true when no slots are bound. + pub fn is_empty(&self) -> bool { + self.bound_mask == 0 + } + + /// Number of assignments tracked since the last clear or restore. + pub fn trail_len(&self) -> usize { + self.trail.len() + } + + /// Iterates over bound slots in ascending slot order. + pub fn iter_bound(&self) -> impl Iterator + '_ { + let mut mask = self.bound_mask; + std::iter::from_fn(move || { + if mask == 0 { + return None; + } + let slot = mask.trailing_zeros() as u8; + mask &= mask - 1; + Some((slot, self.slots[usize::from(slot)])) + }) + } + + fn slot_index(slot: u8) -> Result { + if usize::from(slot) < MAX_BINDING_SLOTS { + Ok(usize::from(slot)) + } else { + Err(BindingEnvError::SlotOutOfRange { slot }) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn binding(term: u64) -> Binding { + Binding::from_term(TermId(term)) + } + + #[test] + fn bind_reuses_existing_term_without_extending_trail() { + let mut env = BindingEnv::new(); + + assert_eq!(env.bind(3, binding(10)), Ok(true)); + assert_eq!(env.bind(3, binding(10)), Ok(true)); + assert_eq!(env.trail_len(), 1); + assert_eq!(env.get(3), Ok(Some(binding(10)))); + } + + #[test] + fn bind_reports_conflict_without_mutating_existing_binding() { + let mut env = BindingEnv::new(); + + assert_eq!(env.bind(3, binding(10)), Ok(true)); + assert_eq!(env.bind(3, binding(11)), Ok(false)); + assert_eq!(env.get(3), Ok(Some(binding(10)))); + assert_eq!(env.trail_len(), 1); + } + + #[test] + fn rollback_removes_bindings_after_mark() { + let mut env = BindingEnv::new(); + env.bind(1, binding(10)).unwrap(); + let mark = env.mark(); + env.bind(2, binding(20)).unwrap(); + env.bind(3, binding(30)).unwrap(); + + assert_eq!(env.rollback_to(mark), Ok(())); + assert_eq!(env.get(1), Ok(Some(binding(10)))); + assert_eq!(env.get(2), Ok(None)); + assert_eq!(env.get(3), Ok(None)); + assert_eq!(env.bound_mask(), 0b10); + } + + #[test] + fn rollback_hides_stale_slot_until_it_is_rebound() { + let mut env = BindingEnv::new(); + let mark = env.mark(); + env.bind(4, binding(40)).unwrap(); + + env.rollback_to(mark).unwrap(); + assert_eq!(env.get(4), Ok(None)); + assert_eq!(env.bind(4, binding(41)), Ok(true)); + assert_eq!(env.get(4), Ok(Some(binding(41)))); + } + + #[test] + fn capture_and_restore_replace_the_dynamic_environment() { + let mut env = BindingEnv::new(); + env.bind(1, binding(10)).unwrap(); + let snapshot = env.capture(); + env.bind(2, binding(20)).unwrap(); + + env.restore(&snapshot); + + assert_eq!(env.get(1), Ok(Some(binding(10)))); + assert_eq!(env.get(2), Ok(None)); + assert_eq!(env.trail_len(), 0); + } + + #[test] + fn clear_hides_bindings_without_preventing_reuse() { + let mut env = BindingEnv::new(); + env.bind(1, binding(10)).unwrap(); + env.bind(2, binding(20)).unwrap(); + + env.clear(); + assert!(env.is_empty()); + assert_eq!(env.get(1), Ok(None)); + assert_eq!(env.get(2), Ok(None)); + assert_eq!(env.trail_len(), 0); + + assert_eq!(env.bind(1, binding(11)), Ok(true)); + assert_eq!(env.get(1), Ok(Some(binding(11)))); + } + + #[test] + fn equality_ignores_stale_unbound_slots() { + let mut with_stale_slot = BindingEnv::new(); + with_stale_slot.bind(1, binding(10)).unwrap(); + with_stale_slot.clear(); + with_stale_slot.bind(2, binding(20)).unwrap(); + + let mut direct = BindingEnv::new(); + direct.bind(2, binding(20)).unwrap(); + + assert_eq!(with_stale_slot, direct); + } + + #[test] + fn snapshot_equality_ignores_stale_unbound_slots() { + let mut with_stale_slot = BindingEnv::new(); + with_stale_slot.bind(1, binding(10)).unwrap(); + with_stale_slot.clear(); + + assert_eq!(with_stale_slot.capture(), BindingEnv::new().capture()); + } + + #[test] + fn iter_bound_visits_bound_slots_in_mask_order() { + let mut env = BindingEnv::new(); + env.bind(5, binding(50)).unwrap(); + env.bind(1, binding(10)).unwrap(); + env.bind(3, binding(30)).unwrap(); + + let slots = env.iter_bound().collect::>(); + + assert_eq!( + slots, + vec![(1, binding(10)), (3, binding(30)), (5, binding(50))] + ); + } + + #[test] + fn with_rollback_removes_only_extent_bindings() { + let mut env = BindingEnv::new(); + env.bind(1, binding(10)).unwrap(); + + let bound_count = env + .with_rollback(|env| { + assert_eq!(env.bind(2, binding(20)), Ok(true)); + assert_eq!(env.bind(1, binding(10)), Ok(true)); + env.len() + }) + .unwrap(); + + assert_eq!(bound_count, 2); + assert_eq!(env.get(1), Ok(Some(binding(10)))); + assert_eq!(env.get(2), Ok(None)); + assert_eq!(env.trail_len(), 1); + } + + #[test] + fn with_rollback_restores_when_closure_returns_error_value() { + let mut env = BindingEnv::new(); + + let result = env + .with_rollback(|env| { + assert_eq!(env.bind(2, binding(20)), Ok(true)); + env.bind(64, binding(64)) + }) + .unwrap(); + + assert_eq!(result, Err(BindingEnvError::SlotOutOfRange { slot: 64 })); + assert_eq!(env.get(2), Ok(None)); + assert!(env.is_empty()); + } + + #[test] + fn bind_rejects_slot_outside_six_bit_domain() { + let mut env = BindingEnv::new(); + + assert_eq!( + env.bind(64, binding(10)), + Err(BindingEnvError::SlotOutOfRange { slot: 64 }) + ); + } +} diff --git a/kernel/src/lib.rs b/kernel/src/lib.rs index 8680771a..eb03dc79 100644 --- a/kernel/src/lib.rs +++ b/kernel/src/lib.rs @@ -9,6 +9,7 @@ mod sources; mod sinks; mod pure; pub mod term_identity; +pub mod binding_env; #[doc(hidden)] pub use mork_expr as __mork_expr; diff --git a/kernel/tests/binding_env.rs b/kernel/tests/binding_env.rs new file mode 100644 index 00000000..d26ae00b --- /dev/null +++ b/kernel/tests/binding_env.rs @@ -0,0 +1,66 @@ +use mork::binding_env::{ + Binding, BindingEnv, BindingEnvError, BindingSnapshot, BindingSource, MAX_BINDING_SLOTS, +}; +use mork::term_identity::{FactId, TermId}; + +fn term(id: u64) -> TermId { + TermId(id) +} + +#[test] +fn binding_env_provides_fixed_slot_scoped_rollback() { + let mut env = BindingEnv::new(); + + assert_eq!(MAX_BINDING_SLOTS, 64); + assert!(env.is_empty()); + assert_eq!(env.bind_term(1, term(10)), Ok(true)); + assert_eq!(env.bind_term(1, term(10)), Ok(true)); + assert_eq!(env.trail_len(), 1); + + let mark = env.mark(); + assert_eq!(env.bind_term(2, term(20)), Ok(true)); + assert_eq!( + env.bind( + 3, + Binding { + term: term(30), + fact: Some(FactId(7)), + source: BindingSource(42), + }, + ), + Ok(true) + ); + assert_eq!(env.bind_term(2, term(21)), Ok(false)); + assert_eq!(env.get(2), Ok(Some(Binding::from_term(term(20))))); + + let snapshot: BindingSnapshot = env.capture(); + let scoped_len = env + .with_rollback(|env| { + assert_eq!(env.bind_term(4, term(40)), Ok(true)); + env.len() + }) + .unwrap(); + + assert_eq!(scoped_len, 4); + assert_eq!(env.get(4), Ok(None)); + assert_eq!(env.len(), 3); + assert_eq!( + env.iter_bound() + .map(|(slot, binding)| (slot, binding.term)) + .collect::>(), + vec![(1, term(10)), (2, term(20)), (3, term(30))] + ); + + assert_eq!(env.rollback_to(mark), Ok(())); + assert_eq!(env.get(1), Ok(Some(Binding::from_term(term(10))))); + assert_eq!(env.get(2), Ok(None)); + assert_eq!(env.get(3), Ok(None)); + + env.restore(&snapshot); + assert_eq!(env.trail_len(), 0); + assert_eq!(env.len(), 3); + assert_eq!( + env.bind_term(64, term(64)), + Err(BindingEnvError::SlotOutOfRange { slot: 64 }) + ); +} From df3430cab2bb1ed8af2439697c735d711a1a2c3c Mon Sep 17 00:00:00 2001 From: MesTTo Date: Tue, 23 Jun 2026 18:40:30 +1000 Subject: [PATCH 03/13] Expose pattern term identity helpers --- kernel/src/term_identity.rs | 74 ++++++++++++++++++++++++++++--------- 1 file changed, 56 insertions(+), 18 deletions(-) diff --git a/kernel/src/term_identity.rs b/kernel/src/term_identity.rs index 795692b0..a6af07a1 100644 --- a/kernel/src/term_identity.rs +++ b/kernel/src/term_identity.rs @@ -167,28 +167,18 @@ impl TermIdentitySidecar { Self::default() } + /// Interns one complete encoded term and all of its subterms without adding + /// a complete fact record. + pub fn insert_term(&mut self, encoded: &[u8]) -> Result { + let (parsed, _) = self.intern_complete(encoded)?; + Ok(parsed.id) + } + /// Interns one complete encoded fact and all of its subterms. /// /// Re-inserting the same complete fact returns the existing [`FactId`]. pub fn insert_fact(&mut self, encoded: &[u8]) -> Result { - let mark = self.mark(); - let parsed = match self.intern_at(encoded, 0) { - Ok(parsed) => parsed, - Err(error) => { - self.rollback_to(mark); - return Err(error); - } - }; - if parsed.end != encoded.len() { - self.rollback_to(mark); - return Err(TermParseError { - offset: parsed.end, - kind: TermParseErrorKind::TrailingBytes { - parsed_len: parsed.end, - total_len: encoded.len(), - }, - }); - } + let (parsed, mark) = self.intern_complete(encoded)?; if let Some(&fact) = self.fact_by_term.get(&parsed.id) { return Ok(fact); @@ -220,6 +210,32 @@ impl TermIdentitySidecar { Ok(fact.id) } + fn intern_complete( + &mut self, + encoded: &[u8], + ) -> Result<(ParsedTerm, SidecarMark), TermParseError> { + let mark = self.mark(); + let parsed = match self.intern_at(encoded, 0) { + Ok(parsed) => parsed, + Err(error) => { + self.rollback_to(mark); + return Err(error); + } + }; + if parsed.end != encoded.len() { + self.rollback_to(mark); + return Err(TermParseError { + offset: parsed.end, + kind: TermParseErrorKind::TrailingBytes { + parsed_len: parsed.end, + total_len: encoded.len(), + }, + }); + } + + Ok((parsed, mark)) + } + /// Interns every value path from a `PathMap<()>` snapshot. pub fn extend_from_pathmap(&mut self, map: &PathMap<()>) -> Result { let mut inserted = 0usize; @@ -244,6 +260,11 @@ impl TermIdentitySidecar { self.facts.get(id.0 as usize) } + /// Returns complete fact records in insertion order. + pub fn facts(&self) -> &[FactRecord] { + &self.facts + } + /// Returns the existing identity for `encoded` if it has already been interned. pub fn term_id_for_encoded(&self, encoded: &[u8]) -> Option { let hash = structural_hash(encoded); @@ -559,6 +580,23 @@ mod tests { assert_eq!(sidecar.stats().terms, 3); } + #[test] + fn insert_term_does_not_create_fact_record() { + let mut encoded = vec![item_byte(Tag::Arity(2))]; + encoded.extend(sym(b"pattern")); + encoded.push(item_byte(Tag::NewVar)); + + let mut sidecar = TermIdentitySidecar::new(); + let root = sidecar.insert_term(&encoded).unwrap(); + + assert_eq!( + sidecar.get_term(root).unwrap().kind, + TermKind::Application { arity: 2 } + ); + assert_eq!(sidecar.stats().facts, 0); + assert!(sidecar.facts().is_empty()); + } + #[test] fn variable_bearing_fact_is_classified_as_schematic() { let mut encoded = vec![item_byte(Tag::Arity(2))]; From 708689b46b2778810c1da4f8e612773f9605c690 Mon Sep 17 00:00:00 2001 From: MesTTo Date: Tue, 23 Jun 2026 18:44:53 +1000 Subject: [PATCH 04/13] Add relational pattern matcher --- kernel/src/lib.rs | 1 + kernel/src/pattern_relations.rs | 562 ++++++++++++++++++++++++++++++++ 2 files changed, 563 insertions(+) create mode 100644 kernel/src/pattern_relations.rs diff --git a/kernel/src/lib.rs b/kernel/src/lib.rs index eb03dc79..e020d925 100644 --- a/kernel/src/lib.rs +++ b/kernel/src/lib.rs @@ -10,6 +10,7 @@ mod sinks; mod pure; pub mod term_identity; pub mod binding_env; +pub mod pattern_relations; #[doc(hidden)] pub use mork_expr as __mork_expr; diff --git a/kernel/src/pattern_relations.rs b/kernel/src/pattern_relations.rs new file mode 100644 index 00000000..80daf059 --- /dev/null +++ b/kernel/src/pattern_relations.rs @@ -0,0 +1,562 @@ +use crate::binding_env::MAX_BINDING_SLOTS; +use crate::term_identity::{TermId, TermIdentitySidecar, TermKind}; + +/// Query-planner variable identity produced by relationalized pattern lowering. +#[repr(transparent)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)] +pub struct PlanVariable(pub u16); + +/// Origin of a lowered query-planner variable. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PlanVariableKind { + /// User-visible encoded variable slot. + UserSlot(u8), + /// Planner-created variable for an application parent. + Internal, +} + +/// Metadata for one lowered planner variable. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PlanVariableRecord { + /// Stable variable identity within the lowered pattern. + pub id: PlanVariable, + /// Variable origin. + pub kind: PlanVariableKind, +} + +/// Value appearing in a lowered application atom. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub enum PlanValue { + /// Exact canonical term constant. + Constant(TermId), + /// Planner variable. + Variable(PlanVariable), +} + +/// Relationalized application constraint: +/// `App_n(parent, child0, child1, ...)`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ApplicationAtom { + /// Encoded application arity. + pub arity: u8, + /// Parent term variable. + pub parent: PlanVariable, + /// Child constants or variables. + pub children: Box<[PlanValue]>, +} + +/// Sidecar lowering of one encoded MORK pattern into structural constraints. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PatternRelationPlan { + root: PlanValue, + atoms: Box<[ApplicationAtom]>, + variables: Box<[PlanVariableRecord]>, + user_slots: [Option; MAX_BINDING_SLOTS], +} + +impl PatternRelationPlan { + /// Root value that should be constrained by the fact-root relation. + pub fn root(&self) -> PlanValue { + self.root + } + + /// Lowered application atoms. + pub fn atoms(&self) -> &[ApplicationAtom] { + &self.atoms + } + + /// Planner variable metadata. + pub fn variables(&self) -> &[PlanVariableRecord] { + &self.variables + } + + /// Returns the planner variable for a user-visible slot, if the slot occurs. + pub fn user_slot(&self, slot: u8) -> Option { + self.user_slots.get(usize::from(slot)).copied().flatten() + } + + /// Number of distinct user-visible slots referenced by the pattern. + pub fn user_slot_count(&self) -> usize { + self.user_slots.iter().flatten().count() + } +} + +/// Errors from relationalized pattern lowering. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PatternLoweringError { + /// The supplied root or one of its children is absent from the term sidecar. + UnknownTerm { term: TermId }, + /// The pattern contains more than the six-bit variable-slot domain permits. + TooManyUserSlots, + /// More planner variables were needed than fit in the compact identifier. + TooManyPlanVariables, +} + +/// One row matched by a relationalized pattern plan. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PatternRelationRow { + /// Complete fact root that matched the pattern. + pub root: TermId, + /// User-visible bindings in ascending slot order. + pub user_bindings: Box<[(u8, TermId)]>, +} + +/// Counters from sidecar-only pattern matching. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct PatternRelationMatchStats { + /// Complete fact roots examined. + pub facts_scanned: usize, + /// Application atoms checked against candidate terms. + pub app_atoms_checked: usize, + /// Successful rows emitted. + pub matches: usize, +} + +/// Matched rows and counters. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct PatternRelationMatches { + /// Matching rows. + pub rows: Vec, + /// Work counters. + pub stats: PatternRelationMatchStats, +} + +/// Errors from executing a relationalized sidecar plan. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PatternRelationMatchError { + /// The plan refers to a variable outside its variable table. + UnknownVariable { variable: PlanVariable }, + /// The sidecar is missing a candidate term referenced by a fact or binding. + UnknownTerm { term: TermId }, + /// More than one application atom used the same parent variable. + DuplicateParentAtom { variable: PlanVariable }, +} + +/// Lowers one canonical term into a sidecar relational query plan. +/// +/// This is not wired into the live matcher yet. It provides the measured +/// `App_n(parent, children...)` shape needed for the next BindingSpace/LFTJ +/// prototype while keeping PathMap byte semantics authoritative. +pub fn lower_pattern( + sidecar: &TermIdentitySidecar, + root: TermId, +) -> Result { + let mut ctx = LoweringContext::new(sidecar); + let root = ctx.lower(root)?; + + Ok(PatternRelationPlan { + root, + atoms: ctx.atoms.into_boxed_slice(), + variables: ctx.variables.into_boxed_slice(), + user_slots: ctx.user_slots, + }) +} + +/// Executes a lowered pattern against complete fact roots in the term sidecar. +/// +/// This is a comparison substrate for ground and repeated-variable facts. It +/// uses canonical `TermId` equality only and never invokes general unification. +pub fn match_facts( + sidecar: &TermIdentitySidecar, + plan: &PatternRelationPlan, +) -> Result { + let mut matcher = FactMatcher::new(sidecar, plan)?; + let mut result = PatternRelationMatches::default(); + + for fact in sidecar.facts() { + result.stats.facts_scanned += 1; + let mut state = MatchState::new(plan.variables.len(), plan.atoms.len()); + if matcher.match_root(&mut state, fact.root, &mut result.stats)? { + result.stats.matches += 1; + result.rows.push(PatternRelationRow { + root: fact.root, + user_bindings: state.user_bindings(plan).into_boxed_slice(), + }); + } + } + + Ok(result) +} + +struct LoweringContext<'a> { + sidecar: &'a TermIdentitySidecar, + atoms: Vec, + variables: Vec, + user_slots: [Option; MAX_BINDING_SLOTS], + next_newvar_slot: u8, +} + +impl<'a> LoweringContext<'a> { + fn new(sidecar: &'a TermIdentitySidecar) -> Self { + Self { + sidecar, + atoms: Vec::new(), + variables: Vec::new(), + user_slots: [None; MAX_BINDING_SLOTS], + next_newvar_slot: 0, + } + } + + fn lower(&mut self, term: TermId) -> Result { + let Some(record) = self.sidecar.get_term(term) else { + return Err(PatternLoweringError::UnknownTerm { term }); + }; + + match record.kind { + TermKind::Symbol => Ok(PlanValue::Constant(term)), + TermKind::NewVar => { + let slot = self.next_newvar_slot; + self.next_newvar_slot = self + .next_newvar_slot + .checked_add(1) + .ok_or(PatternLoweringError::TooManyUserSlots)?; + Ok(PlanValue::Variable(self.user_slot(slot)?)) + } + TermKind::VarRef(slot) => Ok(PlanValue::Variable(self.user_slot(slot)?)), + TermKind::Application { arity } => { + let parent = self.fresh_variable(PlanVariableKind::Internal)?; + let children = record + .children() + .iter() + .copied() + .map(|child| self.lower(child)) + .collect::, _>>()?; + + self.atoms.push(ApplicationAtom { + arity, + parent, + children: children.into_boxed_slice(), + }); + + Ok(PlanValue::Variable(parent)) + } + } + } + + fn user_slot(&mut self, slot: u8) -> Result { + let index = usize::from(slot); + if index >= MAX_BINDING_SLOTS { + return Err(PatternLoweringError::TooManyUserSlots); + } + + if let Some(variable) = self.user_slots[index] { + return Ok(variable); + } + + let variable = self.fresh_variable(PlanVariableKind::UserSlot(slot))?; + self.user_slots[index] = Some(variable); + Ok(variable) + } + + fn fresh_variable( + &mut self, + kind: PlanVariableKind, + ) -> Result { + let id = u16::try_from(self.variables.len()) + .map_err(|_| PatternLoweringError::TooManyPlanVariables)?; + let variable = PlanVariable(id); + self.variables + .push(PlanVariableRecord { id: variable, kind }); + Ok(variable) + } +} + +struct FactMatcher<'a> { + sidecar: &'a TermIdentitySidecar, + plan: &'a PatternRelationPlan, + atom_by_parent: Vec>, +} + +impl<'a> FactMatcher<'a> { + fn new( + sidecar: &'a TermIdentitySidecar, + plan: &'a PatternRelationPlan, + ) -> Result { + let mut atom_by_parent = vec![None; plan.variables.len()]; + for (index, atom) in plan.atoms.iter().enumerate() { + let parent = usize::from(atom.parent.0); + let Some(slot) = atom_by_parent.get_mut(parent) else { + return Err(PatternRelationMatchError::UnknownVariable { + variable: atom.parent, + }); + }; + if slot.replace(index).is_some() { + return Err(PatternRelationMatchError::DuplicateParentAtom { + variable: atom.parent, + }); + } + } + + Ok(Self { + sidecar, + plan, + atom_by_parent, + }) + } + + fn match_root( + &mut self, + state: &mut MatchState, + root: TermId, + stats: &mut PatternRelationMatchStats, + ) -> Result { + self.match_value(state, self.plan.root, root, stats) + } + + fn match_value( + &mut self, + state: &mut MatchState, + value: PlanValue, + term: TermId, + stats: &mut PatternRelationMatchStats, + ) -> Result { + match value { + PlanValue::Constant(expected) => Ok(expected == term), + PlanValue::Variable(variable) => self.bind_variable(state, variable, term, stats), + } + } + + fn bind_variable( + &mut self, + state: &mut MatchState, + variable: PlanVariable, + term: TermId, + stats: &mut PatternRelationMatchStats, + ) -> Result { + let index = usize::from(variable.0); + let Some(binding) = state.bindings.get_mut(index) else { + return Err(PatternRelationMatchError::UnknownVariable { variable }); + }; + + if let Some(existing) = *binding { + return Ok(existing == term); + } + + *binding = Some(term); + self.check_variable_atom(state, variable, stats) + } + + fn check_variable_atom( + &mut self, + state: &mut MatchState, + variable: PlanVariable, + stats: &mut PatternRelationMatchStats, + ) -> Result { + let variable_index = usize::from(variable.0); + let Some(&maybe_atom) = self.atom_by_parent.get(variable_index) else { + return Err(PatternRelationMatchError::UnknownVariable { variable }); + }; + let Some(atom_index) = maybe_atom else { + return Ok(true); + }; + if state.checked_atoms[atom_index] { + return Ok(true); + } + + let term = state.bindings[variable_index] + .expect("variable should be bound before checking its application atom"); + let Some(record) = self.sidecar.get_term(term) else { + return Err(PatternRelationMatchError::UnknownTerm { term }); + }; + let atom = &self.plan.atoms[atom_index]; + + let TermKind::Application { arity } = record.kind else { + return Ok(false); + }; + if arity != atom.arity || record.children().len() != atom.children.len() { + return Ok(false); + } + + state.checked_atoms[atom_index] = true; + stats.app_atoms_checked += 1; + + for (&child_value, &child_term) in atom.children.iter().zip(record.children()) { + if !self.match_value(state, child_value, child_term, stats)? { + return Ok(false); + } + } + + Ok(true) + } +} + +struct MatchState { + bindings: Vec>, + checked_atoms: Vec, +} + +impl MatchState { + fn new(variables: usize, atoms: usize) -> Self { + Self { + bindings: vec![None; variables], + checked_atoms: vec![false; atoms], + } + } + + fn user_bindings(&self, plan: &PatternRelationPlan) -> Vec<(u8, TermId)> { + plan.user_slots + .iter() + .enumerate() + .filter_map(|(slot, variable)| { + let variable = (*variable)?; + let term = self.bindings[usize::from(variable.0)]?; + Some(( + u8::try_from(slot).expect("binding slot is within 0..64"), + term, + )) + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::space::Space; + use crate::term_identity::TermIdentitySidecar; + use mork_expr::{item_byte, Tag}; + use std::collections::BTreeSet; + + fn sym(bytes: &[u8]) -> Vec { + let mut out = vec![item_byte(Tag::SymbolSize(bytes.len() as u8))]; + out.extend_from_slice(bytes); + out + } + + fn app(children: &[Vec]) -> Vec { + let mut out = vec![item_byte(Tag::Arity(children.len() as u8))]; + for child in children { + out.extend_from_slice(child); + } + out + } + + fn var() -> Vec { + vec![item_byte(Tag::NewVar)] + } + + fn var_ref(slot: u8) -> Vec { + vec![item_byte(Tag::VarRef(slot))] + } + + fn lower_fact_pattern( + pattern: Vec, + ) -> (TermIdentitySidecar, PatternRelationPlan, PlanVariable) { + let mut sidecar = TermIdentitySidecar::new(); + let fact = sidecar.insert_fact(&pattern).unwrap(); + let root = sidecar.get_fact(fact).unwrap().root; + let plan = lower_pattern(&sidecar, root).unwrap(); + let PlanValue::Variable(root_variable) = plan.root() else { + panic!("application root should lower to an internal variable"); + }; + (sidecar, plan, root_variable) + } + + #[test] + fn repeated_variable_pattern_lowers_to_shared_user_slot() { + let inner = app(&[sym(b"f"), var_ref(0)]); + let pattern = app(&[sym(b"edge"), var(), inner]); + let (sidecar, plan, root_variable) = lower_fact_pattern(pattern); + let user_slot = plan.user_slot(0).unwrap(); + let edge = sidecar.term_id_for_encoded(&sym(b"edge")).unwrap(); + + assert_eq!(plan.user_slot_count(), 1); + assert_eq!( + plan.variables()[usize::from(root_variable.0)].kind, + PlanVariableKind::Internal + ); + assert_eq!( + plan.variables()[usize::from(user_slot.0)].kind, + PlanVariableKind::UserSlot(0) + ); + + let root_atom = plan + .atoms() + .iter() + .find(|atom| atom.parent == root_variable) + .unwrap(); + assert_eq!(root_atom.arity, 3); + assert_eq!(root_atom.children[0], PlanValue::Constant(edge)); + assert_eq!(root_atom.children[1], PlanValue::Variable(user_slot)); + + let PlanValue::Variable(inner_variable) = root_atom.children[2] else { + panic!("nested application child should lower to an internal variable"); + }; + let inner_atom = plan + .atoms() + .iter() + .find(|atom| atom.parent == inner_variable) + .unwrap(); + assert_eq!(inner_atom.arity, 2); + assert_eq!(inner_atom.children[1], PlanValue::Variable(user_slot)); + } + + #[test] + fn ground_application_uses_constants_for_children() { + let pattern = app(&[sym(b"edge"), sym(b"Alice"), sym(b"Bob")]); + let (_, plan, root_variable) = lower_fact_pattern(pattern); + let root_atom = plan + .atoms() + .iter() + .find(|atom| atom.parent == root_variable) + .unwrap(); + + assert_eq!(plan.user_slot_count(), 0); + assert!(root_atom + .children + .iter() + .all(|value| matches!(value, PlanValue::Constant(_)))); + } + + #[test] + fn unknown_term_returns_error() { + let sidecar = TermIdentitySidecar::new(); + + assert_eq!( + lower_pattern(&sidecar, TermId(7)), + Err(PatternLoweringError::UnknownTerm { term: TermId(7) }) + ); + } + + #[test] + fn sidecar_matcher_preserves_product_query_roots_for_repeated_variable_pattern() { + let mut space = Space::new(); + space + .add_all_sexpr( + br#" +(edge Alice (f Alice)) +(edge Alice (f Bob)) +(edge Bob (f Bob)) +(edge Carol (g Carol)) +(edge Dave (f Eve)) +"#, + ) + .unwrap(); + + let pattern = app(&[sym(b"edge"), var(), app(&[sym(b"f"), var_ref(0)])]); + let mut sidecar = TermIdentitySidecar::new(); + let pattern_root = sidecar.insert_term(&pattern).unwrap(); + sidecar.extend_from_pathmap(&space.btm).unwrap(); + let plan = lower_pattern(&sidecar, pattern_root).unwrap(); + let sidecar_matches = match_facts(&sidecar, &plan).unwrap(); + + let product_pattern = crate::expr!(space, "[2] , [3] edge $ [2] f _1"); + let mut product_roots = BTreeSet::new(); + let product_count = Space::query_multi(&space.btm, product_pattern, |_, loc| { + let span = unsafe { loc.span().as_ref().unwrap() }; + product_roots.insert(span.to_vec()); + true + }); + + let sidecar_roots = sidecar_matches + .rows + .iter() + .map(|row| sidecar.get_term(row.root).unwrap().encoded().to_vec()) + .collect::>(); + + assert_eq!(product_count, 2); + assert_eq!(sidecar_matches.stats.matches, product_count); + assert_eq!(sidecar_matches.stats.facts_scanned, sidecar.stats().facts); + assert_eq!(sidecar_roots, product_roots); + } +} From 4b1a5a4d4babd2086bdb89920e4009ab839d434c Mon Sep 17 00:00:00 2001 From: MesTTo Date: Tue, 23 Jun 2026 18:52:14 +1000 Subject: [PATCH 05/13] Add expression trie sidecar --- kernel/src/expression_trie.rs | 309 ++++++++++++++++++++++++++++++++ kernel/src/lib.rs | 3 + kernel/src/pattern_relations.rs | 72 +++----- kernel/src/test_exprs.rs | 54 ++++++ 4 files changed, 393 insertions(+), 45 deletions(-) create mode 100644 kernel/src/expression_trie.rs create mode 100644 kernel/src/test_exprs.rs diff --git a/kernel/src/expression_trie.rs b/kernel/src/expression_trie.rs new file mode 100644 index 00000000..33ea4967 --- /dev/null +++ b/kernel/src/expression_trie.rs @@ -0,0 +1,309 @@ +use std::collections::BTreeMap; + +use crate::pattern_relations::{ + lower_pattern, match_fact_ids, PatternLoweringError, PatternRelationMatchError, + PatternRelationMatches, +}; +use crate::term_identity::{FactId, TermId, TermIdentitySidecar, TermKind}; + +/// Typed preorder token used by the derived expression trie. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)] +pub enum ExpressionTrieToken { + /// Application node with encoded arity. + App(u8), + /// Complete interned symbol item. + Symbol(TermId), + /// Stored schematic new-variable token. + NewVar, + /// Stored schematic variable reference token. + VarRef(u8), +} + +/// Snapshot-local discrimination-trie style index over canonical term roots. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ExpressionTrieIndex { + nodes: Vec, + stats: ExpressionTrieStats, +} + +/// Counters for expression-trie build and candidate lookup. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct ExpressionTrieStats { + /// Complete facts indexed. + pub facts_indexed: usize, + /// Trie nodes allocated, including the root. + pub trie_nodes: usize, + /// Typed preorder tokens inserted across all facts. + pub tokens_indexed: usize, +} + +/// Candidate lookup result before exact filtering. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ExpressionTrieCandidates { + /// Conservative typed prefix used for trie descent. + pub prefix: Box<[ExpressionTrieToken]>, + /// Complete facts below the prefix. + pub facts: Box<[FactId]>, +} + +/// Match result from expression-trie candidate retrieval plus exact filtering. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ExpressionTrieMatches { + /// Prefix-filter candidates. + pub candidates: ExpressionTrieCandidates, + /// Exact relationalized pattern matches over the candidate facts. + pub exact: PatternRelationMatches, +} + +/// Errors from expression-trie construction or matching. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ExpressionTrieError { + /// A term referenced by a fact or pattern is absent from the sidecar. + UnknownTerm { term: TermId }, + /// Pattern lowering failed. + Lowering(PatternLoweringError), + /// Exact candidate filtering failed. + Match(PatternRelationMatchError), +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +struct ExpressionTrieNode { + children: BTreeMap, + facts: Vec, +} + +impl ExpressionTrieIndex { + /// Builds a derived typed expression trie from complete fact roots. + pub fn build(sidecar: &TermIdentitySidecar) -> Result { + let mut index = Self { + nodes: vec![ExpressionTrieNode::default()], + stats: ExpressionTrieStats { + trie_nodes: 1, + ..ExpressionTrieStats::default() + }, + }; + + for fact in sidecar.facts() { + index.insert_fact(sidecar, fact.id, fact.root)?; + } + + Ok(index) + } + + /// Build and lookup counters. + pub fn stats(&self) -> ExpressionTrieStats { + self.stats + } + + /// Returns candidate fact IDs for the grounded typed prefix of `pattern`. + /// + /// Pattern variables stop prefix extraction because they match complete + /// subterms of unknown length. Later constants are checked by the exact + /// relationalized matcher. + pub fn candidates_for_pattern( + &self, + sidecar: &TermIdentitySidecar, + pattern: TermId, + ) -> Result { + let mut prefix = Vec::new(); + append_ground_prefix(sidecar, pattern, &mut prefix)?; + let facts = self.facts_below_prefix(&prefix); + + Ok(ExpressionTrieCandidates { + prefix: prefix.into_boxed_slice(), + facts: facts.into_boxed_slice(), + }) + } + + /// Prefix-filtered exact matching for one pattern term. + pub fn match_pattern( + &self, + sidecar: &TermIdentitySidecar, + pattern: TermId, + ) -> Result { + let candidates = self.candidates_for_pattern(sidecar, pattern)?; + let plan = lower_pattern(sidecar, pattern).map_err(ExpressionTrieError::Lowering)?; + let exact = match_fact_ids(sidecar, &plan, candidates.facts.iter().copied()) + .map_err(ExpressionTrieError::Match)?; + + Ok(ExpressionTrieMatches { candidates, exact }) + } + + fn insert_fact( + &mut self, + sidecar: &TermIdentitySidecar, + fact: FactId, + root: TermId, + ) -> Result<(), ExpressionTrieError> { + let mut path = Vec::new(); + append_exact_tokens(sidecar, root, &mut path)?; + + let mut node = 0usize; + for token in path { + self.stats.tokens_indexed += 1; + if let Some(&child) = self.nodes[node].children.get(&token) { + node = child; + continue; + } + + let child = self.nodes.len(); + self.nodes.push(ExpressionTrieNode::default()); + self.nodes[node].children.insert(token, child); + self.stats.trie_nodes += 1; + node = child; + } + + self.nodes[node].facts.push(fact); + self.stats.facts_indexed += 1; + Ok(()) + } + + fn facts_below_prefix(&self, prefix: &[ExpressionTrieToken]) -> Vec { + let mut node = 0usize; + for token in prefix { + let Some(&child) = self.nodes[node].children.get(token) else { + return Vec::new(); + }; + node = child; + } + + let mut facts = Vec::new(); + self.collect_facts(node, &mut facts); + facts.sort_unstable(); + facts + } + + fn collect_facts(&self, node: usize, facts: &mut Vec) { + facts.extend_from_slice(&self.nodes[node].facts); + for &child in self.nodes[node].children.values() { + self.collect_facts(child, facts); + } + } +} + +fn append_exact_tokens( + sidecar: &TermIdentitySidecar, + term: TermId, + out: &mut Vec, +) -> Result<(), ExpressionTrieError> { + let Some(record) = sidecar.get_term(term) else { + return Err(ExpressionTrieError::UnknownTerm { term }); + }; + + match record.kind { + TermKind::Symbol => out.push(ExpressionTrieToken::Symbol(term)), + TermKind::Application { arity } => { + out.push(ExpressionTrieToken::App(arity)); + for &child in record.children() { + append_exact_tokens(sidecar, child, out)?; + } + } + TermKind::NewVar => out.push(ExpressionTrieToken::NewVar), + TermKind::VarRef(level) => out.push(ExpressionTrieToken::VarRef(level)), + } + + Ok(()) +} + +fn append_ground_prefix( + sidecar: &TermIdentitySidecar, + term: TermId, + out: &mut Vec, +) -> Result { + let Some(record) = sidecar.get_term(term) else { + return Err(ExpressionTrieError::UnknownTerm { term }); + }; + + match record.kind { + TermKind::Symbol => { + out.push(ExpressionTrieToken::Symbol(term)); + Ok(true) + } + TermKind::Application { arity } => { + out.push(ExpressionTrieToken::App(arity)); + for &child in record.children() { + if !append_ground_prefix(sidecar, child, out)? { + return Ok(false); + } + } + Ok(true) + } + TermKind::NewVar | TermKind::VarRef(_) => Ok(false), + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use super::*; + use crate::space::Space; + use crate::test_exprs::{ + add_repeated_edge_facts, app, repeated_edge_pattern, repeated_edge_product_roots, sym, + }; + + #[test] + fn typed_expression_trie_filters_repeated_variable_pattern_before_exact_match() { + let mut space = Space::new(); + add_repeated_edge_facts( + &mut space, + br#" +(node Alice) +(tag Bob) +"#, + ); + + let pattern = repeated_edge_pattern(); + let mut sidecar = TermIdentitySidecar::new(); + let pattern_root = sidecar.insert_term(&pattern).unwrap(); + sidecar.extend_from_pathmap(&space.btm).unwrap(); + let index = ExpressionTrieIndex::build(&sidecar).unwrap(); + + let matches = index.match_pattern(&sidecar, pattern_root).unwrap(); + let (product_count, product_roots) = repeated_edge_product_roots(&space); + let trie_roots = matches + .exact + .rows + .iter() + .map(|row| sidecar.get_term(row.root).unwrap().encoded().to_vec()) + .collect::>(); + + assert_eq!(product_count, 2); + assert_eq!(matches.exact.stats.matches, product_count); + assert_eq!(trie_roots, product_roots); + assert_eq!(matches.candidates.prefix.len(), 2); + assert_eq!(matches.candidates.facts.len(), 5); + assert!(matches.candidates.facts.len() < sidecar.stats().facts); + assert_eq!( + matches.exact.stats.facts_scanned, + matches.candidates.facts.len() + ); + } + + #[test] + fn typed_expression_trie_exact_ground_prefix_returns_one_candidate() { + let mut space = Space::new(); + space + .add_all_sexpr( + br#" +(edge Alice Bob) +(edge Bob Carol) +(node Alice) +"#, + ) + .unwrap(); + + let pattern = app(&[sym(b"edge"), sym(b"Alice"), sym(b"Bob")]); + let mut sidecar = TermIdentitySidecar::new(); + let pattern_root = sidecar.insert_term(&pattern).unwrap(); + sidecar.extend_from_pathmap(&space.btm).unwrap(); + let index = ExpressionTrieIndex::build(&sidecar).unwrap(); + + let matches = index.match_pattern(&sidecar, pattern_root).unwrap(); + + assert_eq!(matches.candidates.facts.len(), 1); + assert_eq!(matches.candidates.prefix.len(), 4); + assert_eq!(matches.exact.stats.matches, 1); + assert_eq!(matches.exact.stats.facts_scanned, 1); + } +} diff --git a/kernel/src/lib.rs b/kernel/src/lib.rs index e020d925..e20589fc 100644 --- a/kernel/src/lib.rs +++ b/kernel/src/lib.rs @@ -11,6 +11,9 @@ mod pure; pub mod term_identity; pub mod binding_env; pub mod pattern_relations; +pub mod expression_trie; +#[cfg(test)] +mod test_exprs; #[doc(hidden)] pub use mork_expr as __mork_expr; diff --git a/kernel/src/pattern_relations.rs b/kernel/src/pattern_relations.rs index 80daf059..784a206b 100644 --- a/kernel/src/pattern_relations.rs +++ b/kernel/src/pattern_relations.rs @@ -1,5 +1,5 @@ use crate::binding_env::MAX_BINDING_SLOTS; -use crate::term_identity::{TermId, TermIdentitySidecar, TermKind}; +use crate::term_identity::{FactId, TermId, TermIdentitySidecar, TermKind}; /// Query-planner variable identity produced by relationalized pattern lowering. #[repr(transparent)] @@ -124,6 +124,8 @@ pub struct PatternRelationMatches { /// Errors from executing a relationalized sidecar plan. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum PatternRelationMatchError { + /// The sidecar is missing a requested complete fact record. + UnknownFact { fact: FactId }, /// The plan refers to a variable outside its variable table. UnknownVariable { variable: PlanVariable }, /// The sidecar is missing a candidate term referenced by a fact or binding. @@ -159,11 +161,27 @@ pub fn lower_pattern( pub fn match_facts( sidecar: &TermIdentitySidecar, plan: &PatternRelationPlan, +) -> Result { + match_fact_ids(sidecar, plan, sidecar.facts().iter().map(|fact| fact.id)) +} + +/// Executes a lowered pattern against an explicit candidate set of fact IDs. +/// +/// This is the exact filtering boundary used by derived indexes. Candidate +/// generation may be approximate, but every emitted row still passes through the +/// same canonical `TermId` equality checks as [`match_facts`]. +pub fn match_fact_ids( + sidecar: &TermIdentitySidecar, + plan: &PatternRelationPlan, + facts: impl IntoIterator, ) -> Result { let mut matcher = FactMatcher::new(sidecar, plan)?; let mut result = PatternRelationMatches::default(); - for fact in sidecar.facts() { + for fact_id in facts { + let Some(fact) = sidecar.get_fact(fact_id) else { + return Err(PatternRelationMatchError::UnknownFact { fact: fact_id }); + }; result.stats.facts_scanned += 1; let mut state = MatchState::new(plan.variables.len(), plan.atoms.len()); if matcher.match_root(&mut state, fact.root, &mut result.stats)? { @@ -414,31 +432,12 @@ mod tests { use super::*; use crate::space::Space; use crate::term_identity::TermIdentitySidecar; - use mork_expr::{item_byte, Tag}; + use crate::test_exprs::{ + add_repeated_edge_facts, app, repeated_edge_pattern, repeated_edge_product_roots, sym, var, + var_ref, + }; use std::collections::BTreeSet; - fn sym(bytes: &[u8]) -> Vec { - let mut out = vec![item_byte(Tag::SymbolSize(bytes.len() as u8))]; - out.extend_from_slice(bytes); - out - } - - fn app(children: &[Vec]) -> Vec { - let mut out = vec![item_byte(Tag::Arity(children.len() as u8))]; - for child in children { - out.extend_from_slice(child); - } - out - } - - fn var() -> Vec { - vec![item_byte(Tag::NewVar)] - } - - fn var_ref(slot: u8) -> Vec { - vec![item_byte(Tag::VarRef(slot))] - } - fn lower_fact_pattern( pattern: Vec, ) -> (TermIdentitySidecar, PatternRelationPlan, PlanVariable) { @@ -521,33 +520,16 @@ mod tests { #[test] fn sidecar_matcher_preserves_product_query_roots_for_repeated_variable_pattern() { let mut space = Space::new(); - space - .add_all_sexpr( - br#" -(edge Alice (f Alice)) -(edge Alice (f Bob)) -(edge Bob (f Bob)) -(edge Carol (g Carol)) -(edge Dave (f Eve)) -"#, - ) - .unwrap(); + add_repeated_edge_facts(&mut space, b""); - let pattern = app(&[sym(b"edge"), var(), app(&[sym(b"f"), var_ref(0)])]); + let pattern = repeated_edge_pattern(); let mut sidecar = TermIdentitySidecar::new(); let pattern_root = sidecar.insert_term(&pattern).unwrap(); sidecar.extend_from_pathmap(&space.btm).unwrap(); let plan = lower_pattern(&sidecar, pattern_root).unwrap(); let sidecar_matches = match_facts(&sidecar, &plan).unwrap(); - let product_pattern = crate::expr!(space, "[2] , [3] edge $ [2] f _1"); - let mut product_roots = BTreeSet::new(); - let product_count = Space::query_multi(&space.btm, product_pattern, |_, loc| { - let span = unsafe { loc.span().as_ref().unwrap() }; - product_roots.insert(span.to_vec()); - true - }); - + let (product_count, product_roots) = repeated_edge_product_roots(&space); let sidecar_roots = sidecar_matches .rows .iter() diff --git a/kernel/src/test_exprs.rs b/kernel/src/test_exprs.rs new file mode 100644 index 00000000..d2461863 --- /dev/null +++ b/kernel/src/test_exprs.rs @@ -0,0 +1,54 @@ +use std::collections::BTreeSet; + +use crate::space::Space; +use mork_expr::{item_byte, Tag}; + +pub fn sym(bytes: &[u8]) -> Vec { + let mut out = vec![item_byte(Tag::SymbolSize(bytes.len() as u8))]; + out.extend_from_slice(bytes); + out +} + +pub fn app(children: &[Vec]) -> Vec { + let mut out = vec![item_byte(Tag::Arity(children.len() as u8))]; + for child in children { + out.extend_from_slice(child); + } + out +} + +pub fn var() -> Vec { + vec![item_byte(Tag::NewVar)] +} + +pub fn var_ref(slot: u8) -> Vec { + vec![item_byte(Tag::VarRef(slot))] +} + +pub fn repeated_edge_pattern() -> Vec { + app(&[sym(b"edge"), var(), app(&[sym(b"f"), var_ref(0)])]) +} + +pub fn add_repeated_edge_facts(space: &mut Space, extra_facts: &[u8]) { + let mut facts = br#" +(edge Alice (f Alice)) +(edge Alice (f Bob)) +(edge Bob (f Bob)) +(edge Carol (g Carol)) +(edge Dave (f Eve)) +"# + .to_vec(); + facts.extend_from_slice(extra_facts); + space.add_all_sexpr(&facts).unwrap(); +} + +pub fn repeated_edge_product_roots(space: &Space) -> (usize, BTreeSet>) { + let product_pattern = crate::expr!(space, "[2] , [3] edge $ [2] f _1"); + let mut product_roots = BTreeSet::new(); + let product_count = Space::query_multi(&space.btm, product_pattern, |_, loc| { + let span = unsafe { loc.span().as_ref().unwrap() }; + product_roots.insert(span.to_vec()); + true + }); + (product_count, product_roots) +} From c4df02c7a4b2f7d4a6ca04552c5201a06af15058 Mon Sep 17 00:00:00 2001 From: MesTTo Date: Tue, 23 Jun 2026 18:56:04 +1000 Subject: [PATCH 06/13] Add relation arrangement sidecar --- kernel/src/arrangements.rs | 276 +++++++++++++++++++++++++++++++++++++ kernel/src/lib.rs | 1 + 2 files changed, 277 insertions(+) create mode 100644 kernel/src/arrangements.rs diff --git a/kernel/src/arrangements.rs b/kernel/src/arrangements.rs new file mode 100644 index 00000000..194d4fd3 --- /dev/null +++ b/kernel/src/arrangements.rs @@ -0,0 +1,276 @@ +use std::collections::BTreeMap; + +use crate::term_identity::{FactId, TermId, TermIdentitySidecar, TermKind}; + +/// Physical argument-order sidecar for relation-like facts. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ArrangementDescriptor { + /// Relation/functor term at child position 0. + pub relation: TermId, + /// Number of relation arguments, excluding the relation/functor child. + pub argument_count: u8, + /// Argument positions used as the key, zero-based after the relation child. + pub key_order: Box<[u8]>, +} + +impl ArrangementDescriptor { + /// Creates a descriptor after validating the key order. + pub fn new( + relation: TermId, + argument_count: u8, + key_order: impl Into>, + ) -> Result { + let key_order = key_order.into(); + validate_key_order(argument_count, &key_order)?; + + Ok(Self { + relation, + argument_count, + key_order, + }) + } +} + +/// One arranged fact row. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ArrangementRow { + /// Complete fact identity. + pub fact: FactId, + /// Root term for the fact. + pub root: TermId, +} + +/// Snapshot-local arrangement index. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ArrangementIndex { + descriptor: ArrangementDescriptor, + rows_by_key: BTreeMap, Vec>, + stats: ArrangementStats, +} + +/// Counters for arrangement construction and lookup. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct ArrangementStats { + /// Complete fact roots inspected during build. + pub facts_scanned: usize, + /// Facts whose root matched the relation and arity. + pub rows: usize, + /// Distinct arranged keys. + pub distinct_keys: usize, + /// Rows returned by prefix lookups. + pub prefix_rows_returned: usize, + /// Prefix lookup calls. + pub prefix_lookups: usize, +} + +/// Errors from arrangement construction. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ArrangementError { + /// The relation term is absent from the term sidecar. + UnknownRelation { relation: TermId }, + /// A key position is outside the declared argument count. + InvalidKeyPosition { position: u8, argument_count: u8 }, + /// Duplicate key positions would not define a useful arrangement order. + DuplicateKeyPosition { position: u8 }, + /// A fact referenced a missing term record. + UnknownTerm { term: TermId }, + /// Encoded arity would overflow when adding the relation/functor child. + ArityOverflow { argument_count: u8 }, +} + +impl ArrangementIndex { + /// Builds a derived arrangement from a term snapshot. + pub fn build( + sidecar: &TermIdentitySidecar, + descriptor: ArrangementDescriptor, + ) -> Result { + if sidecar.get_term(descriptor.relation).is_none() { + return Err(ArrangementError::UnknownRelation { + relation: descriptor.relation, + }); + } + + let encoded_arity = + descriptor + .argument_count + .checked_add(1) + .ok_or(ArrangementError::ArityOverflow { + argument_count: descriptor.argument_count, + })?; + let mut stats = ArrangementStats::default(); + let mut rows_by_key: BTreeMap, Vec> = BTreeMap::new(); + + for fact in sidecar.facts() { + stats.facts_scanned += 1; + let Some(root) = sidecar.get_term(fact.root) else { + return Err(ArrangementError::UnknownTerm { term: fact.root }); + }; + if root.kind + != (TermKind::Application { + arity: encoded_arity, + }) + { + continue; + } + let children = root.children(); + if children.first().copied() != Some(descriptor.relation) { + continue; + } + + let key = descriptor + .key_order + .iter() + .map(|&position| children[usize::from(position) + 1]) + .collect::>() + .into_boxed_slice(); + rows_by_key.entry(key).or_default().push(ArrangementRow { + fact: fact.id, + root: fact.root, + }); + stats.rows += 1; + } + + stats.distinct_keys = rows_by_key.len(); + Ok(Self { + descriptor, + rows_by_key, + stats, + }) + } + + /// Arrangement descriptor. + pub fn descriptor(&self) -> &ArrangementDescriptor { + &self.descriptor + } + + /// Returns counters accumulated by build and lookup calls. + pub fn stats(&self) -> ArrangementStats { + self.stats + } + + /// Returns all rows whose arranged key starts with `prefix`. + pub fn seek_prefix(&mut self, prefix: &[TermId]) -> Vec { + self.stats.prefix_lookups += 1; + + let mut rows = Vec::new(); + for (key, key_rows) in self.rows_by_key.range(prefix.to_vec().into_boxed_slice()..) { + if !key.starts_with(prefix) { + break; + } + rows.extend_from_slice(key_rows); + } + + self.stats.prefix_rows_returned += rows.len(); + rows + } + + /// Exact arranged-key lookup. + pub fn get_exact(&self, key: &[TermId]) -> &[ArrangementRow] { + self.rows_by_key.get(key).map(Vec::as_slice).unwrap_or(&[]) + } +} + +fn validate_key_order(argument_count: u8, key_order: &[u8]) -> Result<(), ArrangementError> { + let mut seen = 0u64; + for &position in key_order { + if position >= argument_count { + return Err(ArrangementError::InvalidKeyPosition { + position, + argument_count, + }); + } + let bit = 1u64 << position; + if seen & bit != 0 { + return Err(ArrangementError::DuplicateKeyPosition { position }); + } + seen |= bit; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::space::Space; + use crate::term_identity::TermIdentitySidecar; + use std::collections::BTreeSet; + + fn encoded_roots( + sidecar: &TermIdentitySidecar, + rows: impl IntoIterator, + ) -> BTreeSet> { + rows.into_iter() + .map(|row| sidecar.get_term(row.root).unwrap().encoded().to_vec()) + .collect() + } + + fn encoded_expr(space: &mut Space, expr: &'static str) -> Vec { + let expr = crate::expr!(space, expr); + unsafe { expr.span().as_ref().unwrap() }.to_vec() + } + + #[test] + fn suffix_bound_arrangement_matches_product_query_roots() { + let mut space = Space::new(); + space + .add_all_sexpr( + br#" +(edge Alice Bob) +(edge Carol Bob) +(edge Alice Dave) +(edge Eve Frank) +(note Bob Alice) +"#, + ) + .unwrap(); + + let mut sidecar = TermIdentitySidecar::new(); + sidecar.extend_from_pathmap(&space.btm).unwrap(); + let edge = sidecar + .term_id_for_encoded(&encoded_expr(&mut space, "edge")) + .unwrap(); + let bob = sidecar + .term_id_for_encoded(&encoded_expr(&mut space, "Bob")) + .unwrap(); + let alice = sidecar + .term_id_for_encoded(&encoded_expr(&mut space, "Alice")) + .unwrap(); + + let descriptor = ArrangementDescriptor::new(edge, 2, [1, 0]).unwrap(); + let mut arrangement = ArrangementIndex::build(&sidecar, descriptor).unwrap(); + let bob_rows = arrangement.seek_prefix(&[bob]); + let exact_rows = arrangement.get_exact(&[bob, alice]); + + let product_pattern = crate::expr!(space, "[2] , [3] edge $ Bob"); + let mut product_roots = BTreeSet::new(); + let product_count = Space::query_multi(&space.btm, product_pattern, |_, loc| { + let span = unsafe { loc.span().as_ref().unwrap() }; + product_roots.insert(span.to_vec()); + true + }); + + assert_eq!(product_count, 2); + assert_eq!(encoded_roots(&sidecar, bob_rows), product_roots); + assert_eq!(exact_rows.len(), 1); + + let stats = arrangement.stats(); + assert_eq!(stats.rows, 4); + assert_eq!(stats.prefix_lookups, 1); + assert_eq!(stats.prefix_rows_returned, 2); + } + + #[test] + fn descriptor_rejects_invalid_key_orders() { + assert_eq!( + ArrangementDescriptor::new(TermId(0), 2, [2]), + Err(ArrangementError::InvalidKeyPosition { + position: 2, + argument_count: 2, + }) + ); + assert_eq!( + ArrangementDescriptor::new(TermId(0), 2, [1, 1]), + Err(ArrangementError::DuplicateKeyPosition { position: 1 }) + ); + } +} diff --git a/kernel/src/lib.rs b/kernel/src/lib.rs index e020d925..38ee377e 100644 --- a/kernel/src/lib.rs +++ b/kernel/src/lib.rs @@ -11,6 +11,7 @@ mod pure; pub mod term_identity; pub mod binding_env; pub mod pattern_relations; +pub mod arrangements; #[doc(hidden)] pub use mork_expr as __mork_expr; From e6e5ada3769b1bcd88bab20d034782a352ffab03 Mon Sep 17 00:00:00 2001 From: MesTTo Date: Tue, 23 Jun 2026 18:58:20 +1000 Subject: [PATCH 07/13] Add BindingSpace relation sidecar --- kernel/src/binding_space.rs | 533 ++++++++++++++++++++++++++++++++++++ kernel/src/lib.rs | 1 + 2 files changed, 534 insertions(+) create mode 100644 kernel/src/binding_space.rs diff --git a/kernel/src/binding_space.rs b/kernel/src/binding_space.rs new file mode 100644 index 00000000..9f910bdf --- /dev/null +++ b/kernel/src/binding_space.rs @@ -0,0 +1,533 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use crate::term_identity::TermId; + +/// Query variable identifier used by BindingSpace sidecars. +#[repr(transparent)] +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Ord, PartialOrd)] +pub struct BindingVar(pub u8); + +/// Compact binding row. +pub type BindingRow = Box<[TermId]>; + +/// Signed relation over canonical term bindings. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct BindingRelation { + schema: Box<[BindingVar]>, + weights: BTreeMap, +} + +/// Errors from relation operations. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BindingRelationError { + /// Row width did not match relation schema. + ArityMismatch { expected: usize, actual: usize }, + /// Operation requires equal schemas. + SchemaMismatch, + /// Variable was not present in the schema. + UnknownVariable { variable: BindingVar }, + /// Variable order does not contain exactly the variables in the input. + InvalidVariableOrder, +} + +impl BindingRelation { + /// Creates an empty relation with the given schema. + pub fn new(schema: impl Into>) -> Self { + Self { + schema: schema.into(), + weights: BTreeMap::new(), + } + } + + /// Relation schema. + pub fn schema(&self) -> &[BindingVar] { + &self.schema + } + + /// Number of retained non-zero rows. + pub fn len(&self) -> usize { + self.weights.len() + } + + /// Returns true when no non-zero rows remain. + pub fn is_empty(&self) -> bool { + self.weights.is_empty() + } + + /// Adds a signed row weight, removing the row when the total reaches zero. + pub fn add( + &mut self, + row: impl Into, + weight: i64, + ) -> Result<(), BindingRelationError> { + let row = row.into(); + if row.len() != self.schema.len() { + return Err(BindingRelationError::ArityMismatch { + expected: self.schema.len(), + actual: row.len(), + }); + } + + let entry = self.weights.entry(row).or_default(); + *entry = entry.saturating_add(weight); + if *entry == 0 { + self.weights.retain(|_, value| *value != 0); + } + Ok(()) + } + + /// Returns the signed weight for `row`, or zero if absent. + pub fn weight(&self, row: &[TermId]) -> i64 { + self.weights.get(row).copied().unwrap_or(0) + } + + /// Iterates all retained rows and weights. + pub fn rows(&self) -> impl Iterator + '_ { + self.weights + .iter() + .map(|(row, &weight)| (row.as_ref(), weight)) + } + + /// Iterates rows with positive visible weight. + pub fn positive_rows(&self) -> impl Iterator + '_ { + self.rows() + .filter_map(|(row, weight)| (weight > 0).then_some(row)) + } + + /// Adds every row from `other`. + pub fn union_assign(&mut self, other: &Self) -> Result<(), BindingRelationError> { + if self.schema != other.schema { + return Err(BindingRelationError::SchemaMismatch); + } + for (row, weight) in &other.weights { + self.add(row.clone(), *weight)?; + } + Ok(()) + } + + /// Presence difference: positive rows from `self` that are not visible in + /// `other`. + pub fn difference_presence(&self, other: &Self) -> Result { + if self.schema != other.schema { + return Err(BindingRelationError::SchemaMismatch); + } + + let visible_other = other + .rows() + .filter_map(|(row, weight)| (weight > 0).then_some(row.to_vec())) + .collect::>(); + let mut out = Self::new(self.schema.clone()); + for (row, weight) in self.rows() { + if weight > 0 && !visible_other.contains(row) { + out.add(row.to_vec(), weight)?; + } + } + Ok(out) + } + + fn schema_index(&self, variable: BindingVar) -> Option { + self.schema.iter().position(|&value| value == variable) + } +} + +/// Reference natural join over signed relations. +pub fn natural_join( + left: &BindingRelation, + right: &BindingRelation, +) -> Result { + let common = left + .schema() + .iter() + .copied() + .filter(|variable| right.schema().contains(variable)) + .collect::>(); + let right_new = right + .schema() + .iter() + .copied() + .filter(|variable| !left.schema().contains(variable)) + .collect::>(); + + let mut out_schema = left.schema().to_vec(); + out_schema.extend_from_slice(&right_new); + let mut out = BindingRelation::new(out_schema); + + let left_common = indexes(left, &common)?; + let right_common = indexes(right, &common)?; + let right_new_indexes = indexes(right, &right_new)?; + + let mut right_index: BTreeMap, Vec<(&[TermId], i64)>> = BTreeMap::new(); + for (row, weight) in right.rows() { + let key = project_key(row, &right_common); + right_index.entry(key).or_default().push((row, weight)); + } + + for (left_row, left_weight) in left.rows() { + let key = project_key(left_row, &left_common); + for (right_row, right_weight) in right_index.get(&key).into_iter().flatten() { + let mut output = left_row.to_vec(); + output.extend(right_new_indexes.iter().map(|&index| right_row[index])); + out.add(output, left_weight.saturating_mul(*right_weight))?; + } + } + + Ok(out) +} + +/// Reference variable-at-a-time Generic Join using leapfrog-style domain +/// intersection. This is a semantic oracle, not a production kernel. +pub fn generic_join( + relations: &[BindingRelation], + variable_order: &[BindingVar], +) -> Result { + let all_variables = relations + .iter() + .flat_map(|relation| relation.schema().iter().copied()) + .collect::>(); + let ordered = variable_order.iter().copied().collect::>(); + if all_variables != ordered || all_variables.len() != variable_order.len() { + return Err(BindingRelationError::InvalidVariableOrder); + } + + let mut out = BindingRelation::new(variable_order.to_vec()); + let mut binding = BTreeMap::::new(); + generic_join_recurse(relations, variable_order, 0, &mut binding, &mut out)?; + Ok(out) +} + +fn generic_join_recurse( + relations: &[BindingRelation], + variable_order: &[BindingVar], + level: usize, + binding: &mut BTreeMap, + out: &mut BindingRelation, +) -> Result<(), BindingRelationError> { + if level == variable_order.len() { + let mut weight = 1i64; + for relation in relations { + let row = relation + .schema() + .iter() + .map(|variable| binding[variable]) + .collect::>(); + weight = weight.saturating_mul(relation.weight(&row)); + } + if weight != 0 { + out.add( + variable_order + .iter() + .map(|variable| binding[variable]) + .collect::>(), + weight, + )?; + } + return Ok(()); + } + + let variable = variable_order[level]; + let mut domains = Vec::new(); + for relation in relations { + let Some(variable_index) = relation.schema_index(variable) else { + continue; + }; + let mut domain = relation + .rows() + .filter(|(_, weight)| *weight > 0) + .filter(|(row, _)| { + relation + .schema() + .iter() + .enumerate() + .all(|(index, relation_var)| { + binding + .get(relation_var) + .is_none_or(|bound| row[index] == *bound) + }) + }) + .map(|(row, _)| row[variable_index]) + .collect::>() + .into_iter() + .collect::>(); + domain.sort_unstable(); + domains.push(domain); + } + + for value in leapfrog_intersection(&domains) { + binding.insert(variable, value); + generic_join_recurse(relations, variable_order, level + 1, binding, out)?; + } + binding.remove(&variable); + Ok(()) +} + +/// Factorized binary equijoin grouped by shared variables. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FactorizedJoin { + /// Shared variables. + pub shared_schema: Box<[BindingVar]>, + /// Variables only on the left input. + pub left_only_schema: Box<[BindingVar]>, + /// Variables only on the right input. + pub right_only_schema: Box<[BindingVar]>, + /// Groups keyed by shared assignment. + pub groups: Box<[FactorGroup]>, +} + +/// One factorized group. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FactorGroup { + /// Shared assignment. + pub key: BindingRow, + /// Distinct left residual assignments. + pub left_residuals: Box<[BindingRow]>, + /// Distinct right residual assignments. + pub right_residuals: Box<[BindingRow]>, +} + +impl FactorGroup { + /// Number of flat rows represented by this group. + pub fn row_count(&self) -> usize { + self.left_residuals.len() * self.right_residuals.len() + } +} + +impl FactorizedJoin { + /// Builds a factorized set join from positive rows in both relations. + pub fn from_relations( + left: &BindingRelation, + right: &BindingRelation, + ) -> Result { + let shared_schema = left + .schema() + .iter() + .copied() + .filter(|variable| right.schema().contains(variable)) + .collect::>(); + let left_only_schema = left + .schema() + .iter() + .copied() + .filter(|variable| !shared_schema.contains(variable)) + .collect::>(); + let right_only_schema = right + .schema() + .iter() + .copied() + .filter(|variable| !shared_schema.contains(variable)) + .collect::>(); + + let left_shared = indexes(left, &shared_schema)?; + let right_shared = indexes(right, &shared_schema)?; + let left_only = indexes(left, &left_only_schema)?; + let right_only = indexes(right, &right_only_schema)?; + + let mut left_groups: BTreeMap, BTreeSet>> = BTreeMap::new(); + let mut right_groups: BTreeMap, BTreeSet>> = BTreeMap::new(); + + for row in left.positive_rows() { + left_groups + .entry(project_key(row, &left_shared)) + .or_default() + .insert(project_key(row, &left_only)); + } + for row in right.positive_rows() { + right_groups + .entry(project_key(row, &right_shared)) + .or_default() + .insert(project_key(row, &right_only)); + } + + let groups = left_groups + .keys() + .filter(|key| right_groups.contains_key(*key)) + .map(|key| FactorGroup { + key: key.clone().into_boxed_slice(), + left_residuals: rows_from_set(&left_groups[key]), + right_residuals: rows_from_set(&right_groups[key]), + }) + .collect::>() + .into_boxed_slice(); + + Ok(Self { + shared_schema: shared_schema.into_boxed_slice(), + left_only_schema: left_only_schema.into_boxed_slice(), + right_only_schema: right_only_schema.into_boxed_slice(), + groups, + }) + } + + /// Number of flat rows represented by the factorized join. + pub fn count(&self) -> usize { + self.groups.iter().map(FactorGroup::row_count).sum() + } + + /// Rough structural node count for comparing factorization to flat rows. + pub fn factorized_node_count(&self) -> usize { + 1 + self + .groups + .iter() + .map(|group| 1 + group.left_residuals.len() + group.right_residuals.len()) + .sum::() + } + + /// Enumerates flat rows in schema order: shared + left-only + right-only. + pub fn rows(&self) -> impl Iterator> + '_ { + self.groups.iter().flat_map(|group| { + group.left_residuals.iter().flat_map(move |left| { + group.right_residuals.iter().map(move |right| { + let mut row = group.key.to_vec(); + row.extend_from_slice(left); + row.extend_from_slice(right); + row + }) + }) + }) + } +} + +fn indexes( + relation: &BindingRelation, + variables: &[BindingVar], +) -> Result, BindingRelationError> { + variables + .iter() + .map(|&variable| { + relation + .schema_index(variable) + .ok_or(BindingRelationError::UnknownVariable { variable }) + }) + .collect() +} + +fn project_key(row: &[TermId], indexes: &[usize]) -> Vec { + indexes.iter().map(|&index| row[index]).collect() +} + +fn rows_from_set(rows: &BTreeSet>) -> Box<[BindingRow]> { + rows.iter() + .cloned() + .map(Vec::into_boxed_slice) + .collect::>() + .into_boxed_slice() +} + +fn leapfrog_intersection(domains: &[Vec]) -> Vec { + if domains.is_empty() || domains.iter().any(Vec::is_empty) { + return Vec::new(); + } + + let mut positions = vec![0usize; domains.len()]; + let mut target = domains + .iter() + .map(|domain| domain[0]) + .max() + .expect("domains are non-empty"); + let mut out = Vec::new(); + + loop { + let mut changed = false; + for (index, domain) in domains.iter().enumerate() { + while positions[index] < domain.len() && domain[positions[index]] < target { + positions[index] += 1; + } + if positions[index] >= domain.len() { + return out; + } + if domain[positions[index]] > target { + target = domain[positions[index]]; + changed = true; + } + } + if !changed { + out.push(target); + positions[0] += 1; + if positions[0] >= domains[0].len() { + return out; + } + target = domains[0][positions[0]]; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn t(id: u64) -> TermId { + TermId(id) + } + + fn v(id: u8) -> BindingVar { + BindingVar(id) + } + + fn relation(schema: &[u8], rows: &[&[u64]]) -> BindingRelation { + let mut relation = + BindingRelation::new(schema.iter().copied().map(BindingVar).collect::>()); + for row in rows { + relation + .add(row.iter().copied().map(TermId).collect::>(), 1) + .unwrap(); + } + relation + } + + #[test] + fn natural_and_generic_join_agree_on_variable_order() { + let left = relation(&[0, 1], &[&[1, 10], &[2, 10], &[3, 20]]); + let right = relation(&[1, 2], &[&[10, 100], &[10, 101], &[30, 300]]); + + let natural = natural_join(&left, &right).unwrap(); + let generic = generic_join(&[left, right], &[v(1), v(0), v(2)]).unwrap(); + let normalized_natural = natural + .positive_rows() + .map(|row| vec![row[1], row[0], row[2]]) + .collect::>(); + let generic_rows = generic + .positive_rows() + .map(Vec::from) + .collect::>(); + + assert_eq!(generic_rows, normalized_natural); + assert_eq!(generic_rows.len(), 4); + } + + #[test] + fn factorized_join_counts_without_flattening() { + let left = relation(&[0, 1], &[&[1, 10], &[2, 10]]); + let right = relation(&[1, 2], &[&[10, 100], &[10, 101], &[10, 102]]); + + let factorized = FactorizedJoin::from_relations(&left, &right).unwrap(); + + assert_eq!(factorized.count(), 6); + assert_eq!(factorized.rows().count(), 6); + assert!(factorized.factorized_node_count() < factorized.count() + 6); + } + + #[test] + fn signed_rows_cancel_and_participate_in_joins() { + let mut left = BindingRelation::new([v(0)]); + left.add(vec![t(1)], 2).unwrap(); + left.add(vec![t(1)], -2).unwrap(); + assert!(left.is_empty()); + + left.add(vec![t(1)], -1).unwrap(); + let right = relation(&[0], &[&[1]]); + let joined = natural_join(&left, &right).unwrap(); + + assert_eq!(joined.weight(&[t(1)]), -1); + } + + #[test] + fn presence_difference_uses_positive_visibility() { + let left = relation(&[0], &[&[1], &[2]]); + let mut right = BindingRelation::new([v(0)]); + right.add(vec![t(1)], -1).unwrap(); + right.add(vec![t(2)], 1).unwrap(); + + let diff = left.difference_presence(&right).unwrap(); + + assert_eq!(diff.weight(&[t(1)]), 1); + assert_eq!(diff.weight(&[t(2)]), 0); + } +} diff --git a/kernel/src/lib.rs b/kernel/src/lib.rs index 38ee377e..f8558afe 100644 --- a/kernel/src/lib.rs +++ b/kernel/src/lib.rs @@ -12,6 +12,7 @@ pub mod term_identity; pub mod binding_env; pub mod pattern_relations; pub mod arrangements; +pub mod binding_space; #[doc(hidden)] pub use mork_expr as __mork_expr; From 7f1b9e7f62ea1d7ebb36bb7653ff3cf34bb47778 Mon Sep 17 00:00:00 2001 From: MesTTo Date: Tue, 23 Jun 2026 19:00:56 +1000 Subject: [PATCH 08/13] Project arrangements into BindingSpace --- kernel/src/arrangements.rs | 157 +++++++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) diff --git a/kernel/src/arrangements.rs b/kernel/src/arrangements.rs index 194d4fd3..7420483d 100644 --- a/kernel/src/arrangements.rs +++ b/kernel/src/arrangements.rs @@ -1,5 +1,6 @@ use std::collections::BTreeMap; +use crate::binding_space::{BindingRelation, BindingRelationError, BindingVar}; use crate::term_identity::{FactId, TermId, TermIdentitySidecar, TermKind}; /// Physical argument-order sidecar for relation-like facts. @@ -40,6 +41,47 @@ pub struct ArrangementRow { pub root: TermId, } +/// Projection from arranged facts into a BindingSpace relation schema. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ArrangementProjection { + /// Output BindingSpace schema. + pub schema: Box<[BindingVar]>, + /// Argument positions for each schema variable, zero-based after the + /// relation/functor child. + pub argument_positions: Box<[u8]>, +} + +impl ArrangementProjection { + /// Creates a projection after validating arity and argument positions. + pub fn new( + argument_count: u8, + schema: impl Into>, + argument_positions: impl Into>, + ) -> Result { + let schema = schema.into(); + let argument_positions = argument_positions.into(); + if schema.len() != argument_positions.len() { + return Err(ArrangementError::ProjectionArityMismatch { + schema_len: schema.len(), + positions_len: argument_positions.len(), + }); + } + for &position in argument_positions.iter() { + if position >= argument_count { + return Err(ArrangementError::InvalidKeyPosition { + position, + argument_count, + }); + } + } + + Ok(Self { + schema, + argument_positions, + }) + } +} + /// Snapshot-local arrangement index. #[derive(Clone, Debug, Eq, PartialEq)] pub struct ArrangementIndex { @@ -76,6 +118,11 @@ pub enum ArrangementError { UnknownTerm { term: TermId }, /// Encoded arity would overflow when adding the relation/functor child. ArityOverflow { argument_count: u8 }, + /// Projection schema and argument-position list have different lengths. + ProjectionArityMismatch { + schema_len: usize, + positions_len: usize, + }, } impl ArrangementIndex { @@ -168,6 +215,69 @@ impl ArrangementIndex { pub fn get_exact(&self, key: &[TermId]) -> &[ArrangementRow] { self.rows_by_key.get(key).map(Vec::as_slice).unwrap_or(&[]) } + + /// Projects arranged facts into a BindingSpace relation. + pub fn project_bindings( + &self, + sidecar: &TermIdentitySidecar, + projection: &ArrangementProjection, + ) -> Result { + let mut relation = BindingRelation::new(projection.schema.clone()); + + for row in self.rows_by_key.values().flatten() { + let arguments = self.root_arguments(sidecar, row.root)?; + let binding_row = projection + .argument_positions + .iter() + .map(|&position| arguments[usize::from(position)]) + .collect::>(); + relation.add(binding_row, 1).map_err(|error| match error { + BindingRelationError::ArityMismatch { expected, actual } => { + ArrangementError::ProjectionArityMismatch { + schema_len: expected, + positions_len: actual, + } + } + BindingRelationError::SchemaMismatch + | BindingRelationError::UnknownVariable { .. } + | BindingRelationError::InvalidVariableOrder => { + ArrangementError::ProjectionArityMismatch { + schema_len: projection.schema.len(), + positions_len: projection.argument_positions.len(), + } + } + })?; + } + + Ok(relation) + } + + fn root_arguments<'a>( + &self, + sidecar: &'a TermIdentitySidecar, + root: TermId, + ) -> Result<&'a [TermId], ArrangementError> { + let Some(record) = sidecar.get_term(root) else { + return Err(ArrangementError::UnknownTerm { term: root }); + }; + let encoded_arity = self.descriptor.argument_count.checked_add(1).ok_or( + ArrangementError::ArityOverflow { + argument_count: self.descriptor.argument_count, + }, + )?; + if record.kind + != (TermKind::Application { + arity: encoded_arity, + }) + { + return Err(ArrangementError::UnknownTerm { term: root }); + } + let children = record.children(); + if children.first().copied() != Some(self.descriptor.relation) { + return Err(ArrangementError::UnknownTerm { term: root }); + } + Ok(&children[1..]) + } } fn validate_key_order(argument_count: u8, key_order: &[u8]) -> Result<(), ArrangementError> { @@ -191,6 +301,7 @@ fn validate_key_order(argument_count: u8, key_order: &[u8]) -> Result<(), Arrang #[cfg(test)] mod tests { use super::*; + use crate::binding_space::{generic_join, BindingVar}; use crate::space::Space; use crate::term_identity::TermIdentitySidecar; use std::collections::BTreeSet; @@ -273,4 +384,50 @@ mod tests { Err(ArrangementError::DuplicateKeyPosition { position: 1 }) ); } + + #[test] + fn arrangement_projection_feeds_generic_join_for_transitive_edges() { + let mut space = Space::new(); + space + .add_all_sexpr( + br#" +(edge Alice Bob) +(edge Bob Carol) +(edge Alice Dana) +(edge Dana Carol) +(edge Carol Erin) +(edge X Y) +"#, + ) + .unwrap(); + + let mut sidecar = TermIdentitySidecar::new(); + sidecar.extend_from_pathmap(&space.btm).unwrap(); + let edge = sidecar + .term_id_for_encoded(&encoded_expr(&mut space, "edge")) + .unwrap(); + + let descriptor = ArrangementDescriptor::new(edge, 2, [0, 1]).unwrap(); + let arrangement = ArrangementIndex::build(&sidecar, descriptor).unwrap(); + let xy = arrangement + .project_bindings( + &sidecar, + &ArrangementProjection::new(2, [BindingVar(0), BindingVar(1)], [0, 1]).unwrap(), + ) + .unwrap(); + let yz = arrangement + .project_bindings( + &sidecar, + &ArrangementProjection::new(2, [BindingVar(1), BindingVar(2)], [0, 1]).unwrap(), + ) + .unwrap(); + let joined = + generic_join(&[xy, yz], &[BindingVar(1), BindingVar(0), BindingVar(2)]).unwrap(); + + let product_pattern = crate::expr!(space, "[3] , [3] edge $ $ [3] edge _2 $"); + let product_count = Space::query_multi(&space.btm, product_pattern, |_, _| true); + + assert_eq!(product_count, 4); + assert_eq!(joined.positive_rows().count(), product_count); + } } From 2d5c49702ab32c1effc13f917faad520a1239c43 Mon Sep 17 00:00:00 2001 From: MesTTo Date: Tue, 23 Jun 2026 19:07:36 +1000 Subject: [PATCH 09/13] Add BindingSpace sidecar plan wrapper --- kernel/src/arrangements.rs | 25 +---- kernel/src/binding_plan.rs | 167 +++++++++++++++++++++++++++++ kernel/src/lib.rs | 4 + kernel/src/test_sidecar_queries.rs | 36 +++++++ 4 files changed, 210 insertions(+), 22 deletions(-) create mode 100644 kernel/src/binding_plan.rs create mode 100644 kernel/src/test_sidecar_queries.rs diff --git a/kernel/src/arrangements.rs b/kernel/src/arrangements.rs index 7420483d..297c43ca 100644 --- a/kernel/src/arrangements.rs +++ b/kernel/src/arrangements.rs @@ -304,6 +304,7 @@ mod tests { use crate::binding_space::{generic_join, BindingVar}; use crate::space::Space; use crate::term_identity::TermIdentitySidecar; + use crate::test_sidecar_queries::{transitive_edge_product_count, transitive_edge_sidecar}; use std::collections::BTreeSet; fn encoded_roots( @@ -387,26 +388,7 @@ mod tests { #[test] fn arrangement_projection_feeds_generic_join_for_transitive_edges() { - let mut space = Space::new(); - space - .add_all_sexpr( - br#" -(edge Alice Bob) -(edge Bob Carol) -(edge Alice Dana) -(edge Dana Carol) -(edge Carol Erin) -(edge X Y) -"#, - ) - .unwrap(); - - let mut sidecar = TermIdentitySidecar::new(); - sidecar.extend_from_pathmap(&space.btm).unwrap(); - let edge = sidecar - .term_id_for_encoded(&encoded_expr(&mut space, "edge")) - .unwrap(); - + let (mut space, sidecar, edge) = transitive_edge_sidecar(); let descriptor = ArrangementDescriptor::new(edge, 2, [0, 1]).unwrap(); let arrangement = ArrangementIndex::build(&sidecar, descriptor).unwrap(); let xy = arrangement @@ -424,8 +406,7 @@ mod tests { let joined = generic_join(&[xy, yz], &[BindingVar(1), BindingVar(0), BindingVar(2)]).unwrap(); - let product_pattern = crate::expr!(space, "[3] , [3] edge $ $ [3] edge _2 $"); - let product_count = Space::query_multi(&space.btm, product_pattern, |_, _| true); + let product_count = transitive_edge_product_count(&mut space); assert_eq!(product_count, 4); assert_eq!(joined.positive_rows().count(), product_count); diff --git a/kernel/src/binding_plan.rs b/kernel/src/binding_plan.rs new file mode 100644 index 00000000..df52d03b --- /dev/null +++ b/kernel/src/binding_plan.rs @@ -0,0 +1,167 @@ +use crate::arrangements::{ + ArrangementDescriptor, ArrangementError, ArrangementIndex, ArrangementProjection, +}; +use crate::binding_space::{generic_join, BindingRelation, BindingRelationError, BindingVar}; +use crate::term_identity::TermIdentitySidecar; + +/// Physical BindingSpace access selected by a compiled sidecar plan. +/// +/// This describes derived access over the term snapshot. It does not change +/// the canonical PathMap/ACT pathspace semantics. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum BindingAccessPlan { + /// Build or use an argument-order arrangement, then project it into a + /// BindingSpace relation. + Arrangement { + descriptor: ArrangementDescriptor, + projection: ArrangementProjection, + }, +} + +/// Sidecar join plan over derived BindingSpace relations. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BindingSidecarPlan { + factors: Box<[BindingAccessPlan]>, + variable_order: Box<[BindingVar]>, +} + +/// Result of executing a sidecar join plan. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BindingSidecarResult { + /// Flat relation returned by the current reference Generic Join oracle. + pub relation: BindingRelation, + /// Execution counters for checking that the sidecar plan shape is visible. + pub stats: BindingSidecarStats, +} + +/// Coarse execution counters for sidecar plans. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct BindingSidecarStats { + /// Number of physical factor accesses opened by the plan. + pub factors: usize, + /// Complete fact roots inspected while constructing arrangements. + pub facts_scanned: usize, + /// Rows retained by constructed arrangements. + pub arrangement_rows: usize, + /// Positive BindingSpace rows produced by factor projections. + pub projected_rows: usize, + /// Positive rows emitted by the final join. + pub output_rows: usize, +} + +/// Error from sidecar plan execution. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BindingSidecarPlanError { + /// A physical arrangement could not be built or projected. + Arrangement(ArrangementError), + /// A BindingSpace relation operation failed. + Binding(BindingRelationError), +} + +impl BindingSidecarPlan { + /// Creates a sidecar plan from factor accesses and a variable order. + pub fn new( + factors: impl Into>, + variable_order: impl Into>, + ) -> Self { + Self { + factors: factors.into(), + variable_order: variable_order.into(), + } + } + + /// Planned factor accesses. + pub fn factors(&self) -> &[BindingAccessPlan] { + &self.factors + } + + /// Variable order consumed by Generic Join / future LFTJ kernels. + pub fn variable_order(&self) -> &[BindingVar] { + &self.variable_order + } + + /// Executes the plan against one term snapshot. + pub fn execute( + &self, + sidecar: &TermIdentitySidecar, + ) -> Result { + let mut stats = BindingSidecarStats::default(); + let mut relations = Vec::with_capacity(self.factors.len()); + + for factor in self.factors.iter() { + stats.factors += 1; + let relation = factor.open(sidecar, &mut stats)?; + stats.projected_rows += relation.positive_rows().count(); + relations.push(relation); + } + + let relation = generic_join(&relations, &self.variable_order) + .map_err(BindingSidecarPlanError::Binding)?; + stats.output_rows = relation.positive_rows().count(); + + Ok(BindingSidecarResult { relation, stats }) + } +} + +impl BindingAccessPlan { + fn open( + &self, + sidecar: &TermIdentitySidecar, + stats: &mut BindingSidecarStats, + ) -> Result { + match self { + BindingAccessPlan::Arrangement { + descriptor, + projection, + } => { + let arrangement = ArrangementIndex::build(sidecar, descriptor.clone()) + .map_err(BindingSidecarPlanError::Arrangement)?; + let arrangement_stats = arrangement.stats(); + stats.facts_scanned += arrangement_stats.facts_scanned; + stats.arrangement_rows += arrangement_stats.rows; + arrangement + .project_bindings(sidecar, projection) + .map_err(BindingSidecarPlanError::Arrangement) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_sidecar_queries::{transitive_edge_product_count, transitive_edge_sidecar}; + + #[test] + fn arrangement_sidecar_plan_matches_transitive_product_query() { + let (mut space, sidecar, edge) = transitive_edge_sidecar(); + let descriptor = ArrangementDescriptor::new(edge, 2, [0, 1]).unwrap(); + let xy = BindingAccessPlan::Arrangement { + descriptor: descriptor.clone(), + projection: ArrangementProjection::new(2, [BindingVar(0), BindingVar(1)], [0, 1]) + .unwrap(), + }; + let yz = BindingAccessPlan::Arrangement { + descriptor, + projection: ArrangementProjection::new(2, [BindingVar(1), BindingVar(2)], [0, 1]) + .unwrap(), + }; + let plan = BindingSidecarPlan::new([xy, yz], [BindingVar(1), BindingVar(0), BindingVar(2)]); + + let result = plan.execute(&sidecar).unwrap(); + let product_count = transitive_edge_product_count(&mut space); + + assert_eq!(product_count, 4); + assert_eq!(result.relation.positive_rows().count(), product_count); + assert_eq!( + result.stats, + BindingSidecarStats { + factors: 2, + facts_scanned: 12, + arrangement_rows: 12, + projected_rows: 12, + output_rows: 4, + } + ); + } +} diff --git a/kernel/src/lib.rs b/kernel/src/lib.rs index f8558afe..883f55fa 100644 --- a/kernel/src/lib.rs +++ b/kernel/src/lib.rs @@ -13,6 +13,10 @@ pub mod binding_env; pub mod pattern_relations; pub mod arrangements; pub mod binding_space; +pub mod binding_plan; + +#[cfg(test)] +mod test_sidecar_queries; #[doc(hidden)] pub use mork_expr as __mork_expr; diff --git a/kernel/src/test_sidecar_queries.rs b/kernel/src/test_sidecar_queries.rs new file mode 100644 index 00000000..1f85baa9 --- /dev/null +++ b/kernel/src/test_sidecar_queries.rs @@ -0,0 +1,36 @@ +use crate::space::Space; +use crate::term_identity::{TermId, TermIdentitySidecar}; + +fn encoded_expr(space: &mut Space, expr: &'static str) -> Vec { + let expr = crate::expr!(space, expr); + unsafe { expr.span().as_ref().unwrap() }.to_vec() +} + +pub(crate) fn transitive_edge_sidecar() -> (Space, TermIdentitySidecar, TermId) { + let mut space = Space::new(); + space + .add_all_sexpr( + br#" +(edge Alice Bob) +(edge Bob Carol) +(edge Alice Dana) +(edge Dana Carol) +(edge Carol Erin) +(edge X Y) +"#, + ) + .unwrap(); + + let mut sidecar = TermIdentitySidecar::new(); + sidecar.extend_from_pathmap(&space.btm).unwrap(); + let edge = sidecar + .term_id_for_encoded(&encoded_expr(&mut space, "edge")) + .unwrap(); + + (space, sidecar, edge) +} + +pub(crate) fn transitive_edge_product_count(space: &mut Space) -> usize { + let product_pattern = crate::expr!(space, "[3] , [3] edge $ $ [3] edge _2 $"); + Space::query_multi(&space.btm, product_pattern, |_, _| true) +} From 58c4b4ff5758d69300bb9c31f3771ced2bf5af38 Mon Sep 17 00:00:00 2001 From: MesTTo Date: Tue, 23 Jun 2026 19:12:19 +1000 Subject: [PATCH 10/13] Add BindingSpace join readiness analysis --- kernel/src/binding_plan.rs | 243 +++++++++++++++++++++++++++++++++++-- 1 file changed, 233 insertions(+), 10 deletions(-) diff --git a/kernel/src/binding_plan.rs b/kernel/src/binding_plan.rs index df52d03b..27cc16e2 100644 --- a/kernel/src/binding_plan.rs +++ b/kernel/src/binding_plan.rs @@ -1,3 +1,5 @@ +use std::collections::{BTreeMap, BTreeSet}; + use crate::arrangements::{ ArrangementDescriptor, ArrangementError, ArrangementIndex, ArrangementProjection, }; @@ -34,6 +36,33 @@ pub struct BindingSidecarResult { pub stats: BindingSidecarStats, } +/// Analysis of a sidecar join plan before choosing a production join kernel. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BindingSidecarAnalysis { + /// Counters collected while opening the planned sidecar factors. + pub stats: BindingSidecarStats, + /// Root-level variable domains visible before any binding is chosen. + pub variables: Box<[BindingVariableDomainStats]>, + /// Heuristic variable order suitable for Generic Join and trie cursors. + pub suggested_variable_order: Box<[BindingVar]>, +} + +/// Root-level domain statistics for one BindingSpace variable. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct BindingVariableDomainStats { + /// Query variable. + pub variable: BindingVar, + /// Number of factors containing this variable. + pub factor_count: usize, + /// Size of the intersection of positive domains from all containing + /// factors, before any earlier variable is bound. + pub root_domain_len: usize, + /// Smallest positive domain contributed by one containing factor. + pub min_factor_domain_len: usize, + /// Largest positive domain contributed by one containing factor. + pub max_factor_domain_len: usize, +} + /// Coarse execution counters for sidecar plans. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub struct BindingSidecarStats { @@ -75,7 +104,7 @@ impl BindingSidecarPlan { &self.factors } - /// Variable order consumed by Generic Join / future LFTJ kernels. + /// Variable order consumed by Generic Join and trie-backed kernels. pub fn variable_order(&self) -> &[BindingVar] { &self.variable_order } @@ -85,6 +114,29 @@ impl BindingSidecarPlan { &self, sidecar: &TermIdentitySidecar, ) -> Result { + let (relations, mut stats) = self.open_relations(sidecar)?; + + let relation = generic_join(&relations, &self.variable_order) + .map_err(BindingSidecarPlanError::Binding)?; + stats.output_rows = relation.positive_rows().count(); + + Ok(BindingSidecarResult { relation, stats }) + } + + /// Opens all sidecar factors and reports root-domain statistics. This is a + /// planning aid; it does not mutate the canonical PathMap/ACT storage. + pub fn analyze( + &self, + sidecar: &TermIdentitySidecar, + ) -> Result { + let (relations, stats) = self.open_relations(sidecar)?; + Ok(analyze_relations(&relations, stats)) + } + + fn open_relations( + &self, + sidecar: &TermIdentitySidecar, + ) -> Result<(Vec, BindingSidecarStats), BindingSidecarPlanError> { let mut stats = BindingSidecarStats::default(); let mut relations = Vec::with_capacity(self.factors.len()); @@ -95,11 +147,7 @@ impl BindingSidecarPlan { relations.push(relation); } - let relation = generic_join(&relations, &self.variable_order) - .map_err(BindingSidecarPlanError::Binding)?; - stats.output_rows = relation.positive_rows().count(); - - Ok(BindingSidecarResult { relation, stats }) + Ok((relations, stats)) } } @@ -127,14 +175,143 @@ impl BindingAccessPlan { } } +fn analyze_relations( + relations: &[BindingRelation], + stats: BindingSidecarStats, +) -> BindingSidecarAnalysis { + let variable_stats = variable_domain_stats(relations); + let suggested_variable_order = suggest_variable_order(relations, &variable_stats); + + BindingSidecarAnalysis { + stats, + variables: variable_stats.into_boxed_slice(), + suggested_variable_order: suggested_variable_order.into_boxed_slice(), + } +} + +fn variable_domain_stats(relations: &[BindingRelation]) -> Vec { + let mut domains_by_variable = BTreeMap::>>::new(); + + for relation in relations { + for (index, &variable) in relation.schema().iter().enumerate() { + let domain = relation + .positive_rows() + .map(|row| row[index]) + .collect::>(); + domains_by_variable + .entry(variable) + .or_default() + .push(domain); + } + } + + domains_by_variable + .into_iter() + .map(|(variable, domains)| { + let root_domain_len = intersect_domain_len(&domains); + let min_factor_domain_len = domains.iter().map(BTreeSet::len).min().unwrap_or(0); + let max_factor_domain_len = domains.iter().map(BTreeSet::len).max().unwrap_or(0); + + BindingVariableDomainStats { + variable, + factor_count: domains.len(), + root_domain_len, + min_factor_domain_len, + max_factor_domain_len, + } + }) + .collect() +} + +fn intersect_domain_len(domains: &[BTreeSet]) -> usize { + let Some((first, rest)) = domains.split_first() else { + return 0; + }; + + let mut intersection = first.clone(); + for domain in rest { + intersection = intersection + .intersection(domain) + .copied() + .collect::>(); + } + intersection.len() +} + +fn suggest_variable_order( + relations: &[BindingRelation], + stats: &[BindingVariableDomainStats], +) -> Vec { + let stats_by_variable = stats + .iter() + .map(|stats| (stats.variable, *stats)) + .collect::>(); + let factor_schemas = relations + .iter() + .map(|relation| relation.schema().iter().copied().collect::>()) + .collect::>(); + let mut remaining = stats.iter().map(|stats| stats.variable).collect::>(); + let mut order = Vec::with_capacity(remaining.len()); + + while !remaining.is_empty() { + let has_connected = !order.is_empty() + && remaining + .iter() + .any(|&variable| variable_connects_to_bound(variable, &order, &factor_schemas)); + let has_non_lonely = remaining + .iter() + .any(|variable| stats_by_variable[variable].factor_count > 1); + + remaining.sort_by(|&left, &right| { + let left_connected = variable_connects_to_bound(left, &order, &factor_schemas); + let right_connected = variable_connects_to_bound(right, &order, &factor_schemas); + if has_connected && left_connected != right_connected { + return right_connected.cmp(&left_connected); + } + + let left_stats = stats_by_variable[&left]; + let right_stats = stats_by_variable[&right]; + let left_lonely = left_stats.factor_count == 1; + let right_lonely = right_stats.factor_count == 1; + if has_non_lonely && left_lonely != right_lonely { + return left_lonely.cmp(&right_lonely); + } + + left_stats + .root_domain_len + .cmp(&right_stats.root_domain_len) + .then_with(|| right_stats.factor_count.cmp(&left_stats.factor_count)) + .then_with(|| left.cmp(&right)) + }); + + order.push(remaining.remove(0)); + } + + order +} + +fn variable_connects_to_bound( + variable: BindingVar, + bound: &[BindingVar], + factor_schemas: &[BTreeSet], +) -> bool { + factor_schemas.iter().any(|schema| { + schema.contains(&variable) + && bound + .iter() + .any(|bound_variable| schema.contains(bound_variable)) + }) +} + #[cfg(test)] mod tests { use super::*; use crate::test_sidecar_queries::{transitive_edge_product_count, transitive_edge_sidecar}; - #[test] - fn arrangement_sidecar_plan_matches_transitive_product_query() { - let (mut space, sidecar, edge) = transitive_edge_sidecar(); + fn transitive_edge_plan( + edge: crate::term_identity::TermId, + variable_order: impl Into>, + ) -> BindingSidecarPlan { let descriptor = ArrangementDescriptor::new(edge, 2, [0, 1]).unwrap(); let xy = BindingAccessPlan::Arrangement { descriptor: descriptor.clone(), @@ -146,7 +323,14 @@ mod tests { projection: ArrangementProjection::new(2, [BindingVar(1), BindingVar(2)], [0, 1]) .unwrap(), }; - let plan = BindingSidecarPlan::new([xy, yz], [BindingVar(1), BindingVar(0), BindingVar(2)]); + + BindingSidecarPlan::new([xy, yz], variable_order) + } + + #[test] + fn arrangement_sidecar_plan_matches_transitive_product_query() { + let (mut space, sidecar, edge) = transitive_edge_sidecar(); + let plan = transitive_edge_plan(edge, [BindingVar(1), BindingVar(0), BindingVar(2)]); let result = plan.execute(&sidecar).unwrap(); let product_count = transitive_edge_product_count(&mut space); @@ -164,4 +348,43 @@ mod tests { } ); } + + #[test] + fn analysis_suggests_selective_connected_variable_order() { + let (_, sidecar, edge) = transitive_edge_sidecar(); + let plan = transitive_edge_plan(edge, [BindingVar(0), BindingVar(1), BindingVar(2)]); + + let analysis = plan.analyze(&sidecar).unwrap(); + + assert_eq!( + analysis.suggested_variable_order.as_ref(), + [BindingVar(1), BindingVar(0), BindingVar(2)] + ); + assert_eq!( + analysis.variables.as_ref(), + [ + BindingVariableDomainStats { + variable: BindingVar(0), + factor_count: 1, + root_domain_len: 5, + min_factor_domain_len: 5, + max_factor_domain_len: 5, + }, + BindingVariableDomainStats { + variable: BindingVar(1), + factor_count: 2, + root_domain_len: 3, + min_factor_domain_len: 5, + max_factor_domain_len: 5, + }, + BindingVariableDomainStats { + variable: BindingVar(2), + factor_count: 1, + root_domain_len: 5, + min_factor_domain_len: 5, + max_factor_domain_len: 5, + }, + ] + ); + } } From e68587c2583f38e8fb1c3c26f3f1e7ca12bf5a27 Mon Sep 17 00:00:00 2001 From: MesTTo Date: Tue, 23 Jun 2026 19:16:29 +1000 Subject: [PATCH 11/13] Add BindingSpace trie join sidecar --- kernel/src/binding_plan.rs | 53 +++++- kernel/src/binding_space.rs | 320 +++++++++++++++++++++++++++++++++++- 2 files changed, 363 insertions(+), 10 deletions(-) diff --git a/kernel/src/binding_plan.rs b/kernel/src/binding_plan.rs index 27cc16e2..84bf7003 100644 --- a/kernel/src/binding_plan.rs +++ b/kernel/src/binding_plan.rs @@ -3,7 +3,9 @@ use std::collections::{BTreeMap, BTreeSet}; use crate::arrangements::{ ArrangementDescriptor, ArrangementError, ArrangementIndex, ArrangementProjection, }; -use crate::binding_space::{generic_join, BindingRelation, BindingRelationError, BindingVar}; +use crate::binding_space::{ + generic_join, trie_join, BindingRelation, BindingRelationError, BindingVar, TrieJoinStats, +}; use crate::term_identity::TermIdentitySidecar; /// Physical BindingSpace access selected by a compiled sidecar plan. @@ -36,6 +38,17 @@ pub struct BindingSidecarResult { pub stats: BindingSidecarStats, } +/// Result of executing a sidecar plan with the trie-backed join kernel. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BindingSidecarTrieJoinResult { + /// Flat relation returned by the trie-backed variable-at-a-time join. + pub relation: BindingRelation, + /// Execution counters for physical factor opening. + pub stats: BindingSidecarStats, + /// Execution counters for the trie-backed join itself. + pub trie_stats: TrieJoinStats, +} + /// Analysis of a sidecar join plan before choosing a production join kernel. #[derive(Clone, Debug, Eq, PartialEq)] pub struct BindingSidecarAnalysis { @@ -123,6 +136,27 @@ impl BindingSidecarPlan { Ok(BindingSidecarResult { relation, stats }) } + /// Executes the plan using the trie-backed variable-at-a-time join kernel. + /// + /// The opened factors are still derived sidecars over the term snapshot; + /// this does not change the canonical PathMap/ACT pathspace semantics. + pub fn execute_trie_join( + &self, + sidecar: &TermIdentitySidecar, + ) -> Result { + let (relations, mut stats) = self.open_relations(sidecar)?; + + let joined = trie_join(&relations, &self.variable_order) + .map_err(BindingSidecarPlanError::Binding)?; + stats.output_rows = joined.relation.positive_rows().count(); + + Ok(BindingSidecarTrieJoinResult { + relation: joined.relation, + stats, + trie_stats: joined.stats, + }) + } + /// Opens all sidecar factors and reports root-domain statistics. This is a /// planning aid; it does not mutate the canonical PathMap/ACT storage. pub fn analyze( @@ -349,6 +383,23 @@ mod tests { ); } + #[test] + fn trie_sidecar_plan_matches_generic_and_product_query() { + let (mut space, sidecar, edge) = transitive_edge_sidecar(); + let plan = transitive_edge_plan(edge, [BindingVar(1), BindingVar(0), BindingVar(2)]); + + let generic = plan.execute(&sidecar).unwrap(); + let trie = plan.execute_trie_join(&sidecar).unwrap(); + let product_count = transitive_edge_product_count(&mut space); + + assert_eq!(trie.relation, generic.relation); + assert_eq!(trie.relation.positive_rows().count(), product_count); + assert_eq!(trie.trie_stats.output_rows, product_count); + assert_eq!(trie.trie_stats.relation_indexes, 2); + assert_eq!(trie.trie_stats.indexed_rows, 12); + assert_eq!(trie.trie_stats.domain_intersections, 8); + } + #[test] fn analysis_suggests_selective_connected_variable_order() { let (_, sidecar, edge) = transitive_edge_sidecar(); diff --git a/kernel/src/binding_space.rs b/kernel/src/binding_space.rs index 9f910bdf..1428876a 100644 --- a/kernel/src/binding_space.rs +++ b/kernel/src/binding_space.rs @@ -180,14 +180,7 @@ pub fn generic_join( relations: &[BindingRelation], variable_order: &[BindingVar], ) -> Result { - let all_variables = relations - .iter() - .flat_map(|relation| relation.schema().iter().copied()) - .collect::>(); - let ordered = variable_order.iter().copied().collect::>(); - if all_variables != ordered || all_variables.len() != variable_order.len() { - return Err(BindingRelationError::InvalidVariableOrder); - } + validate_variable_order(relations, variable_order)?; let mut out = BindingRelation::new(variable_order.to_vec()); let mut binding = BTreeMap::::new(); @@ -195,6 +188,75 @@ pub fn generic_join( Ok(out) } +/// Result of executing a trie-backed variable-at-a-time join. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TrieJoinResult { + /// Flat relation in the requested variable order. + pub relation: BindingRelation, + /// Physical work counters for the trie-backed executor. + pub stats: TrieJoinStats, +} + +/// Counters from the trie-backed variable-at-a-time join. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct TrieJoinStats { + /// Input relation indexes constructed for the join. + pub relation_indexes: usize, + /// Positive rows retained across all relation indexes. + pub indexed_rows: usize, + /// Distinct trie prefixes with outgoing children across all indexes. + pub trie_nodes: usize, + /// Variable-domain intersections performed during traversal. + pub domain_intersections: usize, + /// Relation domains participating in those intersections. + pub domain_sources: usize, + /// Domain values presented to leapfrog intersection. + pub domain_values: usize, + /// Final relation row-weight probes. + pub weight_lookups: usize, + /// Positive rows emitted by the join. + pub output_rows: usize, +} + +/// Trie-backed variable-at-a-time join over positive BindingSpace rows. +/// +/// This is the first physical LFTJ-style sidecar kernel: each input relation is +/// re-keyed as a trie compatible with the global `variable_order`, then the +/// executor synchronizes the current variable's sorted domains and recurses. +pub fn trie_join( + relations: &[BindingRelation], + variable_order: &[BindingVar], +) -> Result { + validate_variable_order(relations, variable_order)?; + + let mut stats = TrieJoinStats { + relation_indexes: relations.len(), + ..TrieJoinStats::default() + }; + let indexes = relations + .iter() + .map(|relation| RelationTrieIndex::build(relation, variable_order)) + .collect::, _>>()?; + for index in &indexes { + stats.indexed_rows += index.weights.len(); + stats.trie_nodes += index.children_by_prefix.len(); + } + + let mut relation = BindingRelation::new(variable_order.to_vec()); + let mut binding = BTreeMap::::new(); + trie_join_recurse( + &indexes, + variable_order, + 0, + &mut binding, + &mut relation, + &mut stats, + )?; + stats.output_rows = relation.positive_rows().count(); + + Ok(TrieJoinResult { relation, stats }) +} + fn generic_join_recurse( relations: &[BindingRelation], variable_order: &[BindingVar], @@ -260,6 +322,150 @@ fn generic_join_recurse( Ok(()) } +#[derive(Clone, Debug)] +struct RelationTrieIndex { + schema: Box<[BindingVar]>, + positions: BTreeMap, + children_by_prefix: BTreeMap>, + weights: BTreeMap, +} + +impl RelationTrieIndex { + fn build( + relation: &BindingRelation, + variable_order: &[BindingVar], + ) -> Result { + if has_duplicates(relation.schema()) { + return Err(BindingRelationError::InvalidVariableOrder); + } + + let schema = variable_order + .iter() + .copied() + .filter(|variable| relation.schema().contains(variable)) + .collect::>(); + let source_indexes = indexes(relation, &schema)?; + + let mut child_sets = BTreeMap::, BTreeSet>::new(); + let mut weights = BTreeMap::::new(); + for (row, weight) in relation.rows() { + if weight <= 0 { + continue; + } + + let ordered_row = source_indexes + .iter() + .map(|&index| row[index]) + .collect::>(); + let entry = weights + .entry(ordered_row.clone().into_boxed_slice()) + .or_default(); + *entry = entry.saturating_add(weight); + + for depth in 0..ordered_row.len() { + child_sets + .entry(ordered_row[..depth].to_vec()) + .or_default() + .insert(ordered_row[depth]); + } + } + + let positions = schema + .iter() + .copied() + .enumerate() + .map(|(index, variable)| (variable, index)) + .collect::>(); + let children_by_prefix = child_sets + .into_iter() + .map(|(prefix, children)| { + ( + prefix.into_boxed_slice(), + children.into_iter().collect::>().into_boxed_slice(), + ) + }) + .collect::>(); + + Ok(Self { + schema: schema.into_boxed_slice(), + positions, + children_by_prefix, + weights, + }) + } + + fn domain( + &self, + variable: BindingVar, + binding: &BTreeMap, + ) -> Option<&[TermId]> { + let position = *self.positions.get(&variable)?; + let mut prefix = Vec::with_capacity(position); + for &prefix_variable in &self.schema[..position] { + let value = binding.get(&prefix_variable)?; + prefix.push(*value); + } + + Some( + self.children_by_prefix + .get(prefix.as_slice()) + .map_or(&[], Box::as_ref), + ) + } + + fn weight(&self, binding: &BTreeMap) -> i64 { + let row = self + .schema + .iter() + .map(|variable| binding[variable]) + .collect::>(); + self.weights.get(row.as_slice()).copied().unwrap_or(0) + } +} + +fn trie_join_recurse( + indexes: &[RelationTrieIndex], + variable_order: &[BindingVar], + level: usize, + binding: &mut BTreeMap, + out: &mut BindingRelation, + stats: &mut TrieJoinStats, +) -> Result<(), BindingRelationError> { + if level == variable_order.len() { + let mut weight = 1i64; + for index in indexes { + stats.weight_lookups += 1; + weight = weight.saturating_mul(index.weight(binding)); + } + if weight != 0 { + out.add( + variable_order + .iter() + .map(|variable| binding[variable]) + .collect::>(), + weight, + )?; + } + return Ok(()); + } + + let variable = variable_order[level]; + let domains = indexes + .iter() + .filter_map(|index| index.domain(variable, binding)) + .collect::>(); + if domains.is_empty() { + return Err(BindingRelationError::InvalidVariableOrder); + } + + for value in leapfrog_intersection_slices(&domains, stats) { + binding.insert(variable, value); + trie_join_recurse(indexes, variable_order, level + 1, binding, out, stats)?; + } + binding.remove(&variable); + Ok(()) +} + /// Factorized binary equijoin grouped by shared variables. #[derive(Clone, Debug, Eq, PartialEq)] pub struct FactorizedJoin { @@ -411,8 +617,53 @@ fn rows_from_set(rows: &BTreeSet>) -> Box<[BindingRow]> { .into_boxed_slice() } +fn validate_variable_order( + relations: &[BindingRelation], + variable_order: &[BindingVar], +) -> Result<(), BindingRelationError> { + if has_duplicates(variable_order) + || relations + .iter() + .any(|relation| has_duplicates(relation.schema())) + { + return Err(BindingRelationError::InvalidVariableOrder); + } + + let all_variables = relations + .iter() + .flat_map(|relation| relation.schema().iter().copied()) + .collect::>(); + let ordered = variable_order.iter().copied().collect::>(); + if all_variables != ordered { + return Err(BindingRelationError::InvalidVariableOrder); + } + + Ok(()) +} + +fn has_duplicates(variables: &[BindingVar]) -> bool { + let mut seen = BTreeSet::new(); + variables + .iter() + .copied() + .any(|variable| !seen.insert(variable)) +} + fn leapfrog_intersection(domains: &[Vec]) -> Vec { - if domains.is_empty() || domains.iter().any(Vec::is_empty) { + let domains = domains.iter().map(Vec::as_slice).collect::>(); + leapfrog_intersection_inner(&domains) +} + +fn leapfrog_intersection_slices(domains: &[&[TermId]], stats: &mut TrieJoinStats) -> Vec { + stats.domain_intersections += 1; + stats.domain_sources += domains.len(); + stats.domain_values += domains.iter().map(|domain| domain.len()).sum::(); + + leapfrog_intersection_inner(domains) +} + +fn leapfrog_intersection_inner(domains: &[&[TermId]]) -> Vec { + if domains.is_empty() || domains.iter().any(|domain| domain.is_empty()) { return Vec::new(); } @@ -492,6 +743,57 @@ mod tests { assert_eq!(generic_rows.len(), 4); } + #[test] + fn trie_join_matches_generic_join_with_index_counters() { + let left = relation(&[0, 1], &[&[1, 10], &[2, 10], &[3, 20]]); + let right = relation(&[1, 2], &[&[10, 100], &[10, 101], &[30, 300]]); + + let generic = generic_join(&[left.clone(), right.clone()], &[v(1), v(0), v(2)]).unwrap(); + let trie = trie_join(&[left, right], &[v(1), v(0), v(2)]).unwrap(); + + assert_eq!(trie.relation, generic); + assert_eq!( + trie.stats, + TrieJoinStats { + relation_indexes: 2, + indexed_rows: 6, + trie_nodes: 6, + domain_intersections: 4, + domain_sources: 5, + domain_values: 10, + weight_lookups: 8, + output_rows: 4, + } + ); + } + + #[test] + fn trie_join_handles_cyclic_triangle_without_binary_intermediate() { + let xy = relation(&[0, 1], &[&[1, 2], &[1, 3], &[2, 3], &[3, 1]]); + let yz = relation(&[1, 2], &[&[2, 3], &[3, 1], &[3, 4], &[1, 2]]); + let zx = relation(&[2, 0], &[&[3, 1], &[1, 2], &[2, 3], &[4, 1]]); + + let generic = + generic_join(&[xy.clone(), yz.clone(), zx.clone()], &[v(0), v(1), v(2)]).unwrap(); + let trie = trie_join(&[xy, yz, zx], &[v(0), v(1), v(2)]).unwrap(); + + assert_eq!(trie.relation, generic); + assert_eq!( + trie.relation + .positive_rows() + .map(Vec::from) + .collect::>(), + BTreeSet::from([ + vec![t(1), t(2), t(3)], + vec![t(1), t(3), t(4)], + vec![t(2), t(3), t(1)], + vec![t(3), t(1), t(2)], + ]) + ); + assert_eq!(trie.stats.output_rows, 4); + assert!(trie.stats.domain_intersections < 10); + } + #[test] fn factorized_join_counts_without_flattening() { let left = relation(&[0, 1], &[&[1, 10], &[2, 10]]); From d1f75a82cbf44fe9c30f3a944a49eba814d236a4 Mon Sep 17 00:00:00 2001 From: MesTTo Date: Tue, 23 Jun 2026 19:19:52 +1000 Subject: [PATCH 12/13] Expose BindingSpace domain cursor --- kernel/src/binding_plan.rs | 5 ++ kernel/src/binding_space.rs | 140 +++++++++++++++++++++++++++++++----- 2 files changed, 128 insertions(+), 17 deletions(-) diff --git a/kernel/src/binding_plan.rs b/kernel/src/binding_plan.rs index 84bf7003..a3280f58 100644 --- a/kernel/src/binding_plan.rs +++ b/kernel/src/binding_plan.rs @@ -398,6 +398,11 @@ mod tests { assert_eq!(trie.trie_stats.relation_indexes, 2); assert_eq!(trie.trie_stats.indexed_rows, 12); assert_eq!(trie.trie_stats.domain_intersections, 8); + assert_eq!( + trie.trie_stats.domain_cursor_opens, + trie.trie_stats.domain_sources + ); + assert!(trie.trie_stats.domain_cursor_seeks >= trie.trie_stats.domain_cursor_opens); } #[test] diff --git a/kernel/src/binding_space.rs b/kernel/src/binding_space.rs index 1428876a..a75ef503 100644 --- a/kernel/src/binding_space.rs +++ b/kernel/src/binding_space.rs @@ -212,12 +212,35 @@ pub struct TrieJoinStats { pub domain_sources: usize, /// Domain values presented to leapfrog intersection. pub domain_values: usize, + /// LFTJ-style domain cursors opened for variable intersections. + pub domain_cursor_opens: usize, + /// Monotone seek calls issued against domain cursors. + pub domain_cursor_seeks: usize, + /// Next calls issued after a cursor-aligned result is emitted. + pub domain_cursor_nexts: usize, /// Final relation row-weight probes. pub weight_lookups: usize, /// Positive rows emitted by the join. pub output_rows: usize, } +/// Minimal LFTJ-style cursor over one ordered variable domain. +/// +/// This is the physical contract for PathMap/ReadZipper-backed factors: +/// ordered current key, monotone `seek`, linear `next`, and an end marker. The +/// current implementation below adapts in-memory relation trie domains to the +/// same contract. +pub trait BindingDomainCursor { + /// Current key at this trie depth, or `None` at end. + fn key(&self) -> Option; + /// Whether the cursor has exhausted its current domain. + fn at_end(&self) -> bool; + /// Advance to the next key in the current domain. + fn next(&mut self); + /// Advance to the least key greater than or equal to `target`. + fn seek(&mut self, target: TermId); +} + /// Trie-backed variable-at-a-time join over positive BindingSpace rows. /// /// This is the first physical LFTJ-style sidecar kernel: each input relation is @@ -423,6 +446,45 @@ impl RelationTrieIndex { } } +#[derive(Clone, Copy, Debug)] +struct SliceDomainCursor<'a> { + domain: &'a [TermId], + position: usize, +} + +impl<'a> SliceDomainCursor<'a> { + fn new(domain: &'a [TermId]) -> Self { + Self { + domain, + position: 0, + } + } +} + +impl BindingDomainCursor for SliceDomainCursor<'_> { + fn key(&self) -> Option { + self.domain.get(self.position).copied() + } + + fn at_end(&self) -> bool { + self.position >= self.domain.len() + } + + fn next(&mut self) { + if !self.at_end() { + self.position += 1; + } + } + + fn seek(&mut self, target: TermId) { + if self.at_end() { + return; + } + + self.position += self.domain[self.position..].partition_point(|&value| value < target); + } +} + fn trie_join_recurse( indexes: &[RelationTrieIndex], variable_order: &[BindingVar], @@ -651,7 +713,8 @@ fn has_duplicates(variables: &[BindingVar]) -> bool { fn leapfrog_intersection(domains: &[Vec]) -> Vec { let domains = domains.iter().map(Vec::as_slice).collect::>(); - leapfrog_intersection_inner(&domains) + let mut stats = TrieJoinStats::default(); + leapfrog_intersection_inner(&domains, &mut stats) } fn leapfrog_intersection_slices(domains: &[&[TermId]], stats: &mut TrieJoinStats) -> Vec { @@ -659,43 +722,63 @@ fn leapfrog_intersection_slices(domains: &[&[TermId]], stats: &mut TrieJoinStats stats.domain_sources += domains.len(); stats.domain_values += domains.iter().map(|domain| domain.len()).sum::(); - leapfrog_intersection_inner(domains) + leapfrog_intersection_inner(domains, stats) } -fn leapfrog_intersection_inner(domains: &[&[TermId]]) -> Vec { +fn leapfrog_intersection_inner(domains: &[&[TermId]], stats: &mut TrieJoinStats) -> Vec { if domains.is_empty() || domains.iter().any(|domain| domain.is_empty()) { return Vec::new(); } - let mut positions = vec![0usize; domains.len()]; - let mut target = domains + let mut cursors = domains + .iter() + .map(|&domain| SliceDomainCursor::new(domain)) + .collect::>(); + + leapfrog_intersection_cursors(&mut cursors, stats) +} + +fn leapfrog_intersection_cursors( + cursors: &mut [C], + stats: &mut TrieJoinStats, +) -> Vec { + stats.domain_cursor_opens += cursors.len(); + + if cursors.is_empty() || cursors.iter().any(BindingDomainCursor::at_end) { + return Vec::new(); + } + + let mut target = cursors .iter() - .map(|domain| domain[0]) + .filter_map(BindingDomainCursor::key) .max() - .expect("domains are non-empty"); + .expect("non-empty cursors have keys"); let mut out = Vec::new(); loop { let mut changed = false; - for (index, domain) in domains.iter().enumerate() { - while positions[index] < domain.len() && domain[positions[index]] < target { - positions[index] += 1; - } - if positions[index] >= domain.len() { + for cursor in cursors.iter_mut() { + stats.domain_cursor_seeks += 1; + cursor.seek(target); + if cursor.at_end() { return out; } - if domain[positions[index]] > target { - target = domain[positions[index]]; + let Some(key) = cursor.key() else { + return out; + }; + if key > target { + target = key; changed = true; } } if !changed { out.push(target); - positions[0] += 1; - if positions[0] >= domains[0].len() { + stats.domain_cursor_nexts += 1; + cursors[0].next(); + if cursors[0].at_end() { return out; } - target = domains[0][positions[0]]; + target = cursors[0].key().expect("cursor just checked as not at end"); } } } @@ -761,12 +844,35 @@ mod tests { domain_intersections: 4, domain_sources: 5, domain_values: 10, + domain_cursor_opens: 5, + domain_cursor_seeks: 11, + domain_cursor_nexts: 7, weight_lookups: 8, output_rows: 4, } ); } + #[test] + fn cursor_leapfrog_intersection_uses_monotone_seek_contract() { + let left = [t(1), t(3), t(5), t(8)]; + let right = [t(2), t(3), t(5), t(9)]; + let guard = [t(3), t(4), t(5), t(10)]; + let mut cursors = [ + SliceDomainCursor::new(&left), + SliceDomainCursor::new(&right), + SliceDomainCursor::new(&guard), + ]; + let mut stats = TrieJoinStats::default(); + + let intersection = leapfrog_intersection_cursors(&mut cursors, &mut stats); + + assert_eq!(intersection, vec![t(3), t(5)]); + assert_eq!(stats.domain_cursor_opens, 3); + assert_eq!(stats.domain_cursor_nexts, 2); + assert!(stats.domain_cursor_seeks >= stats.domain_cursor_opens); + } + #[test] fn trie_join_handles_cyclic_triangle_without_binary_intermediate() { let xy = relation(&[0, 1], &[&[1, 2], &[1, 3], &[2, 3], &[3, 1]]); From 154bd7e3631c0d59fe34ff60c2ea6955edb4a246 Mon Sep 17 00:00:00 2001 From: MesTTo Date: Tue, 23 Jun 2026 19:26:47 +1000 Subject: [PATCH 13/13] Add expression trie BindingSpace access --- kernel/src/binding_plan.rs | 216 ++++++++++++++++++++++++++++- kernel/src/test_sidecar_queries.rs | 2 +- 2 files changed, 215 insertions(+), 3 deletions(-) diff --git a/kernel/src/binding_plan.rs b/kernel/src/binding_plan.rs index a3280f58..542cf50a 100644 --- a/kernel/src/binding_plan.rs +++ b/kernel/src/binding_plan.rs @@ -3,10 +3,12 @@ use std::collections::{BTreeMap, BTreeSet}; use crate::arrangements::{ ArrangementDescriptor, ArrangementError, ArrangementIndex, ArrangementProjection, }; +use crate::binding_env::MAX_BINDING_SLOTS; use crate::binding_space::{ generic_join, trie_join, BindingRelation, BindingRelationError, BindingVar, TrieJoinStats, }; -use crate::term_identity::TermIdentitySidecar; +use crate::expression_trie::{ExpressionTrieError, ExpressionTrieIndex}; +use crate::term_identity::{TermId, TermIdentitySidecar}; /// Physical BindingSpace access selected by a compiled sidecar plan. /// @@ -20,6 +22,46 @@ pub enum BindingAccessPlan { descriptor: ArrangementDescriptor, projection: ArrangementProjection, }, + /// Use the typed expression trie to retrieve candidate facts for one + /// pattern, exact-filter them, then project user-visible slots into a + /// BindingSpace relation. + Pattern { + pattern: TermId, + projection: PatternProjection, + }, +} + +/// Projection from a matched pattern row into a BindingSpace relation schema. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PatternProjection { + /// Output BindingSpace schema. + pub schema: Box<[BindingVar]>, + /// User-visible pattern slots for each schema variable. + pub user_slots: Box<[u8]>, +} + +impl PatternProjection { + /// Creates a projection after validating arity and six-bit slot bounds. + pub fn new( + schema: impl Into>, + user_slots: impl Into>, + ) -> Result { + let schema = schema.into(); + let user_slots = user_slots.into(); + if schema.len() != user_slots.len() { + return Err(BindingSidecarPlanError::PatternProjectionArityMismatch { + schema_len: schema.len(), + slots_len: user_slots.len(), + }); + } + for &slot in user_slots.iter() { + if usize::from(slot) >= MAX_BINDING_SLOTS { + return Err(BindingSidecarPlanError::InvalidPatternSlot { slot }); + } + } + + Ok(Self { schema, user_slots }) + } } /// Sidecar join plan over derived BindingSpace relations. @@ -85,6 +127,14 @@ pub struct BindingSidecarStats { pub facts_scanned: usize, /// Rows retained by constructed arrangements. pub arrangement_rows: usize, + /// Complete facts indexed into expression tries. + pub expression_trie_facts_indexed: usize, + /// Candidate facts returned by expression-trie prefix retrieval. + pub expression_trie_candidates: usize, + /// Application atoms checked by exact relationalized pattern filters. + pub pattern_app_atoms_checked: usize, + /// Exact pattern matches before BindingSpace projection. + pub pattern_matches: usize, /// Positive BindingSpace rows produced by factor projections. pub projected_rows: usize, /// Positive rows emitted by the final join. @@ -96,8 +146,16 @@ pub struct BindingSidecarStats { pub enum BindingSidecarPlanError { /// A physical arrangement could not be built or projected. Arrangement(ArrangementError), + /// Expression-trie candidate retrieval or exact filtering failed. + ExpressionTrie(ExpressionTrieError), /// A BindingSpace relation operation failed. Binding(BindingRelationError), + /// Pattern projection schema and user-slot list have different lengths. + PatternProjectionArityMismatch { schema_len: usize, slots_len: usize }, + /// Pattern projection requested a slot outside MORK's six-bit domain. + InvalidPatternSlot { slot: u8 }, + /// Exact pattern matching did not bind a projected user slot. + MissingPatternBinding { slot: u8 }, } impl BindingSidecarPlan { @@ -205,6 +263,40 @@ impl BindingAccessPlan { .project_bindings(sidecar, projection) .map_err(BindingSidecarPlanError::Arrangement) } + BindingAccessPlan::Pattern { + pattern, + projection, + } => { + let index = ExpressionTrieIndex::build(sidecar) + .map_err(BindingSidecarPlanError::ExpressionTrie)?; + stats.expression_trie_facts_indexed += index.stats().facts_indexed; + let matches = index + .match_pattern(sidecar, *pattern) + .map_err(BindingSidecarPlanError::ExpressionTrie)?; + stats.expression_trie_candidates += matches.candidates.facts.len(); + stats.pattern_app_atoms_checked += matches.exact.stats.app_atoms_checked; + stats.pattern_matches += matches.exact.stats.matches; + + let mut relation = BindingRelation::new(projection.schema.clone()); + for row in matches.exact.rows { + let binding_row = projection + .user_slots + .iter() + .map(|&slot| { + row.user_bindings + .iter() + .find_map(|&(binding_slot, term)| { + (binding_slot == slot).then_some(term) + }) + .ok_or(BindingSidecarPlanError::MissingPatternBinding { slot }) + }) + .collect::, _>>()?; + relation + .add(binding_row, 1) + .map_err(BindingSidecarPlanError::Binding)?; + } + Ok(relation) + } } } } @@ -340,7 +432,10 @@ fn variable_connects_to_bound( #[cfg(test)] mod tests { use super::*; - use crate::test_sidecar_queries::{transitive_edge_product_count, transitive_edge_sidecar}; + use crate::space::Space; + use crate::test_sidecar_queries::{ + encoded_expr, transitive_edge_product_count, transitive_edge_sidecar, + }; fn transitive_edge_plan( edge: crate::term_identity::TermId, @@ -379,6 +474,7 @@ mod tests { arrangement_rows: 12, projected_rows: 12, output_rows: 4, + ..BindingSidecarStats::default() } ); } @@ -443,4 +539,120 @@ mod tests { ] ); } + + #[test] + fn expression_trie_pattern_factor_projects_repeated_variable_bindings() { + let mut space = Space::new(); + space + .add_all_sexpr( + br#" +(edge Alice (f Alice)) +(edge Alice (f Bob)) +(edge Bob (f Bob)) +(edge Carol (g Carol)) +(edge Dave (f Eve)) +(node Alice) +(tag Bob) +"#, + ) + .unwrap(); + + let mut sidecar = TermIdentitySidecar::new(); + let pattern = sidecar + .insert_term(&encoded_expr(&mut space, "[3] edge $ [2] f _1")) + .unwrap(); + sidecar.extend_from_pathmap(&space.btm).unwrap(); + let plan = BindingSidecarPlan::new( + [BindingAccessPlan::Pattern { + pattern, + projection: PatternProjection::new([BindingVar(0)], [0]).unwrap(), + }], + [BindingVar(0)], + ); + + let result = plan.execute_trie_join(&sidecar).unwrap(); + let product_pattern = crate::expr!(space, "[2] , [3] edge $ [2] f _1"); + let product_count = Space::query_multi(&space.btm, product_pattern, |_, _| true); + let alice = sidecar + .term_id_for_encoded(&encoded_expr(&mut space, "Alice")) + .unwrap(); + let bob = sidecar + .term_id_for_encoded(&encoded_expr(&mut space, "Bob")) + .unwrap(); + let projected = result + .relation + .positive_rows() + .map(|row| row[0]) + .collect::>(); + + assert_eq!(product_count, 2); + assert_eq!(result.relation.positive_rows().count(), product_count); + assert_eq!(projected, BTreeSet::from([alice, bob])); + assert_eq!(result.stats.factors, 1); + assert_eq!(result.stats.expression_trie_facts_indexed, 7); + assert_eq!(result.stats.expression_trie_candidates, 5); + assert_eq!(result.stats.pattern_matches, 2); + assert_eq!(result.stats.projected_rows, 2); + assert_eq!(result.trie_stats.output_rows, 2); + } + + #[test] + fn expression_trie_pattern_factor_joins_with_arrangement_factor() { + let mut space = Space::new(); + space + .add_all_sexpr( + br#" +(edge Alice (f Alice)) +(edge Bob (f Bob)) +(edge Carol (f Carol)) +(edge Dave (f Eve)) +(color Alice red) +(color Bob blue) +(color Carol red) +(color Eve red) +"#, + ) + .unwrap(); + + let mut sidecar = TermIdentitySidecar::new(); + let pattern = sidecar + .insert_term(&encoded_expr(&mut space, "[3] edge $ [2] f _1")) + .unwrap(); + sidecar.extend_from_pathmap(&space.btm).unwrap(); + let color = sidecar + .term_id_for_encoded(&encoded_expr(&mut space, "color")) + .unwrap(); + let descriptor = ArrangementDescriptor::new(color, 2, [0, 1]).unwrap(); + let plan = BindingSidecarPlan::new( + [ + BindingAccessPlan::Pattern { + pattern, + projection: PatternProjection::new([BindingVar(0)], [0]).unwrap(), + }, + BindingAccessPlan::Arrangement { + descriptor, + projection: ArrangementProjection::new( + 2, + [BindingVar(0), BindingVar(1)], + [0, 1], + ) + .unwrap(), + }, + ], + [BindingVar(0), BindingVar(1)], + ); + + let generic = plan.execute(&sidecar).unwrap(); + let trie = plan.execute_trie_join(&sidecar).unwrap(); + let product_pattern = crate::expr!(space, "[3] , [3] edge $ [2] f _1 [3] color _1 $"); + let product_count = Space::query_multi(&space.btm, product_pattern, |_, _| true); + + assert_eq!(product_count, 3); + assert_eq!(trie.relation, generic.relation); + assert_eq!(trie.relation.positive_rows().count(), product_count); + assert_eq!(trie.stats.expression_trie_candidates, 4); + assert_eq!(trie.stats.pattern_matches, 3); + assert_eq!(trie.stats.arrangement_rows, 4); + assert_eq!(trie.trie_stats.output_rows, product_count); + } } diff --git a/kernel/src/test_sidecar_queries.rs b/kernel/src/test_sidecar_queries.rs index 1f85baa9..902ebdaf 100644 --- a/kernel/src/test_sidecar_queries.rs +++ b/kernel/src/test_sidecar_queries.rs @@ -1,7 +1,7 @@ use crate::space::Space; use crate::term_identity::{TermId, TermIdentitySidecar}; -fn encoded_expr(space: &mut Space, expr: &'static str) -> Vec { +pub(crate) fn encoded_expr(space: &mut Space, expr: &'static str) -> Vec { let expr = crate::expr!(space, expr); unsafe { expr.span().as_ref().unwrap() }.to_vec() }