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 (ADR-001).
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    pub const ALL: [Self; 8] = [
45        Self::Concept,
46        Self::Document,
47        Self::Dataset,
48        Self::Project,
49        Self::Person,
50        Self::Org,
51        Self::Artifact,
52        Self::Service,
53    ];
54
55    pub const fn name(self) -> &'static str {
56        match self {
57            Self::Concept => "concept",
58            Self::Document => "document",
59            Self::Dataset => "dataset",
60            Self::Project => "project",
61            Self::Person => "person",
62            Self::Org => "org",
63            Self::Artifact => "artifact",
64            Self::Service => "service",
65        }
66    }
67}
68
69impl fmt::Display for EntityKind {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        f.write_str(self.name())
72    }
73}
74
75const ENTITY_KIND_VALID: &[&str] = &[
76    "concept", "document", "dataset", "project", "person", "org", "artifact", "service",
77];
78
79impl FromStr for EntityKind {
80    type Err = crate::error::UnknownVariant;
81
82    fn from_str(s: &str) -> Result<Self, Self::Err> {
83        match s.trim().to_ascii_lowercase().as_str() {
84            "concept" => Ok(Self::Concept),
85            "document" | "doc" | "paper" => Ok(Self::Document),
86            "dataset" | "data" | "benchmark" => Ok(Self::Dataset),
87            "project" | "repo" | "crate" | "library" | "lib" => Ok(Self::Project),
88            "person" | "author" | "researcher" => Ok(Self::Person),
89            "org" | "organization" | "organisation" | "lab" | "company" => Ok(Self::Org),
90            "artifact" | "art" => Ok(Self::Artifact),
91            "service" | "svc" => Ok(Self::Service),
92            other => Err(crate::error::UnknownVariant::new(
93                "entity_kind",
94                other,
95                ENTITY_KIND_VALID,
96            )),
97        }
98    }
99}
100
101/// A graph node with a type, display name, and key-value properties.
102#[derive(Clone, Debug)]
103#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
104pub struct Entity {
105    #[cfg_attr(feature = "serde", serde(flatten))]
106    pub header: Header,
107    pub kind: EntityKind,
108    /// Pack-governed subtype token (e.g. `"paper"`, `"snapshot"`). Never stored
109    /// raw in `properties` — queries compile this to `entities.entity_type = ?`.
110    pub entity_type: Option<String>,
111    pub name: String,
112    pub description: Option<String>,
113    pub properties: BTreeMap<String, PropertyValue>,
114    pub tags: Vec<String>,
115    pub deleted_at: Option<Timestamp>,
116}
117
118/// A directed, typed edge between two entities (or cross-substrate nodes).
119#[derive(Clone, Debug)]
120#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
121pub struct Link {
122    pub id: Id128,
123    pub namespace: String,
124    pub source: Id128,
125    pub target: Id128,
126    pub relation: EdgeRelation,
127    pub properties: BTreeMap<String, PropertyValue>,
128    pub weight: f64,
129    pub created_at: Timestamp,
130    pub updated_at: Timestamp,
131    pub deleted_at: Option<Timestamp>,
132}
133
134/// Property values stored on entities, links, and notes.
135///
136/// Recursive: supports arrays and nested objects for free-form JSON properties
137/// (e.g. `entity_ids[]`, `alternatives_considered[]` per ADR-019).
138#[derive(Clone, Debug, PartialEq)]
139#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
140#[cfg_attr(feature = "serde", serde(untagged))]
141pub enum PropertyValue {
142    String(String),
143    Integer(i64),
144    Float(f64),
145    Boolean(bool),
146    Array(Vec<PropertyValue>),
147    Object(BTreeMap<String, PropertyValue>),
148    Null,
149}
150
151impl fmt::Display for PropertyValue {
152    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
153        match self {
154            Self::String(s) => f.write_str(s),
155            Self::Integer(n) => write!(f, "{n}"),
156            Self::Float(n) => write!(f, "{n}"),
157            Self::Boolean(b) => write!(f, "{b}"),
158            Self::Array(arr) => write!(f, "[{} items]", arr.len()),
159            Self::Object(obj) => write!(f, "{{{} keys}}", obj.len()),
160            Self::Null => f.write_str("null"),
161        }
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use crate::{Namespace, Timestamp};
169
170    #[test]
171    fn entity_with_properties() {
172        let mut props = BTreeMap::new();
173        props.insert("role".into(), PropertyValue::String("engineer".into()));
174        props.insert("age".into(), PropertyValue::Integer(30));
175
176        let entity = Entity {
177            header: Header::new(
178                Id128::from_u128(1),
179                Namespace::local(),
180                Timestamp::from_secs(1700000000),
181            ),
182            kind: EntityKind::Person,
183            entity_type: Some("researcher".into()),
184            name: "Ocean".into(),
185            description: None,
186            properties: props,
187            tags: alloc::vec![],
188            deleted_at: None,
189        };
190        assert_eq!(entity.kind, EntityKind::Person);
191        assert_eq!(entity.kind.name(), "person");
192        assert_eq!(entity.entity_type.as_deref(), Some("researcher"));
193        assert_eq!(entity.properties.len(), 2);
194    }
195
196    #[test]
197    fn entity_kind_default_is_concept() {
198        assert_eq!(EntityKind::default(), EntityKind::Concept);
199    }
200
201    #[test]
202    fn entity_kind_display_roundtrip() {
203        for kind in EntityKind::ALL {
204            let s = alloc::format!("{kind}");
205            let parsed = EntityKind::from_str(&s).unwrap();
206            assert_eq!(parsed, kind);
207        }
208    }
209
210    #[test]
211    fn entity_kind_from_str_aliases() {
212        assert_eq!(EntityKind::from_str("doc").unwrap(), EntityKind::Document);
213        assert_eq!(EntityKind::from_str("paper").unwrap(), EntityKind::Document);
214        assert_eq!(
215            EntityKind::from_str("benchmark").unwrap(),
216            EntityKind::Dataset
217        );
218        assert_eq!(EntityKind::from_str("repo").unwrap(), EntityKind::Project);
219        assert_eq!(EntityKind::from_str("author").unwrap(), EntityKind::Person);
220        assert_eq!(EntityKind::from_str("lab").unwrap(), EntityKind::Org);
221        assert_eq!(EntityKind::from_str("art").unwrap(), EntityKind::Artifact);
222        assert_eq!(EntityKind::from_str("svc").unwrap(), EntityKind::Service);
223    }
224
225    #[test]
226    fn entity_kind_artifact_and_service_roundtrip() {
227        assert_eq!(EntityKind::Artifact.name(), "artifact");
228        assert_eq!(EntityKind::Service.name(), "service");
229        assert_eq!(
230            EntityKind::from_str("artifact").unwrap(),
231            EntityKind::Artifact
232        );
233        assert_eq!(
234            EntityKind::from_str("service").unwrap(),
235            EntityKind::Service
236        );
237    }
238
239    #[test]
240    fn entity_kind_all_has_eight_variants() {
241        assert_eq!(EntityKind::ALL.len(), 8);
242        assert!(EntityKind::ALL.contains(&EntityKind::Artifact));
243        assert!(EntityKind::ALL.contains(&EntityKind::Service));
244    }
245
246    #[test]
247    fn entity_kind_unknown_valid_list_includes_new_kinds() {
248        let err = EntityKind::from_str("gadget").unwrap_err();
249        assert!(err.valid.contains(&"artifact"));
250        assert!(err.valid.contains(&"service"));
251    }
252
253    #[test]
254    fn entity_kind_from_str_case_insensitive() {
255        assert_eq!(
256            EntityKind::from_str("CONCEPT").unwrap(),
257            EntityKind::Concept
258        );
259        assert_eq!(EntityKind::from_str("Person").unwrap(), EntityKind::Person);
260    }
261
262    #[test]
263    fn entity_kind_from_str_unknown_errors() {
264        let err = EntityKind::from_str("gadget").unwrap_err();
265        assert_eq!(err.domain, "entity_kind");
266        assert_eq!(err.value, "gadget");
267        assert!(err.valid.contains(&"concept"));
268    }
269
270    #[test]
271    fn link_construction() {
272        let ts = Timestamp::from_secs(1700000000);
273        let link = Link {
274            id: Id128::from_u128(100),
275            namespace: "default".into(),
276            source: Id128::from_u128(1),
277            target: Id128::from_u128(2),
278            relation: EdgeRelation::Extends,
279            properties: BTreeMap::new(),
280            weight: 1.0,
281            created_at: ts,
282            updated_at: ts,
283            deleted_at: None,
284        };
285        assert_eq!(link.relation, EdgeRelation::Extends);
286    }
287}