Skip to main content

khive_types/
entity_type.rs

1//! EntityTypeRegistry — validates and normalises `(EntityKind, entity_type)` pairs.
2//!
3//! Canonical, dependency-light home for the closed entity-subtype taxonomy
4//! (#571): both KG create validation and schedule replay validation resolve
5//! against this single registry instead of maintaining separate copies.
6
7use alloc::collections::BTreeMap;
8use alloc::format;
9use alloc::string::{String, ToString};
10use alloc::vec::Vec;
11use core::fmt;
12
13use crate::entity::EntityKind;
14
15/// Normalise a raw `entity_type` string to canonical snake_case.
16///
17/// Pipeline: trim → lowercase → runs of separators (space, hyphen, underscore)
18/// collapsed to a single `_` → leading/trailing `_` stripped.
19///
20/// This implements the ADR-001:106 write-time normalisation step that precedes
21/// alias resolution. `pub` (not `pub(crate)`) so callers outside this crate —
22/// e.g. `khive-pack-kg`'s alias-substitution reporting — can compare a raw
23/// value against this SAME cosmetic normalisation instead of maintaining a
24/// separate, drift-prone copy of the rule.
25pub fn to_snake_case(s: &str) -> String {
26    let mut out = String::with_capacity(s.len());
27    let mut prev_sep = true; // treat start as separator so leading _ are stripped
28    for ch in s.chars() {
29        if ch == ' ' || ch == '-' || ch == '_' {
30            if !prev_sep && !out.is_empty() {
31                out.push('_');
32                prev_sep = true;
33            }
34        } else {
35            out.push(ch.to_ascii_lowercase());
36            prev_sep = false;
37        }
38    }
39    if out.ends_with('_') {
40        out.pop();
41    }
42    out
43}
44
45/// One entry in the registry: a canonical subtype name for a specific kind,
46/// together with any accepted aliases.
47#[derive(Clone, Debug)]
48pub struct EntityTypeDef {
49    /// The entity kind this subtype belongs to.
50    pub kind: EntityKind,
51    /// Canonical name that is written to the DB.
52    pub type_name: &'static str,
53    /// Alternative spellings that are accepted at the wire level but
54    /// normalised to `type_name` before storage.
55    pub aliases: &'static [&'static str],
56}
57
58/// Static table of built-in subtypes (non-exhaustive; packs may extend).
59static BUILTIN_DEFS: &[EntityTypeDef] = &[
60    // ── Document ────────────────────────────────────────────────────────────
61    EntityTypeDef {
62        kind: EntityKind::Document,
63        type_name: "paper",
64        aliases: &["preprint", "article"],
65    },
66    EntityTypeDef {
67        kind: EntityKind::Document,
68        type_name: "report",
69        aliases: &[],
70    },
71    EntityTypeDef {
72        kind: EntityKind::Document,
73        type_name: "blog_post",
74        aliases: &["blog"],
75    },
76    EntityTypeDef {
77        kind: EntityKind::Document,
78        type_name: "book",
79        aliases: &[],
80    },
81    EntityTypeDef {
82        kind: EntityKind::Document,
83        type_name: "specification",
84        aliases: &["spec"],
85    },
86    EntityTypeDef {
87        kind: EntityKind::Document,
88        type_name: "documentation",
89        aliases: &["docs"],
90    },
91    EntityTypeDef {
92        kind: EntityKind::Document,
93        type_name: "thesis",
94        aliases: &[],
95    },
96    EntityTypeDef {
97        kind: EntityKind::Document,
98        type_name: "page",
99        aliases: &["web_page"],
100    },
101    // ADR-191: any fetched body other than an HTML page — robots.txt, a
102    // sitemap, a feed, a JSON body, a PDF, an alternate rendering served at
103    // its own URL.
104    EntityTypeDef {
105        kind: EntityKind::Document,
106        type_name: "resource",
107        aliases: &[],
108    },
109    EntityTypeDef {
110        kind: EntityKind::Document,
111        type_name: "research_report",
112        aliases: &[],
113    },
114    // ── Concept ─────────────────────────────────────────────────────────────
115    EntityTypeDef {
116        kind: EntityKind::Concept,
117        type_name: "algorithm",
118        aliases: &["algo"],
119    },
120    // ── Formal math ─────────────────────────────────────────────────────────
121    EntityTypeDef {
122        kind: EntityKind::Concept,
123        type_name: "theorem",
124        aliases: &["lemma", "proposition", "corollary"],
125    },
126    EntityTypeDef {
127        kind: EntityKind::Concept,
128        type_name: "definition",
129        aliases: &["def"],
130    },
131    EntityTypeDef {
132        kind: EntityKind::Concept,
133        type_name: "structure",
134        aliases: &["inductive", "struct", "class"],
135    },
136    EntityTypeDef {
137        kind: EntityKind::Concept,
138        type_name: "instance",
139        aliases: &[],
140    },
141    EntityTypeDef {
142        kind: EntityKind::Concept,
143        type_name: "axiom",
144        aliases: &[],
145    },
146    EntityTypeDef {
147        kind: EntityKind::Concept,
148        type_name: "goal",
149        aliases: &["proof_goal"],
150    },
151    EntityTypeDef {
152        kind: EntityKind::Concept,
153        type_name: "technique",
154        aliases: &[],
155    },
156    EntityTypeDef {
157        kind: EntityKind::Concept,
158        type_name: "architecture",
159        aliases: &["arch"],
160    },
161    EntityTypeDef {
162        kind: EntityKind::Concept,
163        // "model" is the alias; canonical name is "model_family"
164        // to distinguish from a Dataset or Artifact trained model instance.
165        type_name: "model_family",
166        aliases: &["model"],
167    },
168    EntityTypeDef {
169        kind: EntityKind::Concept,
170        type_name: "theory",
171        aliases: &[],
172    },
173    EntityTypeDef {
174        kind: EntityKind::Concept,
175        type_name: "research_gap",
176        aliases: &["gap"],
177    },
178    EntityTypeDef {
179        kind: EntityKind::Concept,
180        type_name: "design_pattern",
181        aliases: &["pattern"],
182    },
183    EntityTypeDef {
184        kind: EntityKind::Concept,
185        type_name: "mathematical_operation",
186        aliases: &["math_op"],
187    },
188    EntityTypeDef {
189        kind: EntityKind::Concept,
190        type_name: "metric",
191        aliases: &[],
192    },
193    EntityTypeDef {
194        kind: EntityKind::Concept,
195        type_name: "objective",
196        aliases: &["loss"],
197    },
198    // ── Code (ADR-085) ──────────────────────────────────────────────────────
199    // "struct" and "class" are not registered as aliases here: formal-math
200    // "structure" already owns them (see above), and ADR-085 requires
201    // ingesters to write the canonical code tokens instead.
202    EntityTypeDef {
203        kind: EntityKind::Concept,
204        type_name: "module",
205        aliases: &["mod", "namespace"],
206    },
207    EntityTypeDef {
208        kind: EntityKind::Concept,
209        type_name: "function",
210        aliases: &["fn", "func", "method"],
211    },
212    EntityTypeDef {
213        kind: EntityKind::Concept,
214        type_name: "datatype",
215        aliases: &["enum", "record", "type_alias"],
216    },
217    EntityTypeDef {
218        kind: EntityKind::Concept,
219        type_name: "interface",
220        aliases: &["trait", "protocol"],
221    },
222    // ── Dataset ──────────────────────────────────────────────────────────────
223    // benchmark belongs to Dataset, not Concept (it evaluates models, it is not itself a concept).
224    EntityTypeDef {
225        kind: EntityKind::Dataset,
226        type_name: "benchmark",
227        aliases: &[],
228    },
229    EntityTypeDef {
230        kind: EntityKind::Dataset,
231        type_name: "corpus",
232        aliases: &[],
233    },
234    EntityTypeDef {
235        kind: EntityKind::Dataset,
236        type_name: "training_set",
237        aliases: &["train_set"],
238    },
239    EntityTypeDef {
240        kind: EntityKind::Dataset,
241        type_name: "evaluation_set",
242        aliases: &["eval_set"],
243    },
244    EntityTypeDef {
245        kind: EntityKind::Dataset,
246        type_name: "test_set",
247        aliases: &[],
248    },
249    EntityTypeDef {
250        kind: EntityKind::Dataset,
251        type_name: "synthetic_dataset",
252        aliases: &["synthetic"],
253    },
254    // ── Project ─────────────────────────────────────────────────────────────
255    // Project subtypes: library, framework, tool, application, repository.
256    // "service" and "svc" are omitted — EntityKind::Service handles running
257    // instances; a service codebase repo is Project + application or tool.
258    EntityTypeDef {
259        kind: EntityKind::Project,
260        type_name: "library",
261        aliases: &["lib"],
262    },
263    EntityTypeDef {
264        kind: EntityKind::Project,
265        type_name: "framework",
266        aliases: &[],
267    },
268    EntityTypeDef {
269        kind: EntityKind::Project,
270        type_name: "tool",
271        aliases: &[],
272    },
273    EntityTypeDef {
274        kind: EntityKind::Project,
275        type_name: "application",
276        aliases: &["app"],
277    },
278    EntityTypeDef {
279        kind: EntityKind::Project,
280        type_name: "repository",
281        aliases: &["repo"],
282    },
283    // ── Org ──────────────────────────────────────────────────────────────────
284    EntityTypeDef {
285        kind: EntityKind::Org,
286        type_name: "academic_institution",
287        aliases: &["university", "uni"],
288    },
289    EntityTypeDef {
290        kind: EntityKind::Org,
291        type_name: "company",
292        aliases: &[],
293    },
294    EntityTypeDef {
295        kind: EntityKind::Org,
296        type_name: "research_lab",
297        aliases: &["lab"],
298    },
299    EntityTypeDef {
300        kind: EntityKind::Org,
301        type_name: "nonprofit",
302        aliases: &[],
303    },
304    EntityTypeDef {
305        kind: EntityKind::Org,
306        type_name: "government_agency",
307        aliases: &["gov_agency"],
308    },
309    EntityTypeDef {
310        kind: EntityKind::Org,
311        type_name: "consortium",
312        aliases: &[],
313    },
314    EntityTypeDef {
315        kind: EntityKind::Org,
316        type_name: "standards_body",
317        aliases: &[],
318    },
319    // ── Artifact ─────────────────────────────────────────────────────────────
320    EntityTypeDef {
321        kind: EntityKind::Artifact,
322        type_name: "checkpoint",
323        aliases: &["ckpt"],
324    },
325    EntityTypeDef {
326        kind: EntityKind::Artifact,
327        type_name: "snapshot",
328        aliases: &[],
329    },
330    EntityTypeDef {
331        kind: EntityKind::Artifact,
332        type_name: "export",
333        aliases: &[],
334    },
335    EntityTypeDef {
336        kind: EntityKind::Artifact,
337        type_name: "embedding_index",
338        aliases: &["embed_index"],
339    },
340    EntityTypeDef {
341        kind: EntityKind::Artifact,
342        type_name: "state_bundle",
343        aliases: &[],
344    },
345    EntityTypeDef {
346        kind: EntityKind::Artifact,
347        type_name: "profile",
348        aliases: &[],
349    },
350    // ── Service ──────────────────────────────────────────────────────────────
351    // Service subtypes: inference_engine, retrieval_engine,
352    // embedding_engine, api, database, search_engine, mcp_server, site.
353    EntityTypeDef {
354        kind: EntityKind::Service,
355        type_name: "inference_engine",
356        aliases: &[],
357    },
358    EntityTypeDef {
359        kind: EntityKind::Service,
360        type_name: "retrieval_engine",
361        aliases: &[],
362    },
363    EntityTypeDef {
364        kind: EntityKind::Service,
365        type_name: "embedding_engine",
366        aliases: &[],
367    },
368    EntityTypeDef {
369        kind: EntityKind::Service,
370        type_name: "api",
371        aliases: &["endpoint"],
372    },
373    EntityTypeDef {
374        kind: EntityKind::Service,
375        type_name: "database",
376        aliases: &["db"],
377    },
378    EntityTypeDef {
379        kind: EntityKind::Service,
380        type_name: "search_engine",
381        aliases: &[],
382    },
383    EntityTypeDef {
384        kind: EntityKind::Service,
385        type_name: "mcp_server",
386        aliases: &["mcp"],
387    },
388    EntityTypeDef {
389        kind: EntityKind::Service,
390        type_name: "site",
391        aliases: &["origin"],
392    },
393    // Person  — no standard subtypes (roles are metadata, not subtypes).
394];
395
396/// Resolved output of [`EntityTypeRegistry::resolve`].
397#[derive(Clone, Debug, PartialEq, Eq)]
398pub struct ResolvedEntityType {
399    /// The canonical `EntityKind` for the resolved combination.
400    pub kind: EntityKind,
401    /// The canonical type name to store, or `None` when no `entity_type` was
402    /// supplied and none was inferred.
403    pub entity_type: Option<String>,
404}
405
406/// Error produced by [`EntityTypeRegistry::resolve`].
407///
408/// Dependency-light: `khive-types` cannot depend on `khive-runtime`, so
409/// callers map this to their own error type at the pack boundary.
410#[derive(Clone, Debug, PartialEq, Eq)]
411pub enum EntityTypeError {
412    /// The raw type name exists, but belongs to a different `EntityKind`.
413    WrongKind {
414        raw_type: String,
415        actual_kind: &'static str,
416        expected_kind: &'static str,
417        valid: String,
418    },
419    /// The raw type name is not registered for any kind.
420    UnknownType {
421        raw_type: String,
422        kind: &'static str,
423        valid: String,
424    },
425}
426
427impl fmt::Display for EntityTypeError {
428    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
429        match self {
430            Self::WrongKind {
431                raw_type,
432                actual_kind,
433                expected_kind,
434                valid,
435            } => write!(
436                f,
437                "entity_type {raw_type:?} belongs to {actual_kind:?}, not {expected_kind:?}; \
438                 valid types for {expected_kind:?}: {valid}",
439            ),
440            Self::UnknownType {
441                raw_type,
442                kind,
443                valid,
444            } => write!(
445                f,
446                "unknown entity_type {raw_type:?} for {kind:?}; valid: {valid}"
447            ),
448        }
449    }
450}
451
452#[cfg(feature = "std")]
453impl std::error::Error for EntityTypeError {}
454
455/// Registry for `(EntityKind, entity_type)` pair validation and alias normalisation.
456#[derive(Clone)]
457pub struct EntityTypeRegistry {
458    /// `alias_or_name (lowercase) → def index`.  Covers both canonical names
459    /// and all registered aliases.
460    lookup: BTreeMap<String, usize>,
461    defs: Vec<EntityTypeDef>,
462}
463
464impl EntityTypeRegistry {
465    /// Build a fresh registry from the supplied definitions.
466    ///
467    /// Every lookup key (kind-qualified and bare, canonical and alias) is
468    /// routed through `to_snake_case` before insertion — the same
469    /// normalisation `resolve` applies to incoming wire values (ADR-001:106).
470    /// Two differently-cased/separated spellings that normalise to the same
471    /// key therefore land on the same registry slot instead of silently
472    /// shadowing one another under distinct raw keys.
473    pub fn new(defs: impl IntoIterator<Item = EntityTypeDef>) -> Self {
474        let defs: Vec<EntityTypeDef> = defs.into_iter().collect();
475        let mut lookup: BTreeMap<String, usize> = BTreeMap::new();
476        for (idx, def) in defs.iter().enumerate() {
477            let canonical_type = to_snake_case(def.type_name);
478            let canonical_key = format!("{}:{}", def.kind.name(), canonical_type);
479            lookup.insert(canonical_key, idx);
480            // Bare name without kind prefix — only insert when unambiguous.
481            lookup.entry(canonical_type).or_insert(idx);
482            for alias in def.aliases {
483                let normalised_alias = to_snake_case(alias);
484                let kind_alias_key = format!("{}:{}", def.kind.name(), normalised_alias);
485                lookup.insert(kind_alias_key, idx);
486                lookup.entry(normalised_alias).or_insert(idx);
487            }
488        }
489        Self { lookup, defs }
490    }
491
492    /// Return the built-in registry with all static subtype definitions.
493    pub fn builtin() -> Self {
494        Self::new(BUILTIN_DEFS.iter().cloned())
495    }
496
497    /// Derive a registry that includes all built-in subtypes plus the
498    /// caller-supplied extras (used by packs that extend the vocabulary).
499    pub fn with_extra(extra: impl IntoIterator<Item = EntityTypeDef>) -> Self {
500        let defs: Vec<EntityTypeDef> = BUILTIN_DEFS.iter().cloned().chain(extra).collect();
501        Self::new(defs)
502    }
503
504    /// Definitions in this composed registry, including canonical names and aliases.
505    pub fn definitions(&self) -> &[EntityTypeDef] {
506        &self.defs
507    }
508
509    /// Boot-time collision check across the composed registry `with_extra`
510    /// builds: built-in defs plus every caller-supplied `(owner, def)` extra.
511    ///
512    /// `new`/`register` populate a single kind-qualified lookup keyspace
513    /// (`"{kind}:{canonical_name}"` and `"{kind}:{alias}"`) with a hard
514    /// `insert` — the later write silently wins. Two independently loaded
515    /// owners can therefore disagree on what a given key resolves to, and
516    /// the winner depends on registration order. ADR-001's registry-ownership
517    /// contract instead requires this to fail at boot: "same `(base_kind,
518    /// canonical_name)` from two different packs = boot error" and "an alias
519    /// collision = boot error." This walks the same keyspace `new`/`register`
520    /// populate and returns the first collision found, naming both
521    /// contributing owners, instead of silently picking a winner.
522    ///
523    /// `extras` is `(owner_label, def)` for pack-declared entries only — the
524    /// built-in table is checked automatically, attributed to the label
525    /// `"builtin"`.
526    ///
527    /// Every canonical and alias key is routed through the same
528    /// `to_snake_case` normalisation `new`/`register` apply before
529    /// insertion (ADR-001:104-107): two spellings that only differ in case
530    /// or separator style (e.g. `"Architecture Decision Record"` vs.
531    /// `"architecture-decision-record"`) collide here exactly as they would
532    /// collide in the composed registry's lookup keyspace.
533    pub fn check_extra_collisions<'a>(
534        extras: impl IntoIterator<Item = (&'a str, &'a EntityTypeDef)>,
535    ) -> Result<(), String> {
536        let mut all: Vec<(&'a str, &'a EntityTypeDef)> =
537            BUILTIN_DEFS.iter().map(|def| ("builtin", def)).collect();
538        all.extend(extras);
539
540        let mut seen: BTreeMap<String, (&'a str, String)> = BTreeMap::new();
541        for (owner, def) in all {
542            let canonical_type = to_snake_case(def.type_name);
543            let canonical_key = format!("{}:{}", def.kind.name(), canonical_type);
544            if let Some((first_owner, first_type)) = seen.get(&canonical_key) {
545                if *first_owner != owner {
546                    return Err(format!(
547                        "duplicate entity_type {canonical_key:?}: claimed by both \
548                         {first_owner:?} and {owner:?}"
549                    ));
550                }
551                if *first_type != canonical_type {
552                    return Err(format!(
553                        "entity_type alias {canonical_key:?}: claimed by both {first_owner:?} \
554                         (canonical {first_type:?}) and {owner:?} (canonical {canonical_type:?})"
555                    ));
556                }
557            } else {
558                seen.insert(canonical_key, (owner, canonical_type.clone()));
559            }
560
561            for alias in def.aliases {
562                let normalised_alias = to_snake_case(alias);
563                let alias_key = format!("{}:{}", def.kind.name(), normalised_alias);
564                if let Some((first_owner, first_type)) = seen.get(&alias_key) {
565                    if *first_owner != owner || *first_type != canonical_type {
566                        return Err(format!(
567                            "entity_type alias {alias_key:?}: claimed by both {first_owner:?} \
568                             (canonical {first_type:?}) and {owner:?} (canonical {canonical_type:?})",
569                        ));
570                    }
571                } else {
572                    seen.insert(alias_key, (owner, canonical_type.clone()));
573                }
574            }
575        }
576        Ok(())
577    }
578
579    /// Register additional subtypes into an existing registry clone.
580    ///
581    /// Mirrors [`Self::new`]'s normalisation: every key is routed through
582    /// `to_snake_case` before insertion.
583    pub fn register(&mut self, def: EntityTypeDef) {
584        let idx = self.defs.len();
585        let canonical_type = to_snake_case(def.type_name);
586        let canonical_key = format!("{}:{}", def.kind.name(), canonical_type);
587        self.lookup.insert(canonical_key, idx);
588        self.lookup.entry(canonical_type).or_insert(idx);
589        for alias in def.aliases {
590            let normalised_alias = to_snake_case(alias);
591            let kind_alias_key = format!("{}:{}", def.kind.name(), normalised_alias);
592            self.lookup.insert(kind_alias_key, idx);
593            self.lookup.entry(normalised_alias).or_insert(idx);
594        }
595        self.defs.push(def);
596    }
597
598    /// Validate and normalise a `(kind, entity_type)` wire pair.
599    pub fn resolve(
600        &self,
601        kind: EntityKind,
602        entity_type: Option<&str>,
603    ) -> Result<ResolvedEntityType, EntityTypeError> {
604        let Some(raw_type) = entity_type else {
605            return Ok(ResolvedEntityType {
606                kind,
607                entity_type: None,
608            });
609        };
610
611        let normalised = to_snake_case(raw_type.trim());
612
613        // Try kind-qualified lookup first (unambiguous).
614        let kind_key = format!("{}:{}", kind.name(), normalised);
615        if let Some(&idx) = self.lookup.get(&kind_key) {
616            let def = &self.defs[idx];
617            return Ok(ResolvedEntityType {
618                kind,
619                entity_type: Some(def.type_name.to_string()),
620            });
621        }
622
623        // Try bare lookup — only valid when the bare name belongs to this kind.
624        if let Some(&idx) = self.lookup.get(&normalised) {
625            let def = &self.defs[idx];
626            if def.kind == kind {
627                return Ok(ResolvedEntityType {
628                    kind,
629                    entity_type: Some(def.type_name.to_string()),
630                });
631            }
632            // The name exists but belongs to a different kind.
633            return Err(EntityTypeError::WrongKind {
634                raw_type: raw_type.to_string(),
635                actual_kind: def.kind.name(),
636                expected_kind: kind.name(),
637                valid: self.valid_types_for(kind),
638            });
639        }
640
641        // Not found at all.
642        Err(EntityTypeError::UnknownType {
643            raw_type: raw_type.to_string(),
644            kind: kind.name(),
645            valid: self.valid_types_for(kind),
646        })
647    }
648
649    /// Comma-separated list of canonical type names valid for `kind`.
650    pub fn valid_types_for(&self, kind: EntityKind) -> String {
651        let mut names: Vec<&str> = self
652            .defs
653            .iter()
654            .filter(|d| d.kind == kind)
655            .map(|d| d.type_name)
656            .collect();
657        names.sort_unstable();
658        if names.is_empty() {
659            "(none registered)".to_string()
660        } else {
661            names.join(" | ")
662        }
663    }
664}
665
666// ── Module-level lazy global ─────────────────────────────────────────────────
667
668#[cfg(feature = "std")]
669static GLOBAL_REGISTRY: std::sync::OnceLock<EntityTypeRegistry> = std::sync::OnceLock::new();
670
671#[cfg(feature = "std")]
672impl EntityTypeRegistry {
673    /// Return a reference to the module-level built-in registry.
674    pub fn global() -> &'static EntityTypeRegistry {
675        GLOBAL_REGISTRY.get_or_init(EntityTypeRegistry::builtin)
676    }
677}
678
679// ── Tests ────────────────────────────────────────────────────────────────────
680
681#[cfg(test)]
682#[path = "entity_type_tests.rs"]
683mod tests;