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    pub fn new(defs: impl IntoIterator<Item = EntityTypeDef>) -> Self {
441        let defs: Vec<EntityTypeDef> = defs.into_iter().collect();
442        let mut lookup: BTreeMap<String, usize> = BTreeMap::new();
443        for (idx, def) in defs.iter().enumerate() {
444            let canonical_key = format!("{}:{}", def.kind.name(), def.type_name);
445            lookup.insert(canonical_key, idx);
446            let bare_key = def.type_name.to_string();
447            // Bare name without kind prefix — only insert when unambiguous.
448            lookup.entry(bare_key).or_insert(idx);
449            for alias in def.aliases {
450                let kind_alias_key = format!("{}:{}", def.kind.name(), alias);
451                lookup.insert(kind_alias_key, idx);
452                lookup.entry(alias.to_string()).or_insert(idx);
453            }
454        }
455        Self { lookup, defs }
456    }
457
458    /// Return the built-in registry with all static subtype definitions.
459    pub fn builtin() -> Self {
460        Self::new(BUILTIN_DEFS.iter().cloned())
461    }
462
463    /// Derive a registry that includes all built-in subtypes plus the
464    /// caller-supplied extras (used by packs that extend the vocabulary).
465    pub fn with_extra(extra: impl IntoIterator<Item = EntityTypeDef>) -> Self {
466        let defs: Vec<EntityTypeDef> = BUILTIN_DEFS.iter().cloned().chain(extra).collect();
467        Self::new(defs)
468    }
469
470    /// Register additional subtypes into an existing registry clone.
471    pub fn register(&mut self, def: EntityTypeDef) {
472        let idx = self.defs.len();
473        let canonical_key = format!("{}:{}", def.kind.name(), def.type_name);
474        self.lookup.insert(canonical_key, idx);
475        self.lookup.entry(def.type_name.to_string()).or_insert(idx);
476        for alias in def.aliases {
477            let kind_alias_key = format!("{}:{}", def.kind.name(), alias);
478            self.lookup.insert(kind_alias_key, idx);
479            self.lookup.entry(alias.to_string()).or_insert(idx);
480        }
481        self.defs.push(def);
482    }
483
484    /// Validate and normalise a `(kind, entity_type)` wire pair.
485    pub fn resolve(
486        &self,
487        kind: EntityKind,
488        entity_type: Option<&str>,
489    ) -> Result<ResolvedEntityType, EntityTypeError> {
490        let Some(raw_type) = entity_type else {
491            return Ok(ResolvedEntityType {
492                kind,
493                entity_type: None,
494            });
495        };
496
497        let normalised = to_snake_case(raw_type.trim());
498
499        // Try kind-qualified lookup first (unambiguous).
500        let kind_key = format!("{}:{}", kind.name(), normalised);
501        if let Some(&idx) = self.lookup.get(&kind_key) {
502            let def = &self.defs[idx];
503            return Ok(ResolvedEntityType {
504                kind,
505                entity_type: Some(def.type_name.to_string()),
506            });
507        }
508
509        // Try bare lookup — only valid when the bare name belongs to this kind.
510        if let Some(&idx) = self.lookup.get(&normalised) {
511            let def = &self.defs[idx];
512            if def.kind == kind {
513                return Ok(ResolvedEntityType {
514                    kind,
515                    entity_type: Some(def.type_name.to_string()),
516                });
517            }
518            // The name exists but belongs to a different kind.
519            return Err(EntityTypeError::WrongKind {
520                raw_type: raw_type.to_string(),
521                actual_kind: def.kind.name(),
522                expected_kind: kind.name(),
523                valid: self.valid_types_for(kind),
524            });
525        }
526
527        // Not found at all.
528        Err(EntityTypeError::UnknownType {
529            raw_type: raw_type.to_string(),
530            kind: kind.name(),
531            valid: self.valid_types_for(kind),
532        })
533    }
534
535    /// Comma-separated list of canonical type names valid for `kind`.
536    pub fn valid_types_for(&self, kind: EntityKind) -> String {
537        let mut names: Vec<&str> = self
538            .defs
539            .iter()
540            .filter(|d| d.kind == kind)
541            .map(|d| d.type_name)
542            .collect();
543        names.sort_unstable();
544        if names.is_empty() {
545            "(none registered)".to_string()
546        } else {
547            names.join(" | ")
548        }
549    }
550}
551
552// ── Module-level lazy global ─────────────────────────────────────────────────
553
554#[cfg(feature = "std")]
555static GLOBAL_REGISTRY: std::sync::OnceLock<EntityTypeRegistry> = std::sync::OnceLock::new();
556
557#[cfg(feature = "std")]
558impl EntityTypeRegistry {
559    /// Return a reference to the module-level built-in registry.
560    pub fn global() -> &'static EntityTypeRegistry {
561        GLOBAL_REGISTRY.get_or_init(EntityTypeRegistry::builtin)
562    }
563}
564
565// ── Tests ────────────────────────────────────────────────────────────────────
566
567#[cfg(test)]
568mod tests {
569    use super::*;
570    use crate::entity::EntityKind;
571
572    fn reg() -> EntityTypeRegistry {
573        EntityTypeRegistry::builtin()
574    }
575
576    // ── Basic happy-path resolution ──────────────────────────────────────────
577
578    #[test]
579    fn resolve_paper_infers_document() {
580        let r = reg();
581        let res = r
582            .resolve(EntityKind::Document, Some("paper"))
583            .expect("paper is a valid Document subtype");
584        assert_eq!(res.kind, EntityKind::Document);
585        assert_eq!(res.entity_type.as_deref(), Some("paper"));
586    }
587
588    #[test]
589    fn resolve_none_entity_type_always_ok() {
590        let r = reg();
591        for kind in EntityKind::ALL {
592            let res = r.resolve(kind, None).expect("None entity_type always ok");
593            assert_eq!(res.entity_type, None);
594        }
595    }
596
597    #[test]
598    fn resolve_algo_alias_to_algorithm() {
599        let r = reg();
600        let res = r
601            .resolve(EntityKind::Concept, Some("algo"))
602            .expect("algo is a valid alias for algorithm");
603        assert_eq!(res.kind, EntityKind::Concept);
604        assert_eq!(res.entity_type.as_deref(), Some("algorithm"));
605    }
606
607    #[test]
608    fn resolve_spec_alias_to_specification() {
609        let r = reg();
610        let res = r
611            .resolve(EntityKind::Document, Some("spec"))
612            .expect("spec is alias for specification");
613        assert_eq!(res.entity_type.as_deref(), Some("specification"));
614    }
615
616    // ── Rejection tests ──────────────────────────────────────────────────────
617
618    #[test]
619    fn reject_brain_profile_for_concept() {
620        let r = reg();
621        let err = r
622            .resolve(EntityKind::Concept, Some("brain_profile"))
623            .expect_err("brain_profile is not a Concept subtype");
624        let msg = format!("{err}");
625        assert!(
626            msg.contains("brain_profile"),
627            "error must mention the rejected type; got: {msg}"
628        );
629        assert!(
630            msg.contains("concept"),
631            "error must mention the target kind; got: {msg}"
632        );
633    }
634
635    #[test]
636    fn reject_unknown_subtype_with_valid_list() {
637        let r = reg();
638        let err = r
639            .resolve(EntityKind::Document, Some("mystery_type"))
640            .expect_err("mystery_type must be rejected");
641        let msg = format!("{err}");
642        assert!(
643            msg.contains("mystery_type"),
644            "error must echo the rejected value; got: {msg}"
645        );
646        // Valid subtypes for Document must appear.
647        assert!(
648            msg.contains("paper"),
649            "error must list valid Document subtypes; got: {msg}"
650        );
651    }
652
653    #[test]
654    fn reject_wrong_kind_subtype_mentions_correct_kind() {
655        // "paper" belongs to Document; passing it for Concept must fail.
656        let r = reg();
657        let err = r
658            .resolve(EntityKind::Concept, Some("paper"))
659            .expect_err("paper is a Document subtype, not Concept");
660        let msg = format!("{err}");
661        assert!(
662            msg.contains("document") || msg.contains("Document"),
663            "error must name the correct kind; got: {msg}"
664        );
665    }
666
667    // ── Extensibility ────────────────────────────────────────────────────────
668
669    #[test]
670    fn register_brain_profile_for_concept() {
671        let mut r = EntityTypeRegistry::builtin();
672        r.register(EntityTypeDef {
673            kind: EntityKind::Concept,
674            type_name: "brain_profile",
675            aliases: &[],
676        });
677        let res = r
678            .resolve(EntityKind::Concept, Some("brain_profile"))
679            .expect("brain_profile registered for Concept");
680        assert_eq!(res.entity_type.as_deref(), Some("brain_profile"));
681    }
682
683    #[test]
684    fn with_extra_adds_subtypes() {
685        // Use a pack-specific subtype that is NOT in BUILTIN_DEFS.
686        let r = EntityTypeRegistry::with_extra([EntityTypeDef {
687            kind: EntityKind::Service,
688            type_name: "grpc_service",
689            aliases: &["grpc"],
690        }]);
691        let res = r
692            .resolve(EntityKind::Service, Some("grpc"))
693            .expect("grpc alias for grpc_service");
694        assert_eq!(res.entity_type.as_deref(), Some("grpc_service"));
695    }
696
697    #[test]
698    fn service_api_is_builtin() {
699        let r = reg();
700        let res = r
701            .resolve(EntityKind::Service, Some("api"))
702            .expect("api is a built-in Service subtype");
703        assert_eq!(res.entity_type.as_deref(), Some("api"));
704        let res2 = r
705            .resolve(EntityKind::Service, Some("endpoint"))
706            .expect("endpoint is an alias for api");
707        assert_eq!(res2.entity_type.as_deref(), Some("api"));
708    }
709
710    #[test]
711    fn service_mcp_server_is_builtin() {
712        let r = reg();
713        let res = r
714            .resolve(EntityKind::Service, Some("mcp_server"))
715            .expect("mcp_server is a built-in Service subtype");
716        assert_eq!(res.entity_type.as_deref(), Some("mcp_server"));
717        let res2 = r
718            .resolve(EntityKind::Service, Some("mcp"))
719            .expect("mcp is an alias for mcp_server");
720        assert_eq!(res2.entity_type.as_deref(), Some("mcp_server"));
721    }
722
723    #[test]
724    fn project_has_no_service_subtype() {
725        let r = reg();
726        r.resolve(EntityKind::Project, Some("service"))
727            .expect_err("service must not be a Project subtype");
728        r.resolve(EntityKind::Project, Some("svc"))
729            .expect_err("svc must not be a Project subtype");
730    }
731
732    #[test]
733    fn benchmark_is_dataset_not_concept() {
734        let r = reg();
735        let res = r
736            .resolve(EntityKind::Dataset, Some("benchmark"))
737            .expect("benchmark is a valid Dataset subtype");
738        assert_eq!(res.entity_type.as_deref(), Some("benchmark"));
739        r.resolve(EntityKind::Concept, Some("benchmark"))
740            .expect_err("benchmark must not be a Concept subtype");
741    }
742
743    #[test]
744    fn model_alias_resolves_to_model_family() {
745        let r = reg();
746        let res = r
747            .resolve(EntityKind::Concept, Some("model"))
748            .expect("model is an alias for model_family");
749        assert_eq!(res.entity_type.as_deref(), Some("model_family"));
750        let res2 = r
751            .resolve(EntityKind::Concept, Some("model_family"))
752            .expect("model_family is the canonical name");
753        assert_eq!(res2.entity_type.as_deref(), Some("model_family"));
754    }
755
756    // ── Case insensitivity ───────────────────────────────────────────────────
757
758    #[test]
759    fn resolve_is_case_insensitive() {
760        let r = reg();
761        let res = r
762            .resolve(EntityKind::Concept, Some("Algorithm"))
763            .expect("Algorithm (mixed case) must resolve");
764        assert_eq!(res.entity_type.as_deref(), Some("algorithm"));
765    }
766
767    // ── valid_types_for ──────────────────────────────────────────────────────
768
769    #[test]
770    fn valid_types_for_person_is_none_registered() {
771        let r = reg();
772        let s = r.valid_types_for(EntityKind::Person);
773        assert_eq!(
774            s, "(none registered)",
775            "Person has no built-in subtypes; got: {s}"
776        );
777    }
778
779    #[test]
780    fn valid_types_for_concept_includes_algorithm() {
781        let r = reg();
782        let s = r.valid_types_for(EntityKind::Concept);
783        assert!(
784            s.contains("algorithm"),
785            "Concept valid types must include algorithm; got: {s}"
786        );
787    }
788
789    #[test]
790    fn resolve_inductive_alias_to_structure() {
791        let r = reg();
792        let res = r
793            .resolve(EntityKind::Concept, Some("inductive"))
794            .expect("inductive is a valid alias for structure");
795        assert_eq!(res.kind, EntityKind::Concept);
796        assert_eq!(res.entity_type.as_deref(), Some("structure"));
797    }
798
799    #[test]
800    fn resolve_goal_subtype() {
801        let r = reg();
802        let res = r
803            .resolve(EntityKind::Concept, Some("goal"))
804            .expect("goal is a valid Concept subtype");
805        assert_eq!(res.kind, EntityKind::Concept);
806        assert_eq!(res.entity_type.as_deref(), Some("goal"));
807    }
808
809    // ── Global registry ──────────────────────────────────────────────────────
810
811    #[cfg(feature = "std")]
812    #[test]
813    fn global_registry_is_accessible() {
814        let r = EntityTypeRegistry::global();
815        let res = r
816            .resolve(EntityKind::Document, Some("paper"))
817            .expect("global registry must resolve paper");
818        assert_eq!(res.entity_type.as_deref(), Some("paper"));
819    }
820
821    // ── snake_case normalisation (ADR-001:106) ───────────────────────────────
822
823    #[test]
824    fn to_snake_case_converts_hyphen_to_underscore() {
825        assert_eq!(to_snake_case("proof-goal"), "proof_goal");
826    }
827
828    #[test]
829    fn to_snake_case_converts_space_to_underscore() {
830        assert_eq!(to_snake_case("Proof Goal"), "proof_goal");
831    }
832
833    #[test]
834    fn to_snake_case_collapses_mixed_separators() {
835        assert_eq!(to_snake_case("proof - goal"), "proof_goal");
836        assert_eq!(to_snake_case("proof__goal"), "proof_goal");
837    }
838
839    #[test]
840    fn to_snake_case_strips_leading_trailing_separators() {
841        assert_eq!(to_snake_case("-theorem-"), "theorem");
842        assert_eq!(to_snake_case(" theorem "), "theorem");
843    }
844
845    #[test]
846    fn resolve_proof_goal_hyphen_normalises_to_goal() {
847        let r = reg();
848        let res = r
849            .resolve(EntityKind::Concept, Some("proof-goal"))
850            .expect("proof-goal must resolve via snake_case normalisation + alias");
851        assert_eq!(res.entity_type.as_deref(), Some("goal"));
852    }
853
854    #[test]
855    fn resolve_proof_goal_space_normalises_to_goal() {
856        let r = reg();
857        let res = r
858            .resolve(EntityKind::Concept, Some("Proof Goal"))
859            .expect("Proof Goal must resolve via snake_case normalisation + alias");
860        assert_eq!(res.entity_type.as_deref(), Some("goal"));
861    }
862
863    #[test]
864    fn resolve_blog_hyphen_normalises_to_blog_post_non_formal() {
865        let r = reg();
866        let res = r
867            .resolve(EntityKind::Document, Some("blog-post"))
868            .expect("blog-post must normalise to blog_post");
869        assert_eq!(res.entity_type.as_deref(), Some("blog_post"));
870    }
871
872    // ── ADR-085 code subtypes ────────────────────────────────────────────────
873
874    #[test]
875    fn entity_type_registry_accepts_code_tokens_and_aliases() {
876        let r = reg();
877        for (raw, canonical) in [
878            ("module", "module"),
879            ("mod", "module"),
880            ("namespace", "module"),
881            ("function", "function"),
882            ("fn", "function"),
883            ("func", "function"),
884            ("method", "function"),
885            ("datatype", "datatype"),
886            ("enum", "datatype"),
887            ("record", "datatype"),
888            ("type_alias", "datatype"),
889            ("interface", "interface"),
890            ("trait", "interface"),
891            ("protocol", "interface"),
892        ] {
893            let res = r
894                .resolve(EntityKind::Concept, Some(raw))
895                .unwrap_or_else(|e| panic!("{raw:?} must resolve for Concept: {e}"));
896            assert_eq!(
897                res.entity_type.as_deref(),
898                Some(canonical),
899                "{raw:?} must resolve to {canonical:?}"
900            );
901        }
902    }
903
904    #[test]
905    fn entity_type_registry_does_not_claim_struct_or_class_for_code() {
906        let r = reg();
907        let struct_res = r
908            .resolve(EntityKind::Concept, Some("struct"))
909            .expect("struct remains a valid Concept subtype (owned by formal structure)");
910        assert_eq!(
911            struct_res.entity_type.as_deref(),
912            Some("structure"),
913            "struct must still resolve to formal-math structure, not datatype"
914        );
915        let class_res = r
916            .resolve(EntityKind::Concept, Some("class"))
917            .expect("class remains a valid Concept subtype (owned by formal structure)");
918        assert_eq!(
919            class_res.entity_type.as_deref(),
920            Some("structure"),
921            "class must still resolve to formal-math structure, not datatype"
922        );
923    }
924}