Skip to main content

hirn_engine/observability/
inspect.rs

1//! Inspect builder: fluent API for record inspection queries.
2
3use hirn_core::HirnError;
4use hirn_core::HirnResult;
5use hirn_core::id::MemoryId;
6use hirn_core::record::MemoryRecord;
7use hirn_core::timestamp::Timestamp;
8use hirn_core::types::Namespace;
9
10use crate::db::HirnDB;
11use crate::graph::GraphEdge;
12use crate::graph_store::GraphStore;
13use crate::ql::context::ConflictGroup;
14use crate::ql::results::SemanticRevisionSummary;
15use crate::retrieval::recall::ResourceEvidenceSummary;
16
17/// Neighbor edge metadata returned by INSPECT.
18#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
19pub struct NeighborInfo {
20    pub edge: GraphEdge,
21    pub neighbor_id: MemoryId,
22}
23
24/// Result of an inspect query executed via the builder API.
25#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
26pub struct InspectResult {
27    pub record: MemoryRecord,
28    pub importance: f32,
29    pub access_count: u64,
30    pub last_accessed: Timestamp,
31    pub neighbors: Vec<NeighborInfo>,
32    pub trust_score: f32,
33    pub semantic_revision: Option<SemanticRevisionSummary>,
34    pub conflict_groups: Vec<ConflictGroup>,
35    pub resource_evidence: Vec<ResourceEvidenceSummary>,
36}
37
38/// Builder for record inspection queries.
39pub struct InspectBuilder<'a> {
40    db: &'a HirnDB,
41    id: MemoryId,
42    allowed_namespaces: Option<Vec<Namespace>>,
43    agent_id: Option<String>,
44    exact_conflict_target: bool,
45}
46
47impl<'a> InspectBuilder<'a> {
48    pub(crate) fn new(db: &'a HirnDB, id: MemoryId) -> Self {
49        Self {
50            db,
51            id,
52            allowed_namespaces: None,
53            agent_id: None,
54            exact_conflict_target: false,
55        }
56    }
57
58    #[must_use]
59    pub fn allowed_namespaces(mut self, allowed_namespaces: Vec<Namespace>) -> Self {
60        self.allowed_namespaces = Some(allowed_namespaces);
61        self
62    }
63
64    #[must_use]
65    pub fn agent_id(mut self, agent_id: impl Into<String>) -> Self {
66        self.agent_id = Some(agent_id.into());
67        self
68    }
69
70    #[must_use]
71    pub fn exact_conflict_target(mut self, exact_conflict_target: bool) -> Self {
72        self.exact_conflict_target = exact_conflict_target;
73        self
74    }
75
76    /// Execute the inspect query.
77    pub async fn execute(self) -> HirnResult<InspectResult> {
78        let record = self.db.get_memory(self.id).await?;
79        if let Some(allowed_namespaces) = self.allowed_namespaces.as_deref() {
80            let namespace = record.effective_namespace();
81            if !allowed_namespaces.contains(&namespace) {
82                return Err(HirnError::AccessDenied(format!(
83                    "INSPECT cannot access namespace '{}'",
84                    namespace.as_str()
85                )));
86            }
87        }
88
89        let conflict_groups = if self.exact_conflict_target {
90            crate::ql::context::detect_conflicts_for_exact_record(
91                self.db,
92                &record,
93                self.allowed_namespaces.as_deref(),
94            )
95            .await
96            .groups
97        } else {
98            crate::ql::context::detect_conflicts_for_record(
99                self.db,
100                &record,
101                self.allowed_namespaces.as_deref(),
102            )
103            .await
104            .groups
105        };
106
107        let semantic_revision = match &record {
108            MemoryRecord::Semantic(record) => {
109                Some(crate::ql::results::load_semantic_revision_summary(self.db, record).await?)
110            }
111            _ => None,
112        };
113
114        let (importance, access_count, last_accessed) = match &record {
115            MemoryRecord::Episodic(record) => {
116                (record.importance, record.access_count, record.last_accessed)
117            }
118            MemoryRecord::Semantic(record) => {
119                (record.confidence, record.access_count, record.updated_at)
120            }
121            MemoryRecord::Working(record) => (record.relevance_score, 0, record.created_at),
122            MemoryRecord::Procedural(record) => (
123                record.success_rate,
124                record.access_count,
125                record.last_accessed,
126            ),
127        };
128
129        let trust_score = match &record {
130            MemoryRecord::Working(_) => 1.0,
131            MemoryRecord::Episodic(record) => {
132                trust_score_for_record(self.db, self.id, &record.provenance).await
133            }
134            MemoryRecord::Semantic(record) => {
135                trust_score_for_record(self.db, self.id, &record.provenance).await
136            }
137            MemoryRecord::Procedural(record) => {
138                trust_score_for_record(self.db, self.id, &record.provenance).await
139            }
140        };
141
142        let neighbors = collect_neighbors(self.db, self.id).await;
143        let agent_id = self.agent_id.as_deref().unwrap_or("anonymous");
144        let resource_evidence = self
145            .db
146            .resource_evidence_summaries_for_record(&record, agent_id)
147            .await?;
148
149        Ok(InspectResult {
150            record,
151            importance,
152            access_count,
153            last_accessed,
154            neighbors,
155            trust_score,
156            semantic_revision,
157            conflict_groups,
158            resource_evidence,
159        })
160    }
161}
162
163async fn collect_neighbors(db: &HirnDB, id: MemoryId) -> Vec<NeighborInfo> {
164    let edges = db.cached_graph().get_edges(id).await.unwrap_or_default();
165    edges
166        .into_iter()
167        .map(|edge| {
168            let neighbor_id = if edge.source == id {
169                edge.target
170            } else {
171                edge.source
172            };
173            NeighborInfo { edge, neighbor_id }
174        })
175        .collect()
176}
177
178async fn trust_score_for_record(
179    db: &HirnDB,
180    id: MemoryId,
181    provenance: &hirn_core::provenance::Provenance,
182) -> f32 {
183    let contradiction_count = db
184        .graph_store()
185        .get_edges_of_type(id, hirn_core::types::EdgeRelation::Contradicts)
186        .await
187        .unwrap_or_default()
188        .len();
189    crate::causal::compute_trust_score(provenance, contradiction_count)
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    use std::sync::Arc;
197
198    use hirn_core::HirnConfig;
199    use hirn_core::episodic::EpisodicRecord;
200    use hirn_core::metadata::Metadata;
201    use hirn_core::types::{AgentId, EdgeRelation, EventType};
202    use hirn_storage::memory_store::MemoryStore;
203
204    async fn temp_db() -> (HirnDB, tempfile::TempDir) {
205        let dir = tempfile::tempdir().unwrap();
206        let path = dir.path().join("inspect-tests");
207        let config = HirnConfig::builder()
208            .db_path(&path)
209            .embedding_dimensions(4)
210            .working_memory_token_limit(1000)
211            .build()
212            .unwrap();
213        let db = HirnDB::open_with_config(config, Arc::new(MemoryStore::new()))
214            .await
215            .unwrap();
216        (db, dir)
217    }
218
219    #[tokio::test(flavor = "multi_thread")]
220    async fn inspect_uses_authoritative_cached_graph_neighbors() {
221        let (db, _dir) = temp_db().await;
222
223        let source_id = db
224            .remember(
225                EpisodicRecord::builder()
226                    .event_type(EventType::Observation)
227                    .content("source event")
228                    .summary("source event")
229                    .embedding(vec![1.0, 0.0, 0.0, 0.0])
230                    .importance(0.9)
231                    .namespace(Namespace::new("inspect_ns").unwrap())
232                    .agent_id(AgentId::new("inspect-test").unwrap())
233                    .build()
234                    .unwrap(),
235            )
236            .await
237            .unwrap();
238        let target_id = db
239            .remember(
240                EpisodicRecord::builder()
241                    .event_type(EventType::Observation)
242                    .content("hot only neighbor")
243                    .summary("hot only neighbor")
244                    .embedding(vec![0.0, 1.0, 0.0, 0.0])
245                    .importance(0.8)
246                    .namespace(Namespace::new("inspect_ns").unwrap())
247                    .agent_id(AgentId::new("inspect-test").unwrap())
248                    .build()
249                    .unwrap(),
250            )
251            .await
252            .unwrap();
253
254        {
255            let mut hot_graph = db.cached_graph().hot_graph_mut();
256            hot_graph
257                .add_edge(
258                    source_id,
259                    target_id,
260                    EdgeRelation::Causes,
261                    0.8,
262                    Metadata::new(),
263                )
264                .unwrap();
265        }
266
267        let result = InspectBuilder::new(&db, source_id).execute().await.unwrap();
268
269        // Both the auto-created TemporalNext edge (from remember ordering) and the
270        // manually-added Causes edge should appear — inspect reads from the hot graph.
271        assert!(
272            result.neighbors.len() >= 1,
273            "expected at least one neighbor; got {}",
274            result.neighbors.len()
275        );
276        let causes_neighbor = result
277            .neighbors
278            .iter()
279            .find(|n| n.edge.relation == EdgeRelation::Causes);
280        assert!(
281            causes_neighbor.is_some(),
282            "expected a Causes neighbor from the hot graph"
283        );
284        let causes_neighbor = causes_neighbor.unwrap();
285        assert_eq!(causes_neighbor.neighbor_id, target_id);
286    }
287}