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, _)) = 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            } else {
552                seen.insert(canonical_key, (owner, canonical_type.clone()));
553            }
554
555            for alias in def.aliases {
556                let normalised_alias = to_snake_case(alias);
557                let alias_key = format!("{}:{}", def.kind.name(), normalised_alias);
558                if let Some((first_owner, first_type)) = seen.get(&alias_key) {
559                    if *first_owner != owner || *first_type != canonical_type {
560                        return Err(format!(
561                            "entity_type alias {alias_key:?}: claimed by both {first_owner:?} \
562                             (canonical {first_type:?}) and {owner:?} (canonical {canonical_type:?})",
563                        ));
564                    }
565                } else {
566                    seen.insert(alias_key, (owner, canonical_type.clone()));
567                }
568            }
569        }
570        Ok(())
571    }
572
573    /// Register additional subtypes into an existing registry clone.
574    ///
575    /// Mirrors [`Self::new`]'s normalisation: every key is routed through
576    /// `to_snake_case` before insertion.
577    pub fn register(&mut self, def: EntityTypeDef) {
578        let idx = self.defs.len();
579        let canonical_type = to_snake_case(def.type_name);
580        let canonical_key = format!("{}:{}", def.kind.name(), canonical_type);
581        self.lookup.insert(canonical_key, idx);
582        self.lookup.entry(canonical_type).or_insert(idx);
583        for alias in def.aliases {
584            let normalised_alias = to_snake_case(alias);
585            let kind_alias_key = format!("{}:{}", def.kind.name(), normalised_alias);
586            self.lookup.insert(kind_alias_key, idx);
587            self.lookup.entry(normalised_alias).or_insert(idx);
588        }
589        self.defs.push(def);
590    }
591
592    /// Validate and normalise a `(kind, entity_type)` wire pair.
593    pub fn resolve(
594        &self,
595        kind: EntityKind,
596        entity_type: Option<&str>,
597    ) -> Result<ResolvedEntityType, EntityTypeError> {
598        let Some(raw_type) = entity_type else {
599            return Ok(ResolvedEntityType {
600                kind,
601                entity_type: None,
602            });
603        };
604
605        let normalised = to_snake_case(raw_type.trim());
606
607        // Try kind-qualified lookup first (unambiguous).
608        let kind_key = format!("{}:{}", kind.name(), normalised);
609        if let Some(&idx) = self.lookup.get(&kind_key) {
610            let def = &self.defs[idx];
611            return Ok(ResolvedEntityType {
612                kind,
613                entity_type: Some(def.type_name.to_string()),
614            });
615        }
616
617        // Try bare lookup — only valid when the bare name belongs to this kind.
618        if let Some(&idx) = self.lookup.get(&normalised) {
619            let def = &self.defs[idx];
620            if def.kind == kind {
621                return Ok(ResolvedEntityType {
622                    kind,
623                    entity_type: Some(def.type_name.to_string()),
624                });
625            }
626            // The name exists but belongs to a different kind.
627            return Err(EntityTypeError::WrongKind {
628                raw_type: raw_type.to_string(),
629                actual_kind: def.kind.name(),
630                expected_kind: kind.name(),
631                valid: self.valid_types_for(kind),
632            });
633        }
634
635        // Not found at all.
636        Err(EntityTypeError::UnknownType {
637            raw_type: raw_type.to_string(),
638            kind: kind.name(),
639            valid: self.valid_types_for(kind),
640        })
641    }
642
643    /// Comma-separated list of canonical type names valid for `kind`.
644    pub fn valid_types_for(&self, kind: EntityKind) -> String {
645        let mut names: Vec<&str> = self
646            .defs
647            .iter()
648            .filter(|d| d.kind == kind)
649            .map(|d| d.type_name)
650            .collect();
651        names.sort_unstable();
652        if names.is_empty() {
653            "(none registered)".to_string()
654        } else {
655            names.join(" | ")
656        }
657    }
658}
659
660// ── Module-level lazy global ─────────────────────────────────────────────────
661
662#[cfg(feature = "std")]
663static GLOBAL_REGISTRY: std::sync::OnceLock<EntityTypeRegistry> = std::sync::OnceLock::new();
664
665#[cfg(feature = "std")]
666impl EntityTypeRegistry {
667    /// Return a reference to the module-level built-in registry.
668    pub fn global() -> &'static EntityTypeRegistry {
669        GLOBAL_REGISTRY.get_or_init(EntityTypeRegistry::builtin)
670    }
671}
672
673// ── Tests ────────────────────────────────────────────────────────────────────
674
675#[cfg(test)]
676mod tests {
677    use super::*;
678    use crate::entity::EntityKind;
679
680    fn reg() -> EntityTypeRegistry {
681        EntityTypeRegistry::builtin()
682    }
683
684    // ── Basic happy-path resolution ──────────────────────────────────────────
685
686    #[test]
687    fn research_report_resolves_in_every_stored_spelling() {
688        let r = reg();
689        for raw in ["research-report", "research_report", "Research Report"] {
690            let res = r
691                .resolve(EntityKind::Document, Some(raw))
692                .unwrap_or_else(|e| panic!("{raw} must resolve as a Document subtype: {e}"));
693            assert_eq!(res.entity_type.as_deref(), Some("research_report"), "{raw}");
694        }
695        // A Document genre, not a Concept one: the same spelling on another
696        // kind is still refused, so the registry stays kind-scoped.
697        assert!(r
698            .resolve(EntityKind::Concept, Some("research-report"))
699            .is_err());
700    }
701
702    #[test]
703    fn resolve_paper_infers_document() {
704        let r = reg();
705        let res = r
706            .resolve(EntityKind::Document, Some("paper"))
707            .expect("paper is a valid Document subtype");
708        assert_eq!(res.kind, EntityKind::Document);
709        assert_eq!(res.entity_type.as_deref(), Some("paper"));
710    }
711
712    #[test]
713    fn resolve_none_entity_type_always_ok() {
714        let r = reg();
715        for kind in EntityKind::ALL {
716            let res = r.resolve(kind, None).expect("None entity_type always ok");
717            assert_eq!(res.entity_type, None);
718        }
719    }
720
721    #[test]
722    fn resolve_algo_alias_to_algorithm() {
723        let r = reg();
724        let res = r
725            .resolve(EntityKind::Concept, Some("algo"))
726            .expect("algo is a valid alias for algorithm");
727        assert_eq!(res.kind, EntityKind::Concept);
728        assert_eq!(res.entity_type.as_deref(), Some("algorithm"));
729    }
730
731    #[test]
732    fn resolve_spec_alias_to_specification() {
733        let r = reg();
734        let res = r
735            .resolve(EntityKind::Document, Some("spec"))
736            .expect("spec is alias for specification");
737        assert_eq!(res.entity_type.as_deref(), Some("specification"));
738    }
739
740    // ── Rejection tests ──────────────────────────────────────────────────────
741
742    #[test]
743    fn reject_brain_profile_for_concept() {
744        let r = reg();
745        let err = r
746            .resolve(EntityKind::Concept, Some("brain_profile"))
747            .expect_err("brain_profile is not a Concept subtype");
748        let msg = format!("{err}");
749        assert!(
750            msg.contains("brain_profile"),
751            "error must mention the rejected type; got: {msg}"
752        );
753        assert!(
754            msg.contains("concept"),
755            "error must mention the target kind; got: {msg}"
756        );
757    }
758
759    #[test]
760    fn reject_unknown_subtype_with_valid_list() {
761        let r = reg();
762        let err = r
763            .resolve(EntityKind::Document, Some("mystery_type"))
764            .expect_err("mystery_type must be rejected");
765        let msg = format!("{err}");
766        assert!(
767            msg.contains("mystery_type"),
768            "error must echo the rejected value; got: {msg}"
769        );
770        // Valid subtypes for Document must appear.
771        assert!(
772            msg.contains("paper"),
773            "error must list valid Document subtypes; got: {msg}"
774        );
775    }
776
777    #[test]
778    fn reject_wrong_kind_subtype_mentions_correct_kind() {
779        // "paper" belongs to Document; passing it for Concept must fail.
780        let r = reg();
781        let err = r
782            .resolve(EntityKind::Concept, Some("paper"))
783            .expect_err("paper is a Document subtype, not Concept");
784        let msg = format!("{err}");
785        assert!(
786            msg.contains("document") || msg.contains("Document"),
787            "error must name the correct kind; got: {msg}"
788        );
789    }
790
791    // ── Extensibility ────────────────────────────────────────────────────────
792
793    #[test]
794    fn register_brain_profile_for_concept() {
795        let mut r = EntityTypeRegistry::builtin();
796        r.register(EntityTypeDef {
797            kind: EntityKind::Concept,
798            type_name: "brain_profile",
799            aliases: &[],
800        });
801        let res = r
802            .resolve(EntityKind::Concept, Some("brain_profile"))
803            .expect("brain_profile registered for Concept");
804        assert_eq!(res.entity_type.as_deref(), Some("brain_profile"));
805    }
806
807    #[test]
808    fn with_extra_adds_subtypes() {
809        // Use a pack-specific subtype that is NOT in BUILTIN_DEFS.
810        let r = EntityTypeRegistry::with_extra([EntityTypeDef {
811            kind: EntityKind::Service,
812            type_name: "grpc_service",
813            aliases: &["grpc"],
814        }]);
815        let res = r
816            .resolve(EntityKind::Service, Some("grpc"))
817            .expect("grpc alias for grpc_service");
818        assert_eq!(res.entity_type.as_deref(), Some("grpc_service"));
819    }
820
821    #[test]
822    fn service_api_is_builtin() {
823        let r = reg();
824        let res = r
825            .resolve(EntityKind::Service, Some("api"))
826            .expect("api is a built-in Service subtype");
827        assert_eq!(res.entity_type.as_deref(), Some("api"));
828        let res2 = r
829            .resolve(EntityKind::Service, Some("endpoint"))
830            .expect("endpoint is an alias for api");
831        assert_eq!(res2.entity_type.as_deref(), Some("api"));
832    }
833
834    #[test]
835    fn service_mcp_server_is_builtin() {
836        let r = reg();
837        let res = r
838            .resolve(EntityKind::Service, Some("mcp_server"))
839            .expect("mcp_server is a built-in Service subtype");
840        assert_eq!(res.entity_type.as_deref(), Some("mcp_server"));
841        let res2 = r
842            .resolve(EntityKind::Service, Some("mcp"))
843            .expect("mcp is an alias for mcp_server");
844        assert_eq!(res2.entity_type.as_deref(), Some("mcp_server"));
845    }
846
847    #[test]
848    fn project_has_no_service_subtype() {
849        let r = reg();
850        r.resolve(EntityKind::Project, Some("service"))
851            .expect_err("service must not be a Project subtype");
852        r.resolve(EntityKind::Project, Some("svc"))
853            .expect_err("svc must not be a Project subtype");
854    }
855
856    #[test]
857    fn benchmark_is_dataset_not_concept() {
858        let r = reg();
859        let res = r
860            .resolve(EntityKind::Dataset, Some("benchmark"))
861            .expect("benchmark is a valid Dataset subtype");
862        assert_eq!(res.entity_type.as_deref(), Some("benchmark"));
863        r.resolve(EntityKind::Concept, Some("benchmark"))
864            .expect_err("benchmark must not be a Concept subtype");
865    }
866
867    #[test]
868    fn model_alias_resolves_to_model_family() {
869        let r = reg();
870        let res = r
871            .resolve(EntityKind::Concept, Some("model"))
872            .expect("model is an alias for model_family");
873        assert_eq!(res.entity_type.as_deref(), Some("model_family"));
874        let res2 = r
875            .resolve(EntityKind::Concept, Some("model_family"))
876            .expect("model_family is the canonical name");
877        assert_eq!(res2.entity_type.as_deref(), Some("model_family"));
878    }
879
880    // ── Case insensitivity ───────────────────────────────────────────────────
881
882    #[test]
883    fn resolve_is_case_insensitive() {
884        let r = reg();
885        let res = r
886            .resolve(EntityKind::Concept, Some("Algorithm"))
887            .expect("Algorithm (mixed case) must resolve");
888        assert_eq!(res.entity_type.as_deref(), Some("algorithm"));
889    }
890
891    // ── valid_types_for ──────────────────────────────────────────────────────
892
893    #[test]
894    fn valid_types_for_person_is_none_registered() {
895        let r = reg();
896        let s = r.valid_types_for(EntityKind::Person);
897        assert_eq!(
898            s, "(none registered)",
899            "Person has no built-in subtypes; got: {s}"
900        );
901    }
902
903    #[test]
904    fn valid_types_for_concept_includes_algorithm() {
905        let r = reg();
906        let s = r.valid_types_for(EntityKind::Concept);
907        assert!(
908            s.contains("algorithm"),
909            "Concept valid types must include algorithm; got: {s}"
910        );
911    }
912
913    #[test]
914    fn resolve_inductive_alias_to_structure() {
915        let r = reg();
916        let res = r
917            .resolve(EntityKind::Concept, Some("inductive"))
918            .expect("inductive is a valid alias for structure");
919        assert_eq!(res.kind, EntityKind::Concept);
920        assert_eq!(res.entity_type.as_deref(), Some("structure"));
921    }
922
923    #[test]
924    fn resolve_goal_subtype() {
925        let r = reg();
926        let res = r
927            .resolve(EntityKind::Concept, Some("goal"))
928            .expect("goal is a valid Concept subtype");
929        assert_eq!(res.kind, EntityKind::Concept);
930        assert_eq!(res.entity_type.as_deref(), Some("goal"));
931    }
932
933    // ── Global registry ──────────────────────────────────────────────────────
934
935    #[cfg(feature = "std")]
936    #[test]
937    fn global_registry_is_accessible() {
938        let r = EntityTypeRegistry::global();
939        let res = r
940            .resolve(EntityKind::Document, Some("paper"))
941            .expect("global registry must resolve paper");
942        assert_eq!(res.entity_type.as_deref(), Some("paper"));
943    }
944
945    // ── snake_case normalisation (ADR-001:106) ───────────────────────────────
946
947    #[test]
948    fn to_snake_case_converts_hyphen_to_underscore() {
949        assert_eq!(to_snake_case("proof-goal"), "proof_goal");
950    }
951
952    #[test]
953    fn to_snake_case_converts_space_to_underscore() {
954        assert_eq!(to_snake_case("Proof Goal"), "proof_goal");
955    }
956
957    #[test]
958    fn to_snake_case_collapses_mixed_separators() {
959        assert_eq!(to_snake_case("proof - goal"), "proof_goal");
960        assert_eq!(to_snake_case("proof__goal"), "proof_goal");
961    }
962
963    #[test]
964    fn to_snake_case_strips_leading_trailing_separators() {
965        assert_eq!(to_snake_case("-theorem-"), "theorem");
966        assert_eq!(to_snake_case(" theorem "), "theorem");
967    }
968
969    #[test]
970    fn resolve_proof_goal_hyphen_normalises_to_goal() {
971        let r = reg();
972        let res = r
973            .resolve(EntityKind::Concept, Some("proof-goal"))
974            .expect("proof-goal must resolve via snake_case normalisation + alias");
975        assert_eq!(res.entity_type.as_deref(), Some("goal"));
976    }
977
978    #[test]
979    fn resolve_proof_goal_space_normalises_to_goal() {
980        let r = reg();
981        let res = r
982            .resolve(EntityKind::Concept, Some("Proof Goal"))
983            .expect("Proof Goal must resolve via snake_case normalisation + alias");
984        assert_eq!(res.entity_type.as_deref(), Some("goal"));
985    }
986
987    #[test]
988    fn resolve_blog_hyphen_normalises_to_blog_post_non_formal() {
989        let r = reg();
990        let res = r
991            .resolve(EntityKind::Document, Some("blog-post"))
992            .expect("blog-post must normalise to blog_post");
993        assert_eq!(res.entity_type.as_deref(), Some("blog_post"));
994    }
995
996    // ── ADR-085 code subtypes ────────────────────────────────────────────────
997
998    #[test]
999    fn entity_type_registry_accepts_code_tokens_and_aliases() {
1000        let r = reg();
1001        for (raw, canonical) in [
1002            ("module", "module"),
1003            ("mod", "module"),
1004            ("namespace", "module"),
1005            ("function", "function"),
1006            ("fn", "function"),
1007            ("func", "function"),
1008            ("method", "function"),
1009            ("datatype", "datatype"),
1010            ("enum", "datatype"),
1011            ("record", "datatype"),
1012            ("type_alias", "datatype"),
1013            ("interface", "interface"),
1014            ("trait", "interface"),
1015            ("protocol", "interface"),
1016        ] {
1017            let res = r
1018                .resolve(EntityKind::Concept, Some(raw))
1019                .unwrap_or_else(|e| panic!("{raw:?} must resolve for Concept: {e}"));
1020            assert_eq!(
1021                res.entity_type.as_deref(),
1022                Some(canonical),
1023                "{raw:?} must resolve to {canonical:?}"
1024            );
1025        }
1026    }
1027
1028    #[test]
1029    fn entity_type_registry_does_not_claim_struct_or_class_for_code() {
1030        let r = reg();
1031        let struct_res = r
1032            .resolve(EntityKind::Concept, Some("struct"))
1033            .expect("struct remains a valid Concept subtype (owned by formal structure)");
1034        assert_eq!(
1035            struct_res.entity_type.as_deref(),
1036            Some("structure"),
1037            "struct must still resolve to formal-math structure, not datatype"
1038        );
1039        let class_res = r
1040            .resolve(EntityKind::Concept, Some("class"))
1041            .expect("class remains a valid Concept subtype (owned by formal structure)");
1042        assert_eq!(
1043            class_res.entity_type.as_deref(),
1044            Some("structure"),
1045            "class must still resolve to formal-math structure, not datatype"
1046        );
1047    }
1048
1049    // ADR-191: the web pack's registry footprint shrank from five ARW-shaped
1050    // tokens to three web-native ones. This test is the acceptance witness
1051    // for that deletion — it accepts the surviving tokens under their kinds
1052    // and refuses the deleted ones under every kind, not just their former one.
1053    #[test]
1054    fn entity_type_registry_accepts_web_tokens_and_aliases() {
1055        let r = reg();
1056        for (kind, canonical, alias) in [
1057            (EntityKind::Service, "site", "origin"),
1058            (EntityKind::Document, "page", "web_page"),
1059        ] {
1060            for raw in [canonical, alias] {
1061                let resolved = r
1062                    .resolve(kind, Some(raw))
1063                    .unwrap_or_else(|e| panic!("{raw:?} must resolve for {kind}: {e}"));
1064                assert_eq!(resolved.kind, kind);
1065                assert_eq!(resolved.entity_type.as_deref(), Some(canonical));
1066                for other_kind in EntityKind::ALL {
1067                    if other_kind != kind {
1068                        r.resolve(other_kind, Some(raw))
1069                            .expect_err("web subtypes belong to exactly one base kind");
1070                    }
1071                }
1072            }
1073        }
1074
1075        // `resource` has no alias.
1076        let resolved = r
1077            .resolve(EntityKind::Document, Some("resource"))
1078            .expect("resource must resolve for Document");
1079        assert_eq!(resolved.kind, EntityKind::Document);
1080        assert_eq!(resolved.entity_type.as_deref(), Some("resource"));
1081        for other_kind in EntityKind::ALL {
1082            if other_kind != EntityKind::Document {
1083                r.resolve(other_kind, Some("resource"))
1084                    .expect_err("resource belongs to exactly one base kind");
1085            }
1086        }
1087
1088        // Deleted ARW-shaped tokens (and their aliases) must be refused under
1089        // every kind, not just their former one — a partial deletion that
1090        // left one arm registered would otherwise pass silently.
1091        for gone in [
1092            "machine_view",
1093            "view",
1094            "agent_tool",
1095            "mcp_tool",
1096            "agent_skill",
1097            "skill_manifest",
1098        ] {
1099            for kind in EntityKind::ALL {
1100                r.resolve(kind, Some(gone)).expect_err(
1101                    "deleted web token must be refused for every kind, not just its former one",
1102                );
1103            }
1104        }
1105    }
1106
1107    #[test]
1108    fn web_tokens_and_aliases_have_unique_registry_owners() {
1109        for token in ["site", "origin", "page", "web_page", "resource"] {
1110            let owners: Vec<_> = BUILTIN_DEFS
1111                .iter()
1112                .filter(|def| {
1113                    to_snake_case(def.type_name) == token
1114                        || def
1115                            .aliases
1116                            .iter()
1117                            .any(|alias| to_snake_case(alias) == token)
1118                })
1119                .collect();
1120            assert_eq!(
1121                owners.len(),
1122                1,
1123                "{token:?} has conflicting owners: {owners:?}"
1124            );
1125        }
1126    }
1127
1128    #[test]
1129    fn web_vocabulary_preserves_tool_as_a_project_subtype() {
1130        let r = reg();
1131        assert_eq!(
1132            r.resolve(EntityKind::Project, Some("tool"))
1133                .expect("tool remains a project subtype")
1134                .entity_type
1135                .as_deref(),
1136            Some("tool")
1137        );
1138        r.resolve(EntityKind::Service, Some("tool"))
1139            .expect_err("tool is not a service subtype");
1140        r.resolve(EntityKind::Document, Some("skill"))
1141            .expect_err("skill is not a document subtype");
1142    }
1143
1144    // ── check_extra_collisions: ADR-001 normalisation (PR #925) ──
1145    //
1146    // `check_extra_collisions` must reject collisions that only become
1147    // visible after ADR-001:104-107's write-time `to_snake_case`
1148    // normalisation — i.e. two raw spellings that differ in case or
1149    // separator style but land on the same kind-qualified key once
1150    // normalised. Before the r2 fix these three cases silently passed
1151    // (`Ok(())`) because the check compared raw, unnormalised strings.
1152
1153    #[test]
1154    fn check_extra_collisions_detects_normalized_alias_vs_alias() {
1155        let def_a = EntityTypeDef {
1156            kind: EntityKind::Service,
1157            type_name: "widget_service",
1158            aliases: &["Widget-Alias"],
1159        };
1160        let def_b = EntityTypeDef {
1161            kind: EntityKind::Service,
1162            type_name: "gadget_service",
1163            aliases: &["widget_alias"],
1164        };
1165        let err =
1166            EntityTypeRegistry::check_extra_collisions([("pack_a", &def_a), ("pack_b", &def_b)])
1167                .expect_err(
1168                    "aliases that only differ in case/hyphenation but normalise to the same \
1169             kind-qualified key must collide",
1170                );
1171        assert!(err.contains("pack_a"), "error must name pack_a: {err}");
1172        assert!(err.contains("pack_b"), "error must name pack_b: {err}");
1173    }
1174
1175    #[test]
1176    fn check_extra_collisions_detects_normalized_alias_vs_canonical() {
1177        let def_a = EntityTypeDef {
1178            kind: EntityKind::Service,
1179            type_name: "primary_service",
1180            aliases: &[],
1181        };
1182        let def_b = EntityTypeDef {
1183            kind: EntityKind::Service,
1184            type_name: "secondary_service",
1185            aliases: &["Primary-Service"],
1186        };
1187        let err =
1188            EntityTypeRegistry::check_extra_collisions([("pack_a", &def_a), ("pack_b", &def_b)])
1189                .expect_err(
1190                    "an alias that normalises onto another owner's canonical key must collide, \
1191             even when the raw spellings differ",
1192                );
1193        assert!(err.contains("pack_a"), "error must name pack_a: {err}");
1194        assert!(err.contains("pack_b"), "error must name pack_b: {err}");
1195    }
1196
1197    #[test]
1198    fn check_extra_collisions_detects_normalized_builtin_vs_pack_collision() {
1199        // BUILTIN_DEFS declares Document "paper". A pack declaring a
1200        // differently-cased spelling that normalises onto the same
1201        // kind-qualified key must still collide with the built-in owner.
1202        let def = EntityTypeDef {
1203            kind: EntityKind::Document,
1204            type_name: "Paper",
1205            aliases: &[],
1206        };
1207        let err = EntityTypeRegistry::check_extra_collisions([("pack_a", &def)]).expect_err(
1208            "a pack canonical name that normalises onto a builtin canonical key must collide",
1209        );
1210        assert!(err.contains("builtin"), "error must name builtin: {err}");
1211        assert!(err.contains("pack_a"), "error must name pack_a: {err}");
1212    }
1213}