zeph_memory/five_signal/causal_distance.rs
1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::collections::HashMap;
5use std::sync::{Arc, Mutex};
6
7use zeph_common::memory::EdgeType;
8
9use crate::graph::GraphStore;
10
11/// Causal distance computer backed by MAGMA graph BFS.
12///
13/// Computes the shortest causal-edge hop count between the current goal entity and each
14/// candidate entity. BFS is bounded by `max_depth` to satisfy NFR-003. Results are cached
15/// per goal entity id to avoid re-traversal within the same turn.
16///
17/// The cache is guarded by a `std::sync::Mutex` (held only for synchronous reads/writes),
18/// so `compute` takes `&self` and the BFS query runs without any lock held, preventing
19/// serialization of concurrent recall operations (#5107).
20pub struct CausalDistanceComputer {
21 graph_store: Arc<GraphStore>,
22 max_depth: u32,
23 neutral_distance: u32,
24 /// Last BFS result: `(goal_entity_id, depth_map)`.
25 cache: Mutex<Option<(i64, HashMap<i64, u32>)>>,
26}
27
28impl CausalDistanceComputer {
29 /// Create a new computer.
30 ///
31 /// # Parameters
32 ///
33 /// - `max_depth`: BFS hop limit (default: 10).
34 /// - `neutral_distance`: distance assigned to unreachable entities (default: 5).
35 #[must_use]
36 pub fn new(graph_store: Arc<GraphStore>, max_depth: u32, neutral_distance: u32) -> Self {
37 Self {
38 graph_store,
39 max_depth,
40 neutral_distance,
41 cache: Mutex::new(None),
42 }
43 }
44
45 /// Compute causal distances from `goal_entity_id` to each entity in `entity_ids`.
46 ///
47 /// Returns a map of `entity_id → causal distance` where unreachable or missing entities
48 /// receive `neutral_distance`. When `goal_entity_id` is `None`, returns an empty map
49 /// (callers treat absent entries as neutral, contributing zero to the signal per FR-006).
50 ///
51 /// BFS result is cached per `goal_entity_id`; the cache is invalidated only when
52 /// the goal entity changes. The cache lock is held only during synchronous operations;
53 /// the BFS I/O runs lock-free to avoid serializing concurrent recall calls (#5107).
54 ///
55 /// # Panics
56 ///
57 /// Panics if the internal cache mutex is poisoned (another thread panicked while
58 /// holding the lock — unrecoverable state).
59 ///
60 /// # Errors
61 ///
62 /// Returns an error if the graph BFS query fails.
63 #[tracing::instrument(
64 name = "memory.five_signal.causal_distance.compute",
65 skip(self, entity_ids),
66 fields(goal_entity_id, candidate_count = entity_ids.len())
67 )]
68 pub async fn compute(
69 &self,
70 goal_entity_id: Option<i64>,
71 entity_ids: &[i64],
72 ) -> Result<HashMap<i64, u32>, crate::error::MemoryError> {
73 tracing::debug!("five_signal: computing causal distances");
74
75 let Some(goal_id) = goal_entity_id else {
76 return Ok(HashMap::new());
77 };
78
79 let neutral = self.neutral_distance;
80
81 // Phase 1: check cache (sync, lock acquired and released immediately).
82 let cached = {
83 let guard = self.cache.lock().expect("causal cache lock poisoned");
84 guard.as_ref().and_then(|(id, map)| {
85 if *id == goal_id {
86 Some(
87 entity_ids
88 .iter()
89 .map(|&eid| (eid, map.get(&eid).copied().unwrap_or(neutral)))
90 .collect::<HashMap<i64, u32>>(),
91 )
92 } else {
93 None
94 }
95 })
96 };
97
98 if let Some(result) = cached {
99 return Ok(result);
100 }
101
102 // Phase 2: BFS runs without holding any lock.
103 let (_, _, depth_map) = self
104 .graph_store
105 .bfs_typed(goal_id, self.max_depth, &[EdgeType::Causal])
106 .await?;
107
108 // Phase 3: store result and build output (sync, lock acquired and released immediately).
109 let result = {
110 let mut guard = self.cache.lock().expect("causal cache lock poisoned");
111 let result = entity_ids
112 .iter()
113 .map(|&eid| (eid, depth_map.get(&eid).copied().unwrap_or(neutral)))
114 .collect();
115 *guard = Some((goal_id, depth_map));
116 result
117 };
118
119 Ok(result)
120 }
121
122 /// Convert a raw causal distance to a score in `[0.0, 1.0]`.
123 ///
124 /// Distance 1 → 1.0, distance 5 → 0.2, `neutral_distance` → neutral value.
125 /// Distance 0 (goal entity itself) → 1.0 (clamped).
126 #[must_use]
127 #[inline]
128 pub fn distance_to_score(distance: u32) -> f64 {
129 if distance == 0 {
130 1.0
131 } else {
132 (1.0_f64 / f64::from(distance)).min(1.0)
133 }
134 }
135
136 /// Invalidate the BFS cache. Call at turn boundaries when the goal entity may change.
137 ///
138 /// # Panics
139 ///
140 /// Panics if the internal cache mutex is poisoned.
141 pub fn invalidate_cache(&self) {
142 let mut guard = self.cache.lock().expect("causal cache lock poisoned");
143 *guard = None;
144 }
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150
151 #[test]
152 fn distance_to_score_values() {
153 assert!((CausalDistanceComputer::distance_to_score(0) - 1.0).abs() < 1e-9);
154 assert!((CausalDistanceComputer::distance_to_score(1) - 1.0).abs() < 1e-9);
155 assert!((CausalDistanceComputer::distance_to_score(2) - 0.5).abs() < 1e-9);
156 assert!((CausalDistanceComputer::distance_to_score(5) - 0.2).abs() < 1e-9);
157 }
158
159 #[test]
160 fn distance_to_score_beyond_max_depth_clamped_to_min() {
161 // Scores decrease as distance grows and never exceed 1.0.
162 let score_at_limit = CausalDistanceComputer::distance_to_score(10);
163 let score_beyond = CausalDistanceComputer::distance_to_score(20);
164 assert!(score_at_limit <= 1.0);
165 assert!(score_beyond <= score_at_limit, "deeper nodes score lower");
166 assert!((score_at_limit - 0.1).abs() < 1e-9);
167 assert!((score_beyond - 0.05).abs() < 1e-9);
168 }
169
170 #[test]
171 fn neutral_distance_determines_unreachable_score() {
172 // Unreachable entities receive neutral_distance (default 5) → score = 1/5 = 0.2.
173 let neutral = 5_u32;
174 let score = CausalDistanceComputer::distance_to_score(neutral);
175 assert!((score - 0.2).abs() < 1e-9);
176 }
177
178 // Regression test for #4405: goal_entity_id=None returns empty map without touching the DB.
179 // Hardcodes `sqlx::SqlitePool` and is not portable to PostgreSQL; skipped entirely
180 // when `postgres` is the active backend (see issue #5364).
181 #[cfg(feature = "sqlite")]
182 #[tokio::test]
183 async fn compute_none_goal_returns_empty_map() {
184 use std::sync::Arc;
185
186 // Build a minimal in-memory graph store so the constructor is satisfied.
187 let pool = sqlx::SqlitePool::connect(":memory:").await.unwrap();
188 let graph_store = Arc::new(crate::graph::GraphStore::new(pool));
189 let computer = CausalDistanceComputer::new(graph_store, 10, 5);
190
191 let result = computer
192 .compute(None, &[1, 2, 3])
193 .await
194 .expect("None goal must not fail");
195 assert!(
196 result.is_empty(),
197 "goal_entity_id=None must return empty map, got: {result:?}"
198 );
199 }
200}