1use std::collections::HashMap;
4use std::str::FromStr;
5
6use uuid::Uuid;
7
8use khive_score::{rrf_score, DeterministicScore};
9use khive_storage::note::{Note, NoteKind};
10use khive_storage::types::{
11 DeleteMode, Direction, EdgeSortField, GraphPath, LinkId, NeighborHit, NeighborQuery,
12 PageRequest, SortOrder, SqlStatement, TextDocument, TextFilter, TextQueryMode,
13 TextSearchRequest, TraversalRequest, VectorSearchRequest,
14};
15use khive_storage::{Edge, EdgeRelation, Entity, EntityFilter, Event};
16use khive_types::{EntityKind, SubstrateKind};
17
18use crate::error::{RuntimeError, RuntimeResult};
19use crate::runtime::KhiveRuntime;
20
21#[derive(Clone, Debug)]
23pub struct NoteSearchHit {
24 pub note_id: Uuid,
25 pub score: DeterministicScore,
26}
27
28#[derive(Clone, Debug)]
30pub enum Resolved {
31 Entity(Entity),
32 Note(Note),
33 Event(Event),
34}
35
36impl KhiveRuntime {
37 pub async fn create_entity(
41 &self,
42 namespace: Option<&str>,
43 kind: &str,
44 name: &str,
45 description: Option<&str>,
46 properties: Option<serde_json::Value>,
47 tags: Vec<String>,
48 ) -> RuntimeResult<Entity> {
49 let ns = self.ns(namespace);
50 let entity_kind = EntityKind::from_str(kind).map_err(RuntimeError::InvalidInput)?;
51 let mut entity = Entity::new(ns, entity_kind, name);
52 if let Some(d) = description {
53 entity = entity.with_description(d);
54 }
55 if let Some(p) = properties {
56 entity = entity.with_properties(p);
57 }
58 if !tags.is_empty() {
59 entity = entity.with_tags(tags);
60 }
61 self.entities(Some(ns))?
62 .upsert_entity(entity.clone())
63 .await?;
64
65 let body = match &entity.description {
66 Some(d) if !d.is_empty() => format!("{} {}", entity.name, d),
67 _ => entity.name.clone(),
68 };
69 self.text(namespace)?
70 .upsert_document(TextDocument {
71 subject_id: entity.id,
72 kind: SubstrateKind::Entity,
73 title: Some(entity.name.clone()),
74 body: body.clone(),
75 tags: entity.tags.clone(),
76 namespace: ns.to_string(),
77 metadata: entity.properties.clone(),
78 updated_at: chrono::Utc::now(),
79 })
80 .await?;
81
82 if self.config().embedding_model.is_some() {
83 let vector = self.embed(&body).await?;
84 self.vectors(namespace)?
85 .insert(entity.id, SubstrateKind::Entity, ns, vector)
86 .await?;
87 }
88
89 Ok(entity)
90 }
91
92 pub async fn get_entity(
97 &self,
98 namespace: Option<&str>,
99 id: Uuid,
100 ) -> RuntimeResult<Option<Entity>> {
101 let entity = match self.entities(namespace)?.get_entity(id).await? {
102 Some(e) => e,
103 None => return Ok(None),
104 };
105 if entity.namespace != self.ns(namespace) {
106 return Ok(None);
107 }
108 Ok(Some(entity))
109 }
110
111 pub async fn list_entities(
113 &self,
114 namespace: Option<&str>,
115 kind: Option<&str>,
116 limit: u32,
117 ) -> RuntimeResult<Vec<Entity>> {
118 let filter = EntityFilter {
119 kinds: match kind {
120 Some(k) => vec![EntityKind::from_str(k).map_err(RuntimeError::InvalidInput)?],
121 None => vec![],
122 },
123 ..Default::default()
124 };
125 let page = self
126 .entities(namespace)?
127 .query_entities(self.ns(namespace), filter, PageRequest { offset: 0, limit })
128 .await?;
129 Ok(page.items)
130 }
131
132 pub async fn link(
136 &self,
137 namespace: Option<&str>,
138 source_id: Uuid,
139 target_id: Uuid,
140 relation: EdgeRelation,
141 weight: f64,
142 ) -> RuntimeResult<Edge> {
143 let edge = Edge {
144 id: LinkId::from(Uuid::new_v4()),
145 source_id,
146 target_id,
147 relation,
148 weight,
149 created_at: chrono::Utc::now(),
150 metadata: None,
151 };
152 self.graph(namespace)?.upsert_edge(edge.clone()).await?;
153 Ok(edge)
154 }
155
156 pub async fn neighbors(
161 &self,
162 namespace: Option<&str>,
163 node_id: Uuid,
164 direction: Direction,
165 limit: Option<u32>,
166 relations: Option<Vec<EdgeRelation>>,
167 ) -> RuntimeResult<Vec<NeighborHit>> {
168 let query = NeighborQuery {
169 direction,
170 relations,
171 limit,
172 min_weight: None,
173 };
174 Ok(self.graph(namespace)?.neighbors(node_id, query).await?)
175 }
176
177 pub async fn traverse(
179 &self,
180 namespace: Option<&str>,
181 request: TraversalRequest,
182 ) -> RuntimeResult<Vec<GraphPath>> {
183 Ok(self.graph(namespace)?.traverse(request).await?)
184 }
185
186 #[allow(clippy::too_many_arguments)]
197 pub async fn create_note(
198 &self,
199 namespace: Option<&str>,
200 kind: NoteKind,
201 name: Option<&str>,
202 content: &str,
203 salience: f64,
204 properties: Option<serde_json::Value>,
205 annotates: Vec<Uuid>,
206 ) -> RuntimeResult<Note> {
207 let ns = self.ns(namespace);
208 let mut note = Note::new(ns, kind, content).with_salience(salience);
209 if let Some(n) = name {
210 note = note.with_name(n);
211 }
212 if let Some(p) = properties {
213 note = note.with_properties(p);
214 }
215 self.notes(Some(ns))?.upsert_note(note.clone()).await?;
216
217 let body = match ¬e.name {
218 Some(n) => format!("{n} {}", note.content),
219 None => note.content.clone(),
220 };
221
222 self.text_for_notes(Some(ns))?
224 .upsert_document(TextDocument {
225 subject_id: note.id,
226 kind: SubstrateKind::Note,
227 title: note.name.clone(),
228 body,
229 tags: vec![],
230 namespace: ns.to_string(),
231 metadata: note.properties.clone(),
232 updated_at: chrono::Utc::now(),
233 })
234 .await?;
235
236 if self.config().embedding_model.is_some() {
238 let vector = self.embed(¬e.content).await?;
239 self.vectors(Some(ns))?
240 .insert(note.id, SubstrateKind::Note, ns, vector)
241 .await?;
242 }
243
244 for target_id in annotates {
246 self.link(Some(ns), note.id, target_id, EdgeRelation::Annotates, 1.0)
247 .await?;
248 }
249
250 Ok(note)
251 }
252
253 pub async fn list_notes(
255 &self,
256 namespace: Option<&str>,
257 kind: Option<&str>,
258 limit: u32,
259 ) -> RuntimeResult<Vec<Note>> {
260 let note_kind = match kind {
261 Some(k) => Some(NoteKind::from_str(k).map_err(RuntimeError::InvalidInput)?),
262 None => None,
263 };
264 let page = self
265 .notes(namespace)?
266 .query_notes(
267 self.ns(namespace),
268 note_kind,
269 PageRequest { offset: 0, limit },
270 )
271 .await?;
272 Ok(page.items)
273 }
274
275 pub async fn search_notes(
285 &self,
286 namespace: Option<&str>,
287 query_text: &str,
288 query_vector: Option<Vec<f32>>,
289 limit: u32,
290 ) -> RuntimeResult<Vec<NoteSearchHit>> {
291 const RRF_K: usize = 60;
292 let candidates = limit.saturating_mul(4).max(limit);
293 let ns = self.ns(namespace).to_string();
294
295 let text_hits = self
297 .text_for_notes(namespace)?
298 .search(TextSearchRequest {
299 query: query_text.to_string(),
300 mode: TextQueryMode::Plain,
301 filter: Some(TextFilter {
302 namespaces: vec![ns.clone()],
303 ..TextFilter::default()
304 }),
305 top_k: candidates,
306 snippet_chars: 200,
307 })
308 .await?;
309
310 let vector_hits = if let Some(vec) = query_vector {
312 self.vectors(namespace)?
313 .search(VectorSearchRequest {
314 query_embedding: vec,
315 top_k: candidates,
316 namespace: Some(ns.clone()),
317 kind: Some(SubstrateKind::Note),
318 })
319 .await?
320 } else {
321 vec![]
322 };
323
324 let mut buckets: HashMap<Uuid, DeterministicScore> = HashMap::new();
326 for (i, hit) in text_hits.into_iter().enumerate() {
327 let rank = i + 1;
328 let entry = buckets.entry(hit.subject_id).or_default();
329 *entry = *entry + rrf_score(rank, RRF_K);
330 }
331 for (i, hit) in vector_hits.into_iter().enumerate() {
332 let rank = i + 1;
333 let entry = buckets.entry(hit.subject_id).or_default();
334 *entry = *entry + rrf_score(rank, RRF_K);
335 }
336
337 let candidate_ids: Vec<Uuid> = buckets.keys().copied().collect();
338 if candidate_ids.is_empty() {
339 return Ok(vec![]);
340 }
341
342 let note_store = self.notes(namespace)?;
344 let mut alive_notes: HashMap<Uuid, Note> = HashMap::new();
345 for id in &candidate_ids {
346 if let Some(note) = note_store.get_note(*id).await? {
347 if note.deleted_at.is_none() {
348 alive_notes.insert(*id, note);
349 }
350 }
351 }
352
353 if !alive_notes.is_empty() {
356 let graph = self.graph(namespace)?;
357 let mut superseded: std::collections::HashSet<Uuid> = std::collections::HashSet::new();
358 for ¬e_id in alive_notes.keys() {
359 let inbound = graph
360 .neighbors(
361 note_id,
362 NeighborQuery {
363 direction: Direction::In,
364 relations: Some(vec![EdgeRelation::Supersedes]),
365 limit: Some(1),
366 min_weight: None,
367 },
368 )
369 .await?;
370 if !inbound.is_empty() {
371 superseded.insert(note_id);
372 }
373 }
374 alive_notes.retain(|id, _| !superseded.contains(id));
375 }
376
377 let mut hits: Vec<NoteSearchHit> = buckets
379 .into_iter()
380 .filter_map(|(id, rrf)| {
381 let note = alive_notes.get(&id)?;
382 let weight = 0.5 + 0.5 * note.salience;
383 let weighted = DeterministicScore::from_f64(rrf.to_f64() * weight);
384 Some(NoteSearchHit {
385 note_id: id,
386 score: weighted,
387 })
388 })
389 .collect();
390
391 hits.sort_by(|a, b| b.score.cmp(&a.score).then(a.note_id.cmp(&b.note_id)));
392 hits.truncate(limit as usize);
393 Ok(hits)
394 }
395
396 pub async fn resolve_prefix(
403 &self,
404 namespace: Option<&str>,
405 prefix: &str,
406 ) -> RuntimeResult<Option<Uuid>> {
407 use khive_storage::types::{SqlStatement, SqlValue};
408
409 let ns = self.ns(namespace).to_string();
410 let pattern = format!("{}%", prefix);
411
412 let tables = [("entities", true), ("notes", true), ("graph_edges", false)];
413
414 let mut matches: Vec<String> = Vec::new();
415 let mut reader = self.sql().reader().await.map_err(RuntimeError::Storage)?;
416
417 for (table, has_deleted_at) in tables {
418 let deleted_filter = if has_deleted_at {
419 " AND deleted_at IS NULL"
420 } else {
421 ""
422 };
423 let sql = SqlStatement {
424 sql: format!(
425 "SELECT id FROM {table} WHERE id LIKE ?1 AND namespace = ?2{deleted_filter} LIMIT 2"
426 ),
427 params: vec![
428 SqlValue::Text(pattern.clone()),
429 SqlValue::Text(ns.clone()),
430 ],
431 label: Some("resolve_prefix".into()),
432 };
433 match reader.query_all(sql).await {
434 Ok(rows) => {
435 for row in rows {
436 if let Some(col) = row.columns.first() {
437 if let SqlValue::Text(s) = &col.value {
438 matches.push(s.clone());
439 }
440 }
441 }
442 }
443 Err(e) => {
444 let msg = e.to_string();
445 if msg.contains("no such table") {
446 continue;
447 }
448 return Err(RuntimeError::Storage(e));
449 }
450 }
451 if matches.len() > 1 {
452 break;
453 }
454 }
455
456 match matches.len() {
457 0 => Ok(None),
458 1 => {
459 let uuid = Uuid::from_str(&matches[0])
460 .map_err(|e| RuntimeError::Internal(format!("stored UUID is invalid: {e}")))?;
461 Ok(Some(uuid))
462 }
463 _ => Err(RuntimeError::Ambiguous(format!(
464 "prefix '{prefix}' matches multiple UUIDs"
465 ))),
466 }
467 }
468
469 pub async fn resolve(
474 &self,
475 namespace: Option<&str>,
476 id: Uuid,
477 ) -> RuntimeResult<Option<Resolved>> {
478 let ns = self.ns(namespace);
479
480 if let Some(entity) = self.get_entity(namespace, id).await? {
482 return Ok(Some(Resolved::Entity(entity)));
483 }
484
485 if let Some(note) = self.notes(namespace)?.get_note(id).await? {
487 if note.namespace == ns {
488 return Ok(Some(Resolved::Note(note)));
489 }
490 }
491
492 if let Some(event) = self.events(namespace)?.get_event(id).await? {
494 if event.namespace == ns {
495 return Ok(Some(Resolved::Event(event)));
496 }
497 }
498
499 Ok(None)
500 }
501
502 pub async fn delete_note(
507 &self,
508 namespace: Option<&str>,
509 id: Uuid,
510 hard: bool,
511 ) -> RuntimeResult<bool> {
512 let ns = self.ns(namespace);
513 let note_store = self.notes(namespace)?;
514 let note = match note_store.get_note(id).await? {
515 Some(n) => n,
516 None => return Ok(false),
517 };
518 if note.namespace != ns {
519 return Ok(false);
520 }
521 let mode = if hard {
522 DeleteMode::Hard
523 } else {
524 DeleteMode::Soft
525 };
526 Ok(note_store.delete_note(id, mode).await?)
527 }
528
529 pub async fn query(
537 &self,
538 namespace: Option<&str>,
539 query: &str,
540 ) -> RuntimeResult<Vec<khive_storage::types::SqlRow>> {
541 let ns = self.ns(namespace);
542 let ast = khive_query::parse_auto(query)?;
543 let opts = khive_query::CompileOptions {
544 scopes: vec![ns.to_string()],
545 ..Default::default()
546 };
547 let compiled = khive_query::compile(&ast, &opts)?;
548 let mut reader = self.sql().reader().await?;
549 let stmt = SqlStatement {
550 sql: compiled.sql,
551 params: compiled.params,
552 label: None,
553 };
554 Ok(reader.query_all(stmt).await?)
555 }
556
557 pub async fn delete_entity(
566 &self,
567 namespace: Option<&str>,
568 id: Uuid,
569 hard: bool,
570 ) -> RuntimeResult<bool> {
571 let entity = match self.entities(namespace)?.get_entity(id).await? {
572 Some(e) => e,
573 None => return Ok(false),
574 };
575 if entity.namespace != self.ns(namespace) {
576 return Ok(false);
577 }
578 let mode = if hard {
579 DeleteMode::Hard
580 } else {
581 DeleteMode::Soft
582 };
583
584 if hard {
586 let graph = self.graph(namespace)?;
587 for direction in [Direction::Out, Direction::In] {
588 let hits = graph
589 .neighbors(
590 id,
591 NeighborQuery {
592 direction,
593 relations: None,
594 limit: None,
595 min_weight: None,
596 },
597 )
598 .await?;
599 for hit in hits {
600 graph.delete_edge(LinkId::from(hit.edge_id)).await?;
601 }
602 }
603 self.remove_from_indexes(namespace, id).await?;
604 }
605
606 Ok(self.entities(namespace)?.delete_entity(id, mode).await?)
607 }
608
609 pub async fn count_entities(
611 &self,
612 namespace: Option<&str>,
613 kind: Option<&str>,
614 ) -> RuntimeResult<u64> {
615 let filter = EntityFilter {
616 kinds: match kind {
617 Some(k) => vec![EntityKind::from_str(k).map_err(RuntimeError::InvalidInput)?],
618 None => vec![],
619 },
620 ..Default::default()
621 };
622 Ok(self
623 .entities(namespace)?
624 .count_entities(self.ns(namespace), filter)
625 .await?)
626 }
627
628 pub async fn get_edge(
632 &self,
633 namespace: Option<&str>,
634 edge_id: Uuid,
635 ) -> RuntimeResult<Option<Edge>> {
636 Ok(self
637 .graph(namespace)?
638 .get_edge(LinkId::from(edge_id))
639 .await?)
640 }
641
642 pub async fn list_edges(
644 &self,
645 namespace: Option<&str>,
646 filter: crate::curation::EdgeListFilter,
647 limit: u32,
648 ) -> RuntimeResult<Vec<Edge>> {
649 let limit = limit.clamp(1, 1000);
650 let page = self
651 .graph(namespace)?
652 .query_edges(
653 filter.into(),
654 vec![SortOrder {
655 field: EdgeSortField::CreatedAt,
656 direction: khive_storage::types::SortDirection::Asc,
657 }],
658 PageRequest { offset: 0, limit },
659 )
660 .await?;
661 Ok(page.items)
662 }
663
664 pub async fn update_edge(
666 &self,
667 namespace: Option<&str>,
668 edge_id: Uuid,
669 relation: Option<EdgeRelation>,
670 weight: Option<f64>,
671 ) -> RuntimeResult<Edge> {
672 let graph = self.graph(namespace)?;
673 let mut edge = graph
674 .get_edge(LinkId::from(edge_id))
675 .await?
676 .ok_or_else(|| crate::RuntimeError::NotFound(format!("edge {edge_id}")))?;
677
678 if let Some(r) = relation {
679 edge.relation = r;
680 }
681 if let Some(w) = weight {
682 edge.weight = w.clamp(0.0, 1.0);
683 }
684
685 graph.upsert_edge(edge.clone()).await?;
686 Ok(edge)
687 }
688
689 pub async fn delete_edge(&self, namespace: Option<&str>, edge_id: Uuid) -> RuntimeResult<bool> {
691 Ok(self
692 .graph(namespace)?
693 .delete_edge(LinkId::from(edge_id))
694 .await?)
695 }
696
697 pub async fn count_edges(
699 &self,
700 namespace: Option<&str>,
701 filter: crate::curation::EdgeListFilter,
702 ) -> RuntimeResult<u64> {
703 Ok(self.graph(namespace)?.count_edges(filter.into()).await?)
704 }
705}
706
707#[cfg(test)]
708mod tests {
709 use super::*;
710 use crate::curation::EdgeListFilter;
711 use crate::runtime::KhiveRuntime;
712
713 fn rt() -> KhiveRuntime {
714 KhiveRuntime::memory().unwrap()
715 }
716
717 #[tokio::test]
718 async fn update_edge_changes_weight() {
719 let rt = rt();
720 let a = rt
721 .create_entity(None, "concept", "A", None, None, vec![])
722 .await
723 .unwrap();
724 let b = rt
725 .create_entity(None, "concept", "B", None, None, vec![])
726 .await
727 .unwrap();
728 let edge = rt
729 .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
730 .await
731 .unwrap();
732 let edge_id: Uuid = edge.id.into();
733
734 let updated = rt
735 .update_edge(None, edge_id, None, Some(0.5))
736 .await
737 .unwrap();
738 assert!((updated.weight - 0.5).abs() < 0.001);
739 }
740
741 #[tokio::test]
742 async fn update_edge_changes_relation() {
743 let rt = rt();
744 let a = rt
745 .create_entity(None, "concept", "A", None, None, vec![])
746 .await
747 .unwrap();
748 let b = rt
749 .create_entity(None, "concept", "B", None, None, vec![])
750 .await
751 .unwrap();
752 let edge = rt
753 .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
754 .await
755 .unwrap();
756 let edge_id: Uuid = edge.id.into();
757
758 let updated = rt
759 .update_edge(None, edge_id, Some(EdgeRelation::VariantOf), None)
760 .await
761 .unwrap();
762 assert_eq!(updated.relation, EdgeRelation::VariantOf);
763 }
764
765 #[tokio::test]
766 async fn list_edges_filters_by_relation() {
767 let rt = rt();
768 let a = rt
769 .create_entity(None, "concept", "A", None, None, vec![])
770 .await
771 .unwrap();
772 let b = rt
773 .create_entity(None, "concept", "B", None, None, vec![])
774 .await
775 .unwrap();
776 let c = rt
777 .create_entity(None, "concept", "C", None, None, vec![])
778 .await
779 .unwrap();
780
781 rt.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
782 .await
783 .unwrap();
784 rt.link(None, a.id, c.id, EdgeRelation::DependsOn, 1.0)
785 .await
786 .unwrap();
787
788 let filter = EdgeListFilter {
789 relations: vec![EdgeRelation::Extends],
790 ..Default::default()
791 };
792 let edges = rt.list_edges(None, filter, 100).await.unwrap();
793 assert_eq!(edges.len(), 1);
794 assert_eq!(edges[0].relation, EdgeRelation::Extends);
795 }
796
797 #[tokio::test]
798 async fn list_edges_filters_by_source() {
799 let rt = rt();
800 let a = rt
801 .create_entity(None, "concept", "A", None, None, vec![])
802 .await
803 .unwrap();
804 let b = rt
805 .create_entity(None, "concept", "B", None, None, vec![])
806 .await
807 .unwrap();
808 let c = rt
809 .create_entity(None, "concept", "C", None, None, vec![])
810 .await
811 .unwrap();
812 let d = rt
813 .create_entity(None, "concept", "D", None, None, vec![])
814 .await
815 .unwrap();
816
817 rt.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
818 .await
819 .unwrap();
820 rt.link(None, c.id, d.id, EdgeRelation::Extends, 1.0)
821 .await
822 .unwrap();
823
824 let filter = EdgeListFilter {
825 source_id: Some(a.id),
826 ..Default::default()
827 };
828 let edges = rt.list_edges(None, filter, 100).await.unwrap();
829 assert_eq!(edges.len(), 1);
830 let src: Uuid = edges[0].source_id;
831 assert_eq!(src, a.id);
832 }
833
834 #[tokio::test]
835 async fn delete_edge_removes_from_storage() {
836 let rt = rt();
837 let a = rt
838 .create_entity(None, "concept", "A", None, None, vec![])
839 .await
840 .unwrap();
841 let b = rt
842 .create_entity(None, "concept", "B", None, None, vec![])
843 .await
844 .unwrap();
845 let edge = rt
846 .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
847 .await
848 .unwrap();
849 let edge_id: Uuid = edge.id.into();
850
851 let deleted = rt.delete_edge(None, edge_id).await.unwrap();
852 assert!(deleted);
853
854 let fetched = rt.get_edge(None, edge_id).await.unwrap();
855 assert!(fetched.is_none(), "edge should be gone after delete");
856 }
857
858 #[tokio::test]
859 async fn count_edges_matches_filter() {
860 let rt = rt();
861 let a = rt
862 .create_entity(None, "concept", "A", None, None, vec![])
863 .await
864 .unwrap();
865 let b = rt
866 .create_entity(None, "concept", "B", None, None, vec![])
867 .await
868 .unwrap();
869 let c = rt
870 .create_entity(None, "concept", "C", None, None, vec![])
871 .await
872 .unwrap();
873
874 rt.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
875 .await
876 .unwrap();
877 rt.link(None, a.id, c.id, EdgeRelation::DependsOn, 1.0)
878 .await
879 .unwrap();
880
881 let all = rt
882 .count_edges(None, EdgeListFilter::default())
883 .await
884 .unwrap();
885 assert_eq!(all, 2);
886
887 let just_extends = rt
888 .count_edges(
889 None,
890 EdgeListFilter {
891 relations: vec![EdgeRelation::Extends],
892 ..Default::default()
893 },
894 )
895 .await
896 .unwrap();
897 assert_eq!(just_extends, 1);
898 }
899
900 #[tokio::test]
901 async fn get_entity_namespace_isolation() {
902 let rt = rt();
903 let entity = rt
904 .create_entity(Some("ns-a"), "concept", "Alpha", None, None, vec![])
905 .await
906 .unwrap();
907
908 let found = rt.get_entity(Some("ns-a"), entity.id).await.unwrap();
910 assert!(found.is_some(), "should be visible in its own namespace");
911
912 let not_found = rt.get_entity(Some("ns-b"), entity.id).await.unwrap();
914 assert!(
915 not_found.is_none(),
916 "should not be visible across namespaces"
917 );
918 }
919
920 #[tokio::test]
921 async fn delete_entity_namespace_isolation() {
922 let rt = rt();
923 let entity = rt
924 .create_entity(Some("ns-a"), "concept", "Beta", None, None, vec![])
925 .await
926 .unwrap();
927
928 let deleted = rt
930 .delete_entity(Some("ns-b"), entity.id, true)
931 .await
932 .unwrap();
933 assert!(!deleted, "cross-namespace delete must return false");
934
935 let still_there = rt.get_entity(Some("ns-a"), entity.id).await.unwrap();
937 assert!(
938 still_there.is_some(),
939 "entity must survive cross-ns delete attempt"
940 );
941
942 let deleted_ok = rt
944 .delete_entity(Some("ns-a"), entity.id, true)
945 .await
946 .unwrap();
947 assert!(deleted_ok, "same-namespace delete must succeed");
948 }
949
950 #[tokio::test]
953 async fn create_note_indexes_into_fts5() {
954 let rt = rt();
955 let note = rt
956 .create_note(
957 None,
958 khive_storage::NoteKind::Observation,
959 None,
960 "FlashAttention reduces memory by using tiling",
961 0.8,
962 None,
963 vec![],
964 )
965 .await
966 .unwrap();
967
968 let ns = rt.ns(None).to_string();
970 let hits = rt
971 .text_for_notes(None)
972 .unwrap()
973 .search(khive_storage::types::TextSearchRequest {
974 query: "FlashAttention".to_string(),
975 mode: khive_storage::types::TextQueryMode::Plain,
976 filter: Some(khive_storage::types::TextFilter {
977 namespaces: vec![ns],
978 ..Default::default()
979 }),
980 top_k: 10,
981 snippet_chars: 100,
982 })
983 .await
984 .unwrap();
985
986 assert!(
987 hits.iter().any(|h| h.subject_id == note.id),
988 "note should be indexed in FTS5 after create"
989 );
990 }
991
992 #[tokio::test]
993 async fn create_note_with_properties() {
994 let rt = rt();
995 let props = serde_json::json!({"source": "arxiv:2205.14135"});
996 let note = rt
997 .create_note(
998 None,
999 khive_storage::NoteKind::Insight,
1000 None,
1001 "FlashAttention is IO-aware",
1002 0.9,
1003 Some(props.clone()),
1004 vec![],
1005 )
1006 .await
1007 .unwrap();
1008
1009 assert_eq!(note.properties.as_ref().unwrap(), &props);
1010 }
1011
1012 #[tokio::test]
1013 async fn create_note_creates_annotates_edges() {
1014 let rt = rt();
1015 let entity = rt
1016 .create_entity(None, "concept", "FlashAttention", None, None, vec![])
1017 .await
1018 .unwrap();
1019
1020 let note = rt
1021 .create_note(
1022 None,
1023 khive_storage::NoteKind::Observation,
1024 None,
1025 "FlashAttention uses SRAM tiling for memory efficiency",
1026 0.9,
1027 None,
1028 vec![entity.id],
1029 )
1030 .await
1031 .unwrap();
1032
1033 let out_neighbors = rt
1035 .neighbors(
1036 None,
1037 note.id,
1038 Direction::Out,
1039 None,
1040 Some(vec![EdgeRelation::Annotates]),
1041 )
1042 .await
1043 .unwrap();
1044 assert_eq!(out_neighbors.len(), 1);
1045 assert_eq!(out_neighbors[0].node_id, entity.id);
1046 assert_eq!(out_neighbors[0].relation, EdgeRelation::Annotates);
1047
1048 let in_neighbors = rt
1050 .neighbors(
1051 None,
1052 entity.id,
1053 Direction::In,
1054 None,
1055 Some(vec![EdgeRelation::Annotates]),
1056 )
1057 .await
1058 .unwrap();
1059 assert_eq!(in_neighbors.len(), 1);
1060 assert_eq!(in_neighbors[0].node_id, note.id);
1061 }
1062
1063 #[tokio::test]
1064 async fn neighbors_without_relation_filter_returns_all() {
1065 let rt = rt();
1066 let a = rt
1067 .create_entity(None, "concept", "A", None, None, vec![])
1068 .await
1069 .unwrap();
1070 let b = rt
1071 .create_entity(None, "concept", "B", None, None, vec![])
1072 .await
1073 .unwrap();
1074 let c = rt
1075 .create_entity(None, "concept", "C", None, None, vec![])
1076 .await
1077 .unwrap();
1078
1079 rt.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1080 .await
1081 .unwrap();
1082 rt.link(None, a.id, c.id, EdgeRelation::DependsOn, 1.0)
1083 .await
1084 .unwrap();
1085
1086 let all = rt
1087 .neighbors(None, a.id, Direction::Out, None, None)
1088 .await
1089 .unwrap();
1090 assert_eq!(all.len(), 2);
1091 }
1092
1093 #[tokio::test]
1094 async fn neighbors_with_relation_filter_returns_subset() {
1095 let rt = rt();
1096 let a = rt
1097 .create_entity(None, "concept", "A", None, None, vec![])
1098 .await
1099 .unwrap();
1100 let b = rt
1101 .create_entity(None, "concept", "B", None, None, vec![])
1102 .await
1103 .unwrap();
1104 let c = rt
1105 .create_entity(None, "concept", "C", None, None, vec![])
1106 .await
1107 .unwrap();
1108
1109 rt.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1110 .await
1111 .unwrap();
1112 rt.link(None, a.id, c.id, EdgeRelation::DependsOn, 1.0)
1113 .await
1114 .unwrap();
1115
1116 let filtered = rt
1117 .neighbors(
1118 None,
1119 a.id,
1120 Direction::Out,
1121 None,
1122 Some(vec![EdgeRelation::Extends]),
1123 )
1124 .await
1125 .unwrap();
1126 assert_eq!(filtered.len(), 1);
1127 assert_eq!(filtered[0].node_id, b.id);
1128 assert_eq!(filtered[0].relation, EdgeRelation::Extends);
1129 }
1130
1131 #[tokio::test]
1132 async fn search_notes_returns_relevant_note() {
1133 let rt = rt();
1134 rt.create_note(
1135 None,
1136 khive_storage::NoteKind::Observation,
1137 None,
1138 "GQA reduces KV cache memory for large models",
1139 0.8,
1140 None,
1141 vec![],
1142 )
1143 .await
1144 .unwrap();
1145
1146 let results = rt
1147 .search_notes(None, "GQA KV cache", None, 10)
1148 .await
1149 .unwrap();
1150
1151 assert!(!results.is_empty(), "search should return the indexed note");
1152 }
1153
1154 #[tokio::test]
1155 async fn search_notes_excludes_soft_deleted() {
1156 let rt = rt();
1157 let note = rt
1158 .create_note(
1159 None,
1160 khive_storage::NoteKind::Observation,
1161 None,
1162 "RoPE positional encoding rotary embeddings",
1163 0.7,
1164 None,
1165 vec![],
1166 )
1167 .await
1168 .unwrap();
1169
1170 rt.notes(None)
1172 .unwrap()
1173 .delete_note(note.id, DeleteMode::Soft)
1174 .await
1175 .unwrap();
1176
1177 let results = rt
1178 .search_notes(None, "RoPE rotary positional", None, 10)
1179 .await
1180 .unwrap();
1181
1182 assert!(
1183 results.iter().all(|h| h.note_id != note.id),
1184 "soft-deleted note should be excluded from search"
1185 );
1186 }
1187
1188 #[tokio::test]
1189 async fn resolve_returns_entity() {
1190 let rt = rt();
1191 let entity = rt
1192 .create_entity(None, "concept", "LoRA", None, None, vec![])
1193 .await
1194 .unwrap();
1195
1196 let resolved = rt.resolve(None, entity.id).await.unwrap();
1197 match resolved {
1198 Some(Resolved::Entity(e)) => assert_eq!(e.id, entity.id),
1199 other => panic!("expected Resolved::Entity, got {:?}", other),
1200 }
1201 }
1202
1203 #[tokio::test]
1204 async fn resolve_returns_note() {
1205 let rt = rt();
1206 let note = rt
1207 .create_note(
1208 None,
1209 khive_storage::NoteKind::Observation,
1210 None,
1211 "LoRA fine-tunes LLMs with low-rank adapters",
1212 0.85,
1213 None,
1214 vec![],
1215 )
1216 .await
1217 .unwrap();
1218
1219 let resolved = rt.resolve(None, note.id).await.unwrap();
1220 match resolved {
1221 Some(Resolved::Note(n)) => assert_eq!(n.id, note.id),
1222 other => panic!("expected Resolved::Note, got {:?}", other),
1223 }
1224 }
1225
1226 #[tokio::test]
1227 async fn resolve_returns_none_for_unknown_uuid() {
1228 let rt = rt();
1229 let unknown = Uuid::new_v4();
1230 let resolved = rt.resolve(None, unknown).await.unwrap();
1231 assert!(resolved.is_none(), "unknown UUID should resolve to None");
1232 }
1233
1234 #[tokio::test]
1235 async fn resolve_prefix_finds_entity_in_own_namespace() {
1236 let rt = rt();
1237 let entity = rt
1238 .create_entity(None, "concept", "PrefixTest", None, None, vec![])
1239 .await
1240 .unwrap();
1241 let prefix = &entity.id.to_string()[..8];
1242
1243 let resolved = rt.resolve_prefix(None, prefix).await.unwrap();
1244 assert_eq!(resolved, Some(entity.id));
1245 }
1246
1247 #[tokio::test]
1248 async fn resolve_prefix_invisible_across_namespaces() {
1249 let rt = rt();
1250 let entity = rt
1251 .create_entity(Some("ns_a"), "concept", "Invisible", None, None, vec![])
1252 .await
1253 .unwrap();
1254 let prefix = &entity.id.to_string()[..8];
1255
1256 let resolved = rt.resolve_prefix(Some("ns_b"), prefix).await.unwrap();
1258 assert_eq!(resolved, None);
1259 }
1260
1261 #[tokio::test]
1262 async fn resolve_prefix_ambiguous_same_namespace() {
1263 use khive_storage::entity::Entity;
1264 use khive_types::EntityKind;
1265
1266 let rt = rt();
1267 let id_a = Uuid::parse_str("aabbccdd-1111-4000-8000-000000000001").unwrap();
1269 let id_b = Uuid::parse_str("aabbccdd-2222-4000-8000-000000000002").unwrap();
1270
1271 let mut entity_a = Entity::new("local", EntityKind::Concept, "AmbigA");
1272 entity_a.id = id_a;
1273 let mut entity_b = Entity::new("local", EntityKind::Concept, "AmbigB");
1274 entity_b.id = id_b;
1275
1276 let store = rt.entities(None).unwrap();
1277 store.upsert_entity(entity_a).await.unwrap();
1278 store.upsert_entity(entity_b).await.unwrap();
1279
1280 let result = rt.resolve_prefix(None, "aabbccdd").await;
1281 assert!(
1282 result.is_err(),
1283 "shared 8-char prefix must return Ambiguous error"
1284 );
1285 }
1286}