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.
22fn to_snake_case(s: &str) -> String {
23    let mut out = String::with_capacity(s.len());
24    let mut prev_sep = true; // treat start as separator so leading _ are stripped
25    for ch in s.chars() {
26        if ch == ' ' || ch == '-' || ch == '_' {
27            if !prev_sep && !out.is_empty() {
28                out.push('_');
29                prev_sep = true;
30            }
31        } else {
32            out.push(ch.to_ascii_lowercase());
33            prev_sep = false;
34        }
35    }
36    if out.ends_with('_') {
37        out.pop();
38    }
39    out
40}
41
42/// One entry in the registry: a canonical subtype name for a specific kind,
43/// together with any accepted aliases.
44#[derive(Clone, Debug)]
45pub struct EntityTypeDef {
46    /// The entity kind this subtype belongs to.
47    pub kind: EntityKind,
48    /// Canonical name that is written to the DB.
49    pub type_name: &'static str,
50    /// Alternative spellings that are accepted at the wire level but
51    /// normalised to `type_name` before storage.
52    pub aliases: &'static [&'static str],
53}
54
55/// Static table of built-in subtypes (non-exhaustive; packs may extend).
56static BUILTIN_DEFS: &[EntityTypeDef] = &[
57    // ── Document ────────────────────────────────────────────────────────────
58    EntityTypeDef {
59        kind: EntityKind::Document,
60        type_name: "paper",
61        aliases: &["preprint", "article"],
62    },
63    EntityTypeDef {
64        kind: EntityKind::Document,
65        type_name: "report",
66        aliases: &[],
67    },
68    EntityTypeDef {
69        kind: EntityKind::Document,
70        type_name: "blog_post",
71        aliases: &["blog"],
72    },
73    EntityTypeDef {
74        kind: EntityKind::Document,
75        type_name: "book",
76        aliases: &[],
77    },
78    EntityTypeDef {
79        kind: EntityKind::Document,
80        type_name: "specification",
81        aliases: &["spec"],
82    },
83    EntityTypeDef {
84        kind: EntityKind::Document,
85        type_name: "documentation",
86        aliases: &["docs"],
87    },
88    EntityTypeDef {
89        kind: EntityKind::Document,
90        type_name: "thesis",
91        aliases: &[],
92    },
93    // ── Concept ─────────────────────────────────────────────────────────────
94    EntityTypeDef {
95        kind: EntityKind::Concept,
96        type_name: "algorithm",
97        aliases: &["algo"],
98    },
99    // ── Formal math ─────────────────────────────────────────────────────────
100    EntityTypeDef {
101        kind: EntityKind::Concept,
102        type_name: "theorem",
103        aliases: &["lemma", "proposition", "corollary"],
104    },
105    EntityTypeDef {
106        kind: EntityKind::Concept,
107        type_name: "definition",
108        aliases: &["def"],
109    },
110    EntityTypeDef {
111        kind: EntityKind::Concept,
112        type_name: "structure",
113        aliases: &["inductive", "struct", "class"],
114    },
115    EntityTypeDef {
116        kind: EntityKind::Concept,
117        type_name: "instance",
118        aliases: &[],
119    },
120    EntityTypeDef {
121        kind: EntityKind::Concept,
122        type_name: "axiom",
123        aliases: &[],
124    },
125    EntityTypeDef {
126        kind: EntityKind::Concept,
127        type_name: "goal",
128        aliases: &["proof_goal"],
129    },
130    EntityTypeDef {
131        kind: EntityKind::Concept,
132        type_name: "technique",
133        aliases: &[],
134    },
135    EntityTypeDef {
136        kind: EntityKind::Concept,
137        type_name: "architecture",
138        aliases: &["arch"],
139    },
140    EntityTypeDef {
141        kind: EntityKind::Concept,
142        // "model" is the alias; canonical name is "model_family"
143        // to distinguish from a Dataset or Artifact trained model instance.
144        type_name: "model_family",
145        aliases: &["model"],
146    },
147    EntityTypeDef {
148        kind: EntityKind::Concept,
149        type_name: "theory",
150        aliases: &[],
151    },
152    EntityTypeDef {
153        kind: EntityKind::Concept,
154        type_name: "research_gap",
155        aliases: &["gap"],
156    },
157    EntityTypeDef {
158        kind: EntityKind::Concept,
159        type_name: "design_pattern",
160        aliases: &["pattern"],
161    },
162    EntityTypeDef {
163        kind: EntityKind::Concept,
164        type_name: "mathematical_operation",
165        aliases: &["math_op"],
166    },
167    EntityTypeDef {
168        kind: EntityKind::Concept,
169        type_name: "metric",
170        aliases: &[],
171    },
172    EntityTypeDef {
173        kind: EntityKind::Concept,
174        type_name: "objective",
175        aliases: &["loss"],
176    },
177    // ── Code (ADR-085) ──────────────────────────────────────────────────────
178    // "struct" and "class" are not registered as aliases here: formal-math
179    // "structure" already owns them (see above), and ADR-085 requires
180    // ingesters to write the canonical code tokens instead.
181    EntityTypeDef {
182        kind: EntityKind::Concept,
183        type_name: "module",
184        aliases: &["mod", "namespace"],
185    },
186    EntityTypeDef {
187        kind: EntityKind::Concept,
188        type_name: "function",
189        aliases: &["fn", "func", "method"],
190    },
191    EntityTypeDef {
192        kind: EntityKind::Concept,
193        type_name: "datatype",
194        aliases: &["enum", "record", "type_alias"],
195    },
196    EntityTypeDef {
197        kind: EntityKind::Concept,
198        type_name: "interface",
199        aliases: &["trait", "protocol"],
200    },
201    // ── Dataset ──────────────────────────────────────────────────────────────
202    // benchmark belongs to Dataset, not Concept (it evaluates models, it is not itself a concept).
203    EntityTypeDef {
204        kind: EntityKind::Dataset,
205        type_name: "benchmark",
206        aliases: &[],
207    },
208    EntityTypeDef {
209        kind: EntityKind::Dataset,
210        type_name: "corpus",
211        aliases: &[],
212    },
213    EntityTypeDef {
214        kind: EntityKind::Dataset,
215        type_name: "training_set",
216        aliases: &["train_set"],
217    },
218    EntityTypeDef {
219        kind: EntityKind::Dataset,
220        type_name: "evaluation_set",
221        aliases: &["eval_set"],
222    },
223    EntityTypeDef {
224        kind: EntityKind::Dataset,
225        type_name: "test_set",
226        aliases: &[],
227    },
228    EntityTypeDef {
229        kind: EntityKind::Dataset,
230        type_name: "synthetic_dataset",
231        aliases: &["synthetic"],
232    },
233    // ── Project ─────────────────────────────────────────────────────────────
234    // Project subtypes: library, framework, tool, application, repository.
235    // "service" and "svc" are omitted — EntityKind::Service handles running
236    // instances; a service codebase repo is Project + application or tool.
237    EntityTypeDef {
238        kind: EntityKind::Project,
239        type_name: "library",
240        aliases: &["lib"],
241    },
242    EntityTypeDef {
243        kind: EntityKind::Project,
244        type_name: "framework",
245        aliases: &[],
246    },
247    EntityTypeDef {
248        kind: EntityKind::Project,
249        type_name: "tool",
250        aliases: &[],
251    },
252    EntityTypeDef {
253        kind: EntityKind::Project,
254        type_name: "application",
255        aliases: &["app"],
256    },
257    EntityTypeDef {
258        kind: EntityKind::Project,
259        type_name: "repository",
260        aliases: &["repo"],
261    },
262    // ── Org ──────────────────────────────────────────────────────────────────
263    EntityTypeDef {
264        kind: EntityKind::Org,
265        type_name: "academic_institution",
266        aliases: &["university", "uni"],
267    },
268    EntityTypeDef {
269        kind: EntityKind::Org,
270        type_name: "company",
271        aliases: &[],
272    },
273    EntityTypeDef {
274        kind: EntityKind::Org,
275        type_name: "research_lab",
276        aliases: &["lab"],
277    },
278    EntityTypeDef {
279        kind: EntityKind::Org,
280        type_name: "nonprofit",
281        aliases: &[],
282    },
283    EntityTypeDef {
284        kind: EntityKind::Org,
285        type_name: "government_agency",
286        aliases: &["gov_agency"],
287    },
288    EntityTypeDef {
289        kind: EntityKind::Org,
290        type_name: "consortium",
291        aliases: &[],
292    },
293    EntityTypeDef {
294        kind: EntityKind::Org,
295        type_name: "standards_body",
296        aliases: &[],
297    },
298    // ── Artifact ─────────────────────────────────────────────────────────────
299    EntityTypeDef {
300        kind: EntityKind::Artifact,
301        type_name: "checkpoint",
302        aliases: &["ckpt"],
303    },
304    EntityTypeDef {
305        kind: EntityKind::Artifact,
306        type_name: "snapshot",
307        aliases: &[],
308    },
309    EntityTypeDef {
310        kind: EntityKind::Artifact,
311        type_name: "export",
312        aliases: &[],
313    },
314    EntityTypeDef {
315        kind: EntityKind::Artifact,
316        type_name: "embedding_index",
317        aliases: &["embed_index"],
318    },
319    EntityTypeDef {
320        kind: EntityKind::Artifact,
321        type_name: "state_bundle",
322        aliases: &[],
323    },
324    EntityTypeDef {
325        kind: EntityKind::Artifact,
326        type_name: "profile",
327        aliases: &[],
328    },
329    // ── Service ──────────────────────────────────────────────────────────────
330    // Service subtypes: inference_engine, retrieval_engine,
331    // embedding_engine, api, database, search_engine, mcp_server.
332    EntityTypeDef {
333        kind: EntityKind::Service,
334        type_name: "inference_engine",
335        aliases: &[],
336    },
337    EntityTypeDef {
338        kind: EntityKind::Service,
339        type_name: "retrieval_engine",
340        aliases: &[],
341    },
342    EntityTypeDef {
343        kind: EntityKind::Service,
344        type_name: "embedding_engine",
345        aliases: &[],
346    },
347    EntityTypeDef {
348        kind: EntityKind::Service,
349        type_name: "api",
350        aliases: &["endpoint"],
351    },
352    EntityTypeDef {
353        kind: EntityKind::Service,
354        type_name: "database",
355        aliases: &["db"],
356    },
357    EntityTypeDef {
358        kind: EntityKind::Service,
359        type_name: "search_engine",
360        aliases: &[],
361    },
362    EntityTypeDef {
363        kind: EntityKind::Service,
364        type_name: "mcp_server",
365        aliases: &["mcp"],
366    },
367    // Person  — no standard subtypes (roles are metadata, not subtypes).
368];
369
370/// Resolved output of [`EntityTypeRegistry::resolve`].
371#[derive(Clone, Debug, PartialEq, Eq)]
372pub struct ResolvedEntityType {
373    /// The canonical `EntityKind` for the resolved combination.
374    pub kind: EntityKind,
375    /// The canonical type name to store, or `None` when no `entity_type` was
376    /// supplied and none was inferred.
377    pub entity_type: Option<String>,
378}
379
380/// Error produced by [`EntityTypeRegistry::resolve`].
381///
382/// Dependency-light: `khive-types` cannot depend on `khive-runtime`, so
383/// callers map this to their own error type at the pack boundary.
384#[derive(Clone, Debug, PartialEq, Eq)]
385pub enum EntityTypeError {
386    /// The raw type name exists, but belongs to a different `EntityKind`.
387    WrongKind {
388        raw_type: String,
389        actual_kind: &'static str,
390        expected_kind: &'static str,
391        valid: String,
392    },
393    /// The raw type name is not registered for any kind.
394    UnknownType {
395        raw_type: String,
396        kind: &'static str,
397        valid: String,
398    },
399}
400
401impl fmt::Display for EntityTypeError {
402    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
403        match self {
404            Self::WrongKind {
405                raw_type,
406                actual_kind,
407                expected_kind,
408                valid,
409            } => write!(
410                f,
411                "entity_type {raw_type:?} belongs to {actual_kind:?}, not {expected_kind:?}; \
412                 valid types for {expected_kind:?}: {valid}",
413            ),
414            Self::UnknownType {
415                raw_type,
416                kind,
417                valid,
418            } => write!(
419                f,
420                "unknown entity_type {raw_type:?} for {kind:?}; valid: {valid}"
421            ),
422        }
423    }
424}
425
426#[cfg(feature = "std")]
427impl std::error::Error for EntityTypeError {}
428
429/// Registry for `(EntityKind, entity_type)` pair validation and alias normalisation.
430#[derive(Clone)]
431pub struct EntityTypeRegistry {
432    /// `alias_or_name (lowercase) → def index`.  Covers both canonical names
433    /// and all registered aliases.
434    lookup: BTreeMap<String, usize>,
435    defs: Vec<EntityTypeDef>,
436}
437
438impl EntityTypeRegistry {
439    /// Build a fresh registry from the supplied definitions.
440    ///
441    /// Every lookup key (kind-qualified and bare, canonical and alias) is
442    /// routed through `to_snake_case` before insertion — the same
443    /// normalisation `resolve` applies to incoming wire values (ADR-001:106).
444    /// Two differently-cased/separated spellings that normalise to the same
445    /// key therefore land on the same registry slot instead of silently
446    /// shadowing one another under distinct raw keys.
447    pub fn new(defs: impl IntoIterator<Item = EntityTypeDef>) -> Self {
448        let defs: Vec<EntityTypeDef> = defs.into_iter().collect();
449        let mut lookup: BTreeMap<String, usize> = BTreeMap::new();
450        for (idx, def) in defs.iter().enumerate() {
451            let canonical_type = to_snake_case(def.type_name);
452            let canonical_key = format!("{}:{}", def.kind.name(), canonical_type);
453            lookup.insert(canonical_key, idx);
454            // Bare name without kind prefix — only insert when unambiguous.
455            lookup.entry(canonical_type).or_insert(idx);
456            for alias in def.aliases {
457                let normalised_alias = to_snake_case(alias);
458                let kind_alias_key = format!("{}:{}", def.kind.name(), normalised_alias);
459                lookup.insert(kind_alias_key, idx);
460                lookup.entry(normalised_alias).or_insert(idx);
461            }
462        }
463        Self { lookup, defs }
464    }
465
466    /// Return the built-in registry with all static subtype definitions.
467    pub fn builtin() -> Self {
468        Self::new(BUILTIN_DEFS.iter().cloned())
469    }
470
471    /// Derive a registry that includes all built-in subtypes plus the
472    /// caller-supplied extras (used by packs that extend the vocabulary).
473    pub fn with_extra(extra: impl IntoIterator<Item = EntityTypeDef>) -> Self {
474        let defs: Vec<EntityTypeDef> = BUILTIN_DEFS.iter().cloned().chain(extra).collect();
475        Self::new(defs)
476    }
477
478    /// Boot-time collision check across the composed registry `with_extra`
479    /// builds: built-in defs plus every caller-supplied `(owner, def)` extra.
480    ///
481    /// `new`/`register` populate a single kind-qualified lookup keyspace
482    /// (`"{kind}:{canonical_name}"` and `"{kind}:{alias}"`) with a hard
483    /// `insert` — the later write silently wins. Two independently loaded
484    /// owners can therefore disagree on what a given key resolves to, and
485    /// the winner depends on registration order. ADR-001's registry-ownership
486    /// contract instead requires this to fail at boot: "same `(base_kind,
487    /// canonical_name)` from two different packs = boot error" and "an alias
488    /// collision = boot error." This walks the same keyspace `new`/`register`
489    /// populate and returns the first collision found, naming both
490    /// contributing owners, instead of silently picking a winner.
491    ///
492    /// `extras` is `(owner_label, def)` for pack-declared entries only — the
493    /// built-in table is checked automatically, attributed to the label
494    /// `"builtin"`.
495    ///
496    /// Every canonical and alias key is routed through the same
497    /// `to_snake_case` normalisation `new`/`register` apply before
498    /// insertion (ADR-001:104-107): two spellings that only differ in case
499    /// or separator style (e.g. `"Architecture Decision Record"` vs.
500    /// `"architecture-decision-record"`) collide here exactly as they would
501    /// collide in the composed registry's lookup keyspace.
502    pub fn check_extra_collisions<'a>(
503        extras: impl IntoIterator<Item = (&'a str, &'a EntityTypeDef)>,
504    ) -> Result<(), String> {
505        let mut all: Vec<(&'a str, &'a EntityTypeDef)> =
506            BUILTIN_DEFS.iter().map(|def| ("builtin", def)).collect();
507        all.extend(extras);
508
509        let mut seen: BTreeMap<String, (&'a str, String)> = BTreeMap::new();
510        for (owner, def) in all {
511            let canonical_type = to_snake_case(def.type_name);
512            let canonical_key = format!("{}:{}", def.kind.name(), canonical_type);
513            if let Some((first_owner, _)) = seen.get(&canonical_key) {
514                if *first_owner != owner {
515                    return Err(format!(
516                        "duplicate entity_type {canonical_key:?}: claimed by both \
517                         {first_owner:?} and {owner:?}"
518                    ));
519                }
520            } else {
521                seen.insert(canonical_key, (owner, canonical_type.clone()));
522            }
523
524            for alias in def.aliases {
525                let normalised_alias = to_snake_case(alias);
526                let alias_key = format!("{}:{}", def.kind.name(), normalised_alias);
527                if let Some((first_owner, first_type)) = seen.get(&alias_key) {
528                    if *first_owner != owner || *first_type != canonical_type {
529                        return Err(format!(
530                            "entity_type alias {alias_key:?}: claimed by both {first_owner:?} \
531                             (canonical {first_type:?}) and {owner:?} (canonical {canonical_type:?})",
532                        ));
533                    }
534                } else {
535                    seen.insert(alias_key, (owner, canonical_type.clone()));
536                }
537            }
538        }
539        Ok(())
540    }
541
542    /// Register additional subtypes into an existing registry clone.
543    ///
544    /// Mirrors [`Self::new`]'s normalisation: every key is routed through
545    /// `to_snake_case` before insertion.
546    pub fn register(&mut self, def: EntityTypeDef) {
547        let idx = self.defs.len();
548        let canonical_type = to_snake_case(def.type_name);
549        let canonical_key = format!("{}:{}", def.kind.name(), canonical_type);
550        self.lookup.insert(canonical_key, idx);
551        self.lookup.entry(canonical_type).or_insert(idx);
552        for alias in def.aliases {
553            let normalised_alias = to_snake_case(alias);
554            let kind_alias_key = format!("{}:{}", def.kind.name(), normalised_alias);
555            self.lookup.insert(kind_alias_key, idx);
556            self.lookup.entry(normalised_alias).or_insert(idx);
557        }
558        self.defs.push(def);
559    }
560
561    /// Validate and normalise a `(kind, entity_type)` wire pair.
562    pub fn resolve(
563        &self,
564        kind: EntityKind,
565        entity_type: Option<&str>,
566    ) -> Result<ResolvedEntityType, EntityTypeError> {
567        let Some(raw_type) = entity_type else {
568            return Ok(ResolvedEntityType {
569                kind,
570                entity_type: None,
571            });
572        };
573
574        let normalised = to_snake_case(raw_type.trim());
575
576        // Try kind-qualified lookup first (unambiguous).
577        let kind_key = format!("{}:{}", kind.name(), normalised);
578        if let Some(&idx) = self.lookup.get(&kind_key) {
579            let def = &self.defs[idx];
580            return Ok(ResolvedEntityType {
581                kind,
582                entity_type: Some(def.type_name.to_string()),
583            });
584        }
585
586        // Try bare lookup — only valid when the bare name belongs to this kind.
587        if let Some(&idx) = self.lookup.get(&normalised) {
588            let def = &self.defs[idx];
589            if def.kind == kind {
590                return Ok(ResolvedEntityType {
591                    kind,
592                    entity_type: Some(def.type_name.to_string()),
593                });
594            }
595            // The name exists but belongs to a different kind.
596            return Err(EntityTypeError::WrongKind {
597                raw_type: raw_type.to_string(),
598                actual_kind: def.kind.name(),
599                expected_kind: kind.name(),
600                valid: self.valid_types_for(kind),
601            });
602        }
603
604        // Not found at all.
605        Err(EntityTypeError::UnknownType {
606            raw_type: raw_type.to_string(),
607            kind: kind.name(),
608            valid: self.valid_types_for(kind),
609        })
610    }
611
612    /// Comma-separated list of canonical type names valid for `kind`.
613    pub fn valid_types_for(&self, kind: EntityKind) -> String {
614        let mut names: Vec<&str> = self
615            .defs
616            .iter()
617            .filter(|d| d.kind == kind)
618            .map(|d| d.type_name)
619            .collect();
620        names.sort_unstable();
621        if names.is_empty() {
622            "(none registered)".to_string()
623        } else {
624            names.join(" | ")
625        }
626    }
627}
628
629// ── Module-level lazy global ─────────────────────────────────────────────────
630
631#[cfg(feature = "std")]
632static GLOBAL_REGISTRY: std::sync::OnceLock<EntityTypeRegistry> = std::sync::OnceLock::new();
633
634#[cfg(feature = "std")]
635impl EntityTypeRegistry {
636    /// Return a reference to the module-level built-in registry.
637    pub fn global() -> &'static EntityTypeRegistry {
638        GLOBAL_REGISTRY.get_or_init(EntityTypeRegistry::builtin)
639    }
640}
641
642// ── Tests ────────────────────────────────────────────────────────────────────
643
644#[cfg(test)]
645mod tests {
646    use super::*;
647    use crate::entity::EntityKind;
648
649    fn reg() -> EntityTypeRegistry {
650        EntityTypeRegistry::builtin()
651    }
652
653    // ── Basic happy-path resolution ──────────────────────────────────────────
654
655    #[test]
656    fn resolve_paper_infers_document() {
657        let r = reg();
658        let res = r
659            .resolve(EntityKind::Document, Some("paper"))
660            .expect("paper is a valid Document subtype");
661        assert_eq!(res.kind, EntityKind::Document);
662        assert_eq!(res.entity_type.as_deref(), Some("paper"));
663    }
664
665    #[test]
666    fn resolve_none_entity_type_always_ok() {
667        let r = reg();
668        for kind in EntityKind::ALL {
669            let res = r.resolve(kind, None).expect("None entity_type always ok");
670            assert_eq!(res.entity_type, None);
671        }
672    }
673
674    #[test]
675    fn resolve_algo_alias_to_algorithm() {
676        let r = reg();
677        let res = r
678            .resolve(EntityKind::Concept, Some("algo"))
679            .expect("algo is a valid alias for algorithm");
680        assert_eq!(res.kind, EntityKind::Concept);
681        assert_eq!(res.entity_type.as_deref(), Some("algorithm"));
682    }
683
684    #[test]
685    fn resolve_spec_alias_to_specification() {
686        let r = reg();
687        let res = r
688            .resolve(EntityKind::Document, Some("spec"))
689            .expect("spec is alias for specification");
690        assert_eq!(res.entity_type.as_deref(), Some("specification"));
691    }
692
693    // ── Rejection tests ──────────────────────────────────────────────────────
694
695    #[test]
696    fn reject_brain_profile_for_concept() {
697        let r = reg();
698        let err = r
699            .resolve(EntityKind::Concept, Some("brain_profile"))
700            .expect_err("brain_profile is not a Concept subtype");
701        let msg = format!("{err}");
702        assert!(
703            msg.contains("brain_profile"),
704            "error must mention the rejected type; got: {msg}"
705        );
706        assert!(
707            msg.contains("concept"),
708            "error must mention the target kind; got: {msg}"
709        );
710    }
711
712    #[test]
713    fn reject_unknown_subtype_with_valid_list() {
714        let r = reg();
715        let err = r
716            .resolve(EntityKind::Document, Some("mystery_type"))
717            .expect_err("mystery_type must be rejected");
718        let msg = format!("{err}");
719        assert!(
720            msg.contains("mystery_type"),
721            "error must echo the rejected value; got: {msg}"
722        );
723        // Valid subtypes for Document must appear.
724        assert!(
725            msg.contains("paper"),
726            "error must list valid Document subtypes; got: {msg}"
727        );
728    }
729
730    #[test]
731    fn reject_wrong_kind_subtype_mentions_correct_kind() {
732        // "paper" belongs to Document; passing it for Concept must fail.
733        let r = reg();
734        let err = r
735            .resolve(EntityKind::Concept, Some("paper"))
736            .expect_err("paper is a Document subtype, not Concept");
737        let msg = format!("{err}");
738        assert!(
739            msg.contains("document") || msg.contains("Document"),
740            "error must name the correct kind; got: {msg}"
741        );
742    }
743
744    // ── Extensibility ────────────────────────────────────────────────────────
745
746    #[test]
747    fn register_brain_profile_for_concept() {
748        let mut r = EntityTypeRegistry::builtin();
749        r.register(EntityTypeDef {
750            kind: EntityKind::Concept,
751            type_name: "brain_profile",
752            aliases: &[],
753        });
754        let res = r
755            .resolve(EntityKind::Concept, Some("brain_profile"))
756            .expect("brain_profile registered for Concept");
757        assert_eq!(res.entity_type.as_deref(), Some("brain_profile"));
758    }
759
760    #[test]
761    fn with_extra_adds_subtypes() {
762        // Use a pack-specific subtype that is NOT in BUILTIN_DEFS.
763        let r = EntityTypeRegistry::with_extra([EntityTypeDef {
764            kind: EntityKind::Service,
765            type_name: "grpc_service",
766            aliases: &["grpc"],
767        }]);
768        let res = r
769            .resolve(EntityKind::Service, Some("grpc"))
770            .expect("grpc alias for grpc_service");
771        assert_eq!(res.entity_type.as_deref(), Some("grpc_service"));
772    }
773
774    #[test]
775    fn service_api_is_builtin() {
776        let r = reg();
777        let res = r
778            .resolve(EntityKind::Service, Some("api"))
779            .expect("api is a built-in Service subtype");
780        assert_eq!(res.entity_type.as_deref(), Some("api"));
781        let res2 = r
782            .resolve(EntityKind::Service, Some("endpoint"))
783            .expect("endpoint is an alias for api");
784        assert_eq!(res2.entity_type.as_deref(), Some("api"));
785    }
786
787    #[test]
788    fn service_mcp_server_is_builtin() {
789        let r = reg();
790        let res = r
791            .resolve(EntityKind::Service, Some("mcp_server"))
792            .expect("mcp_server is a built-in Service subtype");
793        assert_eq!(res.entity_type.as_deref(), Some("mcp_server"));
794        let res2 = r
795            .resolve(EntityKind::Service, Some("mcp"))
796            .expect("mcp is an alias for mcp_server");
797        assert_eq!(res2.entity_type.as_deref(), Some("mcp_server"));
798    }
799
800    #[test]
801    fn project_has_no_service_subtype() {
802        let r = reg();
803        r.resolve(EntityKind::Project, Some("service"))
804            .expect_err("service must not be a Project subtype");
805        r.resolve(EntityKind::Project, Some("svc"))
806            .expect_err("svc must not be a Project subtype");
807    }
808
809    #[test]
810    fn benchmark_is_dataset_not_concept() {
811        let r = reg();
812        let res = r
813            .resolve(EntityKind::Dataset, Some("benchmark"))
814            .expect("benchmark is a valid Dataset subtype");
815        assert_eq!(res.entity_type.as_deref(), Some("benchmark"));
816        r.resolve(EntityKind::Concept, Some("benchmark"))
817            .expect_err("benchmark must not be a Concept subtype");
818    }
819
820    #[test]
821    fn model_alias_resolves_to_model_family() {
822        let r = reg();
823        let res = r
824            .resolve(EntityKind::Concept, Some("model"))
825            .expect("model is an alias for model_family");
826        assert_eq!(res.entity_type.as_deref(), Some("model_family"));
827        let res2 = r
828            .resolve(EntityKind::Concept, Some("model_family"))
829            .expect("model_family is the canonical name");
830        assert_eq!(res2.entity_type.as_deref(), Some("model_family"));
831    }
832
833    // ── Case insensitivity ───────────────────────────────────────────────────
834
835    #[test]
836    fn resolve_is_case_insensitive() {
837        let r = reg();
838        let res = r
839            .resolve(EntityKind::Concept, Some("Algorithm"))
840            .expect("Algorithm (mixed case) must resolve");
841        assert_eq!(res.entity_type.as_deref(), Some("algorithm"));
842    }
843
844    // ── valid_types_for ──────────────────────────────────────────────────────
845
846    #[test]
847    fn valid_types_for_person_is_none_registered() {
848        let r = reg();
849        let s = r.valid_types_for(EntityKind::Person);
850        assert_eq!(
851            s, "(none registered)",
852            "Person has no built-in subtypes; got: {s}"
853        );
854    }
855
856    #[test]
857    fn valid_types_for_concept_includes_algorithm() {
858        let r = reg();
859        let s = r.valid_types_for(EntityKind::Concept);
860        assert!(
861            s.contains("algorithm"),
862            "Concept valid types must include algorithm; got: {s}"
863        );
864    }
865
866    #[test]
867    fn resolve_inductive_alias_to_structure() {
868        let r = reg();
869        let res = r
870            .resolve(EntityKind::Concept, Some("inductive"))
871            .expect("inductive is a valid alias for structure");
872        assert_eq!(res.kind, EntityKind::Concept);
873        assert_eq!(res.entity_type.as_deref(), Some("structure"));
874    }
875
876    #[test]
877    fn resolve_goal_subtype() {
878        let r = reg();
879        let res = r
880            .resolve(EntityKind::Concept, Some("goal"))
881            .expect("goal is a valid Concept subtype");
882        assert_eq!(res.kind, EntityKind::Concept);
883        assert_eq!(res.entity_type.as_deref(), Some("goal"));
884    }
885
886    // ── Global registry ──────────────────────────────────────────────────────
887
888    #[cfg(feature = "std")]
889    #[test]
890    fn global_registry_is_accessible() {
891        let r = EntityTypeRegistry::global();
892        let res = r
893            .resolve(EntityKind::Document, Some("paper"))
894            .expect("global registry must resolve paper");
895        assert_eq!(res.entity_type.as_deref(), Some("paper"));
896    }
897
898    // ── snake_case normalisation (ADR-001:106) ───────────────────────────────
899
900    #[test]
901    fn to_snake_case_converts_hyphen_to_underscore() {
902        assert_eq!(to_snake_case("proof-goal"), "proof_goal");
903    }
904
905    #[test]
906    fn to_snake_case_converts_space_to_underscore() {
907        assert_eq!(to_snake_case("Proof Goal"), "proof_goal");
908    }
909
910    #[test]
911    fn to_snake_case_collapses_mixed_separators() {
912        assert_eq!(to_snake_case("proof - goal"), "proof_goal");
913        assert_eq!(to_snake_case("proof__goal"), "proof_goal");
914    }
915
916    #[test]
917    fn to_snake_case_strips_leading_trailing_separators() {
918        assert_eq!(to_snake_case("-theorem-"), "theorem");
919        assert_eq!(to_snake_case(" theorem "), "theorem");
920    }
921
922    #[test]
923    fn resolve_proof_goal_hyphen_normalises_to_goal() {
924        let r = reg();
925        let res = r
926            .resolve(EntityKind::Concept, Some("proof-goal"))
927            .expect("proof-goal must resolve via snake_case normalisation + alias");
928        assert_eq!(res.entity_type.as_deref(), Some("goal"));
929    }
930
931    #[test]
932    fn resolve_proof_goal_space_normalises_to_goal() {
933        let r = reg();
934        let res = r
935            .resolve(EntityKind::Concept, Some("Proof Goal"))
936            .expect("Proof Goal must resolve via snake_case normalisation + alias");
937        assert_eq!(res.entity_type.as_deref(), Some("goal"));
938    }
939
940    #[test]
941    fn resolve_blog_hyphen_normalises_to_blog_post_non_formal() {
942        let r = reg();
943        let res = r
944            .resolve(EntityKind::Document, Some("blog-post"))
945            .expect("blog-post must normalise to blog_post");
946        assert_eq!(res.entity_type.as_deref(), Some("blog_post"));
947    }
948
949    // ── ADR-085 code subtypes ────────────────────────────────────────────────
950
951    #[test]
952    fn entity_type_registry_accepts_code_tokens_and_aliases() {
953        let r = reg();
954        for (raw, canonical) in [
955            ("module", "module"),
956            ("mod", "module"),
957            ("namespace", "module"),
958            ("function", "function"),
959            ("fn", "function"),
960            ("func", "function"),
961            ("method", "function"),
962            ("datatype", "datatype"),
963            ("enum", "datatype"),
964            ("record", "datatype"),
965            ("type_alias", "datatype"),
966            ("interface", "interface"),
967            ("trait", "interface"),
968            ("protocol", "interface"),
969        ] {
970            let res = r
971                .resolve(EntityKind::Concept, Some(raw))
972                .unwrap_or_else(|e| panic!("{raw:?} must resolve for Concept: {e}"));
973            assert_eq!(
974                res.entity_type.as_deref(),
975                Some(canonical),
976                "{raw:?} must resolve to {canonical:?}"
977            );
978        }
979    }
980
981    #[test]
982    fn entity_type_registry_does_not_claim_struct_or_class_for_code() {
983        let r = reg();
984        let struct_res = r
985            .resolve(EntityKind::Concept, Some("struct"))
986            .expect("struct remains a valid Concept subtype (owned by formal structure)");
987        assert_eq!(
988            struct_res.entity_type.as_deref(),
989            Some("structure"),
990            "struct must still resolve to formal-math structure, not datatype"
991        );
992        let class_res = r
993            .resolve(EntityKind::Concept, Some("class"))
994            .expect("class remains a valid Concept subtype (owned by formal structure)");
995        assert_eq!(
996            class_res.entity_type.as_deref(),
997            Some("structure"),
998            "class must still resolve to formal-math structure, not datatype"
999        );
1000    }
1001
1002    // ── check_extra_collisions: ADR-001 normalisation (PR #925) ──
1003    //
1004    // `check_extra_collisions` must reject collisions that only become
1005    // visible after ADR-001:104-107's write-time `to_snake_case`
1006    // normalisation — i.e. two raw spellings that differ in case or
1007    // separator style but land on the same kind-qualified key once
1008    // normalised. Before the r2 fix these three cases silently passed
1009    // (`Ok(())`) because the check compared raw, unnormalised strings.
1010
1011    #[test]
1012    fn check_extra_collisions_detects_normalized_alias_vs_alias() {
1013        let def_a = EntityTypeDef {
1014            kind: EntityKind::Service,
1015            type_name: "widget_service",
1016            aliases: &["Widget-Alias"],
1017        };
1018        let def_b = EntityTypeDef {
1019            kind: EntityKind::Service,
1020            type_name: "gadget_service",
1021            aliases: &["widget_alias"],
1022        };
1023        let err =
1024            EntityTypeRegistry::check_extra_collisions([("pack_a", &def_a), ("pack_b", &def_b)])
1025                .expect_err(
1026                    "aliases that only differ in case/hyphenation but normalise to the same \
1027             kind-qualified key must collide",
1028                );
1029        assert!(err.contains("pack_a"), "error must name pack_a: {err}");
1030        assert!(err.contains("pack_b"), "error must name pack_b: {err}");
1031    }
1032
1033    #[test]
1034    fn check_extra_collisions_detects_normalized_alias_vs_canonical() {
1035        let def_a = EntityTypeDef {
1036            kind: EntityKind::Service,
1037            type_name: "primary_service",
1038            aliases: &[],
1039        };
1040        let def_b = EntityTypeDef {
1041            kind: EntityKind::Service,
1042            type_name: "secondary_service",
1043            aliases: &["Primary-Service"],
1044        };
1045        let err =
1046            EntityTypeRegistry::check_extra_collisions([("pack_a", &def_a), ("pack_b", &def_b)])
1047                .expect_err(
1048                    "an alias that normalises onto another owner's canonical key must collide, \
1049             even when the raw spellings differ",
1050                );
1051        assert!(err.contains("pack_a"), "error must name pack_a: {err}");
1052        assert!(err.contains("pack_b"), "error must name pack_b: {err}");
1053    }
1054
1055    #[test]
1056    fn check_extra_collisions_detects_normalized_builtin_vs_pack_collision() {
1057        // BUILTIN_DEFS declares Document "paper". A pack declaring a
1058        // differently-cased spelling that normalises onto the same
1059        // kind-qualified key must still collide with the built-in owner.
1060        let def = EntityTypeDef {
1061            kind: EntityKind::Document,
1062            type_name: "Paper",
1063            aliases: &[],
1064        };
1065        let err = EntityTypeRegistry::check_extra_collisions([("pack_a", &def)]).expect_err(
1066            "a pack canonical name that normalises onto a builtin canonical key must collide",
1067        );
1068        assert!(err.contains("builtin"), "error must name builtin: {err}");
1069        assert!(err.contains("pack_a"), "error must name pack_a: {err}");
1070    }
1071}