1use std::collections::HashMap;
4use std::str::FromStr;
5
6use uuid::Uuid;
7
8use khive_score::{rrf_score, DeterministicScore};
9use khive_storage::note::Note;
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::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 mut entity = Entity::new(ns, kind, name);
51 if let Some(d) = description {
52 entity = entity.with_description(d);
53 }
54 if let Some(p) = properties {
55 entity = entity.with_properties(p);
56 }
57 if !tags.is_empty() {
58 entity = entity.with_tags(tags);
59 }
60 self.entities(Some(ns))?
61 .upsert_entity(entity.clone())
62 .await?;
63
64 let body = match &entity.description {
65 Some(d) if !d.is_empty() => format!("{} {}", entity.name, d),
66 _ => entity.name.clone(),
67 };
68 self.text(namespace)?
69 .upsert_document(TextDocument {
70 subject_id: entity.id,
71 kind: SubstrateKind::Entity,
72 title: Some(entity.name.clone()),
73 body: body.clone(),
74 tags: entity.tags.clone(),
75 namespace: ns.to_string(),
76 metadata: entity.properties.clone(),
77 updated_at: chrono::Utc::now(),
78 })
79 .await?;
80
81 if self.config().embedding_model.is_some() {
82 let vector = self.embed(&body).await?;
83 self.vectors(namespace)?
84 .insert(entity.id, SubstrateKind::Entity, ns, vector)
85 .await?;
86 }
87
88 Ok(entity)
89 }
90
91 pub async fn get_entity(
96 &self,
97 namespace: Option<&str>,
98 id: Uuid,
99 ) -> RuntimeResult<Option<Entity>> {
100 let entity = match self.entities(namespace)?.get_entity(id).await? {
101 Some(e) => e,
102 None => return Ok(None),
103 };
104 if entity.namespace != self.ns(namespace) {
105 return Ok(None);
106 }
107 Ok(Some(entity))
108 }
109
110 pub async fn list_entities(
112 &self,
113 namespace: Option<&str>,
114 kind: Option<&str>,
115 limit: u32,
116 ) -> RuntimeResult<Vec<Entity>> {
117 let filter = EntityFilter {
118 kinds: match kind {
119 Some(k) => vec![k.to_string()],
120 None => vec![],
121 },
122 ..Default::default()
123 };
124 let page = self
125 .entities(namespace)?
126 .query_entities(self.ns(namespace), filter, PageRequest { offset: 0, limit })
127 .await?;
128 Ok(page.items)
129 }
130
131 async fn validate_edge_relation_endpoints(
145 &self,
146 namespace: Option<&str>,
147 source_id: Uuid,
148 target_id: Uuid,
149 relation: EdgeRelation,
150 ) -> RuntimeResult<()> {
151 if relation == EdgeRelation::Annotates {
152 match self.resolve(namespace, source_id).await? {
154 Some(Resolved::Note(_)) => {}
155 Some(_) => {
156 return Err(RuntimeError::InvalidInput(format!(
157 "annotates source {source_id} must be a note"
158 )));
159 }
160 None => {
161 if self.get_edge(namespace, source_id).await?.is_some() {
163 return Err(RuntimeError::InvalidInput(format!(
164 "annotates source {source_id} must be a note"
165 )));
166 }
167 return Err(RuntimeError::NotFound(format!(
168 "link source {source_id} not found in namespace"
169 )));
170 }
171 }
172 if !self.substrate_exists_in_ns(namespace, target_id).await? {
174 return Err(RuntimeError::NotFound(format!(
175 "link target {target_id} not found in namespace"
176 )));
177 }
178 } else if relation == EdgeRelation::Supersedes {
179 let src = match self.resolve(namespace, source_id).await? {
182 Some(r) => r,
183 None => {
184 if self.get_edge(namespace, source_id).await?.is_some() {
185 return Err(RuntimeError::InvalidInput(format!(
186 "supersedes source {source_id} must be a note or entity (got edge)"
187 )));
188 }
189 return Err(RuntimeError::NotFound(format!(
190 "link source {source_id} not found in namespace"
191 )));
192 }
193 };
194 let tgt = match self.resolve(namespace, target_id).await? {
195 Some(r) => r,
196 None => {
197 if self.get_edge(namespace, target_id).await?.is_some() {
198 return Err(RuntimeError::InvalidInput(format!(
199 "supersedes target {target_id} must be a note or entity (got edge)"
200 )));
201 }
202 return Err(RuntimeError::NotFound(format!(
203 "link target {target_id} not found in namespace"
204 )));
205 }
206 };
207 match (&src, &tgt) {
208 (Resolved::Entity(_), Resolved::Entity(_)) => {}
209 (Resolved::Note(_), Resolved::Note(_)) => {}
210 (Resolved::Event(_), _) => {
211 return Err(RuntimeError::InvalidInput(format!(
212 "supersedes does not apply to events; source {source_id} is an event"
213 )));
214 }
215 (_, Resolved::Event(_)) => {
216 return Err(RuntimeError::InvalidInput(format!(
217 "supersedes does not apply to events; target {target_id} is an event"
218 )));
219 }
220 (Resolved::Entity(_), Resolved::Note(_)) => {
221 return Err(RuntimeError::InvalidInput(format!(
222 "supersedes endpoints must be the same substrate (note→note or entity→entity); \
223 got source={source_id} (entity) target={target_id} (note)"
224 )));
225 }
226 (Resolved::Note(_), Resolved::Entity(_)) => {
227 return Err(RuntimeError::InvalidInput(format!(
228 "supersedes endpoints must be the same substrate (note→note or entity→entity); \
229 got source={source_id} (note) target={target_id} (entity)"
230 )));
231 }
232 }
233 } else {
234 match self.resolve(namespace, source_id).await? {
239 Some(Resolved::Entity(_)) => {}
240 Some(_) => {
241 return Err(RuntimeError::InvalidInput(format!(
242 "link source {source_id} must be an entity for relation {relation:?} \
243 (ADR-002: only `annotates` crosses substrates)"
244 )));
245 }
246 None => {
247 if self.get_edge(namespace, source_id).await?.is_some() {
248 return Err(RuntimeError::InvalidInput(format!(
249 "link source {source_id} must be an entity for relation {relation:?} \
250 (ADR-002: only `annotates` crosses substrates)"
251 )));
252 }
253 return Err(RuntimeError::NotFound(format!(
254 "link source {source_id} not found in namespace"
255 )));
256 }
257 }
258 match self.resolve(namespace, target_id).await? {
259 Some(Resolved::Entity(_)) => {}
260 Some(_) => {
261 return Err(RuntimeError::InvalidInput(format!(
262 "link target {target_id} must be an entity for relation {relation:?} \
263 (ADR-002: only `annotates` crosses substrates)"
264 )));
265 }
266 None => {
267 if self.get_edge(namespace, target_id).await?.is_some() {
268 return Err(RuntimeError::InvalidInput(format!(
269 "link target {target_id} must be an entity for relation {relation:?} \
270 (ADR-002: only `annotates` crosses substrates)"
271 )));
272 }
273 return Err(RuntimeError::NotFound(format!(
274 "link target {target_id} not found in namespace"
275 )));
276 }
277 }
278 }
279 Ok(())
280 }
281
282 pub async fn link(
290 &self,
291 namespace: Option<&str>,
292 source_id: Uuid,
293 target_id: Uuid,
294 relation: EdgeRelation,
295 weight: f64,
296 ) -> RuntimeResult<Edge> {
297 self.validate_edge_relation_endpoints(namespace, source_id, target_id, relation)
298 .await?;
299 let edge = Edge {
300 id: LinkId::from(Uuid::new_v4()),
301 source_id,
302 target_id,
303 relation,
304 weight,
305 created_at: chrono::Utc::now(),
306 metadata: None,
307 };
308 self.graph(namespace)?.upsert_edge(edge.clone()).await?;
309 Ok(edge)
310 }
311
312 async fn substrate_exists_in_ns(
317 &self,
318 namespace: Option<&str>,
319 id: Uuid,
320 ) -> RuntimeResult<bool> {
321 if self.resolve(namespace, id).await?.is_some() {
322 return Ok(true);
323 }
324 Ok(self.get_edge(namespace, id).await?.is_some())
325 }
326
327 pub async fn neighbors(
332 &self,
333 namespace: Option<&str>,
334 node_id: Uuid,
335 direction: Direction,
336 limit: Option<u32>,
337 relations: Option<Vec<EdgeRelation>>,
338 ) -> RuntimeResult<Vec<NeighborHit>> {
339 let query = NeighborQuery {
340 direction,
341 relations,
342 limit,
343 min_weight: None,
344 };
345 Ok(self.graph(namespace)?.neighbors(node_id, query).await?)
346 }
347
348 pub async fn traverse(
350 &self,
351 namespace: Option<&str>,
352 request: TraversalRequest,
353 ) -> RuntimeResult<Vec<GraphPath>> {
354 Ok(self.graph(namespace)?.traverse(request).await?)
355 }
356
357 #[allow(clippy::too_many_arguments)]
368 pub async fn create_note(
369 &self,
370 namespace: Option<&str>,
371 kind: &str,
372 name: Option<&str>,
373 content: &str,
374 salience: f64,
375 properties: Option<serde_json::Value>,
376 annotates: Vec<Uuid>,
377 ) -> RuntimeResult<Note> {
378 let ns = self.ns(namespace);
379
380 for &target_id in &annotates {
382 if !self.substrate_exists_in_ns(namespace, target_id).await? {
383 return Err(RuntimeError::NotFound(format!(
384 "create_note annotates target {target_id} not found in namespace"
385 )));
386 }
387 }
388
389 let mut note = Note::new(ns, kind, content).with_salience(salience);
390 if let Some(n) = name {
391 note = note.with_name(n);
392 }
393 if let Some(p) = properties {
394 note = note.with_properties(p);
395 }
396 self.notes(Some(ns))?.upsert_note(note.clone()).await?;
397
398 let body = match ¬e.name {
399 Some(n) => format!("{n} {}", note.content),
400 None => note.content.clone(),
401 };
402
403 self.text_for_notes(Some(ns))?
405 .upsert_document(TextDocument {
406 subject_id: note.id,
407 kind: SubstrateKind::Note,
408 title: note.name.clone(),
409 body,
410 tags: vec![],
411 namespace: ns.to_string(),
412 metadata: note.properties.clone(),
413 updated_at: chrono::Utc::now(),
414 })
415 .await?;
416
417 if self.config().embedding_model.is_some() {
419 let vector = self.embed(¬e.content).await?;
420 self.vectors(Some(ns))?
421 .insert(note.id, SubstrateKind::Note, ns, vector)
422 .await?;
423 }
424
425 for target_id in annotates {
427 self.link(Some(ns), note.id, target_id, EdgeRelation::Annotates, 1.0)
428 .await?;
429 }
430
431 Ok(note)
432 }
433
434 pub async fn list_notes(
436 &self,
437 namespace: Option<&str>,
438 kind: Option<&str>,
439 limit: u32,
440 ) -> RuntimeResult<Vec<Note>> {
441 let page = self
442 .notes(namespace)?
443 .query_notes(self.ns(namespace), kind, PageRequest { offset: 0, limit })
444 .await?;
445 Ok(page.items)
446 }
447
448 pub async fn search_notes(
458 &self,
459 namespace: Option<&str>,
460 query_text: &str,
461 query_vector: Option<Vec<f32>>,
462 limit: u32,
463 ) -> RuntimeResult<Vec<NoteSearchHit>> {
464 const RRF_K: usize = 60;
465 let candidates = limit.saturating_mul(4).max(limit);
466 let ns = self.ns(namespace).to_string();
467
468 let text_hits = self
470 .text_for_notes(namespace)?
471 .search(TextSearchRequest {
472 query: query_text.to_string(),
473 mode: TextQueryMode::Plain,
474 filter: Some(TextFilter {
475 namespaces: vec![ns.clone()],
476 ..TextFilter::default()
477 }),
478 top_k: candidates,
479 snippet_chars: 200,
480 })
481 .await?;
482
483 let vector_hits = if let Some(vec) = query_vector {
485 self.vectors(namespace)?
486 .search(VectorSearchRequest {
487 query_embedding: vec,
488 top_k: candidates,
489 namespace: Some(ns.clone()),
490 kind: Some(SubstrateKind::Note),
491 })
492 .await?
493 } else {
494 vec![]
495 };
496
497 let mut buckets: HashMap<Uuid, DeterministicScore> = HashMap::new();
499 for (i, hit) in text_hits.into_iter().enumerate() {
500 let rank = i + 1;
501 let entry = buckets.entry(hit.subject_id).or_default();
502 *entry = *entry + rrf_score(rank, RRF_K);
503 }
504 for (i, hit) in vector_hits.into_iter().enumerate() {
505 let rank = i + 1;
506 let entry = buckets.entry(hit.subject_id).or_default();
507 *entry = *entry + rrf_score(rank, RRF_K);
508 }
509
510 let candidate_ids: Vec<Uuid> = buckets.keys().copied().collect();
511 if candidate_ids.is_empty() {
512 return Ok(vec![]);
513 }
514
515 let note_store = self.notes(namespace)?;
517 let mut alive_notes: HashMap<Uuid, Note> = HashMap::new();
518 for id in &candidate_ids {
519 if let Some(note) = note_store.get_note(*id).await? {
520 if note.deleted_at.is_none() {
521 alive_notes.insert(*id, note);
522 }
523 }
524 }
525
526 if !alive_notes.is_empty() {
529 let graph = self.graph(namespace)?;
530 let mut superseded: std::collections::HashSet<Uuid> = std::collections::HashSet::new();
531 for ¬e_id in alive_notes.keys() {
532 let inbound = graph
533 .neighbors(
534 note_id,
535 NeighborQuery {
536 direction: Direction::In,
537 relations: Some(vec![EdgeRelation::Supersedes]),
538 limit: Some(1),
539 min_weight: None,
540 },
541 )
542 .await?;
543 if !inbound.is_empty() {
544 superseded.insert(note_id);
545 }
546 }
547 alive_notes.retain(|id, _| !superseded.contains(id));
548 }
549
550 let mut hits: Vec<NoteSearchHit> = buckets
552 .into_iter()
553 .filter_map(|(id, rrf)| {
554 let note = alive_notes.get(&id)?;
555 let weight = 0.5 + 0.5 * note.salience;
556 let weighted = DeterministicScore::from_f64(rrf.to_f64() * weight);
557 Some(NoteSearchHit {
558 note_id: id,
559 score: weighted,
560 })
561 })
562 .collect();
563
564 hits.sort_by(|a, b| b.score.cmp(&a.score).then(a.note_id.cmp(&b.note_id)));
565 hits.truncate(limit as usize);
566 Ok(hits)
567 }
568
569 pub async fn resolve_prefix(
576 &self,
577 namespace: Option<&str>,
578 prefix: &str,
579 ) -> RuntimeResult<Option<Uuid>> {
580 use khive_storage::types::{SqlStatement, SqlValue};
581
582 let ns = self.ns(namespace).to_string();
583 let pattern = format!("{}%", prefix);
584
585 let tables = [("entities", true), ("notes", true), ("graph_edges", false)];
586
587 let mut matches: Vec<String> = Vec::new();
588 let mut reader = self.sql().reader().await.map_err(RuntimeError::Storage)?;
589
590 for (table, has_deleted_at) in tables {
591 let deleted_filter = if has_deleted_at {
592 " AND deleted_at IS NULL"
593 } else {
594 ""
595 };
596 let sql = SqlStatement {
597 sql: format!(
598 "SELECT id FROM {table} WHERE id LIKE ?1 AND namespace = ?2{deleted_filter} LIMIT 2"
599 ),
600 params: vec![
601 SqlValue::Text(pattern.clone()),
602 SqlValue::Text(ns.clone()),
603 ],
604 label: Some("resolve_prefix".into()),
605 };
606 match reader.query_all(sql).await {
607 Ok(rows) => {
608 for row in rows {
609 if let Some(col) = row.columns.first() {
610 if let SqlValue::Text(s) = &col.value {
611 matches.push(s.clone());
612 }
613 }
614 }
615 }
616 Err(e) => {
617 let msg = e.to_string();
618 if msg.contains("no such table") {
619 continue;
620 }
621 return Err(RuntimeError::Storage(e));
622 }
623 }
624 if matches.len() > 1 {
625 break;
626 }
627 }
628
629 match matches.len() {
630 0 => Ok(None),
631 1 => {
632 let uuid = Uuid::from_str(&matches[0])
633 .map_err(|e| RuntimeError::Internal(format!("stored UUID is invalid: {e}")))?;
634 Ok(Some(uuid))
635 }
636 _ => Err(RuntimeError::Ambiguous(format!(
637 "prefix '{prefix}' matches multiple UUIDs"
638 ))),
639 }
640 }
641
642 pub async fn resolve(
647 &self,
648 namespace: Option<&str>,
649 id: Uuid,
650 ) -> RuntimeResult<Option<Resolved>> {
651 let ns = self.ns(namespace);
652
653 if let Some(entity) = self.get_entity(namespace, id).await? {
655 return Ok(Some(Resolved::Entity(entity)));
656 }
657
658 if let Some(note) = self.notes(namespace)?.get_note(id).await? {
660 if note.namespace == ns {
661 return Ok(Some(Resolved::Note(note)));
662 }
663 }
664
665 if let Some(event) = self.events(namespace)?.get_event(id).await? {
667 if event.namespace == ns {
668 return Ok(Some(Resolved::Event(event)));
669 }
670 }
671
672 Ok(None)
673 }
674
675 pub async fn delete_note(
680 &self,
681 namespace: Option<&str>,
682 id: Uuid,
683 hard: bool,
684 ) -> RuntimeResult<bool> {
685 let ns = self.ns(namespace);
686 let note_store = self.notes(namespace)?;
687 let note = match note_store.get_note(id).await? {
688 Some(n) => n,
689 None => return Ok(false),
690 };
691 if note.namespace != ns {
692 return Ok(false);
693 }
694 let mode = if hard {
695 DeleteMode::Hard
696 } else {
697 DeleteMode::Soft
698 };
699 Ok(note_store.delete_note(id, mode).await?)
700 }
701
702 pub async fn query(
710 &self,
711 namespace: Option<&str>,
712 query: &str,
713 ) -> RuntimeResult<Vec<khive_storage::types::SqlRow>> {
714 let ns = self.ns(namespace);
715 let ast = khive_query::parse_auto(query)?;
716 let opts = khive_query::CompileOptions {
717 scopes: vec![ns.to_string()],
718 ..Default::default()
719 };
720 let compiled = khive_query::compile(&ast, &opts)?;
721 let mut reader = self.sql().reader().await?;
722 let stmt = SqlStatement {
723 sql: compiled.sql,
724 params: compiled.params,
725 label: None,
726 };
727 Ok(reader.query_all(stmt).await?)
728 }
729
730 pub async fn delete_entity(
739 &self,
740 namespace: Option<&str>,
741 id: Uuid,
742 hard: bool,
743 ) -> RuntimeResult<bool> {
744 let entity = match self.entities(namespace)?.get_entity(id).await? {
745 Some(e) => e,
746 None => return Ok(false),
747 };
748 if entity.namespace != self.ns(namespace) {
749 return Ok(false);
750 }
751 let mode = if hard {
752 DeleteMode::Hard
753 } else {
754 DeleteMode::Soft
755 };
756
757 if hard {
759 let graph = self.graph(namespace)?;
760 for direction in [Direction::Out, Direction::In] {
761 let hits = graph
762 .neighbors(
763 id,
764 NeighborQuery {
765 direction,
766 relations: None,
767 limit: None,
768 min_weight: None,
769 },
770 )
771 .await?;
772 for hit in hits {
773 graph.delete_edge(LinkId::from(hit.edge_id)).await?;
774 }
775 }
776 self.remove_from_indexes(namespace, id).await?;
777 }
778
779 Ok(self.entities(namespace)?.delete_entity(id, mode).await?)
780 }
781
782 pub async fn count_entities(
784 &self,
785 namespace: Option<&str>,
786 kind: Option<&str>,
787 ) -> RuntimeResult<u64> {
788 let filter = EntityFilter {
789 kinds: match kind {
790 Some(k) => vec![k.to_string()],
791 None => vec![],
792 },
793 ..Default::default()
794 };
795 Ok(self
796 .entities(namespace)?
797 .count_entities(self.ns(namespace), filter)
798 .await?)
799 }
800
801 pub async fn get_edge(
805 &self,
806 namespace: Option<&str>,
807 edge_id: Uuid,
808 ) -> RuntimeResult<Option<Edge>> {
809 Ok(self
810 .graph(namespace)?
811 .get_edge(LinkId::from(edge_id))
812 .await?)
813 }
814
815 pub async fn list_edges(
817 &self,
818 namespace: Option<&str>,
819 filter: crate::curation::EdgeListFilter,
820 limit: u32,
821 ) -> RuntimeResult<Vec<Edge>> {
822 let limit = limit.clamp(1, 1000);
823 let page = self
824 .graph(namespace)?
825 .query_edges(
826 filter.into(),
827 vec![SortOrder {
828 field: EdgeSortField::CreatedAt,
829 direction: khive_storage::types::SortDirection::Asc,
830 }],
831 PageRequest { offset: 0, limit },
832 )
833 .await?;
834 Ok(page.items)
835 }
836
837 pub async fn update_edge(
844 &self,
845 namespace: Option<&str>,
846 edge_id: Uuid,
847 relation: Option<EdgeRelation>,
848 weight: Option<f64>,
849 ) -> RuntimeResult<Edge> {
850 let graph = self.graph(namespace)?;
851 let mut edge = graph
852 .get_edge(LinkId::from(edge_id))
853 .await?
854 .ok_or_else(|| crate::RuntimeError::NotFound(format!("edge {edge_id}")))?;
855
856 if let Some(r) = relation {
857 self.validate_edge_relation_endpoints(namespace, edge.source_id, edge.target_id, r)
859 .await?;
860 edge.relation = r;
861 }
862 if let Some(w) = weight {
863 edge.weight = w.clamp(0.0, 1.0);
864 }
865
866 graph.upsert_edge(edge.clone()).await?;
867 Ok(edge)
868 }
869
870 pub async fn delete_edge(&self, namespace: Option<&str>, edge_id: Uuid) -> RuntimeResult<bool> {
872 Ok(self
873 .graph(namespace)?
874 .delete_edge(LinkId::from(edge_id))
875 .await?)
876 }
877
878 pub async fn count_edges(
880 &self,
881 namespace: Option<&str>,
882 filter: crate::curation::EdgeListFilter,
883 ) -> RuntimeResult<u64> {
884 Ok(self.graph(namespace)?.count_edges(filter.into()).await?)
885 }
886}
887
888#[cfg(test)]
889mod tests {
890 use super::*;
891 use crate::curation::EdgeListFilter;
892 use crate::runtime::KhiveRuntime;
893
894 fn rt() -> KhiveRuntime {
895 KhiveRuntime::memory().unwrap()
896 }
897
898 #[tokio::test]
899 async fn update_edge_changes_weight() {
900 let rt = rt();
901 let a = rt
902 .create_entity(None, "concept", "A", None, None, vec![])
903 .await
904 .unwrap();
905 let b = rt
906 .create_entity(None, "concept", "B", None, None, vec![])
907 .await
908 .unwrap();
909 let edge = rt
910 .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
911 .await
912 .unwrap();
913 let edge_id: Uuid = edge.id.into();
914
915 let updated = rt
916 .update_edge(None, edge_id, None, Some(0.5))
917 .await
918 .unwrap();
919 assert!((updated.weight - 0.5).abs() < 0.001);
920 }
921
922 #[tokio::test]
923 async fn update_edge_changes_relation() {
924 let rt = rt();
925 let a = rt
926 .create_entity(None, "concept", "A", None, None, vec![])
927 .await
928 .unwrap();
929 let b = rt
930 .create_entity(None, "concept", "B", None, None, vec![])
931 .await
932 .unwrap();
933 let edge = rt
934 .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
935 .await
936 .unwrap();
937 let edge_id: Uuid = edge.id.into();
938
939 let updated = rt
940 .update_edge(None, edge_id, Some(EdgeRelation::VariantOf), None)
941 .await
942 .unwrap();
943 assert_eq!(updated.relation, EdgeRelation::VariantOf);
944 }
945
946 #[tokio::test]
951 async fn update_edge_annotates_note_to_entity_set_supersedes_returns_invalid_input() {
952 let rt = rt();
953 let note = rt
954 .create_note(None, "observation", None, "a note", 0.5, None, vec![])
955 .await
956 .unwrap();
957 let entity = rt
958 .create_entity(None, "concept", "E", None, None, vec![])
959 .await
960 .unwrap();
961 let edge = rt
963 .link(None, note.id, entity.id, EdgeRelation::Annotates, 1.0)
964 .await
965 .unwrap();
966 let edge_id: Uuid = edge.id.into();
967
968 let result = rt
970 .update_edge(None, edge_id, Some(EdgeRelation::Supersedes), None)
971 .await;
972 assert!(
973 matches!(result, Err(RuntimeError::InvalidInput(_))),
974 "update to Supersedes on note→entity edge must return InvalidInput, got {result:?}"
975 );
976
977 let fetched = rt.get_edge(None, edge_id).await.unwrap().unwrap();
979 assert_eq!(
980 fetched.relation,
981 EdgeRelation::Annotates,
982 "edge relation must be unchanged after failed update"
983 );
984 }
985
986 #[tokio::test]
989 async fn update_edge_entity_to_entity_set_annotates_returns_invalid_input() {
990 let rt = rt();
991 let a = rt
992 .create_entity(None, "concept", "A", None, None, vec![])
993 .await
994 .unwrap();
995 let b = rt
996 .create_entity(None, "concept", "B", None, None, vec![])
997 .await
998 .unwrap();
999 let edge = rt
1000 .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1001 .await
1002 .unwrap();
1003 let edge_id: Uuid = edge.id.into();
1004
1005 let result = rt
1006 .update_edge(None, edge_id, Some(EdgeRelation::Annotates), None)
1007 .await;
1008 assert!(
1009 matches!(result, Err(RuntimeError::InvalidInput(_))),
1010 "update to Annotates on entity→entity edge must return InvalidInput, got {result:?}"
1011 );
1012 }
1013
1014 #[tokio::test]
1017 async fn update_edge_entity_to_entity_set_supersedes_succeeds() {
1018 let rt = rt();
1019 let a = rt
1020 .create_entity(None, "concept", "A", None, None, vec![])
1021 .await
1022 .unwrap();
1023 let b = rt
1024 .create_entity(None, "concept", "B", None, None, vec![])
1025 .await
1026 .unwrap();
1027 let edge = rt
1028 .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1029 .await
1030 .unwrap();
1031 let edge_id: Uuid = edge.id.into();
1032
1033 let updated = rt
1034 .update_edge(None, edge_id, Some(EdgeRelation::Supersedes), None)
1035 .await
1036 .unwrap();
1037 assert_eq!(updated.relation, EdgeRelation::Supersedes);
1038
1039 let fetched = rt.get_edge(None, edge_id).await.unwrap().unwrap();
1041 assert_eq!(fetched.relation, EdgeRelation::Supersedes);
1042 }
1043
1044 #[tokio::test]
1046 async fn update_edge_weight_only_skips_validation() {
1047 let rt = rt();
1048 let a = rt
1049 .create_entity(None, "concept", "A", None, None, vec![])
1050 .await
1051 .unwrap();
1052 let b = rt
1053 .create_entity(None, "concept", "B", None, None, vec![])
1054 .await
1055 .unwrap();
1056 let edge = rt
1057 .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1058 .await
1059 .unwrap();
1060 let edge_id: Uuid = edge.id.into();
1061
1062 let updated = rt
1063 .update_edge(None, edge_id, None, Some(0.3))
1064 .await
1065 .unwrap();
1066 assert_eq!(updated.relation, EdgeRelation::Extends);
1067 assert!((updated.weight - 0.3).abs() < 0.001);
1068 }
1069
1070 #[tokio::test]
1072 async fn update_edge_same_class_relation_change_succeeds() {
1073 let rt = rt();
1074 let a = rt
1075 .create_entity(None, "concept", "A", None, None, vec![])
1076 .await
1077 .unwrap();
1078 let b = rt
1079 .create_entity(None, "concept", "B", None, None, vec![])
1080 .await
1081 .unwrap();
1082 let edge = rt
1083 .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1084 .await
1085 .unwrap();
1086 let edge_id: Uuid = edge.id.into();
1087
1088 let updated = rt
1089 .update_edge(None, edge_id, Some(EdgeRelation::VariantOf), None)
1090 .await
1091 .unwrap();
1092 assert_eq!(updated.relation, EdgeRelation::VariantOf);
1093 }
1094
1095 #[tokio::test]
1096 async fn list_edges_filters_by_relation() {
1097 let rt = rt();
1098 let a = rt
1099 .create_entity(None, "concept", "A", None, None, vec![])
1100 .await
1101 .unwrap();
1102 let b = rt
1103 .create_entity(None, "concept", "B", None, None, vec![])
1104 .await
1105 .unwrap();
1106 let c = rt
1107 .create_entity(None, "concept", "C", None, None, vec![])
1108 .await
1109 .unwrap();
1110
1111 rt.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1112 .await
1113 .unwrap();
1114 rt.link(None, a.id, c.id, EdgeRelation::DependsOn, 1.0)
1115 .await
1116 .unwrap();
1117
1118 let filter = EdgeListFilter {
1119 relations: vec![EdgeRelation::Extends],
1120 ..Default::default()
1121 };
1122 let edges = rt.list_edges(None, filter, 100).await.unwrap();
1123 assert_eq!(edges.len(), 1);
1124 assert_eq!(edges[0].relation, EdgeRelation::Extends);
1125 }
1126
1127 #[tokio::test]
1128 async fn list_edges_filters_by_source() {
1129 let rt = rt();
1130 let a = rt
1131 .create_entity(None, "concept", "A", None, None, vec![])
1132 .await
1133 .unwrap();
1134 let b = rt
1135 .create_entity(None, "concept", "B", None, None, vec![])
1136 .await
1137 .unwrap();
1138 let c = rt
1139 .create_entity(None, "concept", "C", None, None, vec![])
1140 .await
1141 .unwrap();
1142 let d = rt
1143 .create_entity(None, "concept", "D", None, None, vec![])
1144 .await
1145 .unwrap();
1146
1147 rt.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1148 .await
1149 .unwrap();
1150 rt.link(None, c.id, d.id, EdgeRelation::Extends, 1.0)
1151 .await
1152 .unwrap();
1153
1154 let filter = EdgeListFilter {
1155 source_id: Some(a.id),
1156 ..Default::default()
1157 };
1158 let edges = rt.list_edges(None, filter, 100).await.unwrap();
1159 assert_eq!(edges.len(), 1);
1160 let src: Uuid = edges[0].source_id;
1161 assert_eq!(src, a.id);
1162 }
1163
1164 #[tokio::test]
1165 async fn delete_edge_removes_from_storage() {
1166 let rt = rt();
1167 let a = rt
1168 .create_entity(None, "concept", "A", None, None, vec![])
1169 .await
1170 .unwrap();
1171 let b = rt
1172 .create_entity(None, "concept", "B", None, None, vec![])
1173 .await
1174 .unwrap();
1175 let edge = rt
1176 .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1177 .await
1178 .unwrap();
1179 let edge_id: Uuid = edge.id.into();
1180
1181 let deleted = rt.delete_edge(None, edge_id).await.unwrap();
1182 assert!(deleted);
1183
1184 let fetched = rt.get_edge(None, edge_id).await.unwrap();
1185 assert!(fetched.is_none(), "edge should be gone after delete");
1186 }
1187
1188 #[tokio::test]
1189 async fn count_edges_matches_filter() {
1190 let rt = rt();
1191 let a = rt
1192 .create_entity(None, "concept", "A", None, None, vec![])
1193 .await
1194 .unwrap();
1195 let b = rt
1196 .create_entity(None, "concept", "B", None, None, vec![])
1197 .await
1198 .unwrap();
1199 let c = rt
1200 .create_entity(None, "concept", "C", None, None, vec![])
1201 .await
1202 .unwrap();
1203
1204 rt.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1205 .await
1206 .unwrap();
1207 rt.link(None, a.id, c.id, EdgeRelation::DependsOn, 1.0)
1208 .await
1209 .unwrap();
1210
1211 let all = rt
1212 .count_edges(None, EdgeListFilter::default())
1213 .await
1214 .unwrap();
1215 assert_eq!(all, 2);
1216
1217 let just_extends = rt
1218 .count_edges(
1219 None,
1220 EdgeListFilter {
1221 relations: vec![EdgeRelation::Extends],
1222 ..Default::default()
1223 },
1224 )
1225 .await
1226 .unwrap();
1227 assert_eq!(just_extends, 1);
1228 }
1229
1230 #[tokio::test]
1231 async fn get_entity_namespace_isolation() {
1232 let rt = rt();
1233 let entity = rt
1234 .create_entity(Some("ns-a"), "concept", "Alpha", None, None, vec![])
1235 .await
1236 .unwrap();
1237
1238 let found = rt.get_entity(Some("ns-a"), entity.id).await.unwrap();
1240 assert!(found.is_some(), "should be visible in its own namespace");
1241
1242 let not_found = rt.get_entity(Some("ns-b"), entity.id).await.unwrap();
1244 assert!(
1245 not_found.is_none(),
1246 "should not be visible across namespaces"
1247 );
1248 }
1249
1250 #[tokio::test]
1251 async fn delete_entity_namespace_isolation() {
1252 let rt = rt();
1253 let entity = rt
1254 .create_entity(Some("ns-a"), "concept", "Beta", None, None, vec![])
1255 .await
1256 .unwrap();
1257
1258 let deleted = rt
1260 .delete_entity(Some("ns-b"), entity.id, true)
1261 .await
1262 .unwrap();
1263 assert!(!deleted, "cross-namespace delete must return false");
1264
1265 let still_there = rt.get_entity(Some("ns-a"), entity.id).await.unwrap();
1267 assert!(
1268 still_there.is_some(),
1269 "entity must survive cross-ns delete attempt"
1270 );
1271
1272 let deleted_ok = rt
1274 .delete_entity(Some("ns-a"), entity.id, true)
1275 .await
1276 .unwrap();
1277 assert!(deleted_ok, "same-namespace delete must succeed");
1278 }
1279
1280 #[tokio::test]
1283 async fn create_note_indexes_into_fts5() {
1284 let rt = rt();
1285 let note = rt
1286 .create_note(
1287 None,
1288 "observation",
1289 None,
1290 "FlashAttention reduces memory by using tiling",
1291 0.8,
1292 None,
1293 vec![],
1294 )
1295 .await
1296 .unwrap();
1297
1298 let ns = rt.ns(None).to_string();
1300 let hits = rt
1301 .text_for_notes(None)
1302 .unwrap()
1303 .search(khive_storage::types::TextSearchRequest {
1304 query: "FlashAttention".to_string(),
1305 mode: khive_storage::types::TextQueryMode::Plain,
1306 filter: Some(khive_storage::types::TextFilter {
1307 namespaces: vec![ns],
1308 ..Default::default()
1309 }),
1310 top_k: 10,
1311 snippet_chars: 100,
1312 })
1313 .await
1314 .unwrap();
1315
1316 assert!(
1317 hits.iter().any(|h| h.subject_id == note.id),
1318 "note should be indexed in FTS5 after create"
1319 );
1320 }
1321
1322 #[tokio::test]
1323 async fn create_note_with_properties() {
1324 let rt = rt();
1325 let props = serde_json::json!({"source": "arxiv:2205.14135"});
1326 let note = rt
1327 .create_note(
1328 None,
1329 "insight",
1330 None,
1331 "FlashAttention is IO-aware",
1332 0.9,
1333 Some(props.clone()),
1334 vec![],
1335 )
1336 .await
1337 .unwrap();
1338
1339 assert_eq!(note.properties.as_ref().unwrap(), &props);
1340 }
1341
1342 #[tokio::test]
1343 async fn create_note_creates_annotates_edges() {
1344 let rt = rt();
1345 let entity = rt
1346 .create_entity(None, "concept", "FlashAttention", None, None, vec![])
1347 .await
1348 .unwrap();
1349
1350 let note = rt
1351 .create_note(
1352 None,
1353 "observation",
1354 None,
1355 "FlashAttention uses SRAM tiling for memory efficiency",
1356 0.9,
1357 None,
1358 vec![entity.id],
1359 )
1360 .await
1361 .unwrap();
1362
1363 let out_neighbors = rt
1365 .neighbors(
1366 None,
1367 note.id,
1368 Direction::Out,
1369 None,
1370 Some(vec![EdgeRelation::Annotates]),
1371 )
1372 .await
1373 .unwrap();
1374 assert_eq!(out_neighbors.len(), 1);
1375 assert_eq!(out_neighbors[0].node_id, entity.id);
1376 assert_eq!(out_neighbors[0].relation, EdgeRelation::Annotates);
1377
1378 let in_neighbors = rt
1380 .neighbors(
1381 None,
1382 entity.id,
1383 Direction::In,
1384 None,
1385 Some(vec![EdgeRelation::Annotates]),
1386 )
1387 .await
1388 .unwrap();
1389 assert_eq!(in_neighbors.len(), 1);
1390 assert_eq!(in_neighbors[0].node_id, note.id);
1391 }
1392
1393 #[tokio::test]
1394 async fn neighbors_without_relation_filter_returns_all() {
1395 let rt = rt();
1396 let a = rt
1397 .create_entity(None, "concept", "A", None, None, vec![])
1398 .await
1399 .unwrap();
1400 let b = rt
1401 .create_entity(None, "concept", "B", None, None, vec![])
1402 .await
1403 .unwrap();
1404 let c = rt
1405 .create_entity(None, "concept", "C", None, None, vec![])
1406 .await
1407 .unwrap();
1408
1409 rt.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1410 .await
1411 .unwrap();
1412 rt.link(None, a.id, c.id, EdgeRelation::DependsOn, 1.0)
1413 .await
1414 .unwrap();
1415
1416 let all = rt
1417 .neighbors(None, a.id, Direction::Out, None, None)
1418 .await
1419 .unwrap();
1420 assert_eq!(all.len(), 2);
1421 }
1422
1423 #[tokio::test]
1424 async fn neighbors_with_relation_filter_returns_subset() {
1425 let rt = rt();
1426 let a = rt
1427 .create_entity(None, "concept", "A", None, None, vec![])
1428 .await
1429 .unwrap();
1430 let b = rt
1431 .create_entity(None, "concept", "B", None, None, vec![])
1432 .await
1433 .unwrap();
1434 let c = rt
1435 .create_entity(None, "concept", "C", None, None, vec![])
1436 .await
1437 .unwrap();
1438
1439 rt.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1440 .await
1441 .unwrap();
1442 rt.link(None, a.id, c.id, EdgeRelation::DependsOn, 1.0)
1443 .await
1444 .unwrap();
1445
1446 let filtered = rt
1447 .neighbors(
1448 None,
1449 a.id,
1450 Direction::Out,
1451 None,
1452 Some(vec![EdgeRelation::Extends]),
1453 )
1454 .await
1455 .unwrap();
1456 assert_eq!(filtered.len(), 1);
1457 assert_eq!(filtered[0].node_id, b.id);
1458 assert_eq!(filtered[0].relation, EdgeRelation::Extends);
1459 }
1460
1461 #[tokio::test]
1462 async fn search_notes_returns_relevant_note() {
1463 let rt = rt();
1464 rt.create_note(
1465 None,
1466 "observation",
1467 None,
1468 "GQA reduces KV cache memory for large models",
1469 0.8,
1470 None,
1471 vec![],
1472 )
1473 .await
1474 .unwrap();
1475
1476 let results = rt
1477 .search_notes(None, "GQA KV cache", None, 10)
1478 .await
1479 .unwrap();
1480
1481 assert!(!results.is_empty(), "search should return the indexed note");
1482 }
1483
1484 #[tokio::test]
1485 async fn search_notes_excludes_soft_deleted() {
1486 let rt = rt();
1487 let note = rt
1488 .create_note(
1489 None,
1490 "observation",
1491 None,
1492 "RoPE positional encoding rotary embeddings",
1493 0.7,
1494 None,
1495 vec![],
1496 )
1497 .await
1498 .unwrap();
1499
1500 rt.notes(None)
1502 .unwrap()
1503 .delete_note(note.id, DeleteMode::Soft)
1504 .await
1505 .unwrap();
1506
1507 let results = rt
1508 .search_notes(None, "RoPE rotary positional", None, 10)
1509 .await
1510 .unwrap();
1511
1512 assert!(
1513 results.iter().all(|h| h.note_id != note.id),
1514 "soft-deleted note should be excluded from search"
1515 );
1516 }
1517
1518 #[tokio::test]
1519 async fn resolve_returns_entity() {
1520 let rt = rt();
1521 let entity = rt
1522 .create_entity(None, "concept", "LoRA", None, None, vec![])
1523 .await
1524 .unwrap();
1525
1526 let resolved = rt.resolve(None, entity.id).await.unwrap();
1527 match resolved {
1528 Some(Resolved::Entity(e)) => assert_eq!(e.id, entity.id),
1529 other => panic!("expected Resolved::Entity, got {:?}", other),
1530 }
1531 }
1532
1533 #[tokio::test]
1534 async fn resolve_returns_note() {
1535 let rt = rt();
1536 let note = rt
1537 .create_note(
1538 None,
1539 "observation",
1540 None,
1541 "LoRA fine-tunes LLMs with low-rank adapters",
1542 0.85,
1543 None,
1544 vec![],
1545 )
1546 .await
1547 .unwrap();
1548
1549 let resolved = rt.resolve(None, note.id).await.unwrap();
1550 match resolved {
1551 Some(Resolved::Note(n)) => assert_eq!(n.id, note.id),
1552 other => panic!("expected Resolved::Note, got {:?}", other),
1553 }
1554 }
1555
1556 #[tokio::test]
1557 async fn resolve_returns_none_for_unknown_uuid() {
1558 let rt = rt();
1559 let unknown = Uuid::new_v4();
1560 let resolved = rt.resolve(None, unknown).await.unwrap();
1561 assert!(resolved.is_none(), "unknown UUID should resolve to None");
1562 }
1563
1564 #[tokio::test]
1565 async fn resolve_prefix_finds_entity_in_own_namespace() {
1566 let rt = rt();
1567 let entity = rt
1568 .create_entity(None, "concept", "PrefixTest", None, None, vec![])
1569 .await
1570 .unwrap();
1571 let prefix = &entity.id.to_string()[..8];
1572
1573 let resolved = rt.resolve_prefix(None, prefix).await.unwrap();
1574 assert_eq!(resolved, Some(entity.id));
1575 }
1576
1577 #[tokio::test]
1578 async fn resolve_prefix_invisible_across_namespaces() {
1579 let rt = rt();
1580 let entity = rt
1581 .create_entity(Some("ns_a"), "concept", "Invisible", None, None, vec![])
1582 .await
1583 .unwrap();
1584 let prefix = &entity.id.to_string()[..8];
1585
1586 let resolved = rt.resolve_prefix(Some("ns_b"), prefix).await.unwrap();
1588 assert_eq!(resolved, None);
1589 }
1590
1591 #[tokio::test]
1592 async fn resolve_prefix_ambiguous_same_namespace() {
1593 use khive_storage::entity::Entity;
1594
1595 let rt = rt();
1596 let id_a = Uuid::parse_str("aabbccdd-1111-4000-8000-000000000001").unwrap();
1598 let id_b = Uuid::parse_str("aabbccdd-2222-4000-8000-000000000002").unwrap();
1599
1600 let mut entity_a = Entity::new("local", "concept", "AmbigA");
1601 entity_a.id = id_a;
1602 let mut entity_b = Entity::new("local", "concept", "AmbigB");
1603 entity_b.id = id_b;
1604
1605 let store = rt.entities(None).unwrap();
1606 store.upsert_entity(entity_a).await.unwrap();
1607 store.upsert_entity(entity_b).await.unwrap();
1608
1609 let result = rt.resolve_prefix(None, "aabbccdd").await;
1610 assert!(
1611 result.is_err(),
1612 "shared 8-char prefix must return Ambiguous error"
1613 );
1614 }
1615
1616 #[tokio::test]
1619 async fn link_phantom_source_returns_not_found() {
1620 let rt = rt();
1621 let b = rt
1622 .create_entity(None, "concept", "B", None, None, vec![])
1623 .await
1624 .unwrap();
1625 let phantom = Uuid::new_v4();
1626
1627 let result = rt
1628 .link(None, phantom, b.id, EdgeRelation::Extends, 1.0)
1629 .await;
1630 match result {
1631 Err(RuntimeError::NotFound(msg)) => {
1632 assert!(
1633 msg.contains("source"),
1634 "error message must name 'source': {msg}"
1635 );
1636 }
1637 other => panic!("expected NotFound for phantom source, got {other:?}"),
1638 }
1639 }
1640
1641 #[tokio::test]
1642 async fn link_phantom_target_returns_not_found() {
1643 let rt = rt();
1644 let a = rt
1645 .create_entity(None, "concept", "A", None, None, vec![])
1646 .await
1647 .unwrap();
1648 let phantom = Uuid::new_v4();
1649
1650 let result = rt
1651 .link(None, a.id, phantom, EdgeRelation::Extends, 1.0)
1652 .await;
1653 match result {
1654 Err(RuntimeError::NotFound(msg)) => {
1655 assert!(
1656 msg.contains("target"),
1657 "error message must name 'target': {msg}"
1658 );
1659 }
1660 other => panic!("expected NotFound for phantom target, got {other:?}"),
1661 }
1662 }
1663
1664 #[tokio::test]
1665 async fn link_real_entities_succeeds() {
1666 let rt = rt();
1667 let a = rt
1668 .create_entity(None, "concept", "A", None, None, vec![])
1669 .await
1670 .unwrap();
1671 let b = rt
1672 .create_entity(None, "concept", "B", None, None, vec![])
1673 .await
1674 .unwrap();
1675
1676 let edge = rt
1677 .link(None, a.id, b.id, EdgeRelation::Extends, 0.8)
1678 .await
1679 .unwrap();
1680 assert_eq!(edge.source_id, a.id);
1681 assert_eq!(edge.target_id, b.id);
1682 assert_eq!(edge.relation, EdgeRelation::Extends);
1683 }
1684
1685 #[tokio::test]
1686 async fn create_note_annotates_phantom_returns_not_found() {
1687 let rt = rt();
1688 let phantom = Uuid::new_v4();
1689
1690 let result = rt
1691 .create_note(
1692 None,
1693 "observation",
1694 None,
1695 "some content",
1696 0.5,
1697 None,
1698 vec![phantom],
1699 )
1700 .await;
1701 assert!(
1702 matches!(result, Err(RuntimeError::NotFound(_))),
1703 "annotates with phantom uuid must return NotFound, got {result:?}"
1704 );
1705 }
1706
1707 #[tokio::test]
1708 async fn create_note_annotates_real_entity_succeeds() {
1709 let rt = rt();
1710 let entity = rt
1711 .create_entity(None, "concept", "RealTarget", None, None, vec![])
1712 .await
1713 .unwrap();
1714
1715 let note = rt
1716 .create_note(
1717 None,
1718 "observation",
1719 None,
1720 "content",
1721 0.5,
1722 None,
1723 vec![entity.id],
1724 )
1725 .await
1726 .unwrap();
1727
1728 let neighbors = rt
1729 .neighbors(
1730 None,
1731 note.id,
1732 Direction::Out,
1733 None,
1734 Some(vec![EdgeRelation::Annotates]),
1735 )
1736 .await
1737 .unwrap();
1738 assert_eq!(neighbors.len(), 1);
1739 assert_eq!(neighbors[0].node_id, entity.id);
1740 }
1741
1742 #[tokio::test]
1743 async fn link_target_in_different_namespace_returns_not_found() {
1744 let rt = rt();
1745 let a = rt
1746 .create_entity(Some("ns-a"), "concept", "A", None, None, vec![])
1747 .await
1748 .unwrap();
1749 let b = rt
1750 .create_entity(Some("ns-b"), "concept", "B", None, None, vec![])
1751 .await
1752 .unwrap();
1753
1754 let result = rt
1756 .link(Some("ns-a"), a.id, b.id, EdgeRelation::Extends, 1.0)
1757 .await;
1758 assert!(
1759 matches!(result, Err(RuntimeError::NotFound(_))),
1760 "target in different namespace must return NotFound (fail-closed), got {result:?}"
1761 );
1762 }
1763
1764 #[tokio::test]
1765 async fn link_phantom_self_loop_returns_not_found() {
1766 let rt = rt();
1767 let phantom = Uuid::new_v4();
1768
1769 let result = rt
1770 .link(None, phantom, phantom, EdgeRelation::Extends, 1.0)
1771 .await;
1772 match result {
1773 Err(RuntimeError::NotFound(msg)) => {
1774 assert!(
1775 msg.contains("source"),
1776 "self-loop must fail on source first: {msg}"
1777 );
1778 }
1779 other => panic!("expected NotFound for phantom self-loop, got {other:?}"),
1780 }
1781 }
1782
1783 #[tokio::test]
1786 async fn link_note_to_edge_annotates_succeeds() {
1787 let rt = rt();
1788 let a = rt
1789 .create_entity(None, "concept", "A", None, None, vec![])
1790 .await
1791 .unwrap();
1792 let b = rt
1793 .create_entity(None, "concept", "B", None, None, vec![])
1794 .await
1795 .unwrap();
1796 let edge = rt
1798 .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1799 .await
1800 .unwrap();
1801 let edge_uuid: Uuid = edge.id.into();
1802
1803 let note = rt
1805 .create_note(None, "observation", None, "edge note", 0.5, None, vec![])
1806 .await
1807 .unwrap();
1808
1809 let result = rt
1810 .link(None, note.id, edge_uuid, EdgeRelation::Annotates, 1.0)
1811 .await;
1812 assert!(
1813 result.is_ok(),
1814 "note→edge Annotates must succeed, got {result:?}"
1815 );
1816 }
1817
1818 #[tokio::test]
1819 async fn create_note_annotates_real_edge_succeeds() {
1820 let rt = rt();
1821 let a = rt
1822 .create_entity(None, "concept", "A", None, None, vec![])
1823 .await
1824 .unwrap();
1825 let b = rt
1826 .create_entity(None, "concept", "B", None, None, vec![])
1827 .await
1828 .unwrap();
1829 let edge = rt
1830 .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1831 .await
1832 .unwrap();
1833 let edge_uuid: Uuid = edge.id.into();
1834
1835 let note = rt
1836 .create_note(
1837 None,
1838 "observation",
1839 None,
1840 "annotating an edge",
1841 0.5,
1842 None,
1843 vec![edge_uuid],
1844 )
1845 .await
1846 .unwrap();
1847
1848 let neighbors = rt
1849 .neighbors(
1850 None,
1851 note.id,
1852 Direction::Out,
1853 None,
1854 Some(vec![EdgeRelation::Annotates]),
1855 )
1856 .await
1857 .unwrap();
1858 assert_eq!(neighbors.len(), 1);
1859 assert_eq!(neighbors[0].node_id, edge_uuid);
1860 }
1861
1862 #[tokio::test]
1863 async fn create_note_annotates_phantom_is_atomic_no_note_persisted() {
1864 let rt = rt();
1865 let phantom = Uuid::new_v4();
1866
1867 let before_count = rt.list_notes(None, None, 1000).await.unwrap().len();
1868
1869 let result = rt
1870 .create_note(
1871 None,
1872 "observation",
1873 None,
1874 "should not persist",
1875 0.5,
1876 None,
1877 vec![phantom],
1878 )
1879 .await;
1880 assert!(
1881 matches!(result, Err(RuntimeError::NotFound(_))),
1882 "phantom annotates target must return NotFound, got {result:?}"
1883 );
1884
1885 let after_count = rt.list_notes(None, None, 1000).await.unwrap().len();
1887 assert_eq!(
1888 before_count, after_count,
1889 "failed create_note must not persist any note row (atomicity)"
1890 );
1891
1892 let search_hits = rt
1894 .search_notes(None, "should not persist", None, 10)
1895 .await
1896 .unwrap();
1897 assert!(
1898 search_hits.is_empty(),
1899 "failed create_note must not index into FTS (atomicity)"
1900 );
1901 }
1904
1905 #[tokio::test]
1909 async fn link_entity_to_edge_uuid_non_annotates_returns_invalid_input() {
1910 let rt = rt();
1911 let a = rt
1912 .create_entity(None, "concept", "A", None, None, vec![])
1913 .await
1914 .unwrap();
1915 let b = rt
1916 .create_entity(None, "concept", "B", None, None, vec![])
1917 .await
1918 .unwrap();
1919 let edge = rt
1921 .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
1922 .await
1923 .unwrap();
1924 let edge_uuid: Uuid = edge.id.into();
1925
1926 let result = rt
1927 .link(None, a.id, edge_uuid, EdgeRelation::Extends, 1.0)
1928 .await;
1929 match result {
1930 Err(RuntimeError::InvalidInput(msg)) => {
1931 assert!(
1932 msg.contains("target"),
1933 "error message must name 'target': {msg}"
1934 );
1935 }
1936 other => {
1937 panic!("expected InvalidInput for edge-uuid target with Extends, got {other:?}")
1938 }
1939 }
1940 }
1941
1942 #[tokio::test]
1944 async fn link_note_as_source_non_annotates_returns_invalid_input() {
1945 let rt = rt();
1946 let note = rt
1947 .create_note(None, "observation", None, "a note", 0.5, None, vec![])
1948 .await
1949 .unwrap();
1950 let entity = rt
1951 .create_entity(None, "concept", "E", None, None, vec![])
1952 .await
1953 .unwrap();
1954
1955 let result = rt
1956 .link(None, note.id, entity.id, EdgeRelation::DependsOn, 1.0)
1957 .await;
1958 match result {
1959 Err(RuntimeError::InvalidInput(msg)) => {
1960 assert!(
1961 msg.contains("source"),
1962 "error message must name 'source': {msg}"
1963 );
1964 }
1965 other => panic!("expected InvalidInput for note source with DependsOn, got {other:?}"),
1966 }
1967 }
1968
1969 #[tokio::test]
1971 async fn link_entity_as_annotates_source_returns_invalid_input() {
1972 let rt = rt();
1973 let a = rt
1974 .create_entity(None, "concept", "A", None, None, vec![])
1975 .await
1976 .unwrap();
1977 let b = rt
1978 .create_entity(None, "concept", "B", None, None, vec![])
1979 .await
1980 .unwrap();
1981
1982 let result = rt
1983 .link(None, a.id, b.id, EdgeRelation::Annotates, 1.0)
1984 .await;
1985 match result {
1986 Err(RuntimeError::InvalidInput(msg)) => {
1987 assert!(
1988 msg.contains("source") && msg.contains("note"),
1989 "error must say source must be a note: {msg}"
1990 );
1991 }
1992 other => {
1993 panic!("expected InvalidInput for entity source with Annotates, got {other:?}")
1994 }
1995 }
1996 }
1997
1998 #[tokio::test]
1999 async fn link_edge_as_annotates_source_returns_invalid_input() {
2000 let rt = rt();
2001 let a = rt
2002 .create_entity(None, "concept", "A", None, None, vec![])
2003 .await
2004 .unwrap();
2005 let b = rt
2006 .create_entity(None, "concept", "B", None, None, vec![])
2007 .await
2008 .unwrap();
2009 let edge = rt
2010 .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
2011 .await
2012 .unwrap();
2013 let edge_uuid: Uuid = edge.id.into();
2014
2015 let result = rt
2017 .link(None, edge_uuid, a.id, EdgeRelation::Annotates, 1.0)
2018 .await;
2019 match result {
2020 Err(RuntimeError::InvalidInput(msg)) => {
2021 assert!(
2022 msg.contains("source") && msg.contains("note"),
2023 "edge-as-annotates-source must report wrong kind, not NotFound: {msg}"
2024 );
2025 }
2026 other => panic!("expected InvalidInput for edge source with Annotates, got {other:?}"),
2027 }
2028 }
2029
2030 #[tokio::test]
2032 async fn link_note_to_event_annotates_succeeds() {
2033 use khive_storage::Event;
2034 use khive_types::SubstrateKind;
2035
2036 let rt = rt();
2037 let note = rt
2038 .create_note(
2039 None,
2040 "observation",
2041 None,
2042 "observing an event",
2043 0.6,
2044 None,
2045 vec![],
2046 )
2047 .await
2048 .unwrap();
2049
2050 let ns = rt.ns(None);
2052 let event = Event::new(ns, "test_verb", SubstrateKind::Entity, "test_actor");
2053 let event_id = event.id;
2054 rt.events(None).unwrap().append_event(event).await.unwrap();
2055
2056 let result = rt
2057 .link(None, note.id, event_id, EdgeRelation::Annotates, 1.0)
2058 .await;
2059 assert!(
2060 result.is_ok(),
2061 "note→event Annotates must succeed, got {result:?}"
2062 );
2063 }
2064
2065 #[tokio::test]
2067 async fn create_note_annotates_event_succeeds() {
2068 use khive_storage::Event;
2069 use khive_types::SubstrateKind;
2070
2071 let rt = rt();
2072 let ns = rt.ns(None);
2073 let event = Event::new(ns, "test_verb", SubstrateKind::Entity, "test_actor");
2074 let event_id = event.id;
2075 rt.events(None).unwrap().append_event(event).await.unwrap();
2076
2077 let result = rt
2078 .create_note(
2079 None,
2080 "observation",
2081 None,
2082 "note annotating an event",
2083 0.5,
2084 None,
2085 vec![event_id],
2086 )
2087 .await;
2088 assert!(
2089 result.is_ok(),
2090 "create_note with event annotates target must succeed, got {result:?}"
2091 );
2092 let note = result.unwrap();
2094 let neighbors = rt
2095 .neighbors(
2096 None,
2097 note.id,
2098 Direction::Out,
2099 None,
2100 Some(vec![EdgeRelation::Annotates]),
2101 )
2102 .await
2103 .unwrap();
2104 assert_eq!(neighbors.len(), 1);
2105 assert_eq!(neighbors[0].node_id, event_id);
2106 }
2107
2108 #[tokio::test]
2112 async fn link_supersedes_note_to_note_succeeds() {
2113 let rt = rt();
2114 let old_note = rt
2115 .create_note(
2116 None,
2117 "observation",
2118 None,
2119 "old observation",
2120 0.7,
2121 None,
2122 vec![],
2123 )
2124 .await
2125 .unwrap();
2126 let new_note = rt
2127 .create_note(
2128 None,
2129 "observation",
2130 None,
2131 "revised observation superseding the old one",
2132 0.9,
2133 None,
2134 vec![],
2135 )
2136 .await
2137 .unwrap();
2138
2139 let result = rt
2140 .link(
2141 None,
2142 new_note.id,
2143 old_note.id,
2144 EdgeRelation::Supersedes,
2145 1.0,
2146 )
2147 .await;
2148 assert!(
2149 result.is_ok(),
2150 "note→note Supersedes must succeed (ADR-019 note supersession), got {result:?}"
2151 );
2152 }
2153
2154 #[tokio::test]
2155 async fn link_supersedes_entity_to_entity_succeeds() {
2156 let rt = rt();
2157 let old_entity = rt
2158 .create_entity(None, "concept", "OldConcept", None, None, vec![])
2159 .await
2160 .unwrap();
2161 let new_entity = rt
2162 .create_entity(None, "concept", "NewConcept", None, None, vec![])
2163 .await
2164 .unwrap();
2165
2166 let result = rt
2167 .link(
2168 None,
2169 new_entity.id,
2170 old_entity.id,
2171 EdgeRelation::Supersedes,
2172 1.0,
2173 )
2174 .await;
2175 assert!(
2176 result.is_ok(),
2177 "entity→entity Supersedes must succeed, got {result:?}"
2178 );
2179 }
2180
2181 #[tokio::test]
2182 async fn link_supersedes_note_to_entity_returns_invalid_input() {
2183 let rt = rt();
2184 let note = rt
2185 .create_note(None, "observation", None, "a note", 0.5, None, vec![])
2186 .await
2187 .unwrap();
2188 let entity = rt
2189 .create_entity(None, "concept", "SomeEntity", None, None, vec![])
2190 .await
2191 .unwrap();
2192
2193 let result = rt
2194 .link(None, note.id, entity.id, EdgeRelation::Supersedes, 1.0)
2195 .await;
2196 match result {
2197 Err(RuntimeError::InvalidInput(msg)) => {
2198 assert!(
2199 msg.contains("same substrate") || msg.contains("same-substrate"),
2200 "error must name the same-substrate rule: {msg}"
2201 );
2202 }
2203 other => panic!(
2204 "expected InvalidInput for note→entity Supersedes (cross-substrate), got {other:?}"
2205 ),
2206 }
2207 }
2208
2209 #[tokio::test]
2210 async fn link_supersedes_entity_to_note_returns_invalid_input() {
2211 let rt = rt();
2212 let entity = rt
2213 .create_entity(None, "concept", "SomeEntity", None, None, vec![])
2214 .await
2215 .unwrap();
2216 let note = rt
2217 .create_note(None, "observation", None, "a note", 0.5, None, vec![])
2218 .await
2219 .unwrap();
2220
2221 let result = rt
2222 .link(None, entity.id, note.id, EdgeRelation::Supersedes, 1.0)
2223 .await;
2224 match result {
2225 Err(RuntimeError::InvalidInput(msg)) => {
2226 assert!(
2227 msg.contains("same substrate") || msg.contains("same-substrate"),
2228 "error must name the same-substrate rule: {msg}"
2229 );
2230 }
2231 other => panic!(
2232 "expected InvalidInput for entity→note Supersedes (cross-substrate), got {other:?}"
2233 ),
2234 }
2235 }
2236
2237 #[tokio::test]
2238 async fn link_supersedes_event_source_returns_invalid_input() {
2239 use khive_storage::Event;
2240 use khive_types::SubstrateKind;
2241
2242 let rt = rt();
2243 let ns = rt.ns(None);
2244 let event = Event::new(ns, "test_verb", SubstrateKind::Entity, "test_actor");
2245 let event_id = event.id;
2246 rt.events(None).unwrap().append_event(event).await.unwrap();
2247
2248 let entity = rt
2249 .create_entity(None, "concept", "SomeEntity", None, None, vec![])
2250 .await
2251 .unwrap();
2252
2253 let result = rt
2254 .link(None, event_id, entity.id, EdgeRelation::Supersedes, 1.0)
2255 .await;
2256 match result {
2257 Err(RuntimeError::InvalidInput(msg)) => {
2258 assert!(msg.contains("event"), "error must mention 'event': {msg}");
2259 }
2260 other => {
2261 panic!("expected InvalidInput for event source with Supersedes, got {other:?}")
2262 }
2263 }
2264 }
2265
2266 #[tokio::test]
2267 async fn link_supersedes_event_target_returns_invalid_input() {
2268 use khive_storage::Event;
2269 use khive_types::SubstrateKind;
2270
2271 let rt = rt();
2272 let ns = rt.ns(None);
2273 let event = Event::new(ns, "test_verb", SubstrateKind::Entity, "test_actor");
2274 let event_id = event.id;
2275 rt.events(None).unwrap().append_event(event).await.unwrap();
2276
2277 let entity = rt
2278 .create_entity(None, "concept", "SomeEntity", None, None, vec![])
2279 .await
2280 .unwrap();
2281
2282 let result = rt
2283 .link(None, entity.id, event_id, EdgeRelation::Supersedes, 1.0)
2284 .await;
2285 match result {
2286 Err(RuntimeError::InvalidInput(msg)) => {
2287 assert!(msg.contains("event"), "error must mention 'event': {msg}");
2288 }
2289 other => {
2290 panic!("expected InvalidInput for event target with Supersedes, got {other:?}")
2291 }
2292 }
2293 }
2294
2295 #[tokio::test]
2296 async fn link_supersedes_edge_source_returns_invalid_input() {
2297 let rt = rt();
2298 let a = rt
2299 .create_entity(None, "concept", "A", None, None, vec![])
2300 .await
2301 .unwrap();
2302 let b = rt
2303 .create_entity(None, "concept", "B", None, None, vec![])
2304 .await
2305 .unwrap();
2306 let edge = rt
2307 .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
2308 .await
2309 .unwrap();
2310 let edge_uuid: Uuid = edge.id.into();
2311
2312 let result = rt
2313 .link(None, edge_uuid, a.id, EdgeRelation::Supersedes, 1.0)
2314 .await;
2315 match result {
2316 Err(RuntimeError::InvalidInput(msg)) => {
2317 assert!(msg.contains("source"), "error must name 'source': {msg}");
2318 }
2319 other => {
2320 panic!("expected InvalidInput for edge-uuid source with Supersedes, got {other:?}")
2321 }
2322 }
2323 }
2324
2325 #[tokio::test]
2326 async fn link_supersedes_edge_target_returns_invalid_input() {
2327 let rt = rt();
2328 let a = rt
2329 .create_entity(None, "concept", "A", None, None, vec![])
2330 .await
2331 .unwrap();
2332 let b = rt
2333 .create_entity(None, "concept", "B", None, None, vec![])
2334 .await
2335 .unwrap();
2336 let edge = rt
2337 .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
2338 .await
2339 .unwrap();
2340 let edge_uuid: Uuid = edge.id.into();
2341
2342 let result = rt
2343 .link(None, a.id, edge_uuid, EdgeRelation::Supersedes, 1.0)
2344 .await;
2345 match result {
2346 Err(RuntimeError::InvalidInput(msg)) => {
2347 assert!(msg.contains("target"), "error must name 'target': {msg}");
2348 }
2349 other => {
2350 panic!("expected InvalidInput for edge-uuid target with Supersedes, got {other:?}")
2351 }
2352 }
2353 }
2354
2355 #[tokio::test]
2356 async fn link_supersedes_phantom_source_returns_not_found() {
2357 let rt = rt();
2358 let note = rt
2359 .create_note(
2360 None,
2361 "observation",
2362 None,
2363 "existing note",
2364 0.5,
2365 None,
2366 vec![],
2367 )
2368 .await
2369 .unwrap();
2370 let phantom = Uuid::new_v4();
2371
2372 let result = rt
2373 .link(None, phantom, note.id, EdgeRelation::Supersedes, 1.0)
2374 .await;
2375 match result {
2376 Err(RuntimeError::NotFound(msg)) => {
2377 assert!(msg.contains("source"), "error must name 'source': {msg}");
2378 }
2379 other => panic!("expected NotFound for phantom source with Supersedes, got {other:?}"),
2380 }
2381 }
2382
2383 #[tokio::test]
2384 async fn link_supersedes_phantom_target_returns_not_found() {
2385 let rt = rt();
2386 let note = rt
2387 .create_note(
2388 None,
2389 "observation",
2390 None,
2391 "existing note",
2392 0.5,
2393 None,
2394 vec![],
2395 )
2396 .await
2397 .unwrap();
2398 let phantom = Uuid::new_v4();
2399
2400 let result = rt
2401 .link(None, note.id, phantom, EdgeRelation::Supersedes, 1.0)
2402 .await;
2403 match result {
2404 Err(RuntimeError::NotFound(msg)) => {
2405 assert!(msg.contains("target"), "error must name 'target': {msg}");
2406 }
2407 other => panic!("expected NotFound for phantom target with Supersedes, got {other:?}"),
2408 }
2409 }
2410
2411 #[tokio::test]
2412 async fn link_supersedes_cross_namespace_source_returns_not_found() {
2413 let rt = rt();
2414 let note_a = rt
2415 .create_note(
2416 Some("ns-a"),
2417 "observation",
2418 None,
2419 "note in ns-a",
2420 0.5,
2421 None,
2422 vec![],
2423 )
2424 .await
2425 .unwrap();
2426 let note_b = rt
2427 .create_note(
2428 Some("ns-b"),
2429 "observation",
2430 None,
2431 "note in ns-b",
2432 0.5,
2433 None,
2434 vec![],
2435 )
2436 .await
2437 .unwrap();
2438
2439 let result = rt
2441 .link(
2442 Some("ns-a"),
2443 note_b.id,
2444 note_a.id,
2445 EdgeRelation::Supersedes,
2446 1.0,
2447 )
2448 .await;
2449 assert!(
2450 matches!(result, Err(RuntimeError::NotFound(_))),
2451 "cross-namespace source with Supersedes must return NotFound (fail-closed), got {result:?}"
2452 );
2453 }
2454
2455 #[tokio::test]
2457 async fn link_extends_note_source_still_returns_invalid_input() {
2458 let rt = rt();
2459 let note = rt
2460 .create_note(
2461 None,
2462 "observation",
2463 None,
2464 "a note that cannot be an extends source",
2465 0.5,
2466 None,
2467 vec![],
2468 )
2469 .await
2470 .unwrap();
2471 let entity = rt
2472 .create_entity(None, "concept", "E", None, None, vec![])
2473 .await
2474 .unwrap();
2475
2476 let result = rt
2477 .link(None, note.id, entity.id, EdgeRelation::Extends, 1.0)
2478 .await;
2479 assert!(
2480 matches!(result, Err(RuntimeError::InvalidInput(_))),
2481 "note source with Extends must still return InvalidInput after this fix, got {result:?}"
2482 );
2483 }
2484
2485 #[tokio::test]
2487 async fn link_annotates_note_to_edge_still_succeeds_after_fix() {
2488 let rt = rt();
2489 let a = rt
2490 .create_entity(None, "concept", "A", None, None, vec![])
2491 .await
2492 .unwrap();
2493 let b = rt
2494 .create_entity(None, "concept", "B", None, None, vec![])
2495 .await
2496 .unwrap();
2497 let edge = rt
2498 .link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
2499 .await
2500 .unwrap();
2501 let edge_uuid: Uuid = edge.id.into();
2502
2503 let note = rt
2504 .create_note(
2505 None,
2506 "observation",
2507 None,
2508 "annotating an edge",
2509 0.5,
2510 None,
2511 vec![],
2512 )
2513 .await
2514 .unwrap();
2515
2516 let result = rt
2517 .link(None, note.id, edge_uuid, EdgeRelation::Annotates, 1.0)
2518 .await;
2519 assert!(
2520 result.is_ok(),
2521 "note→edge Annotates must still succeed after supersedes fix, got {result:?}"
2522 );
2523 }
2524}