1use std::collections::{BTreeSet, HashMap, HashSet};
4
5use khive_storage::types::{Direction, NeighborCursor, NeighborHit, NeighborQuery};
6use uuid::Uuid;
7
8use crate::{KhiveRuntime, Namespace, NamespaceToken, Resolved, RuntimeError, VerbRegistry};
9
10pub struct KgNeighborRead {
12 pub query: NeighborQuery,
13 pub after: Option<NeighborCursor>,
14 pub neighbor_kinds: Option<Vec<String>>,
15 pub enrich: bool,
16 pub namespace: Option<Namespace>,
19}
20
21pub(crate) fn neighbor_read_namespaces<'a>(
22 token: &'a NamespaceToken,
23 namespace: Option<&'a Namespace>,
24) -> Result<&'a [Namespace], RuntimeError> {
25 match namespace {
26 Some(namespace) if token.visible_namespaces().contains(namespace) => {
27 Ok(std::slice::from_ref(namespace))
28 }
29 Some(_) => Err(RuntimeError::InvalidInput(
30 "KG neighbor namespace must already be visible to the caller".into(),
31 )),
32 None => Ok(token.visible_namespaces()),
33 }
34}
35
36impl VerbRegistry {
37 pub async fn neighbors_for_kg_read(
40 &self,
41 runtime: &KhiveRuntime,
42 token: &NamespaceToken,
43 node_id: Uuid,
44 options: KgNeighborRead,
45 ) -> Result<Vec<NeighborHit>, RuntimeError> {
46 self.neighbors_for_kg_read_inner(runtime, token, node_id, options, false)
47 .await
48 .map(|(hits, _)| hits)
49 }
50
51 pub async fn neighbors_for_kg_read_with_entity_kinds(
54 &self,
55 runtime: &KhiveRuntime,
56 token: &NamespaceToken,
57 node_id: Uuid,
58 options: KgNeighborRead,
59 ) -> Result<(Vec<NeighborHit>, HashMap<Uuid, String>), RuntimeError> {
60 self.neighbors_for_kg_read_inner(runtime, token, node_id, options, true)
61 .await
62 }
63
64 async fn neighbors_for_kg_read_inner(
65 &self,
66 runtime: &KhiveRuntime,
67 token: &NamespaceToken,
68 node_id: Uuid,
69 options: KgNeighborRead,
70 with_entity_kinds: bool,
71 ) -> Result<(Vec<NeighborHit>, HashMap<Uuid, String>), RuntimeError> {
72 neighbor_read_namespaces(token, options.namespace.as_ref())?;
73 if self
74 .resolve_kg_read_by_id(runtime, token, node_id, false)
75 .await?
76 .is_none()
77 && !runtime.substrate_exists_by_id(token, node_id).await?
78 {
79 return Err(RuntimeError::NotFound(format!(
80 "neighbor anchor {node_id} not found"
81 )));
82 }
83 if with_entity_kinds {
84 runtime
85 .neighbors_for_resolved_kg_read_with_entity_kinds(token, node_id, options)
86 .await
87 } else {
88 runtime
89 .neighbors_for_resolved_kg_read(token, node_id, options)
90 .await
91 .map(|hits| (hits, HashMap::new()))
92 }
93 }
94
95 pub async fn directed_neighbors_for_kg_read(
98 &self,
99 runtime: &KhiveRuntime,
100 token: &NamespaceToken,
101 node_id: Uuid,
102 query: NeighborQuery,
103 namespace: Option<&Namespace>,
104 ) -> Result<Vec<(NeighborHit, Direction)>, RuntimeError> {
105 neighbor_read_namespaces(token, namespace)?;
106 if self
107 .resolve_kg_read_by_id(runtime, token, node_id, false)
108 .await?
109 .is_none()
110 && !runtime.substrate_exists_by_id(token, node_id).await?
111 {
112 return Err(RuntimeError::NotFound(format!(
113 "neighbor anchor {node_id} not found"
114 )));
115 }
116 runtime
117 .directed_neighbors_for_resolved_kg_read(token, node_id, query, namespace)
118 .await
119 }
120}
121
122pub(crate) struct KgReadResolver {
123 runtimes: Vec<KhiveRuntime>,
124 primary: KhiveRuntime,
125 by_pack: HashMap<String, KhiveRuntime>,
126}
127
128impl KgReadResolver {
129 pub(crate) fn new(primary: &KhiveRuntime, runtimes: &HashMap<String, KhiveRuntime>) -> Self {
130 let mut seen = HashSet::new();
134 let mut unique = Vec::new();
135 let mut packs: Vec<_> = runtimes.iter().collect();
136 packs.sort_by_key(|(name, _)| *name);
137 for runtime in std::iter::once(primary).chain(packs.into_iter().map(|(_, rt)| rt)) {
138 if seen.insert(runtime.backend_id().clone()) {
139 unique.push(runtime.clone());
140 }
141 }
142 Self {
143 runtimes: unique,
144 primary: primary.clone(),
145 by_pack: runtimes.clone(),
146 }
147 }
148
149 pub(crate) fn runtimes_for_lint(&self) -> Vec<KhiveRuntime> {
151 let mut by_pack: Vec<_> = self.by_pack.iter().collect();
152 by_pack.sort_by_key(|(pack, _)| *pack);
153 std::iter::once(self.primary.clone())
154 .chain(by_pack.into_iter().map(|(_, runtime)| runtime.clone()))
155 .collect()
156 }
157
158 pub(crate) fn runtime_for_pack(&self, pack: &str) -> &KhiveRuntime {
159 self.by_pack.get(pack).unwrap_or(&self.primary)
160 }
161
162 pub(crate) async fn by_id(
163 &self,
164 token: &NamespaceToken,
165 id: Uuid,
166 include_deleted: bool,
167 ) -> Result<Option<Resolved>, RuntimeError> {
168 let mut found = None;
169 for runtime in &self.runtimes {
170 let candidate = if include_deleted {
171 runtime.resolve_by_id_including_deleted(token, id).await?
172 } else {
173 runtime.resolve_by_id(token, id).await?
174 };
175 if found.is_none()
178 || (matches!(&found, Some(Resolved::Note(_)))
179 && matches!(&candidate, Some(Resolved::Entity(_))))
180 {
181 found = candidate;
182 }
183 }
184 Ok(found)
185 }
186
187 pub(crate) async fn entity_runtime(
188 &self,
189 token: &NamespaceToken,
190 id: Uuid,
191 ) -> Result<Option<KhiveRuntime>, RuntimeError> {
192 let mut owner = None;
193 for runtime in &self.runtimes {
194 let store = runtime.entities(token)?;
195 let entity = store.get_entity_including_deleted(id).await?;
196 if entity.is_some() {
197 if owner.is_some() {
198 return Err(RuntimeError::InvalidInput(format!(
199 "entity {id} exists on multiple backends; deletion is ambiguous"
200 )));
201 }
202 owner = Some(runtime.clone());
203 }
204 }
205 Ok(owner)
206 }
207
208 pub(crate) async fn prefix(
209 &self,
210 prefix: &str,
211 include_deleted: bool,
212 ) -> Result<Option<Uuid>, RuntimeError> {
213 let mut matches = BTreeSet::new();
214 for runtime in &self.runtimes {
215 let candidate = runtime
216 .resolve_prefix_for_kg_read(prefix, include_deleted)
217 .await;
218 match candidate {
219 Ok(Some(id)) => {
220 matches.insert(id);
221 }
222 Ok(None) => {}
223 Err(RuntimeError::AmbiguousPrefix { matches: ids, .. }) => matches.extend(ids),
224 Err(error) => return Err(error),
225 }
226 }
227 match matches.len() {
228 0 => Ok(None),
229 1 => Ok(matches.into_iter().next()),
230 _ => Err(RuntimeError::AmbiguousPrefix {
231 prefix: prefix.into(),
232 matches: matches.into_iter().collect(),
233 }),
234 }
235 }
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241 use crate::{BackendId, Namespace, RuntimeConfig};
242 use chrono::Utc;
243 use khive_storage::types::{Edge, LinkId};
244 use khive_storage::{Entity, Note};
245
246 #[tokio::test]
247 async fn kg_neighbor_namespace_selection_is_narrowing_and_keeps_directed_self_loops() {
248 let project = Namespace::parse("project").unwrap();
249 let main = KhiveRuntime::new(RuntimeConfig {
250 db_path: None,
251 backend_id: BackendId::main(),
252 actor_id: Some("reader".into()),
253 visible_namespaces: vec![project.clone()],
254 ..RuntimeConfig::no_embeddings()
255 })
256 .unwrap();
257 let token = main
258 .authorize_with_visibility(Namespace::local(), vec![project.clone()])
259 .unwrap();
260 let anchor = Entity::new("local", "concept", "namespace selector anchor");
261 let neighbor = Entity::new("project", "concept", "namespace selector neighbor");
262 for entity in [&anchor, &neighbor] {
263 main.entities(&token)
264 .unwrap()
265 .upsert_entity(entity.clone())
266 .await
267 .unwrap();
268 }
269 let self_loop = Uuid::new_v4();
270 for (namespace, id, source) in [
271 (Namespace::local(), self_loop, anchor.id),
272 (project.clone(), Uuid::new_v4(), neighbor.id),
273 ] {
274 let edge_token = main.authorize(namespace.clone()).unwrap();
275 let now = Utc::now();
276 main.graph(&edge_token)
277 .unwrap()
278 .upsert_edge(Edge {
279 id: LinkId(id),
280 namespace: namespace.to_string(),
281 source_id: source,
282 target_id: anchor.id,
283 relation: khive_storage::EdgeRelation::Extends,
284 weight: 1.0,
285 created_at: now,
286 updated_at: now,
287 deleted_at: None,
288 metadata: None,
289 target_backend: None,
290 })
291 .await
292 .unwrap();
293 }
294 let registry = crate::VerbRegistryBuilder::new().build().unwrap();
295 let query = NeighborQuery {
296 direction: Direction::Both,
297 relations: None,
298 limit: Some(10),
299 min_weight: None,
300 };
301 let baseline = main
302 .neighbors_with_query_directed(&token, anchor.id, query.clone())
303 .await
304 .unwrap();
305 let full = registry
306 .directed_neighbors_for_kg_read(&main, &token, anchor.id, query.clone(), None)
307 .await
308 .unwrap();
309 let keys = |hits: &[(NeighborHit, Direction)]| {
310 hits.iter()
311 .map(|(hit, direction)| (hit.node_id, hit.edge_id, direction.clone()))
312 .collect::<Vec<_>>()
313 };
314 assert_eq!(keys(&full), keys(&baseline));
315 let local = registry
316 .directed_neighbors_for_kg_read(
317 &main,
318 &token,
319 anchor.id,
320 query.clone(),
321 Some(&Namespace::local()),
322 )
323 .await
324 .unwrap();
325 assert_eq!(local.len(), 2);
326 assert_eq!(local[0].0.edge_id, self_loop);
327 assert_eq!(local[0].1, Direction::Out);
328 assert_eq!(local[1].1, Direction::In);
329 let project_hits = registry
330 .neighbors_for_kg_read(
331 &main,
332 &token,
333 anchor.id,
334 KgNeighborRead {
335 query: query.clone(),
336 after: None,
337 neighbor_kinds: None,
338 enrich: true,
339 namespace: Some(project),
340 },
341 )
342 .await
343 .unwrap();
344 assert_eq!(project_hits.len(), 1);
345 assert_eq!(project_hits[0].node_id, neighbor.id);
346 let outside = Namespace::parse("outside").unwrap();
347 assert!(matches!(
348 registry
349 .directed_neighbors_for_kg_read(
350 &main,
351 &token,
352 anchor.id,
353 query.clone(),
354 Some(&outside)
355 )
356 .await,
357 Err(RuntimeError::InvalidInput(_))
358 ));
359 assert!(matches!(
360 registry
361 .neighbors_for_kg_read(
362 &main,
363 &token,
364 anchor.id,
365 KgNeighborRead {
366 query,
367 after: None,
368 neighbor_kinds: None,
369 enrich: true,
370 namespace: Some(outside),
371 },
372 )
373 .await,
374 Err(RuntimeError::InvalidInput(_))
375 ));
376 }
377
378 fn runtime(name: &str) -> KhiveRuntime {
379 KhiveRuntime::new(RuntimeConfig {
380 db_path: None,
381 backend_id: BackendId::parse(name).unwrap(),
382 ..RuntimeConfig::no_embeddings()
383 })
384 .unwrap()
385 }
386
387 async fn note(runtime: &KhiveRuntime, id: Uuid, namespace: &str) -> Note {
388 let token = runtime
389 .authorize(Namespace::parse(namespace).unwrap())
390 .unwrap();
391 let mut note = Note::new(namespace, "observation", "read routing fixture");
392 note.id = id;
393 runtime
394 .notes(&token)
395 .unwrap()
396 .upsert_note(note.clone())
397 .await
398 .unwrap();
399 note
400 }
401
402 #[tokio::test]
403 async fn issue2992_shared_reads_deduplicate_backends_and_preserve_global_prefix_ambiguity() {
404 let main = runtime("main");
405 let secondary = runtime("comm");
406 let resolver = KgReadResolver::new(
407 &main,
408 &HashMap::from([
409 ("kg".into(), main.clone()),
410 ("comm".into(), secondary.clone()),
411 ("another-pack".into(), secondary.clone()),
412 ]),
413 );
414 assert_eq!(resolver.runtimes.len(), 2);
415 let first = Uuid::parse_str("cafe1234-0000-4000-8000-000000000001").unwrap();
416 let second = Uuid::parse_str("cafe1234-0000-4000-8000-000000000002").unwrap();
417 let remote = note(&secondary, first, "elsewhere").await;
418 let token = main.authorize(Namespace::local()).unwrap();
419 assert!(
420 matches!(resolver.by_id(&token, first, false).await.unwrap(), Some(Resolved::Note(found)) if found == remote)
421 );
422 assert_eq!(
423 resolver.prefix("cafe1234", false).await.unwrap(),
424 Some(first)
425 );
426 note(&main, first, "local").await;
428 assert_eq!(
429 resolver.prefix("cafe1234", false).await.unwrap(),
430 Some(first)
431 );
432 note(&main, second, "local").await;
433 let error = resolver.prefix("cafe1234", false).await.unwrap_err();
434 assert!(
435 matches!(error, RuntimeError::AmbiguousPrefix { matches, .. } if matches == vec![first, second])
436 );
437 assert!(resolver.prefix("deadbeef", false).await.unwrap().is_none());
438 assert!(resolver
439 .by_id(&token, Uuid::new_v4(), false)
440 .await
441 .unwrap()
442 .is_none());
443 }
444
445 #[tokio::test]
446 async fn issue2992_shared_reads_surface_backend_read_failures_instead_of_partial_results() {
447 let main = runtime("main");
448 let secondary = runtime("comm");
449 let resolver =
450 KgReadResolver::new(&main, &HashMap::from([("comm".into(), secondary.clone())]));
451 let id = Uuid::parse_str("bad01234-0000-4000-8000-000000000001").unwrap();
452 note(&main, id, "local").await;
453 let token = main.authorize(Namespace::local()).unwrap();
454 assert!(resolver.by_id(&token, id, false).await.unwrap().is_some());
455 assert_eq!(resolver.prefix("bad01234", false).await.unwrap(), Some(id));
456 let expired = std::time::Duration::ZERO;
462 assert!(matches!(
463 khive_storage::scope_request_read_deadline(expired, resolver.by_id(&token, id, false))
464 .await,
465 Err(RuntimeError::Storage(_))
466 ));
467 assert!(matches!(
468 khive_storage::scope_request_read_deadline(expired, resolver.prefix("bad01234", false))
469 .await,
470 Err(RuntimeError::Storage(_))
471 ));
472 assert!(matches!(
473 khive_storage::scope_request_read_deadline(expired, resolver.prefix("deadbeef", false))
474 .await,
475 Err(RuntimeError::Storage(_))
476 ));
477 }
478
479 #[tokio::test]
480 async fn issue2992_shared_reads_include_live_entities_and_explicit_deleted_notes() {
481 let main = runtime("main");
482 let secondary = runtime("archive");
483 let resolver = KgReadResolver::new(
484 &main,
485 &HashMap::from([("archive".into(), secondary.clone())]),
486 );
487 let token = main.authorize(Namespace::local()).unwrap();
488 let entity = Entity::new("elsewhere", "concept", "remote entity");
489 let entity_id = entity.id;
490 secondary
491 .entities(&token)
492 .unwrap()
493 .upsert_entity(entity)
494 .await
495 .unwrap();
496 assert!(
497 matches!(resolver.by_id(&token, entity_id, false).await.unwrap(), Some(Resolved::Entity(found)) if found.id == entity_id)
498 );
499 let mut deleted = Note::new("local", "observation", "deleted remote note");
500 deleted.deleted_at = Some(deleted.updated_at);
501 secondary
502 .notes(&token)
503 .unwrap()
504 .upsert_note(deleted.clone())
505 .await
506 .unwrap();
507 assert!(resolver
508 .by_id(&token, deleted.id, false)
509 .await
510 .unwrap()
511 .is_none());
512 assert!(
513 matches!(resolver.by_id(&token, deleted.id, true).await.unwrap(), Some(Resolved::Note(found)) if found == deleted)
514 );
515 let prefix = deleted.id.simple().to_string();
516 assert_eq!(
517 resolver.prefix(&prefix, true).await.unwrap(),
518 Some(deleted.id)
519 );
520 }
521}