Skip to main content

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}