Skip to main content

khive_types/
entity.rs

1//! Entity substrate — graph nodes with typed properties and links.
2
3extern crate alloc;
4use alloc::collections::BTreeMap;
5use alloc::string::String;
6use alloc::vec::Vec;
7use core::fmt;
8use core::str::FromStr;
9
10use crate::{EdgeRelation, Header, Id128, Timestamp};
11
12/// 8 closed base kinds for graph-node classification.
13///
14/// Governed subtype values live in `Entity::entity_type`; `properties` remain
15/// metadata and must not carry ontology type strings.
16#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
17#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
18#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
19pub enum EntityKind {
20    /// Algorithms, techniques, architectures, theories, models, research gaps.
21    /// The default / residual bucket.
22    #[default]
23    Concept,
24    /// Papers, preprints, technical reports, blog posts, books.
25    /// Has: title, authors, year, venue, DOI/URL.
26    Document,
27    /// Benchmarks, corpora, evaluation sets.
28    /// Has: task type, size, metrics, license.
29    Dataset,
30    /// Codebases, libraries, tools, frameworks.
31    /// Has: language, repo URL, license.
32    Project,
33    /// Researchers, engineers, authors.
34    Person,
35    /// Labs, companies, institutions.
36    Org,
37    /// Built artifacts: binaries, model checkpoints, Docker images, packages.
38    Artifact,
39    /// Running or deployable services: APIs, hosted endpoints, SaaS products.
40    Service,
41}
42
43impl EntityKind {
44    /// All 8 canonical entity kinds in taxonomy-table order.
45    pub const ALL: [Self; 8] = [
46        Self::Concept,
47        Self::Document,
48        Self::Dataset,
49        Self::Project,
50        Self::Person,
51        Self::Org,
52        Self::Artifact,
53        Self::Service,
54    ];
55
56    /// Return the canonical lowercase string for this kind, as stored on the wire.
57    pub const fn name(self) -> &'static str {
58        match self {
59            Self::Concept => "concept",
60            Self::Document => "document",
61            Self::Dataset => "dataset",
62            Self::Project => "project",
63            Self::Person => "person",
64            Self::Org => "org",
65            Self::Artifact => "artifact",
66            Self::Service => "service",
67        }
68    }
69}
70
71impl fmt::Display for EntityKind {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        f.write_str(self.name())
74    }
75}
76
77// Canonical entity kind strings for the closed 8-kind taxonomy.
78const ENTITY_KIND_VALID: &[&str] = &[
79    "concept", "document", "dataset", "project", "person", "org", "artifact", "service",
80];
81
82impl FromStr for EntityKind {
83    type Err = crate::error::UnknownVariant;
84
85    /// Parse a string into an `EntityKind`.
86    ///
87    /// Accepts the 8 canonical kind names (case-insensitive) plus a set of
88    /// convenience aliases to aid human-authored DSL requests (e.g. `"paper"`
89    /// resolves to `Document`, `"repo"` to `Project`).
90    ///
91    /// **Note on subtype aliasing**: when `kind="paper"` is parsed here, only the
92    /// base `EntityKind::Document` is returned.  Callers that need to preserve the
93    /// `entity_type` subtoken must use the pack registry resolution path, which
94    /// returns both the base kind and the subtype string.  `from_str` is
95    /// intentionally base-kind-only for use in contexts where the subtype is
96    /// carried separately (e.g. `Entity.entity_type`).
97    fn from_str(s: &str) -> Result<Self, Self::Err> {
98        match s.trim().to_ascii_lowercase().as_str() {
99            "concept" => Ok(Self::Concept),
100            "document" | "doc" | "paper" => Ok(Self::Document),
101            "dataset" | "data" | "benchmark" => Ok(Self::Dataset),
102            "project" | "repo" | "crate" | "library" | "lib" => Ok(Self::Project),
103            "person" | "author" | "researcher" => Ok(Self::Person),
104            "org" | "organization" | "organisation" | "lab" | "company" => Ok(Self::Org),
105            "artifact" | "art" => Ok(Self::Artifact),
106            "service" | "svc" => Ok(Self::Service),
107            other => Err(crate::error::UnknownVariant::new(
108                "entity_kind",
109                other,
110                ENTITY_KIND_VALID,
111            )),
112        }
113    }
114}
115
116/// Entity-kind admission for an explicit archive import.
117///
118/// This policy never changes relation validation or registers new kinds for
119/// ordinary writes. VCS snapshot degradation is a separate policy.
120#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
121pub enum ImportKindPolicy {
122    /// Refuse entity kinds outside the importer's installed vocabulary.
123    #[default]
124    Strict,
125    /// Preserve genuinely unknown, nonblank entity-kind spellings verbatim.
126    PreserveUnknown,
127}
128
129/// A graph node with a type, display name, and key-value properties.
130#[derive(Clone, Debug)]
131#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
132pub struct Entity {
133    /// Identity and namespace metadata shared by all substrate records.
134    #[cfg_attr(feature = "serde", serde(flatten))]
135    pub header: Header,
136    /// Closed base kind that classifies this entity.
137    pub kind: EntityKind,
138    /// Pack-governed subtype token (e.g. `"paper"`, `"snapshot"`). Never stored
139    /// raw in `properties` — queries compile this to `entities.entity_type = ?`.
140    pub entity_type: Option<String>,
141    /// Human-readable display name (required; must be non-empty).
142    pub name: String,
143    /// Optional long-form description of this entity.
144    pub description: Option<String>,
145    /// Arbitrary structured metadata as key-value pairs.
146    pub properties: BTreeMap<String, PropertyValue>,
147    /// Categorical labels for filtering and retrieval.
148    pub tags: Vec<String>,
149    /// Set when the entity is soft-deleted; absent means active.
150    pub deleted_at: Option<Timestamp>,
151}
152
153/// A directed, typed edge between two entities (or cross-substrate nodes).
154///
155/// `weight` must be finite and in `[0.0, 1.0]`. When the `serde` feature is
156/// enabled, deserialization rejects out-of-range or non-finite weights.
157#[derive(Clone, Debug)]
158#[cfg_attr(feature = "serde", derive(serde::Serialize))]
159#[cfg_attr(feature = "serde", serde(into = "LinkRaw"))]
160pub struct Link {
161    /// Unique edge identifier.
162    pub id: Id128,
163    /// Namespace that owns and isolates this edge.
164    pub namespace: String,
165    /// Source node identifier.
166    pub source: Id128,
167    /// Target node identifier.
168    pub target: Id128,
169    /// Closed relation type that semantically describes this edge.
170    pub relation: EdgeRelation,
171    /// Arbitrary structured metadata attached to this edge.
172    pub properties: BTreeMap<String, PropertyValue>,
173    /// Numeric edge weight in the range [0.0, 1.0]; 1.0 means definitional strength.
174    pub weight: f64,
175    /// Wall-clock time when this edge was created.
176    pub created_at: Timestamp,
177    /// Wall-clock time of the most recent update.
178    pub updated_at: Timestamp,
179    /// Set when the edge is soft-deleted; absent means active.
180    pub deleted_at: Option<Timestamp>,
181}
182
183/// Return whether an edge weight is finite and within `[0.0, 1.0]`.
184///
185/// Both endpoints and signed zero are accepted; NaN and infinities are rejected.
186pub fn validate_edge_weight(weight: f64) -> bool {
187    weight.is_finite() && (0.0..=1.0).contains(&weight)
188}
189
190impl Link {
191    /// Return `true` if all numeric fields carry finite, domain-valid values.
192    ///
193    /// - `weight` must be finite and in `[0.0, 1.0]`.
194    pub fn is_valid(&self) -> bool {
195        validate_edge_weight(self.weight)
196    }
197}
198
199#[cfg(feature = "serde")]
200#[derive(serde::Serialize, serde::Deserialize)]
201struct LinkRaw {
202    id: Id128,
203    namespace: String,
204    source: Id128,
205    target: Id128,
206    relation: EdgeRelation,
207    properties: BTreeMap<String, PropertyValue>,
208    weight: f64,
209    created_at: Timestamp,
210    updated_at: Timestamp,
211    deleted_at: Option<Timestamp>,
212}
213
214#[cfg(feature = "serde")]
215impl From<Link> for LinkRaw {
216    fn from(l: Link) -> Self {
217        Self {
218            id: l.id,
219            namespace: l.namespace,
220            source: l.source,
221            target: l.target,
222            relation: l.relation,
223            properties: l.properties,
224            weight: l.weight,
225            created_at: l.created_at,
226            updated_at: l.updated_at,
227            deleted_at: l.deleted_at,
228        }
229    }
230}
231
232#[cfg(feature = "serde")]
233impl TryFrom<LinkRaw> for Link {
234    type Error = String;
235
236    fn try_from(raw: LinkRaw) -> Result<Self, Self::Error> {
237        if !raw.weight.is_finite() {
238            return Err(alloc::format!(
239                "Link weight must be finite, got {}",
240                raw.weight
241            ));
242        }
243        if !validate_edge_weight(raw.weight) {
244            return Err(alloc::format!(
245                "Link weight must be in [0.0, 1.0], got {}",
246                raw.weight
247            ));
248        }
249        Ok(Link {
250            id: raw.id,
251            namespace: raw.namespace,
252            source: raw.source,
253            target: raw.target,
254            relation: raw.relation,
255            properties: raw.properties,
256            weight: raw.weight,
257            created_at: raw.created_at,
258            updated_at: raw.updated_at,
259            deleted_at: raw.deleted_at,
260        })
261    }
262}
263
264#[cfg(feature = "serde")]
265impl<'de> serde::Deserialize<'de> for Link {
266    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
267    where
268        D: serde::Deserializer<'de>,
269    {
270        let raw = LinkRaw::deserialize(deserializer)?;
271        Link::try_from(raw).map_err(serde::de::Error::custom)
272    }
273}
274
275/// Property values stored on entities, links, and notes.
276///
277/// Recursive: supports arrays and nested objects for free-form JSON properties
278/// (e.g. `entity_ids[]`, `alternatives_considered[]`).
279#[derive(Clone, Debug, PartialEq)]
280#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
281#[cfg_attr(feature = "serde", serde(untagged))]
282pub enum PropertyValue {
283    String(String),
284    Integer(i64),
285    Float(f64),
286    Boolean(bool),
287    Array(Vec<PropertyValue>),
288    Object(BTreeMap<String, PropertyValue>),
289    Null,
290}
291
292impl fmt::Display for PropertyValue {
293    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
294        match self {
295            Self::String(s) => f.write_str(s),
296            Self::Integer(n) => write!(f, "{n}"),
297            Self::Float(n) => write!(f, "{n}"),
298            Self::Boolean(b) => write!(f, "{b}"),
299            Self::Array(arr) => write!(f, "[{} items]", arr.len()),
300            Self::Object(obj) => write!(f, "{{{} keys}}", obj.len()),
301            Self::Null => f.write_str("null"),
302        }
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309    use crate::{Namespace, Timestamp};
310    #[cfg(feature = "serde")]
311    use alloc::string::ToString;
312
313    #[test]
314    fn edge_weight_validation_covers_numeric_boundaries() {
315        for weight in [-0.0, 0.0, f64::from_bits(1), 0.5, 1.0] {
316            assert!(crate::validate_edge_weight(weight), "{weight:?}");
317        }
318        for weight in [
319            f64::NAN,
320            f64::INFINITY,
321            f64::NEG_INFINITY,
322            -f64::from_bits(1),
323            1.0 + f64::EPSILON,
324        ] {
325            assert!(!crate::validate_edge_weight(weight), "{weight:?}");
326        }
327    }
328
329    #[test]
330    fn entity_with_properties() {
331        let mut props = BTreeMap::new();
332        props.insert("role".into(), PropertyValue::String("engineer".into()));
333        props.insert("age".into(), PropertyValue::Integer(30));
334
335        let entity = Entity {
336            header: Header::new(
337                Id128::from_u128(1),
338                Namespace::local(),
339                Timestamp::from_secs(1700000000),
340            ),
341            kind: EntityKind::Person,
342            entity_type: Some("researcher".into()),
343            name: "Operator".into(),
344            description: None,
345            properties: props,
346            tags: alloc::vec![],
347            deleted_at: None,
348        };
349        assert_eq!(entity.kind, EntityKind::Person);
350        assert_eq!(entity.kind.name(), "person");
351        assert_eq!(entity.entity_type.as_deref(), Some("researcher"));
352        assert_eq!(entity.properties.len(), 2);
353    }
354
355    #[test]
356    fn entity_kind_default_is_concept() {
357        assert_eq!(EntityKind::default(), EntityKind::Concept);
358    }
359
360    #[test]
361    fn entity_kind_display_roundtrip() {
362        for kind in EntityKind::ALL {
363            let s = alloc::format!("{kind}");
364            let parsed = EntityKind::from_str(&s).unwrap();
365            assert_eq!(parsed, kind);
366        }
367    }
368
369    #[test]
370    fn entity_kind_from_str_aliases() {
371        assert_eq!(EntityKind::from_str("doc").unwrap(), EntityKind::Document);
372        assert_eq!(EntityKind::from_str("paper").unwrap(), EntityKind::Document);
373        assert_eq!(
374            EntityKind::from_str("benchmark").unwrap(),
375            EntityKind::Dataset
376        );
377        assert_eq!(EntityKind::from_str("repo").unwrap(), EntityKind::Project);
378        assert_eq!(EntityKind::from_str("author").unwrap(), EntityKind::Person);
379        assert_eq!(EntityKind::from_str("lab").unwrap(), EntityKind::Org);
380        assert_eq!(EntityKind::from_str("art").unwrap(), EntityKind::Artifact);
381        assert_eq!(EntityKind::from_str("svc").unwrap(), EntityKind::Service);
382    }
383
384    #[test]
385    fn entity_kind_artifact_and_service_roundtrip() {
386        assert_eq!(EntityKind::Artifact.name(), "artifact");
387        assert_eq!(EntityKind::Service.name(), "service");
388        assert_eq!(
389            EntityKind::from_str("artifact").unwrap(),
390            EntityKind::Artifact
391        );
392        assert_eq!(
393            EntityKind::from_str("service").unwrap(),
394            EntityKind::Service
395        );
396    }
397
398    #[test]
399    fn entity_kind_all_has_eight_variants() {
400        assert_eq!(EntityKind::ALL.len(), 8);
401        assert!(EntityKind::ALL.contains(&EntityKind::Artifact));
402        assert!(EntityKind::ALL.contains(&EntityKind::Service));
403    }
404
405    #[test]
406    fn entity_kind_unknown_valid_list_includes_new_kinds() {
407        let err = EntityKind::from_str("gadget").unwrap_err();
408        assert!(err.valid.contains(&"artifact"));
409        assert!(err.valid.contains(&"service"));
410    }
411
412    #[test]
413    fn entity_kind_from_str_case_insensitive() {
414        assert_eq!(
415            EntityKind::from_str("CONCEPT").unwrap(),
416            EntityKind::Concept
417        );
418        assert_eq!(EntityKind::from_str("Person").unwrap(), EntityKind::Person);
419    }
420
421    #[test]
422    fn entity_kind_from_str_unknown_errors() {
423        let err = EntityKind::from_str("gadget").unwrap_err();
424        assert_eq!(err.domain, "entity_kind");
425        assert_eq!(err.value, "gadget");
426        assert!(err.valid.contains(&"concept"));
427    }
428
429    #[test]
430    fn link_construction() {
431        let ts = Timestamp::from_secs(1700000000);
432        let link = Link {
433            id: Id128::from_u128(100),
434            namespace: "default".into(),
435            source: Id128::from_u128(1),
436            target: Id128::from_u128(2),
437            relation: EdgeRelation::Extends,
438            properties: BTreeMap::new(),
439            weight: 1.0,
440            created_at: ts,
441            updated_at: ts,
442            deleted_at: None,
443        };
444        assert_eq!(link.relation, EdgeRelation::Extends);
445        assert!(link.is_valid());
446    }
447
448    #[test]
449    fn link_is_valid_rejects_out_of_range() {
450        let ts = Timestamp::from_secs(1700000000);
451        let link = Link {
452            id: Id128::from_u128(100),
453            namespace: "default".into(),
454            source: Id128::from_u128(1),
455            target: Id128::from_u128(2),
456            relation: EdgeRelation::Extends,
457            properties: BTreeMap::new(),
458            weight: 2.0,
459            created_at: ts,
460            updated_at: ts,
461            deleted_at: None,
462        };
463        assert!(!link.is_valid());
464    }
465
466    #[cfg(feature = "serde")]
467    #[test]
468    fn link_serde_rejects_weight_above_one() {
469        let json = serde_json::json!({
470            "id": "00000000-0000-0000-0000-000000000064",
471            "namespace": "default",
472            "source": "00000000-0000-0000-0000-000000000001",
473            "target": "00000000-0000-0000-0000-000000000002",
474            "relation": "extends",
475            "properties": {},
476            "weight": 2.0,
477            "created_at": 1700000000000000_u64,
478            "updated_at": 1700000000000000_u64,
479            "deleted_at": null
480        });
481        let result: Result<Link, _> = serde_json::from_value(json);
482        assert!(result.is_err());
483        let err = result.unwrap_err().to_string();
484        assert!(
485            err.contains("[0.0, 1.0]"),
486            "error should mention range: {err}"
487        );
488    }
489
490    #[cfg(feature = "serde")]
491    #[test]
492    fn link_serde_rejects_negative_weight() {
493        let json = serde_json::json!({
494            "id": "00000000-0000-0000-0000-000000000064",
495            "namespace": "default",
496            "source": "00000000-0000-0000-0000-000000000001",
497            "target": "00000000-0000-0000-0000-000000000002",
498            "relation": "extends",
499            "properties": {},
500            "weight": -0.1,
501            "created_at": 1700000000000000_u64,
502            "updated_at": 1700000000000000_u64,
503            "deleted_at": null
504        });
505        let result: Result<Link, _> = serde_json::from_value(json);
506        assert!(result.is_err());
507    }
508
509    #[cfg(feature = "serde")]
510    #[test]
511    fn link_serde_accepts_valid_weight() {
512        let json = serde_json::json!({
513            "id": "00000000-0000-0000-0000-000000000064",
514            "namespace": "default",
515            "source": "00000000-0000-0000-0000-000000000001",
516            "target": "00000000-0000-0000-0000-000000000002",
517            "relation": "extends",
518            "properties": {},
519            "weight": 0.75,
520            "created_at": 1700000000000000_u64,
521            "updated_at": 1700000000000000_u64,
522            "deleted_at": null
523        });
524        let link: Link = serde_json::from_value(json).expect("valid weight should deserialize");
525        assert_eq!(link.weight, 0.75);
526    }
527}