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(
401 &self,
402 namespace: Option<&str>,
403 id: Uuid,
404 ) -> RuntimeResult<Option<Resolved>> {
405 let ns = self.ns(namespace);
406
407 if let Some(entity) = self.get_entity(namespace, id).await? {
409 return Ok(Some(Resolved::Entity(entity)));
410 }
411
412 if let Some(note) = self.notes(namespace)?.get_note(id).await? {
414 if note.namespace == ns {
415 return Ok(Some(Resolved::Note(note)));
416 }
417 }
418
419 if let Some(event) = self.events(namespace)?.get_event(id).await? {
421 if event.namespace == ns {
422 return Ok(Some(Resolved::Event(event)));
423 }
424 }
425
426 Ok(None)
427 }
428
429 pub async fn delete_note(
434 &self,
435 namespace: Option<&str>,
436 id: Uuid,
437 hard: bool,
438 ) -> RuntimeResult<bool> {
439 let ns = self.ns(namespace);
440 let note_store = self.notes(namespace)?;
441 let note = match note_store.get_note(id).await? {
442 Some(n) => n,
443 None => return Ok(false),
444 };
445 if note.namespace != ns {
446 return Ok(false);
447 }
448 let mode = if hard {
449 DeleteMode::Hard
450 } else {
451 DeleteMode::Soft
452 };
453 Ok(note_store.delete_note(id, mode).await?)
454 }
455
456 pub async fn query(
464 &self,
465 namespace: Option<&str>,
466 query: &str,
467 ) -> RuntimeResult<Vec<khive_storage::types::SqlRow>> {
468 let ns = self.ns(namespace);
469 let ast = khive_query::parse_auto(query)?;
470 let opts = khive_query::CompileOptions {
471 scopes: vec![ns.to_string()],
472 ..Default::default()
473 };
474 let compiled = khive_query::compile(&ast, &opts)?;
475 let mut reader = self.sql().reader().await?;
476 let stmt = SqlStatement {
477 sql: compiled.sql,
478 params: compiled.params,
479 label: None,
480 };
481 Ok(reader.query_all(stmt).await?)
482 }
483
484 pub async fn delete_entity(
493 &self,
494 namespace: Option<&str>,
495 id: Uuid,
496 hard: bool,
497 ) -> RuntimeResult<bool> {
498 let entity = match self.entities(namespace)?.get_entity(id).await? {
499 Some(e) => e,
500 None => return Ok(false),
501 };
502 if entity.namespace != self.ns(namespace) {
503 return Ok(false);
504 }
505 let mode = if hard {
506 DeleteMode::Hard
507 } else {
508 DeleteMode::Soft
509 };
510
511 if hard {
513 let graph = self.graph(namespace)?;
514 for direction in [Direction::Out, Direction::In] {
515 let hits = graph
516 .neighbors(
517 id,
518 NeighborQuery {
519 direction,
520 relations: None,
521 limit: None,
522 min_weight: None,
523 },
524 )
525 .await?;
526 for hit in hits {
527 graph.delete_edge(LinkId::from(hit.edge_id)).await?;
528 }
529 }
530 self.remove_from_indexes(namespace, id).await?;
531 }
532
533 Ok(self.entities(namespace)?.delete_entity(id, mode).await?)
534 }
535
536 pub async fn count_entities(
538 &self,
539 namespace: Option<&str>,
540 kind: Option<&str>,
541 ) -> RuntimeResult<u64> {
542 let filter = EntityFilter {
543 kinds: match kind {
544 Some(k) => vec![EntityKind::from_str(k).map_err(RuntimeError::InvalidInput)?],
545 None => vec![],
546 },
547 ..Default::default()
548 };
549 Ok(self
550 .entities(namespace)?
551 .count_entities(self.ns(namespace), filter)
552 .await?)
553 }
554
555 pub async fn get_edge(
559 &self,
560 namespace: Option<&str>,
561 edge_id: Uuid,
562 ) -> RuntimeResult<Option<Edge>> {
563 Ok(self
564 .graph(namespace)?
565 .get_edge(LinkId::from(edge_id))
566 .await?)
567 }
568
569 pub async fn list_edges(
571 &self,
572 namespace: Option<&str>,
573 filter: crate::curation::EdgeListFilter,
574 limit: u32,
575 ) -> RuntimeResult<Vec<Edge>> {
576 let limit = limit.clamp(1, 1000);
577 let page = self
578 .graph(namespace)?
579 .query_edges(
580 filter.into(),
581 vec![SortOrder {
582 field: EdgeSortField::CreatedAt,
583 direction: khive_storage::types::SortDirection::Asc,
584 }],
585 PageRequest { offset: 0, limit },
586 )
587 .await?;
588 Ok(page.items)
589 }
590
591 pub async fn update_edge(
593 &self,
594 namespace: Option<&str>,
595 edge_id: Uuid,
596 relation: Option<EdgeRelation>,
597 weight: Option<f64>,
598 ) -> RuntimeResult<Edge> {
599 let graph = self.graph(namespace)?;
600 let mut edge = graph
601 .get_edge(LinkId::from(edge_id))
602 .await?
603 .ok_or_else(|| crate::RuntimeError::NotFound(format!("edge {edge_id}")))?;
604
605 if let Some(r) = relation {
606 edge.relation = r;
607 }
608 if let Some(w) = weight {
609 edge.weight = w.clamp(0.0, 1.0);
610 }
611
612 graph.upsert_edge(edge.clone()).await?;
613 Ok(edge)
614 }
615
616 pub async fn delete_edge(&self, namespace: Option<&str>, edge_id: Uuid) -> RuntimeResult<bool> {
618 Ok(self
619 .graph(namespace)?
620 .delete_edge(LinkId::from(edge_id))
621 .await?)
622 }
623
624 pub async fn count_edges(
626 &self,
627 namespace: Option<&str>,
628 filter: crate::curation::EdgeListFilter,
629 ) -> RuntimeResult<u64> {
630 Ok(self.graph(namespace)?.count_edges(filter.into()).await?)
631 }
632}
633
634#[cfg(test)]
635mod tests {
636 use super::*;
637 use crate::curation::EdgeListFilter;
638 use crate::runtime::KhiveRuntime;
639
640 fn rt() -> KhiveRuntime {
641 KhiveRuntime::memory().unwrap()
642 }
643
644 #[tokio::test]
645 async fn update_edge_changes_weight() {
646 let rt = rt();
647 let a = rt
648 .create_entity(None, "concept", "A", None, None, vec![])
649 .await
650 .unwrap();
651 let b = rt
652 .create_entity(None, "concept", "B", None, None, vec![])
653 .await
654 .unwrap();
655 let edge = rt
656 .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
657 .await
658 .unwrap();
659 let edge_id: Uuid = edge.id.into();
660
661 let updated = rt
662 .update_edge(None, edge_id, None, Some(0.5))
663 .await
664 .unwrap();
665 assert!((updated.weight - 0.5).abs() < 0.001);
666 }
667
668 #[tokio::test]
669 async fn update_edge_changes_relation() {
670 let rt = rt();
671 let a = rt
672 .create_entity(None, "concept", "A", None, None, vec![])
673 .await
674 .unwrap();
675 let b = rt
676 .create_entity(None, "concept", "B", None, None, vec![])
677 .await
678 .unwrap();
679 let edge = rt
680 .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
681 .await
682 .unwrap();
683 let edge_id: Uuid = edge.id.into();
684
685 let updated = rt
686 .update_edge(None, edge_id, Some(EdgeRelation::VariantOf), None)
687 .await
688 .unwrap();
689 assert_eq!(updated.relation, EdgeRelation::VariantOf);
690 }
691
692 #[tokio::test]
693 async fn list_edges_filters_by_relation() {
694 let rt = rt();
695 let a = rt
696 .create_entity(None, "concept", "A", None, None, vec![])
697 .await
698 .unwrap();
699 let b = rt
700 .create_entity(None, "concept", "B", None, None, vec![])
701 .await
702 .unwrap();
703 let c = rt
704 .create_entity(None, "concept", "C", None, None, vec![])
705 .await
706 .unwrap();
707
708 rt.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
709 .await
710 .unwrap();
711 rt.link(None, a.id, c.id, EdgeRelation::DependsOn, 1.0)
712 .await
713 .unwrap();
714
715 let filter = EdgeListFilter {
716 relations: vec![EdgeRelation::Extends],
717 ..Default::default()
718 };
719 let edges = rt.list_edges(None, filter, 100).await.unwrap();
720 assert_eq!(edges.len(), 1);
721 assert_eq!(edges[0].relation, EdgeRelation::Extends);
722 }
723
724 #[tokio::test]
725 async fn list_edges_filters_by_source() {
726 let rt = rt();
727 let a = rt
728 .create_entity(None, "concept", "A", None, None, vec![])
729 .await
730 .unwrap();
731 let b = rt
732 .create_entity(None, "concept", "B", None, None, vec![])
733 .await
734 .unwrap();
735 let c = rt
736 .create_entity(None, "concept", "C", None, None, vec![])
737 .await
738 .unwrap();
739 let d = rt
740 .create_entity(None, "concept", "D", None, None, vec![])
741 .await
742 .unwrap();
743
744 rt.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
745 .await
746 .unwrap();
747 rt.link(None, c.id, d.id, EdgeRelation::Extends, 1.0)
748 .await
749 .unwrap();
750
751 let filter = EdgeListFilter {
752 source_id: Some(a.id),
753 ..Default::default()
754 };
755 let edges = rt.list_edges(None, filter, 100).await.unwrap();
756 assert_eq!(edges.len(), 1);
757 let src: Uuid = edges[0].source_id;
758 assert_eq!(src, a.id);
759 }
760
761 #[tokio::test]
762 async fn delete_edge_removes_from_storage() {
763 let rt = rt();
764 let a = rt
765 .create_entity(None, "concept", "A", None, None, vec![])
766 .await
767 .unwrap();
768 let b = rt
769 .create_entity(None, "concept", "B", None, None, vec![])
770 .await
771 .unwrap();
772 let edge = rt
773 .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
774 .await
775 .unwrap();
776 let edge_id: Uuid = edge.id.into();
777
778 let deleted = rt.delete_edge(None, edge_id).await.unwrap();
779 assert!(deleted);
780
781 let fetched = rt.get_edge(None, edge_id).await.unwrap();
782 assert!(fetched.is_none(), "edge should be gone after delete");
783 }
784
785 #[tokio::test]
786 async fn count_edges_matches_filter() {
787 let rt = rt();
788 let a = rt
789 .create_entity(None, "concept", "A", None, None, vec![])
790 .await
791 .unwrap();
792 let b = rt
793 .create_entity(None, "concept", "B", None, None, vec![])
794 .await
795 .unwrap();
796 let c = rt
797 .create_entity(None, "concept", "C", None, None, vec![])
798 .await
799 .unwrap();
800
801 rt.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
802 .await
803 .unwrap();
804 rt.link(None, a.id, c.id, EdgeRelation::DependsOn, 1.0)
805 .await
806 .unwrap();
807
808 let all = rt
809 .count_edges(None, EdgeListFilter::default())
810 .await
811 .unwrap();
812 assert_eq!(all, 2);
813
814 let just_extends = rt
815 .count_edges(
816 None,
817 EdgeListFilter {
818 relations: vec![EdgeRelation::Extends],
819 ..Default::default()
820 },
821 )
822 .await
823 .unwrap();
824 assert_eq!(just_extends, 1);
825 }
826
827 #[tokio::test]
828 async fn get_entity_namespace_isolation() {
829 let rt = rt();
830 let entity = rt
831 .create_entity(Some("ns-a"), "concept", "Alpha", None, None, vec![])
832 .await
833 .unwrap();
834
835 let found = rt.get_entity(Some("ns-a"), entity.id).await.unwrap();
837 assert!(found.is_some(), "should be visible in its own namespace");
838
839 let not_found = rt.get_entity(Some("ns-b"), entity.id).await.unwrap();
841 assert!(
842 not_found.is_none(),
843 "should not be visible across namespaces"
844 );
845 }
846
847 #[tokio::test]
848 async fn delete_entity_namespace_isolation() {
849 let rt = rt();
850 let entity = rt
851 .create_entity(Some("ns-a"), "concept", "Beta", None, None, vec![])
852 .await
853 .unwrap();
854
855 let deleted = rt
857 .delete_entity(Some("ns-b"), entity.id, true)
858 .await
859 .unwrap();
860 assert!(!deleted, "cross-namespace delete must return false");
861
862 let still_there = rt.get_entity(Some("ns-a"), entity.id).await.unwrap();
864 assert!(
865 still_there.is_some(),
866 "entity must survive cross-ns delete attempt"
867 );
868
869 let deleted_ok = rt
871 .delete_entity(Some("ns-a"), entity.id, true)
872 .await
873 .unwrap();
874 assert!(deleted_ok, "same-namespace delete must succeed");
875 }
876
877 #[tokio::test]
880 async fn create_note_indexes_into_fts5() {
881 let rt = rt();
882 let note = rt
883 .create_note(
884 None,
885 khive_storage::NoteKind::Observation,
886 None,
887 "FlashAttention reduces memory by using tiling",
888 0.8,
889 None,
890 vec![],
891 )
892 .await
893 .unwrap();
894
895 let ns = rt.ns(None).to_string();
897 let hits = rt
898 .text_for_notes(None)
899 .unwrap()
900 .search(khive_storage::types::TextSearchRequest {
901 query: "FlashAttention".to_string(),
902 mode: khive_storage::types::TextQueryMode::Plain,
903 filter: Some(khive_storage::types::TextFilter {
904 namespaces: vec![ns],
905 ..Default::default()
906 }),
907 top_k: 10,
908 snippet_chars: 100,
909 })
910 .await
911 .unwrap();
912
913 assert!(
914 hits.iter().any(|h| h.subject_id == note.id),
915 "note should be indexed in FTS5 after create"
916 );
917 }
918
919 #[tokio::test]
920 async fn create_note_with_properties() {
921 let rt = rt();
922 let props = serde_json::json!({"source": "arxiv:2205.14135"});
923 let note = rt
924 .create_note(
925 None,
926 khive_storage::NoteKind::Insight,
927 None,
928 "FlashAttention is IO-aware",
929 0.9,
930 Some(props.clone()),
931 vec![],
932 )
933 .await
934 .unwrap();
935
936 assert_eq!(note.properties.as_ref().unwrap(), &props);
937 }
938
939 #[tokio::test]
940 async fn create_note_creates_annotates_edges() {
941 let rt = rt();
942 let entity = rt
943 .create_entity(None, "concept", "FlashAttention", None, None, vec![])
944 .await
945 .unwrap();
946
947 let note = rt
948 .create_note(
949 None,
950 khive_storage::NoteKind::Observation,
951 None,
952 "FlashAttention uses SRAM tiling for memory efficiency",
953 0.9,
954 None,
955 vec![entity.id],
956 )
957 .await
958 .unwrap();
959
960 let out_neighbors = rt
962 .neighbors(
963 None,
964 note.id,
965 Direction::Out,
966 None,
967 Some(vec![EdgeRelation::Annotates]),
968 )
969 .await
970 .unwrap();
971 assert_eq!(out_neighbors.len(), 1);
972 assert_eq!(out_neighbors[0].node_id, entity.id);
973 assert_eq!(out_neighbors[0].relation, EdgeRelation::Annotates);
974
975 let in_neighbors = rt
977 .neighbors(
978 None,
979 entity.id,
980 Direction::In,
981 None,
982 Some(vec![EdgeRelation::Annotates]),
983 )
984 .await
985 .unwrap();
986 assert_eq!(in_neighbors.len(), 1);
987 assert_eq!(in_neighbors[0].node_id, note.id);
988 }
989
990 #[tokio::test]
991 async fn neighbors_without_relation_filter_returns_all() {
992 let rt = rt();
993 let a = rt
994 .create_entity(None, "concept", "A", None, None, vec![])
995 .await
996 .unwrap();
997 let b = rt
998 .create_entity(None, "concept", "B", None, None, vec![])
999 .await
1000 .unwrap();
1001 let c = rt
1002 .create_entity(None, "concept", "C", None, None, vec![])
1003 .await
1004 .unwrap();
1005
1006 rt.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1007 .await
1008 .unwrap();
1009 rt.link(None, a.id, c.id, EdgeRelation::DependsOn, 1.0)
1010 .await
1011 .unwrap();
1012
1013 let all = rt
1014 .neighbors(None, a.id, Direction::Out, None, None)
1015 .await
1016 .unwrap();
1017 assert_eq!(all.len(), 2);
1018 }
1019
1020 #[tokio::test]
1021 async fn neighbors_with_relation_filter_returns_subset() {
1022 let rt = rt();
1023 let a = rt
1024 .create_entity(None, "concept", "A", None, None, vec![])
1025 .await
1026 .unwrap();
1027 let b = rt
1028 .create_entity(None, "concept", "B", None, None, vec![])
1029 .await
1030 .unwrap();
1031 let c = rt
1032 .create_entity(None, "concept", "C", None, None, vec![])
1033 .await
1034 .unwrap();
1035
1036 rt.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1037 .await
1038 .unwrap();
1039 rt.link(None, a.id, c.id, EdgeRelation::DependsOn, 1.0)
1040 .await
1041 .unwrap();
1042
1043 let filtered = rt
1044 .neighbors(
1045 None,
1046 a.id,
1047 Direction::Out,
1048 None,
1049 Some(vec![EdgeRelation::Extends]),
1050 )
1051 .await
1052 .unwrap();
1053 assert_eq!(filtered.len(), 1);
1054 assert_eq!(filtered[0].node_id, b.id);
1055 assert_eq!(filtered[0].relation, EdgeRelation::Extends);
1056 }
1057
1058 #[tokio::test]
1059 async fn search_notes_returns_relevant_note() {
1060 let rt = rt();
1061 rt.create_note(
1062 None,
1063 khive_storage::NoteKind::Observation,
1064 None,
1065 "GQA reduces KV cache memory for large models",
1066 0.8,
1067 None,
1068 vec![],
1069 )
1070 .await
1071 .unwrap();
1072
1073 let results = rt
1074 .search_notes(None, "GQA KV cache", None, 10)
1075 .await
1076 .unwrap();
1077
1078 assert!(!results.is_empty(), "search should return the indexed note");
1079 }
1080
1081 #[tokio::test]
1082 async fn search_notes_excludes_soft_deleted() {
1083 let rt = rt();
1084 let note = rt
1085 .create_note(
1086 None,
1087 khive_storage::NoteKind::Observation,
1088 None,
1089 "RoPE positional encoding rotary embeddings",
1090 0.7,
1091 None,
1092 vec![],
1093 )
1094 .await
1095 .unwrap();
1096
1097 rt.notes(None)
1099 .unwrap()
1100 .delete_note(note.id, DeleteMode::Soft)
1101 .await
1102 .unwrap();
1103
1104 let results = rt
1105 .search_notes(None, "RoPE rotary positional", None, 10)
1106 .await
1107 .unwrap();
1108
1109 assert!(
1110 results.iter().all(|h| h.note_id != note.id),
1111 "soft-deleted note should be excluded from search"
1112 );
1113 }
1114
1115 #[tokio::test]
1116 async fn resolve_returns_entity() {
1117 let rt = rt();
1118 let entity = rt
1119 .create_entity(None, "concept", "LoRA", None, None, vec![])
1120 .await
1121 .unwrap();
1122
1123 let resolved = rt.resolve(None, entity.id).await.unwrap();
1124 match resolved {
1125 Some(Resolved::Entity(e)) => assert_eq!(e.id, entity.id),
1126 other => panic!("expected Resolved::Entity, got {:?}", other),
1127 }
1128 }
1129
1130 #[tokio::test]
1131 async fn resolve_returns_note() {
1132 let rt = rt();
1133 let note = rt
1134 .create_note(
1135 None,
1136 khive_storage::NoteKind::Observation,
1137 None,
1138 "LoRA fine-tunes LLMs with low-rank adapters",
1139 0.85,
1140 None,
1141 vec![],
1142 )
1143 .await
1144 .unwrap();
1145
1146 let resolved = rt.resolve(None, note.id).await.unwrap();
1147 match resolved {
1148 Some(Resolved::Note(n)) => assert_eq!(n.id, note.id),
1149 other => panic!("expected Resolved::Note, got {:?}", other),
1150 }
1151 }
1152
1153 #[tokio::test]
1154 async fn resolve_returns_none_for_unknown_uuid() {
1155 let rt = rt();
1156 let unknown = Uuid::new_v4();
1157 let resolved = rt.resolve(None, unknown).await.unwrap();
1158 assert!(resolved.is_none(), "unknown UUID should resolve to None");
1159 }
1160}