Skip to main content

khive_types/
edge.rs

1//! Edge relation types for the closed ontology used throughout khive.
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.
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 ontology-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    /// Parse a string into an `EdgeRelation`.
161    ///
162    /// Accepts the 15 canonical relation names (case-insensitive, with hyphens
163    /// normalised to underscores) and also squashed forms that omit the separator
164    /// (e.g. `"partof"`, `"derivedfrom"`).  The squashed forms exist for ergonomic
165    /// DSL entry; they are **not** stored on the wire, which always uses the
166    /// canonical snake_case form produced by [`EdgeRelation::as_str`].
167    fn from_str(s: &str) -> Result<Self, Self::Err> {
168        let normalised: String = s
169            .chars()
170            .map(|c| {
171                if c == '-' {
172                    '_'
173                } else {
174                    c.to_ascii_lowercase()
175                }
176            })
177            .filter(|c| c.is_ascii_alphanumeric() || *c == '_')
178            .collect();
179
180        match normalised.as_str() {
181            "contains" => Ok(Self::Contains),
182            "part_of" | "partof" => Ok(Self::PartOf),
183            "instance_of" | "instanceof" => Ok(Self::InstanceOf),
184            "extends" => Ok(Self::Extends),
185            "variant_of" | "variantof" => Ok(Self::VariantOf),
186            "introduced_by" | "introducedby" => Ok(Self::IntroducedBy),
187            "supersedes" => Ok(Self::Supersedes),
188            "derived_from" | "derivedfrom" => Ok(Self::DerivedFrom),
189            "precedes" => Ok(Self::Precedes),
190            "depends_on" | "dependson" => Ok(Self::DependsOn),
191            "enables" => Ok(Self::Enables),
192            "implements" => Ok(Self::Implements),
193            "competes_with" | "competeswith" => Ok(Self::CompetesWith),
194            "composed_with" | "composedwith" => Ok(Self::ComposedWith),
195            "annotates" => Ok(Self::Annotates),
196            _ => Err(crate::error::UnknownVariant::new(
197                "edge_relation",
198                s,
199                Self::VALID_NAMES,
200            )),
201        }
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use alloc::string::ToString;
209
210    #[test]
211    fn all_has_fifteen_variants() {
212        assert_eq!(EdgeRelation::ALL.len(), 15);
213    }
214
215    #[test]
216    fn all_eight_categories_covered() {
217        let mut cats = alloc::vec::Vec::new();
218        for r in EdgeRelation::ALL {
219            let c = r.category();
220            if !cats.contains(&c) {
221                cats.push(c);
222            }
223        }
224        assert_eq!(cats.len(), 8, "all 8 categories must be represented");
225    }
226
227    #[test]
228    fn display_roundtrip_for_all() {
229        for relation in EdgeRelation::ALL {
230            let s = relation.to_string();
231            let parsed: EdgeRelation = s.parse().expect("display output should re-parse");
232            assert_eq!(parsed, relation);
233        }
234    }
235
236    #[test]
237    fn from_str_case_insensitive() {
238        assert_eq!(
239            "Extends".parse::<EdgeRelation>().unwrap(),
240            EdgeRelation::Extends
241        );
242        assert_eq!(
243            "extends".parse::<EdgeRelation>().unwrap(),
244            EdgeRelation::Extends
245        );
246        assert_eq!(
247            "EXTENDS".parse::<EdgeRelation>().unwrap(),
248            EdgeRelation::Extends
249        );
250    }
251
252    #[test]
253    fn from_str_hyphen_tolerant() {
254        assert_eq!(
255            "part_of".parse::<EdgeRelation>().unwrap(),
256            EdgeRelation::PartOf
257        );
258        assert_eq!(
259            "part-of".parse::<EdgeRelation>().unwrap(),
260            EdgeRelation::PartOf
261        );
262        assert_eq!(
263            "partof".parse::<EdgeRelation>().unwrap(),
264            EdgeRelation::PartOf
265        );
266
267        assert_eq!(
268            "introduced_by".parse::<EdgeRelation>().unwrap(),
269            EdgeRelation::IntroducedBy
270        );
271        assert_eq!(
272            "introduced-by".parse::<EdgeRelation>().unwrap(),
273            EdgeRelation::IntroducedBy
274        );
275    }
276
277    #[test]
278    fn from_str_unknown_returns_error_with_list() {
279        let err = "related_to".parse::<EdgeRelation>().unwrap_err();
280        let msg = err.to_string();
281        assert!(
282            msg.contains("related_to"),
283            "error should mention the bad input"
284        );
285        assert!(
286            msg.contains("contains"),
287            "error should list valid relations"
288        );
289        assert!(
290            msg.contains("derived_from"),
291            "error should list derived_from"
292        );
293        assert!(msg.contains("precedes"), "error should list precedes");
294        assert!(msg.contains("annotates"), "error should list all 15");
295    }
296
297    #[test]
298    fn category_returns_correct_group() {
299        assert_eq!(EdgeRelation::Contains.category(), EdgeCategory::Structure);
300        assert_eq!(EdgeRelation::PartOf.category(), EdgeCategory::Structure);
301        assert_eq!(EdgeRelation::InstanceOf.category(), EdgeCategory::Structure);
302
303        assert_eq!(EdgeRelation::Extends.category(), EdgeCategory::Derivation);
304        assert_eq!(EdgeRelation::VariantOf.category(), EdgeCategory::Derivation);
305        assert_eq!(
306            EdgeRelation::IntroducedBy.category(),
307            EdgeCategory::Derivation
308        );
309        assert_eq!(
310            EdgeRelation::Supersedes.category(),
311            EdgeCategory::Derivation
312        );
313
314        assert_eq!(EdgeRelation::DependsOn.category(), EdgeCategory::Dependency);
315        assert_eq!(EdgeRelation::Enables.category(), EdgeCategory::Dependency);
316
317        assert_eq!(
318            EdgeRelation::Implements.category(),
319            EdgeCategory::Implementation
320        );
321
322        assert_eq!(
323            EdgeRelation::DerivedFrom.category(),
324            EdgeCategory::Provenance
325        );
326        assert_eq!(EdgeRelation::Precedes.category(), EdgeCategory::Temporal);
327
328        assert_eq!(EdgeRelation::CompetesWith.category(), EdgeCategory::Lateral);
329        assert_eq!(EdgeRelation::ComposedWith.category(), EdgeCategory::Lateral);
330
331        assert_eq!(EdgeRelation::Annotates.category(), EdgeCategory::Annotation);
332    }
333
334    #[test]
335    fn from_str_new_relations() {
336        assert_eq!(
337            "derived_from".parse::<EdgeRelation>().unwrap(),
338            EdgeRelation::DerivedFrom
339        );
340        assert_eq!(
341            "derived-from".parse::<EdgeRelation>().unwrap(),
342            EdgeRelation::DerivedFrom
343        );
344        assert_eq!(
345            "derivedfrom".parse::<EdgeRelation>().unwrap(),
346            EdgeRelation::DerivedFrom
347        );
348        assert_eq!(
349            "precedes".parse::<EdgeRelation>().unwrap(),
350            EdgeRelation::Precedes
351        );
352    }
353
354    #[test]
355    fn is_symmetric_only_for_lateral_peer_relations() {
356        assert!(EdgeRelation::CompetesWith.is_symmetric());
357        assert!(EdgeRelation::ComposedWith.is_symmetric());
358        assert!(!EdgeRelation::DependsOn.is_symmetric());
359        assert!(!EdgeRelation::DerivedFrom.is_symmetric());
360        assert!(!EdgeRelation::Precedes.is_symmetric());
361        assert!(!EdgeRelation::Extends.is_symmetric());
362    }
363
364    #[cfg(feature = "serde")]
365    #[test]
366    fn serde_snake_case_roundtrip() {
367        let rel = EdgeRelation::IntroducedBy;
368        let json = serde_json::to_string(&rel).unwrap();
369        assert_eq!(json, "\"introduced_by\"");
370        let parsed: EdgeRelation = serde_json::from_str(&json).unwrap();
371        assert_eq!(parsed, rel);
372    }
373
374    #[cfg(feature = "serde")]
375    #[test]
376    fn serde_new_relations_roundtrip() {
377        for rel in [EdgeRelation::DerivedFrom, EdgeRelation::Precedes] {
378            let json = serde_json::to_string(&rel).unwrap();
379            let parsed: EdgeRelation = serde_json::from_str(&json).unwrap();
380            assert_eq!(parsed, rel);
381        }
382    }
383}