use std::collections::HashMap;
use std::str::FromStr;
use uuid::Uuid;
use khive_score::{rrf_score, DeterministicScore};
use khive_storage::note::Note;
use khive_storage::types::{
DeleteMode, Direction, EdgeSortField, GraphPath, LinkId, NeighborHit, NeighborQuery,
PageRequest, SortOrder, SqlStatement, TextDocument, TextFilter, TextQueryMode,
TextSearchRequest, TraversalRequest, VectorSearchRequest,
};
use khive_storage::{Edge, EdgeRelation, Entity, EntityFilter, Event};
use khive_types::SubstrateKind;
use crate::error::{RuntimeError, RuntimeResult};
use crate::runtime::KhiveRuntime;
#[derive(Clone, Debug)]
pub struct NoteSearchHit {
pub note_id: Uuid,
pub score: DeterministicScore,
}
#[derive(Clone, Debug)]
pub enum Resolved {
Entity(Entity),
Note(Note),
Event(Event),
}
impl KhiveRuntime {
pub async fn create_entity(
&self,
namespace: Option<&str>,
kind: &str,
name: &str,
description: Option<&str>,
properties: Option<serde_json::Value>,
tags: Vec<String>,
) -> RuntimeResult<Entity> {
let ns = self.ns(namespace);
let mut entity = Entity::new(ns, kind, name);
if let Some(d) = description {
entity = entity.with_description(d);
}
if let Some(p) = properties {
entity = entity.with_properties(p);
}
if !tags.is_empty() {
entity = entity.with_tags(tags);
}
self.entities(Some(ns))?
.upsert_entity(entity.clone())
.await?;
let body = match &entity.description {
Some(d) if !d.is_empty() => format!("{} {}", entity.name, d),
_ => entity.name.clone(),
};
self.text(namespace)?
.upsert_document(TextDocument {
subject_id: entity.id,
kind: SubstrateKind::Entity,
title: Some(entity.name.clone()),
body: body.clone(),
tags: entity.tags.clone(),
namespace: ns.to_string(),
metadata: entity.properties.clone(),
updated_at: chrono::Utc::now(),
})
.await?;
if self.config().embedding_model.is_some() {
let vector = self.embed(&body).await?;
self.vectors(namespace)?
.insert(entity.id, SubstrateKind::Entity, ns, vector)
.await?;
}
Ok(entity)
}
pub async fn get_entity(
&self,
namespace: Option<&str>,
id: Uuid,
) -> RuntimeResult<Option<Entity>> {
let entity = match self.entities(namespace)?.get_entity(id).await? {
Some(e) => e,
None => return Ok(None),
};
if entity.namespace != self.ns(namespace) {
return Ok(None);
}
Ok(Some(entity))
}
pub async fn list_entities(
&self,
namespace: Option<&str>,
kind: Option<&str>,
limit: u32,
) -> RuntimeResult<Vec<Entity>> {
let filter = EntityFilter {
kinds: match kind {
Some(k) => vec![k.to_string()],
None => vec![],
},
..Default::default()
};
let page = self
.entities(namespace)?
.query_entities(self.ns(namespace), filter, PageRequest { offset: 0, limit })
.await?;
Ok(page.items)
}
async fn validate_edge_relation_endpoints(
&self,
namespace: Option<&str>,
source_id: Uuid,
target_id: Uuid,
relation: EdgeRelation,
) -> RuntimeResult<()> {
if relation == EdgeRelation::Annotates {
match self.resolve(namespace, source_id).await? {
Some(Resolved::Note(_)) => {}
Some(_) => {
return Err(RuntimeError::InvalidInput(format!(
"annotates source {source_id} must be a note"
)));
}
None => {
if self.get_edge(namespace, source_id).await?.is_some() {
return Err(RuntimeError::InvalidInput(format!(
"annotates source {source_id} must be a note"
)));
}
return Err(RuntimeError::NotFound(format!(
"link source {source_id} not found in namespace"
)));
}
}
if !self.substrate_exists_in_ns(namespace, target_id).await? {
return Err(RuntimeError::NotFound(format!(
"link target {target_id} not found in namespace"
)));
}
} else if relation == EdgeRelation::Supersedes {
let src = match self.resolve(namespace, source_id).await? {
Some(r) => r,
None => {
if self.get_edge(namespace, source_id).await?.is_some() {
return Err(RuntimeError::InvalidInput(format!(
"supersedes source {source_id} must be a note or entity (got edge)"
)));
}
return Err(RuntimeError::NotFound(format!(
"link source {source_id} not found in namespace"
)));
}
};
let tgt = match self.resolve(namespace, target_id).await? {
Some(r) => r,
None => {
if self.get_edge(namespace, target_id).await?.is_some() {
return Err(RuntimeError::InvalidInput(format!(
"supersedes target {target_id} must be a note or entity (got edge)"
)));
}
return Err(RuntimeError::NotFound(format!(
"link target {target_id} not found in namespace"
)));
}
};
match (&src, &tgt) {
(Resolved::Entity(_), Resolved::Entity(_)) => {}
(Resolved::Note(_), Resolved::Note(_)) => {}
(Resolved::Event(_), _) => {
return Err(RuntimeError::InvalidInput(format!(
"supersedes does not apply to events; source {source_id} is an event"
)));
}
(_, Resolved::Event(_)) => {
return Err(RuntimeError::InvalidInput(format!(
"supersedes does not apply to events; target {target_id} is an event"
)));
}
(Resolved::Entity(_), Resolved::Note(_)) => {
return Err(RuntimeError::InvalidInput(format!(
"supersedes endpoints must be the same substrate (note→note or entity→entity); \
got source={source_id} (entity) target={target_id} (note)"
)));
}
(Resolved::Note(_), Resolved::Entity(_)) => {
return Err(RuntimeError::InvalidInput(format!(
"supersedes endpoints must be the same substrate (note→note or entity→entity); \
got source={source_id} (note) target={target_id} (entity)"
)));
}
}
} else {
match self.resolve(namespace, source_id).await? {
Some(Resolved::Entity(_)) => {}
Some(_) => {
return Err(RuntimeError::InvalidInput(format!(
"link source {source_id} must be an entity for relation {relation:?} \
(ADR-002: only `annotates` crosses substrates)"
)));
}
None => {
if self.get_edge(namespace, source_id).await?.is_some() {
return Err(RuntimeError::InvalidInput(format!(
"link source {source_id} must be an entity for relation {relation:?} \
(ADR-002: only `annotates` crosses substrates)"
)));
}
return Err(RuntimeError::NotFound(format!(
"link source {source_id} not found in namespace"
)));
}
}
match self.resolve(namespace, target_id).await? {
Some(Resolved::Entity(_)) => {}
Some(_) => {
return Err(RuntimeError::InvalidInput(format!(
"link target {target_id} must be an entity for relation {relation:?} \
(ADR-002: only `annotates` crosses substrates)"
)));
}
None => {
if self.get_edge(namespace, target_id).await?.is_some() {
return Err(RuntimeError::InvalidInput(format!(
"link target {target_id} must be an entity for relation {relation:?} \
(ADR-002: only `annotates` crosses substrates)"
)));
}
return Err(RuntimeError::NotFound(format!(
"link target {target_id} not found in namespace"
)));
}
}
}
Ok(())
}
pub async fn link(
&self,
namespace: Option<&str>,
source_id: Uuid,
target_id: Uuid,
relation: EdgeRelation,
weight: f64,
) -> RuntimeResult<Edge> {
self.validate_edge_relation_endpoints(namespace, source_id, target_id, relation)
.await?;
let edge = Edge {
id: LinkId::from(Uuid::new_v4()),
source_id,
target_id,
relation,
weight,
created_at: chrono::Utc::now(),
metadata: None,
};
self.graph(namespace)?.upsert_edge(edge.clone()).await?;
Ok(edge)
}
async fn substrate_exists_in_ns(
&self,
namespace: Option<&str>,
id: Uuid,
) -> RuntimeResult<bool> {
if self.resolve(namespace, id).await?.is_some() {
return Ok(true);
}
Ok(self.get_edge(namespace, id).await?.is_some())
}
pub async fn neighbors(
&self,
namespace: Option<&str>,
node_id: Uuid,
direction: Direction,
limit: Option<u32>,
relations: Option<Vec<EdgeRelation>>,
) -> RuntimeResult<Vec<NeighborHit>> {
let query = NeighborQuery {
direction,
relations,
limit,
min_weight: None,
};
Ok(self.graph(namespace)?.neighbors(node_id, query).await?)
}
pub async fn traverse(
&self,
namespace: Option<&str>,
request: TraversalRequest,
) -> RuntimeResult<Vec<GraphPath>> {
Ok(self.graph(namespace)?.traverse(request).await?)
}
#[allow(clippy::too_many_arguments)]
pub async fn create_note(
&self,
namespace: Option<&str>,
kind: &str,
name: Option<&str>,
content: &str,
salience: f64,
properties: Option<serde_json::Value>,
annotates: Vec<Uuid>,
) -> RuntimeResult<Note> {
let ns = self.ns(namespace);
for &target_id in &annotates {
if !self.substrate_exists_in_ns(namespace, target_id).await? {
return Err(RuntimeError::NotFound(format!(
"create_note annotates target {target_id} not found in namespace"
)));
}
}
let mut note = Note::new(ns, kind, content).with_salience(salience);
if let Some(n) = name {
note = note.with_name(n);
}
if let Some(p) = properties {
note = note.with_properties(p);
}
self.notes(Some(ns))?.upsert_note(note.clone()).await?;
let body = match ¬e.name {
Some(n) => format!("{n} {}", note.content),
None => note.content.clone(),
};
self.text_for_notes(Some(ns))?
.upsert_document(TextDocument {
subject_id: note.id,
kind: SubstrateKind::Note,
title: note.name.clone(),
body,
tags: vec![],
namespace: ns.to_string(),
metadata: note.properties.clone(),
updated_at: chrono::Utc::now(),
})
.await?;
if self.config().embedding_model.is_some() {
let vector = self.embed(¬e.content).await?;
self.vectors(Some(ns))?
.insert(note.id, SubstrateKind::Note, ns, vector)
.await?;
}
for target_id in annotates {
self.link(Some(ns), note.id, target_id, EdgeRelation::Annotates, 1.0)
.await?;
}
Ok(note)
}
pub async fn list_notes(
&self,
namespace: Option<&str>,
kind: Option<&str>,
limit: u32,
) -> RuntimeResult<Vec<Note>> {
let page = self
.notes(namespace)?
.query_notes(self.ns(namespace), kind, PageRequest { offset: 0, limit })
.await?;
Ok(page.items)
}
pub async fn search_notes(
&self,
namespace: Option<&str>,
query_text: &str,
query_vector: Option<Vec<f32>>,
limit: u32,
) -> RuntimeResult<Vec<NoteSearchHit>> {
const RRF_K: usize = 60;
let candidates = limit.saturating_mul(4).max(limit);
let ns = self.ns(namespace).to_string();
let text_hits = self
.text_for_notes(namespace)?
.search(TextSearchRequest {
query: query_text.to_string(),
mode: TextQueryMode::Plain,
filter: Some(TextFilter {
namespaces: vec![ns.clone()],
..TextFilter::default()
}),
top_k: candidates,
snippet_chars: 200,
})
.await?;
let vector_hits = if let Some(vec) = query_vector {
self.vectors(namespace)?
.search(VectorSearchRequest {
query_embedding: vec,
top_k: candidates,
namespace: Some(ns.clone()),
kind: Some(SubstrateKind::Note),
})
.await?
} else {
vec![]
};
let mut buckets: HashMap<Uuid, DeterministicScore> = HashMap::new();
for (i, hit) in text_hits.into_iter().enumerate() {
let rank = i + 1;
let entry = buckets.entry(hit.subject_id).or_default();
*entry = *entry + rrf_score(rank, RRF_K);
}
for (i, hit) in vector_hits.into_iter().enumerate() {
let rank = i + 1;
let entry = buckets.entry(hit.subject_id).or_default();
*entry = *entry + rrf_score(rank, RRF_K);
}
let candidate_ids: Vec<Uuid> = buckets.keys().copied().collect();
if candidate_ids.is_empty() {
return Ok(vec![]);
}
let note_store = self.notes(namespace)?;
let mut alive_notes: HashMap<Uuid, Note> = HashMap::new();
for id in &candidate_ids {
if let Some(note) = note_store.get_note(*id).await? {
if note.deleted_at.is_none() {
alive_notes.insert(*id, note);
}
}
}
if !alive_notes.is_empty() {
let graph = self.graph(namespace)?;
let mut superseded: std::collections::HashSet<Uuid> = std::collections::HashSet::new();
for ¬e_id in alive_notes.keys() {
let inbound = graph
.neighbors(
note_id,
NeighborQuery {
direction: Direction::In,
relations: Some(vec![EdgeRelation::Supersedes]),
limit: Some(1),
min_weight: None,
},
)
.await?;
if !inbound.is_empty() {
superseded.insert(note_id);
}
}
alive_notes.retain(|id, _| !superseded.contains(id));
}
let mut hits: Vec<NoteSearchHit> = buckets
.into_iter()
.filter_map(|(id, rrf)| {
let note = alive_notes.get(&id)?;
let weight = 0.5 + 0.5 * note.salience;
let weighted = DeterministicScore::from_f64(rrf.to_f64() * weight);
Some(NoteSearchHit {
note_id: id,
score: weighted,
})
})
.collect();
hits.sort_by(|a, b| b.score.cmp(&a.score).then(a.note_id.cmp(&b.note_id)));
hits.truncate(limit as usize);
Ok(hits)
}
pub async fn resolve_prefix(
&self,
namespace: Option<&str>,
prefix: &str,
) -> RuntimeResult<Option<Uuid>> {
use khive_storage::types::{SqlStatement, SqlValue};
let ns = self.ns(namespace).to_string();
let pattern = format!("{}%", prefix);
let tables = [("entities", true), ("notes", true), ("graph_edges", false)];
let mut matches: Vec<String> = Vec::new();
let mut reader = self.sql().reader().await.map_err(RuntimeError::Storage)?;
for (table, has_deleted_at) in tables {
let deleted_filter = if has_deleted_at {
" AND deleted_at IS NULL"
} else {
""
};
let sql = SqlStatement {
sql: format!(
"SELECT id FROM {table} WHERE id LIKE ?1 AND namespace = ?2{deleted_filter} LIMIT 2"
),
params: vec![
SqlValue::Text(pattern.clone()),
SqlValue::Text(ns.clone()),
],
label: Some("resolve_prefix".into()),
};
match reader.query_all(sql).await {
Ok(rows) => {
for row in rows {
if let Some(col) = row.columns.first() {
if let SqlValue::Text(s) = &col.value {
matches.push(s.clone());
}
}
}
}
Err(e) => {
let msg = e.to_string();
if msg.contains("no such table") {
continue;
}
return Err(RuntimeError::Storage(e));
}
}
if matches.len() > 1 {
break;
}
}
match matches.len() {
0 => Ok(None),
1 => {
let uuid = Uuid::from_str(&matches[0])
.map_err(|e| RuntimeError::Internal(format!("stored UUID is invalid: {e}")))?;
Ok(Some(uuid))
}
_ => Err(RuntimeError::Ambiguous(format!(
"prefix '{prefix}' matches multiple UUIDs"
))),
}
}
pub async fn resolve(
&self,
namespace: Option<&str>,
id: Uuid,
) -> RuntimeResult<Option<Resolved>> {
let ns = self.ns(namespace);
if let Some(entity) = self.get_entity(namespace, id).await? {
return Ok(Some(Resolved::Entity(entity)));
}
if let Some(note) = self.notes(namespace)?.get_note(id).await? {
if note.namespace == ns {
return Ok(Some(Resolved::Note(note)));
}
}
if let Some(event) = self.events(namespace)?.get_event(id).await? {
if event.namespace == ns {
return Ok(Some(Resolved::Event(event)));
}
}
Ok(None)
}
pub async fn delete_note(
&self,
namespace: Option<&str>,
id: Uuid,
hard: bool,
) -> RuntimeResult<bool> {
let ns = self.ns(namespace);
let note_store = self.notes(namespace)?;
let note = match note_store.get_note(id).await? {
Some(n) => n,
None => return Ok(false),
};
if note.namespace != ns {
return Ok(false);
}
let mode = if hard {
DeleteMode::Hard
} else {
DeleteMode::Soft
};
Ok(note_store.delete_note(id, mode).await?)
}
pub async fn query(
&self,
namespace: Option<&str>,
query: &str,
) -> RuntimeResult<Vec<khive_storage::types::SqlRow>> {
let ns = self.ns(namespace);
let ast = khive_query::parse_auto(query)?;
let opts = khive_query::CompileOptions {
scopes: vec![ns.to_string()],
..Default::default()
};
let compiled = khive_query::compile(&ast, &opts)?;
let mut reader = self.sql().reader().await?;
let stmt = SqlStatement {
sql: compiled.sql,
params: compiled.params,
label: None,
};
Ok(reader.query_all(stmt).await?)
}
pub async fn delete_entity(
&self,
namespace: Option<&str>,
id: Uuid,
hard: bool,
) -> RuntimeResult<bool> {
let entity = match self.entities(namespace)?.get_entity(id).await? {
Some(e) => e,
None => return Ok(false),
};
if entity.namespace != self.ns(namespace) {
return Ok(false);
}
let mode = if hard {
DeleteMode::Hard
} else {
DeleteMode::Soft
};
if hard {
let graph = self.graph(namespace)?;
for direction in [Direction::Out, Direction::In] {
let hits = graph
.neighbors(
id,
NeighborQuery {
direction,
relations: None,
limit: None,
min_weight: None,
},
)
.await?;
for hit in hits {
graph.delete_edge(LinkId::from(hit.edge_id)).await?;
}
}
self.remove_from_indexes(namespace, id).await?;
}
Ok(self.entities(namespace)?.delete_entity(id, mode).await?)
}
pub async fn count_entities(
&self,
namespace: Option<&str>,
kind: Option<&str>,
) -> RuntimeResult<u64> {
let filter = EntityFilter {
kinds: match kind {
Some(k) => vec![k.to_string()],
None => vec![],
},
..Default::default()
};
Ok(self
.entities(namespace)?
.count_entities(self.ns(namespace), filter)
.await?)
}
pub async fn get_edge(
&self,
namespace: Option<&str>,
edge_id: Uuid,
) -> RuntimeResult<Option<Edge>> {
Ok(self
.graph(namespace)?
.get_edge(LinkId::from(edge_id))
.await?)
}
pub async fn list_edges(
&self,
namespace: Option<&str>,
filter: crate::curation::EdgeListFilter,
limit: u32,
) -> RuntimeResult<Vec<Edge>> {
let limit = limit.clamp(1, 1000);
let page = self
.graph(namespace)?
.query_edges(
filter.into(),
vec![SortOrder {
field: EdgeSortField::CreatedAt,
direction: khive_storage::types::SortDirection::Asc,
}],
PageRequest { offset: 0, limit },
)
.await?;
Ok(page.items)
}
pub async fn update_edge(
&self,
namespace: Option<&str>,
edge_id: Uuid,
relation: Option<EdgeRelation>,
weight: Option<f64>,
) -> RuntimeResult<Edge> {
let graph = self.graph(namespace)?;
let mut edge = graph
.get_edge(LinkId::from(edge_id))
.await?
.ok_or_else(|| crate::RuntimeError::NotFound(format!("edge {edge_id}")))?;
if let Some(r) = relation {
self.validate_edge_relation_endpoints(namespace, edge.source_id, edge.target_id, r)
.await?;
edge.relation = r;
}
if let Some(w) = weight {
edge.weight = w.clamp(0.0, 1.0);
}
graph.upsert_edge(edge.clone()).await?;
Ok(edge)
}
pub async fn delete_edge(&self, namespace: Option<&str>, edge_id: Uuid) -> RuntimeResult<bool> {
Ok(self
.graph(namespace)?
.delete_edge(LinkId::from(edge_id))
.await?)
}
pub async fn count_edges(
&self,
namespace: Option<&str>,
filter: crate::curation::EdgeListFilter,
) -> RuntimeResult<u64> {
Ok(self.graph(namespace)?.count_edges(filter.into()).await?)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::curation::EdgeListFilter;
use crate::runtime::KhiveRuntime;
fn rt() -> KhiveRuntime {
KhiveRuntime::memory().unwrap()
}
#[tokio::test]
async fn update_edge_changes_weight() {
let rt = rt();
let a = rt
.create_entity(None, "concept", "A", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(None, "concept", "B", None, None, vec![])
.await
.unwrap();
let edge = rt
.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
.await
.unwrap();
let edge_id: Uuid = edge.id.into();
let updated = rt
.update_edge(None, edge_id, None, Some(0.5))
.await
.unwrap();
assert!((updated.weight - 0.5).abs() < 0.001);
}
#[tokio::test]
async fn update_edge_changes_relation() {
let rt = rt();
let a = rt
.create_entity(None, "concept", "A", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(None, "concept", "B", None, None, vec![])
.await
.unwrap();
let edge = rt
.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
.await
.unwrap();
let edge_id: Uuid = edge.id.into();
let updated = rt
.update_edge(None, edge_id, Some(EdgeRelation::VariantOf), None)
.await
.unwrap();
assert_eq!(updated.relation, EdgeRelation::VariantOf);
}
#[tokio::test]
async fn update_edge_annotates_note_to_entity_set_supersedes_returns_invalid_input() {
let rt = rt();
let note = rt
.create_note(None, "observation", None, "a note", 0.5, None, vec![])
.await
.unwrap();
let entity = rt
.create_entity(None, "concept", "E", None, None, vec![])
.await
.unwrap();
let edge = rt
.link(None, note.id, entity.id, EdgeRelation::Annotates, 1.0)
.await
.unwrap();
let edge_id: Uuid = edge.id.into();
let result = rt
.update_edge(None, edge_id, Some(EdgeRelation::Supersedes), None)
.await;
assert!(
matches!(result, Err(RuntimeError::InvalidInput(_))),
"update to Supersedes on note→entity edge must return InvalidInput, got {result:?}"
);
let fetched = rt.get_edge(None, edge_id).await.unwrap().unwrap();
assert_eq!(
fetched.relation,
EdgeRelation::Annotates,
"edge relation must be unchanged after failed update"
);
}
#[tokio::test]
async fn update_edge_entity_to_entity_set_annotates_returns_invalid_input() {
let rt = rt();
let a = rt
.create_entity(None, "concept", "A", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(None, "concept", "B", None, None, vec![])
.await
.unwrap();
let edge = rt
.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
.await
.unwrap();
let edge_id: Uuid = edge.id.into();
let result = rt
.update_edge(None, edge_id, Some(EdgeRelation::Annotates), None)
.await;
assert!(
matches!(result, Err(RuntimeError::InvalidInput(_))),
"update to Annotates on entity→entity edge must return InvalidInput, got {result:?}"
);
}
#[tokio::test]
async fn update_edge_entity_to_entity_set_supersedes_succeeds() {
let rt = rt();
let a = rt
.create_entity(None, "concept", "A", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(None, "concept", "B", None, None, vec![])
.await
.unwrap();
let edge = rt
.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
.await
.unwrap();
let edge_id: Uuid = edge.id.into();
let updated = rt
.update_edge(None, edge_id, Some(EdgeRelation::Supersedes), None)
.await
.unwrap();
assert_eq!(updated.relation, EdgeRelation::Supersedes);
let fetched = rt.get_edge(None, edge_id).await.unwrap().unwrap();
assert_eq!(fetched.relation, EdgeRelation::Supersedes);
}
#[tokio::test]
async fn update_edge_weight_only_skips_validation() {
let rt = rt();
let a = rt
.create_entity(None, "concept", "A", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(None, "concept", "B", None, None, vec![])
.await
.unwrap();
let edge = rt
.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
.await
.unwrap();
let edge_id: Uuid = edge.id.into();
let updated = rt
.update_edge(None, edge_id, None, Some(0.3))
.await
.unwrap();
assert_eq!(updated.relation, EdgeRelation::Extends);
assert!((updated.weight - 0.3).abs() < 0.001);
}
#[tokio::test]
async fn update_edge_same_class_relation_change_succeeds() {
let rt = rt();
let a = rt
.create_entity(None, "concept", "A", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(None, "concept", "B", None, None, vec![])
.await
.unwrap();
let edge = rt
.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
.await
.unwrap();
let edge_id: Uuid = edge.id.into();
let updated = rt
.update_edge(None, edge_id, Some(EdgeRelation::VariantOf), None)
.await
.unwrap();
assert_eq!(updated.relation, EdgeRelation::VariantOf);
}
#[tokio::test]
async fn list_edges_filters_by_relation() {
let rt = rt();
let a = rt
.create_entity(None, "concept", "A", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(None, "concept", "B", None, None, vec![])
.await
.unwrap();
let c = rt
.create_entity(None, "concept", "C", None, None, vec![])
.await
.unwrap();
rt.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
.await
.unwrap();
rt.link(None, a.id, c.id, EdgeRelation::DependsOn, 1.0)
.await
.unwrap();
let filter = EdgeListFilter {
relations: vec![EdgeRelation::Extends],
..Default::default()
};
let edges = rt.list_edges(None, filter, 100).await.unwrap();
assert_eq!(edges.len(), 1);
assert_eq!(edges[0].relation, EdgeRelation::Extends);
}
#[tokio::test]
async fn list_edges_filters_by_source() {
let rt = rt();
let a = rt
.create_entity(None, "concept", "A", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(None, "concept", "B", None, None, vec![])
.await
.unwrap();
let c = rt
.create_entity(None, "concept", "C", None, None, vec![])
.await
.unwrap();
let d = rt
.create_entity(None, "concept", "D", None, None, vec![])
.await
.unwrap();
rt.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
.await
.unwrap();
rt.link(None, c.id, d.id, EdgeRelation::Extends, 1.0)
.await
.unwrap();
let filter = EdgeListFilter {
source_id: Some(a.id),
..Default::default()
};
let edges = rt.list_edges(None, filter, 100).await.unwrap();
assert_eq!(edges.len(), 1);
let src: Uuid = edges[0].source_id;
assert_eq!(src, a.id);
}
#[tokio::test]
async fn delete_edge_removes_from_storage() {
let rt = rt();
let a = rt
.create_entity(None, "concept", "A", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(None, "concept", "B", None, None, vec![])
.await
.unwrap();
let edge = rt
.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
.await
.unwrap();
let edge_id: Uuid = edge.id.into();
let deleted = rt.delete_edge(None, edge_id).await.unwrap();
assert!(deleted);
let fetched = rt.get_edge(None, edge_id).await.unwrap();
assert!(fetched.is_none(), "edge should be gone after delete");
}
#[tokio::test]
async fn count_edges_matches_filter() {
let rt = rt();
let a = rt
.create_entity(None, "concept", "A", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(None, "concept", "B", None, None, vec![])
.await
.unwrap();
let c = rt
.create_entity(None, "concept", "C", None, None, vec![])
.await
.unwrap();
rt.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
.await
.unwrap();
rt.link(None, a.id, c.id, EdgeRelation::DependsOn, 1.0)
.await
.unwrap();
let all = rt
.count_edges(None, EdgeListFilter::default())
.await
.unwrap();
assert_eq!(all, 2);
let just_extends = rt
.count_edges(
None,
EdgeListFilter {
relations: vec![EdgeRelation::Extends],
..Default::default()
},
)
.await
.unwrap();
assert_eq!(just_extends, 1);
}
#[tokio::test]
async fn get_entity_namespace_isolation() {
let rt = rt();
let entity = rt
.create_entity(Some("ns-a"), "concept", "Alpha", None, None, vec![])
.await
.unwrap();
let found = rt.get_entity(Some("ns-a"), entity.id).await.unwrap();
assert!(found.is_some(), "should be visible in its own namespace");
let not_found = rt.get_entity(Some("ns-b"), entity.id).await.unwrap();
assert!(
not_found.is_none(),
"should not be visible across namespaces"
);
}
#[tokio::test]
async fn delete_entity_namespace_isolation() {
let rt = rt();
let entity = rt
.create_entity(Some("ns-a"), "concept", "Beta", None, None, vec![])
.await
.unwrap();
let deleted = rt
.delete_entity(Some("ns-b"), entity.id, true)
.await
.unwrap();
assert!(!deleted, "cross-namespace delete must return false");
let still_there = rt.get_entity(Some("ns-a"), entity.id).await.unwrap();
assert!(
still_there.is_some(),
"entity must survive cross-ns delete attempt"
);
let deleted_ok = rt
.delete_entity(Some("ns-a"), entity.id, true)
.await
.unwrap();
assert!(deleted_ok, "same-namespace delete must succeed");
}
#[tokio::test]
async fn create_note_indexes_into_fts5() {
let rt = rt();
let note = rt
.create_note(
None,
"observation",
None,
"FlashAttention reduces memory by using tiling",
0.8,
None,
vec![],
)
.await
.unwrap();
let ns = rt.ns(None).to_string();
let hits = rt
.text_for_notes(None)
.unwrap()
.search(khive_storage::types::TextSearchRequest {
query: "FlashAttention".to_string(),
mode: khive_storage::types::TextQueryMode::Plain,
filter: Some(khive_storage::types::TextFilter {
namespaces: vec![ns],
..Default::default()
}),
top_k: 10,
snippet_chars: 100,
})
.await
.unwrap();
assert!(
hits.iter().any(|h| h.subject_id == note.id),
"note should be indexed in FTS5 after create"
);
}
#[tokio::test]
async fn create_note_with_properties() {
let rt = rt();
let props = serde_json::json!({"source": "arxiv:2205.14135"});
let note = rt
.create_note(
None,
"insight",
None,
"FlashAttention is IO-aware",
0.9,
Some(props.clone()),
vec![],
)
.await
.unwrap();
assert_eq!(note.properties.as_ref().unwrap(), &props);
}
#[tokio::test]
async fn create_note_creates_annotates_edges() {
let rt = rt();
let entity = rt
.create_entity(None, "concept", "FlashAttention", None, None, vec![])
.await
.unwrap();
let note = rt
.create_note(
None,
"observation",
None,
"FlashAttention uses SRAM tiling for memory efficiency",
0.9,
None,
vec![entity.id],
)
.await
.unwrap();
let out_neighbors = rt
.neighbors(
None,
note.id,
Direction::Out,
None,
Some(vec![EdgeRelation::Annotates]),
)
.await
.unwrap();
assert_eq!(out_neighbors.len(), 1);
assert_eq!(out_neighbors[0].node_id, entity.id);
assert_eq!(out_neighbors[0].relation, EdgeRelation::Annotates);
let in_neighbors = rt
.neighbors(
None,
entity.id,
Direction::In,
None,
Some(vec![EdgeRelation::Annotates]),
)
.await
.unwrap();
assert_eq!(in_neighbors.len(), 1);
assert_eq!(in_neighbors[0].node_id, note.id);
}
#[tokio::test]
async fn neighbors_without_relation_filter_returns_all() {
let rt = rt();
let a = rt
.create_entity(None, "concept", "A", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(None, "concept", "B", None, None, vec![])
.await
.unwrap();
let c = rt
.create_entity(None, "concept", "C", None, None, vec![])
.await
.unwrap();
rt.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
.await
.unwrap();
rt.link(None, a.id, c.id, EdgeRelation::DependsOn, 1.0)
.await
.unwrap();
let all = rt
.neighbors(None, a.id, Direction::Out, None, None)
.await
.unwrap();
assert_eq!(all.len(), 2);
}
#[tokio::test]
async fn neighbors_with_relation_filter_returns_subset() {
let rt = rt();
let a = rt
.create_entity(None, "concept", "A", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(None, "concept", "B", None, None, vec![])
.await
.unwrap();
let c = rt
.create_entity(None, "concept", "C", None, None, vec![])
.await
.unwrap();
rt.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
.await
.unwrap();
rt.link(None, a.id, c.id, EdgeRelation::DependsOn, 1.0)
.await
.unwrap();
let filtered = rt
.neighbors(
None,
a.id,
Direction::Out,
None,
Some(vec![EdgeRelation::Extends]),
)
.await
.unwrap();
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].node_id, b.id);
assert_eq!(filtered[0].relation, EdgeRelation::Extends);
}
#[tokio::test]
async fn search_notes_returns_relevant_note() {
let rt = rt();
rt.create_note(
None,
"observation",
None,
"GQA reduces KV cache memory for large models",
0.8,
None,
vec![],
)
.await
.unwrap();
let results = rt
.search_notes(None, "GQA KV cache", None, 10)
.await
.unwrap();
assert!(!results.is_empty(), "search should return the indexed note");
}
#[tokio::test]
async fn search_notes_excludes_soft_deleted() {
let rt = rt();
let note = rt
.create_note(
None,
"observation",
None,
"RoPE positional encoding rotary embeddings",
0.7,
None,
vec![],
)
.await
.unwrap();
rt.notes(None)
.unwrap()
.delete_note(note.id, DeleteMode::Soft)
.await
.unwrap();
let results = rt
.search_notes(None, "RoPE rotary positional", None, 10)
.await
.unwrap();
assert!(
results.iter().all(|h| h.note_id != note.id),
"soft-deleted note should be excluded from search"
);
}
#[tokio::test]
async fn resolve_returns_entity() {
let rt = rt();
let entity = rt
.create_entity(None, "concept", "LoRA", None, None, vec![])
.await
.unwrap();
let resolved = rt.resolve(None, entity.id).await.unwrap();
match resolved {
Some(Resolved::Entity(e)) => assert_eq!(e.id, entity.id),
other => panic!("expected Resolved::Entity, got {:?}", other),
}
}
#[tokio::test]
async fn resolve_returns_note() {
let rt = rt();
let note = rt
.create_note(
None,
"observation",
None,
"LoRA fine-tunes LLMs with low-rank adapters",
0.85,
None,
vec![],
)
.await
.unwrap();
let resolved = rt.resolve(None, note.id).await.unwrap();
match resolved {
Some(Resolved::Note(n)) => assert_eq!(n.id, note.id),
other => panic!("expected Resolved::Note, got {:?}", other),
}
}
#[tokio::test]
async fn resolve_returns_none_for_unknown_uuid() {
let rt = rt();
let unknown = Uuid::new_v4();
let resolved = rt.resolve(None, unknown).await.unwrap();
assert!(resolved.is_none(), "unknown UUID should resolve to None");
}
#[tokio::test]
async fn resolve_prefix_finds_entity_in_own_namespace() {
let rt = rt();
let entity = rt
.create_entity(None, "concept", "PrefixTest", None, None, vec![])
.await
.unwrap();
let prefix = &entity.id.to_string()[..8];
let resolved = rt.resolve_prefix(None, prefix).await.unwrap();
assert_eq!(resolved, Some(entity.id));
}
#[tokio::test]
async fn resolve_prefix_invisible_across_namespaces() {
let rt = rt();
let entity = rt
.create_entity(Some("ns_a"), "concept", "Invisible", None, None, vec![])
.await
.unwrap();
let prefix = &entity.id.to_string()[..8];
let resolved = rt.resolve_prefix(Some("ns_b"), prefix).await.unwrap();
assert_eq!(resolved, None);
}
#[tokio::test]
async fn resolve_prefix_ambiguous_same_namespace() {
use khive_storage::entity::Entity;
let rt = rt();
let id_a = Uuid::parse_str("aabbccdd-1111-4000-8000-000000000001").unwrap();
let id_b = Uuid::parse_str("aabbccdd-2222-4000-8000-000000000002").unwrap();
let mut entity_a = Entity::new("local", "concept", "AmbigA");
entity_a.id = id_a;
let mut entity_b = Entity::new("local", "concept", "AmbigB");
entity_b.id = id_b;
let store = rt.entities(None).unwrap();
store.upsert_entity(entity_a).await.unwrap();
store.upsert_entity(entity_b).await.unwrap();
let result = rt.resolve_prefix(None, "aabbccdd").await;
assert!(
result.is_err(),
"shared 8-char prefix must return Ambiguous error"
);
}
#[tokio::test]
async fn link_phantom_source_returns_not_found() {
let rt = rt();
let b = rt
.create_entity(None, "concept", "B", None, None, vec![])
.await
.unwrap();
let phantom = Uuid::new_v4();
let result = rt
.link(None, phantom, b.id, EdgeRelation::Extends, 1.0)
.await;
match result {
Err(RuntimeError::NotFound(msg)) => {
assert!(
msg.contains("source"),
"error message must name 'source': {msg}"
);
}
other => panic!("expected NotFound for phantom source, got {other:?}"),
}
}
#[tokio::test]
async fn link_phantom_target_returns_not_found() {
let rt = rt();
let a = rt
.create_entity(None, "concept", "A", None, None, vec![])
.await
.unwrap();
let phantom = Uuid::new_v4();
let result = rt
.link(None, a.id, phantom, EdgeRelation::Extends, 1.0)
.await;
match result {
Err(RuntimeError::NotFound(msg)) => {
assert!(
msg.contains("target"),
"error message must name 'target': {msg}"
);
}
other => panic!("expected NotFound for phantom target, got {other:?}"),
}
}
#[tokio::test]
async fn link_real_entities_succeeds() {
let rt = rt();
let a = rt
.create_entity(None, "concept", "A", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(None, "concept", "B", None, None, vec![])
.await
.unwrap();
let edge = rt
.link(None, a.id, b.id, EdgeRelation::Extends, 0.8)
.await
.unwrap();
assert_eq!(edge.source_id, a.id);
assert_eq!(edge.target_id, b.id);
assert_eq!(edge.relation, EdgeRelation::Extends);
}
#[tokio::test]
async fn create_note_annotates_phantom_returns_not_found() {
let rt = rt();
let phantom = Uuid::new_v4();
let result = rt
.create_note(
None,
"observation",
None,
"some content",
0.5,
None,
vec![phantom],
)
.await;
assert!(
matches!(result, Err(RuntimeError::NotFound(_))),
"annotates with phantom uuid must return NotFound, got {result:?}"
);
}
#[tokio::test]
async fn create_note_annotates_real_entity_succeeds() {
let rt = rt();
let entity = rt
.create_entity(None, "concept", "RealTarget", None, None, vec![])
.await
.unwrap();
let note = rt
.create_note(
None,
"observation",
None,
"content",
0.5,
None,
vec![entity.id],
)
.await
.unwrap();
let neighbors = rt
.neighbors(
None,
note.id,
Direction::Out,
None,
Some(vec![EdgeRelation::Annotates]),
)
.await
.unwrap();
assert_eq!(neighbors.len(), 1);
assert_eq!(neighbors[0].node_id, entity.id);
}
#[tokio::test]
async fn link_target_in_different_namespace_returns_not_found() {
let rt = rt();
let a = rt
.create_entity(Some("ns-a"), "concept", "A", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(Some("ns-b"), "concept", "B", None, None, vec![])
.await
.unwrap();
let result = rt
.link(Some("ns-a"), a.id, b.id, EdgeRelation::Extends, 1.0)
.await;
assert!(
matches!(result, Err(RuntimeError::NotFound(_))),
"target in different namespace must return NotFound (fail-closed), got {result:?}"
);
}
#[tokio::test]
async fn link_phantom_self_loop_returns_not_found() {
let rt = rt();
let phantom = Uuid::new_v4();
let result = rt
.link(None, phantom, phantom, EdgeRelation::Extends, 1.0)
.await;
match result {
Err(RuntimeError::NotFound(msg)) => {
assert!(
msg.contains("source"),
"self-loop must fail on source first: {msg}"
);
}
other => panic!("expected NotFound for phantom self-loop, got {other:?}"),
}
}
#[tokio::test]
async fn link_note_to_edge_annotates_succeeds() {
let rt = rt();
let a = rt
.create_entity(None, "concept", "A", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(None, "concept", "B", None, None, vec![])
.await
.unwrap();
let edge = rt
.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
.await
.unwrap();
let edge_uuid: Uuid = edge.id.into();
let note = rt
.create_note(None, "observation", None, "edge note", 0.5, None, vec![])
.await
.unwrap();
let result = rt
.link(None, note.id, edge_uuid, EdgeRelation::Annotates, 1.0)
.await;
assert!(
result.is_ok(),
"note→edge Annotates must succeed, got {result:?}"
);
}
#[tokio::test]
async fn create_note_annotates_real_edge_succeeds() {
let rt = rt();
let a = rt
.create_entity(None, "concept", "A", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(None, "concept", "B", None, None, vec![])
.await
.unwrap();
let edge = rt
.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
.await
.unwrap();
let edge_uuid: Uuid = edge.id.into();
let note = rt
.create_note(
None,
"observation",
None,
"annotating an edge",
0.5,
None,
vec![edge_uuid],
)
.await
.unwrap();
let neighbors = rt
.neighbors(
None,
note.id,
Direction::Out,
None,
Some(vec![EdgeRelation::Annotates]),
)
.await
.unwrap();
assert_eq!(neighbors.len(), 1);
assert_eq!(neighbors[0].node_id, edge_uuid);
}
#[tokio::test]
async fn create_note_annotates_phantom_is_atomic_no_note_persisted() {
let rt = rt();
let phantom = Uuid::new_v4();
let before_count = rt.list_notes(None, None, 1000).await.unwrap().len();
let result = rt
.create_note(
None,
"observation",
None,
"should not persist",
0.5,
None,
vec![phantom],
)
.await;
assert!(
matches!(result, Err(RuntimeError::NotFound(_))),
"phantom annotates target must return NotFound, got {result:?}"
);
let after_count = rt.list_notes(None, None, 1000).await.unwrap().len();
assert_eq!(
before_count, after_count,
"failed create_note must not persist any note row (atomicity)"
);
let search_hits = rt
.search_notes(None, "should not persist", None, 10)
.await
.unwrap();
assert!(
search_hits.is_empty(),
"failed create_note must not index into FTS (atomicity)"
);
}
#[tokio::test]
async fn link_entity_to_edge_uuid_non_annotates_returns_invalid_input() {
let rt = rt();
let a = rt
.create_entity(None, "concept", "A", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(None, "concept", "B", None, None, vec![])
.await
.unwrap();
let edge = rt
.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
.await
.unwrap();
let edge_uuid: Uuid = edge.id.into();
let result = rt
.link(None, a.id, edge_uuid, EdgeRelation::Extends, 1.0)
.await;
match result {
Err(RuntimeError::InvalidInput(msg)) => {
assert!(
msg.contains("target"),
"error message must name 'target': {msg}"
);
}
other => {
panic!("expected InvalidInput for edge-uuid target with Extends, got {other:?}")
}
}
}
#[tokio::test]
async fn link_note_as_source_non_annotates_returns_invalid_input() {
let rt = rt();
let note = rt
.create_note(None, "observation", None, "a note", 0.5, None, vec![])
.await
.unwrap();
let entity = rt
.create_entity(None, "concept", "E", None, None, vec![])
.await
.unwrap();
let result = rt
.link(None, note.id, entity.id, EdgeRelation::DependsOn, 1.0)
.await;
match result {
Err(RuntimeError::InvalidInput(msg)) => {
assert!(
msg.contains("source"),
"error message must name 'source': {msg}"
);
}
other => panic!("expected InvalidInput for note source with DependsOn, got {other:?}"),
}
}
#[tokio::test]
async fn link_entity_as_annotates_source_returns_invalid_input() {
let rt = rt();
let a = rt
.create_entity(None, "concept", "A", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(None, "concept", "B", None, None, vec![])
.await
.unwrap();
let result = rt
.link(None, a.id, b.id, EdgeRelation::Annotates, 1.0)
.await;
match result {
Err(RuntimeError::InvalidInput(msg)) => {
assert!(
msg.contains("source") && msg.contains("note"),
"error must say source must be a note: {msg}"
);
}
other => {
panic!("expected InvalidInput for entity source with Annotates, got {other:?}")
}
}
}
#[tokio::test]
async fn link_edge_as_annotates_source_returns_invalid_input() {
let rt = rt();
let a = rt
.create_entity(None, "concept", "A", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(None, "concept", "B", None, None, vec![])
.await
.unwrap();
let edge = rt
.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
.await
.unwrap();
let edge_uuid: Uuid = edge.id.into();
let result = rt
.link(None, edge_uuid, a.id, EdgeRelation::Annotates, 1.0)
.await;
match result {
Err(RuntimeError::InvalidInput(msg)) => {
assert!(
msg.contains("source") && msg.contains("note"),
"edge-as-annotates-source must report wrong kind, not NotFound: {msg}"
);
}
other => panic!("expected InvalidInput for edge source with Annotates, got {other:?}"),
}
}
#[tokio::test]
async fn link_note_to_event_annotates_succeeds() {
use khive_storage::Event;
use khive_types::SubstrateKind;
let rt = rt();
let note = rt
.create_note(
None,
"observation",
None,
"observing an event",
0.6,
None,
vec![],
)
.await
.unwrap();
let ns = rt.ns(None);
let event = Event::new(ns, "test_verb", SubstrateKind::Entity, "test_actor");
let event_id = event.id;
rt.events(None).unwrap().append_event(event).await.unwrap();
let result = rt
.link(None, note.id, event_id, EdgeRelation::Annotates, 1.0)
.await;
assert!(
result.is_ok(),
"note→event Annotates must succeed, got {result:?}"
);
}
#[tokio::test]
async fn create_note_annotates_event_succeeds() {
use khive_storage::Event;
use khive_types::SubstrateKind;
let rt = rt();
let ns = rt.ns(None);
let event = Event::new(ns, "test_verb", SubstrateKind::Entity, "test_actor");
let event_id = event.id;
rt.events(None).unwrap().append_event(event).await.unwrap();
let result = rt
.create_note(
None,
"observation",
None,
"note annotating an event",
0.5,
None,
vec![event_id],
)
.await;
assert!(
result.is_ok(),
"create_note with event annotates target must succeed, got {result:?}"
);
let note = result.unwrap();
let neighbors = rt
.neighbors(
None,
note.id,
Direction::Out,
None,
Some(vec![EdgeRelation::Annotates]),
)
.await
.unwrap();
assert_eq!(neighbors.len(), 1);
assert_eq!(neighbors[0].node_id, event_id);
}
#[tokio::test]
async fn link_supersedes_note_to_note_succeeds() {
let rt = rt();
let old_note = rt
.create_note(
None,
"observation",
None,
"old observation",
0.7,
None,
vec![],
)
.await
.unwrap();
let new_note = rt
.create_note(
None,
"observation",
None,
"revised observation superseding the old one",
0.9,
None,
vec![],
)
.await
.unwrap();
let result = rt
.link(
None,
new_note.id,
old_note.id,
EdgeRelation::Supersedes,
1.0,
)
.await;
assert!(
result.is_ok(),
"note→note Supersedes must succeed (ADR-019 note supersession), got {result:?}"
);
}
#[tokio::test]
async fn link_supersedes_entity_to_entity_succeeds() {
let rt = rt();
let old_entity = rt
.create_entity(None, "concept", "OldConcept", None, None, vec![])
.await
.unwrap();
let new_entity = rt
.create_entity(None, "concept", "NewConcept", None, None, vec![])
.await
.unwrap();
let result = rt
.link(
None,
new_entity.id,
old_entity.id,
EdgeRelation::Supersedes,
1.0,
)
.await;
assert!(
result.is_ok(),
"entity→entity Supersedes must succeed, got {result:?}"
);
}
#[tokio::test]
async fn link_supersedes_note_to_entity_returns_invalid_input() {
let rt = rt();
let note = rt
.create_note(None, "observation", None, "a note", 0.5, None, vec![])
.await
.unwrap();
let entity = rt
.create_entity(None, "concept", "SomeEntity", None, None, vec![])
.await
.unwrap();
let result = rt
.link(None, note.id, entity.id, EdgeRelation::Supersedes, 1.0)
.await;
match result {
Err(RuntimeError::InvalidInput(msg)) => {
assert!(
msg.contains("same substrate") || msg.contains("same-substrate"),
"error must name the same-substrate rule: {msg}"
);
}
other => panic!(
"expected InvalidInput for note→entity Supersedes (cross-substrate), got {other:?}"
),
}
}
#[tokio::test]
async fn link_supersedes_entity_to_note_returns_invalid_input() {
let rt = rt();
let entity = rt
.create_entity(None, "concept", "SomeEntity", None, None, vec![])
.await
.unwrap();
let note = rt
.create_note(None, "observation", None, "a note", 0.5, None, vec![])
.await
.unwrap();
let result = rt
.link(None, entity.id, note.id, EdgeRelation::Supersedes, 1.0)
.await;
match result {
Err(RuntimeError::InvalidInput(msg)) => {
assert!(
msg.contains("same substrate") || msg.contains("same-substrate"),
"error must name the same-substrate rule: {msg}"
);
}
other => panic!(
"expected InvalidInput for entity→note Supersedes (cross-substrate), got {other:?}"
),
}
}
#[tokio::test]
async fn link_supersedes_event_source_returns_invalid_input() {
use khive_storage::Event;
use khive_types::SubstrateKind;
let rt = rt();
let ns = rt.ns(None);
let event = Event::new(ns, "test_verb", SubstrateKind::Entity, "test_actor");
let event_id = event.id;
rt.events(None).unwrap().append_event(event).await.unwrap();
let entity = rt
.create_entity(None, "concept", "SomeEntity", None, None, vec![])
.await
.unwrap();
let result = rt
.link(None, event_id, entity.id, EdgeRelation::Supersedes, 1.0)
.await;
match result {
Err(RuntimeError::InvalidInput(msg)) => {
assert!(msg.contains("event"), "error must mention 'event': {msg}");
}
other => {
panic!("expected InvalidInput for event source with Supersedes, got {other:?}")
}
}
}
#[tokio::test]
async fn link_supersedes_event_target_returns_invalid_input() {
use khive_storage::Event;
use khive_types::SubstrateKind;
let rt = rt();
let ns = rt.ns(None);
let event = Event::new(ns, "test_verb", SubstrateKind::Entity, "test_actor");
let event_id = event.id;
rt.events(None).unwrap().append_event(event).await.unwrap();
let entity = rt
.create_entity(None, "concept", "SomeEntity", None, None, vec![])
.await
.unwrap();
let result = rt
.link(None, entity.id, event_id, EdgeRelation::Supersedes, 1.0)
.await;
match result {
Err(RuntimeError::InvalidInput(msg)) => {
assert!(msg.contains("event"), "error must mention 'event': {msg}");
}
other => {
panic!("expected InvalidInput for event target with Supersedes, got {other:?}")
}
}
}
#[tokio::test]
async fn link_supersedes_edge_source_returns_invalid_input() {
let rt = rt();
let a = rt
.create_entity(None, "concept", "A", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(None, "concept", "B", None, None, vec![])
.await
.unwrap();
let edge = rt
.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
.await
.unwrap();
let edge_uuid: Uuid = edge.id.into();
let result = rt
.link(None, edge_uuid, a.id, EdgeRelation::Supersedes, 1.0)
.await;
match result {
Err(RuntimeError::InvalidInput(msg)) => {
assert!(msg.contains("source"), "error must name 'source': {msg}");
}
other => {
panic!("expected InvalidInput for edge-uuid source with Supersedes, got {other:?}")
}
}
}
#[tokio::test]
async fn link_supersedes_edge_target_returns_invalid_input() {
let rt = rt();
let a = rt
.create_entity(None, "concept", "A", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(None, "concept", "B", None, None, vec![])
.await
.unwrap();
let edge = rt
.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
.await
.unwrap();
let edge_uuid: Uuid = edge.id.into();
let result = rt
.link(None, a.id, edge_uuid, EdgeRelation::Supersedes, 1.0)
.await;
match result {
Err(RuntimeError::InvalidInput(msg)) => {
assert!(msg.contains("target"), "error must name 'target': {msg}");
}
other => {
panic!("expected InvalidInput for edge-uuid target with Supersedes, got {other:?}")
}
}
}
#[tokio::test]
async fn link_supersedes_phantom_source_returns_not_found() {
let rt = rt();
let note = rt
.create_note(
None,
"observation",
None,
"existing note",
0.5,
None,
vec![],
)
.await
.unwrap();
let phantom = Uuid::new_v4();
let result = rt
.link(None, phantom, note.id, EdgeRelation::Supersedes, 1.0)
.await;
match result {
Err(RuntimeError::NotFound(msg)) => {
assert!(msg.contains("source"), "error must name 'source': {msg}");
}
other => panic!("expected NotFound for phantom source with Supersedes, got {other:?}"),
}
}
#[tokio::test]
async fn link_supersedes_phantom_target_returns_not_found() {
let rt = rt();
let note = rt
.create_note(
None,
"observation",
None,
"existing note",
0.5,
None,
vec![],
)
.await
.unwrap();
let phantom = Uuid::new_v4();
let result = rt
.link(None, note.id, phantom, EdgeRelation::Supersedes, 1.0)
.await;
match result {
Err(RuntimeError::NotFound(msg)) => {
assert!(msg.contains("target"), "error must name 'target': {msg}");
}
other => panic!("expected NotFound for phantom target with Supersedes, got {other:?}"),
}
}
#[tokio::test]
async fn link_supersedes_cross_namespace_source_returns_not_found() {
let rt = rt();
let note_a = rt
.create_note(
Some("ns-a"),
"observation",
None,
"note in ns-a",
0.5,
None,
vec![],
)
.await
.unwrap();
let note_b = rt
.create_note(
Some("ns-b"),
"observation",
None,
"note in ns-b",
0.5,
None,
vec![],
)
.await
.unwrap();
let result = rt
.link(
Some("ns-a"),
note_b.id,
note_a.id,
EdgeRelation::Supersedes,
1.0,
)
.await;
assert!(
matches!(result, Err(RuntimeError::NotFound(_))),
"cross-namespace source with Supersedes must return NotFound (fail-closed), got {result:?}"
);
}
#[tokio::test]
async fn link_extends_note_source_still_returns_invalid_input() {
let rt = rt();
let note = rt
.create_note(
None,
"observation",
None,
"a note that cannot be an extends source",
0.5,
None,
vec![],
)
.await
.unwrap();
let entity = rt
.create_entity(None, "concept", "E", None, None, vec![])
.await
.unwrap();
let result = rt
.link(None, note.id, entity.id, EdgeRelation::Extends, 1.0)
.await;
assert!(
matches!(result, Err(RuntimeError::InvalidInput(_))),
"note source with Extends must still return InvalidInput after this fix, got {result:?}"
);
}
#[tokio::test]
async fn link_annotates_note_to_edge_still_succeeds_after_fix() {
let rt = rt();
let a = rt
.create_entity(None, "concept", "A", None, None, vec![])
.await
.unwrap();
let b = rt
.create_entity(None, "concept", "B", None, None, vec![])
.await
.unwrap();
let edge = rt
.link(None, a.id, b.id, EdgeRelation::Extends, 1.0)
.await
.unwrap();
let edge_uuid: Uuid = edge.id.into();
let note = rt
.create_note(
None,
"observation",
None,
"annotating an edge",
0.5,
None,
vec![],
)
.await
.unwrap();
let result = rt
.link(None, note.id, edge_uuid, EdgeRelation::Annotates, 1.0)
.await;
assert!(
result.is_ok(),
"note→edge Annotates must still succeed after supersedes fix, got {result:?}"
);
}
}