Skip to main content

khive_types/
edge.rs

1//! Edge relation types for the closed ontology defined in ADR-002 / ADR-021.
2
3extern crate alloc;
4use alloc::string::String;
5use core::fmt;
6use core::str::FromStr;
7
8#[cfg(feature = "serde")]
9use serde::{Deserialize, Serialize};
10
11/// The 8 structural categories that group the 15 canonical edge relations.
12///
13/// Exposed via [`EdgeRelation::category`] for query planners and UI rendering.
14#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
15#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
16#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
17pub enum EdgeCategory {
18    /// Composition: `contains`, `part_of`, `instance_of`
19    Structure,
20    /// Intellectual lineage: `extends`, `variant_of`, `introduced_by`, `supersedes`
21    Derivation,
22    /// Data/artifact origin: `derived_from`
23    Provenance,
24    /// Time ordering: `precedes`
25    Temporal,
26    /// Build/runtime needs: `depends_on`, `enables`
27    Dependency,
28    /// Code ↔ concept: `implements`
29    Implementation,
30    /// Peer relationships: `competes_with`, `composed_with`
31    Lateral,
32    /// Cross-substrate annotation: `annotates`
33    Annotation,
34}
35
36/// Closed set of 15 canonical edge relations (ADR-002).
37///
38/// No `Default` — every edge requires an explicit relation.
39/// Wire format: snake_case strings (e.g. `"part_of"`, `"introduced_by"`).
40#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
41#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
42#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
43pub enum EdgeRelation {
44    // Structure
45    Contains,
46    PartOf,
47    InstanceOf,
48    // Derivation
49    Extends,
50    VariantOf,
51    IntroducedBy,
52    Supersedes,
53    // Provenance
54    DerivedFrom,
55    // Temporal
56    Precedes,
57    // Dependency
58    DependsOn,
59    Enables,
60    // Implementation
61    Implements,
62    // Lateral
63    CompetesWith,
64    ComposedWith,
65    // Annotation
66    Annotates,
67}
68
69impl EdgeRelation {
70    /// All 15 canonical relations in ADR-002 table order.
71    pub const ALL: [Self; 15] = [
72        Self::Contains,
73        Self::PartOf,
74        Self::InstanceOf,
75        Self::Extends,
76        Self::VariantOf,
77        Self::IntroducedBy,
78        Self::Supersedes,
79        Self::DerivedFrom,
80        Self::Precedes,
81        Self::DependsOn,
82        Self::Enables,
83        Self::Implements,
84        Self::CompetesWith,
85        Self::ComposedWith,
86        Self::Annotates,
87    ];
88
89    /// Valid snake_case names for all 15 canonical relations.
90    pub const VALID_NAMES: &'static [&'static str] = &[
91        "contains",
92        "part_of",
93        "instance_of",
94        "extends",
95        "variant_of",
96        "introduced_by",
97        "supersedes",
98        "derived_from",
99        "precedes",
100        "depends_on",
101        "enables",
102        "implements",
103        "competes_with",
104        "composed_with",
105        "annotates",
106    ];
107
108    /// `true` for symmetric relations: edge direction has no semantic meaning.
109    pub const fn is_symmetric(&self) -> bool {
110        matches!(self, Self::CompetesWith | Self::ComposedWith)
111    }
112
113    /// The category this relation belongs to.
114    pub const fn category(&self) -> EdgeCategory {
115        match self {
116            Self::Contains | Self::PartOf | Self::InstanceOf => EdgeCategory::Structure,
117            Self::Extends | Self::VariantOf | Self::IntroducedBy | Self::Supersedes => {
118                EdgeCategory::Derivation
119            }
120            Self::DerivedFrom => EdgeCategory::Provenance,
121            Self::Precedes => EdgeCategory::Temporal,
122            Self::DependsOn | Self::Enables => EdgeCategory::Dependency,
123            Self::Implements => EdgeCategory::Implementation,
124            Self::CompetesWith | Self::ComposedWith => EdgeCategory::Lateral,
125            Self::Annotates => EdgeCategory::Annotation,
126        }
127    }
128
129    /// Canonical snake_case name as stored in the database.
130    pub const fn as_str(&self) -> &'static str {
131        match self {
132            Self::Contains => "contains",
133            Self::PartOf => "part_of",
134            Self::InstanceOf => "instance_of",
135            Self::Extends => "extends",
136            Self::VariantOf => "variant_of",
137            Self::IntroducedBy => "introduced_by",
138            Self::Supersedes => "supersedes",
139            Self::DerivedFrom => "derived_from",
140            Self::Precedes => "precedes",
141            Self::DependsOn => "depends_on",
142            Self::Enables => "enables",
143            Self::Implements => "implements",
144            Self::CompetesWith => "competes_with",
145            Self::ComposedWith => "composed_with",
146            Self::Annotates => "annotates",
147        }
148    }
149}
150
151impl fmt::Display for EdgeRelation {
152    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
153        f.write_str(self.as_str())
154    }
155}
156
157impl FromStr for EdgeRelation {
158    type Err = crate::error::UnknownVariant;
159
160    fn from_str(s: &str) -> Result<Self, Self::Err> {
161        let normalised: String = s
162            .chars()
163            .map(|c| {
164                if c == '-' {
165                    '_'
166                } else {
167                    c.to_ascii_lowercase()
168                }
169            })
170            .filter(|c| c.is_ascii_alphanumeric() || *c == '_')
171            .collect();
172
173        match normalised.as_str() {
174            "contains" => Ok(Self::Contains),
175            "part_of" | "partof" => Ok(Self::PartOf),
176            "instance_of" | "instanceof" => Ok(Self::InstanceOf),
177            "extends" => Ok(Self::Extends),
178            "variant_of" | "variantof" => Ok(Self::VariantOf),
179            "introduced_by" | "introducedby" => Ok(Self::IntroducedBy),
180            "supersedes" => Ok(Self::Supersedes),
181            "derived_from" | "derivedfrom" => Ok(Self::DerivedFrom),
182            "precedes" => Ok(Self::Precedes),
183            "depends_on" | "dependson" => Ok(Self::DependsOn),
184            "enables" => Ok(Self::Enables),
185            "implements" => Ok(Self::Implements),
186            "competes_with" | "competeswith" => Ok(Self::CompetesWith),
187            "composed_with" | "composedwith" => Ok(Self::ComposedWith),
188            "annotates" => Ok(Self::Annotates),
189            _ => Err(crate::error::UnknownVariant::new(
190                "edge_relation",
191                s,
192                Self::VALID_NAMES,
193            )),
194        }
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use alloc::string::ToString;
202
203    #[test]
204    fn all_has_fifteen_variants() {
205        assert_eq!(EdgeRelation::ALL.len(), 15);
206    }
207
208    #[test]
209    fn all_eight_categories_covered() {
210        let mut cats = alloc::vec::Vec::new();
211        for r in EdgeRelation::ALL {
212            let c = r.category();
213            if !cats.contains(&c) {
214                cats.push(c);
215            }
216        }
217        assert_eq!(cats.len(), 8, "all 8 categories must be represented");
218    }
219
220    #[test]
221    fn display_roundtrip_for_all() {
222        for relation in EdgeRelation::ALL {
223            let s = relation.to_string();
224            let parsed: EdgeRelation = s.parse().expect("display output should re-parse");
225            assert_eq!(parsed, relation);
226        }
227    }
228
229    #[test]
230    fn from_str_case_insensitive() {
231        assert_eq!(
232            "Extends".parse::<EdgeRelation>().unwrap(),
233            EdgeRelation::Extends
234        );
235        assert_eq!(
236            "extends".parse::<EdgeRelation>().unwrap(),
237            EdgeRelation::Extends
238        );
239        assert_eq!(
240            "EXTENDS".parse::<EdgeRelation>().unwrap(),
241            EdgeRelation::Extends
242        );
243    }
244
245    #[test]
246    fn from_str_hyphen_tolerant() {
247        assert_eq!(
248            "part_of".parse::<EdgeRelation>().unwrap(),
249            EdgeRelation::PartOf
250        );
251        assert_eq!(
252            "part-of".parse::<EdgeRelation>().unwrap(),
253            EdgeRelation::PartOf
254        );
255        assert_eq!(
256            "partof".parse::<EdgeRelation>().unwrap(),
257            EdgeRelation::PartOf
258        );
259
260        assert_eq!(
261            "introduced_by".parse::<EdgeRelation>().unwrap(),
262            EdgeRelation::IntroducedBy
263        );
264        assert_eq!(
265            "introduced-by".parse::<EdgeRelation>().unwrap(),
266            EdgeRelation::IntroducedBy
267        );
268    }
269
270    #[test]
271    fn from_str_unknown_returns_error_with_list() {
272        let err = "related_to".parse::<EdgeRelation>().unwrap_err();
273        let msg = err.to_string();
274        assert!(
275            msg.contains("related_to"),
276            "error should mention the bad input"
277        );
278        assert!(
279            msg.contains("contains"),
280            "error should list valid relations"
281        );
282        assert!(
283            msg.contains("derived_from"),
284            "error should list derived_from"
285        );
286        assert!(msg.contains("precedes"), "error should list precedes");
287        assert!(msg.contains("annotates"), "error should list all 15");
288    }
289
290    #[test]
291    fn category_returns_correct_group() {
292        assert_eq!(EdgeRelation::Contains.category(), EdgeCategory::Structure);
293        assert_eq!(EdgeRelation::PartOf.category(), EdgeCategory::Structure);
294        assert_eq!(EdgeRelation::InstanceOf.category(), EdgeCategory::Structure);
295
296        assert_eq!(EdgeRelation::Extends.category(), EdgeCategory::Derivation);
297        assert_eq!(EdgeRelation::VariantOf.category(), EdgeCategory::Derivation);
298        assert_eq!(
299            EdgeRelation::IntroducedBy.category(),
300            EdgeCategory::Derivation
301        );
302        assert_eq!(
303            EdgeRelation::Supersedes.category(),
304            EdgeCategory::Derivation
305        );
306
307        assert_eq!(EdgeRelation::DependsOn.category(), EdgeCategory::Dependency);
308        assert_eq!(EdgeRelation::Enables.category(), EdgeCategory::Dependency);
309
310        assert_eq!(
311            EdgeRelation::Implements.category(),
312            EdgeCategory::Implementation
313        );
314
315        assert_eq!(
316            EdgeRelation::DerivedFrom.category(),
317            EdgeCategory::Provenance
318        );
319        assert_eq!(EdgeRelation::Precedes.category(), EdgeCategory::Temporal);
320
321        assert_eq!(EdgeRelation::CompetesWith.category(), EdgeCategory::Lateral);
322        assert_eq!(EdgeRelation::ComposedWith.category(), EdgeCategory::Lateral);
323
324        assert_eq!(EdgeRelation::Annotates.category(), EdgeCategory::Annotation);
325    }
326
327    #[test]
328    fn from_str_new_relations() {
329        assert_eq!(
330            "derived_from".parse::<EdgeRelation>().unwrap(),
331            EdgeRelation::DerivedFrom
332        );
333        assert_eq!(
334            "derived-from".parse::<EdgeRelation>().unwrap(),
335            EdgeRelation::DerivedFrom
336        );
337        assert_eq!(
338            "derivedfrom".parse::<EdgeRelation>().unwrap(),
339            EdgeRelation::DerivedFrom
340        );
341        assert_eq!(
342            "precedes".parse::<EdgeRelation>().unwrap(),
343            EdgeRelation::Precedes
344        );
345    }
346
347    #[test]
348    fn is_symmetric_only_for_lateral_peer_relations() {
349        assert!(EdgeRelation::CompetesWith.is_symmetric());
350        assert!(EdgeRelation::ComposedWith.is_symmetric());
351        assert!(!EdgeRelation::DependsOn.is_symmetric());
352        assert!(!EdgeRelation::DerivedFrom.is_symmetric());
353        assert!(!EdgeRelation::Precedes.is_symmetric());
354        assert!(!EdgeRelation::Extends.is_symmetric());
355    }
356
357    #[cfg(feature = "serde")]
358    #[test]
359    fn serde_snake_case_roundtrip() {
360        let rel = EdgeRelation::IntroducedBy;
361        let json = serde_json::to_string(&rel).unwrap();
362        assert_eq!(json, "\"introduced_by\"");
363        let parsed: EdgeRelation = serde_json::from_str(&json).unwrap();
364        assert_eq!(parsed, rel);
365    }
366
367    #[cfg(feature = "serde")]
368    #[test]
369    fn serde_new_relations_roundtrip() {
370        for rel in [EdgeRelation::DerivedFrom, EdgeRelation::Precedes] {
371            let json = serde_json::to_string(&rel).unwrap();
372            let parsed: EdgeRelation = serde_json::from_str(&json).unwrap();
373            assert_eq!(parsed, rel);
374        }
375    }
376}