Skip to main content

khive_runtime/
operations.rs

1//! High-level operations composing storage capabilities into user-facing verbs.
2
3use 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/// A note search result with UUID and salience-weighted RRF score.
22#[derive(Clone, Debug)]
23pub struct NoteSearchHit {
24    pub note_id: Uuid,
25    pub score: DeterministicScore,
26}
27
28/// Result of resolving a UUID to its substrate kind.
29#[derive(Clone, Debug)]
30pub enum Resolved {
31    Entity(Entity),
32    Note(Note),
33    Event(Event),
34}
35
36impl KhiveRuntime {
37    // ---- Entity operations ----
38
39    /// Create and persist a new entity.
40    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    /// Retrieve an entity by ID.
93    ///
94    /// Returns `None` if the entity does not exist or belongs to a different namespace.
95    /// This enforces ADR-007 namespace isolation at the runtime layer.
96    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    /// List entities in a namespace, optionally filtered by kind.
112    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    // ---- Edge operations ----
133
134    /// Create a directed edge between two entities.
135    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    /// Get immediate neighbors of a node, optionally filtered by relation type.
157    ///
158    /// Pass `relations: Some(vec![EdgeRelation::Annotates])` to retrieve only
159    /// annotation edges, enabling cross-substrate navigation as described in ADR-024.
160    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    /// Traverse the graph from a set of root nodes.
178    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    // ---- Note operations ----
187
188    /// Create and persist a note, optionally with properties and annotation targets.
189    ///
190    /// After creating the note:
191    /// - Always indexes into FTS5 at the `notes_<namespace>` key.
192    /// - If an embedding model is configured, indexes into the vector store with
193    ///   `SubstrateKind::Note`.
194    /// - For each UUID in `annotates`, creates an `EdgeRelation::Annotates` edge from
195    ///   the note to that target.
196    #[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 &note.name {
218            Some(n) => format!("{n} {}", note.content),
219            None => note.content.clone(),
220        };
221
222        // Index into FTS5.
223        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        // Index into vector store if model is configured.
237        if self.config().embedding_model.is_some() {
238            let vector = self.embed(&note.content).await?;
239            self.vectors(Some(ns))?
240                .insert(note.id, SubstrateKind::Note, ns, vector)
241                .await?;
242        }
243
244        // Create annotates edges.
245        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    /// List notes, optionally filtered by kind.
254    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    /// Search notes using a hybrid FTS5 + vector pipeline with salience weighting.
276    ///
277    /// Pipeline (per ADR-024):
278    /// 1. FTS5 query against `notes_<namespace>`.
279    /// 2. If embedding model is configured: vector search filtered to `kind="note"`.
280    /// 3. RRF fusion (k=60).
281    /// 4. Salience-weighted rerank: `score *= (0.5 + 0.5 * note.salience)`.
282    /// 5. Filter soft-deleted notes (`deleted_at IS NOT NULL`).
283    /// 6. Truncate to `limit`.
284    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        // FTS5 over the notes index.
296        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        // Vector search filtered to notes.
311        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        // RRF fusion.
325        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        // Fetch each candidate note individually to get salience and apply soft-delete filter.
343        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        // Drop superseded notes: any note targeted by a `supersedes` edge is
354        // obsolete and excluded from default search (ADR-019, ADR-024).
355        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 &note_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        // Apply salience weighting and collect final hits.
378        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    /// Resolve a UUID to its substrate kind by trying entity, then note, then event stores.
397    ///
398    /// Returns `None` if the UUID is not found in any substrate.
399    /// Cost: at most 3 store lookups per call (cheap for v0.1).
400    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        // Entity: use the namespace-checked getter (returns None on mismatch).
408        if let Some(entity) = self.get_entity(namespace, id).await? {
409            return Ok(Some(Resolved::Entity(entity)));
410        }
411
412        // Note: storage get_note is ID-only — verify namespace after fetch.
413        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        // Event: storage get_event is ID-only — verify namespace after fetch.
420        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    /// Delete a note by ID, enforcing namespace isolation.
430    ///
431    /// Returns `false` without deleting if the note does not exist or belongs to
432    /// a different namespace (ADR-007 namespace isolation).
433    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    // ---- Query operations ----
457
458    /// Execute a GQL or SPARQL query string, returning raw SQL rows.
459    ///
460    /// The query is compiled to SQL with the namespace scope applied.
461    /// GQL syntax: `MATCH (a:concept)-[e:extends]->(b) RETURN a, b LIMIT 10`
462    /// SPARQL syntax: `SELECT ?a WHERE { ?a :kind "concept" . }`
463    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    /// Delete an entity by ID (soft delete by default).
485    ///
486    /// On hard delete, cascades to remove all incident edges (both inbound and
487    /// outbound) to prevent dangling references. Soft delete leaves edges in
488    /// place — queries already filter by `deleted_at IS NULL`.
489    ///
490    /// Returns `false` without deleting if the entity exists but belongs to a
491    /// different namespace (ADR-007 namespace isolation).
492    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        // On hard delete, cascade-remove incident edges to prevent dangling refs.
512        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    /// Count entities in a namespace, optionally filtered.
537    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    // ---- Edge CRUD operations ----
556
557    /// Fetch a single edge by id. Returns `None` if the edge does not exist.
558    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    /// List edges matching `filter`. `limit` is capped at 1000; defaults to 100.
570    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    /// Patch-style edge update. Only `Some(_)` fields are applied.
592    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    /// Hard-delete an edge by id. Returns `true` if an edge was removed.
617    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    /// Count edges matching `filter`.
625    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        // Same namespace: visible.
836        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        // Different namespace: invisible.
840        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        // Delete from wrong namespace: no-op, returns false.
856        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        // Entity still present in its own namespace.
863        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        // Delete from correct namespace: succeeds.
870        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    // ---- Note ADR-024 tests ----
878
879    #[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        // FTS5 should have indexed the note content.
896        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        // The note should have an outbound `annotates` edge to the entity.
961        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        // The entity should have an inbound `annotates` edge from the note.
976        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        // Soft-delete the note.
1098        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}