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