Skip to main content

knowledge_runtime/entity/
registry.rs

1//! Scope-aware in-memory entity registry with alias resolution and fallback.
2
3use crate::error::RuntimeError;
4use crate::ids::{EntityId, ScopeKey};
5use serde::{Deserialize, Serialize};
6use std::collections::BTreeMap;
7
8/// Entity kind discriminant.
9#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum EntityKind {
12    /// A person (contributor, user, author).
13    Person,
14    /// A code entity (function, type, module, etc.).
15    Code,
16    /// A project, product, or system.
17    Project,
18    /// A concept or topic.
19    Concept,
20    /// Untyped / caller-defined.
21    Custom(String),
22}
23
24/// A registered entity with aliases and metadata.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct Entity {
27    /// Canonical identity.
28    pub id: EntityId,
29    /// Human-readable canonical name.
30    pub canonical_name: String,
31    /// Entity kind.
32    pub kind: EntityKind,
33    /// Scope this entity belongs to.
34    pub scope: ScopeKey,
35    /// Alternative names that resolve to this entity.
36    pub aliases: Vec<String>,
37    /// Optional structured metadata.
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub metadata: Option<serde_json::Value>,
40}
41
42/// Match quality when resolving a mention to an entity.
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "snake_case")]
45pub enum MatchQuality {
46    /// Exact canonical name or ID match within the requested scope.
47    ExactCanonical,
48    /// Matched through an alias within the requested scope.
49    ExactAlias,
50    /// Matched in a broader/different scope as a fallback.
51    /// The `actual_scope` field in `ResolveResult` shows where it was found.
52    ScopedFallback,
53    /// No registered match — the mention is an unresolved candidate.
54    Unresolved,
55}
56
57/// Result of resolving a mention against the registry.
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct ResolveResult {
60    /// The primary resolved entity, if found.
61    pub entity: Option<Entity>,
62    /// How the match was made.
63    pub quality: MatchQuality,
64    /// The original mention that was resolved.
65    pub mention: String,
66    /// Additional candidates if the mention was ambiguous across scopes.
67    pub alternatives: Vec<Entity>,
68    /// The scope that was queried.
69    pub queried_scope: ScopeKey,
70}
71
72/// Composite key for scope-partitioned entity indexes.
73#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
74struct ScopedName {
75    scope: ScopeKey,
76    lower_name: String,
77}
78
79/// Scope-aware in-memory entity registry.
80///
81/// Entities are partitioned by scope. Resolution first tries the exact scope,
82/// then falls back to namespace-only scope if the exact scope yields nothing.
83/// Cross-scope collisions are avoided by using `ScopeKey` in all indexes.
84///
85/// This registry owns entity data as a derived projection — it does NOT
86/// own source truth. Entities can be rebuilt from upstream stores.
87#[derive(Debug)]
88pub struct EntityRegistry {
89    /// Entities by ID.
90    entities: BTreeMap<EntityId, Entity>,
91    /// Index: (scope, lowercase canonical name) -> entity ID.
92    name_index: BTreeMap<ScopedName, EntityId>,
93    /// Index: (scope, lowercase alias) -> entity ID.
94    alias_index: BTreeMap<ScopedName, EntityId>,
95    /// Maximum aliases per entity.
96    max_aliases: usize,
97    /// Maximum total entities across all scopes.
98    max_entities: usize,
99}
100
101impl EntityRegistry {
102    /// Create a new registry with bounded alias and entity limits.
103    pub fn new(max_aliases: usize, max_entities: usize) -> Self {
104        Self {
105            entities: BTreeMap::new(),
106            name_index: BTreeMap::new(),
107            alias_index: BTreeMap::new(),
108            max_aliases,
109            max_entities,
110        }
111    }
112
113    /// Register a new entity. Returns error if the registry is full.
114    pub fn register(&mut self, entity: Entity) -> Result<(), RuntimeError> {
115        if self.entities.len() >= self.max_entities && !self.entities.contains_key(&entity.id) {
116            return Err(RuntimeError::RegistryFull {
117                capacity: self.max_entities,
118            });
119        }
120
121        if entity.aliases.len() > self.max_aliases {
122            return Err(RuntimeError::InvalidConfig {
123                field: "entity.aliases",
124                reason: format!(
125                    "entity has {} aliases, max is {}",
126                    entity.aliases.len(),
127                    self.max_aliases
128                ),
129            });
130        }
131
132        // Remove old index entries if updating
133        if let Some(old) = self.entities.remove(&entity.id) {
134            let old_key = ScopedName {
135                scope: old.scope.clone(),
136                lower_name: old.canonical_name.to_lowercase(),
137            };
138            self.name_index.remove(&old_key);
139            for alias in &old.aliases {
140                let alias_key = ScopedName {
141                    scope: old.scope.clone(),
142                    lower_name: alias.to_lowercase(),
143                };
144                self.alias_index.remove(&alias_key);
145            }
146        }
147
148        // COR-005: Check for canonical/alias collisions before inserting.
149        // Reject if another entity (different ID) already owns this name or alias.
150        let name_key = ScopedName {
151            scope: entity.scope.clone(),
152            lower_name: entity.canonical_name.to_lowercase(),
153        };
154        if let Some(existing_id) = self.name_index.get(&name_key) {
155            if existing_id != &entity.id {
156                return Err(RuntimeError::EntityCollision {
157                    name: entity.canonical_name.clone(),
158                    existing_id: existing_id.clone(),
159                    new_id: entity.id.clone(),
160                });
161            }
162        }
163        for alias in &entity.aliases {
164            let alias_key = ScopedName {
165                scope: entity.scope.clone(),
166                lower_name: alias.to_lowercase(),
167            };
168            if let Some(existing_id) = self.alias_index.get(&alias_key) {
169                if existing_id != &entity.id {
170                    return Err(RuntimeError::EntityCollision {
171                        name: alias.clone(),
172                        existing_id: existing_id.clone(),
173                        new_id: entity.id.clone(),
174                    });
175                }
176            }
177            // Also check if alias collides with another entity's canonical name
178            if let Some(existing_id) = self.name_index.get(&alias_key) {
179                if existing_id != &entity.id {
180                    return Err(RuntimeError::EntityCollision {
181                        name: alias.clone(),
182                        existing_id: existing_id.clone(),
183                        new_id: entity.id.clone(),
184                    });
185                }
186            }
187        }
188
189        // Insert index entries (collision-free at this point)
190        self.name_index.insert(name_key, entity.id.clone());
191
192        for alias in &entity.aliases {
193            let alias_key = ScopedName {
194                scope: entity.scope.clone(),
195                lower_name: alias.to_lowercase(),
196            };
197            self.alias_index.insert(alias_key, entity.id.clone());
198        }
199
200        self.entities.insert(entity.id.clone(), entity);
201        Ok(())
202    }
203
204    /// Remove an entity by ID.
205    pub fn remove(&mut self, id: &EntityId) -> Option<Entity> {
206        if let Some(entity) = self.entities.remove(id) {
207            let name_key = ScopedName {
208                scope: entity.scope.clone(),
209                lower_name: entity.canonical_name.to_lowercase(),
210            };
211            self.name_index.remove(&name_key);
212            for alias in &entity.aliases {
213                let alias_key = ScopedName {
214                    scope: entity.scope.clone(),
215                    lower_name: alias.to_lowercase(),
216                };
217                self.alias_index.remove(&alias_key);
218            }
219            Some(entity)
220        } else {
221            None
222        }
223    }
224
225    /// Look up an entity by ID.
226    pub fn get(&self, id: &EntityId) -> Option<&Entity> {
227        self.entities.get(id)
228    }
229
230    /// Resolve a raw mention within a specific scope.
231    ///
232    /// Resolution order:
233    /// 1. Exact canonical name match in the given scope -> `ExactCanonical`
234    /// 2. Exact alias match in the given scope -> `ExactAlias`
235    /// 3. Namespace-only fallback (if scope has more dimensions) -> `ScopedFallback`
236    /// 4. Unresolved
237    ///
238    /// Alternatives from other scopes with the same namespace are included
239    /// in `ResolveResult::alternatives` when the primary match is `Unresolved`
240    /// or `ScopedFallback`, so callers can see potential ambiguity.
241    pub fn resolve(&self, mention: &str, scope: &ScopeKey) -> ResolveResult {
242        let lower = mention.to_lowercase();
243
244        // 1. Exact name match in given scope
245        let exact_name_key = ScopedName {
246            scope: scope.clone(),
247            lower_name: lower.clone(),
248        };
249        if let Some(id) = self.name_index.get(&exact_name_key) {
250            if let Some(entity) = self.entities.get(id) {
251                return ResolveResult {
252                    entity: Some(entity.clone()),
253                    quality: MatchQuality::ExactCanonical,
254                    mention: mention.to_string(),
255                    alternatives: Vec::new(),
256                    queried_scope: scope.clone(),
257                };
258            }
259        }
260
261        // 2. Exact alias match in given scope
262        let exact_alias_key = ScopedName {
263            scope: scope.clone(),
264            lower_name: lower.clone(),
265        };
266        if let Some(id) = self.alias_index.get(&exact_alias_key) {
267            if let Some(entity) = self.entities.get(id) {
268                return ResolveResult {
269                    entity: Some(entity.clone()),
270                    quality: MatchQuality::ExactAlias,
271                    mention: mention.to_string(),
272                    alternatives: Vec::new(),
273                    queried_scope: scope.clone(),
274                };
275            }
276        }
277
278        // 3. Namespace-only fallback if scope has more dimensions
279        let has_extra_dims =
280            scope.domain.is_some() || scope.workspace_id.is_some() || scope.repo_id.is_some();
281
282        if has_extra_dims {
283            let ns_only = ScopeKey::namespace_only(&scope.namespace);
284            let ns_name_key = ScopedName {
285                scope: ns_only.clone(),
286                lower_name: lower.clone(),
287            };
288            if let Some(id) = self.name_index.get(&ns_name_key) {
289                if let Some(entity) = self.entities.get(id) {
290                    let alternatives = self.find_alternatives(mention, scope, Some(id));
291                    return ResolveResult {
292                        entity: Some(entity.clone()),
293                        quality: MatchQuality::ScopedFallback,
294                        mention: mention.to_string(),
295                        alternatives,
296                        queried_scope: scope.clone(),
297                    };
298                }
299            }
300            let ns_alias_key = ScopedName {
301                scope: ns_only,
302                lower_name: lower.clone(),
303            };
304            if let Some(id) = self.alias_index.get(&ns_alias_key) {
305                if let Some(entity) = self.entities.get(id) {
306                    let alternatives = self.find_alternatives(mention, scope, Some(id));
307                    return ResolveResult {
308                        entity: Some(entity.clone()),
309                        quality: MatchQuality::ScopedFallback,
310                        mention: mention.to_string(),
311                        alternatives,
312                        queried_scope: scope.clone(),
313                    };
314                }
315            }
316        }
317
318        // 4. Unresolved — gather alternatives from same namespace
319        let alternatives = self.find_alternatives(mention, scope, None);
320
321        ResolveResult {
322            entity: None,
323            quality: MatchQuality::Unresolved,
324            mention: mention.to_string(),
325            alternatives,
326            queried_scope: scope.clone(),
327        }
328    }
329
330    /// Find entities matching `mention` in the same namespace but different
331    /// sub-scopes, excluding `exclude_id`.
332    fn find_alternatives(
333        &self,
334        mention: &str,
335        scope: &ScopeKey,
336        exclude_id: Option<&EntityId>,
337    ) -> Vec<Entity> {
338        let lower = mention.to_lowercase();
339        let mut seen = std::collections::HashSet::new();
340        let mut alts = Vec::new();
341
342        for (key, id) in self.name_index.iter().chain(self.alias_index.iter()) {
343            if key.lower_name == lower
344                && key.scope.namespace == scope.namespace
345                && key.scope != *scope
346                && (exclude_id != Some(id))
347                && seen.insert(id.clone())
348            {
349                if let Some(entity) = self.entities.get(id) {
350                    alts.push(entity.clone());
351                }
352            }
353        }
354        alts
355    }
356
357    /// Number of registered entities.
358    pub fn len(&self) -> usize {
359        self.entities.len()
360    }
361
362    /// Whether the registry is empty.
363    pub fn is_empty(&self) -> bool {
364        self.entities.is_empty()
365    }
366
367    /// Iterate over all entities.
368    pub fn iter(&self) -> impl Iterator<Item = &Entity> {
369        self.entities.values()
370    }
371
372    /// Iterate over entities in a specific scope.
373    pub fn iter_scope<'a>(&'a self, scope: &'a ScopeKey) -> impl Iterator<Item = &'a Entity> + 'a {
374        self.entities.values().filter(move |e| &e.scope == scope)
375    }
376
377    /// Remove all entities across all scopes (for full cache rebuild).
378    pub fn clear_all(&mut self) {
379        self.entities.clear();
380        self.name_index.clear();
381        self.alias_index.clear();
382    }
383
384    /// Remove all entities in a specific scope.
385    pub fn clear_scope(&mut self, scope: &ScopeKey) {
386        let ids_to_remove: Vec<EntityId> = self
387            .entities
388            .values()
389            .filter(|e| &e.scope == scope)
390            .map(|e| e.id.clone())
391            .collect();
392        for id in ids_to_remove {
393            self.remove(&id);
394        }
395    }
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    fn make_entity(name: &str, scope: ScopeKey, aliases: Vec<&str>) -> Entity {
403        Entity {
404            id: EntityId::new(format!("{}@{}", name.to_lowercase(), scope)),
405            canonical_name: name.to_string(),
406            kind: EntityKind::Person,
407            scope,
408            aliases: aliases.into_iter().map(String::from).collect(),
409            metadata: None,
410        }
411    }
412
413    #[test]
414    fn exact_name_match_in_scope() {
415        let scope = ScopeKey::namespace_only("test");
416        let mut reg = EntityRegistry::new(16, 100);
417        reg.register(make_entity("Alice", scope.clone(), vec![]))
418            .unwrap();
419
420        let result = reg.resolve("alice", &scope);
421        assert_eq!(result.quality, MatchQuality::ExactCanonical);
422        assert!(result.entity.is_some());
423    }
424
425    #[test]
426    fn alias_match_in_scope() {
427        let scope = ScopeKey::namespace_only("test");
428        let mut reg = EntityRegistry::new(16, 100);
429        reg.register(make_entity("Alice", scope.clone(), vec!["ally"]))
430            .unwrap();
431
432        let result = reg.resolve("ally", &scope);
433        assert_eq!(result.quality, MatchQuality::ExactAlias);
434    }
435
436    #[test]
437    fn unresolved_mention() {
438        let scope = ScopeKey::namespace_only("test");
439        let reg = EntityRegistry::new(16, 100);
440        let result = reg.resolve("unknown", &scope);
441        assert_eq!(result.quality, MatchQuality::Unresolved);
442        assert!(result.entity.is_none());
443    }
444
445    #[test]
446    fn same_mention_different_scopes_resolves_differently() {
447        let scope_a = ScopeKey {
448            namespace: "ns".into(),
449            domain: None,
450            workspace_id: None,
451            repo_id: Some("repo-a".into()),
452        };
453        let scope_b = ScopeKey {
454            namespace: "ns".into(),
455            domain: None,
456            workspace_id: None,
457            repo_id: Some("repo-b".into()),
458        };
459
460        let mut reg = EntityRegistry::new(16, 100);
461        reg.register(Entity {
462            id: EntityId::new("alice-a"),
463            canonical_name: "Alice".into(),
464            kind: EntityKind::Person,
465            scope: scope_a.clone(),
466            aliases: vec![],
467            metadata: None,
468        })
469        .unwrap();
470        reg.register(Entity {
471            id: EntityId::new("alice-b"),
472            canonical_name: "Alice".into(),
473            kind: EntityKind::Person,
474            scope: scope_b.clone(),
475            aliases: vec![],
476            metadata: None,
477        })
478        .unwrap();
479
480        let result_a = reg.resolve("Alice", &scope_a);
481        let result_b = reg.resolve("Alice", &scope_b);
482
483        assert_eq!(result_a.quality, MatchQuality::ExactCanonical);
484        assert_eq!(result_b.quality, MatchQuality::ExactCanonical);
485        assert_eq!(result_a.entity.unwrap().id.as_str(), "alice-a");
486        assert_eq!(result_b.entity.unwrap().id.as_str(), "alice-b");
487    }
488
489    #[test]
490    fn alias_only_works_in_correct_scope() {
491        let scope_a = ScopeKey {
492            namespace: "ns".into(),
493            domain: None,
494            workspace_id: None,
495            repo_id: Some("repo-a".into()),
496        };
497        let scope_b = ScopeKey {
498            namespace: "ns".into(),
499            domain: None,
500            workspace_id: None,
501            repo_id: Some("repo-b".into()),
502        };
503
504        let mut reg = EntityRegistry::new(16, 100);
505        reg.register(Entity {
506            id: EntityId::new("alice-a"),
507            canonical_name: "Alice".into(),
508            kind: EntityKind::Person,
509            scope: scope_a.clone(),
510            aliases: vec!["ally".into()],
511            metadata: None,
512        })
513        .unwrap();
514
515        // "ally" resolves in scope_a
516        let result = reg.resolve("ally", &scope_a);
517        assert_eq!(result.quality, MatchQuality::ExactAlias);
518
519        // "ally" does NOT resolve in scope_b
520        let result = reg.resolve("ally", &scope_b);
521        assert_eq!(result.quality, MatchQuality::Unresolved);
522        // But it appears as an alternative
523        assert_eq!(result.alternatives.len(), 1);
524        assert_eq!(result.alternatives[0].id.as_str(), "alice-a");
525    }
526
527    #[test]
528    fn scoped_fallback_to_namespace_only() {
529        let ns_scope = ScopeKey::namespace_only("ns");
530        let narrow_scope = ScopeKey {
531            namespace: "ns".into(),
532            domain: Some("code".into()),
533            workspace_id: None,
534            repo_id: None,
535        };
536
537        let mut reg = EntityRegistry::new(16, 100);
538        reg.register(Entity {
539            id: EntityId::new("alice-ns"),
540            canonical_name: "Alice".into(),
541            kind: EntityKind::Person,
542            scope: ns_scope.clone(),
543            aliases: vec![],
544            metadata: None,
545        })
546        .unwrap();
547
548        // Querying narrow scope falls back to namespace-only
549        let result = reg.resolve("Alice", &narrow_scope);
550        assert_eq!(result.quality, MatchQuality::ScopedFallback);
551        assert!(result.entity.is_some());
552    }
553
554    #[test]
555    fn registry_full_rejects() {
556        let scope = ScopeKey::namespace_only("test");
557        let mut reg = EntityRegistry::new(16, 1);
558        reg.register(make_entity("Alice", scope.clone(), vec![]))
559            .unwrap();
560        let err = reg.register(make_entity("Bob", scope, vec![])).unwrap_err();
561        assert_eq!(err.kind(), "registry_full");
562    }
563
564    #[test]
565    fn update_replaces_old_indexes() {
566        let scope = ScopeKey::namespace_only("test");
567        let mut reg = EntityRegistry::new(16, 100);
568        reg.register(make_entity("Alice", scope.clone(), vec!["ally"]))
569            .unwrap();
570        // Re-register with different alias
571        let updated = Entity {
572            id: EntityId::new(format!("alice@{}", scope)),
573            canonical_name: "Alice".into(),
574            kind: EntityKind::Person,
575            scope: scope.clone(),
576            aliases: vec!["al".into()],
577            metadata: None,
578        };
579        reg.register(updated).unwrap();
580
581        // Old alias should no longer resolve
582        assert_eq!(
583            reg.resolve("ally", &scope).quality,
584            MatchQuality::Unresolved
585        );
586        // New alias should resolve
587        assert_eq!(reg.resolve("al", &scope).quality, MatchQuality::ExactAlias);
588    }
589
590    #[test]
591    fn clear_scope_removes_only_targeted_scope() {
592        let scope_a = ScopeKey::namespace_only("a");
593        let scope_b = ScopeKey::namespace_only("b");
594        let mut reg = EntityRegistry::new(16, 100);
595        reg.register(make_entity("Alice", scope_a.clone(), vec![]))
596            .unwrap();
597        reg.register(make_entity("Bob", scope_b.clone(), vec![]))
598            .unwrap();
599
600        reg.clear_scope(&scope_a);
601        assert_eq!(reg.len(), 1);
602        assert_eq!(
603            reg.resolve("Alice", &scope_a).quality,
604            MatchQuality::Unresolved
605        );
606        assert_eq!(
607            reg.resolve("Bob", &scope_b).quality,
608            MatchQuality::ExactCanonical
609        );
610    }
611
612    #[test]
613    fn clear_all_removes_everything() {
614        let scope_a = ScopeKey::namespace_only("a");
615        let scope_b = ScopeKey::namespace_only("b");
616        let mut reg = EntityRegistry::new(16, 100);
617        reg.register(make_entity("Alice", scope_a.clone(), vec!["ally"]))
618            .unwrap();
619        reg.register(make_entity("Bob", scope_b.clone(), vec!["bobby"]))
620            .unwrap();
621
622        assert_eq!(reg.len(), 2);
623        reg.clear_all();
624        assert_eq!(reg.len(), 0);
625        assert!(reg.is_empty());
626
627        // All indexes must be cleared — no resolution should succeed
628        assert_eq!(
629            reg.resolve("Alice", &scope_a).quality,
630            MatchQuality::Unresolved
631        );
632        assert_eq!(
633            reg.resolve("ally", &scope_a).quality,
634            MatchQuality::Unresolved
635        );
636        assert_eq!(
637            reg.resolve("Bob", &scope_b).quality,
638            MatchQuality::Unresolved
639        );
640        assert_eq!(
641            reg.resolve("bobby", &scope_b).quality,
642            MatchQuality::Unresolved
643        );
644    }
645
646    #[test]
647    fn clear_all_allows_re_registration() {
648        let scope = ScopeKey::namespace_only("test");
649        let mut reg = EntityRegistry::new(16, 2);
650        reg.register(make_entity("Alice", scope.clone(), vec![]))
651            .unwrap();
652        reg.register(make_entity("Bob", scope.clone(), vec![]))
653            .unwrap();
654
655        // Registry is now full
656        assert!(reg
657            .register(make_entity("Charlie", scope.clone(), vec![]))
658            .is_err());
659
660        // Clear and re-register
661        reg.clear_all();
662        reg.register(make_entity("Charlie", scope.clone(), vec![]))
663            .unwrap();
664        assert_eq!(reg.len(), 1);
665        assert_eq!(
666            reg.resolve("Charlie", &scope).quality,
667            MatchQuality::ExactCanonical
668        );
669    }
670}