Skip to main content

ralph_core/
hat_registry.rs

1//! Hat registry for managing agent personas.
2
3use crate::config::{HatConfig, RalphConfig};
4use ralph_proto::{Hat, HatId, Topic};
5use std::collections::{BTreeMap, HashSet};
6
7/// Registry for managing and creating hats from configuration.
8#[derive(Debug, Default)]
9pub struct HatRegistry {
10    hats: BTreeMap<HatId, Hat>,
11    configs: BTreeMap<HatId, HatConfig>,
12    /// Prefix index for O(1) early-exit on no-match lookups.
13    /// Contains all first segments of subscription patterns (e.g., "task" from "task.*").
14    /// Also contains "*" if any global wildcard exists.
15    prefix_index: HashSet<String>,
16}
17
18impl HatRegistry {
19    /// Creates a new empty registry.
20    pub fn new() -> Self {
21        Self::default()
22    }
23
24    /// Creates a registry from configuration.
25    ///
26    /// Empty config → empty registry (HatlessRalph is the fallback, not default hats).
27    pub fn from_config(config: &RalphConfig) -> Self {
28        let mut registry = Self::new();
29
30        for (id, hat_config) in &config.hats {
31            let hat = Self::hat_from_config(id, hat_config);
32            registry.register_with_config(hat, hat_config.clone());
33        }
34
35        registry
36    }
37
38    /// Creates a Hat from HatConfig.
39    fn hat_from_config(id: &str, config: &HatConfig) -> Hat {
40        let mut hat = Hat::new(id, &config.name);
41        hat.description = config.description.clone().unwrap_or_default();
42        hat.subscriptions = config.trigger_topics();
43        hat.publishes = config.publish_topics();
44        hat.instructions = config.instructions.clone();
45        hat
46    }
47
48    /// Registers a hat with the registry.
49    pub fn register(&mut self, hat: Hat) {
50        self.index_hat_subscriptions(&hat);
51        self.hats.insert(hat.id.clone(), hat);
52    }
53
54    /// Registers a hat with its configuration.
55    pub fn register_with_config(&mut self, hat: Hat, config: HatConfig) {
56        let id = hat.id.clone();
57        self.index_hat_subscriptions(&hat);
58        self.hats.insert(id.clone(), hat);
59        self.configs.insert(id, config);
60    }
61
62    /// Indexes a hat's subscriptions for O(1) prefix lookup.
63    fn index_hat_subscriptions(&mut self, hat: &Hat) {
64        for sub in &hat.subscriptions {
65            let pattern = sub.as_str();
66            // Global wildcard matches everything - mark it specially
67            if pattern == "*" {
68                self.prefix_index.insert("*".to_string());
69            } else {
70                // Extract first segment (e.g., "task" from "task.*" or "task.start")
71                if let Some(prefix) = pattern.split('.').next() {
72                    self.prefix_index.insert(prefix.to_string());
73                }
74            }
75        }
76    }
77
78    /// Gets a hat by ID.
79    pub fn get(&self, id: &HatId) -> Option<&Hat> {
80        self.hats.get(id)
81    }
82
83    /// Gets a hat's configuration by ID.
84    pub fn get_config(&self, id: &HatId) -> Option<&HatConfig> {
85        self.configs.get(id)
86    }
87
88    /// Returns all hats in the registry.
89    pub fn all(&self) -> impl Iterator<Item = &Hat> {
90        self.hats.values()
91    }
92
93    /// Returns all hat IDs.
94    pub fn ids(&self) -> impl Iterator<Item = &HatId> {
95        self.hats.keys()
96    }
97
98    /// Returns the number of registered hats.
99    pub fn len(&self) -> usize {
100        self.hats.len()
101    }
102
103    /// Returns true if no hats are registered.
104    pub fn is_empty(&self) -> bool {
105        self.hats.is_empty()
106    }
107
108    /// Finds all hats subscribed to a topic.
109    /// BTreeMap iteration is already sorted by key.
110    pub fn subscribers(&self, topic: &Topic) -> Vec<&Hat> {
111        self.hats
112            .values()
113            .filter(|hat| hat.is_subscribed(topic))
114            .collect()
115    }
116
117    /// Finds the first hat that would be triggered by a topic.
118    /// Returns the hat ID if found, used for event logging.
119    /// BTreeMap iteration is already sorted by key.
120    pub fn find_by_trigger(&self, topic: &str) -> Option<&HatId> {
121        let topic = Topic::new(topic);
122        self.hats
123            .values()
124            .find(|hat| hat.is_subscribed(&topic))
125            .map(|hat| &hat.id)
126    }
127
128    /// Returns true if any hat is subscribed to the given topic.
129    pub fn has_subscriber(&self, topic: &str) -> bool {
130        let topic = Topic::new(topic);
131        self.hats.values().any(|hat| hat.is_subscribed(&topic))
132    }
133
134    /// Check if a hat is allowed to publish the given topic.
135    ///
136    /// Returns `true` for unregistered hats (Ralph can publish anything).
137    /// Uses the same pattern matching as subscription routing.
138    pub fn can_publish(&self, hat_id: &HatId, topic: &str) -> bool {
139        let Some(hat) = self.hats.get(hat_id) else {
140            return true; // Unregistered hat (ralph), no restriction
141        };
142        hat.publishes
143            .iter()
144            .any(|pub_topic| pub_topic.matches_str(topic))
145    }
146
147    /// Returns the first hat subscribed to the given topic.
148    ///
149    /// Uses prefix index for O(1) early-exit when the topic prefix doesn't match
150    /// any subscription pattern.
151    pub fn get_for_topic(&self, topic: &str) -> Option<&Hat> {
152        // Fast path: Check if any subscription could possibly match this topic
153        // If we have a global wildcard "*", we must do the full scan
154        if !self.prefix_index.contains("*") {
155            // Extract prefix from topic (e.g., "task" from "task.start")
156            let topic_prefix = topic.split('.').next().unwrap_or(topic);
157            if !self.prefix_index.contains(topic_prefix) {
158                // No subscription has this prefix - early exit
159                return None;
160            }
161        }
162
163        // Fall back to full linear scan (BTreeMap is already sorted by key)
164        self.hats.values().find(|hat| hat.is_subscribed_str(topic))
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use std::time::Instant;
172
173    #[test]
174    fn test_empty_config_creates_empty_registry() {
175        let config = RalphConfig::default();
176        let registry = HatRegistry::from_config(&config);
177
178        // Empty config → empty registry (HatlessRalph is the fallback, not default hats)
179        assert!(registry.is_empty());
180        assert_eq!(registry.len(), 0);
181    }
182
183    #[test]
184    fn test_custom_hats_from_config() {
185        let yaml = r#"
186hats:
187  implementer:
188    name: "Implementer"
189    triggers: ["task.*"]
190  reviewer:
191    name: "Reviewer"
192    triggers: ["impl.*"]
193"#;
194        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
195        let registry = HatRegistry::from_config(&config);
196
197        assert_eq!(registry.len(), 2);
198
199        let impl_hat = registry.get(&HatId::new("implementer")).unwrap();
200        assert!(impl_hat.is_subscribed(&Topic::new("task.start")));
201        assert!(!impl_hat.is_subscribed(&Topic::new("impl.done")));
202
203        let review_hat = registry.get(&HatId::new("reviewer")).unwrap();
204        assert!(review_hat.is_subscribed(&Topic::new("impl.done")));
205    }
206
207    #[test]
208    fn test_has_subscriber() {
209        let yaml = r#"
210hats:
211  impl:
212    name: "Implementer"
213    triggers: ["task.*"]
214"#;
215        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
216        let registry = HatRegistry::from_config(&config);
217
218        assert!(registry.has_subscriber("task.start"));
219        assert!(!registry.has_subscriber("build.task"));
220    }
221
222    #[test]
223    fn test_get_for_topic() {
224        let yaml = r#"
225hats:
226  impl:
227    name: "Implementer"
228    triggers: ["task.*"]
229"#;
230        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
231        let registry = HatRegistry::from_config(&config);
232
233        let hat = registry.get_for_topic("task.start");
234        assert!(hat.is_some());
235        assert_eq!(hat.unwrap().id.as_str(), "impl");
236
237        let no_hat = registry.get_for_topic("build.task");
238        assert!(no_hat.is_none());
239    }
240
241    #[test]
242    fn test_empty_registry_has_no_subscribers() {
243        let config = RalphConfig::default();
244        let registry = HatRegistry::from_config(&config);
245
246        // Empty config → no subscribers (HatlessRalph handles orphaned events)
247        assert!(!registry.has_subscriber("build.task"));
248        assert!(registry.get_for_topic("build.task").is_none());
249    }
250
251    #[test]
252    fn test_find_subscribers() {
253        let yaml = r#"
254hats:
255  impl:
256    name: "Implementer"
257    triggers: ["task.*", "review.done"]
258  reviewer:
259    name: "Reviewer"
260    triggers: ["impl.*"]
261"#;
262        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
263        let registry = HatRegistry::from_config(&config);
264
265        let task_subs = registry.subscribers(&Topic::new("task.start"));
266        assert_eq!(task_subs.len(), 1);
267        assert_eq!(task_subs[0].id.as_str(), "impl");
268
269        let impl_subs = registry.subscribers(&Topic::new("impl.done"));
270        assert_eq!(impl_subs.len(), 1);
271        assert_eq!(impl_subs[0].id.as_str(), "reviewer");
272    }
273
274    /// Benchmark test for get_for_topic() performance.
275    /// Run with: cargo test -p ralph-core bench_get_for_topic -- --nocapture
276    #[test]
277    fn bench_get_for_topic_baseline() {
278        // Create registry with 20 hats (realistic production scenario)
279        let mut yaml = String::from("hats:\n");
280        for i in 0..20 {
281            yaml.push_str(&format!(
282                "  hat{}:\n    name: \"Hat {}\"\n    triggers: [\"topic{}.*\", \"other{}.event\"]\n",
283                i, i, i, i
284            ));
285        }
286        let config: RalphConfig = serde_yaml::from_str(&yaml).unwrap();
287        let registry = HatRegistry::from_config(&config);
288
289        // Topics to test - mix of matches and non-matches
290        let topics = [
291            "topic0.start",  // First hat match
292            "topic10.event", // Middle hat match
293            "topic19.done",  // Last hat match
294            "nomatch.topic", // No match
295        ];
296
297        const ITERATIONS: u32 = 100_000;
298
299        let start = Instant::now();
300        for _ in 0..ITERATIONS {
301            for topic in &topics {
302                let _ = registry.get_for_topic(topic);
303            }
304        }
305        let elapsed = start.elapsed();
306
307        let ops = u64::from(ITERATIONS) * (topics.len() as u64);
308        let ns_per_op = elapsed.as_nanos() / u128::from(ops);
309
310        println!("\n=== get_for_topic() Baseline ===");
311        println!("Registry size: {} hats", registry.len());
312        println!("Operations: {}", ops);
313        println!("Total time: {:?}", elapsed);
314        println!("Time per operation: {} ns", ns_per_op);
315        println!("================================\n");
316
317        // Assert reasonable performance (sanity check)
318        assert!(
319            ns_per_op < 10_000,
320            "Performance degraded: {} ns/op",
321            ns_per_op
322        );
323    }
324
325    #[test]
326    fn test_get_for_topic_returns_alphabetically_first_hat() {
327        // Two hats subscribing to same wildcard pattern → get_for_topic returns alphabetically first
328        let yaml = r#"
329hats:
330  zebra:
331    name: "Zebra"
332    triggers: ["task.*"]
333  alpha:
334    name: "Alpha"
335    triggers: ["task.*"]
336"#;
337        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
338        let registry = HatRegistry::from_config(&config);
339
340        // Should deterministically return "alpha" (alphabetically first)
341        let hat = registry.get_for_topic("task.start");
342        assert!(hat.is_some());
343        assert_eq!(
344            hat.unwrap().id.as_str(),
345            "alpha",
346            "get_for_topic should return alphabetically first matching hat"
347        );
348
349        // Run multiple times to confirm determinism
350        for _ in 0..100 {
351            let hat = registry.get_for_topic("task.start").unwrap();
352            assert_eq!(hat.id.as_str(), "alpha");
353        }
354    }
355
356    #[test]
357    fn test_find_by_trigger_returns_alphabetically_first_hat() {
358        let yaml = r#"
359hats:
360  zebra:
361    name: "Zebra"
362    triggers: ["task.*"]
363  alpha:
364    name: "Alpha"
365    triggers: ["task.*"]
366"#;
367        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
368        let registry = HatRegistry::from_config(&config);
369
370        let hat_id = registry.find_by_trigger("task.start");
371        assert!(hat_id.is_some());
372        assert_eq!(
373            hat_id.unwrap().as_str(),
374            "alpha",
375            "find_by_trigger should return alphabetically first matching hat"
376        );
377    }
378
379    #[test]
380    fn test_subscribers_returns_deterministic_order() {
381        let yaml = r#"
382hats:
383  zebra:
384    name: "Zebra"
385    triggers: ["task.*"]
386  middle:
387    name: "Middle"
388    triggers: ["task.*"]
389  alpha:
390    name: "Alpha"
391    triggers: ["task.*"]
392"#;
393        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
394        let registry = HatRegistry::from_config(&config);
395
396        let subs = registry.subscribers(&Topic::new("task.start"));
397        assert_eq!(subs.len(), 3);
398        assert_eq!(subs[0].id.as_str(), "alpha");
399        assert_eq!(subs[1].id.as_str(), "middle");
400        assert_eq!(subs[2].id.as_str(), "zebra");
401    }
402
403    #[test]
404    fn test_can_publish_allows_declared_topic() {
405        let yaml = r#"
406hats:
407  builder:
408    name: "Builder"
409    triggers: ["build.start"]
410    publishes: ["build.done", "build.blocked"]
411"#;
412        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
413        let registry = HatRegistry::from_config(&config);
414
415        assert!(registry.can_publish(&HatId::new("builder"), "build.done"));
416        assert!(registry.can_publish(&HatId::new("builder"), "build.blocked"));
417    }
418
419    #[test]
420    fn test_can_publish_rejects_undeclared_topic() {
421        let yaml = r#"
422hats:
423  builder:
424    name: "Builder"
425    triggers: ["build.start"]
426    publishes: ["build.done"]
427"#;
428        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
429        let registry = HatRegistry::from_config(&config);
430
431        assert!(!registry.can_publish(&HatId::new("builder"), "LOOP_COMPLETE"));
432        assert!(!registry.can_publish(&HatId::new("builder"), "plan.approved"));
433    }
434
435    #[test]
436    fn test_can_publish_allows_wildcard() {
437        let yaml = r#"
438hats:
439  builder:
440    name: "Builder"
441    triggers: ["build.start"]
442    publishes: ["build.*"]
443"#;
444        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
445        let registry = HatRegistry::from_config(&config);
446
447        assert!(registry.can_publish(&HatId::new("builder"), "build.done"));
448        assert!(registry.can_publish(&HatId::new("builder"), "build.blocked"));
449        assert!(!registry.can_publish(&HatId::new("builder"), "LOOP_COMPLETE"));
450    }
451
452    #[test]
453    fn test_can_publish_unknown_hat_allows_all() {
454        let yaml = r#"
455hats:
456  builder:
457    name: "Builder"
458    triggers: ["build.start"]
459    publishes: ["build.done"]
460"#;
461        let config: RalphConfig = serde_yaml::from_str(yaml).unwrap();
462        let registry = HatRegistry::from_config(&config);
463
464        // Unregistered hat (e.g. "ralph") should be able to publish anything
465        assert!(registry.can_publish(&HatId::new("ralph"), "anything"));
466        assert!(registry.can_publish(&HatId::new("ralph"), "LOOP_COMPLETE"));
467    }
468}