1use std::collections::HashSet;
7
8use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use uuid::Uuid;
11
12use khive_storage::types::{EdgeFilter, LinkId, PageRequest};
13use khive_storage::{EdgeRelation, EntityFilter};
14
15use crate::error::{RuntimeError, RuntimeResult};
16use crate::runtime::{KhiveRuntime, NamespaceToken};
17
18#[derive(Clone, Debug, Serialize, Deserialize)]
25pub struct KgArchive {
26 pub format: String,
27 pub version: String,
28 pub namespace: String,
29 pub exported_at: DateTime<Utc>,
30 pub entities: Vec<ExportedEntity>,
31 pub edges: Vec<ExportedEdge>,
32}
33
34#[derive(Clone, Debug, Serialize, Deserialize)]
36pub struct ExportedEntity {
37 pub id: Uuid,
38 pub kind: String,
40 #[serde(skip_serializing_if = "Option::is_none")]
42 pub entity_type: Option<String>,
43 pub name: String,
44 #[serde(skip_serializing_if = "Option::is_none")]
45 pub description: Option<String>,
46 #[serde(skip_serializing_if = "Option::is_none")]
47 pub properties: Option<serde_json::Value>,
48 #[serde(default)]
49 pub tags: Vec<String>,
50 pub created_at: DateTime<Utc>,
51 pub updated_at: DateTime<Utc>,
52}
53
54#[derive(Clone, Debug, Serialize, Deserialize)]
56pub struct ExportedEdge {
57 #[serde(default = "Uuid::new_v4")]
62 pub edge_id: Uuid,
63 pub source: Uuid,
64 pub target: Uuid,
65 pub relation: EdgeRelation,
67 pub weight: f64,
68}
69
70#[derive(Clone, Debug, Serialize, Deserialize)]
72pub struct ImportSummary {
73 pub entities_imported: usize,
74 pub edges_imported: usize,
75 pub edges_skipped: usize,
81}
82
83impl KhiveRuntime {
86 pub async fn export_kg(&self, token: &NamespaceToken) -> RuntimeResult<KgArchive> {
92 let ns = token.namespace().as_str().to_owned();
93
94 let entity_page = self
95 .entities(token)?
96 .query_entities(
97 &ns,
98 EntityFilter::default(),
99 PageRequest {
100 offset: 0,
101 limit: u32::MAX,
102 },
103 )
104 .await?;
105
106 let entities: Vec<ExportedEntity> = entity_page
107 .items
108 .into_iter()
109 .map(|e| {
110 let created_at =
111 DateTime::from_timestamp_micros(e.created_at).unwrap_or_else(Utc::now);
112 let updated_at =
113 DateTime::from_timestamp_micros(e.updated_at).unwrap_or_else(Utc::now);
114 ExportedEntity {
115 id: e.id,
116 kind: e.kind.to_string(),
117 entity_type: e.entity_type,
118 name: e.name,
119 description: e.description,
120 properties: e.properties,
121 tags: e.tags,
122 created_at,
123 updated_at,
124 }
125 })
126 .collect();
127
128 let source_ids: Vec<Uuid> = entities.iter().map(|e| e.id).collect();
129 let edges = if source_ids.is_empty() {
130 Vec::new()
131 } else {
132 let filter = EdgeFilter {
133 source_ids: source_ids.clone(),
134 ..Default::default()
135 };
136 let edge_page = self
137 .graph(token)?
138 .query_edges(
139 filter,
140 Vec::new(),
141 PageRequest {
142 offset: 0,
143 limit: u32::MAX,
144 },
145 )
146 .await?;
147
148 let id_set: HashSet<Uuid> = source_ids.into_iter().collect();
149 edge_page
150 .items
151 .into_iter()
152 .filter(|e| id_set.contains(&e.source_id))
153 .map(|e| ExportedEdge {
154 edge_id: e.id.into(),
155 source: e.source_id,
156 target: e.target_id,
157 relation: e.relation,
158 weight: e.weight,
159 })
160 .collect()
161 };
162
163 Ok(KgArchive {
164 format: "khive-kg".to_string(),
165 version: "0.1".to_string(),
166 namespace: ns,
167 exported_at: Utc::now(),
168 entities,
169 edges,
170 })
171 }
172
173 pub async fn export_kg_json(&self, token: &NamespaceToken) -> RuntimeResult<String> {
175 let archive = self.export_kg(token).await?;
176 serde_json::to_string(&archive).map_err(|e| RuntimeError::InvalidInput(e.to_string()))
177 }
178
179 pub async fn import_kg(
188 &self,
189 archive: &KgArchive,
190 token: &NamespaceToken,
191 ) -> RuntimeResult<ImportSummary> {
192 if archive.format != "khive-kg" {
193 return Err(RuntimeError::InvalidInput(format!(
194 "unsupported archive format {:?}; expected \"khive-kg\"",
195 archive.format
196 )));
197 }
198 if archive.version != "0.1" {
199 return Err(RuntimeError::InvalidInput(format!(
200 "unsupported archive version {:?}; supported: \"0.1\"",
201 archive.version
202 )));
203 }
204
205 let ns = token.namespace().as_str().to_owned();
206
207 let store = self.entities(token)?;
208 let mut entities_imported = 0usize;
209 for ee in &archive.entities {
210 self.validate_entity_kind(&ee.kind)?;
211 let created_micros = ee.created_at.timestamp_micros();
212 let updated_micros = ee.updated_at.timestamp_micros();
213 let entity = khive_storage::entity::Entity {
214 id: ee.id,
215 namespace: ns.clone(),
216 kind: ee.kind.clone(),
217 entity_type: ee.entity_type.clone(),
218 name: ee.name.clone(),
219 description: ee.description.clone(),
220 properties: ee.properties.clone(),
221 tags: ee.tags.clone(),
222 created_at: created_micros,
223 updated_at: updated_micros,
224 deleted_at: None,
225 merged_into: None,
226 merge_event_id: None,
227 };
228 store.upsert_entity(entity.clone()).await?;
229 self.reindex_entity(token, &entity).await?;
231 entities_imported += 1;
232 }
233
234 let graph = self.graph(token)?;
238 let mut edges_imported = 0usize;
239 let mut edges_skipped = 0usize;
240 for ee in &archive.edges {
241 crate::operations::validate_edge_weight(ee.weight)?;
242 let source_ok = match self.get_entity(token, ee.source).await {
243 Ok(_) => true,
244 Err(RuntimeError::NotFound(_)) => false,
245 Err(e) => return Err(e),
246 };
247 if !source_ok {
248 tracing::warn!(
249 source = %ee.source,
250 target = %ee.target,
251 relation = ?ee.relation,
252 "import_kg: skipping edge — source entity not found in namespace {ns:?}"
253 );
254 edges_skipped += 1;
255 continue;
256 }
257 let target_ok = match self.get_entity(token, ee.target).await {
258 Ok(_) => true,
259 Err(RuntimeError::NotFound(_)) => false,
260 Err(e) => return Err(e),
261 };
262 if !target_ok {
263 tracing::warn!(
264 source = %ee.source,
265 target = %ee.target,
266 relation = ?ee.relation,
267 "import_kg: skipping edge — target entity not found in namespace {ns:?}"
268 );
269 edges_skipped += 1;
270 continue;
271 }
272 let now = Utc::now();
273 let edge = khive_storage::types::Edge {
274 id: LinkId::from(ee.edge_id),
275 namespace: ns.clone(),
276 source_id: ee.source,
277 target_id: ee.target,
278 relation: ee.relation,
279 weight: ee.weight,
280 created_at: now,
281 updated_at: now,
282 deleted_at: None,
283 metadata: None,
284 target_backend: None,
285 };
286 graph.upsert_edge(edge).await?;
287 edges_imported += 1;
288 }
289
290 Ok(ImportSummary {
291 entities_imported,
292 edges_imported,
293 edges_skipped,
294 })
295 }
296
297 pub async fn import_kg_json(
299 &self,
300 json: &str,
301 token: &NamespaceToken,
302 ) -> RuntimeResult<ImportSummary> {
303 let archive: KgArchive =
304 serde_json::from_str(json).map_err(|e| RuntimeError::InvalidInput(e.to_string()))?;
305 self.import_kg(&archive, token).await
306 }
307}
308
309#[cfg(test)]
314mod tests {
315 use super::*;
316 use crate::runtime::{KhiveRuntime, NamespaceToken};
317 use crate::Namespace;
318 use khive_storage::EdgeRelation;
319
320 async fn make_rt() -> KhiveRuntime {
321 KhiveRuntime::memory().expect("in-memory runtime")
322 }
323
324 #[tokio::test]
326 async fn roundtrip_entities_and_edges() {
327 let src = make_rt().await;
328 let tok = NamespaceToken::local();
329 let e1 = src
330 .create_entity(
331 &tok,
332 "concept",
333 None,
334 "FlashAttention",
335 Some("fast attention"),
336 None,
337 vec![],
338 )
339 .await
340 .unwrap();
341 let e2 = src
342 .create_entity(
343 &tok,
344 "concept",
345 None,
346 "FlashAttention-2",
347 None,
348 None,
349 vec![],
350 )
351 .await
352 .unwrap();
353 let e3 = src
354 .create_entity(
355 &tok,
356 "person",
357 None,
358 "Tri Dao",
359 None,
360 None,
361 vec!["author".into()],
362 )
363 .await
364 .unwrap();
365 src.link(&tok, e2.id, e1.id, EdgeRelation::Extends, 1.0, None)
366 .await
367 .unwrap();
368 src.link(&tok, e1.id, e3.id, EdgeRelation::IntroducedBy, 0.9, None)
369 .await
370 .unwrap();
371
372 let archive = src.export_kg(&tok).await.unwrap();
373 assert_eq!(archive.entities.len(), 3);
374 assert_eq!(archive.edges.len(), 2);
375 assert_eq!(archive.format, "khive-kg");
376 assert_eq!(archive.version, "0.1");
377
378 let dst = make_rt().await;
379 let summary = dst.import_kg(&archive, &tok).await.unwrap();
380 assert_eq!(summary.entities_imported, 3);
381 assert_eq!(summary.edges_imported, 2);
382
383 let got = dst.get_entity(&tok, e1.id).await.unwrap();
384 assert_eq!(got.name, "FlashAttention");
385 assert_eq!(got.description.as_deref(), Some("fast attention"));
386 }
387
388 #[tokio::test]
390 async fn json_roundtrip() {
391 let src = make_rt().await;
392 let tok = NamespaceToken::local();
393 let e1 = src
394 .create_entity(
395 &tok,
396 "concept",
397 None,
398 "LoRA",
399 Some("low-rank adaptation"),
400 Some(serde_json::json!({"year": "2021"})),
401 vec!["fine-tuning".into()],
402 )
403 .await
404 .unwrap();
405 let e2 = src
406 .create_entity(&tok, "concept", None, "QLoRA", None, None, vec![])
407 .await
408 .unwrap();
409 src.link(&tok, e2.id, e1.id, EdgeRelation::VariantOf, 0.9, None)
410 .await
411 .unwrap();
412
413 let json_str = src.export_kg_json(&tok).await.unwrap();
414 assert!(json_str.contains("khive-kg"));
415
416 let dst = make_rt().await;
417 let summary = dst.import_kg_json(&json_str, &tok).await.unwrap();
418 assert_eq!(summary.entities_imported, 2);
419 assert_eq!(summary.edges_imported, 1);
420
421 let got = dst.get_entity(&tok, e1.id).await.unwrap();
422 assert_eq!(got.tags, vec!["fine-tuning"]);
423 }
424
425 #[tokio::test]
432 async fn namespace_targeting() {
433 let src = make_rt().await;
434 let tok_a = NamespaceToken::for_namespace(Namespace::parse("a").unwrap());
435 let tok_b = NamespaceToken::for_namespace(Namespace::parse("b").unwrap());
436 src.create_entity(&tok_a, "concept", None, "Sinkhorn", None, None, vec![])
437 .await
438 .unwrap();
439
440 let archive = src.export_kg(&tok_a).await.unwrap();
441 assert_eq!(archive.namespace, "a");
442
443 let dst = make_rt().await;
444 let summary = dst.import_kg(&archive, &tok_b).await.unwrap();
445 assert_eq!(summary.entities_imported, 1);
446
447 let in_b = dst.list_entities(&tok_b, None, None, 100, 0).await.unwrap();
448 assert_eq!(in_b.len(), 1);
449 assert_eq!(in_b[0].name, "Sinkhorn");
450
451 let in_a = src.list_entities(&tok_a, None, None, 100, 0).await.unwrap();
452 assert_eq!(in_a.len(), 1);
453
454 let dst_a = dst.list_entities(&tok_a, None, None, 100, 0).await.unwrap();
455 assert_eq!(dst_a.len(), 0);
456 }
457
458 #[tokio::test]
460 async fn format_validation_rejects_wrong_format() {
461 let rt = make_rt().await;
462 let tok = NamespaceToken::local();
463 let bad = KgArchive {
464 format: "wrong".to_string(),
465 version: "0.1".to_string(),
466 namespace: "local".to_string(),
467 exported_at: Utc::now(),
468 entities: vec![],
469 edges: vec![],
470 };
471 let err = rt.import_kg(&bad, &tok).await.unwrap_err();
472 assert!(matches!(err, RuntimeError::InvalidInput(_)));
473 }
474
475 #[tokio::test]
477 async fn import_unsupported_archive_version_returns_error() {
478 let rt = make_rt().await;
479 let tok = NamespaceToken::local();
480 let bad = KgArchive {
481 format: "khive-kg".to_string(),
482 version: "999.0".to_string(),
483 namespace: "local".to_string(),
484 exported_at: Utc::now(),
485 entities: vec![],
486 edges: vec![],
487 };
488 let err = rt.import_kg(&bad, &tok).await.unwrap_err();
489 assert!(
490 matches!(err, RuntimeError::InvalidInput(_)),
491 "expected InvalidInput, got {err:?}"
492 );
493 if let RuntimeError::InvalidInput(msg) = err {
494 assert!(
495 msg.contains("999.0"),
496 "error message should mention the unsupported version, got: {msg:?}"
497 );
498 }
499 }
500
501 #[test]
503 fn invalid_relation_rejected_at_deserialize() {
504 let json = r#"{
505 "format":"khive-kg","version":"0.1","namespace":"local",
506 "exported_at":"2026-01-01T00:00:00Z",
507 "entities":[],
508 "edges":[{"edge_id":"00000000-0000-0000-0000-000000000099",
509 "source":"00000000-0000-0000-0000-000000000001",
510 "target":"00000000-0000-0000-0000-000000000002",
511 "relation":"related_to","weight":0.5}]
512 }"#;
513 let result: Result<KgArchive, _> = serde_json::from_str(json);
514 assert!(
515 result.is_err(),
516 "non-canonical relation should fail to deserialize"
517 );
518 }
519
520 #[tokio::test]
527 async fn import_edge_with_dangling_source_is_skipped() {
528 let phantom_source = Uuid::parse_str("deadbeef-dead-4ead-dead-deadbeefcafe").unwrap();
529
530 let rt = make_rt().await;
531 let tok = NamespaceToken::local();
532 let real = rt
533 .create_entity(&tok, "concept", None, "Real", None, None, vec![])
534 .await
535 .unwrap();
536
537 let archive = KgArchive {
538 format: "khive-kg".to_string(),
539 version: "0.1".to_string(),
540 namespace: "local".to_string(),
541 exported_at: Utc::now(),
542 entities: vec![ExportedEntity {
543 id: real.id,
544 kind: "concept".to_string(),
545 entity_type: None,
546 name: "Real".to_string(),
547 description: None,
548 properties: None,
549 tags: vec![],
550 created_at: Utc::now(),
551 updated_at: Utc::now(),
552 }],
553 edges: vec![ExportedEdge {
554 edge_id: Uuid::new_v4(),
555 source: phantom_source,
556 target: real.id,
557 relation: EdgeRelation::Extends,
558 weight: 1.0,
559 }],
560 };
561
562 let dst = make_rt().await;
563 let summary = dst.import_kg(&archive, &tok).await.unwrap();
564 assert_eq!(summary.entities_imported, 1);
565 assert_eq!(
566 summary.edges_imported, 0,
567 "dangling source must not be imported"
568 );
569 assert_eq!(
570 summary.edges_skipped, 1,
571 "dangling source must be counted as skipped"
572 );
573 }
574
575 #[tokio::test]
580 async fn import_edge_with_dangling_target_is_skipped() {
581 let phantom_target = Uuid::parse_str("cafebabe-cafe-4abe-cafe-cafebabecafe").unwrap();
582
583 let rt = make_rt().await;
584 let tok = NamespaceToken::local();
585 let real = rt
586 .create_entity(&tok, "concept", None, "Source", None, None, vec![])
587 .await
588 .unwrap();
589
590 let archive = KgArchive {
591 format: "khive-kg".to_string(),
592 version: "0.1".to_string(),
593 namespace: "local".to_string(),
594 exported_at: Utc::now(),
595 entities: vec![ExportedEntity {
596 id: real.id,
597 kind: "concept".to_string(),
598 entity_type: None,
599 name: "Source".to_string(),
600 description: None,
601 properties: None,
602 tags: vec![],
603 created_at: Utc::now(),
604 updated_at: Utc::now(),
605 }],
606 edges: vec![ExportedEdge {
607 edge_id: Uuid::new_v4(),
608 source: real.id,
609 target: phantom_target,
610 relation: EdgeRelation::DependsOn,
611 weight: 0.8,
612 }],
613 };
614
615 let dst = make_rt().await;
616 let summary = dst.import_kg(&archive, &tok).await.unwrap();
617 assert_eq!(summary.entities_imported, 1);
618 assert_eq!(
619 summary.edges_imported, 0,
620 "dangling target must not be imported"
621 );
622 assert_eq!(
623 summary.edges_skipped, 1,
624 "dangling target must be counted as skipped"
625 );
626 }
627
628 #[tokio::test]
633 async fn import_mixed_edges_reports_correct_counts() {
634 let phantom = Uuid::parse_str("11111111-1111-4111-8111-111111111111").unwrap();
635
636 let src = make_rt().await;
637 let tok = NamespaceToken::local();
638 let a = src
639 .create_entity(&tok, "concept", None, "A", None, None, vec![])
640 .await
641 .unwrap();
642 let b = src
643 .create_entity(&tok, "concept", None, "B", None, None, vec![])
644 .await
645 .unwrap();
646 let c = src
647 .create_entity(&tok, "concept", None, "C", None, None, vec![])
648 .await
649 .unwrap();
650
651 let archive = KgArchive {
652 format: "khive-kg".to_string(),
653 version: "0.1".to_string(),
654 namespace: "local".to_string(),
655 exported_at: Utc::now(),
656 entities: vec![
657 ExportedEntity {
658 id: a.id,
659 kind: "concept".to_string(),
660 entity_type: None,
661 name: "A".to_string(),
662 description: None,
663 properties: None,
664 tags: vec![],
665 created_at: Utc::now(),
666 updated_at: Utc::now(),
667 },
668 ExportedEntity {
669 id: b.id,
670 kind: "concept".to_string(),
671 entity_type: None,
672 name: "B".to_string(),
673 description: None,
674 properties: None,
675 tags: vec![],
676 created_at: Utc::now(),
677 updated_at: Utc::now(),
678 },
679 ExportedEntity {
680 id: c.id,
681 kind: "concept".to_string(),
682 entity_type: None,
683 name: "C".to_string(),
684 description: None,
685 properties: None,
686 tags: vec![],
687 created_at: Utc::now(),
688 updated_at: Utc::now(),
689 },
690 ],
691 edges: vec![
692 ExportedEdge {
693 edge_id: Uuid::new_v4(),
694 source: a.id,
695 target: b.id,
696 relation: EdgeRelation::Extends,
697 weight: 1.0,
698 },
699 ExportedEdge {
700 edge_id: Uuid::new_v4(),
701 source: b.id,
702 target: c.id,
703 relation: EdgeRelation::DependsOn,
704 weight: 0.9,
705 },
706 ExportedEdge {
707 edge_id: Uuid::new_v4(),
708 source: a.id,
709 target: phantom,
710 relation: EdgeRelation::Enables,
711 weight: 0.5,
712 },
713 ],
714 };
715
716 let dst = make_rt().await;
717 let summary = dst.import_kg(&archive, &tok).await.unwrap();
718 assert_eq!(summary.entities_imported, 3);
719 assert_eq!(
720 summary.edges_imported, 2,
721 "only valid edges must be imported"
722 );
723 assert_eq!(
724 summary.edges_skipped, 1,
725 "one dangling edge must be reported"
726 );
727 }
728
729 #[tokio::test]
731 async fn import_all_valid_edges_reports_zero_skipped() {
732 let src = make_rt().await;
733 let tok = NamespaceToken::local();
734 let e1 = src
735 .create_entity(&tok, "concept", None, "E1", None, None, vec![])
736 .await
737 .unwrap();
738 let e2 = src
739 .create_entity(&tok, "concept", None, "E2", None, None, vec![])
740 .await
741 .unwrap();
742 src.link(&tok, e1.id, e2.id, EdgeRelation::VariantOf, 0.7, None)
743 .await
744 .unwrap();
745
746 let archive = src.export_kg(&tok).await.unwrap();
747 let dst = make_rt().await;
748 let summary = dst.import_kg(&archive, &tok).await.unwrap();
749 assert_eq!(summary.edges_imported, 1);
750 assert_eq!(
751 summary.edges_skipped, 0,
752 "no edges should be skipped when all endpoints exist"
753 );
754 }
755
756 #[tokio::test]
760 async fn export_kg_preserves_edge_id() {
761 let rt = make_rt().await;
762 let tok = NamespaceToken::local();
763 let a = rt
764 .create_entity(&tok, "concept", None, "Alpha", None, None, vec![])
765 .await
766 .unwrap();
767 let b = rt
768 .create_entity(&tok, "concept", None, "Beta", None, None, vec![])
769 .await
770 .unwrap();
771 let stored_edge = rt
772 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
773 .await
774 .unwrap();
775 let stored_id: Uuid = stored_edge.id.into();
776
777 let archive = rt.export_kg(&tok).await.unwrap();
778 assert_eq!(archive.edges.len(), 1);
779 assert_eq!(
780 archive.edges[0].edge_id, stored_id,
781 "exported edge_id must equal the LinkId returned by link"
782 );
783 }
784
785 #[tokio::test]
787 async fn import_kg_persists_edge_id() {
788 let src = make_rt().await;
789 let tok = NamespaceToken::local();
790 let a = src
791 .create_entity(&tok, "concept", None, "Alpha", None, None, vec![])
792 .await
793 .unwrap();
794 let b = src
795 .create_entity(&tok, "concept", None, "Beta", None, None, vec![])
796 .await
797 .unwrap();
798 let stored_edge = src
799 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
800 .await
801 .unwrap();
802 let original_id: Uuid = stored_edge.id.into();
803
804 let archive = src.export_kg(&tok).await.unwrap();
805 let dst = make_rt().await;
806 dst.import_kg(&archive, &tok).await.unwrap();
807
808 let imported_edge = dst.get_edge(&tok, original_id).await.unwrap();
809 assert!(
810 imported_edge.is_some(),
811 "imported edge must be retrievable by the original edge_id"
812 );
813 let imported_edge = imported_edge.unwrap();
814 assert_eq!(
815 Uuid::from(imported_edge.id),
816 original_id,
817 "stored edge id must equal the archive edge_id"
818 );
819 }
820
821 #[tokio::test]
826 async fn old_archive_missing_edge_id_round_trips() {
827 let src_id = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap();
828 let tgt_id = Uuid::parse_str("00000000-0000-0000-0000-000000000002").unwrap();
829
830 let json = format!(
832 r#"{{
833 "format": "khive-kg",
834 "version": "0.1",
835 "namespace": "local",
836 "exported_at": "2026-01-01T00:00:00Z",
837 "entities": [
838 {{"id":"{src_id}","kind":"concept","name":"SrcNode","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"}},
839 {{"id":"{tgt_id}","kind":"concept","name":"TgtNode","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"}}
840 ],
841 "edges": [
842 {{
843 "source": "{src_id}",
844 "target": "{tgt_id}",
845 "relation": "extends",
846 "weight": 0.9
847 }}
848 ]
849 }}"#
850 );
851
852 let archive: KgArchive = serde_json::from_str(&json)
854 .expect("old archive without edge_id must deserialize successfully");
855 assert_eq!(archive.edges.len(), 1);
856 let generated_id = archive.edges[0].edge_id;
857 assert_ne!(
858 generated_id,
859 Uuid::nil(),
860 "missing edge_id in old archive must get a fresh non-nil UUID"
861 );
862
863 let rt = make_rt().await;
864 let tok = NamespaceToken::local();
865 let summary = rt.import_kg(&archive, &tok).await.unwrap();
866 assert_eq!(summary.entities_imported, 2);
867 assert_eq!(
868 summary.edges_imported, 1,
869 "edge must be imported when both endpoints exist"
870 );
871
872 let stored = rt.get_edge(&tok, generated_id).await.unwrap();
873 assert!(
874 stored.is_some(),
875 "imported edge must be retrievable by the generated edge_id"
876 );
877 assert_eq!(
878 Uuid::from(stored.unwrap().id),
879 generated_id,
880 "stored edge id must equal the generated edge_id"
881 );
882
883 let re_archive = rt.export_kg(&tok).await.unwrap();
884 assert_eq!(re_archive.edges.len(), 1);
885 assert_eq!(
886 re_archive.edges[0].edge_id, generated_id,
887 "re-exported edge_id must equal the ID generated on first import"
888 );
889 }
890
891 #[tokio::test]
896 async fn export_import_export_edge_id_equality() {
897 let src = make_rt().await;
898 let tok = NamespaceToken::local();
899 let a = src
900 .create_entity(&tok, "concept", None, "NodeA", None, None, vec![])
901 .await
902 .unwrap();
903 let b = src
904 .create_entity(&tok, "concept", None, "NodeB", None, None, vec![])
905 .await
906 .unwrap();
907 let stored = src
908 .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
909 .await
910 .unwrap();
911 let original_edge_id: Uuid = stored.id.into();
912
913 let archive1 = src.export_kg(&tok).await.unwrap();
914 assert_eq!(archive1.edges.len(), 1);
915 assert_eq!(
916 archive1.edges[0].edge_id, original_edge_id,
917 "first export must carry the stored edge_id"
918 );
919
920 let dst = make_rt().await;
921 dst.import_kg(&archive1, &tok).await.unwrap();
922
923 let archive2 = dst.export_kg(&tok).await.unwrap();
924 assert_eq!(archive2.edges.len(), 1);
925
926 let re_edge = archive2
927 .edges
928 .iter()
929 .find(|e| e.source == a.id && e.target == b.id && e.relation == EdgeRelation::Extends)
930 .expect(
931 "re-exported archive must contain the original edge by (source,target,relation)",
932 );
933 assert_eq!(
934 re_edge.edge_id, original_edge_id,
935 "edge_id must be identical across export → import → export"
936 );
937 }
938}