Skip to main content

khive_runtime/
portability.rs

1//! KG export / import — portable JSON archive for namespace-scoped knowledge graphs.
2//!
3//! Embeddings are excluded (regenerable from text + model). Edges are collected by
4//! querying all entity IDs in the namespace first, then fetching incident edges.
5
6use 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// ── Archive types ─────────────────────────────────────────────────────────────
19
20/// Portable JSON archive of a namespace-scoped knowledge graph.
21///
22/// The `format` field is always `"khive-kg"`. The `version` field identifies
23/// the serialization schema; parsers should reject unknown versions.
24#[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/// An entity record in the portable archive.
35#[derive(Clone, Debug, Serialize, Deserialize)]
36pub struct ExportedEntity {
37    pub id: Uuid,
38    /// Pack-owned kind string (e.g. `"concept"`, `"person"`).
39    pub kind: String,
40    /// Pack-governed subtype token (e.g. `"paper"`, `"snapshot"`).
41    #[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/// A directed edge record in the portable archive.
55#[derive(Clone, Debug, Serialize, Deserialize)]
56pub struct ExportedEdge {
57    /// Stable edge identity across export/import cycles.
58    ///
59    /// Old archives (pre-0.2) omit this field. `serde(default)` assigns a fresh
60    /// UUID on import so backward-compatible archives are accepted as-is.
61    #[serde(default = "Uuid::new_v4")]
62    pub edge_id: Uuid,
63    pub source: Uuid,
64    pub target: Uuid,
65    /// One of the canonical edge relations (closed enum).
66    pub relation: EdgeRelation,
67    pub weight: f64,
68}
69
70/// Outcome of a successful import operation.
71#[derive(Clone, Debug, Serialize, Deserialize)]
72pub struct ImportSummary {
73    pub entities_imported: usize,
74    pub edges_imported: usize,
75    /// Number of edges that were skipped because one or both endpoint UUIDs
76    /// were not found in the target namespace after entity import.
77    ///
78    /// A non-zero value indicates the archive contained dangling edges (edges
79    /// referencing entities not present in the archive or the existing graph).
80    pub edges_skipped: usize,
81}
82
83// ── KhiveRuntime impl ─────────────────────────────────────────────────────────
84
85impl KhiveRuntime {
86    /// Export all entities and edges in a namespace to a portable JSON archive.
87    ///
88    /// Edge collection: all entity IDs in the namespace are gathered first;
89    /// `query_edges` is then called with those IDs as `source_ids`. This
90    /// captures every edge whose source entity belongs to the namespace.
91    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    /// Export to a JSON string (convenience wrapper around `export_kg`).
174    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    /// Import an archive into `target_namespace`.
180    ///
181    /// If `target_namespace` is `None`, the archive's own namespace is used.
182    ///
183    /// - Entities: upserted by ID; existing records are overwritten.
184    /// - Edges: upserted; existing records are overwritten.
185    /// - Validation: `format != "khive-kg"` or unsupported version → `InvalidInput`.
186    ///   Invalid edge relations are caught at JSON deserialization time.
187    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                content_ref: None,
228            };
229            store.upsert_entity(entity.clone()).await?;
230            // Reindex so imported entities are searchable via hybrid_search immediately.
231            self.reindex_entity(token, &entity).await?;
232            entities_imported += 1;
233        }
234
235        // Untrusted archives may reference entities absent from the target namespace;
236        // check both endpoints and skip edges with a missing source or target to avoid
237        // dangling references in the graph store.
238        let graph = self.graph(token)?;
239        let mut edges_imported = 0usize;
240        let mut edges_skipped = 0usize;
241        for ee in &archive.edges {
242            crate::operations::validate_edge_weight(ee.weight)?;
243            let source_ok = match self.get_entity(token, ee.source).await {
244                Ok(_) => true,
245                Err(RuntimeError::NotFound(_)) => false,
246                Err(e) => return Err(e),
247            };
248            if !source_ok {
249                tracing::warn!(
250                    source = %ee.source,
251                    target = %ee.target,
252                    relation = ?ee.relation,
253                    "import_kg: skipping edge — source entity not found in namespace {ns:?}"
254                );
255                edges_skipped += 1;
256                continue;
257            }
258            let target_ok = match self.get_entity(token, ee.target).await {
259                Ok(_) => true,
260                Err(RuntimeError::NotFound(_)) => false,
261                Err(e) => return Err(e),
262            };
263            if !target_ok {
264                tracing::warn!(
265                    source = %ee.source,
266                    target = %ee.target,
267                    relation = ?ee.relation,
268                    "import_kg: skipping edge — target entity not found in namespace {ns:?}"
269                );
270                edges_skipped += 1;
271                continue;
272            }
273            let now = Utc::now();
274            let edge = khive_storage::types::Edge {
275                id: LinkId::from(ee.edge_id),
276                namespace: ns.clone(),
277                source_id: ee.source,
278                target_id: ee.target,
279                relation: ee.relation,
280                weight: ee.weight,
281                created_at: now,
282                updated_at: now,
283                deleted_at: None,
284                metadata: None,
285                target_backend: None,
286            };
287            graph.upsert_edge(edge).await?;
288            edges_imported += 1;
289        }
290
291        Ok(ImportSummary {
292            entities_imported,
293            edges_imported,
294            edges_skipped,
295        })
296    }
297
298    /// Import from a JSON string (convenience wrapper around `import_kg`).
299    pub async fn import_kg_json(
300        &self,
301        json: &str,
302        token: &NamespaceToken,
303    ) -> RuntimeResult<ImportSummary> {
304        let archive: KgArchive =
305            serde_json::from_str(json).map_err(|e| RuntimeError::InvalidInput(e.to_string()))?;
306        self.import_kg(&archive, token).await
307    }
308}
309
310// ── Tests ─────────────────────────────────────────────────────────────────────
311
312// Kept inline: these tests exercise round-trip invariants over private encoding
313// helpers that would otherwise need to be made pub to test from tests/.
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use crate::runtime::{KhiveRuntime, NamespaceToken};
318    use crate::Namespace;
319    use khive_storage::EdgeRelation;
320
321    async fn make_rt() -> KhiveRuntime {
322        KhiveRuntime::memory().expect("in-memory runtime")
323    }
324
325    /// 1. Roundtrip: 3 entities + 2 edges survive export → import on a fresh runtime.
326    #[tokio::test]
327    async fn roundtrip_entities_and_edges() {
328        let src = make_rt().await;
329        let tok = NamespaceToken::local();
330        let e1 = src
331            .create_entity(
332                &tok,
333                "concept",
334                None,
335                "FlashAttention",
336                Some("fast attention"),
337                None,
338                vec![],
339            )
340            .await
341            .unwrap();
342        let e2 = src
343            .create_entity(
344                &tok,
345                "concept",
346                None,
347                "FlashAttention-2",
348                None,
349                None,
350                vec![],
351            )
352            .await
353            .unwrap();
354        let e3 = src
355            .create_entity(
356                &tok,
357                "person",
358                None,
359                "Tri Dao",
360                None,
361                None,
362                vec!["author".into()],
363            )
364            .await
365            .unwrap();
366        src.link(&tok, e2.id, e1.id, EdgeRelation::Extends, 1.0, None)
367            .await
368            .unwrap();
369        src.link(&tok, e1.id, e3.id, EdgeRelation::IntroducedBy, 0.9, None)
370            .await
371            .unwrap();
372
373        let archive = src.export_kg(&tok).await.unwrap();
374        assert_eq!(archive.entities.len(), 3);
375        assert_eq!(archive.edges.len(), 2);
376        assert_eq!(archive.format, "khive-kg");
377        assert_eq!(archive.version, "0.1");
378
379        let dst = make_rt().await;
380        let summary = dst.import_kg(&archive, &tok).await.unwrap();
381        assert_eq!(summary.entities_imported, 3);
382        assert_eq!(summary.edges_imported, 2);
383
384        let got = dst.get_entity(&tok, e1.id).await.unwrap();
385        assert_eq!(got.name, "FlashAttention");
386        assert_eq!(got.description.as_deref(), Some("fast attention"));
387    }
388
389    /// 2. JSON roundtrip: export_kg_json → import_kg_json produces equivalent state.
390    #[tokio::test]
391    async fn json_roundtrip() {
392        let src = make_rt().await;
393        let tok = NamespaceToken::local();
394        let e1 = src
395            .create_entity(
396                &tok,
397                "concept",
398                None,
399                "LoRA",
400                Some("low-rank adaptation"),
401                Some(serde_json::json!({"year": "2021"})),
402                vec!["fine-tuning".into()],
403            )
404            .await
405            .unwrap();
406        let e2 = src
407            .create_entity(&tok, "concept", None, "QLoRA", None, None, vec![])
408            .await
409            .unwrap();
410        src.link(&tok, e2.id, e1.id, EdgeRelation::VariantOf, 0.9, None)
411            .await
412            .unwrap();
413
414        let json_str = src.export_kg_json(&tok).await.unwrap();
415        assert!(json_str.contains("khive-kg"));
416
417        let dst = make_rt().await;
418        let summary = dst.import_kg_json(&json_str, &tok).await.unwrap();
419        assert_eq!(summary.entities_imported, 2);
420        assert_eq!(summary.edges_imported, 1);
421
422        let got = dst.get_entity(&tok, e1.id).await.unwrap();
423        assert_eq!(got.tags, vec!["fine-tuning"]);
424    }
425
426    /// 3. Namespace targeting: export from namespace "a", import into namespace "b" on a
427    ///    fresh runtime — entities land in "b", and the source runtime's "a" is unaffected.
428    ///
429    ///    Note: source and destination are separate runtimes (separate in-memory DBs).
430    ///    Same-DB cross-namespace copy is not a portability use case — portability is about
431    ///    moving graphs between instances, not between namespaces within one instance.
432    #[tokio::test]
433    async fn namespace_targeting() {
434        let src = make_rt().await;
435        let tok_a = NamespaceToken::for_namespace(Namespace::parse("a").unwrap());
436        let tok_b = NamespaceToken::for_namespace(Namespace::parse("b").unwrap());
437        src.create_entity(&tok_a, "concept", None, "Sinkhorn", None, None, vec![])
438            .await
439            .unwrap();
440
441        let archive = src.export_kg(&tok_a).await.unwrap();
442        assert_eq!(archive.namespace, "a");
443
444        let dst = make_rt().await;
445        let summary = dst.import_kg(&archive, &tok_b).await.unwrap();
446        assert_eq!(summary.entities_imported, 1);
447
448        let in_b = dst.list_entities(&tok_b, None, None, 100, 0).await.unwrap();
449        assert_eq!(in_b.len(), 1);
450        assert_eq!(in_b[0].name, "Sinkhorn");
451
452        let in_a = src.list_entities(&tok_a, None, None, 100, 0).await.unwrap();
453        assert_eq!(in_a.len(), 1);
454
455        let dst_a = dst.list_entities(&tok_a, None, None, 100, 0).await.unwrap();
456        assert_eq!(dst_a.len(), 0);
457    }
458
459    /// 4. Format validation: wrong `format` field → InvalidInput.
460    #[tokio::test]
461    async fn format_validation_rejects_wrong_format() {
462        let rt = make_rt().await;
463        let tok = NamespaceToken::local();
464        let bad = KgArchive {
465            format: "wrong".to_string(),
466            version: "0.1".to_string(),
467            namespace: "local".to_string(),
468            exported_at: Utc::now(),
469            entities: vec![],
470            edges: vec![],
471        };
472        let err = rt.import_kg(&bad, &tok).await.unwrap_err();
473        assert!(matches!(err, RuntimeError::InvalidInput(_)));
474    }
475
476    /// 5. Unsupported archive version → InvalidInput.
477    #[tokio::test]
478    async fn import_unsupported_archive_version_returns_error() {
479        let rt = make_rt().await;
480        let tok = NamespaceToken::local();
481        let bad = KgArchive {
482            format: "khive-kg".to_string(),
483            version: "999.0".to_string(),
484            namespace: "local".to_string(),
485            exported_at: Utc::now(),
486            entities: vec![],
487            edges: vec![],
488        };
489        let err = rt.import_kg(&bad, &tok).await.unwrap_err();
490        assert!(
491            matches!(err, RuntimeError::InvalidInput(_)),
492            "expected InvalidInput, got {err:?}"
493        );
494        if let RuntimeError::InvalidInput(msg) = err {
495            assert!(
496                msg.contains("999.0"),
497                "error message should mention the unsupported version, got: {msg:?}"
498            );
499        }
500    }
501
502    /// 6. Invalid relation in archive → InvalidInput.
503    #[test]
504    fn invalid_relation_rejected_at_deserialize() {
505        let json = r#"{
506            "format":"khive-kg","version":"0.1","namespace":"local",
507            "exported_at":"2026-01-01T00:00:00Z",
508            "entities":[],
509            "edges":[{"edge_id":"00000000-0000-0000-0000-000000000099",
510                       "source":"00000000-0000-0000-0000-000000000001",
511                       "target":"00000000-0000-0000-0000-000000000002",
512                       "relation":"related_to","weight":0.5}]
513        }"#;
514        let result: Result<KgArchive, _> = serde_json::from_str(json);
515        assert!(
516            result.is_err(),
517            "non-canonical relation should fail to deserialize"
518        );
519    }
520
521    // ── Dangling-edge validation tests ────────────────────────────────────────
522
523    /// 6. Edge with dangling source (source UUID not in entity table) is skipped.
524    ///
525    /// The archive has one entity + one edge whose source is a phantom UUID.
526    /// Import succeeds, entities_imported=1, edges_imported=0, edges_skipped=1.
527    #[tokio::test]
528    async fn import_edge_with_dangling_source_is_skipped() {
529        let phantom_source = Uuid::parse_str("deadbeef-dead-4ead-dead-deadbeefcafe").unwrap();
530
531        let rt = make_rt().await;
532        let tok = NamespaceToken::local();
533        let real = rt
534            .create_entity(&tok, "concept", None, "Real", None, None, vec![])
535            .await
536            .unwrap();
537
538        let archive = KgArchive {
539            format: "khive-kg".to_string(),
540            version: "0.1".to_string(),
541            namespace: "local".to_string(),
542            exported_at: Utc::now(),
543            entities: vec![ExportedEntity {
544                id: real.id,
545                kind: "concept".to_string(),
546                entity_type: None,
547                name: "Real".to_string(),
548                description: None,
549                properties: None,
550                tags: vec![],
551                created_at: Utc::now(),
552                updated_at: Utc::now(),
553            }],
554            edges: vec![ExportedEdge {
555                edge_id: Uuid::new_v4(),
556                source: phantom_source,
557                target: real.id,
558                relation: EdgeRelation::Extends,
559                weight: 1.0,
560            }],
561        };
562
563        let dst = make_rt().await;
564        let summary = dst.import_kg(&archive, &tok).await.unwrap();
565        assert_eq!(summary.entities_imported, 1);
566        assert_eq!(
567            summary.edges_imported, 0,
568            "dangling source must not be imported"
569        );
570        assert_eq!(
571            summary.edges_skipped, 1,
572            "dangling source must be counted as skipped"
573        );
574    }
575
576    /// 7. Edge with dangling target (target UUID not in entity table) is skipped.
577    ///
578    /// The archive has one entity + one edge whose target is a phantom UUID.
579    /// Import succeeds, entities_imported=1, edges_imported=0, edges_skipped=1.
580    #[tokio::test]
581    async fn import_edge_with_dangling_target_is_skipped() {
582        let phantom_target = Uuid::parse_str("cafebabe-cafe-4abe-cafe-cafebabecafe").unwrap();
583
584        let rt = make_rt().await;
585        let tok = NamespaceToken::local();
586        let real = rt
587            .create_entity(&tok, "concept", None, "Source", None, None, vec![])
588            .await
589            .unwrap();
590
591        let archive = KgArchive {
592            format: "khive-kg".to_string(),
593            version: "0.1".to_string(),
594            namespace: "local".to_string(),
595            exported_at: Utc::now(),
596            entities: vec![ExportedEntity {
597                id: real.id,
598                kind: "concept".to_string(),
599                entity_type: None,
600                name: "Source".to_string(),
601                description: None,
602                properties: None,
603                tags: vec![],
604                created_at: Utc::now(),
605                updated_at: Utc::now(),
606            }],
607            edges: vec![ExportedEdge {
608                edge_id: Uuid::new_v4(),
609                source: real.id,
610                target: phantom_target,
611                relation: EdgeRelation::DependsOn,
612                weight: 0.8,
613            }],
614        };
615
616        let dst = make_rt().await;
617        let summary = dst.import_kg(&archive, &tok).await.unwrap();
618        assert_eq!(summary.entities_imported, 1);
619        assert_eq!(
620            summary.edges_imported, 0,
621            "dangling target must not be imported"
622        );
623        assert_eq!(
624            summary.edges_skipped, 1,
625            "dangling target must be counted as skipped"
626        );
627    }
628
629    /// 8. Mixed batch: some valid edges and some dangling edges — correct counts reported.
630    ///
631    /// Archive has 3 entities, 2 valid edges, and 1 dangling edge (phantom target).
632    /// Import succeeds with edges_imported=2, edges_skipped=1.
633    #[tokio::test]
634    async fn import_mixed_edges_reports_correct_counts() {
635        let phantom = Uuid::parse_str("11111111-1111-4111-8111-111111111111").unwrap();
636
637        let src = make_rt().await;
638        let tok = NamespaceToken::local();
639        let a = src
640            .create_entity(&tok, "concept", None, "A", None, None, vec![])
641            .await
642            .unwrap();
643        let b = src
644            .create_entity(&tok, "concept", None, "B", None, None, vec![])
645            .await
646            .unwrap();
647        let c = src
648            .create_entity(&tok, "concept", None, "C", None, None, vec![])
649            .await
650            .unwrap();
651
652        let archive = KgArchive {
653            format: "khive-kg".to_string(),
654            version: "0.1".to_string(),
655            namespace: "local".to_string(),
656            exported_at: Utc::now(),
657            entities: vec![
658                ExportedEntity {
659                    id: a.id,
660                    kind: "concept".to_string(),
661                    entity_type: None,
662                    name: "A".to_string(),
663                    description: None,
664                    properties: None,
665                    tags: vec![],
666                    created_at: Utc::now(),
667                    updated_at: Utc::now(),
668                },
669                ExportedEntity {
670                    id: b.id,
671                    kind: "concept".to_string(),
672                    entity_type: None,
673                    name: "B".to_string(),
674                    description: None,
675                    properties: None,
676                    tags: vec![],
677                    created_at: Utc::now(),
678                    updated_at: Utc::now(),
679                },
680                ExportedEntity {
681                    id: c.id,
682                    kind: "concept".to_string(),
683                    entity_type: None,
684                    name: "C".to_string(),
685                    description: None,
686                    properties: None,
687                    tags: vec![],
688                    created_at: Utc::now(),
689                    updated_at: Utc::now(),
690                },
691            ],
692            edges: vec![
693                ExportedEdge {
694                    edge_id: Uuid::new_v4(),
695                    source: a.id,
696                    target: b.id,
697                    relation: EdgeRelation::Extends,
698                    weight: 1.0,
699                },
700                ExportedEdge {
701                    edge_id: Uuid::new_v4(),
702                    source: b.id,
703                    target: c.id,
704                    relation: EdgeRelation::DependsOn,
705                    weight: 0.9,
706                },
707                ExportedEdge {
708                    edge_id: Uuid::new_v4(),
709                    source: a.id,
710                    target: phantom,
711                    relation: EdgeRelation::Enables,
712                    weight: 0.5,
713                },
714            ],
715        };
716
717        let dst = make_rt().await;
718        let summary = dst.import_kg(&archive, &tok).await.unwrap();
719        assert_eq!(summary.entities_imported, 3);
720        assert_eq!(
721            summary.edges_imported, 2,
722            "only valid edges must be imported"
723        );
724        assert_eq!(
725            summary.edges_skipped, 1,
726            "one dangling edge must be reported"
727        );
728    }
729
730    /// 9. All-valid edges produce edges_skipped=0 (no regression on the happy path).
731    #[tokio::test]
732    async fn import_all_valid_edges_reports_zero_skipped() {
733        let src = make_rt().await;
734        let tok = NamespaceToken::local();
735        let e1 = src
736            .create_entity(&tok, "concept", None, "E1", None, None, vec![])
737            .await
738            .unwrap();
739        let e2 = src
740            .create_entity(&tok, "concept", None, "E2", None, None, vec![])
741            .await
742            .unwrap();
743        src.link(&tok, e1.id, e2.id, EdgeRelation::VariantOf, 0.7, None)
744            .await
745            .unwrap();
746
747        let archive = src.export_kg(&tok).await.unwrap();
748        let dst = make_rt().await;
749        let summary = dst.import_kg(&archive, &tok).await.unwrap();
750        assert_eq!(summary.edges_imported, 1);
751        assert_eq!(
752            summary.edges_skipped, 0,
753            "no edges should be skipped when all endpoints exist"
754        );
755    }
756
757    // ── edge_id contract tests ────────────────────────────────────────────────
758
759    /// 10. export_kg sets edge_id in the archive to the LinkId returned by link.
760    #[tokio::test]
761    async fn export_kg_preserves_edge_id() {
762        let rt = make_rt().await;
763        let tok = NamespaceToken::local();
764        let a = rt
765            .create_entity(&tok, "concept", None, "Alpha", None, None, vec![])
766            .await
767            .unwrap();
768        let b = rt
769            .create_entity(&tok, "concept", None, "Beta", None, None, vec![])
770            .await
771            .unwrap();
772        let stored_edge = rt
773            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
774            .await
775            .unwrap();
776        let stored_id: Uuid = stored_edge.id.into();
777
778        let archive = rt.export_kg(&tok).await.unwrap();
779        assert_eq!(archive.edges.len(), 1);
780        assert_eq!(
781            archive.edges[0].edge_id, stored_id,
782            "exported edge_id must equal the LinkId returned by link"
783        );
784    }
785
786    /// 11. import_kg writes the archive edge_id as the stored LinkId.
787    #[tokio::test]
788    async fn import_kg_persists_edge_id() {
789        let src = make_rt().await;
790        let tok = NamespaceToken::local();
791        let a = src
792            .create_entity(&tok, "concept", None, "Alpha", None, None, vec![])
793            .await
794            .unwrap();
795        let b = src
796            .create_entity(&tok, "concept", None, "Beta", None, None, vec![])
797            .await
798            .unwrap();
799        let stored_edge = src
800            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
801            .await
802            .unwrap();
803        let original_id: Uuid = stored_edge.id.into();
804
805        let archive = src.export_kg(&tok).await.unwrap();
806        let dst = make_rt().await;
807        dst.import_kg(&archive, &tok).await.unwrap();
808
809        let imported_edge = dst.get_edge(&tok, original_id).await.unwrap();
810        assert!(
811            imported_edge.is_some(),
812            "imported edge must be retrievable by the original edge_id"
813        );
814        let imported_edge = imported_edge.unwrap();
815        assert_eq!(
816            Uuid::from(imported_edge.id),
817            original_id,
818            "stored edge id must equal the archive edge_id"
819        );
820    }
821
822    /// 12. Old archive (no edge_id field) deserializes, imports, and re-exports with the
823    ///     same generated UUID — proving the generated ID survives the full round trip.
824    ///
825    ///     The fixture includes two entities so the edge is not skipped during import.
826    #[tokio::test]
827    async fn old_archive_missing_edge_id_round_trips() {
828        let src_id = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap();
829        let tgt_id = Uuid::parse_str("00000000-0000-0000-0000-000000000002").unwrap();
830
831        // Simulate a pre-0.2 archive JSON where the edge lacks an edge_id field.
832        let json = format!(
833            r#"{{
834                "format": "khive-kg",
835                "version": "0.1",
836                "namespace": "local",
837                "exported_at": "2026-01-01T00:00:00Z",
838                "entities": [
839                    {{"id":"{src_id}","kind":"concept","name":"SrcNode","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"}},
840                    {{"id":"{tgt_id}","kind":"concept","name":"TgtNode","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"}}
841                ],
842                "edges": [
843                    {{
844                        "source": "{src_id}",
845                        "target": "{tgt_id}",
846                        "relation": "extends",
847                        "weight": 0.9
848                    }}
849                ]
850            }}"#
851        );
852
853        // serde(default) must assign a fresh non-nil UUID when edge_id is absent.
854        let archive: KgArchive = serde_json::from_str(&json)
855            .expect("old archive without edge_id must deserialize successfully");
856        assert_eq!(archive.edges.len(), 1);
857        let generated_id = archive.edges[0].edge_id;
858        assert_ne!(
859            generated_id,
860            Uuid::nil(),
861            "missing edge_id in old archive must get a fresh non-nil UUID"
862        );
863
864        let rt = make_rt().await;
865        let tok = NamespaceToken::local();
866        let summary = rt.import_kg(&archive, &tok).await.unwrap();
867        assert_eq!(summary.entities_imported, 2);
868        assert_eq!(
869            summary.edges_imported, 1,
870            "edge must be imported when both endpoints exist"
871        );
872
873        let stored = rt.get_edge(&tok, generated_id).await.unwrap();
874        assert!(
875            stored.is_some(),
876            "imported edge must be retrievable by the generated edge_id"
877        );
878        assert_eq!(
879            Uuid::from(stored.unwrap().id),
880            generated_id,
881            "stored edge id must equal the generated edge_id"
882        );
883
884        let re_archive = rt.export_kg(&tok).await.unwrap();
885        assert_eq!(re_archive.edges.len(), 1);
886        assert_eq!(
887            re_archive.edges[0].edge_id, generated_id,
888            "re-exported edge_id must equal the ID generated on first import"
889        );
890    }
891
892    /// 13. Explicit export → import → export equality: the edge_id is unchanged across
893    ///     a full round trip when the source archive already contains an edge_id.
894    ///
895    ///     Verifies by (source, target, relation) key that re-export emits the original ID.
896    #[tokio::test]
897    async fn export_import_export_edge_id_equality() {
898        let src = make_rt().await;
899        let tok = NamespaceToken::local();
900        let a = src
901            .create_entity(&tok, "concept", None, "NodeA", None, None, vec![])
902            .await
903            .unwrap();
904        let b = src
905            .create_entity(&tok, "concept", None, "NodeB", None, None, vec![])
906            .await
907            .unwrap();
908        let stored = src
909            .link(&tok, a.id, b.id, EdgeRelation::Extends, 1.0, None)
910            .await
911            .unwrap();
912        let original_edge_id: Uuid = stored.id.into();
913
914        let archive1 = src.export_kg(&tok).await.unwrap();
915        assert_eq!(archive1.edges.len(), 1);
916        assert_eq!(
917            archive1.edges[0].edge_id, original_edge_id,
918            "first export must carry the stored edge_id"
919        );
920
921        let dst = make_rt().await;
922        dst.import_kg(&archive1, &tok).await.unwrap();
923
924        let archive2 = dst.export_kg(&tok).await.unwrap();
925        assert_eq!(archive2.edges.len(), 1);
926
927        let re_edge = archive2
928            .edges
929            .iter()
930            .find(|e| e.source == a.id && e.target == b.id && e.relation == EdgeRelation::Extends)
931            .expect(
932                "re-exported archive must contain the original edge by (source,target,relation)",
933            );
934        assert_eq!(
935            re_edge.edge_id, original_edge_id,
936            "edge_id must be identical across export → import → export"
937        );
938    }
939}