1use std::collections::{HashMap, HashSet};
7
8use anyhow::Context;
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use uuid::Uuid;
12
13use khive_storage::types::{EdgeFilter, LinkId, PageRequest};
14use khive_storage::{EdgeRelation, EntityFilter};
15
16use crate::error::{RuntimeError, RuntimeResult};
17use crate::runtime::{KhiveRuntime, NamespaceToken};
18
19pub fn parse_archive_timestamp(
24 value: Option<&str>,
25 fallback: DateTime<Utc>,
26) -> anyhow::Result<DateTime<Utc>> {
27 let Some(raw) = value else {
28 return Ok(fallback);
29 };
30 DateTime::parse_from_rfc3339(raw)
31 .map(|dt| dt.with_timezone(&Utc))
32 .with_context(|| format!("timestamp {raw:?} must be RFC3339"))
33}
34
35#[derive(Clone, Debug, Serialize, Deserialize)]
42pub struct KgArchive {
43 pub format: String,
44 pub version: String,
45 pub namespace: String,
46 pub exported_at: DateTime<Utc>,
47 pub entities: Vec<ExportedEntity>,
48 pub edges: Vec<ExportedEdge>,
49}
50
51#[derive(Clone, Debug, Serialize, Deserialize)]
53pub struct ExportedEntity {
54 pub id: Uuid,
55 pub kind: String,
57 #[serde(skip_serializing_if = "Option::is_none")]
59 pub entity_type: Option<String>,
60 pub name: String,
61 #[serde(skip_serializing_if = "Option::is_none")]
62 pub description: Option<String>,
63 #[serde(skip_serializing_if = "Option::is_none")]
64 pub properties: Option<serde_json::Value>,
65 #[serde(default)]
66 pub tags: Vec<String>,
67 pub created_at: DateTime<Utc>,
68 pub updated_at: DateTime<Utc>,
69}
70
71#[derive(Clone, Debug, Serialize, Deserialize)]
73pub struct ExportedEdge {
74 #[serde(default = "Uuid::new_v4")]
79 pub edge_id: Uuid,
80 pub source: Uuid,
81 pub target: Uuid,
82 pub relation: EdgeRelation,
84 pub weight: f64,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub properties: Option<serde_json::Value>,
88 #[serde(
91 default = "default_import_timestamp",
92 deserialize_with = "deserialize_import_timestamp"
93 )]
94 pub created_at: DateTime<Utc>,
95 #[serde(
98 default = "default_import_timestamp",
99 deserialize_with = "deserialize_import_timestamp"
100 )]
101 pub updated_at: DateTime<Utc>,
102}
103
104fn default_import_timestamp() -> DateTime<Utc> {
105 Utc::now()
106}
107
108fn deserialize_import_timestamp<'de, D>(deserializer: D) -> Result<DateTime<Utc>, D::Error>
109where
110 D: serde::Deserializer<'de>,
111{
112 let raw = String::deserialize(deserializer)?;
113 DateTime::parse_from_rfc3339(&raw)
114 .map(|timestamp| timestamp.with_timezone(&Utc))
115 .map_err(|error| serde::de::Error::custom(format!("timestamp must be RFC3339: {error}")))
116}
117
118#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
120#[serde(rename_all = "snake_case")]
121pub enum ImportEdgeSkipCause {
122 MissingSource,
124 MissingTarget,
126 EndpointContract,
128}
129
130#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
132pub struct ImportSkippedEdge {
133 pub edge_id: Uuid,
134 pub source: Uuid,
135 pub target: Uuid,
136 pub relation: EdgeRelation,
137 pub cause: ImportEdgeSkipCause,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub error: Option<String>,
141}
142
143#[derive(Clone, Debug, Serialize, Deserialize)]
145pub struct ImportSummary {
146 pub entities_imported: usize,
147 pub edges_imported: usize,
148 pub edges_skipped: usize,
153 #[serde(default, skip_serializing_if = "Vec::is_empty")]
157 pub skipped_edges: Vec<ImportSkippedEdge>,
158 #[serde(default)]
161 pub embedding_truncation: crate::retrieval::EmbeddingTruncationReport,
162}
163
164impl KhiveRuntime {
167 pub async fn export_kg(&self, token: &NamespaceToken) -> RuntimeResult<KgArchive> {
173 let ns = token.namespace().as_str().to_owned();
174
175 let entity_page = self
176 .entities(token)?
177 .query_entities(
178 &ns,
179 EntityFilter::default(),
180 PageRequest {
181 offset: 0,
182 limit: u32::MAX,
183 },
184 )
185 .await?;
186
187 let entities: Vec<ExportedEntity> = entity_page
188 .items
189 .into_iter()
190 .map(|e| {
191 let created_at =
192 DateTime::from_timestamp_micros(e.created_at).unwrap_or_else(Utc::now);
193 let updated_at =
194 DateTime::from_timestamp_micros(e.updated_at).unwrap_or_else(Utc::now);
195 ExportedEntity {
196 id: e.id,
197 kind: e.kind.to_string(),
198 entity_type: e.entity_type,
199 name: e.name,
200 description: e.description,
201 properties: e.properties,
202 tags: e.tags,
203 created_at,
204 updated_at,
205 }
206 })
207 .collect();
208
209 let source_ids: Vec<Uuid> = entities.iter().map(|e| e.id).collect();
210 let edges = if source_ids.is_empty() {
211 Vec::new()
212 } else {
213 let filter = EdgeFilter {
214 source_ids: source_ids.clone(),
215 ..Default::default()
216 };
217 let edge_page = self
218 .graph(token)?
219 .query_edges(
220 filter,
221 Vec::new(),
222 PageRequest {
223 offset: 0,
224 limit: u32::MAX,
225 },
226 )
227 .await?;
228
229 let id_set: HashSet<Uuid> = source_ids.into_iter().collect();
230 edge_page
231 .items
232 .into_iter()
233 .filter(|e| id_set.contains(&e.source_id))
234 .map(|e| ExportedEdge {
235 edge_id: e.id.into(),
236 source: e.source_id,
237 target: e.target_id,
238 relation: e.relation,
239 weight: e.weight,
240 properties: e.metadata,
241 created_at: e.created_at,
242 updated_at: e.updated_at,
243 })
244 .collect()
245 };
246
247 Ok(KgArchive {
248 format: "khive-kg".to_string(),
249 version: "0.1".to_string(),
250 namespace: ns,
251 exported_at: Utc::now(),
252 entities,
253 edges,
254 })
255 }
256
257 pub async fn export_kg_json(&self, token: &NamespaceToken) -> RuntimeResult<String> {
259 let archive = self.export_kg(token).await?;
260 serde_json::to_string(&archive).map_err(|e| RuntimeError::InvalidInput(e.to_string()))
261 }
262
263 pub async fn import_kg(
272 &self,
273 archive: &KgArchive,
274 token: &NamespaceToken,
275 ) -> RuntimeResult<ImportSummary> {
276 if archive.format != "khive-kg" {
277 return Err(RuntimeError::InvalidInput(format!(
278 "unsupported archive format {:?}; expected \"khive-kg\"",
279 archive.format
280 )));
281 }
282 if archive.version != "0.1" {
283 return Err(RuntimeError::InvalidInput(format!(
284 "unsupported archive version {:?}; supported: \"0.1\"",
285 archive.version
286 )));
287 }
288
289 let ns = token.namespace().as_str().to_owned();
290
291 for (index, entity) in archive.entities.iter().enumerate() {
295 self.validate_entity_kind(&entity.kind)?;
296 crate::secret_gate::reject_reserved_secret_gate_property(entity.properties.as_ref())?;
301 if entity.name.trim().is_empty() {
302 return Err(RuntimeError::InvalidInput(format!(
303 "archive entity {index} ({}) name must be non-blank",
304 entity.id
305 )));
306 }
307 }
308 for (index, edge) in archive.edges.iter().enumerate() {
309 let record = format!("edge[{index}]");
310 crate::operations::validate_edge_weight(edge.weight)?;
311 crate::secret_gate::reject_reserved_secret_gate_property(edge.properties.as_ref())?;
316 if let Some(p) = edge.properties.as_ref() {
317 crate::secret_gate::check_json_at(p, &record, "properties")?;
318 }
319 }
320
321 let store = self.entities(token)?;
322 let mut entities_imported = 0usize;
323 let mut embedding_truncation = crate::retrieval::EmbeddingTruncationReport::default();
324 for page in archive
325 .entities
326 .chunks(crate::retrieval::EMBEDDING_BATCH_PAGE_SIZE)
327 {
328 let entities: Vec<khive_storage::entity::Entity> = page
329 .iter()
330 .map(|ee| khive_storage::entity::Entity {
331 entity_type: ee.entity_type.clone(),
332 description: ee.description.clone(),
333 properties: ee.properties.clone(),
334 tags: ee.tags.clone(),
335 ..khive_storage::entity::Entity::minimal(
336 ee.id,
337 ns.clone(),
338 ee.kind.clone(),
339 ee.name.clone(),
340 ee.created_at.timestamp_micros(),
341 ee.updated_at.timestamp_micros(),
342 )
343 })
344 .collect();
345 let texts: Vec<String> = entities
346 .iter()
347 .map(crate::curation::entity_embedding_text)
348 .collect();
349 let mut embeddings: Vec<HashMap<String, crate::retrieval::DocumentEmbeddingOutcome>> =
350 (0..entities.len()).map(|_| HashMap::new()).collect();
351 for model_name in self.registered_embedding_model_names() {
352 match self
353 .embed_document_batch_with_model_outcomes_for_token(token, &model_name, &texts)
354 .await
355 {
356 Ok(outcomes) => {
357 for (slot, outcome) in embeddings.iter_mut().zip(outcomes) {
358 slot.insert(model_name.clone(), outcome);
359 }
360 }
361 Err(error) => {
362 tracing::warn!(
363 model = %model_name,
364 error = %error,
365 "import_kg: batch embed failed; retrying records individually"
366 );
367 }
368 }
369 }
370 for (entity, outcomes) in entities.into_iter().zip(embeddings) {
371 store.upsert_entity(entity.clone()).await?;
372 embedding_truncation.merge(
375 self.reindex_entity_with_precomputed(token, &entity, outcomes)
376 .await?,
377 );
378 entities_imported += 1;
379 }
380 }
381
382 let graph = self.graph(token)?;
386 let mut edges_imported = 0usize;
387 let mut skipped_edges = Vec::new();
388 for ee in &archive.edges {
389 let source_ok = match self.get_entity(token, ee.source).await {
390 Ok(_) => true,
391 Err(RuntimeError::NotFound(_)) => false,
392 Err(e) => return Err(e),
393 };
394 if !source_ok {
395 tracing::warn!(
396 source = %ee.source,
397 target = %ee.target,
398 relation = ?ee.relation,
399 "import_kg: skipping edge — source entity not found in namespace {ns:?}"
400 );
401 skipped_edges.push(ImportSkippedEdge {
402 edge_id: ee.edge_id,
403 source: ee.source,
404 target: ee.target,
405 relation: ee.relation,
406 cause: ImportEdgeSkipCause::MissingSource,
407 error: None,
408 });
409 continue;
410 }
411 let target_ok = match self.get_entity(token, ee.target).await {
412 Ok(_) => true,
413 Err(RuntimeError::NotFound(_)) => false,
414 Err(e) => return Err(e),
415 };
416 if !target_ok {
417 tracing::warn!(
418 source = %ee.source,
419 target = %ee.target,
420 relation = ?ee.relation,
421 "import_kg: skipping edge — target entity not found in namespace {ns:?}"
422 );
423 skipped_edges.push(ImportSkippedEdge {
424 edge_id: ee.edge_id,
425 source: ee.source,
426 target: ee.target,
427 relation: ee.relation,
428 cause: ImportEdgeSkipCause::MissingTarget,
429 error: None,
430 });
431 continue;
432 }
433 match self
438 .validate_edge_relation_endpoints(token, ee.source, ee.target, ee.relation)
439 .await
440 {
441 Ok(_) => {}
442 Err(e @ (RuntimeError::InvalidInput(_) | RuntimeError::NotFound(_))) => {
443 tracing::warn!(
444 source = %ee.source,
445 target = %ee.target,
446 relation = ?ee.relation,
447 error = %e,
448 "import_kg: skipping edge — endpoint contract violation in namespace {ns:?}"
449 );
450 skipped_edges.push(ImportSkippedEdge {
451 edge_id: ee.edge_id,
452 source: ee.source,
453 target: ee.target,
454 relation: ee.relation,
455 cause: ImportEdgeSkipCause::EndpointContract,
456 error: Some(e.to_string()),
457 });
458 continue;
459 }
460 Err(e) => return Err(e),
461 }
462 let edge = khive_storage::types::Edge {
463 id: LinkId::from(ee.edge_id),
464 namespace: ns.clone(),
465 source_id: ee.source,
466 target_id: ee.target,
467 relation: ee.relation,
468 weight: ee.weight,
469 created_at: ee.created_at,
470 updated_at: ee.updated_at,
471 deleted_at: None,
472 metadata: ee.properties.clone(),
473 target_backend: None,
474 };
475 graph.upsert_edge(edge).await?;
476 edges_imported += 1;
477 }
478
479 Ok(ImportSummary {
480 entities_imported,
481 edges_imported,
482 edges_skipped: skipped_edges.len(),
483 skipped_edges,
484 embedding_truncation,
485 })
486 }
487
488 pub async fn import_kg_json(
490 &self,
491 json: &str,
492 token: &NamespaceToken,
493 ) -> RuntimeResult<ImportSummary> {
494 let archive: KgArchive =
495 serde_json::from_str(json).map_err(|e| RuntimeError::InvalidInput(e.to_string()))?;
496 self.import_kg(&archive, token).await
497 }
498}
499
500#[cfg(test)]
505mod tests {
506 use std::sync::atomic::{AtomicUsize, Ordering};
507 use std::sync::Arc;
508
509 use super::*;
510 use crate::runtime::{KhiveRuntime, NamespaceToken};
511 use crate::{EmbedderProvider, Namespace};
512 use async_trait::async_trait;
513 use khive_storage::EdgeRelation;
514 use lattice_embed::{EmbedError, EmbeddingModel, EmbeddingService, MAX_TEXT_BYTES};
515
516 const IMPORT_TEST_MODEL: &str = "all-minilm-l6-v2";
517 const IMPORT_BATCH_MODEL: &str = "import-batch-model";
518 const IMPORT_BATCH_MODEL_TWO: &str = "import-batch-model-two";
519
520 struct CountingImportService {
521 calls: Arc<AtomicUsize>,
522 reject_poison: bool,
523 }
524
525 #[async_trait]
526 impl EmbeddingService for CountingImportService {
527 async fn embed(
528 &self,
529 texts: &[String],
530 _model: EmbeddingModel,
531 ) -> std::result::Result<Vec<Vec<f32>>, EmbedError> {
532 self.calls.fetch_add(1, Ordering::SeqCst);
533 if self.reject_poison
534 && (texts.len() > 1 || texts.iter().any(|text| text.contains("poison")))
535 {
536 return Err(EmbedError::InferenceFailed("poison input".into()));
537 }
538 Ok(texts
539 .iter()
540 .map(|text| {
541 vec![
542 text.len() as f32,
543 text.bytes().map(u32::from).sum::<u32>() as f32,
544 text.as_bytes().first().copied().unwrap_or_default() as f32,
545 text.as_bytes().last().copied().unwrap_or_default() as f32,
546 ]
547 })
548 .collect())
549 }
550
551 fn supports_model(&self, _model: EmbeddingModel) -> bool {
552 true
553 }
554
555 fn name(&self) -> &'static str {
556 "import-batch-counting-service"
557 }
558 }
559
560 struct CountingImportProvider {
561 name: &'static str,
562 calls: Arc<AtomicUsize>,
563 reject_poison: bool,
564 }
565
566 #[async_trait]
567 impl EmbedderProvider for CountingImportProvider {
568 fn name(&self) -> &str {
569 self.name
570 }
571
572 fn dimensions(&self) -> usize {
573 4
574 }
575
576 async fn build(&self) -> std::result::Result<Arc<dyn EmbeddingService>, RuntimeError> {
577 Ok(Arc::new(CountingImportService {
578 calls: Arc::clone(&self.calls),
579 reject_poison: self.reject_poison,
580 }))
581 }
582 }
583
584 fn batch_archive(names: impl IntoIterator<Item = String>) -> KgArchive {
585 KgArchive {
586 format: "khive-kg".to_string(),
587 version: "0.1".to_string(),
588 namespace: "local".to_string(),
589 exported_at: Utc::now(),
590 entities: names
591 .into_iter()
592 .map(|name| ExportedEntity {
593 id: Uuid::new_v4(),
594 kind: "concept".to_string(),
595 entity_type: None,
596 name,
597 description: Some("batch body".to_string()),
598 properties: None,
599 tags: vec![],
600 created_at: Utc::now(),
601 updated_at: Utc::now(),
602 })
603 .collect(),
604 edges: vec![],
605 }
606 }
607
608 struct ImportEmbeddingService;
609
610 #[async_trait]
611 impl EmbeddingService for ImportEmbeddingService {
612 async fn embed(
613 &self,
614 texts: &[String],
615 _model: EmbeddingModel,
616 ) -> std::result::Result<Vec<Vec<f32>>, EmbedError> {
617 let dimensions = EmbeddingModel::AllMiniLmL6V2.dimensions();
618 Ok(texts.iter().map(|_| vec![1.0; dimensions]).collect())
619 }
620
621 fn supports_model(&self, _model: EmbeddingModel) -> bool {
622 true
623 }
624
625 fn name(&self) -> &'static str {
626 "kg-import-truncation-test"
627 }
628 }
629
630 struct ImportEmbeddingProvider;
631
632 #[async_trait]
633 impl EmbedderProvider for ImportEmbeddingProvider {
634 fn name(&self) -> &str {
635 IMPORT_TEST_MODEL
636 }
637
638 fn dimensions(&self) -> usize {
639 EmbeddingModel::AllMiniLmL6V2.dimensions()
640 }
641
642 async fn build(&self) -> std::result::Result<Arc<dyn EmbeddingService>, RuntimeError> {
643 Ok(Arc::new(ImportEmbeddingService))
644 }
645 }
646
647 async fn make_rt() -> KhiveRuntime {
648 KhiveRuntime::memory().expect("in-memory runtime")
649 }
650
651 fn make_rt_with_embedder() -> KhiveRuntime {
652 let runtime = KhiveRuntime::memory().expect("in-memory runtime");
653 runtime.register_embedder(ImportEmbeddingProvider);
654 runtime
655 }
656
657 #[test]
658 fn archive_timestamp_falls_back_only_when_absent_and_converts_offsets_to_utc() {
659 let fallback = DateTime::parse_from_rfc3339("2020-01-01T00:00:00Z")
660 .unwrap()
661 .with_timezone(&Utc);
662 assert_eq!(parse_archive_timestamp(None, fallback).unwrap(), fallback);
663
664 let utc = parse_archive_timestamp(Some("2024-05-06T07:08:09Z"), fallback).unwrap();
665 assert_eq!(utc.to_rfc3339(), "2024-05-06T07:08:09+00:00");
666
667 let offset = parse_archive_timestamp(Some("2024-05-06T09:08:09+02:00"), fallback).unwrap();
668 assert_eq!(offset, utc);
669
670 let err = parse_archive_timestamp(Some("2024-05-06 07:08:09"), fallback).unwrap_err();
671 let text = format!("{err:#}");
672 assert!(text.contains("\"2024-05-06 07:08:09\""), "{text}");
673 assert!(text.contains("must be RFC3339"), "{text}");
674 assert!(
675 err.chain().count() > 1,
676 "parser source error is kept: {text}"
677 );
678 }
679
680 #[tokio::test]
681 async fn import_batches_provider_calls_and_matches_per_record_reindex_vectors() {
682 let token = NamespaceToken::local();
683 let archive = batch_archive((0..257).map(|index| format!("Batch entity {index}")));
684 let ids: Vec<Uuid> = archive.entities.iter().map(|entity| entity.id).collect();
685 let calls = Arc::new(AtomicUsize::new(0));
686 let runtime = KhiveRuntime::memory().unwrap();
687 runtime.register_embedder(CountingImportProvider {
688 name: IMPORT_BATCH_MODEL,
689 calls: Arc::clone(&calls),
690 reject_poison: false,
691 });
692 let usage = crate::usage::UsageContext::new();
693 let summary = crate::usage::scope(usage.clone(), runtime.import_kg(&archive, &token))
694 .await
695 .unwrap();
696 assert_eq!(summary.entities_imported, ids.len());
697 assert_eq!(usage.snapshot()["embed_calls"], ids.len() as u64);
698 assert_eq!(
699 calls.load(Ordering::SeqCst),
700 2,
701 "257 records need two provider batches"
702 );
703
704 let singles = KhiveRuntime::memory().unwrap();
705 let singleton_calls = Arc::new(AtomicUsize::new(0));
706 singles.register_embedder(CountingImportProvider {
707 name: IMPORT_BATCH_MODEL,
708 calls: Arc::clone(&singleton_calls),
709 reject_poison: false,
710 });
711 for id in &ids {
712 let entity = runtime.get_entity(&token, *id).await.unwrap();
713 singles
714 .entities(&token)
715 .unwrap()
716 .upsert_entity(entity.clone())
717 .await
718 .unwrap();
719 singles.reindex_entity(&token, &entity).await.unwrap();
720 }
721 assert_eq!(singleton_calls.load(Ordering::SeqCst), ids.len());
722 let batched_vectors = runtime
723 .vectors_for_model(&token, IMPORT_BATCH_MODEL)
724 .unwrap()
725 .get_vectors(&ids, "local", "entity.body")
726 .await
727 .unwrap();
728 let singleton_vectors = singles
729 .vectors_for_model(&token, IMPORT_BATCH_MODEL)
730 .unwrap()
731 .get_vectors(&ids, "local", "entity.body")
732 .await
733 .unwrap();
734 assert_eq!(batched_vectors.len(), ids.len());
735 assert_eq!(batched_vectors, singleton_vectors);
736 }
737
738 #[tokio::test]
739 async fn import_batches_each_registered_model_once_per_page() {
740 let token = NamespaceToken::local();
741 let archive = batch_archive(["alpha", "beta", "gamma"].map(str::to_string));
742 let calls_a = Arc::new(AtomicUsize::new(0));
743 let calls_b = Arc::new(AtomicUsize::new(0));
744 let runtime = KhiveRuntime::memory().unwrap();
745 for (name, calls) in [
746 (IMPORT_BATCH_MODEL, Arc::clone(&calls_a)),
747 (IMPORT_BATCH_MODEL_TWO, Arc::clone(&calls_b)),
748 ] {
749 runtime.register_embedder(CountingImportProvider {
750 name,
751 calls,
752 reject_poison: false,
753 });
754 }
755 runtime.import_kg(&archive, &token).await.unwrap();
756 assert_eq!(calls_a.load(Ordering::SeqCst), 1);
757 assert_eq!(calls_b.load(Ordering::SeqCst), 1);
758 let ids: Vec<Uuid> = archive.entities.iter().map(|entity| entity.id).collect();
759 for model in [IMPORT_BATCH_MODEL, IMPORT_BATCH_MODEL_TWO] {
760 assert_eq!(
761 runtime
762 .vectors_for_model(&token, model)
763 .unwrap()
764 .get_vectors(&ids, "local", "entity.body")
765 .await
766 .unwrap()
767 .len(),
768 ids.len()
769 );
770 }
771 }
772
773 #[tokio::test]
774 async fn import_failed_batch_isolates_poison_without_duplicate_vector_writes() {
775 use khive_storage::types::{SqlStatement, SqlValue};
776
777 let token = NamespaceToken::local();
778 let archive = batch_archive(["good one", "poison", "good two"].map(str::to_string));
779 let ids: Vec<Uuid> = archive.entities.iter().map(|entity| entity.id).collect();
780 let calls = Arc::new(AtomicUsize::new(0));
781 let runtime = KhiveRuntime::memory().unwrap();
782 runtime.register_embedder(CountingImportProvider {
783 name: IMPORT_BATCH_MODEL,
784 calls: Arc::clone(&calls),
785 reject_poison: true,
786 });
787 let summary = runtime.import_kg(&archive, &token).await.unwrap();
788 assert_eq!(summary.entities_imported, 3);
789 assert_eq!(
790 calls.load(Ordering::SeqCst),
791 4,
792 "one failed page plus three singleton retries"
793 );
794 let vectors = runtime
795 .vectors_for_model(&token, IMPORT_BATCH_MODEL)
796 .unwrap()
797 .get_vectors(&ids, "local", "entity.body")
798 .await
799 .unwrap();
800 assert_eq!(vectors.len(), 2);
801 assert!(!vectors.contains_key(&ids[1]));
802 let mut reader = runtime.sql().reader().await.unwrap();
803 let rows = reader
804 .query_all(SqlStatement {
805 sql: "SELECT COUNT(*) FROM ann_write_log WHERE embedding_model = ?1 AND op = 'upsert'".into(),
806 params: vec![SqlValue::Text(IMPORT_BATCH_MODEL.into())],
807 label: Some("import-batch-upsert-count".into()),
808 })
809 .await
810 .unwrap();
811 assert!(matches!(&rows[0].columns[0].value, SqlValue::Integer(2)));
812 let provenance = reader
813 .query_scalar(SqlStatement {
814 sql: "SELECT COUNT(*) FROM vector_provenance WHERE model_key = ?1".into(),
815 params: vec![SqlValue::Text(crate::config::sanitize_key(
816 IMPORT_BATCH_MODEL,
817 ))],
818 label: Some("import-batch-provenance-count".into()),
819 })
820 .await
821 .unwrap();
822 assert!(matches!(provenance, Some(SqlValue::Integer(0))));
823 }
824
825 #[tokio::test]
826 async fn import_summary_reports_actual_embedding_truncation() {
827 let runtime = make_rt_with_embedder();
828 let token = NamespaceToken::local();
829 let archive = KgArchive {
830 format: "khive-kg".to_string(),
831 version: "0.1".to_string(),
832 namespace: "local".to_string(),
833 exported_at: Utc::now(),
834 entities: vec![ExportedEntity {
835 id: Uuid::new_v4(),
836 kind: "concept".to_string(),
837 entity_type: None,
838 name: "Imported long entity".to_string(),
839 description: Some("x".repeat(MAX_TEXT_BYTES + 1)),
840 properties: None,
841 tags: vec![],
842 created_at: Utc::now(),
843 updated_at: Utc::now(),
844 }],
845 edges: vec![],
846 };
847
848 let summary = runtime
849 .import_kg(&archive, &token)
850 .await
851 .expect("import with bounded embedding input");
852
853 assert_eq!(summary.entities_imported, 1);
854 assert_eq!(summary.embedding_truncation.truncated, 1);
855 assert!(summary.embedding_truncation.discarded_bytes > 0);
856 let wire = serde_json::to_value(&summary).expect("serialize import summary");
857 assert_eq!(wire["embedding_truncation"]["truncated"], 1);
858 }
859
860 #[tokio::test]
861 async fn import_rejects_whitespace_name_before_any_entity_write() {
862 let runtime = make_rt().await;
863 let token = NamespaceToken::local();
864 let valid_id = Uuid::new_v4();
865 let archive = KgArchive {
866 format: "khive-kg".to_string(),
867 version: "0.1".to_string(),
868 namespace: "local".to_string(),
869 exported_at: Utc::now(),
870 entities: vec![
871 ExportedEntity {
872 id: valid_id,
873 kind: "concept".to_string(),
874 entity_type: None,
875 name: "Must not be written".to_string(),
876 description: None,
877 properties: None,
878 tags: vec![],
879 created_at: Utc::now(),
880 updated_at: Utc::now(),
881 },
882 ExportedEntity {
883 id: Uuid::new_v4(),
884 kind: "concept".to_string(),
885 entity_type: None,
886 name: " \t\n ".to_string(),
887 description: None,
888 properties: None,
889 tags: vec![],
890 created_at: Utc::now(),
891 updated_at: Utc::now(),
892 },
893 ],
894 edges: vec![],
895 };
896
897 let err = runtime
898 .import_kg(&archive, &token)
899 .await
900 .expect_err("whitespace-only entity names must fail the whole import");
901 assert!(
902 err.to_string().contains("non-blank"),
903 "error must explain the name invariant: {err}"
904 );
905 assert!(
906 runtime.get_entity(&token, valid_id).await.is_err(),
907 "deterministic validation must finish before the first entity write"
908 );
909 }
910
911 #[tokio::test]
913 async fn roundtrip_entities_and_edges() {
914 let src = make_rt().await;
915 let tok = NamespaceToken::local();
916 let e1 = src
917 .create_entity(
918 &tok,
919 "concept",
920 None,
921 "FlashAttention",
922 Some("fast attention"),
923 None,
924 vec![],
925 )
926 .await
927 .unwrap();
928 let e2 = src
929 .create_entity(
930 &tok,
931 "concept",
932 None,
933 "FlashAttention-2",
934 None,
935 None,
936 vec![],
937 )
938 .await
939 .unwrap();
940 let e3 = src
941 .create_entity(
942 &tok,
943 "person",
944 None,
945 "Tri Dao",
946 None,
947 None,
948 vec!["author".into()],
949 )
950 .await
951 .unwrap();
952 src.link(&tok, e2.id, e1.id, EdgeRelation::Extends, 1.0, None)
953 .await
954 .unwrap();
955 src.link(&tok, e1.id, e3.id, EdgeRelation::IntroducedBy, 0.9, None)
956 .await
957 .unwrap();
958
959 let archive = src.export_kg(&tok).await.unwrap();
960 assert_eq!(archive.entities.len(), 3);
961 assert_eq!(archive.edges.len(), 2);
962 assert_eq!(archive.format, "khive-kg");
963 assert_eq!(archive.version, "0.1");
964
965 let dst = make_rt().await;
966 let summary = dst.import_kg(&archive, &tok).await.unwrap();
967 assert_eq!(summary.entities_imported, 3);
968 assert_eq!(summary.edges_imported, 2);
969
970 let got = dst.get_entity(&tok, e1.id).await.unwrap();
971 assert_eq!(got.name, "FlashAttention");
972 assert_eq!(got.description.as_deref(), Some("fast attention"));
973 }
974
975 #[tokio::test]
977 async fn json_roundtrip() {
978 let src = make_rt().await;
979 let tok = NamespaceToken::local();
980 let e1 = src
981 .create_entity(
982 &tok,
983 "concept",
984 None,
985 "LoRA",
986 Some("low-rank adaptation"),
987 Some(serde_json::json!({"year": "2021"})),
988 vec!["fine-tuning".into()],
989 )
990 .await
991 .unwrap();
992 let e2 = src
993 .create_entity(&tok, "concept", None, "QLoRA", None, None, vec![])
994 .await
995 .unwrap();
996 src.link(&tok, e2.id, e1.id, EdgeRelation::VariantOf, 0.9, None)
997 .await
998 .unwrap();
999
1000 let json_str = src.export_kg_json(&tok).await.unwrap();
1001 assert!(json_str.contains("khive-kg"));
1002
1003 let dst = make_rt().await;
1004 let summary = dst.import_kg_json(&json_str, &tok).await.unwrap();
1005 assert_eq!(summary.entities_imported, 2);
1006 assert_eq!(summary.edges_imported, 1);
1007
1008 let got = dst.get_entity(&tok, e1.id).await.unwrap();
1009 assert_eq!(got.tags, vec!["fine-tuning"]);
1010 }
1011
1012 #[tokio::test]
1019 async fn namespace_targeting() {
1020 let src = make_rt().await;
1021 let tok_a = NamespaceToken::for_namespace(Namespace::parse("a").unwrap());
1022 let tok_b = NamespaceToken::for_namespace(Namespace::parse("b").unwrap());
1023 src.create_entity(&tok_a, "concept", None, "Sinkhorn", None, None, vec![])
1024 .await
1025 .unwrap();
1026
1027 let archive = src.export_kg(&tok_a).await.unwrap();
1028 assert_eq!(archive.namespace, "a");
1029
1030 let dst = make_rt().await;
1031 let summary = dst.import_kg(&archive, &tok_b).await.unwrap();
1032 assert_eq!(summary.entities_imported, 1);
1033
1034 let in_b = dst.list_entities(&tok_b, None, None, 100, 0).await.unwrap();
1035 assert_eq!(in_b.len(), 1);
1036 assert_eq!(in_b[0].name, "Sinkhorn");
1037
1038 let in_a = src.list_entities(&tok_a, None, None, 100, 0).await.unwrap();
1039 assert_eq!(in_a.len(), 1);
1040
1041 let dst_a = dst.list_entities(&tok_a, None, None, 100, 0).await.unwrap();
1042 assert_eq!(dst_a.len(), 0);
1043 }
1044
1045 #[tokio::test]
1047 async fn format_validation_rejects_wrong_format() {
1048 let rt = make_rt().await;
1049 let tok = NamespaceToken::local();
1050 let bad = KgArchive {
1051 format: "wrong".to_string(),
1052 version: "0.1".to_string(),
1053 namespace: "local".to_string(),
1054 exported_at: Utc::now(),
1055 entities: vec![],
1056 edges: vec![],
1057 };
1058 let err = rt.import_kg(&bad, &tok).await.unwrap_err();
1059 assert!(matches!(err, RuntimeError::InvalidInput(_)));
1060 }
1061
1062 #[tokio::test]
1064 async fn import_unsupported_archive_version_returns_error() {
1065 let rt = make_rt().await;
1066 let tok = NamespaceToken::local();
1067 let bad = KgArchive {
1068 format: "khive-kg".to_string(),
1069 version: "999.0".to_string(),
1070 namespace: "local".to_string(),
1071 exported_at: Utc::now(),
1072 entities: vec![],
1073 edges: vec![],
1074 };
1075 let err = rt.import_kg(&bad, &tok).await.unwrap_err();
1076 assert!(
1077 matches!(err, RuntimeError::InvalidInput(_)),
1078 "expected InvalidInput, got {err:?}"
1079 );
1080 if let RuntimeError::InvalidInput(msg) = err {
1081 assert!(
1082 msg.contains("999.0"),
1083 "error message should mention the unsupported version, got: {msg:?}"
1084 );
1085 }
1086 }
1087
1088 #[tokio::test]
1092 async fn import_entity_with_reserved_secret_gate_property_is_rejected() {
1093 let rt = make_rt().await;
1094 let tok = NamespaceToken::local();
1095 let archive = KgArchive {
1096 format: "khive-kg".to_string(),
1097 version: "0.1".to_string(),
1098 namespace: "local".to_string(),
1099 exported_at: Utc::now(),
1100 entities: vec![ExportedEntity {
1101 id: Uuid::new_v4(),
1102 kind: "concept".to_string(),
1103 entity_type: None,
1104 name: "ReservedKeyImport".to_string(),
1105 description: None,
1106 properties: Some(serde_json::json!({
1107 "khive:secret_gate": "exempted:content-sha256-manifest-v1"
1108 })),
1109 tags: vec![],
1110 created_at: Utc::now(),
1111 updated_at: Utc::now(),
1112 }],
1113 edges: vec![],
1114 };
1115
1116 let err = rt.import_kg(&archive, &tok).await.unwrap_err();
1117 assert!(
1118 matches!(err, RuntimeError::InvalidInput(ref msg) if msg.contains("khive:secret_gate") && msg.contains("runtime-owned")),
1119 "expected a reservation rejection, got {err:?}"
1120 );
1121
1122 let entities = rt.list_entities(&tok, None, None, 100, 0).await.unwrap();
1123 assert!(
1124 !entities.iter().any(|e| e.name == "ReservedKeyImport"),
1125 "rejected archive import must not create the entity"
1126 );
1127 }
1128
1129 #[tokio::test]
1134 async fn import_edge_with_reserved_secret_gate_property_is_rejected() {
1135 let rt = make_rt().await;
1136 let tok = NamespaceToken::local();
1137 let a = Uuid::new_v4();
1138 let b = Uuid::new_v4();
1139 let mk = |id: Uuid, name: &str| ExportedEntity {
1140 id,
1141 kind: "concept".to_string(),
1142 entity_type: None,
1143 name: name.to_string(),
1144 description: None,
1145 properties: None,
1146 tags: vec![],
1147 created_at: Utc::now(),
1148 updated_at: Utc::now(),
1149 };
1150 let archive = KgArchive {
1151 format: "khive-kg".to_string(),
1152 version: "0.1".to_string(),
1153 namespace: "local".to_string(),
1154 exported_at: Utc::now(),
1155 entities: vec![mk(a, "EdgeGateA"), mk(b, "EdgeGateB")],
1156 edges: vec![ExportedEdge {
1157 edge_id: Uuid::new_v4(),
1158 source: a,
1159 target: b,
1160 relation: EdgeRelation::Extends,
1161 weight: 0.5,
1162 properties: Some(serde_json::json!({
1163 "khive:secret_gate": "exempted:content-sha256-manifest-v1"
1164 })),
1165 created_at: Utc::now(),
1166 updated_at: Utc::now(),
1167 }],
1168 };
1169
1170 let err = rt.import_kg(&archive, &tok).await.unwrap_err();
1171 assert!(
1172 matches!(err, RuntimeError::InvalidInput(ref msg) if msg.contains("khive:secret_gate") && msg.contains("runtime-owned")),
1173 "expected a reservation rejection, got {err:?}"
1174 );
1175
1176 let entities = rt.list_entities(&tok, None, None, 100, 0).await.unwrap();
1179 assert!(
1180 !entities.iter().any(|e| e.name == "EdgeGateA"),
1181 "rejected archive import must not create any records"
1182 );
1183 }
1184
1185 #[tokio::test]
1189 async fn import_edge_with_credential_shaped_property_is_rejected() {
1190 let rt = make_rt().await;
1191 let tok = NamespaceToken::local();
1192 let a = Uuid::new_v4();
1193 let b = Uuid::new_v4();
1194 let mk = |id: Uuid, name: &str| ExportedEntity {
1195 id,
1196 kind: "concept".to_string(),
1197 entity_type: None,
1198 name: name.to_string(),
1199 description: None,
1200 properties: None,
1201 tags: vec![],
1202 created_at: Utc::now(),
1203 updated_at: Utc::now(),
1204 };
1205 let archive = KgArchive {
1206 format: "khive-kg".to_string(),
1207 version: "0.1".to_string(),
1208 namespace: "local".to_string(),
1209 exported_at: Utc::now(),
1210 entities: vec![mk(a, "EdgeScanA"), mk(b, "EdgeScanB")],
1211 edges: vec![ExportedEdge {
1212 edge_id: Uuid::new_v4(),
1213 source: a,
1214 target: b,
1215 relation: EdgeRelation::Extends,
1216 weight: 0.5,
1217 properties: Some(serde_json::json!({
1218 "api_key": "AKIAFAKEKEY1234567890"
1219 })),
1220 created_at: Utc::now(),
1221 updated_at: Utc::now(),
1222 }],
1223 };
1224
1225 let err = rt.import_kg(&archive, &tok).await.unwrap_err();
1226 assert!(
1227 matches!(err, RuntimeError::SecretDetected(_)),
1228 "expected the blocking scanner to reject the value, got {err:?}"
1229 );
1230
1231 let entities = rt.list_entities(&tok, None, None, 100, 0).await.unwrap();
1232 assert!(
1233 !entities.iter().any(|e| e.name == "EdgeScanA"),
1234 "rejected archive import must not create any records"
1235 );
1236 }
1237
1238 #[test]
1240 fn invalid_relation_rejected_at_deserialize() {
1241 let json = r#"{
1242 "format":"khive-kg","version":"0.1","namespace":"local",
1243 "exported_at":"2026-01-01T00:00:00Z",
1244 "entities":[],
1245 "edges":[{"edge_id":"00000000-0000-0000-0000-000000000099",
1246 "source":"00000000-0000-0000-0000-000000000001",
1247 "target":"00000000-0000-0000-0000-000000000002",
1248 "relation":"related_to","weight":0.5}]
1249 }"#;
1250 let result: Result<KgArchive, _> = serde_json::from_str(json);
1251 assert!(
1252 result.is_err(),
1253 "non-canonical relation should fail to deserialize"
1254 );
1255 }
1256
1257 #[tokio::test]
1264 async fn import_edge_with_dangling_source_is_skipped() {
1265 let phantom_source = Uuid::parse_str("deadbeef-dead-4ead-dead-deadbeefcafe").unwrap();
1266
1267 let rt = make_rt().await;
1268 let tok = NamespaceToken::local();
1269 let real = rt
1270 .create_entity(&tok, "concept", None, "Real", None, None, vec![])
1271 .await
1272 .unwrap();
1273
1274 let archive = KgArchive {
1275 format: "khive-kg".to_string(),
1276 version: "0.1".to_string(),
1277 namespace: "local".to_string(),
1278 exported_at: Utc::now(),
1279 entities: vec![ExportedEntity {
1280 id: real.id,
1281 kind: "concept".to_string(),
1282 entity_type: None,
1283 name: "Real".to_string(),
1284 description: None,
1285 properties: None,
1286 tags: vec![],
1287 created_at: Utc::now(),
1288 updated_at: Utc::now(),
1289 }],
1290 edges: vec![ExportedEdge {
1291 edge_id: Uuid::new_v4(),
1292 source: phantom_source,
1293 target: real.id,
1294 relation: EdgeRelation::Extends,
1295 weight: 1.0,
1296 properties: None,
1297 created_at: Utc::now(),
1298 updated_at: Utc::now(),
1299 }],
1300 };
1301
1302 let dst = make_rt().await;
1303 let summary = dst.import_kg(&archive, &tok).await.unwrap();
1304 assert_eq!(summary.entities_imported, 1);
1305 assert_eq!(
1306 summary.edges_imported, 0,
1307 "dangling source must not be imported"
1308 );
1309 assert_eq!(
1310 summary.edges_skipped, 1,
1311 "dangling source must be counted as skipped"
1312 );
1313 }
1314
1315 #[tokio::test]
1320 async fn import_edge_with_dangling_target_is_skipped() {
1321 let phantom_target = Uuid::parse_str("cafebabe-cafe-4abe-cafe-cafebabecafe").unwrap();
1322
1323 let rt = make_rt().await;
1324 let tok = NamespaceToken::local();
1325 let real = rt
1326 .create_entity(&tok, "concept", None, "Source", None, None, vec![])
1327 .await
1328 .unwrap();
1329
1330 let archive = KgArchive {
1331 format: "khive-kg".to_string(),
1332 version: "0.1".to_string(),
1333 namespace: "local".to_string(),
1334 exported_at: Utc::now(),
1335 entities: vec![ExportedEntity {
1336 id: real.id,
1337 kind: "concept".to_string(),
1338 entity_type: None,
1339 name: "Source".to_string(),
1340 description: None,
1341 properties: None,
1342 tags: vec![],
1343 created_at: Utc::now(),
1344 updated_at: Utc::now(),
1345 }],
1346 edges: vec![ExportedEdge {
1347 edge_id: Uuid::new_v4(),
1348 source: real.id,
1349 target: phantom_target,
1350 relation: EdgeRelation::DependsOn,
1351 weight: 0.8,
1352 properties: None,
1353 created_at: Utc::now(),
1354 updated_at: Utc::now(),
1355 }],
1356 };
1357
1358 let dst = make_rt().await;
1359 let summary = dst.import_kg(&archive, &tok).await.unwrap();
1360 assert_eq!(summary.entities_imported, 1);
1361 assert_eq!(
1362 summary.edges_imported, 0,
1363 "dangling target must not be imported"
1364 );
1365 assert_eq!(
1366 summary.edges_skipped, 1,
1367 "dangling target must be counted as skipped"
1368 );
1369 }
1370
1371 #[tokio::test]
1376 async fn import_mixed_edges_reports_correct_counts() {
1377 let phantom = Uuid::parse_str("11111111-1111-4111-8111-111111111111").unwrap();
1378
1379 let src = make_rt().await;
1380 let tok = NamespaceToken::local();
1381 let a = src
1382 .create_entity(&tok, "concept", None, "A", None, None, vec![])
1383 .await
1384 .unwrap();
1385 let b = src
1386 .create_entity(&tok, "concept", None, "B", None, None, vec![])
1387 .await
1388 .unwrap();
1389 let c = src
1390 .create_entity(&tok, "concept", None, "C", None, None, vec![])
1391 .await
1392 .unwrap();
1393
1394 let archive = KgArchive {
1395 format: "khive-kg".to_string(),
1396 version: "0.1".to_string(),
1397 namespace: "local".to_string(),
1398 exported_at: Utc::now(),
1399 entities: vec![
1400 ExportedEntity {
1401 id: a.id,
1402 kind: "concept".to_string(),
1403 entity_type: None,
1404 name: "A".to_string(),
1405 description: None,
1406 properties: None,
1407 tags: vec![],
1408 created_at: Utc::now(),
1409 updated_at: Utc::now(),
1410 },
1411 ExportedEntity {
1412 id: b.id,
1413 kind: "concept".to_string(),
1414 entity_type: None,
1415 name: "B".to_string(),
1416 description: None,
1417 properties: None,
1418 tags: vec![],
1419 created_at: Utc::now(),
1420 updated_at: Utc::now(),
1421 },
1422 ExportedEntity {
1423 id: c.id,
1424 kind: "concept".to_string(),
1425 entity_type: None,
1426 name: "C".to_string(),
1427 description: None,
1428 properties: None,
1429 tags: vec![],
1430 created_at: Utc::now(),
1431 updated_at: Utc::now(),
1432 },
1433 ],
1434 edges: vec![
1435 ExportedEdge {
1436 edge_id: Uuid::new_v4(),
1437 source: a.id,
1438 target: b.id,
1439 relation: EdgeRelation::Extends,
1440 weight: 1.0,
1441 properties: None,
1442 created_at: Utc::now(),
1443 updated_at: Utc::now(),
1444 },
1445 ExportedEdge {
1446 edge_id: Uuid::new_v4(),
1447 source: b.id,
1448 target: c.id,
1449 relation: EdgeRelation::VariantOf,
1454 weight: 0.9,
1455 properties: None,
1456 created_at: Utc::now(),
1457 updated_at: Utc::now(),
1458 },
1459 ExportedEdge {
1460 edge_id: Uuid::new_v4(),
1461 source: a.id,
1462 target: phantom,
1463 relation: EdgeRelation::Enables,
1464 weight: 0.5,
1465 properties: None,
1466 created_at: Utc::now(),
1467 updated_at: Utc::now(),
1468 },
1469 ],
1470 };
1471
1472 let dst = make_rt().await;
1473 let summary = dst.import_kg(&archive, &tok).await.unwrap();
1474 assert_eq!(summary.entities_imported, 3);
1475 assert_eq!(
1476 summary.edges_imported, 2,
1477 "only valid edges must be imported"
1478 );
1479 assert_eq!(
1480 summary.edges_skipped, 1,
1481 "one dangling edge must be reported"
1482 );
1483 }
1484
1485 #[tokio::test]
1487 async fn import_all_valid_edges_reports_zero_skipped() {
1488 let src = make_rt().await;
1489 let tok = NamespaceToken::local();
1490 let e1 = src
1491 .create_entity(&tok, "concept", None, "E1", None, None, vec![])
1492 .await
1493 .unwrap();
1494 let e2 = src
1495 .create_entity(&tok, "concept", None, "E2", None, None, vec![])
1496 .await
1497 .unwrap();
1498 src.link(&tok, e1.id, e2.id, EdgeRelation::VariantOf, 0.7, None)
1499 .await
1500 .unwrap();
1501
1502 let archive = src.export_kg(&tok).await.unwrap();
1503 let dst = make_rt().await;
1504 let summary = dst.import_kg(&archive, &tok).await.unwrap();
1505 assert_eq!(summary.edges_imported, 1);
1506 assert_eq!(
1507 summary.edges_skipped, 0,
1508 "no edges should be skipped when all endpoints exist"
1509 );
1510 }
1511
1512 #[tokio::test]
1518 async fn import_edge_violating_endpoint_contract_is_skipped() {
1519 let src = make_rt().await;
1520 let tok = NamespaceToken::local();
1521 let e1 = src
1522 .create_entity(&tok, "concept", None, "E1", None, None, vec![])
1523 .await
1524 .unwrap();
1525 let e2 = src
1526 .create_entity(&tok, "concept", None, "E2", None, None, vec![])
1527 .await
1528 .unwrap();
1529
1530 let archive = KgArchive {
1531 format: "khive-kg".to_string(),
1532 version: "0.1".to_string(),
1533 namespace: "local".to_string(),
1534 exported_at: Utc::now(),
1535 entities: vec![
1536 ExportedEntity {
1537 id: e1.id,
1538 kind: "concept".to_string(),
1539 entity_type: None,
1540 name: "E1".to_string(),
1541 description: None,
1542 properties: None,
1543 tags: vec![],
1544 created_at: Utc::now(),
1545 updated_at: Utc::now(),
1546 },
1547 ExportedEntity {
1548 id: e2.id,
1549 kind: "concept".to_string(),
1550 entity_type: None,
1551 name: "E2".to_string(),
1552 description: None,
1553 properties: None,
1554 tags: vec![],
1555 created_at: Utc::now(),
1556 updated_at: Utc::now(),
1557 },
1558 ],
1559 edges: vec![ExportedEdge {
1560 edge_id: Uuid::new_v4(),
1561 source: e1.id,
1562 target: e2.id,
1563 relation: EdgeRelation::Precedes,
1564 weight: 1.0,
1565 properties: None,
1566 created_at: Utc::now(),
1567 updated_at: Utc::now(),
1568 }],
1569 };
1570
1571 let dst = make_rt().await;
1572 let summary = dst.import_kg(&archive, &tok).await.unwrap();
1573 assert_eq!(summary.entities_imported, 2);
1574 assert_eq!(
1575 summary.edges_imported, 0,
1576 "an endpoint-contract-violating edge must not be imported"
1577 );
1578 assert_eq!(
1579 summary.edges_skipped, 1,
1580 "an endpoint-contract-violating edge must be counted as skipped"
1581 );
1582 assert!(
1583 dst.neighbors(
1584 &tok,
1585 e1.id,
1586 khive_storage::types::Direction::Out,
1587 None,
1588 None
1589 )
1590 .await
1591 .unwrap()
1592 .is_empty(),
1593 "the contract-violating edge must not exist in the destination graph"
1594 );
1595 }
1596
1597 #[tokio::test]
1601 async fn export_kg_preserves_edge_id() {
1602 let rt = make_rt().await;
1603 let tok = NamespaceToken::local();
1604 let a = rt
1605 .create_entity(&tok, "concept", None, "Alpha", None, None, vec![])
1606 .await
1607 .unwrap();
1608 let b = rt
1609 .create_entity(&tok, "concept", None, "Beta", None, None, vec![])
1610 .await
1611 .unwrap();
1612 let stored_edge = rt
1613 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
1614 .await
1615 .unwrap();
1616 let stored_id: Uuid = stored_edge.id.into();
1617
1618 let archive = rt.export_kg(&tok).await.unwrap();
1619 assert_eq!(archive.edges.len(), 1);
1620 assert_eq!(
1621 archive.edges[0].edge_id, stored_id,
1622 "exported edge_id must equal the LinkId returned by link"
1623 );
1624 }
1625
1626 #[tokio::test]
1628 async fn import_kg_persists_edge_id_and_timestamps() {
1629 let src = make_rt().await;
1630 let tok = NamespaceToken::local();
1631 let a = src
1632 .create_entity(&tok, "concept", None, "Alpha", None, None, vec![])
1633 .await
1634 .unwrap();
1635 let b = src
1636 .create_entity(&tok, "concept", None, "Beta", None, None, vec![])
1637 .await
1638 .unwrap();
1639 let stored_edge = src
1640 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
1641 .await
1642 .unwrap();
1643 let original_id: Uuid = stored_edge.id.into();
1644
1645 let expected_created = chrono::DateTime::parse_from_rfc3339("2026-03-03T00:00:00Z")
1646 .unwrap()
1647 .with_timezone(&Utc);
1648 let expected_updated = chrono::DateTime::parse_from_rfc3339("2026-04-04T00:00:00Z")
1649 .unwrap()
1650 .with_timezone(&Utc);
1651 let mut archive = src.export_kg(&tok).await.unwrap();
1652 archive.edges[0].created_at = expected_created;
1653 archive.edges[0].updated_at = expected_updated;
1654 archive.edges[0].properties = Some(serde_json::json!({"confidence": 0.95}));
1655 let dst = make_rt().await;
1656 dst.import_kg(&archive, &tok).await.unwrap();
1657
1658 let imported_edge = dst.get_edge(&tok, original_id).await.unwrap();
1659 assert!(
1660 imported_edge.is_some(),
1661 "imported edge must be retrievable by the original edge_id"
1662 );
1663 let imported_edge = imported_edge.unwrap();
1664 assert_eq!(
1665 Uuid::from(imported_edge.id),
1666 original_id,
1667 "stored edge id must equal the archive edge_id"
1668 );
1669 assert_eq!(
1670 imported_edge.created_at, expected_created,
1671 "present edge created_at must not be replaced with import time"
1672 );
1673 assert_eq!(
1674 imported_edge.updated_at, expected_updated,
1675 "present edge updated_at must not be replaced with created_at or import time"
1676 );
1677 assert_eq!(
1678 imported_edge.metadata,
1679 Some(serde_json::json!({"confidence": 0.95})),
1680 "edge properties must persist as storage metadata"
1681 );
1682
1683 let reexported = dst.export_kg(&tok).await.unwrap();
1684 assert_eq!(
1685 reexported.edges[0].properties,
1686 Some(serde_json::json!({"confidence": 0.95})),
1687 "edge metadata must re-export as portable properties"
1688 );
1689 }
1690
1691 #[tokio::test]
1696 async fn old_archive_missing_edge_id_round_trips() {
1697 let src_id = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap();
1698 let tgt_id = Uuid::parse_str("00000000-0000-0000-0000-000000000002").unwrap();
1699
1700 let json = format!(
1702 r#"{{
1703 "format": "khive-kg",
1704 "version": "0.1",
1705 "namespace": "local",
1706 "exported_at": "2026-01-01T00:00:00Z",
1707 "entities": [
1708 {{"id":"{src_id}","kind":"concept","name":"SrcNode","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"}},
1709 {{"id":"{tgt_id}","kind":"concept","name":"TgtNode","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"}}
1710 ],
1711 "edges": [
1712 {{
1713 "source": "{src_id}",
1714 "target": "{tgt_id}",
1715 "relation": "extends",
1716 "weight": 0.9
1717 }}
1718 ]
1719 }}"#
1720 );
1721
1722 let archive: KgArchive = serde_json::from_str(&json)
1724 .expect("old archive without edge_id must deserialize successfully");
1725 assert_eq!(archive.edges.len(), 1);
1726 let generated_id = archive.edges[0].edge_id;
1727 assert_ne!(
1728 generated_id,
1729 Uuid::nil(),
1730 "missing edge_id in old archive must get a fresh non-nil UUID"
1731 );
1732
1733 let rt = make_rt().await;
1734 let tok = NamespaceToken::local();
1735 let summary = rt.import_kg(&archive, &tok).await.unwrap();
1736 assert_eq!(summary.entities_imported, 2);
1737 assert_eq!(
1738 summary.edges_imported, 1,
1739 "edge must be imported when both endpoints exist"
1740 );
1741
1742 let stored = rt.get_edge(&tok, generated_id).await.unwrap();
1743 assert!(
1744 stored.is_some(),
1745 "imported edge must be retrievable by the generated edge_id"
1746 );
1747 assert_eq!(
1748 Uuid::from(stored.unwrap().id),
1749 generated_id,
1750 "stored edge id must equal the generated edge_id"
1751 );
1752
1753 let re_archive = rt.export_kg(&tok).await.unwrap();
1754 assert_eq!(re_archive.edges.len(), 1);
1755 assert_eq!(
1756 re_archive.edges[0].edge_id, generated_id,
1757 "re-exported edge_id must equal the ID generated on first import"
1758 );
1759 }
1760
1761 #[tokio::test]
1766 async fn export_import_export_edge_id_equality() {
1767 let src = make_rt().await;
1768 let tok = NamespaceToken::local();
1769 let a = src
1770 .create_entity(&tok, "concept", None, "NodeA", None, None, vec![])
1771 .await
1772 .unwrap();
1773 let b = src
1774 .create_entity(&tok, "concept", None, "NodeB", None, None, vec![])
1775 .await
1776 .unwrap();
1777 let stored = src
1778 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
1779 .await
1780 .unwrap();
1781 let original_edge_id: Uuid = stored.id.into();
1782
1783 let archive1 = src.export_kg(&tok).await.unwrap();
1784 assert_eq!(archive1.edges.len(), 1);
1785 assert_eq!(
1786 archive1.edges[0].edge_id, original_edge_id,
1787 "first export must carry the stored edge_id"
1788 );
1789
1790 let dst = make_rt().await;
1791 dst.import_kg(&archive1, &tok).await.unwrap();
1792
1793 let archive2 = dst.export_kg(&tok).await.unwrap();
1794 assert_eq!(archive2.edges.len(), 1);
1795
1796 let re_edge = archive2
1797 .edges
1798 .iter()
1799 .find(|e| e.source == a.id && e.target == b.id && e.relation == EdgeRelation::Extends)
1800 .expect(
1801 "re-exported archive must contain the original edge by (source,target,relation)",
1802 );
1803 assert_eq!(
1804 re_edge.edge_id, original_edge_id,
1805 "edge_id must be identical across export → import → export"
1806 );
1807 }
1808
1809 fn skip_report_entity(id: Uuid, name: &str) -> ExportedEntity {
1811 let timestamp = DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
1812 .unwrap()
1813 .with_timezone(&Utc);
1814 ExportedEntity {
1815 id,
1816 kind: "document".into(),
1817 entity_type: None,
1818 name: name.into(),
1819 description: None,
1820 properties: None,
1821 tags: vec![],
1822 created_at: timestamp,
1823 updated_at: timestamp,
1824 }
1825 }
1826
1827 fn skip_report_edge(
1828 id: u128,
1829 source: Uuid,
1830 target: Uuid,
1831 relation: EdgeRelation,
1832 ) -> ExportedEdge {
1833 let timestamp = DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
1834 .unwrap()
1835 .with_timezone(&Utc);
1836 ExportedEdge {
1837 edge_id: Uuid::from_u128(id),
1838 source,
1839 target,
1840 relation,
1841 weight: 0.7,
1842 properties: None,
1843 created_at: timestamp,
1844 updated_at: timestamp,
1845 }
1846 }
1847
1848 fn skip_report_archive(entities: Vec<ExportedEntity>, edges: Vec<ExportedEdge>) -> KgArchive {
1849 KgArchive {
1850 format: "khive-kg".into(),
1851 version: "0.1".into(),
1852 namespace: "local".into(),
1853 exported_at: Utc::now(),
1854 entities,
1855 edges,
1856 }
1857 }
1858
1859 #[tokio::test]
1860 async fn skipped_edge_details_cover_three_causes_and_retain_the_valid_edge() {
1861 let runtime = make_rt().await;
1862 let token = NamespaceToken::local();
1863 let source = Uuid::from_u128(1);
1864 let target = Uuid::from_u128(2);
1865 let absent = Uuid::from_u128(3);
1866 let mut valid = skip_report_edge(101, source, target, EdgeRelation::LinksTo);
1867 valid.properties = Some(serde_json::json!({"retained": true}));
1868 let archive = skip_report_archive(
1869 vec![
1870 skip_report_entity(source, "Source"),
1871 skip_report_entity(target, "Target"),
1872 ],
1873 vec![
1874 skip_report_edge(100, absent, source, EdgeRelation::LinksTo),
1875 valid.clone(),
1876 skip_report_edge(102, source, absent, EdgeRelation::DependsOn),
1877 skip_report_edge(103, source, target, EdgeRelation::Annotates),
1878 ],
1879 );
1880 let summary = runtime.import_kg(&archive, &token).await.unwrap();
1881 let expected = vec![
1882 ImportSkippedEdge {
1883 edge_id: Uuid::from_u128(100),
1884 source: absent,
1885 target: source,
1886 relation: EdgeRelation::LinksTo,
1887 cause: ImportEdgeSkipCause::MissingSource,
1888 error: None,
1889 },
1890 ImportSkippedEdge {
1891 edge_id: Uuid::from_u128(102),
1892 source,
1893 target: absent,
1894 relation: EdgeRelation::DependsOn,
1895 cause: ImportEdgeSkipCause::MissingTarget,
1896 error: None,
1897 },
1898 ImportSkippedEdge {
1899 edge_id: Uuid::from_u128(103),
1900 source,
1901 target,
1902 relation: EdgeRelation::Annotates,
1903 cause: ImportEdgeSkipCause::EndpointContract,
1904 error: Some(format!(
1905 "invalid input: annotates source {source} must be a note"
1906 )),
1907 },
1908 ];
1909 assert_eq!(summary.entities_imported, 2);
1910 assert_eq!(summary.edges_imported, 1);
1911 assert_eq!(summary.edges_skipped, 3);
1912 assert_eq!(summary.skipped_edges, expected);
1913 let wire = serde_json::to_value(&summary).unwrap();
1914 assert_eq!(
1915 wire["skipped_edges"],
1916 serde_json::json!([
1917 {"edge_id":Uuid::from_u128(100),"source":absent,"target":source,
1918 "relation":"links_to","cause":"missing_source"},
1919 {"edge_id":Uuid::from_u128(102),"source":source,"target":absent,
1920 "relation":"depends_on","cause":"missing_target"},
1921 {"edge_id":Uuid::from_u128(103),"source":source,"target":target,
1922 "relation":"annotates","cause":"endpoint_contract",
1923 "error":format!("invalid input: annotates source {source} must be a note")}
1924 ])
1925 );
1926 let decoded: ImportSummary = serde_json::from_value(wire).unwrap();
1927 assert_eq!(decoded.skipped_edges, expected);
1928 assert_eq!(decoded.edges_skipped, 3);
1929 for id in [100, 102, 103] {
1930 assert!(runtime
1931 .get_edge(&token, Uuid::from_u128(id))
1932 .await
1933 .unwrap()
1934 .is_none());
1935 }
1936 let stored = runtime
1937 .get_edge(&token, valid.edge_id)
1938 .await
1939 .unwrap()
1940 .unwrap();
1941 assert_eq!(Uuid::from(stored.id), valid.edge_id);
1942 assert_eq!(stored.source_id, source);
1943 assert_eq!(stored.target_id, target);
1944 assert_eq!(stored.relation, EdgeRelation::LinksTo);
1945 assert_eq!(stored.metadata, valid.properties);
1946 assert_eq!(stored.created_at, valid.created_at);
1947 assert_eq!(stored.updated_at, valid.updated_at);
1948 let exported = runtime.export_kg(&token).await.unwrap();
1949 assert_eq!(exported.edges.len(), 1);
1950 assert_eq!(exported.edges[0].edge_id, valid.edge_id);
1951 }
1952
1953 #[tokio::test]
1954 async fn skipped_edge_details_preserve_duplicates_order_and_both_missing_precedence() {
1955 let runtime = make_rt().await;
1956 let token = NamespaceToken::local();
1957 let present = Uuid::from_u128(1);
1958 let absent = Uuid::from_u128(3);
1959 let other_absent = Uuid::from_u128(4);
1960 let repeated = skip_report_edge(111, absent, present, EdgeRelation::LinksTo);
1961 let archive = skip_report_archive(
1962 vec![skip_report_entity(present, "Present")],
1963 vec![
1964 skip_report_edge(110, present, absent, EdgeRelation::DependsOn),
1965 repeated.clone(),
1966 skip_report_edge(112, absent, present, EdgeRelation::LinksTo),
1967 repeated,
1968 skip_report_edge(113, absent, other_absent, EdgeRelation::Extends),
1969 ],
1970 );
1971 let summary = runtime.import_kg(&archive, &token).await.unwrap();
1972 assert_eq!(summary.edges_imported, 0);
1973 assert_eq!(summary.edges_skipped, 5);
1974 assert_eq!(
1975 summary.skipped_edges,
1976 vec![
1977 ImportSkippedEdge {
1978 edge_id: Uuid::from_u128(110),
1979 source: present,
1980 target: absent,
1981 relation: EdgeRelation::DependsOn,
1982 cause: ImportEdgeSkipCause::MissingTarget,
1983 error: None,
1984 },
1985 ImportSkippedEdge {
1986 edge_id: Uuid::from_u128(111),
1987 source: absent,
1988 target: present,
1989 relation: EdgeRelation::LinksTo,
1990 cause: ImportEdgeSkipCause::MissingSource,
1991 error: None,
1992 },
1993 ImportSkippedEdge {
1994 edge_id: Uuid::from_u128(112),
1995 source: absent,
1996 target: present,
1997 relation: EdgeRelation::LinksTo,
1998 cause: ImportEdgeSkipCause::MissingSource,
1999 error: None,
2000 },
2001 ImportSkippedEdge {
2002 edge_id: Uuid::from_u128(111),
2003 source: absent,
2004 target: present,
2005 relation: EdgeRelation::LinksTo,
2006 cause: ImportEdgeSkipCause::MissingSource,
2007 error: None,
2008 },
2009 ImportSkippedEdge {
2010 edge_id: Uuid::from_u128(113),
2011 source: absent,
2012 target: other_absent,
2013 relation: EdgeRelation::Extends,
2014 cause: ImportEdgeSkipCause::MissingSource,
2015 error: None,
2016 },
2017 ]
2018 );
2019 assert!(runtime.export_kg(&token).await.unwrap().edges.is_empty());
2020 }
2021
2022 #[tokio::test]
2023 async fn successful_imports_without_skips_keep_the_previous_json_shape() {
2024 let token = NamespaceToken::local();
2025 let a = Uuid::from_u128(1);
2026 let b = Uuid::from_u128(2);
2027 for (archive, entities, edges) in [
2028 (skip_report_archive(vec![], vec![]), 0, 0),
2029 (
2030 skip_report_archive(
2031 vec![skip_report_entity(a, "A"), skip_report_entity(b, "B")],
2032 vec![skip_report_edge(101, a, b, EdgeRelation::LinksTo)],
2033 ),
2034 2,
2035 1,
2036 ),
2037 ] {
2038 let runtime = make_rt().await;
2039 let summary = runtime.import_kg(&archive, &token).await.unwrap();
2040 assert!(summary.skipped_edges.is_empty());
2041 assert_eq!(summary.edges_skipped, 0);
2042 assert_eq!(
2043 serde_json::to_value(&summary).unwrap(),
2044 serde_json::json!({
2045 "entities_imported":entities,"edges_imported":edges,"edges_skipped":0,
2046 "embedding_truncation":{"truncated":0,"discarded_bytes":0}
2047 })
2048 );
2049 }
2050 }
2051
2052 #[test]
2053 fn historical_summary_counts_do_not_invent_skipped_edge_details() {
2054 for count in [0, 2] {
2055 let old = serde_json::json!({
2056 "entities_imported":1,"edges_imported":0,"edges_skipped":count
2057 });
2058 let summary: ImportSummary = serde_json::from_value(old).unwrap();
2059 assert_eq!(summary.edges_skipped, count);
2060 assert!(summary.skipped_edges.is_empty());
2061 assert_eq!(
2062 serde_json::to_value(summary).unwrap(),
2063 serde_json::json!({
2064 "entities_imported":1,"edges_imported":0,"edges_skipped":count,
2065 "embedding_truncation":{"truncated":0,"discarded_bytes":0}
2066 })
2067 );
2068 }
2069 }
2070
2071 #[test]
2072 fn skipped_edge_cause_spellings_are_closed_and_error_is_preserved_verbatim() {
2073 let wire = serde_json::json!({
2074 "edge_id":"00000000-0000-0000-0000-000000000103",
2075 "source":"00000000-0000-0000-0000-000000000001",
2076 "target":"00000000-0000-0000-0000-000000000002",
2077 "relation":"annotates","cause":"endpoint_contract",
2078 "error":"not found: link target changed during endpoint validation"
2079 });
2080 let detail: ImportSkippedEdge = serde_json::from_value(wire.clone()).unwrap();
2081 assert_eq!(detail.cause, ImportEdgeSkipCause::EndpointContract);
2082 assert_eq!(
2083 detail.error.as_deref(),
2084 Some("not found: link target changed during endpoint validation")
2085 );
2086 assert_eq!(serde_json::to_value(detail).unwrap(), wire);
2087 let mut invalid = wire;
2088 invalid["cause"] = serde_json::json!("storage_failure");
2089 assert!(serde_json::from_value::<ImportSkippedEdge>(invalid).is_err());
2090 }
2091}