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)]
262#[path = "edge_tests.rs"]
263mod tests;