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 mut normalised = String::with_capacity(s.len());
181        for c in s.chars() {
182            match c {
183                '-' | '_' => normalised.push('_'),
184                c if c.is_ascii_alphanumeric() => normalised.push(c.to_ascii_lowercase()),
185                _ => {
186                    return Err(crate::error::UnknownVariant::new(
187                        "edge_relation",
188                        s,
189                        Self::VALID_NAMES,
190                    ));
191                }
192            }
193        }
194
195        match normalised.as_str() {
196            "contains" => Ok(Self::Contains),
197            "part_of" | "partof" => Ok(Self::PartOf),
198            "instance_of" | "instanceof" => Ok(Self::InstanceOf),
199            "extends" => Ok(Self::Extends),
200            "variant_of" | "variantof" => Ok(Self::VariantOf),
201            "introduced_by" | "introducedby" => Ok(Self::IntroducedBy),
202            "supersedes" => Ok(Self::Supersedes),
203            "derived_from" | "derivedfrom" => Ok(Self::DerivedFrom),
204            "precedes" => Ok(Self::Precedes),
205            "depends_on" | "dependson" => Ok(Self::DependsOn),
206            "enables" => Ok(Self::Enables),
207            "implements" => Ok(Self::Implements),
208            "competes_with" | "competeswith" => Ok(Self::CompetesWith),
209            "composed_with" | "composedwith" => Ok(Self::ComposedWith),
210            "annotates" => Ok(Self::Annotates),
211            "supports" => Ok(Self::Supports),
212            "refutes" => Ok(Self::Refutes),
213            _ => Err(crate::error::UnknownVariant::new(
214                "edge_relation",
215                s,
216                Self::VALID_NAMES,
217            )),
218        }
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use alloc::string::ToString;
226
227    #[test]
228    fn all_has_seventeen_variants() {
229        assert_eq!(EdgeRelation::ALL.len(), 17);
230    }
231
232    #[test]
233    fn all_nine_categories_covered() {
234        let mut cats = alloc::vec::Vec::new();
235        for r in EdgeRelation::ALL {
236            let c = r.category();
237            if !cats.contains(&c) {
238                cats.push(c);
239            }
240        }
241        assert_eq!(cats.len(), 9, "all 9 categories must be represented");
242    }
243
244    #[test]
245    fn display_roundtrip_for_all() {
246        for relation in EdgeRelation::ALL {
247            let s = relation.to_string();
248            let parsed: EdgeRelation = s.parse().expect("display output should re-parse");
249            assert_eq!(parsed, relation);
250        }
251    }
252
253    #[test]
254    fn from_str_case_insensitive() {
255        assert_eq!(
256            "Extends".parse::<EdgeRelation>().unwrap(),
257            EdgeRelation::Extends
258        );
259        assert_eq!(
260            "extends".parse::<EdgeRelation>().unwrap(),
261            EdgeRelation::Extends
262        );
263        assert_eq!(
264            "EXTENDS".parse::<EdgeRelation>().unwrap(),
265            EdgeRelation::Extends
266        );
267    }
268
269    #[test]
270    fn from_str_hyphen_tolerant() {
271        assert_eq!(
272            "part_of".parse::<EdgeRelation>().unwrap(),
273            EdgeRelation::PartOf
274        );
275        assert_eq!(
276            "part-of".parse::<EdgeRelation>().unwrap(),
277            EdgeRelation::PartOf
278        );
279        assert_eq!(
280            "partof".parse::<EdgeRelation>().unwrap(),
281            EdgeRelation::PartOf
282        );
283
284        assert_eq!(
285            "introduced_by".parse::<EdgeRelation>().unwrap(),
286            EdgeRelation::IntroducedBy
287        );
288        assert_eq!(
289            "introduced-by".parse::<EdgeRelation>().unwrap(),
290            EdgeRelation::IntroducedBy
291        );
292    }
293
294    #[test]
295    fn from_str_unknown_returns_error_with_list() {
296        let err = "related_to".parse::<EdgeRelation>().unwrap_err();
297        let msg = err.to_string();
298        assert!(
299            msg.contains("related_to"),
300            "error should mention the bad input"
301        );
302        assert!(
303            msg.contains("contains"),
304            "error should list valid relations"
305        );
306        assert!(
307            msg.contains("derived_from"),
308            "error should list derived_from"
309        );
310        assert!(msg.contains("precedes"), "error should list precedes");
311        assert!(msg.contains("annotates"), "error should list all 17");
312    }
313
314    #[test]
315    fn edge_relation_bang_rejected() {
316        for bad in ["supports!", "part/of", "depends.on", "competes with"] {
317            let err = bad
318                .parse::<EdgeRelation>()
319                .expect_err("malformed punctuation/whitespace must be rejected");
320            assert_eq!(err.domain, "edge_relation");
321            assert_eq!(err.value, bad);
322        }
323    }
324
325    #[test]
326    fn category_returns_correct_group() {
327        assert_eq!(EdgeRelation::Contains.category(), EdgeCategory::Structure);
328        assert_eq!(EdgeRelation::PartOf.category(), EdgeCategory::Structure);
329        assert_eq!(EdgeRelation::InstanceOf.category(), EdgeCategory::Structure);
330
331        assert_eq!(EdgeRelation::Extends.category(), EdgeCategory::Derivation);
332        assert_eq!(EdgeRelation::VariantOf.category(), EdgeCategory::Derivation);
333        assert_eq!(
334            EdgeRelation::IntroducedBy.category(),
335            EdgeCategory::Derivation
336        );
337        assert_eq!(
338            EdgeRelation::Supersedes.category(),
339            EdgeCategory::Derivation
340        );
341
342        assert_eq!(EdgeRelation::DependsOn.category(), EdgeCategory::Dependency);
343        assert_eq!(EdgeRelation::Enables.category(), EdgeCategory::Dependency);
344
345        assert_eq!(
346            EdgeRelation::Implements.category(),
347            EdgeCategory::Implementation
348        );
349
350        assert_eq!(
351            EdgeRelation::DerivedFrom.category(),
352            EdgeCategory::Provenance
353        );
354        assert_eq!(EdgeRelation::Precedes.category(), EdgeCategory::Temporal);
355
356        assert_eq!(EdgeRelation::CompetesWith.category(), EdgeCategory::Lateral);
357        assert_eq!(EdgeRelation::ComposedWith.category(), EdgeCategory::Lateral);
358
359        assert_eq!(EdgeRelation::Annotates.category(), EdgeCategory::Annotation);
360    }
361
362    #[test]
363    fn from_str_new_relations() {
364        assert_eq!(
365            "derived_from".parse::<EdgeRelation>().unwrap(),
366            EdgeRelation::DerivedFrom
367        );
368        assert_eq!(
369            "derived-from".parse::<EdgeRelation>().unwrap(),
370            EdgeRelation::DerivedFrom
371        );
372        assert_eq!(
373            "derivedfrom".parse::<EdgeRelation>().unwrap(),
374            EdgeRelation::DerivedFrom
375        );
376        assert_eq!(
377            "precedes".parse::<EdgeRelation>().unwrap(),
378            EdgeRelation::Precedes
379        );
380    }
381
382    #[test]
383    fn is_symmetric_only_for_lateral_peer_relations() {
384        assert!(EdgeRelation::CompetesWith.is_symmetric());
385        assert!(EdgeRelation::ComposedWith.is_symmetric());
386        assert!(!EdgeRelation::DependsOn.is_symmetric());
387        assert!(!EdgeRelation::DerivedFrom.is_symmetric());
388        assert!(!EdgeRelation::Precedes.is_symmetric());
389        assert!(!EdgeRelation::Extends.is_symmetric());
390    }
391
392    #[test]
393    fn from_str_epistemic_relations() {
394        assert_eq!(
395            "supports".parse::<EdgeRelation>().unwrap(),
396            EdgeRelation::Supports
397        );
398        assert_eq!(
399            "refutes".parse::<EdgeRelation>().unwrap(),
400            EdgeRelation::Refutes
401        );
402        assert_eq!(
403            "Supports".parse::<EdgeRelation>().unwrap(),
404            EdgeRelation::Supports
405        );
406        assert_eq!(
407            "REFUTES".parse::<EdgeRelation>().unwrap(),
408            EdgeRelation::Refutes
409        );
410        assert_eq!(EdgeRelation::Supports.category(), EdgeCategory::Epistemic);
411        assert_eq!(EdgeRelation::Refutes.category(), EdgeCategory::Epistemic);
412        assert!(!EdgeRelation::Supports.is_symmetric());
413        assert!(!EdgeRelation::Refutes.is_symmetric());
414    }
415
416    #[cfg(feature = "serde")]
417    #[test]
418    fn serde_snake_case_roundtrip() {
419        let rel = EdgeRelation::IntroducedBy;
420        let json = serde_json::to_string(&rel).unwrap();
421        assert_eq!(json, "\"introduced_by\"");
422        let parsed: EdgeRelation = serde_json::from_str(&json).unwrap();
423        assert_eq!(parsed, rel);
424    }
425
426    #[cfg(feature = "serde")]
427    #[test]
428    fn serde_new_relations_roundtrip() {
429        for rel in [EdgeRelation::DerivedFrom, EdgeRelation::Precedes] {
430            let json = serde_json::to_string(&rel).unwrap();
431            let parsed: EdgeRelation = serde_json::from_str(&json).unwrap();
432            assert_eq!(parsed, rel);
433        }
434    }
435
436    #[cfg(feature = "serde")]
437    #[test]
438    fn serde_epistemic_relations_roundtrip() {
439        let sup_json = serde_json::to_string(&EdgeRelation::Supports).unwrap();
440        assert_eq!(sup_json, "\"supports\"");
441        let sup_parsed: EdgeRelation = serde_json::from_str(&sup_json).unwrap();
442        assert_eq!(sup_parsed, EdgeRelation::Supports);
443
444        let ref_json = serde_json::to_string(&EdgeRelation::Refutes).unwrap();
445        assert_eq!(ref_json, "\"refutes\"");
446        let ref_parsed: EdgeRelation = serde_json::from_str(&ref_json).unwrap();
447        assert_eq!(ref_parsed, EdgeRelation::Refutes);
448    }
449}