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 9 structural categories that group the 17 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    /// Evidence for/against a claim: `supports`, `refutes`
35    Epistemic,
36}
37
38/// Closed set of 17 canonical edge relations.
39///
40/// No `Default` — every edge requires an explicit relation.
41/// Wire format: snake_case strings (e.g. `"part_of"`, `"introduced_by"`).
42#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
43#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
44#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
45pub enum EdgeRelation {
46    // Structure
47    Contains,
48    PartOf,
49    InstanceOf,
50    // Derivation
51    Extends,
52    VariantOf,
53    IntroducedBy,
54    Supersedes,
55    // Provenance
56    DerivedFrom,
57    // Temporal
58    Precedes,
59    // Dependency
60    DependsOn,
61    Enables,
62    // Implementation
63    Implements,
64    // Lateral
65    CompetesWith,
66    ComposedWith,
67    // Annotation
68    Annotates,
69    // Epistemic
70    Supports,
71    Refutes,
72}
73
74impl EdgeRelation {
75    /// All 17 canonical relations in ontology-table order.
76    pub const ALL: [Self; 17] = [
77        Self::Contains,
78        Self::PartOf,
79        Self::InstanceOf,
80        Self::Extends,
81        Self::VariantOf,
82        Self::IntroducedBy,
83        Self::Supersedes,
84        Self::DerivedFrom,
85        Self::Precedes,
86        Self::DependsOn,
87        Self::Enables,
88        Self::Implements,
89        Self::CompetesWith,
90        Self::ComposedWith,
91        Self::Annotates,
92        Self::Supports,
93        Self::Refutes,
94    ];
95
96    /// Valid snake_case names for all 17 canonical relations.
97    pub const VALID_NAMES: &'static [&'static str] = &[
98        "contains",
99        "part_of",
100        "instance_of",
101        "extends",
102        "variant_of",
103        "introduced_by",
104        "supersedes",
105        "derived_from",
106        "precedes",
107        "depends_on",
108        "enables",
109        "implements",
110        "competes_with",
111        "composed_with",
112        "annotates",
113        "supports",
114        "refutes",
115    ];
116
117    /// `true` for symmetric relations: edge direction has no semantic meaning.
118    pub const fn is_symmetric(&self) -> bool {
119        matches!(self, Self::CompetesWith | Self::ComposedWith)
120    }
121
122    /// The category this relation belongs to.
123    pub const fn category(&self) -> EdgeCategory {
124        match self {
125            Self::Contains | Self::PartOf | Self::InstanceOf => EdgeCategory::Structure,
126            Self::Extends | Self::VariantOf | Self::IntroducedBy | Self::Supersedes => {
127                EdgeCategory::Derivation
128            }
129            Self::DerivedFrom => EdgeCategory::Provenance,
130            Self::Precedes => EdgeCategory::Temporal,
131            Self::DependsOn | Self::Enables => EdgeCategory::Dependency,
132            Self::Implements => EdgeCategory::Implementation,
133            Self::CompetesWith | Self::ComposedWith => EdgeCategory::Lateral,
134            Self::Annotates => EdgeCategory::Annotation,
135            Self::Supports | Self::Refutes => EdgeCategory::Epistemic,
136        }
137    }
138
139    /// Canonical snake_case name as stored in the database.
140    pub const fn as_str(&self) -> &'static str {
141        match self {
142            Self::Contains => "contains",
143            Self::PartOf => "part_of",
144            Self::InstanceOf => "instance_of",
145            Self::Extends => "extends",
146            Self::VariantOf => "variant_of",
147            Self::IntroducedBy => "introduced_by",
148            Self::Supersedes => "supersedes",
149            Self::DerivedFrom => "derived_from",
150            Self::Precedes => "precedes",
151            Self::DependsOn => "depends_on",
152            Self::Enables => "enables",
153            Self::Implements => "implements",
154            Self::CompetesWith => "competes_with",
155            Self::ComposedWith => "composed_with",
156            Self::Annotates => "annotates",
157            Self::Supports => "supports",
158            Self::Refutes => "refutes",
159        }
160    }
161}
162
163impl fmt::Display for EdgeRelation {
164    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
165        f.write_str(self.as_str())
166    }
167}
168
169impl FromStr for EdgeRelation {
170    type Err = crate::error::UnknownVariant;
171
172    /// Parse a string into an `EdgeRelation`.
173    ///
174    /// Accepts the 17 canonical relation names (case-insensitive, with hyphens
175    /// normalised to underscores) and also squashed forms that omit the separator
176    /// (e.g. `"partof"`, `"derivedfrom"`).  The squashed forms exist for ergonomic
177    /// DSL entry; they are **not** stored on the wire, which always uses the
178    /// canonical snake_case form produced by [`EdgeRelation::as_str`].
179    fn from_str(s: &str) -> Result<Self, Self::Err> {
180        let normalised: String = s
181            .chars()
182            .map(|c| {
183                if c == '-' {
184                    '_'
185                } else {
186                    c.to_ascii_lowercase()
187                }
188            })
189            .filter(|c| c.is_ascii_alphanumeric() || *c == '_')
190            .collect();
191
192        match normalised.as_str() {
193            "contains" => Ok(Self::Contains),
194            "part_of" | "partof" => Ok(Self::PartOf),
195            "instance_of" | "instanceof" => Ok(Self::InstanceOf),
196            "extends" => Ok(Self::Extends),
197            "variant_of" | "variantof" => Ok(Self::VariantOf),
198            "introduced_by" | "introducedby" => Ok(Self::IntroducedBy),
199            "supersedes" => Ok(Self::Supersedes),
200            "derived_from" | "derivedfrom" => Ok(Self::DerivedFrom),
201            "precedes" => Ok(Self::Precedes),
202            "depends_on" | "dependson" => Ok(Self::DependsOn),
203            "enables" => Ok(Self::Enables),
204            "implements" => Ok(Self::Implements),
205            "competes_with" | "competeswith" => Ok(Self::CompetesWith),
206            "composed_with" | "composedwith" => Ok(Self::ComposedWith),
207            "annotates" => Ok(Self::Annotates),
208            "supports" => Ok(Self::Supports),
209            "refutes" => Ok(Self::Refutes),
210            _ => Err(crate::error::UnknownVariant::new(
211                "edge_relation",
212                s,
213                Self::VALID_NAMES,
214            )),
215        }
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use alloc::string::ToString;
223
224    #[test]
225    fn all_has_seventeen_variants() {
226        assert_eq!(EdgeRelation::ALL.len(), 17);
227    }
228
229    #[test]
230    fn all_nine_categories_covered() {
231        let mut cats = alloc::vec::Vec::new();
232        for r in EdgeRelation::ALL {
233            let c = r.category();
234            if !cats.contains(&c) {
235                cats.push(c);
236            }
237        }
238        assert_eq!(cats.len(), 9, "all 9 categories must be represented");
239    }
240
241    #[test]
242    fn display_roundtrip_for_all() {
243        for relation in EdgeRelation::ALL {
244            let s = relation.to_string();
245            let parsed: EdgeRelation = s.parse().expect("display output should re-parse");
246            assert_eq!(parsed, relation);
247        }
248    }
249
250    #[test]
251    fn from_str_case_insensitive() {
252        assert_eq!(
253            "Extends".parse::<EdgeRelation>().unwrap(),
254            EdgeRelation::Extends
255        );
256        assert_eq!(
257            "extends".parse::<EdgeRelation>().unwrap(),
258            EdgeRelation::Extends
259        );
260        assert_eq!(
261            "EXTENDS".parse::<EdgeRelation>().unwrap(),
262            EdgeRelation::Extends
263        );
264    }
265
266    #[test]
267    fn from_str_hyphen_tolerant() {
268        assert_eq!(
269            "part_of".parse::<EdgeRelation>().unwrap(),
270            EdgeRelation::PartOf
271        );
272        assert_eq!(
273            "part-of".parse::<EdgeRelation>().unwrap(),
274            EdgeRelation::PartOf
275        );
276        assert_eq!(
277            "partof".parse::<EdgeRelation>().unwrap(),
278            EdgeRelation::PartOf
279        );
280
281        assert_eq!(
282            "introduced_by".parse::<EdgeRelation>().unwrap(),
283            EdgeRelation::IntroducedBy
284        );
285        assert_eq!(
286            "introduced-by".parse::<EdgeRelation>().unwrap(),
287            EdgeRelation::IntroducedBy
288        );
289    }
290
291    #[test]
292    fn from_str_unknown_returns_error_with_list() {
293        let err = "related_to".parse::<EdgeRelation>().unwrap_err();
294        let msg = err.to_string();
295        assert!(
296            msg.contains("related_to"),
297            "error should mention the bad input"
298        );
299        assert!(
300            msg.contains("contains"),
301            "error should list valid relations"
302        );
303        assert!(
304            msg.contains("derived_from"),
305            "error should list derived_from"
306        );
307        assert!(msg.contains("precedes"), "error should list precedes");
308        assert!(msg.contains("annotates"), "error should list all 17");
309    }
310
311    #[test]
312    fn category_returns_correct_group() {
313        assert_eq!(EdgeRelation::Contains.category(), EdgeCategory::Structure);
314        assert_eq!(EdgeRelation::PartOf.category(), EdgeCategory::Structure);
315        assert_eq!(EdgeRelation::InstanceOf.category(), EdgeCategory::Structure);
316
317        assert_eq!(EdgeRelation::Extends.category(), EdgeCategory::Derivation);
318        assert_eq!(EdgeRelation::VariantOf.category(), EdgeCategory::Derivation);
319        assert_eq!(
320            EdgeRelation::IntroducedBy.category(),
321            EdgeCategory::Derivation
322        );
323        assert_eq!(
324            EdgeRelation::Supersedes.category(),
325            EdgeCategory::Derivation
326        );
327
328        assert_eq!(EdgeRelation::DependsOn.category(), EdgeCategory::Dependency);
329        assert_eq!(EdgeRelation::Enables.category(), EdgeCategory::Dependency);
330
331        assert_eq!(
332            EdgeRelation::Implements.category(),
333            EdgeCategory::Implementation
334        );
335
336        assert_eq!(
337            EdgeRelation::DerivedFrom.category(),
338            EdgeCategory::Provenance
339        );
340        assert_eq!(EdgeRelation::Precedes.category(), EdgeCategory::Temporal);
341
342        assert_eq!(EdgeRelation::CompetesWith.category(), EdgeCategory::Lateral);
343        assert_eq!(EdgeRelation::ComposedWith.category(), EdgeCategory::Lateral);
344
345        assert_eq!(EdgeRelation::Annotates.category(), EdgeCategory::Annotation);
346    }
347
348    #[test]
349    fn from_str_new_relations() {
350        assert_eq!(
351            "derived_from".parse::<EdgeRelation>().unwrap(),
352            EdgeRelation::DerivedFrom
353        );
354        assert_eq!(
355            "derived-from".parse::<EdgeRelation>().unwrap(),
356            EdgeRelation::DerivedFrom
357        );
358        assert_eq!(
359            "derivedfrom".parse::<EdgeRelation>().unwrap(),
360            EdgeRelation::DerivedFrom
361        );
362        assert_eq!(
363            "precedes".parse::<EdgeRelation>().unwrap(),
364            EdgeRelation::Precedes
365        );
366    }
367
368    #[test]
369    fn is_symmetric_only_for_lateral_peer_relations() {
370        assert!(EdgeRelation::CompetesWith.is_symmetric());
371        assert!(EdgeRelation::ComposedWith.is_symmetric());
372        assert!(!EdgeRelation::DependsOn.is_symmetric());
373        assert!(!EdgeRelation::DerivedFrom.is_symmetric());
374        assert!(!EdgeRelation::Precedes.is_symmetric());
375        assert!(!EdgeRelation::Extends.is_symmetric());
376    }
377
378    #[test]
379    fn from_str_epistemic_relations() {
380        assert_eq!(
381            "supports".parse::<EdgeRelation>().unwrap(),
382            EdgeRelation::Supports
383        );
384        assert_eq!(
385            "refutes".parse::<EdgeRelation>().unwrap(),
386            EdgeRelation::Refutes
387        );
388        assert_eq!(
389            "Supports".parse::<EdgeRelation>().unwrap(),
390            EdgeRelation::Supports
391        );
392        assert_eq!(
393            "REFUTES".parse::<EdgeRelation>().unwrap(),
394            EdgeRelation::Refutes
395        );
396        assert_eq!(EdgeRelation::Supports.category(), EdgeCategory::Epistemic);
397        assert_eq!(EdgeRelation::Refutes.category(), EdgeCategory::Epistemic);
398        assert!(!EdgeRelation::Supports.is_symmetric());
399        assert!(!EdgeRelation::Refutes.is_symmetric());
400    }
401
402    #[cfg(feature = "serde")]
403    #[test]
404    fn serde_snake_case_roundtrip() {
405        let rel = EdgeRelation::IntroducedBy;
406        let json = serde_json::to_string(&rel).unwrap();
407        assert_eq!(json, "\"introduced_by\"");
408        let parsed: EdgeRelation = serde_json::from_str(&json).unwrap();
409        assert_eq!(parsed, rel);
410    }
411
412    #[cfg(feature = "serde")]
413    #[test]
414    fn serde_new_relations_roundtrip() {
415        for rel in [EdgeRelation::DerivedFrom, EdgeRelation::Precedes] {
416            let json = serde_json::to_string(&rel).unwrap();
417            let parsed: EdgeRelation = serde_json::from_str(&json).unwrap();
418            assert_eq!(parsed, rel);
419        }
420    }
421
422    #[cfg(feature = "serde")]
423    #[test]
424    fn serde_epistemic_relations_roundtrip() {
425        let sup_json = serde_json::to_string(&EdgeRelation::Supports).unwrap();
426        assert_eq!(sup_json, "\"supports\"");
427        let sup_parsed: EdgeRelation = serde_json::from_str(&sup_json).unwrap();
428        assert_eq!(sup_parsed, EdgeRelation::Supports);
429
430        let ref_json = serde_json::to_string(&EdgeRelation::Refutes).unwrap();
431        assert_eq!(ref_json, "\"refutes\"");
432        let ref_parsed: EdgeRelation = serde_json::from_str(&ref_json).unwrap();
433        assert_eq!(ref_parsed, EdgeRelation::Refutes);
434    }
435}