1use anyhow::{Ok, Result, anyhow};
4use chrono::{DateTime, Utc};
5use parking_lot::RwLock;
6use std::sync::Arc;
7use ustr::{Ustr, UstrMap, UstrSet};
8
9use crate::{
10 blacklist::Blacklist,
11 course_library::CourseLibrary,
12 data::{
13 CourseManifest, ExerciseManifest, LessonManifest, MasteryScore, SchedulerOptions, UnitType,
14 filter::{KeyValueFilter, SavedFilter, SessionPart, StudySessionData, UnitFilter},
15 },
16 filter_manager::FilterManager,
17 graph::UnitGraph,
18 practice_rewards::PracticeRewards,
19 practice_stats::PracticeStats,
20 review_list::ReviewList,
21};
22
23#[derive(Clone)]
25pub struct SchedulerData {
26 pub options: SchedulerOptions,
28
29 pub course_library: Arc<RwLock<dyn CourseLibrary>>,
31
32 pub unit_graph: Arc<RwLock<dyn UnitGraph>>,
34
35 pub practice_stats: Arc<RwLock<dyn PracticeStats>>,
37
38 pub practice_rewards: Arc<RwLock<dyn PracticeRewards>>,
40
41 pub blacklist: Arc<RwLock<dyn Blacklist>>,
43
44 pub review_list: Arc<RwLock<dyn ReviewList>>,
46
47 pub filter_manager: Arc<RwLock<dyn FilterManager>>,
49
50 pub frequency_map: Arc<RwLock<UstrMap<usize>>>,
54
55 pub trial_counts: Arc<RwLock<(usize, usize)>>,
57}
58
59impl SchedulerData {
60 #[inline]
62 pub fn get_lesson_id(&self, exercise_id: Ustr) -> Result<Ustr> {
63 self.unit_graph
64 .read()
65 .get_exercise_lesson(exercise_id)
66 .ok_or(anyhow!(
67 "missing lesson ID for exercise with ID {exercise_id}"
68 ))
69 }
70
71 #[inline]
73 pub fn get_course_id(&self, lesson_id: Ustr) -> Result<Ustr> {
74 self.unit_graph
75 .read()
76 .get_lesson_course(lesson_id)
77 .ok_or(anyhow!("missing course ID for lesson with ID {lesson_id}"))
78 }
79
80 #[inline]
82 #[must_use]
83 pub fn get_unit_type(&self, unit_id: Ustr) -> Option<UnitType> {
84 self.unit_graph.read().get_unit_type(unit_id)
85 }
86
87 #[inline]
89 pub fn get_unit_type_strict(&self, unit_id: Ustr) -> Result<UnitType> {
90 self.unit_graph
91 .read()
92 .get_unit_type(unit_id)
93 .ok_or(anyhow!("missing unit type for unit with ID {unit_id}"))
94 }
95
96 #[inline]
98 pub fn get_course_manifest(&self, course_id: Ustr) -> Result<Arc<CourseManifest>> {
99 self.course_library
100 .read()
101 .get_course_manifest(course_id)
102 .ok_or(anyhow!("missing manifest for course with ID {course_id}"))
103 }
104
105 #[inline]
107 pub fn get_lesson_manifest(&self, lesson_id: Ustr) -> Result<Arc<LessonManifest>> {
108 self.course_library
109 .read()
110 .get_lesson_manifest(lesson_id)
111 .ok_or(anyhow!("missing manifest for lesson with ID {lesson_id}"))
112 }
113
114 #[inline]
116 pub fn get_exercise_manifest(&self, exercise_id: Ustr) -> Result<Arc<ExerciseManifest>> {
117 self.course_library
118 .read()
119 .get_exercise_manifest(exercise_id)
120 .ok_or(anyhow!(
121 "missing manifest for exercise with ID {exercise_id}"
122 ))
123 }
124
125 #[inline]
127 pub fn blacklisted(&self, unit_id: Ustr) -> Result<bool> {
128 let blacklisted = self.blacklist.read().blacklisted(unit_id)?;
129 Ok(blacklisted)
130 }
131
132 pub fn inside_blacklisted(&self, exercise_id: Ustr) -> Result<bool> {
134 let blacklist = self.blacklist.read();
136 let blacklisted = blacklist.blacklisted(exercise_id)?;
137 if blacklisted {
138 return Ok(true);
139 }
140
141 let lesson_id = self.get_lesson_id(exercise_id).unwrap_or_default();
143 let lesson_blacklisted = blacklist.blacklisted(lesson_id)?;
144 let course_id = self.get_course_id(lesson_id).unwrap_or_default();
145 let course_blacklisted = blacklist.blacklisted(course_id)?;
146 Ok(lesson_blacklisted || course_blacklisted)
147 }
148
149 #[inline]
151 #[must_use]
152 pub fn get_all_dependents(&self, unit_id: Ustr) -> Vec<Ustr> {
153 return self
154 .unit_graph
155 .read()
156 .get_dependents(unit_id)
157 .unwrap_or_default()
158 .iter()
159 .copied()
160 .collect();
161 }
162
163 #[inline]
165 #[must_use]
166 pub fn get_superseding(&self, unit_id: Ustr) -> Option<Arc<UstrSet>> {
167 return self.unit_graph.read().get_superseded_by(unit_id);
168 }
169
170 #[must_use]
172 pub fn get_dependencies_at_depth(&self, unit_id: Ustr, depth: usize) -> Vec<Ustr> {
173 let mut dependencies = vec![];
175 let mut stack = vec![(unit_id, 0)];
176 let graph = self.unit_graph.read();
177 while let Some((candidate_id, candidate_depth)) = stack.pop() {
178 if candidate_depth == depth {
179 dependencies.push(candidate_id);
181 continue;
182 }
183
184 let candidate_dependencies = graph.get_dependencies(candidate_id);
186 match candidate_dependencies {
187 Some(candidate_dependencies) => {
188 if candidate_dependencies.is_empty() {
189 dependencies.push(candidate_id);
191 } else {
192 stack.extend(
194 candidate_dependencies
195 .iter()
196 .copied()
197 .map(|dependency| (dependency, candidate_depth + 1)),
198 );
199 }
200 }
201 None => dependencies.push(candidate_id),
202 }
203 }
204
205 dependencies.retain(|dependency| graph.get_unit_type(*dependency).is_some());
208 dependencies
209 }
210
211 #[inline]
213 #[must_use]
214 pub fn get_lesson_course(&self, lesson_id: Ustr) -> Option<Ustr> {
215 self.unit_graph.read().get_lesson_course(lesson_id)
216 }
217
218 #[inline]
221 pub fn unit_exists(&self, unit_id: Ustr) -> Result<bool> {
222 let unit_type = self.unit_graph.read().get_unit_type(unit_id);
224 if unit_type.is_none() {
225 return Ok(false);
226 }
227
228 let library = self.course_library.read();
230 match unit_type.unwrap() {
231 UnitType::Course => Ok(library.get_course_manifest(unit_id).is_some()),
232 UnitType::Lesson => Ok(library.get_lesson_manifest(unit_id).is_some()),
233 UnitType::Exercise => Ok(library.get_exercise_manifest(unit_id).is_some()),
234 }
235 }
236
237 #[inline]
239 #[must_use]
240 pub fn get_lesson_exercises(&self, unit_id: Ustr) -> Vec<Ustr> {
241 self.unit_graph
242 .read()
243 .get_lesson_exercises(unit_id)
244 .unwrap_or_default()
245 .iter()
246 .copied()
247 .collect()
248 }
249
250 #[inline]
252 #[must_use]
253 pub fn get_num_lessons_in_course(&self, course_id: Ustr) -> usize {
254 self.unit_graph
255 .read()
256 .get_course_lessons(course_id)
257 .unwrap_or_default()
258 .len()
259 }
260
261 #[inline]
264 pub fn unit_passes_filter(
265 &self,
266 unit_id: Ustr,
267 metadata_filter: Option<&KeyValueFilter>,
268 ) -> Result<bool> {
269 if metadata_filter.is_none() {
271 return Ok(true);
272 }
273
274 let unit_type = self.get_unit_type_strict(unit_id)?;
276 match unit_type {
277 UnitType::Exercise => Err(anyhow!(
279 "cannot apply metadata filter to exercise with ID {unit_id}",
280 )),
281 UnitType::Course => {
282 let course_manifest = self.get_course_manifest(unit_id)?;
284 Ok(metadata_filter
285 .as_ref()
286 .unwrap()
287 .apply_to_course(&*course_manifest))
288 }
289 UnitType::Lesson => {
290 let course_manifest =
293 self.get_course_manifest(self.get_lesson_course(unit_id).unwrap_or_default())?;
294 let lesson_manifest = self.get_lesson_manifest(unit_id)?;
295 Ok(metadata_filter
296 .as_ref()
297 .unwrap()
298 .apply_to_lesson(&*course_manifest, &*lesson_manifest))
299 }
300 }
301 }
302
303 #[inline]
305 pub fn increment_exercise_frequency(&self, exercise_id: Ustr) {
306 let mut frequency_map = self.frequency_map.write();
307 let frequency = frequency_map.entry(exercise_id).or_insert(0);
308 *frequency += 1;
309 }
310
311 #[inline]
313 #[must_use]
314 pub fn get_exercise_frequency(&self, exercise_id: Ustr) -> usize {
315 self.frequency_map
316 .read()
317 .get(&exercise_id)
318 .copied()
319 .unwrap_or(0)
320 }
321
322 pub fn get_saved_filter(&self, filter_id: &str) -> Result<Arc<SavedFilter>> {
325 match self.filter_manager.read().get_filter(filter_id) {
326 Some(filter) => Ok(filter),
327 None => Err(anyhow!("no saved filter with ID {filter_id} exists")),
328 }
329 }
330
331 pub fn get_session_filter(
333 &self,
334 session_data: &StudySessionData,
335 time: DateTime<Utc>,
336 ) -> Result<Option<UnitFilter>> {
337 match session_data.get_part(time) {
338 SessionPart::NoFilter { .. } => Ok(None),
339 SessionPart::UnitFilter { filter, .. } => Ok(Some(filter)),
340 SessionPart::SavedFilter { filter_id, .. } => {
341 let saved_filter = self.get_saved_filter(&filter_id)?;
342 Ok(Some(saved_filter.filter.clone()))
343 }
344 }
345 }
346
347 #[must_use]
349 pub fn all_valid_exercises_in_lesson(&self, lesson_id: Ustr) -> Vec<Ustr> {
350 if self.blacklisted(lesson_id).unwrap_or(false) {
352 return vec![];
353 }
354
355 let course_id = self.get_lesson_course(lesson_id).unwrap_or_default();
357 if self.blacklisted(course_id).unwrap_or(false) {
358 return vec![];
359 }
360
361 let exercises = self.get_lesson_exercises(lesson_id);
363 exercises
364 .into_iter()
365 .filter(|exercise_id| !self.blacklisted(*exercise_id).unwrap_or(false))
366 .collect()
367 }
368
369 #[must_use]
371 pub fn all_valid_exercises(&self, unit_id: Ustr) -> Vec<Ustr> {
372 let unit_type = self.get_unit_type(unit_id);
374 match unit_type {
375 None => vec![],
376 Some(UnitType::Exercise) => {
377 if self.blacklisted(unit_id).unwrap_or(false) {
379 vec![]
380 } else {
381 vec![unit_id]
382 }
383 }
384 Some(UnitType::Lesson) => self.all_valid_exercises_in_lesson(unit_id),
385 Some(UnitType::Course) => {
386 if self.blacklisted(unit_id).unwrap_or(false) {
388 return vec![];
389 }
390
391 let lessons = self
393 .unit_graph
394 .read()
395 .get_course_lessons(unit_id)
396 .unwrap_or_default();
397 lessons
398 .iter()
399 .copied()
400 .flat_map(|lesson_id| self.all_valid_exercises_in_lesson(lesson_id))
401 .collect()
402 }
403 }
404 }
405
406 pub fn update_success_rate(&self, score: &MasteryScore) {
408 let mut counts = self.trial_counts.write();
409 match score {
410 MasteryScore::One | MasteryScore::Two => counts.1 += 1,
411 MasteryScore::Three | MasteryScore::Four | MasteryScore::Five => counts.0 += 1,
412 }
413 }
414
415 #[must_use]
417 pub fn get_success_rate(&self) -> f32 {
418 let counts = self.trial_counts.read();
419 let total = counts.0 + counts.1;
420 if total == 0 {
421 1.0
422 } else {
423 counts.0 as f32 / total as f32
424 }
425 }
426}
427
428#[cfg(test)]
429#[cfg_attr(coverage, coverage(off))]
430mod test {
431 use anyhow::Result;
432 use chrono::Duration;
433 use parking_lot::RwLock;
434 use std::{
435 collections::{BTreeMap, HashMap},
436 sync::{Arc, LazyLock},
437 };
438 use ustr::Ustr;
439
440 use crate::{
441 data::{
442 MasteryScore, UnitType,
443 filter::{
444 FilterType, KeyValueFilter, SavedFilter, SessionPart, StudySession,
445 StudySessionData, UnitFilter,
446 },
447 },
448 filter_manager::LocalFilterManager,
449 test_utils::*,
450 };
451
452 static NUM_EXERCISES: usize = 2;
453
454 static TEST_LIBRARY: LazyLock<Vec<TestCourse>> = LazyLock::new(|| {
456 vec![TestCourse {
457 id: TestId(0, None, None),
458 dependencies: vec![],
459 encompassed: vec![],
460 superseded: vec![],
461 metadata: BTreeMap::from([
462 (
463 "course_key_1".to_string(),
464 vec!["course_key_1:value_1".to_string()],
465 ),
466 (
467 "course_key_2".to_string(),
468 vec!["course_key_2:value_1".to_string()],
469 ),
470 ]),
471 lessons: vec![
472 TestLesson {
473 id: TestId(0, Some(0), None),
474 dependencies: vec![],
475 encompassed: vec![],
476 superseded: vec![],
477 metadata: BTreeMap::from([
478 (
479 "lesson_key_1".to_string(),
480 vec!["lesson_key_1:value_1".to_string()],
481 ),
482 (
483 "lesson_key_2".to_string(),
484 vec!["lesson_key_2:value_1".to_string()],
485 ),
486 ]),
487 num_exercises: NUM_EXERCISES,
488 },
489 TestLesson {
490 id: TestId(0, Some(1), None),
491 dependencies: vec![TestId(0, Some(0), None)],
492 encompassed: vec![],
493 superseded: vec![],
494 metadata: BTreeMap::from([
495 (
496 "lesson_key_1".to_string(),
497 vec!["lesson_key_1:value_2".to_string()],
498 ),
499 (
500 "lesson_key_2".to_string(),
501 vec!["lesson_key_2:value_2".to_string()],
502 ),
503 ]),
504 num_exercises: NUM_EXERCISES,
505 },
506 ],
507 }]
508 });
509
510 #[test]
512 fn unit_exists() -> Result<()> {
513 let temp_dir = tempfile::tempdir()?;
514 let library = init_test_simulation(temp_dir.path(), &TEST_LIBRARY)?;
515 let scheduler_data = library.get_scheduler_data();
516
517 assert_eq!(
518 scheduler_data.get_unit_type_strict(Ustr::from("0"))?,
519 UnitType::Course
520 );
521 assert!(scheduler_data.unit_exists(Ustr::from("0"))?);
522 assert_eq!(
523 scheduler_data.get_unit_type_strict(Ustr::from("0::0"))?,
524 UnitType::Lesson
525 );
526 assert!(scheduler_data.unit_exists(Ustr::from("0::0"))?);
527 assert_eq!(
528 scheduler_data.get_unit_type_strict(Ustr::from("0::0::0"))?,
529 UnitType::Exercise
530 );
531 assert!(scheduler_data.unit_exists(Ustr::from("0::0::0"))?);
532 Ok(())
533 }
534
535 #[test]
537 fn exercise_metadata_filter() -> Result<()> {
538 let temp_dir = tempfile::tempdir()?;
539 let library = init_test_simulation(temp_dir.path(), &TEST_LIBRARY)?;
540 let scheduler_data = library.get_scheduler_data();
541 let metadata_filter = KeyValueFilter::CourseFilter {
542 key: "key".into(),
543 value: "value".into(),
544 filter_type: FilterType::Include,
545 };
546 assert!(
547 scheduler_data
548 .unit_passes_filter(Ustr::from("0::0::0"), Some(&metadata_filter))
549 .is_err()
550 );
551 Ok(())
552 }
553
554 #[test]
557 fn exercise_frequency() -> Result<()> {
558 let temp_dir = tempfile::tempdir()?;
559 let library = init_test_simulation(temp_dir.path(), &TEST_LIBRARY)?;
560 let scheduler_data = library.get_scheduler_data();
561
562 assert_eq!(
563 scheduler_data.get_exercise_frequency(Ustr::from("0::0::0")),
564 0
565 );
566 scheduler_data.increment_exercise_frequency(Ustr::from("0::0::0"));
567 assert_eq!(
568 scheduler_data.get_exercise_frequency(Ustr::from("0::0::0")),
569 1
570 );
571 Ok(())
572 }
573
574 #[test]
576 fn get_session_filter() -> Result<()> {
577 let temp_dir = tempfile::tempdir()?;
578 let library = init_test_simulation(temp_dir.path(), &TEST_LIBRARY)?;
579
580 let mut scheduler_data = library.get_scheduler_data();
582 scheduler_data.filter_manager = Arc::new(RwLock::new(LocalFilterManager {
583 filters: HashMap::from([(
584 "saved_filter".to_string(),
585 Arc::new(SavedFilter {
586 id: "saved_filter".to_string(),
587 description: "Saved filter".to_string(),
588 filter: UnitFilter::ReviewListFilter,
589 }),
590 )]),
591 }));
592
593 let start_time = chrono::Utc::now();
595 let session_data = StudySessionData {
596 start_time,
597 definition: StudySession {
598 id: "session".to_string(),
599 description: "Session".to_string(),
600 parts: vec![
601 SessionPart::UnitFilter {
602 filter: UnitFilter::ReviewListFilter,
603 duration: 1,
604 },
605 SessionPart::NoFilter { duration: 1 },
606 SessionPart::SavedFilter {
607 filter_id: "saved_filter".into(),
608 duration: 1,
609 },
610 ],
611 },
612 };
613
614 assert_eq!(
616 scheduler_data.get_session_filter(&session_data, start_time)?,
617 Some(UnitFilter::ReviewListFilter)
618 );
619 assert_eq!(
620 scheduler_data.get_session_filter(&session_data, start_time + Duration::minutes(1))?,
621 None
622 );
623 assert_eq!(
624 scheduler_data.get_session_filter(&session_data, start_time + Duration::minutes(2))?,
625 Some(UnitFilter::ReviewListFilter)
626 );
627
628 assert!(
630 scheduler_data
631 .get_session_filter(
632 &StudySessionData {
633 start_time,
634 definition: StudySession {
635 id: "session".to_string(),
636 description: "Session".to_string(),
637 parts: vec![SessionPart::SavedFilter {
638 filter_id: "unknown_filter".into(),
639 duration: 1,
640 }],
641 },
642 },
643 start_time
644 )
645 .is_err()
646 );
647
648 Ok(())
649 }
650
651 #[test]
653 fn all_valid_exercises() -> Result<()> {
654 let temp_dir = tempfile::tempdir()?;
656 let library = init_test_simulation(temp_dir.path(), &TEST_LIBRARY)?;
657 let scheduler_data = library.get_scheduler_data();
658
659 assert!(
661 scheduler_data
662 .all_valid_exercises(Ustr::from("unknown"))
663 .is_empty()
664 );
665
666 assert_eq!(
668 scheduler_data.all_valid_exercises(Ustr::from("0::0::0")),
669 vec![Ustr::from("0::0::0")]
670 );
671
672 scheduler_data
674 .blacklist
675 .write()
676 .add_to_blacklist(Ustr::from("0::0::0"))?;
677 assert!(
678 scheduler_data
679 .all_valid_exercises(Ustr::from("0::0::0"))
680 .is_empty()
681 );
682
683 let mut valid_exercises = scheduler_data.all_valid_exercises(Ustr::from("0::1"));
685 valid_exercises.sort();
686 assert_eq!(
687 valid_exercises,
688 vec![Ustr::from("0::1::0"), Ustr::from("0::1::1")]
689 );
690
691 scheduler_data
693 .blacklist
694 .write()
695 .add_to_blacklist(Ustr::from("0::1"))?;
696 assert!(
697 scheduler_data
698 .all_valid_exercises(Ustr::from("0::1"))
699 .is_empty()
700 );
701
702 assert_eq!(
704 scheduler_data.all_valid_exercises(Ustr::from("0")),
705 vec![Ustr::from("0::0::1"),]
706 );
707
708 scheduler_data
710 .blacklist
711 .write()
712 .add_to_blacklist(Ustr::from("0"))?;
713 assert!(
714 scheduler_data
715 .all_valid_exercises(Ustr::from("0"))
716 .is_empty()
717 );
718
719 Ok(())
720 }
721
722 #[test]
724 fn success_rate() -> Result<()> {
725 let temp_dir = tempfile::tempdir()?;
726 let library = init_test_simulation(temp_dir.path(), &TEST_LIBRARY)?;
727 let scheduler_data = library.get_scheduler_data();
728
729 assert_eq!(scheduler_data.get_success_rate(), 1.0);
731
732 scheduler_data.update_success_rate(&MasteryScore::One);
735 scheduler_data.update_success_rate(&MasteryScore::Two);
736 scheduler_data.update_success_rate(&MasteryScore::Three);
737 scheduler_data.update_success_rate(&MasteryScore::Four);
738 scheduler_data.update_success_rate(&MasteryScore::Five);
739 assert_eq!(scheduler_data.get_success_rate(), 0.6);
740 Ok(())
741 }
742}