Skip to main content

arct_core/
recommendation.rs

1//! Smart lesson recommendation engine
2//!
3//! This module provides an intelligent recommendation system that suggests
4//! the most relevant lessons based on user progress, difficulty preferences,
5//! and learning patterns.
6
7use crate::lesson::{Difficulty, Lesson, LessonLibrary};
8use crate::stats::UserStats;
9use serde::{Deserialize, Serialize};
10use std::collections::HashSet;
11
12/// A lesson recommendation with reasoning
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct LessonRecommendation {
15    pub lesson: Lesson,
16    pub reason: RecommendationReason,
17    pub priority: u8, // 1-10, higher = more recommended
18}
19
20/// The reasoning behind why a lesson is recommended
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub enum RecommendationReason {
23    /// The logical next lesson in the learning sequence
24    NextInSequence,
25    /// Prerequisites have just been satisfied
26    PrerequisiteSatisfied,
27    /// Similar difficulty to recently completed lessons
28    SameDifficulty,
29    /// What other users commonly do next (placeholder for future)
30    PopularNext,
31    /// Fills a gap in the user's knowledge
32    FillGap,
33    /// A lesson completed long ago (good for review)
34    Review,
35    /// Matches user's skill level
36    SkillLevelMatch,
37    /// Related to recently completed topics
38    RelatedTopic,
39}
40
41/// Engine that generates smart lesson recommendations
42pub struct RecommendationEngine {
43    library: LessonLibrary,
44}
45
46impl RecommendationEngine {
47    /// Create a new recommendation engine with the built-in lesson library
48    pub fn new() -> Self {
49        Self {
50            library: LessonLibrary::new(),
51        }
52    }
53
54    /// Create a recommendation engine backed by a specific lesson library
55    /// (e.g. built-ins merged with user lesson packs)
56    pub fn with_library(library: LessonLibrary) -> Self {
57        Self { library }
58    }
59
60    /// Get personalized lesson recommendations for a user
61    ///
62    /// Returns a sorted list of recommendations, best matches first
63    pub fn get_recommendations(
64        &self,
65        completed_lessons: &HashSet<String>,
66        stats: &UserStats,
67        max_recommendations: usize,
68    ) -> Vec<LessonRecommendation> {
69        let all_lessons: Vec<Lesson> = self.library.all().into_iter().cloned().collect();
70        let mut recommendations = Vec::new();
71
72        for lesson in all_lessons {
73            // Skip already completed lessons
74            if completed_lessons.contains(&lesson.id) {
75                continue;
76            }
77
78            // Check if prerequisites are met
79            if !self.check_prerequisites(&lesson, completed_lessons) {
80                continue;
81            }
82
83            // Calculate priority and determine reason
84            let (priority, reason) =
85                self.calculate_priority_and_reason(&lesson, completed_lessons, stats);
86
87            // Only recommend lessons with meaningful priority
88            if priority > 0 {
89                recommendations.push(LessonRecommendation {
90                    lesson,
91                    reason,
92                    priority,
93                });
94            }
95        }
96
97        // Sort by priority (highest first)
98        recommendations.sort_by(|a, b| b.priority.cmp(&a.priority));
99
100        // Return top N recommendations
101        recommendations.truncate(max_recommendations);
102        recommendations
103    }
104
105    /// Calculate the priority score and determine recommendation reason
106    fn calculate_priority_and_reason(
107        &self,
108        lesson: &Lesson,
109        completed: &HashSet<String>,
110        stats: &UserStats,
111    ) -> (u8, RecommendationReason) {
112        let mut priority = 0u8;
113        let mut reason = RecommendationReason::SkillLevelMatch;
114
115        // Factor 1: Prerequisites just satisfied (high priority)
116        if !lesson.prerequisites.is_empty() {
117            let all_prereqs_done = lesson
118                .prerequisites
119                .iter()
120                .all(|prereq| completed.contains(prereq));
121
122            if all_prereqs_done {
123                priority += 8;
124                reason = RecommendationReason::PrerequisiteSatisfied;
125            }
126        }
127
128        // Factor 2: Beginner lessons for new users (high priority)
129        if completed.is_empty() && lesson.difficulty == Difficulty::Beginner {
130            priority = 10;
131            reason = RecommendationReason::SkillLevelMatch;
132        }
133
134        // Factor 3: Match user's skill level based on completed lessons
135        let user_skill_level = self.estimate_user_skill_level(completed, stats);
136        if lesson.difficulty == user_skill_level {
137            priority += 6;
138            if priority > 6 {
139                // Keep higher priority reason
140            } else {
141                reason = RecommendationReason::SameDifficulty;
142            }
143        } else if self.is_next_difficulty_level(user_skill_level, lesson.difficulty) {
144            priority += 5;
145            reason = RecommendationReason::NextInSequence;
146        }
147
148        // Factor 4: Topic continuity (related tags)
149        if self.has_related_topics(lesson, completed) {
150            priority += 4;
151            if priority <= 4 {
152                reason = RecommendationReason::RelatedTopic;
153            }
154        }
155
156        // Factor 5: Fill knowledge gaps
157        if self.fills_knowledge_gap(lesson, completed) {
158            priority += 3;
159            if priority <= 3 {
160                reason = RecommendationReason::FillGap;
161            }
162        }
163
164        // Factor 6: Short lessons for busy users
165        if lesson.estimated_minutes <= 10 && stats.total_time_seconds < 1800 {
166            priority += 2; // Boost short lessons for new/busy users
167        }
168
169        // Factor 7: Ensure beginners don't get advanced lessons too early
170        if completed.len() < 3 && lesson.difficulty == Difficulty::Advanced {
171            priority = priority.saturating_sub(8);
172        }
173        if completed.len() < 5 && lesson.difficulty == Difficulty::Expert {
174            priority = priority.saturating_sub(10);
175        }
176
177        // Factor 8: Avoid recommending lessons too far above user level
178        if self.is_too_difficult(user_skill_level, lesson.difficulty) {
179            priority = priority.saturating_sub(7);
180        }
181
182        (priority.min(10), reason)
183    }
184
185    /// Check if all prerequisites for a lesson are completed
186    fn check_prerequisites(&self, lesson: &Lesson, completed: &HashSet<String>) -> bool {
187        lesson.prerequisites.iter().all(|prereq| completed.contains(prereq))
188    }
189
190    /// Estimate user's current skill level based on completed lessons
191    fn estimate_user_skill_level(
192        &self,
193        completed: &HashSet<String>,
194        stats: &UserStats,
195    ) -> Difficulty {
196        if completed.is_empty() {
197            return Difficulty::Beginner;
198        }
199
200        // Count completed lessons by difficulty
201        let beginner_count = stats.lessons_by_difficulty.get(&Difficulty::Beginner).unwrap_or(&0);
202        let intermediate_count = stats.lessons_by_difficulty.get(&Difficulty::Intermediate).unwrap_or(&0);
203        let advanced_count = stats.lessons_by_difficulty.get(&Difficulty::Advanced).unwrap_or(&0);
204        let expert_count = stats.lessons_by_difficulty.get(&Difficulty::Expert).unwrap_or(&0);
205
206        // Determine skill level based on completion pattern
207        if *expert_count >= 3 {
208            Difficulty::Expert
209        } else if *advanced_count >= 3 || (*intermediate_count >= 5 && *advanced_count >= 1) {
210            Difficulty::Advanced
211        } else if *intermediate_count >= 2 || (*beginner_count >= 5 && *intermediate_count >= 1) {
212            Difficulty::Intermediate
213        } else {
214            Difficulty::Beginner
215        }
216    }
217
218    /// Check if the lesson difficulty is the next logical step
219    fn is_next_difficulty_level(&self, current: Difficulty, lesson: Difficulty) -> bool {
220        matches!(
221            (current, lesson),
222            (Difficulty::Beginner, Difficulty::Intermediate)
223                | (Difficulty::Intermediate, Difficulty::Advanced)
224                | (Difficulty::Advanced, Difficulty::Expert)
225        )
226    }
227
228    /// Check if a lesson is too difficult for the user's current level
229    fn is_too_difficult(&self, current: Difficulty, lesson: Difficulty) -> bool {
230        match (current, lesson) {
231            (Difficulty::Beginner, Difficulty::Advanced) => true,
232            (Difficulty::Beginner, Difficulty::Expert) => true,
233            (Difficulty::Intermediate, Difficulty::Expert) => true,
234            _ => false,
235        }
236    }
237
238    /// Check if a lesson shares topics with recently completed lessons
239    fn has_related_topics(&self, lesson: &Lesson, completed: &HashSet<String>) -> bool {
240        let all_lessons: Vec<Lesson> = self.library.all().into_iter().cloned().collect();
241
242        // Get tags from recently completed lessons
243        let completed_tags: HashSet<String> = all_lessons
244            .iter()
245            .filter(|l| completed.contains(&l.id))
246            .flat_map(|l| l.tags.clone())
247            .collect();
248
249        // Check if this lesson has any matching tags
250        lesson.tags.iter().any(|tag| completed_tags.contains(tag))
251    }
252
253    /// Check if a lesson fills a knowledge gap
254    fn fills_knowledge_gap(&self, lesson: &Lesson, completed: &HashSet<String>) -> bool {
255        // A lesson fills a gap if:
256        // 1. It has no prerequisites (fundamental skill)
257        // 2. Or its tags represent a topic area not yet explored
258
259        if lesson.prerequisites.is_empty() && !lesson.tags.is_empty() {
260            let all_lessons: Vec<Lesson> = self.library.all().into_iter().cloned().collect();
261            let completed_tags: HashSet<String> = all_lessons
262                .iter()
263                .filter(|l| completed.contains(&l.id))
264                .flat_map(|l| l.tags.clone())
265                .collect();
266
267            // Check if this lesson introduces new tags
268            lesson.tags.iter().any(|tag| !completed_tags.contains(tag))
269        } else {
270            false
271        }
272    }
273
274    /// Get recommendations for a specific difficulty level
275    pub fn get_recommendations_by_difficulty(
276        &self,
277        completed_lessons: &HashSet<String>,
278        difficulty: Difficulty,
279        max_recommendations: usize,
280    ) -> Vec<LessonRecommendation> {
281        let all_lessons: Vec<Lesson> = self.library.all().into_iter().cloned().collect();
282        let mut recommendations = Vec::new();
283
284        for lesson in all_lessons {
285            if completed_lessons.contains(&lesson.id) {
286                continue;
287            }
288
289            if lesson.difficulty != difficulty {
290                continue;
291            }
292
293            if !self.check_prerequisites(&lesson, completed_lessons) {
294                continue;
295            }
296
297            recommendations.push(LessonRecommendation {
298                lesson,
299                reason: RecommendationReason::SameDifficulty,
300                priority: 7,
301            });
302        }
303
304        recommendations.truncate(max_recommendations);
305        recommendations
306    }
307
308    /// Get the next recommended lesson (top recommendation)
309    pub fn get_next_lesson(
310        &self,
311        completed_lessons: &HashSet<String>,
312        stats: &UserStats,
313    ) -> Option<LessonRecommendation> {
314        self.get_recommendations(completed_lessons, stats, 1).into_iter().next()
315    }
316
317    /// Get all lessons that are now available (prerequisites met)
318    pub fn get_available_lessons(
319        &self,
320        completed_lessons: &HashSet<String>,
321    ) -> Vec<Lesson> {
322        let all_lessons: Vec<Lesson> = self.library.all().into_iter().cloned().collect();
323
324        all_lessons
325            .into_iter()
326            .filter(|lesson| {
327                !completed_lessons.contains(&lesson.id)
328                    && self.check_prerequisites(lesson, completed_lessons)
329            })
330            .collect()
331    }
332}
333
334impl Default for RecommendationEngine {
335    fn default() -> Self {
336        Self::new()
337    }
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343
344    #[test]
345    fn test_recommendation_engine_creation() {
346        let engine = RecommendationEngine::new();
347        let completed = HashSet::new();
348        let stats = UserStats::new();
349        let recommendations = engine.get_recommendations(&completed, &stats, 5);
350
351        // Should recommend beginner lessons for new users
352        assert!(!recommendations.is_empty());
353    }
354
355    #[test]
356    fn test_beginner_recommendations() {
357        let engine = RecommendationEngine::new();
358        let completed = HashSet::new();
359        let stats = UserStats::new();
360        let recommendations = engine.get_recommendations(&completed, &stats, 5);
361
362        // New users should get beginner lessons
363        for rec in recommendations {
364            assert_eq!(rec.lesson.difficulty, Difficulty::Beginner);
365        }
366    }
367
368    #[test]
369    fn test_skill_level_estimation() {
370        let engine = RecommendationEngine::new();
371        let mut stats = UserStats::new();
372        let completed = HashSet::new();
373
374        // No lessons completed = Beginner
375        let level = engine.estimate_user_skill_level(&completed, &stats);
376        assert_eq!(level, Difficulty::Beginner);
377
378        // After completing beginner lessons
379        stats.record_lesson_completion("lesson1".to_string(), Difficulty::Beginner);
380        stats.record_lesson_completion("lesson2".to_string(), Difficulty::Beginner);
381        let level = engine.estimate_user_skill_level(&completed, &stats);
382        assert_eq!(level, Difficulty::Beginner);
383    }
384
385    #[test]
386    fn test_prerequisite_checking() {
387        let engine = RecommendationEngine::new();
388        let library = LessonLibrary::new();
389        let lessons: Vec<Lesson> = library.all().into_iter().cloned().collect();
390
391        if let Some(lesson_with_prereq) = lessons.iter().find(|l| !l.prerequisites.is_empty()) {
392            let completed = HashSet::new();
393            assert!(!engine.check_prerequisites(lesson_with_prereq, &completed));
394
395            let mut completed_with_prereq = HashSet::new();
396            for prereq in &lesson_with_prereq.prerequisites {
397                completed_with_prereq.insert(prereq.clone());
398            }
399            assert!(engine.check_prerequisites(lesson_with_prereq, &completed_with_prereq));
400        }
401    }
402
403    #[test]
404    fn test_difficulty_progression() {
405        let engine = RecommendationEngine::new();
406
407        assert!(engine.is_next_difficulty_level(Difficulty::Beginner, Difficulty::Intermediate));
408        assert!(engine.is_next_difficulty_level(Difficulty::Intermediate, Difficulty::Advanced));
409        assert!(!engine.is_next_difficulty_level(Difficulty::Beginner, Difficulty::Expert));
410    }
411
412    #[test]
413    fn test_too_difficult_check() {
414        let engine = RecommendationEngine::new();
415
416        assert!(engine.is_too_difficult(Difficulty::Beginner, Difficulty::Advanced));
417        assert!(engine.is_too_difficult(Difficulty::Beginner, Difficulty::Expert));
418        assert!(!engine.is_too_difficult(Difficulty::Beginner, Difficulty::Intermediate));
419        assert!(!engine.is_too_difficult(Difficulty::Intermediate, Difficulty::Advanced));
420    }
421
422    #[test]
423    fn test_get_next_lesson() {
424        let engine = RecommendationEngine::new();
425        let completed = HashSet::new();
426        let stats = UserStats::new();
427
428        let next = engine.get_next_lesson(&completed, &stats);
429        assert!(next.is_some());
430
431        if let Some(recommendation) = next {
432            assert_eq!(recommendation.lesson.difficulty, Difficulty::Beginner);
433            assert!(recommendation.priority > 0);
434        }
435    }
436
437    #[test]
438    fn test_available_lessons() {
439        let engine = RecommendationEngine::new();
440        let completed = HashSet::new();
441
442        let available = engine.get_available_lessons(&completed);
443        assert!(!available.is_empty());
444
445        // All available lessons should have no prerequisites or met prerequisites
446        for lesson in available {
447            assert!(lesson.prerequisites.is_empty() ||
448                   lesson.prerequisites.iter().all(|p| completed.contains(p)));
449        }
450    }
451
452    #[test]
453    fn test_recommendations_by_difficulty() {
454        let engine = RecommendationEngine::new();
455        let completed = HashSet::new();
456
457        let beginner_recs = engine.get_recommendations_by_difficulty(
458            &completed,
459            Difficulty::Beginner,
460            5,
461        );
462
463        for rec in beginner_recs {
464            assert_eq!(rec.lesson.difficulty, Difficulty::Beginner);
465        }
466    }
467
468    #[test]
469    fn test_exclude_completed_lessons() {
470        let engine = RecommendationEngine::new();
471        let mut completed = HashSet::new();
472        let stats = UserStats::new();
473
474        let first_rec = engine.get_next_lesson(&completed, &stats);
475        assert!(first_rec.is_some());
476
477        if let Some(rec) = first_rec {
478            completed.insert(rec.lesson.id.clone());
479            let next_rec = engine.get_next_lesson(&completed, &stats);
480
481            if let Some(next) = next_rec {
482                assert_ne!(next.lesson.id, rec.lesson.id);
483            }
484        }
485    }
486}