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 10 semantic categories that group the 20 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, reference and location: `contains`, `part_of`, `instance_of`, `links_to`,
19    /// `located_in`
20    Structure,
21    /// Intellectual lineage: `extends`, `variant_of`, `introduced_by`, `supersedes`
22    Derivation,
23    /// Data/artifact origin: `derived_from`
24    Provenance,
25    /// Time ordering: `precedes`
26    Temporal,
27    /// Build/runtime needs: `depends_on`, `enables`
28    Dependency,
29    /// Code ↔ concept: `implements`
30    Implementation,
31    /// Peer relationships: `competes_with`, `composed_with`
32    Lateral,
33    /// Cross-substrate annotation: `annotates`
34    Annotation,
35    /// Evidence for/against a claim: `supports`, `refutes`
36    Epistemic,
37    /// Current whole or partial ownership: `owns`
38    Ownership,
39}
40
41/// Closed set of 20 canonical edge relations.
42///
43/// No `Default` — every edge requires an explicit relation.
44/// Wire format: snake_case strings (e.g. `"part_of"`, `"introduced_by"`).
45#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
46#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
47#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
48pub enum EdgeRelation {
49    // Structure
50    Contains,
51    PartOf,
52    InstanceOf,
53    LinksTo,
54    LocatedIn,
55    // Derivation
56    Extends,
57    VariantOf,
58    IntroducedBy,
59    Supersedes,
60    // Provenance
61    DerivedFrom,
62    // Temporal
63    Precedes,
64    // Dependency
65    DependsOn,
66    Enables,
67    // Implementation
68    Implements,
69    // Lateral
70    CompetesWith,
71    ComposedWith,
72    // Annotation
73    Annotates,
74    // Epistemic
75    Supports,
76    Refutes,
77    // Ownership
78    Owns,
79}
80
81impl EdgeRelation {
82    /// All 20 canonical relations in ontology-table order.
83    pub const ALL: [Self; 20] = [
84        Self::Contains,
85        Self::PartOf,
86        Self::InstanceOf,
87        Self::LinksTo,
88        Self::LocatedIn,
89        Self::Extends,
90        Self::VariantOf,
91        Self::IntroducedBy,
92        Self::Supersedes,
93        Self::DerivedFrom,
94        Self::Precedes,
95        Self::DependsOn,
96        Self::Enables,
97        Self::Implements,
98        Self::CompetesWith,
99        Self::ComposedWith,
100        Self::Annotates,
101        Self::Supports,
102        Self::Refutes,
103        Self::Owns,
104    ];
105
106    /// Valid snake_case names for all 20 canonical relations.
107    pub const VALID_NAMES: &'static [&'static str] = &[
108        "contains",
109        "part_of",
110        "instance_of",
111        "links_to",
112        "located_in",
113        "extends",
114        "variant_of",
115        "introduced_by",
116        "supersedes",
117        "derived_from",
118        "precedes",
119        "depends_on",
120        "enables",
121        "implements",
122        "competes_with",
123        "composed_with",
124        "annotates",
125        "supports",
126        "refutes",
127        "owns",
128    ];
129
130    /// `true` for symmetric relations: edge direction has no semantic meaning.
131    pub const fn is_symmetric(&self) -> bool {
132        matches!(self, Self::CompetesWith | Self::ComposedWith)
133    }
134
135    /// Order the endpoints of an edge so a symmetric relation has one stored form.
136    ///
137    /// For symmetric relations (`competes_with`, `composed_with`) the smaller
138    /// endpoint comes first, so A→B and B→A collapse into a single canonical row.
139    /// Every other relation, and a symmetric pair that is already ordered or has
140    /// equal endpoints, is returned as given.
141    ///
142    /// Generic over `T: Ord` because this crate does not depend on a UUID type;
143    /// for UUIDs the order is the byte order of the id.
144    pub fn canonical_endpoints<T: Ord>(&self, source: T, target: T) -> (T, T) {
145        if self.is_symmetric() && target < source {
146            (target, source)
147        } else {
148            (source, target)
149        }
150    }
151
152    /// The category this relation belongs to.
153    pub const fn category(&self) -> EdgeCategory {
154        match self {
155            Self::Contains | Self::PartOf | Self::InstanceOf | Self::LinksTo | Self::LocatedIn => {
156                EdgeCategory::Structure
157            }
158            Self::Extends | Self::VariantOf | Self::IntroducedBy | Self::Supersedes => {
159                EdgeCategory::Derivation
160            }
161            Self::DerivedFrom => EdgeCategory::Provenance,
162            Self::Precedes => EdgeCategory::Temporal,
163            Self::DependsOn | Self::Enables => EdgeCategory::Dependency,
164            Self::Implements => EdgeCategory::Implementation,
165            Self::CompetesWith | Self::ComposedWith => EdgeCategory::Lateral,
166            Self::Annotates => EdgeCategory::Annotation,
167            Self::Supports | Self::Refutes => EdgeCategory::Epistemic,
168            Self::Owns => EdgeCategory::Ownership,
169        }
170    }
171
172    /// Canonical snake_case name as stored in the database.
173    pub const fn as_str(&self) -> &'static str {
174        match self {
175            Self::Contains => "contains",
176            Self::PartOf => "part_of",
177            Self::InstanceOf => "instance_of",
178            Self::LinksTo => "links_to",
179            Self::LocatedIn => "located_in",
180            Self::Extends => "extends",
181            Self::VariantOf => "variant_of",
182            Self::IntroducedBy => "introduced_by",
183            Self::Supersedes => "supersedes",
184            Self::DerivedFrom => "derived_from",
185            Self::Precedes => "precedes",
186            Self::DependsOn => "depends_on",
187            Self::Enables => "enables",
188            Self::Implements => "implements",
189            Self::CompetesWith => "competes_with",
190            Self::ComposedWith => "composed_with",
191            Self::Annotates => "annotates",
192            Self::Supports => "supports",
193            Self::Refutes => "refutes",
194            Self::Owns => "owns",
195        }
196    }
197}
198
199impl fmt::Display for EdgeRelation {
200    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
201        f.write_str(self.as_str())
202    }
203}
204
205impl FromStr for EdgeRelation {
206    type Err = crate::error::UnknownVariant;
207
208    /// Parse a string into an `EdgeRelation`.
209    ///
210    /// Accepts the 20 canonical relation names (case-insensitive, with hyphens
211    /// normalised to underscores) and also squashed forms that omit the separator
212    /// (e.g. `"partof"`, `"derivedfrom"`).  The squashed forms exist for ergonomic
213    /// DSL entry; they are **not** stored on the wire, which always uses the
214    /// canonical snake_case form produced by [`EdgeRelation::as_str`].
215    fn from_str(s: &str) -> Result<Self, Self::Err> {
216        let mut normalised = String::with_capacity(s.len());
217        for c in s.chars() {
218            match c {
219                '-' | '_' => normalised.push('_'),
220                c if c.is_ascii_alphanumeric() => normalised.push(c.to_ascii_lowercase()),
221                _ => {
222                    return Err(crate::error::UnknownVariant::new(
223                        "edge_relation",
224                        s,
225                        Self::VALID_NAMES,
226                    ));
227                }
228            }
229        }
230
231        match normalised.as_str() {
232            "contains" => Ok(Self::Contains),
233            "part_of" | "partof" => Ok(Self::PartOf),
234            "instance_of" | "instanceof" => Ok(Self::InstanceOf),
235            "links_to" | "linksto" => Ok(Self::LinksTo),
236            "located_in" | "locatedin" => Ok(Self::LocatedIn),
237            "extends" => Ok(Self::Extends),
238            "variant_of" | "variantof" => Ok(Self::VariantOf),
239            "introduced_by" | "introducedby" => Ok(Self::IntroducedBy),
240            "supersedes" => Ok(Self::Supersedes),
241            "derived_from" | "derivedfrom" => Ok(Self::DerivedFrom),
242            "precedes" => Ok(Self::Precedes),
243            "depends_on" | "dependson" => Ok(Self::DependsOn),
244            "enables" => Ok(Self::Enables),
245            "implements" => Ok(Self::Implements),
246            "competes_with" | "competeswith" => Ok(Self::CompetesWith),
247            "composed_with" | "composedwith" => Ok(Self::ComposedWith),
248            "annotates" => Ok(Self::Annotates),
249            "supports" => Ok(Self::Supports),
250            "refutes" => Ok(Self::Refutes),
251            "owns" => Ok(Self::Owns),
252            _ => Err(crate::error::UnknownVariant::new(
253                "edge_relation",
254                s,
255                Self::VALID_NAMES,
256            )),
257        }
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use alloc::string::ToString;
265
266    #[test]
267    fn all_has_twenty_variants() {
268        assert_eq!(EdgeRelation::ALL.len(), 20);
269    }
270
271    #[test]
272    fn all_ten_categories_covered() {
273        let mut cats = alloc::vec::Vec::new();
274        for r in EdgeRelation::ALL {
275            let c = r.category();
276            if !cats.contains(&c) {
277                cats.push(c);
278            }
279        }
280        assert_eq!(cats.len(), 10, "all 10 categories must be represented");
281    }
282
283    #[test]
284    fn owns_is_directional_ownership() {
285        assert_eq!("owns".parse::<EdgeRelation>().unwrap(), EdgeRelation::Owns);
286        assert_eq!("OWNS".parse::<EdgeRelation>().unwrap(), EdgeRelation::Owns);
287        assert_eq!(EdgeRelation::Owns.to_string(), "owns");
288        assert_eq!(EdgeRelation::Owns.category(), EdgeCategory::Ownership);
289        assert!(!EdgeRelation::Owns.is_symmetric());
290        assert_eq!(EdgeRelation::Owns.canonical_endpoints(9, 2), (9, 2));
291    }
292
293    #[test]
294    fn display_roundtrip_for_all() {
295        for relation in EdgeRelation::ALL {
296            let s = relation.to_string();
297            let parsed: EdgeRelation = s.parse().expect("display output should re-parse");
298            assert_eq!(parsed, relation);
299        }
300    }
301
302    #[test]
303    fn from_str_case_insensitive() {
304        assert_eq!(
305            "Extends".parse::<EdgeRelation>().unwrap(),
306            EdgeRelation::Extends
307        );
308        assert_eq!(
309            "extends".parse::<EdgeRelation>().unwrap(),
310            EdgeRelation::Extends
311        );
312        assert_eq!(
313            "EXTENDS".parse::<EdgeRelation>().unwrap(),
314            EdgeRelation::Extends
315        );
316    }
317
318    #[test]
319    fn from_str_hyphen_tolerant() {
320        assert_eq!(
321            "part_of".parse::<EdgeRelation>().unwrap(),
322            EdgeRelation::PartOf
323        );
324        assert_eq!(
325            "part-of".parse::<EdgeRelation>().unwrap(),
326            EdgeRelation::PartOf
327        );
328        assert_eq!(
329            "partof".parse::<EdgeRelation>().unwrap(),
330            EdgeRelation::PartOf
331        );
332
333        assert_eq!(
334            "introduced_by".parse::<EdgeRelation>().unwrap(),
335            EdgeRelation::IntroducedBy
336        );
337        assert_eq!(
338            "introduced-by".parse::<EdgeRelation>().unwrap(),
339            EdgeRelation::IntroducedBy
340        );
341    }
342
343    #[test]
344    fn from_str_unknown_returns_error_with_list() {
345        let err = "related_to".parse::<EdgeRelation>().unwrap_err();
346        let msg = err.to_string();
347        assert!(
348            msg.contains("related_to"),
349            "error should mention the bad input"
350        );
351        assert!(
352            msg.contains("contains"),
353            "error should list valid relations"
354        );
355        assert!(
356            msg.contains("derived_from"),
357            "error should list derived_from"
358        );
359        assert!(msg.contains("precedes"), "error should list precedes");
360        assert!(msg.contains("annotates"), "error should list annotates");
361    }
362
363    #[test]
364    fn edge_relation_bang_rejected() {
365        for bad in ["supports!", "part/of", "depends.on", "competes with"] {
366            let err = bad
367                .parse::<EdgeRelation>()
368                .expect_err("malformed punctuation/whitespace must be rejected");
369            assert_eq!(err.domain, "edge_relation");
370            assert_eq!(err.value, bad);
371        }
372    }
373
374    #[test]
375    fn category_returns_correct_group() {
376        assert_eq!(EdgeRelation::Contains.category(), EdgeCategory::Structure);
377        assert_eq!(EdgeRelation::PartOf.category(), EdgeCategory::Structure);
378        assert_eq!(EdgeRelation::InstanceOf.category(), EdgeCategory::Structure);
379        assert_eq!(EdgeRelation::LinksTo.category(), EdgeCategory::Structure);
380        assert_eq!(EdgeRelation::LocatedIn.category(), EdgeCategory::Structure);
381
382        assert_eq!(EdgeRelation::Extends.category(), EdgeCategory::Derivation);
383        assert_eq!(EdgeRelation::VariantOf.category(), EdgeCategory::Derivation);
384        assert_eq!(
385            EdgeRelation::IntroducedBy.category(),
386            EdgeCategory::Derivation
387        );
388        assert_eq!(
389            EdgeRelation::Supersedes.category(),
390            EdgeCategory::Derivation
391        );
392
393        assert_eq!(EdgeRelation::DependsOn.category(), EdgeCategory::Dependency);
394        assert_eq!(EdgeRelation::Enables.category(), EdgeCategory::Dependency);
395
396        assert_eq!(
397            EdgeRelation::Implements.category(),
398            EdgeCategory::Implementation
399        );
400
401        assert_eq!(
402            EdgeRelation::DerivedFrom.category(),
403            EdgeCategory::Provenance
404        );
405        assert_eq!(EdgeRelation::Precedes.category(), EdgeCategory::Temporal);
406
407        assert_eq!(EdgeRelation::CompetesWith.category(), EdgeCategory::Lateral);
408        assert_eq!(EdgeRelation::ComposedWith.category(), EdgeCategory::Lateral);
409
410        assert_eq!(EdgeRelation::Annotates.category(), EdgeCategory::Annotation);
411    }
412
413    #[test]
414    fn from_str_new_relations() {
415        assert_eq!(
416            "derived_from".parse::<EdgeRelation>().unwrap(),
417            EdgeRelation::DerivedFrom
418        );
419        assert_eq!(
420            "derived-from".parse::<EdgeRelation>().unwrap(),
421            EdgeRelation::DerivedFrom
422        );
423        assert_eq!(
424            "derivedfrom".parse::<EdgeRelation>().unwrap(),
425            EdgeRelation::DerivedFrom
426        );
427        assert_eq!(
428            "precedes".parse::<EdgeRelation>().unwrap(),
429            EdgeRelation::Precedes
430        );
431    }
432
433    #[test]
434    fn from_str_links_to() {
435        assert_eq!(
436            "links_to".parse::<EdgeRelation>().unwrap(),
437            EdgeRelation::LinksTo
438        );
439        assert_eq!(
440            "links-to".parse::<EdgeRelation>().unwrap(),
441            EdgeRelation::LinksTo
442        );
443        assert_eq!(
444            "linksto".parse::<EdgeRelation>().unwrap(),
445            EdgeRelation::LinksTo
446        );
447        assert_eq!(EdgeRelation::LinksTo.to_string(), "links_to");
448        assert_eq!(EdgeRelation::LinksTo.category(), EdgeCategory::Structure);
449    }
450
451    #[test]
452    fn from_str_located_in() {
453        assert_eq!(
454            "located_in".parse::<EdgeRelation>().unwrap(),
455            EdgeRelation::LocatedIn
456        );
457        assert_eq!(
458            "located-in".parse::<EdgeRelation>().unwrap(),
459            EdgeRelation::LocatedIn
460        );
461        assert_eq!(
462            "locatedin".parse::<EdgeRelation>().unwrap(),
463            EdgeRelation::LocatedIn
464        );
465        assert_eq!(EdgeRelation::LocatedIn.to_string(), "located_in");
466        assert_eq!(EdgeRelation::LocatedIn.category(), EdgeCategory::Structure);
467    }
468
469    #[test]
470    fn is_symmetric_only_for_lateral_peer_relations() {
471        assert!(EdgeRelation::CompetesWith.is_symmetric());
472        assert!(EdgeRelation::ComposedWith.is_symmetric());
473        assert!(!EdgeRelation::DependsOn.is_symmetric());
474        assert!(!EdgeRelation::DerivedFrom.is_symmetric());
475        assert!(!EdgeRelation::Precedes.is_symmetric());
476        assert!(!EdgeRelation::Extends.is_symmetric());
477        assert!(!EdgeRelation::LinksTo.is_symmetric());
478        assert!(!EdgeRelation::LocatedIn.is_symmetric());
479    }
480
481    /// The symmetric relations, named here rather than derived from `is_symmetric`
482    /// so the tests below do not take their expectation from the code under test.
483    const SYMMETRIC: [EdgeRelation; 2] = [EdgeRelation::CompetesWith, EdgeRelation::ComposedWith];
484
485    /// Endpoint pairs with source less than, greater than and equal to target.
486    const PAIRS: [(u8, u8); 3] = [(1, 2), (2, 1), (7, 7)];
487
488    #[test]
489    fn canonical_endpoints_sorts_symmetric_relations() {
490        for relation in SYMMETRIC {
491            assert!(relation.is_symmetric(), "{relation}");
492            for (source, target) in PAIRS {
493                assert_eq!(
494                    relation.canonical_endpoints(source, target),
495                    (source.min(target), source.max(target)),
496                    "{relation}: {source} to {target}"
497                );
498            }
499        }
500    }
501
502    #[test]
503    fn canonical_endpoints_leaves_other_relations_as_given() {
504        let others: alloc::vec::Vec<EdgeRelation> = EdgeRelation::ALL
505            .into_iter()
506            .filter(|relation| !SYMMETRIC.contains(relation))
507            .collect();
508        assert_eq!(others.len(), EdgeRelation::ALL.len() - SYMMETRIC.len());
509        for relation in others {
510            assert!(!relation.is_symmetric(), "{relation}");
511            for (source, target) in PAIRS {
512                assert_eq!(
513                    relation.canonical_endpoints(source, target),
514                    (source, target),
515                    "{relation}: {source} to {target}"
516                );
517            }
518        }
519    }
520
521    #[test]
522    fn from_str_epistemic_relations() {
523        assert_eq!(
524            "supports".parse::<EdgeRelation>().unwrap(),
525            EdgeRelation::Supports
526        );
527        assert_eq!(
528            "refutes".parse::<EdgeRelation>().unwrap(),
529            EdgeRelation::Refutes
530        );
531        assert_eq!(
532            "Supports".parse::<EdgeRelation>().unwrap(),
533            EdgeRelation::Supports
534        );
535        assert_eq!(
536            "REFUTES".parse::<EdgeRelation>().unwrap(),
537            EdgeRelation::Refutes
538        );
539        assert_eq!(EdgeRelation::Supports.category(), EdgeCategory::Epistemic);
540        assert_eq!(EdgeRelation::Refutes.category(), EdgeCategory::Epistemic);
541        assert!(!EdgeRelation::Supports.is_symmetric());
542        assert!(!EdgeRelation::Refutes.is_symmetric());
543    }
544
545    #[cfg(feature = "serde")]
546    #[test]
547    fn serde_snake_case_roundtrip() {
548        let rel = EdgeRelation::IntroducedBy;
549        let json = serde_json::to_string(&rel).unwrap();
550        assert_eq!(json, "\"introduced_by\"");
551        let parsed: EdgeRelation = serde_json::from_str(&json).unwrap();
552        assert_eq!(parsed, rel);
553    }
554
555    #[cfg(feature = "serde")]
556    #[test]
557    fn serde_new_relations_roundtrip() {
558        for rel in [EdgeRelation::DerivedFrom, EdgeRelation::Precedes] {
559            let json = serde_json::to_string(&rel).unwrap();
560            let parsed: EdgeRelation = serde_json::from_str(&json).unwrap();
561            assert_eq!(parsed, rel);
562        }
563    }
564
565    #[cfg(feature = "serde")]
566    #[test]
567    fn serde_epistemic_relations_roundtrip() {
568        let sup_json = serde_json::to_string(&EdgeRelation::Supports).unwrap();
569        assert_eq!(sup_json, "\"supports\"");
570        let sup_parsed: EdgeRelation = serde_json::from_str(&sup_json).unwrap();
571        assert_eq!(sup_parsed, EdgeRelation::Supports);
572
573        let ref_json = serde_json::to_string(&EdgeRelation::Refutes).unwrap();
574        assert_eq!(ref_json, "\"refutes\"");
575        let ref_parsed: EdgeRelation = serde_json::from_str(&ref_json).unwrap();
576        assert_eq!(ref_parsed, EdgeRelation::Refutes);
577    }
578}