Skip to main content

aria_core/
item.rs

1use std::collections::{HashMap, HashSet};
2
3/// Trait that caller's item type must implement.
4///
5/// The engine only needs three things from any item:
6///   - a stable unique ID
7///   - a normalised score proxy (0.0–1.0) used by built-in factors
8///     (difficulty in learning, price_ratio in ecommerce, remoteness in travel…)
9///   - a category/topic string for coverage factor
10///
11/// All other domain data lives in `metadata()` — accessible to custom factors.
12pub trait Scoreable: Send + Sync {
13    fn id(&self) -> &str;
14
15    /// Normalised difficulty/complexity proxy. Range [0.0, 1.0].
16    /// Caller defines what this means in their domain.
17    fn score_proxy(&self) -> f32;
18
19    /// Category label used for coverage balancing.
20    /// e.g. "algebra", "electronics", "beach", "thriller"
21    fn category(&self) -> &str;
22
23    /// Prerequisite item IDs. Engine will not suggest this item
24    /// until all prereqs appear in the user's `resolved_set`.
25    fn prerequisites(&self) -> &[String] {
26        &[]
27    }
28
29    /// Arbitrary key-value metadata for custom factor logic.
30    fn metadata(&self) -> &HashMap<String, String>;
31}
32
33/// Concrete generic item — callers can use this directly
34/// or implement `Scoreable` on their own type.
35#[derive(Debug, Clone)]
36pub struct Item {
37    pub id: String,
38    pub score_proxy: f32,
39    pub category: String,
40    pub prerequisites: Vec<String>,
41    pub metadata: HashMap<String, String>,
42}
43
44impl Item {
45    pub fn new(id: impl Into<String>, score_proxy: f32, category: impl Into<String>) -> Self {
46        Self {
47            id: id.into(),
48            score_proxy: score_proxy.clamp(0.0, 1.0),
49            category: category.into(),
50            prerequisites: Vec::new(),
51            metadata: HashMap::new(),
52        }
53    }
54
55    pub fn with_prereqs(mut self, prereqs: Vec<String>) -> Self {
56        self.prerequisites = prereqs;
57        self
58    }
59
60    pub fn with_metadata(mut self, metadata: HashMap<String, String>) -> Self {
61        self.metadata = metadata;
62        self
63    }
64}
65
66impl Scoreable for Item {
67    fn id(&self) -> &str {
68        &self.id
69    }
70
71    fn score_proxy(&self) -> f32 {
72        self.score_proxy
73    }
74
75    fn category(&self) -> &str {
76        &self.category
77    }
78
79    fn prerequisites(&self) -> &[String] {
80        &self.prerequisites
81    }
82
83    fn metadata(&self) -> &HashMap<String, String> {
84        &self.metadata
85    }
86}
87
88/// Item registry — owns all registered items, validates prereq graph.
89pub struct ItemRegistry {
90    items: HashMap<String, Item>,
91}
92
93impl ItemRegistry {
94    pub fn new() -> Self {
95        Self {
96            items: HashMap::new(),
97        }
98    }
99
100    /// Register items. Returns Err if a cyclic prerequisite is detected.
101    pub fn register(&mut self, items: Vec<Item>) -> Result<(), crate::error::AriaError> {
102        for item in items {
103            self.items.insert(item.id.clone(), item);
104        }
105        self.validate_prereq_graph()
106    }
107
108    /// Returns items whose prerequisites are all satisfied by resolved_set.
109    pub fn eligible<'a>(&'a self, resolved_set: &HashSet<String>) -> Vec<&'a Item> {
110        self.items
111            .values()
112            .filter(|item| {
113                item.prerequisites
114                    .iter()
115                    .all(|prereq| resolved_set.contains(prereq))
116            })
117            .collect()
118    }
119
120    pub fn get(&self, id: &str) -> Option<&Item> {
121        self.items.get(id)
122    }
123
124    pub fn len(&self) -> usize {
125        self.items.len()
126    }
127
128    pub fn is_empty(&self) -> bool {
129        self.items.is_empty()
130    }
131
132    /// Topological sort to detect cycles in prerequisite graph.
133    fn validate_prereq_graph(&self) -> Result<(), crate::error::AriaError> {
134        let mut visited: HashSet<&str> = HashSet::new();
135        let mut in_stack: HashSet<&str> = HashSet::new();
136
137        for id in self.items.keys() {
138            if !visited.contains(id.as_str()) {
139                self.dfs(id, &mut visited, &mut in_stack)?;
140            }
141        }
142        Ok(())
143    }
144
145    fn dfs<'a>(
146        &'a self,
147        id: &'a str,
148        visited: &mut HashSet<&'a str>,
149        in_stack: &mut HashSet<&'a str>,
150    ) -> Result<(), crate::error::AriaError> {
151        visited.insert(id);
152        in_stack.insert(id);
153
154        if let Some(item) = self.items.get(id) {
155            for prereq in &item.prerequisites {
156                if !visited.contains(prereq.as_str()) {
157                    self.dfs(prereq, visited, in_stack)?;
158                } else if in_stack.contains(prereq.as_str()) {
159                    return Err(crate::error::AriaError::CyclicPrerequisite(prereq.clone()));
160                }
161            }
162        }
163
164        in_stack.remove(id);
165        Ok(())
166    }
167}
168
169impl Default for ItemRegistry {
170    fn default() -> Self {
171        Self::new()
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    #[test]
180    fn cycle_detection() {
181        let mut registry = ItemRegistry::new();
182        let items = vec![
183            Item::new("a", 0.3, "cat").with_prereqs(vec!["b".into()]),
184            Item::new("b", 0.5, "cat").with_prereqs(vec!["a".into()]),
185        ];
186        assert!(registry.register(items).is_err());
187    }
188
189    #[test]
190    fn eligible_filters_unsatisfied_prereqs() {
191        let mut registry = ItemRegistry::new();
192        registry
193            .register(vec![
194                Item::new("a", 0.3, "cat"),
195                Item::new("b", 0.5, "cat").with_prereqs(vec!["a".into()]),
196            ])
197            .unwrap();
198
199        let resolved: HashSet<String> = HashSet::new();
200        let eligible = registry.eligible(&resolved);
201        assert_eq!(eligible.len(), 1);
202        assert_eq!(eligible[0].id(), "a");
203    }
204
205    #[test]
206    fn eligible_after_prereq_resolved() {
207        let mut registry = ItemRegistry::new();
208        registry
209            .register(vec![
210                Item::new("a", 0.3, "cat"),
211                Item::new("b", 0.5, "cat").with_prereqs(vec!["a".into()]),
212            ])
213            .unwrap();
214
215        let mut resolved: HashSet<String> = HashSet::new();
216        resolved.insert("a".into());
217        let eligible = registry.eligible(&resolved);
218        assert_eq!(eligible.len(), 2);
219    }
220}