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