hirn_engine/observability/
inspect.rs1use 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#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
19pub struct NeighborInfo {
20 pub edge: GraphEdge,
21 pub neighbor_id: MemoryId,
22}
23
24#[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
38pub 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 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 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}