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