Skip to main content

khive_vcs_adapters/
record.rs

1// Copyright 2026 Haiyang Li. Licensed under Apache-2.0.
2//
3//! Wire record shapes produced by format adapters for the KG import pipeline.
4
5use serde::{Deserialize, Serialize};
6use uuid::Uuid;
7
8/// Entity record shape produced by format adapters.
9///
10/// Adapters produce these; the standard `khive kg import` pipeline validates
11/// and loads them into `working.db`.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct EntityRecord {
14    pub id: Uuid,
15    pub kind: String,
16    pub name: String,
17    #[serde(skip_serializing_if = "Option::is_none")]
18    pub description: Option<String>,
19    #[serde(default)]
20    pub properties: serde_json::Value,
21    #[serde(default)]
22    pub tags: Vec<String>,
23}
24
25/// Raw deserialization target for [`EdgeRecord`].
26#[derive(Deserialize)]
27struct EdgeRecordRaw {
28    edge_id: Uuid,
29    source: String,
30    target: String,
31    relation: String,
32    #[serde(default = "default_weight")]
33    weight: f64,
34    #[serde(default)]
35    properties: serde_json::Value,
36}
37
38impl TryFrom<EdgeRecordRaw> for EdgeRecord {
39    type Error = String;
40
41    fn try_from(raw: EdgeRecordRaw) -> Result<Self, Self::Error> {
42        if !raw.weight.is_finite() {
43            return Err(format!(
44                "EdgeRecord: weight must be finite, got {}",
45                raw.weight
46            ));
47        }
48        if !(0.0..=1.0).contains(&raw.weight) {
49            return Err(format!(
50                "EdgeRecord: weight must be in [0.0, 1.0], got {}",
51                raw.weight
52            ));
53        }
54        Ok(Self {
55            edge_id: raw.edge_id,
56            source: raw.source,
57            target: raw.target,
58            relation: raw.relation,
59            weight: raw.weight,
60            properties: raw.properties,
61        })
62    }
63}
64
65/// Edge record shape produced by format adapters. Deserialization rejects non-finite weights.
66#[derive(Debug, Clone, Serialize, Deserialize)]
67#[serde(try_from = "EdgeRecordRaw")]
68pub struct EdgeRecord {
69    pub edge_id: Uuid,
70    pub source: String,
71    pub target: String,
72    pub relation: String,
73    #[serde(default = "default_weight")]
74    pub weight: f64,
75    #[serde(default)]
76    pub properties: serde_json::Value,
77}
78
79fn default_weight() -> f64 {
80    0.7
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86    use uuid::Uuid;
87
88    fn raw_with_weight(w: f64) -> EdgeRecordRaw {
89        EdgeRecordRaw {
90            edge_id: Uuid::nil(),
91            source: "aa".into(),
92            target: "bb".into(),
93            relation: "extends".into(),
94            weight: w,
95            properties: serde_json::Value::Null,
96        }
97    }
98
99    #[test]
100    fn edge_record_try_from_rejects_nan_weight() {
101        assert!(
102            EdgeRecord::try_from(raw_with_weight(f64::NAN)).is_err(),
103            "NaN weight must be rejected"
104        );
105    }
106
107    #[test]
108    fn edge_record_try_from_rejects_inf_weight() {
109        assert!(
110            EdgeRecord::try_from(raw_with_weight(f64::INFINITY)).is_err(),
111            "Inf weight must be rejected"
112        );
113    }
114
115    #[test]
116    fn edge_record_try_from_rejects_weight_below_range() {
117        assert!(
118            EdgeRecord::try_from(raw_with_weight(-0.1)).is_err(),
119            "weight -0.1 must be rejected (below [0.0, 1.0])"
120        );
121    }
122
123    #[test]
124    fn edge_record_try_from_rejects_weight_above_range() {
125        assert!(
126            EdgeRecord::try_from(raw_with_weight(1.1)).is_err(),
127            "weight 1.1 must be rejected (above [0.0, 1.0])"
128        );
129    }
130
131    #[test]
132    fn edge_record_serde_rejects_weight_below_range() {
133        let json = r#"{"edge_id":"00000000-0000-0000-0000-000000000000","source":"a","target":"b","relation":"extends","weight":-0.1}"#;
134        let result: Result<EdgeRecord, _> = serde_json::from_str(json);
135        assert!(
136            result.is_err(),
137            "serde must reject weight < 0.0 at the JSON boundary"
138        );
139    }
140
141    #[test]
142    fn edge_record_serde_rejects_weight_above_range() {
143        let json = r#"{"edge_id":"00000000-0000-0000-0000-000000000000","source":"a","target":"b","relation":"extends","weight":2.0}"#;
144        let result: Result<EdgeRecord, _> = serde_json::from_str(json);
145        assert!(
146            result.is_err(),
147            "serde must reject weight > 1.0 at the JSON boundary"
148        );
149    }
150
151    #[test]
152    fn edge_record_try_from_accepts_finite_weight() {
153        assert!(
154            EdgeRecord::try_from(raw_with_weight(0.7)).is_ok(),
155            "finite weight 0.7 must be accepted"
156        );
157    }
158
159    #[test]
160    fn edge_record_try_from_accepts_boundary_weights() {
161        assert!(
162            EdgeRecord::try_from(raw_with_weight(0.0)).is_ok(),
163            "weight 0.0 must be accepted (lower bound)"
164        );
165        assert!(
166            EdgeRecord::try_from(raw_with_weight(1.0)).is_ok(),
167            "weight 1.0 must be accepted (upper bound)"
168        );
169    }
170
171    #[test]
172    fn edge_record_serde_roundtrip_valid() {
173        let id = Uuid::new_v4();
174        let record = EdgeRecord {
175            edge_id: id,
176            source: "aa".into(),
177            target: "bb".into(),
178            relation: "extends".into(),
179            weight: 0.7,
180            properties: serde_json::Value::Null,
181        };
182        let json = serde_json::to_string(&record).unwrap();
183        let restored: EdgeRecord = serde_json::from_str(&json).unwrap();
184        assert_eq!(restored.edge_id, id);
185        assert!((restored.weight - 0.7).abs() < 1e-12);
186    }
187}