Skip to main content

clawft_kernel/
crossref.rs

1//! Universal cross-references between forest structures.
2//!
3//! This module is compiled only when the `ecc` feature is enabled.
4//! It provides [`UniversalNodeId`] (BLAKE3-hashed identity for any node),
5//! [`CrossRef`] (a typed directed edge between two nodes), and
6//! [`CrossRefStore`] (a concurrent forward/reverse index).
7
8use std::fmt;
9use std::sync::atomic::{AtomicU64, Ordering};
10
11use dashmap::DashMap;
12use serde::{Deserialize, Serialize};
13
14// ---------------------------------------------------------------------------
15// StructureTag
16// ---------------------------------------------------------------------------
17
18/// Identifies which forest structure a node belongs to.
19#[non_exhaustive]
20#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
21pub enum StructureTag {
22    /// ExoChain ledger (0x01).
23    ExoChain,
24    /// Resource tree (0x02).
25    ResourceTree,
26    /// Causal graph (0x03).
27    CausalGraph,
28    /// HNSW vector index (0x04).
29    HnswIndex,
30    /// Domain-specific extension (0x10+).
31    Custom(u8),
32}
33
34impl StructureTag {
35    /// Returns the canonical byte discriminant.
36    pub fn as_u8(&self) -> u8 {
37        match self {
38            Self::ExoChain => 0x01,
39            Self::ResourceTree => 0x02,
40            Self::CausalGraph => 0x03,
41            Self::HnswIndex => 0x04,
42            Self::Custom(v) => *v,
43        }
44    }
45}
46
47impl fmt::Display for StructureTag {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        match self {
50            Self::ExoChain => write!(f, "ExoChain"),
51            Self::ResourceTree => write!(f, "ResourceTree"),
52            Self::CausalGraph => write!(f, "CausalGraph"),
53            Self::HnswIndex => write!(f, "HnswIndex"),
54            Self::Custom(v) => write!(f, "Custom(0x{v:02x})"),
55        }
56    }
57}
58
59// ---------------------------------------------------------------------------
60// UniversalNodeId
61// ---------------------------------------------------------------------------
62
63/// A 32-byte BLAKE3 hash that uniquely identifies any node across all
64/// forest structures.
65#[derive(Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
66pub struct UniversalNodeId(pub [u8; 32]);
67
68impl UniversalNodeId {
69    /// Derive a deterministic identity by hashing the concatenation of all
70    /// constituent fields via BLAKE3.
71    pub fn new(
72        structure_tag: &StructureTag,
73        context_id: &[u8],
74        hlc_timestamp: u64,
75        content_hash: &[u8],
76        parent_id: &[u8],
77    ) -> Self {
78        let mut hasher = blake3::Hasher::new();
79        hasher.update(&[structure_tag.as_u8()]);
80        hasher.update(context_id);
81        hasher.update(&hlc_timestamp.to_le_bytes());
82        hasher.update(content_hash);
83        hasher.update(parent_id);
84        Self(*hasher.finalize().as_bytes())
85    }
86
87    /// The all-zeros sentinel ID.
88    pub fn zero() -> Self {
89        Self([0u8; 32])
90    }
91
92    /// Construct from a raw 32-byte array.
93    pub fn from_bytes(bytes: [u8; 32]) -> Self {
94        Self(bytes)
95    }
96
97    /// Borrow the inner bytes.
98    pub fn as_bytes(&self) -> &[u8; 32] {
99        &self.0
100    }
101}
102
103impl fmt::Display for UniversalNodeId {
104    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105        for b in &self.0 {
106            write!(f, "{b:02x}")?;
107        }
108        Ok(())
109    }
110}
111
112impl fmt::Debug for UniversalNodeId {
113    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114        write!(f, "UniversalNodeId({self})")
115    }
116}
117
118// ---------------------------------------------------------------------------
119// CrossRefType
120// ---------------------------------------------------------------------------
121
122/// The semantic relationship carried by a [`CrossRef`].
123#[non_exhaustive]
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
125pub enum CrossRefType {
126    /// Source was triggered by target (0x01).
127    TriggeredBy,
128    /// Source is evidence for target (0x02).
129    EvidenceFor,
130    /// Source elaborates on target (0x03).
131    Elaborates,
132    /// Source is the emotional cause of target (0x04).
133    EmotionCause,
134    /// Source provides goal motivation for target (0x05).
135    GoalMotivation,
136    /// Scene boundary marker (0x06).
137    SceneBoundary,
138    /// Memory encoding link (0x09).
139    MemoryEncoded,
140    /// Theory-of-mind inference (0x0A).
141    TomInference,
142    /// Backchannel continuer (0x0B, ADR-062 D5): a "mm-hmm" linking a listener
143    /// to the current speaker node **without** creating a turn node.
144    Continuer,
145    /// Per-speaker association (0x0C, ADR-062 §Architecture / ADR-046 forest
146    /// join): links a turn node to the stable identity node of whoever produced
147    /// it. Lets the forest walk recall a speaker's prior turns as cross-structure
148    /// context, not by cosine alone.
149    Speaker,
150    /// Domain-specific extension.
151    Custom(u8),
152}
153
154impl fmt::Display for CrossRefType {
155    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156        match self {
157            Self::TriggeredBy => write!(f, "TriggeredBy"),
158            Self::EvidenceFor => write!(f, "EvidenceFor"),
159            Self::Elaborates => write!(f, "Elaborates"),
160            Self::EmotionCause => write!(f, "EmotionCause"),
161            Self::GoalMotivation => write!(f, "GoalMotivation"),
162            Self::SceneBoundary => write!(f, "SceneBoundary"),
163            Self::MemoryEncoded => write!(f, "MemoryEncoded"),
164            Self::TomInference => write!(f, "TomInference"),
165            Self::Continuer => write!(f, "Continuer"),
166            Self::Speaker => write!(f, "Speaker"),
167            Self::Custom(v) => write!(f, "Custom(0x{v:02x})"),
168        }
169    }
170}
171
172// ---------------------------------------------------------------------------
173// CrossRef
174// ---------------------------------------------------------------------------
175
176/// A directed, typed cross-reference between two universal nodes.
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct CrossRef {
179    /// The originating node.
180    pub source: UniversalNodeId,
181    /// Which structure the source lives in.
182    pub source_structure: StructureTag,
183    /// The destination node.
184    pub target: UniversalNodeId,
185    /// Which structure the target lives in.
186    pub target_structure: StructureTag,
187    /// Semantic relationship type.
188    pub ref_type: CrossRefType,
189    /// HLC timestamp at creation.
190    pub created_at: u64,
191    /// ExoChain sequence number for provenance.
192    pub chain_seq: u64,
193}
194
195// ---------------------------------------------------------------------------
196// CrossRefStore
197// ---------------------------------------------------------------------------
198
199/// Concurrent forward/reverse index of [`CrossRef`] edges.
200pub struct CrossRefStore {
201    forward: DashMap<UniversalNodeId, Vec<CrossRef>>,
202    reverse: DashMap<UniversalNodeId, Vec<CrossRef>>,
203    count: AtomicU64,
204}
205
206impl CrossRefStore {
207    /// Create an empty store.
208    pub fn new() -> Self {
209        Self {
210            forward: DashMap::new(),
211            reverse: DashMap::new(),
212            count: AtomicU64::new(0),
213        }
214    }
215
216    /// Insert a cross-reference, indexing it in both directions.
217    pub fn insert(&self, crossref: CrossRef) {
218        self.forward
219            .entry(crossref.source.clone())
220            .or_default()
221            .push(crossref.clone());
222        self.reverse
223            .entry(crossref.target.clone())
224            .or_default()
225            .push(crossref);
226        self.count.fetch_add(1, Ordering::Relaxed);
227    }
228
229    /// All cross-refs originating from `id`.
230    pub fn get_forward(&self, id: &UniversalNodeId) -> Vec<CrossRef> {
231        self.forward
232            .get(id)
233            .map(|v| v.value().clone())
234            .unwrap_or_default()
235    }
236
237    /// All cross-refs pointing *to* `id`.
238    pub fn get_reverse(&self, id: &UniversalNodeId) -> Vec<CrossRef> {
239        self.reverse
240            .get(id)
241            .map(|v| v.value().clone())
242            .unwrap_or_default()
243    }
244
245    /// All cross-refs where `id` appears as source **or** target.
246    pub fn get_all(&self, id: &UniversalNodeId) -> Vec<CrossRef> {
247        let mut out = self.get_forward(id);
248        out.extend(self.get_reverse(id));
249        out
250    }
251
252    /// Total number of cross-refs inserted.
253    pub fn count(&self) -> u64 {
254        self.count.load(Ordering::Relaxed)
255    }
256
257    /// Filter cross-refs involving `id` (either direction) by relationship type.
258    pub fn by_type(&self, id: &UniversalNodeId, ref_type: &CrossRefType) -> Vec<CrossRef> {
259        self.get_all(id)
260            .into_iter()
261            .filter(|cr| &cr.ref_type == ref_type)
262            .collect()
263    }
264
265    /// Every cross-ref in the store, each yielded exactly once.
266    ///
267    /// The forward index holds one copy of each cross-ref under its `source`
268    /// key (the reverse index is the mirror), so flattening the forward map
269    /// enumerates the store without duplication. Ordering is unspecified
270    /// (`DashMap` iteration order). Intended for whole-store projections such as
271    /// the `conversation.graph` RPC, which scopes the result to a conversation
272    /// by intersecting each cross-ref's endpoints against that conversation's
273    /// node UID set (there is no `conv_id` on a [`CrossRef`] to filter by
274    /// directly).
275    pub fn all(&self) -> Vec<CrossRef> {
276        self.forward
277            .iter()
278            .flat_map(|entry| entry.value().clone())
279            .collect()
280    }
281
282    /// Remove all entries (useful for calibration resets).
283    pub fn clear(&self) {
284        self.forward.clear();
285        self.reverse.clear();
286        self.count.store(0, Ordering::Relaxed);
287    }
288}
289
290impl Default for CrossRefStore {
291    fn default() -> Self {
292        Self::new()
293    }
294}
295
296// ---------------------------------------------------------------------------
297// Tests
298// ---------------------------------------------------------------------------
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    fn make_id(tag: &StructureTag, ctx: &[u8], ts: u64) -> UniversalNodeId {
305        UniversalNodeId::new(tag, ctx, ts, b"hash", b"parent")
306    }
307
308    fn sample_crossref(src: UniversalNodeId, tgt: UniversalNodeId, rt: CrossRefType) -> CrossRef {
309        CrossRef {
310            source: src,
311            source_structure: StructureTag::ExoChain,
312            target: tgt,
313            target_structure: StructureTag::ResourceTree,
314            ref_type: rt,
315            created_at: 1000,
316            chain_seq: 42,
317        }
318    }
319
320    #[test]
321    fn universal_node_id_creation() {
322        let id = make_id(&StructureTag::ExoChain, b"ctx", 1);
323        assert_ne!(id.as_bytes(), &[0u8; 32]);
324    }
325
326    #[test]
327    fn universal_node_id_deterministic() {
328        let a = make_id(&StructureTag::ExoChain, b"ctx", 1);
329        let b = make_id(&StructureTag::ExoChain, b"ctx", 1);
330        assert_eq!(a, b);
331
332        let c = make_id(&StructureTag::ExoChain, b"ctx", 2);
333        assert_ne!(a, c);
334    }
335
336    #[test]
337    fn universal_node_id_display_hex() {
338        let id = UniversalNodeId::zero();
339        let s = format!("{id}");
340        assert_eq!(s.len(), 64);
341        assert!(s.chars().all(|c| c.is_ascii_hexdigit()));
342        assert_eq!(s, "0".repeat(64));
343    }
344
345    #[test]
346    fn universal_node_id_zero() {
347        let z = UniversalNodeId::zero();
348        assert_eq!(z.as_bytes(), &[0u8; 32]);
349        assert_eq!(z, UniversalNodeId::from_bytes([0u8; 32]));
350    }
351
352    #[test]
353    fn structure_tag_as_u8() {
354        assert_eq!(StructureTag::ExoChain.as_u8(), 0x01);
355        assert_eq!(StructureTag::ResourceTree.as_u8(), 0x02);
356        assert_eq!(StructureTag::CausalGraph.as_u8(), 0x03);
357        assert_eq!(StructureTag::HnswIndex.as_u8(), 0x04);
358        assert_eq!(StructureTag::Custom(0x10).as_u8(), 0x10);
359    }
360
361    #[test]
362    fn structure_tag_display() {
363        assert_eq!(StructureTag::ExoChain.to_string(), "ExoChain");
364        assert_eq!(StructureTag::ResourceTree.to_string(), "ResourceTree");
365        assert_eq!(StructureTag::Custom(0x10).to_string(), "Custom(0x10)");
366    }
367
368    #[test]
369    fn crossref_type_display() {
370        assert_eq!(CrossRefType::TriggeredBy.to_string(), "TriggeredBy");
371        assert_eq!(CrossRefType::EvidenceFor.to_string(), "EvidenceFor");
372        assert_eq!(CrossRefType::TomInference.to_string(), "TomInference");
373        assert_eq!(CrossRefType::Custom(0xff).to_string(), "Custom(0xff)");
374    }
375
376    #[test]
377    fn crossref_store_insert_and_get_forward() {
378        let store = CrossRefStore::new();
379        let src = make_id(&StructureTag::ExoChain, b"a", 1);
380        let tgt = make_id(&StructureTag::ResourceTree, b"b", 2);
381        store.insert(sample_crossref(src.clone(), tgt, CrossRefType::TriggeredBy));
382
383        let fwd = store.get_forward(&src);
384        assert_eq!(fwd.len(), 1);
385        assert_eq!(fwd[0].ref_type, CrossRefType::TriggeredBy);
386    }
387
388    #[test]
389    fn crossref_store_insert_and_get_reverse() {
390        let store = CrossRefStore::new();
391        let src = make_id(&StructureTag::ExoChain, b"a", 1);
392        let tgt = make_id(&StructureTag::ResourceTree, b"b", 2);
393        store.insert(sample_crossref(src, tgt.clone(), CrossRefType::EvidenceFor));
394
395        let rev = store.get_reverse(&tgt);
396        assert_eq!(rev.len(), 1);
397        assert_eq!(rev[0].ref_type, CrossRefType::EvidenceFor);
398    }
399
400    #[test]
401    fn crossref_store_get_all_both_directions() {
402        let store = CrossRefStore::new();
403        let a = make_id(&StructureTag::ExoChain, b"a", 1);
404        let b = make_id(&StructureTag::ResourceTree, b"b", 2);
405        let c = make_id(&StructureTag::CausalGraph, b"c", 3);
406
407        // a -> b
408        store.insert(sample_crossref(
409            a.clone(),
410            b.clone(),
411            CrossRefType::TriggeredBy,
412        ));
413        // c -> a
414        store.insert(sample_crossref(c, a.clone(), CrossRefType::Elaborates));
415
416        let all = store.get_all(&a);
417        assert_eq!(all.len(), 2);
418        assert_eq!(store.count(), 2);
419    }
420
421    #[test]
422    fn crossref_store_by_type() {
423        let store = CrossRefStore::new();
424        let a = make_id(&StructureTag::ExoChain, b"a", 1);
425        let b = make_id(&StructureTag::ResourceTree, b"b", 2);
426        let c = make_id(&StructureTag::CausalGraph, b"c", 3);
427
428        store.insert(sample_crossref(a.clone(), b, CrossRefType::TriggeredBy));
429        store.insert(sample_crossref(a.clone(), c, CrossRefType::EvidenceFor));
430
431        let triggered = store.by_type(&a, &CrossRefType::TriggeredBy);
432        assert_eq!(triggered.len(), 1);
433
434        let evidence = store.by_type(&a, &CrossRefType::EvidenceFor);
435        assert_eq!(evidence.len(), 1);
436
437        let none = store.by_type(&a, &CrossRefType::SceneBoundary);
438        assert!(none.is_empty());
439    }
440
441    #[test]
442    fn crossref_store_resolves_continuer() {
443        // ADR-062 D5: a backchannel is a Continuer cross-ref from listener to
444        // the current speaker node, resolvable via by_type.
445        let store = CrossRefStore::new();
446        let listener = make_id(&StructureTag::CausalGraph, b"listener", 1);
447        let speaker = make_id(&StructureTag::CausalGraph, b"speaker", 2);
448        store.insert(sample_crossref(
449            listener.clone(),
450            speaker.clone(),
451            CrossRefType::Continuer,
452        ));
453        // Unrelated edge that must not match.
454        let other = make_id(&StructureTag::CausalGraph, b"other", 3);
455        store.insert(sample_crossref(
456            listener.clone(),
457            other,
458            CrossRefType::Elaborates,
459        ));
460
461        let continuers = store.by_type(&listener, &CrossRefType::Continuer);
462        assert_eq!(continuers.len(), 1);
463        assert_eq!(continuers[0].target, speaker);
464        assert_eq!(continuers[0].ref_type, CrossRefType::Continuer);
465        assert_eq!(CrossRefType::Continuer.to_string(), "Continuer");
466    }
467
468    #[test]
469    fn crossref_store_clear() {
470        let store = CrossRefStore::new();
471        let a = make_id(&StructureTag::ExoChain, b"a", 1);
472        let b = make_id(&StructureTag::ResourceTree, b"b", 2);
473        store.insert(sample_crossref(a.clone(), b, CrossRefType::TriggeredBy));
474        assert_eq!(store.count(), 1);
475
476        store.clear();
477        assert_eq!(store.count(), 0);
478        assert!(store.get_forward(&a).is_empty());
479    }
480}