1use alloc::collections::BTreeMap;
8use alloc::format;
9use alloc::string::{String, ToString};
10use alloc::vec::Vec;
11use core::fmt;
12
13use crate::entity::EntityKind;
14
15pub fn to_snake_case(s: &str) -> String {
26 let mut out = String::with_capacity(s.len());
27 let mut prev_sep = true; 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#[derive(Clone, Debug)]
48pub struct EntityTypeDef {
49 pub kind: EntityKind,
51 pub type_name: &'static str,
53 pub aliases: &'static [&'static str],
56}
57
58static BUILTIN_DEFS: &[EntityTypeDef] = &[
60 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 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 EntityTypeDef {
116 kind: EntityKind::Concept,
117 type_name: "algorithm",
118 aliases: &["algo"],
119 },
120 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 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 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 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 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 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 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 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 ];
395
396#[derive(Clone, Debug, PartialEq, Eq)]
398pub struct ResolvedEntityType {
399 pub kind: EntityKind,
401 pub entity_type: Option<String>,
404}
405
406#[derive(Clone, Debug, PartialEq, Eq)]
411pub enum EntityTypeError {
412 WrongKind {
414 raw_type: String,
415 actual_kind: &'static str,
416 expected_kind: &'static str,
417 valid: String,
418 },
419 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#[derive(Clone)]
457pub struct EntityTypeRegistry {
458 lookup: BTreeMap<String, usize>,
461 defs: Vec<EntityTypeDef>,
462}
463
464impl EntityTypeRegistry {
465 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 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 pub fn builtin() -> Self {
494 Self::new(BUILTIN_DEFS.iter().cloned())
495 }
496
497 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 pub fn definitions(&self) -> &[EntityTypeDef] {
506 &self.defs
507 }
508
509 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 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 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 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 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 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 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 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#[cfg(feature = "std")]
663static GLOBAL_REGISTRY: std::sync::OnceLock<EntityTypeRegistry> = std::sync::OnceLock::new();
664
665#[cfg(feature = "std")]
666impl EntityTypeRegistry {
667 pub fn global() -> &'static EntityTypeRegistry {
669 GLOBAL_REGISTRY.get_or_init(EntityTypeRegistry::builtin)
670 }
671}
672
673#[cfg(test)]
676mod tests {
677 use super::*;
678 use crate::entity::EntityKind;
679
680 fn reg() -> EntityTypeRegistry {
681 EntityTypeRegistry::builtin()
682 }
683
684 #[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 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 #[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 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 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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 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 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 #[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 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}