• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

trane-project / trane / 13581000065

28 Feb 2025 03:55AM UTC coverage: 99.502% (-0.2%) from 99.674%
13581000065

Pull #323

github

web-flow
Merge c0cbe36ed into e3080ce1d
Pull Request #323: Revamp literacy course

177 of 187 new or added lines in 1 file covered. (94.65%)

2 existing lines in 2 files now uncovered.

4998 of 5023 relevant lines covered (99.5%)

186561.88 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.79
/src/scheduler.rs
1
//! Defines and implements the data structures used to schedule batches of exercises to show to the
2
//! user. This module is the core mechanism of how Trane guides students to mastery of the material.
3
//!
4
//! The scheduler has a few core goals:
5
//! 1. Schedule exercises the user has practiced before in order to improve them and keep them up to
6
//!    date if they have been mastered already.
7
//! 2. Once the current material has been sufficiently mastered, schedule exercises that list the
8
//!    current material as a dependency.
9
//! 3. Optimize the difficulty of the schedule exercises so that the user is neither frustrated
10
//!    because many of the exercises are too difficult or bored because they have become too easy.
11
//!    The optimal are lies slightly outside their current comfort zone.
12
//! 4. Record the scores self-reported by the user to use them to drive the decisions done in
13
//!    service of all the other goals.
14
//!
15
//! In more formal terms, the scheduler's job is to plan the most optimal traversal of the graph of
16
//! skills as the student's performance blocks or unblocks certain paths. The current implementation
17
//! uses depth-first search to traverse the graph and collect a large pool of exercises, a multiple
18
//! of the actual exercises included in the final batch. From this large pool, the candidates are
19
//! split in groups of exercises which each match a disjoint range of scores to be randomly selected
20
//! into a list of fixed size. The result is combined, shuffled, and becomes the final batch
21
//! presented to the student.
22

23
mod cache;
24
pub mod data;
25
mod filter;
26

27
use anyhow::Result;
28
use chrono::Utc;
29
use rand::{seq::SliceRandom, thread_rng};
30
use ustr::{Ustr, UstrMap, UstrSet};
31

32
use crate::{
33
    data::{
34
        filter::{ExerciseFilter, KeyValueFilter, UnitFilter},
35
        ExerciseManifest, MasteryScore, SchedulerOptions, UnitType,
36
    },
37
    error::ExerciseSchedulerError,
38
    scheduler::{cache::ScoreCache, data::SchedulerData, filter::CandidateFilter},
39
};
40

41
/// The scheduler returns early if the search reaches a dead end and the number of candidates is
42
/// bigger than the multiple of the final batch size and this value. This is to avoid the need to
43
/// search the entire graph if the search already found a decently sized pool of candidates.
44
const MAX_CANDIDATE_FACTOR: usize = 10;
45

46
/// The trait that defines the interface for the scheduler. Contains functions to request a new
47
/// batch of exercises and to provide Trane the self-reported scores for said exercises.
48
pub trait ExerciseScheduler {
49
    /// Gets a new batch of exercises scheduled for a new trial. Contains an optimal filter to
50
    /// restrict the units visited during the search with the purpose of allowing students to choose
51
    /// which material to practice. If the filter is not provided, the scheduler will search the
52
    /// entire graph.
53
    fn get_exercise_batch(
54
        &self,
55
        filter: Option<ExerciseFilter>,
56
    ) -> Result<Vec<ExerciseManifest>, ExerciseSchedulerError>;
57

58
    /// Records the score of the given exercise's trial. The scores are used by the scheduler to
59
    /// decide when to stop traversing a path and how to sort and filter all the found candidates
60
    /// into a final batch.
61
    fn score_exercise(
62
        &self,
63
        exercise_id: Ustr,
64
        score: MasteryScore,
65
        timestamp: i64,
66
    ) -> Result<(), ExerciseSchedulerError>;
67

68
    /// Removes any cached scores for the given unit. The score will be recomputed the next time the
69
    /// score is needed.
70
    ///
71
    /// The scores for lessons and exercises are cached to save a large amount of unnecessary
72
    /// computation. Without the caller manually invalidating the cache using this call, it is not
73
    /// possible to know when the cached value becomes outdated with the current interface. The
74
    /// reason is that the calls to modify the blacklist are not known by the scheduler.
75
    ///
76
    /// However, the final users of Trane do not need to call this function because the `Trane`
77
    /// object in lib.rs takes care of clearing the cache when exposing the interface that modifies
78
    /// the blacklist.
79
    fn invalidate_cached_score(&self, unit_id: Ustr);
80

81
    /// Removes any cached scores from units with the given prefix. The same considerations as
82
    /// `invalidate_cached_score` apply.
83
    fn invalidate_cached_scores_with_prefix(&self, prefix: &str);
84

85
    /// Returns the options used to control the behavior of the scheduler.
86
    fn get_scheduler_options(&self) -> SchedulerOptions;
87

88
    /// Sets the options used to control the behavior of the scheduler.
89
    fn set_scheduler_options(&mut self, options: SchedulerOptions);
90

91
    /// Resets the options used to control the behavior of the scheduler to their default values.
92
    fn reset_scheduler_options(&mut self);
93
}
94

95
/// An item in the stack of units that are scheduled for traversal during the process of scheduling
96
/// the next batch of exercises.
97
struct StackItem {
98
    /// The ID of the unit contained in the item.
99
    unit_id: Ustr,
100

101
    /// The depth of this unit from the starting unit. That is, the number of hops the graph search
102
    /// needed to reach this exercise.
103
    depth: usize,
104
}
105

106
/// An exercise selected during the initial phase of the search and which will be grouped with all
107
/// the other candidates which fall in the same mastery window and filtered and randomly selected
108
/// to form the final batch.
109
#[derive(Clone, Debug)]
110
struct Candidate {
111
    /// The ID of the exercise.
112
    exercise_id: Ustr,
113

114
    // The ID of the exercise's lesson.
115
    lesson_id: Ustr,
116

117
    /// The depth of this unit from the starting unit. That is, the number of hops the graph search
118
    /// needed to reach this exercise.
119
    depth: f32,
120

121
    /// The score assigned to the exercise represented as a float number between 0.0 and 5.0
122
    /// inclusive. This score will be computed from the previous trials of this exercise.
123
    score: f32,
124

125
    /// The number of previous trials that have been recorded for this exercise.
126
    num_trials: usize,
127

128
    /// The number of times this exercise has been scheduled during the run of this scheduler. This
129
    /// value will be used to assign more weight to exercises that have been scheduled less often.
130
    frequency: usize,
131
}
132

133
/// An implementation of [`ExerciseScheduler`] based on depth-first search.
134
pub struct DepthFirstScheduler {
135
    /// The external data used by the scheduler. Contains pointers to the graph, blacklist, and
136
    /// course library and provides convenient functions.
137
    data: SchedulerData,
138

139
    /// A cache of unit scores. Scores are cached to avoid unnecessary computation, an issue that
140
    /// was found during profiling of Trane's performance. The memory footprint of Trane is low, so
141
    /// the trade-off is worth it.
142
    score_cache: ScoreCache,
143

144
    /// The filter used to build the final batch of exercises among the candidates found during the
145
    /// graph search.
146
    filter: CandidateFilter,
147
}
148

149
impl DepthFirstScheduler {
150
    /// Creates a new scheduler.
151
    #[must_use]
152
    pub fn new(data: SchedulerData) -> Self {
71✔
153
        Self {
71✔
154
            data: data.clone(),
71✔
155
            score_cache: ScoreCache::new(data.clone(), data.options.clone()),
71✔
156
            filter: CandidateFilter::new(data),
71✔
157
        }
71✔
158
    }
71✔
159

160
    /// Shuffles the units and pushes them to the given stack. Used with the goal of ensuring that
161
    /// the units are traversed in a different order each time a new batch is requested.
162
    fn shuffle_to_stack(curr_unit: &StackItem, mut units: Vec<Ustr>, stack: &mut Vec<StackItem>) {
930,125✔
163
        units.shuffle(&mut thread_rng());
930,125✔
164
        stack.extend(units.iter().map(|id| StackItem {
1,023,312✔
165
            unit_id: *id,
1,023,312✔
166
            depth: curr_unit.depth + 1,
1,023,312✔
167
        }));
1,023,312✔
168
    }
930,125✔
169

170
    /// Returns all the courses and lessons without dependencies which are used to initialize a
171
    /// search of the entire graph.
172
    fn get_all_starting_units(&self) -> UstrSet {
7,635✔
173
        // Replace any missing units with their dependents and repeat this process until there are
7,635✔
174
        // no missing courses.
7,635✔
175
        let mut starting_courses = self.data.unit_graph.read().get_dependency_sinks();
7,635✔
176
        loop {
177
            let mut new_starting_courses = UstrSet::default();
7,693✔
178
            for course_id in &starting_courses {
55,487✔
179
                if self.data.unit_exists(*course_id).unwrap_or(false) {
47,794✔
180
                    new_starting_courses.insert(*course_id);
46,814✔
181
                } else {
46,814✔
182
                    new_starting_courses.extend(self.data.get_all_dependents(*course_id).iter());
980✔
183
                }
980✔
184
            }
185
            if new_starting_courses.len() == starting_courses.len() {
7,693✔
186
                break;
7,635✔
187
            }
58✔
188
            starting_courses = new_starting_courses;
58✔
189
        }
190

191
        // Some courses added to the original list in the previous steps might have other
192
        // dependencies, some of which exist in the course library. This means they cannot be
193
        // considered a starting course, so remove them from the final output.
194
        starting_courses
7,635✔
195
            .into_iter()
7,635✔
196
            .filter(|course_id| {
47,446✔
197
                self.data
47,446✔
198
                    .unit_graph
47,446✔
199
                    .read()
47,446✔
200
                    .get_dependencies(*course_id)
47,446✔
201
                    .unwrap_or_default()
47,446✔
202
                    .iter()
47,446✔
203
                    .all(|id| !self.data.unit_exists(*id).unwrap())
47,446✔
204
            })
47,446✔
205
            .collect()
7,635✔
206
    }
7,635✔
207

208
    /// Returns the lessons in the course that have no dependencies with other lessons in the course
209
    /// and whose dependencies are satisfied.
210
    pub fn get_course_valid_starting_lessons(
347,717✔
211
        &self,
347,717✔
212
        course_id: Ustr,
347,717✔
213
        depth: usize,
347,717✔
214
        metadata_filter: Option<&KeyValueFilter>,
347,717✔
215
    ) -> Result<Vec<Ustr>> {
347,717✔
216
        Ok(self
347,717✔
217
            .data
347,717✔
218
            .unit_graph
347,717✔
219
            .read()
347,717✔
220
            .get_starting_lessons(course_id)
347,717✔
221
            .unwrap_or_default()
347,717✔
222
            .into_iter()
347,717✔
223
            .filter(|id| {
367,535✔
224
                // Filter out lessons whose dependencies are not satisfied. Otherwise, those lessons
367,535✔
225
                // would be traversed prematurely.
367,535✔
226
                self.all_satisfied_dependencies(*id, depth, metadata_filter)
367,535✔
227
            })
367,535✔
228
            .collect())
347,717✔
229
    }
347,717✔
230

231
    //@<lp-example-1
232
    /// Returns an initial stack with all the starting units in the graph that are used to search
233
    /// the entire graph.
234
    fn get_initial_stack(&self, metadata_filter: Option<&KeyValueFilter>) -> Vec<StackItem> {
7,635✔
235
        // First get all the starting units and then all of their starting lessons.
7,635✔
236
        let starting_units = self.get_all_starting_units();
7,635✔
237
        let mut initial_stack: Vec<StackItem> = vec![];
7,635✔
238
        for course_id in starting_units {
54,907✔
239
            // Set the depth to zero since all the starting units are at the same depth.
240
            let lesson_ids = self
47,272✔
241
                .get_course_valid_starting_lessons(course_id, 0, metadata_filter)
47,272✔
242
                .unwrap_or_default();
47,272✔
243

47,272✔
244
            if lesson_ids.is_empty() {
47,272✔
245
                // For units with no lessons, insert the unit itself as a starting unit so that its
7,304✔
246
                // dependents are traversed.
7,304✔
247
                initial_stack.push(StackItem {
7,304✔
248
                    unit_id: course_id,
7,304✔
249
                    depth: 0,
7,304✔
250
                });
7,304✔
251
            } else {
39,968✔
252
                // Insert all the starting lessons in the stack.
39,968✔
253
                initial_stack.extend(
39,968✔
254
                    lesson_ids
39,968✔
255
                        .into_iter()
39,968✔
256
                        .map(|unit_id| StackItem { unit_id, depth: 0 }),
50,063✔
257
                );
39,968✔
258
            }
39,968✔
259
        }
260

261
        // Shuffle the lessons to follow a different ordering each time a new batch is requested.
262
        initial_stack.shuffle(&mut thread_rng());
7,635✔
263
        initial_stack
7,635✔
264
    }
7,635✔
265
    //>@lp-example-1
266

267
    /// Returns the list of candidates selected from the given lesson along with the average score.
268
    /// The average score is used to help decide whether to continue searching a path in the graph.
269
    fn get_candidates_from_lesson_helper(&self, item: &StackItem) -> Result<(Vec<Candidate>, f32)> {
414,034✔
270
        // Retrieve the lesson's exercises.
414,034✔
271
        let exercises = self.data.all_valid_exercises_in_lesson(item.unit_id);
414,034✔
272
        if exercises.is_empty() {
414,034✔
273
            // Return early to avoid division by zero later on.
274
            return Ok((vec![], 0.0));
36,241✔
275
        }
377,793✔
276

277
        // Generate a list of candidates from the lesson's exercises.
278
        let candidates = exercises
377,793✔
279
            .into_iter()
377,793✔
280
            .map(|exercise_id| {
2,425,358✔
281
                Ok(Candidate {
2,425,358✔
282
                    exercise_id,
2,425,358✔
283
                    lesson_id: item.unit_id, // It's assumed that the item is a lesson.
2,425,358✔
284
                    depth: (item.depth + 1) as f32,
2,425,358✔
285
                    score: self
2,425,358✔
286
                        .score_cache
2,425,358✔
287
                        .get_unit_score(exercise_id)?
2,425,358✔
288
                        .unwrap_or_default(),
2,425,358✔
289
                    num_trials: self
2,425,358✔
290
                        .score_cache
2,425,358✔
291
                        .get_num_trials(exercise_id)?
2,425,358✔
292
                        .unwrap_or_default(),
2,425,358✔
293
                    frequency: self.data.get_exercise_frequency(exercise_id),
2,425,358✔
294
                })
295
            })
2,425,358✔
296
            .collect::<Result<Vec<Candidate>>>()?;
377,793✔
297

298
        // Calculate the average score of the candidates.
299
        let avg_score = candidates.iter().map(|c| c.score).sum::<f32>() / (candidates.len() as f32);
2,425,358✔
300
        Ok((candidates, avg_score))
377,793✔
301
    }
414,034✔
302

303
    /// Returns whether the given dependency can be considered as satisfied. If all the dependencies
304
    /// of a unit are met, the search can continue with the unit.
305
    fn satisfied_dependency(
1,815,855✔
306
        &self,
1,815,855✔
307
        dependency_id: Ustr,
1,815,855✔
308
        depth: usize,
1,815,855✔
309
        metadata_filter: Option<&KeyValueFilter>,
1,815,855✔
310
    ) -> bool {
1,815,855✔
311
        // Dependencies which do not pass the metadata filter are considered as satisfied, so the
1,815,855✔
312
        // search can continue past them.
1,815,855✔
313
        let passes_filter = self
1,815,855✔
314
            .data
1,815,855✔
315
            .unit_passes_filter(dependency_id, metadata_filter)
1,815,855✔
316
            .unwrap_or(false);
1,815,855✔
317
        if !passes_filter {
1,815,855✔
318
            return true;
5,072✔
319
        }
1,810,783✔
320

1,810,783✔
321
        // Dependencies in the blacklist are considered as satisfied, so the search can continue
1,810,783✔
322
        // past them.
1,810,783✔
323
        let blacklisted = self.data.blacklisted(dependency_id);
1,810,783✔
324
        if blacklisted.unwrap_or(false) {
1,810,783✔
325
            return true;
302✔
326
        }
1,810,481✔
327

1,810,481✔
328
        // Dependencies which are a lesson belonging to a blacklisted course are considered as
1,810,481✔
329
        // satisfied, so the search can continue past them.
1,810,481✔
330
        let course_id = self
1,810,481✔
331
            .data
1,810,481✔
332
            .get_lesson_course(dependency_id)
1,810,481✔
333
            .unwrap_or_default();
1,810,481✔
334
        if self.data.blacklisted(course_id).unwrap_or(false) {
1,810,481✔
335
            return true;
237✔
336
        }
1,810,244✔
337

1,810,244✔
338
        // The dependency is considered as satisfied if it's been superseded by another unit.
1,810,244✔
339
        let superseding = self.score_cache.get_superseding_recursive(dependency_id);
1,810,244✔
340
        if let Some(superseding) = superseding {
1,810,244✔
341
            if self.score_cache.is_superseded(dependency_id, &superseding) {
22,327✔
342
                return true;
20,081✔
343
            }
2,246✔
344
        }
1,787,917✔
345

346
        // Finally, dependencies with a score equal or greater than the passing score are considered
347
        // as satisfied.
348
        let score = self.score_cache.get_unit_score(dependency_id);
1,790,163✔
349
        if let Ok(Some(score)) = score {
1,789,646✔
350
            score >= self.data.options.passing_score.compute_score(depth)
1,466,364✔
351
        } else {
352
            true
323,799✔
353
        }
354
    }
1,815,855✔
355

356
    /// Returns whether all the dependencies of the given unit are satisfied.
357
    fn all_satisfied_dependencies(
1,076,859✔
358
        &self,
1,076,859✔
359
        unit_id: Ustr,
1,076,859✔
360
        depth: usize,
1,076,859✔
361
        metadata_filter: Option<&KeyValueFilter>,
1,076,859✔
362
    ) -> bool {
1,076,859✔
363
        self.data
1,076,859✔
364
            .unit_graph
1,076,859✔
365
            .read()
1,076,859✔
366
            .get_dependencies(unit_id)
1,076,859✔
367
            .unwrap_or_default()
1,076,859✔
368
            .into_iter()
1,076,859✔
369
            .all(|dependency_id| self.satisfied_dependency(dependency_id, depth, metadata_filter))
1,815,855✔
370
    }
1,076,859✔
371

372
    /// Returns the valid dependents which can be visited after the given unit. A valid dependent is
373
    /// a unit whose full dependencies are met.
374
    fn get_valid_dependents(
631,355✔
375
        &self,
631,355✔
376
        unit_id: Ustr,
631,355✔
377
        depth: usize,
631,355✔
378
        metadata_filter: Option<&KeyValueFilter>,
631,355✔
379
    ) -> Vec<Ustr> {
631,355✔
380
        self.data
631,355✔
381
            .get_all_dependents(unit_id)
631,355✔
382
            .into_iter()
631,355✔
383
            .filter(|unit_id| self.all_satisfied_dependencies(*unit_id, depth, metadata_filter))
709,324✔
384
            .collect()
631,355✔
385
    }
631,355✔
386

387
    // Returns whether the given course should be skipped during the search. If so, the valid
388
    /// dependents of the course should be added to the stack.
389
    fn skip_course(
300,445✔
390
        &self,
300,445✔
391
        course_id: Ustr,
300,445✔
392
        metadata_filter: Option<&KeyValueFilter>,
300,445✔
393
        pending_course_lessons: &mut UstrMap<usize>,
300,445✔
394
    ) -> bool {
300,445✔
395
        // Check if the course is blacklisted.
300,445✔
396
        let blacklisted = self.data.blacklisted(course_id).unwrap_or(false);
300,445✔
397

300,445✔
398
        // Check if the course passes the metadata filter.
300,445✔
399
        let passes_filter = self
300,445✔
400
            .data
300,445✔
401
            .unit_passes_filter(course_id, metadata_filter)
300,445✔
402
            .unwrap_or(true);
300,445✔
403

300,445✔
404
        // Check the number of pending lessons in the course.
300,445✔
405
        let pending_lessons = pending_course_lessons
300,445✔
406
            .entry(course_id)
300,445✔
407
            .or_insert_with(|| self.data.get_num_lessons_in_course(course_id));
300,445✔
408

300,445✔
409
        // Check if the course has been superseded by another unit.
300,445✔
410
        let superseding_units = self
300,445✔
411
            .score_cache
300,445✔
412
            .get_superseding_recursive(course_id)
300,445✔
413
            .unwrap_or_default();
300,445✔
414
        let is_superseded = self
300,445✔
415
            .score_cache
300,445✔
416
            .is_superseded(course_id, &superseding_units);
300,445✔
417

300,445✔
418
        // The course should be skipped if the course is blacklisted, does not pass the filter, has
300,445✔
419
        // no pending lessons, or if it' been superseded.
300,445✔
420
        blacklisted || !passes_filter || *pending_lessons == 0 || is_superseded
300,445✔
421
    }
300,445✔
422

423
    /// Returns whether the given lesson should be skipped during the search. If so, the valid
424
    /// dependents of the lesson should be added to the stack.
425
    fn skip_lesson(&self, lesson_id: Ustr, metadata_filter: Option<&KeyValueFilter>) -> bool {
443,612✔
426
        // Check if the lesson is blacklisted.
443,612✔
427
        let blacklisted = self.data.blacklisted(lesson_id).unwrap_or(false);
443,612✔
428

443,612✔
429
        // Check if the lesson passes the metadata filter.
443,612✔
430
        let passes_filter = self
443,612✔
431
            .data
443,612✔
432
            .unit_passes_filter(lesson_id, metadata_filter)
443,612✔
433
            .unwrap_or(true);
443,612✔
434

443,612✔
435
        // Check if the lesson has been superseded by another unit.
443,612✔
436
        let superseding_units = self
443,612✔
437
            .score_cache
443,612✔
438
            .get_superseding_recursive(lesson_id)
443,612✔
439
            .unwrap_or_default();
443,612✔
440
        let is_lesson_superseded = self
443,612✔
441
            .score_cache
443,612✔
442
            .is_superseded(lesson_id, &superseding_units);
443,612✔
443

443,612✔
444
        // Check if the lesson's course has been superseded by another unit.
443,612✔
445
        let course_id = self.data.get_lesson_course(lesson_id).unwrap_or_default();
443,612✔
446
        let superseding_units = self
443,612✔
447
            .score_cache
443,612✔
448
            .get_superseding_recursive(course_id)
443,612✔
449
            .unwrap_or_default();
443,612✔
450
        let is_course_superseded = self
443,612✔
451
            .score_cache
443,612✔
452
            .is_superseded(course_id, &superseding_units);
443,612✔
453

443,612✔
454
        // The lesson should be skipped if it is blacklisted, does not pass the filter or if it or
443,612✔
455
        // its course have been superseded.
443,612✔
456
        blacklisted || !passes_filter || is_lesson_superseded || is_course_superseded
443,612✔
457
    }
443,612✔
458

459
    /// Searches for candidates across the entire graph. An optional metadata filter can be used to
460
    /// only select exercises from the courses and lessons that match the filter and ignore the rest
461
    /// of the graph while still respecting the dependency relationships.
462
    fn get_candidates_from_graph(
7,856✔
463
        &self,
7,856✔
464
        initial_stack: Vec<StackItem>,
7,856✔
465
        metadata_filter: Option<&KeyValueFilter>,
7,856✔
466
    ) -> Result<Vec<Candidate>> {
7,856✔
467
        // Initialize the stack with every starting lesson, which are those units with no
7,856✔
468
        // dependencies that are needed to reach all the units in the graph.
7,856✔
469
        let mut stack: Vec<StackItem> = Vec::new();
7,856✔
470
        stack.extend(initial_stack);
7,856✔
471

7,856✔
472
        // Initialize the list of candidates and the set of visited units.
7,856✔
473
        let max_candidates = self.data.options.batch_size * MAX_CANDIDATE_FACTOR;
7,856✔
474
        let mut all_candidates: Vec<Candidate> = Vec::new();
7,856✔
475
        let mut visited = UstrSet::default();
7,856✔
476

7,856✔
477
        // The dependency relationships between a course and its lessons are not explicitly encoded
7,856✔
478
        // in the graph. While this would simplify this section of the search logic, it would
7,856✔
479
        // require that courses are represented by two nodes. The first incoming node would connect
7,856✔
480
        // the course dependencies to the first lessons in the course. The second outgoing node
7,856✔
481
        // would connect the last lessons in the course to the course dependents.
7,856✔
482
        //
7,856✔
483
        // To get past this limitation, the search will only add the course dependents until all of
7,856✔
484
        // its lessons have been visited and mastered. This value is tracked by the
7,856✔
485
        // `pending_course_lessons` map.
7,856✔
486
        let mut pending_course_lessons: UstrMap<usize> = UstrMap::default();
7,856✔
487

488
        // Perform a depth-first search of the graph.
489
        while let Some(curr_unit) = stack.pop() {
1,243,823✔
490
            // Immediately skip the item if it has been visited.
491
            if visited.contains(&curr_unit.unit_id) {
1,235,967✔
492
                continue;
492,300✔
493
            }
743,667✔
494

743,667✔
495
            // The logic past this point depends on the type of the unit.
743,667✔
496
            let unit_type = self.data.get_unit_type(curr_unit.unit_id);
743,667✔
497
            if unit_type.is_none() {
743,667✔
498
                // The type of the unit is unknown. This can happen when a unit depends on some
499
                // unknown unit.
500
                continue;
806✔
501
            }
742,861✔
502
            let unit_type = unit_type.unwrap();
742,861✔
503

742,861✔
504
            // Handle courses and lessons. Exercises are skipped by the search.
742,861✔
505
            if unit_type == UnitType::Course {
742,861✔
506
                // Retrieve the starting lessons in the course and add them to the stack.
507
                let starting_lessons: Vec<Ustr> = self
300,445✔
508
                    .get_course_valid_starting_lessons(
300,445✔
509
                        curr_unit.unit_id,
300,445✔
510
                        curr_unit.depth,
300,445✔
511
                        metadata_filter,
300,445✔
512
                    )
300,445✔
513
                    .unwrap_or_default();
300,445✔
514
                Self::shuffle_to_stack(&curr_unit, starting_lessons, &mut stack);
300,445✔
515

300,445✔
516
                // The course can be skipped. Add it to the visited set, push its valid dependents
300,445✔
517
                // onto the stack, and continue.
300,445✔
518
                if self.skip_course(
300,445✔
519
                    curr_unit.unit_id,
300,445✔
520
                    metadata_filter,
300,445✔
521
                    &mut pending_course_lessons,
300,445✔
522
                ) {
300,445✔
523
                    visited.insert(curr_unit.unit_id);
187,743✔
524
                    let valid_deps = self.get_valid_dependents(
187,743✔
525
                        curr_unit.unit_id,
187,743✔
526
                        curr_unit.depth,
187,743✔
527
                        metadata_filter,
187,743✔
528
                    );
187,743✔
529
                    Self::shuffle_to_stack(&curr_unit, valid_deps, &mut stack);
187,743✔
530
                    continue;
187,743✔
531
                }
112,702✔
532

112,702✔
533
                // The course has pending lessons, so it cannot be marked as visited yet. Simply
112,702✔
534
                // continue with the search.
112,702✔
535
                continue;
112,702✔
536
            } else if unit_type == UnitType::Lesson {
442,416✔
537
                // If the searched reached this point, the unit must be a lesson.
538
                visited.insert(curr_unit.unit_id);
442,416✔
539

540
                // Update the number of lessons pending to be processed.
541
                let course_id = self.data.get_course_id(curr_unit.unit_id)?;
442,416✔
542
                let pending_lessons = pending_course_lessons
442,416✔
543
                    .entry(course_id)
442,416✔
544
                    .or_insert_with(|| self.data.get_num_lessons_in_course(course_id));
442,416✔
545
                *pending_lessons -= 1;
442,416✔
546

442,416✔
547
                // Check whether there are pending lessons.
442,416✔
548
                if *pending_lessons == 0 {
442,416✔
549
                    // Once all the lessons in the course have been visited, re-add the course to
155,698✔
550
                    // the stack, so the search can continue exploring its dependents.
155,698✔
551
                    stack.push(StackItem {
155,698✔
552
                        unit_id: course_id,
155,698✔
553
                        depth: curr_unit.depth + 1,
155,698✔
554
                    });
155,698✔
555
                }
286,718✔
556

557
                // Retrieve the valid dependents of the lesson, and directly skip the lesson if
558
                // needed.
559
                let valid_deps =
442,416✔
560
                    self.get_valid_dependents(curr_unit.unit_id, curr_unit.depth, metadata_filter);
442,416✔
561
                if self.skip_lesson(curr_unit.unit_id, metadata_filter) {
442,416✔
562
                    Self::shuffle_to_stack(&curr_unit, valid_deps, &mut stack);
30,294✔
563
                    continue;
30,294✔
564
                }
412,122✔
565

566
                // Retrieve the candidates from the lesson and add them to the list of candidates.
567
                let (candidates, avg_score) = self.get_candidates_from_lesson_helper(&curr_unit)?;
412,122✔
568
                let num_candidates = candidates.len();
412,122✔
569
                all_candidates.extend(candidates);
412,122✔
570

412,122✔
571
                // The average score is considered valid only if at least one candidate was
412,122✔
572
                // retrieved. Compare it against the passing score to decide whether the search
412,122✔
573
                // should continue past this lesson.
412,122✔
574
                if num_candidates > 0
412,122✔
575
                    && avg_score
375,963✔
576
                        < self
375,963✔
577
                            .data
375,963✔
578
                            .options
375,963✔
579
                            .passing_score
375,963✔
580
                            .compute_score(curr_unit.depth)
375,963✔
581
                {
582
                    // If the search reaches a dead-end and there are already enough candidates,
583
                    // terminate the search. Otherwise, continue with the search.
584
                    if all_candidates.len() >= max_candidates {
1,652✔
UNCOV
585
                        break;
×
586
                    }
1,652✔
587
                    continue;
1,652✔
588
                }
410,470✔
589

410,470✔
590
                // The search should continue past this lesson. Add its valid dependents to the
410,470✔
591
                // stack.
410,470✔
592
                Self::shuffle_to_stack(&curr_unit, valid_deps, &mut stack);
410,470✔
593
            }
×
594
        }
595

596
        Ok(all_candidates)
7,856✔
597
    }
7,856✔
598

599
    /// Searches for candidates from the given course.
600
    fn get_candidates_from_course(&self, course_ids: &[Ustr]) -> Result<Vec<Candidate>> {
403✔
601
        // Initialize the set of visited units and the stack with the starting lessons from the
403✔
602
        // courses. Add all starting lessons, even if their dependencies are not satisfied, because
403✔
603
        // the user specifically asked for questions from these courses.
403✔
604
        let mut stack: Vec<StackItem> = Vec::new();
403✔
605
        let mut visited = UstrSet::default();
403✔
606
        for course_id in course_ids {
1,050✔
607
            let starting_lessons = self
647✔
608
                .data
647✔
609
                .unit_graph
647✔
610
                .read()
647✔
611
                .get_starting_lessons(*course_id)
647✔
612
                .unwrap_or_default()
647✔
613
                .into_iter()
647✔
614
                .map(|id| StackItem {
647✔
615
                    unit_id: id,
566✔
616
                    depth: 0,
566✔
617
                });
647✔
618
            stack.extend(starting_lessons);
647✔
619
            visited.insert(*course_id);
647✔
620
        }
647✔
621

622
        // Initialize the list of candidates.
623
        let max_candidates = self.data.options.batch_size * MAX_CANDIDATE_FACTOR;
403✔
624
        let mut all_candidates: Vec<Candidate> = Vec::new();
403✔
625

626
        // Perform a depth-first search to find the candidates.
627
        while let Some(curr_unit) = stack.pop() {
1,599✔
628
            // Continue if the unit has been visited and update the list of visited units.
629
            if visited.contains(&curr_unit.unit_id) {
1,196✔
630
                continue;
×
631
            }
1,196✔
632
            visited.insert(curr_unit.unit_id);
1,196✔
633

1,196✔
634
            // The logic past this point depends on the type of the unit.
1,196✔
635
            let unit_type = self.data.get_unit_type(curr_unit.unit_id);
1,196✔
636
            if unit_type.is_none() {
1,196✔
637
                // The type of the unit is unknown. This can happen when a unit depends on some
638
                // unknown unit.
639
                continue;
×
640
            }
1,196✔
641
            let unit_type = unit_type.unwrap();
1,196✔
642

1,196✔
643
            // Only handle lessons. Other courses and exercises are skipped by the search.
1,196✔
644
            if unit_type == UnitType::Lesson {
1,196✔
645
                // If the searched reached this point, the unit must be a lesson. Ignore lessons
646
                // from other courses that might have been added to the stack.
647
                let lesson_course_id = self
1,196✔
648
                    .data
1,196✔
649
                    .get_lesson_course(curr_unit.unit_id)
1,196✔
650
                    .unwrap_or_default();
1,196✔
651
                if !course_ids.contains(&lesson_course_id) {
1,196✔
652
                    continue;
×
653
                }
1,196✔
654

1,196✔
655
                // Retrieve the valid dependents of the lesson, and directly skip the lesson if
1,196✔
656
                // needed.
1,196✔
657
                let valid_deps =
1,196✔
658
                    self.get_valid_dependents(curr_unit.unit_id, curr_unit.depth, None);
1,196✔
659
                if self.skip_lesson(curr_unit.unit_id, None) {
1,196✔
660
                    Self::shuffle_to_stack(&curr_unit, valid_deps, &mut stack);
162✔
661
                    continue;
162✔
662
                }
1,034✔
663

664
                // Retrieve the candidates from the lesson and add them to the list of candidates.
665
                let (candidates, avg_score) = self.get_candidates_from_lesson_helper(&curr_unit)?;
1,034✔
666
                let num_candidates = candidates.len();
1,034✔
667
                all_candidates.extend(candidates);
1,034✔
668

1,034✔
669
                // The average score is considered valid only if at least one candidate was
1,034✔
670
                // retrieved. Compare it against the passing score to decide whether the search
1,034✔
671
                // should continue past this lesson.
1,034✔
672
                if num_candidates > 0
1,034✔
673
                    && avg_score
1,034✔
674
                        < self
1,034✔
675
                            .data
1,034✔
676
                            .options
1,034✔
677
                            .passing_score
1,034✔
678
                            .compute_score(curr_unit.depth)
1,034✔
679
                {
680
                    // If the search reaches a dead-end and there are already enough candidates,
681
                    // terminate the search. Continue otherwise.
682
                    if all_candidates.len() >= max_candidates {
23✔
683
                        break;
×
684
                    }
23✔
685
                    continue;
23✔
686
                }
1,011✔
687

1,011✔
688
                // Add the lesson's valid dependents to the stack.
1,011✔
689
                Self::shuffle_to_stack(&curr_unit, valid_deps, &mut stack);
1,011✔
690
            }
×
691
        }
692

693
        Ok(all_candidates)
403✔
694
    }
403✔
695

696
    /// Searches for candidates from the given lesson.
697
    fn get_candidates_from_lesson(&self, lesson_id: Ustr) -> Result<Vec<Candidate>> {
878✔
698
        let (candidates, _) = self.get_candidates_from_lesson_helper(&StackItem {
878✔
699
            unit_id: lesson_id,
878✔
700
            depth: 0,
878✔
701
        })?;
878✔
702
        Ok(candidates)
878✔
703
    }
878✔
704

705
    /// Searches from candidates from the units in the review list. This mode allows the student to
706
    /// exclusively practice the courses, lessons, and exercises they have marked for review.
707
    fn get_candidates_from_review_list(&self) -> Result<Vec<Candidate>> {
145✔
708
        // Retrieve candidates from each entry in the review list.
145✔
709
        let mut candidates = vec![];
145✔
710
        let review_list = self.data.review_list.read().get_review_list_entries()?;
145✔
711
        for unit_id in review_list {
435✔
712
            match self.data.get_unit_type_strict(unit_id)? {
290✔
713
                UnitType::Course => {
714
                    // If the unit is a course, use the course scheduler to retrieve candidates.
715
                    let course_ids = vec![unit_id];
158✔
716
                    candidates.extend(self.get_candidates_from_course(&course_ids)?);
158✔
717
                }
718
                UnitType::Lesson => {
719
                    // If the unit is a lesson, use the lesson scheduler to retrieve candidates.
720
                    candidates.extend(self.get_candidates_from_lesson(unit_id)?);
32✔
721
                }
722
                UnitType::Exercise => {
723
                    // Retrieve the exercise's lesson ID.
724
                    let lesson_id = self
100✔
725
                        .data
100✔
726
                        .unit_graph
100✔
727
                        .read()
100✔
728
                        .get_exercise_lesson(unit_id)
100✔
729
                        .unwrap_or_default();
100✔
730

100✔
731
                    // If the unit is an exercise, directly add it to the list of candidates.
100✔
732
                    candidates.push(Candidate {
100✔
733
                        exercise_id: unit_id,
100✔
734
                        lesson_id,
100✔
735
                        depth: 0.0,
100✔
736
                        score: self
100✔
737
                            .score_cache
100✔
738
                            .get_unit_score(unit_id)?
100✔
739
                            .unwrap_or_default(),
100✔
740
                        num_trials: self
100✔
741
                            .score_cache
100✔
742
                            .get_num_trials(unit_id)?
100✔
743
                            .unwrap_or_default(),
100✔
744
                        frequency: *self.data.frequency_map.read().get(&unit_id).unwrap_or(&0),
100✔
745
                    });
746
                }
747
            }
748
        }
749

750
        Ok(candidates)
145✔
751
    }
145✔
752

753
    /// Retrieves an initial batch of candidates based on the given filter.
754
    fn get_initial_candidates(&self, filter: Option<ExerciseFilter>) -> Result<Vec<Candidate>> {
8,810✔
755
        // Retrieve an initial list of candidates based on the type of the filter.
756
        let candidates = match filter {
8,810✔
757
            None => {
758
                // If the filter is empty, retrieve candidates from the entire graph. This mode is
759
                // Trane's default.
760
                let initial_stack = self.get_initial_stack(None);
7,232✔
761
                self.get_candidates_from_graph(initial_stack, None)?
7,232✔
762
            }
763
            Some(filter) => match filter {
1,578✔
764
                // Otherwise, use the given filter to select how candidates are retrieved.
765
                ExerciseFilter::UnitFilter(filter) => match filter {
1,496✔
766
                    UnitFilter::CourseFilter { course_ids } => {
245✔
767
                        self.get_candidates_from_course(&course_ids[..])?
245✔
768
                    }
769
                    UnitFilter::LessonFilter { lesson_ids } => {
482✔
770
                        let mut candidates = Vec::new();
482✔
771
                        for lesson_id in lesson_ids {
1,328✔
772
                            candidates
846✔
773
                                .extend(self.get_candidates_from_lesson(lesson_id)?.into_iter());
846✔
774
                        }
775
                        candidates
482✔
776
                    }
777
                    UnitFilter::MetadataFilter { filter } => {
403✔
778
                        let initial_stack = self.get_initial_stack(Some(&filter));
403✔
779
                        self.get_candidates_from_graph(initial_stack, Some(&filter))?
403✔
780
                    }
781
                    UnitFilter::ReviewListFilter => self.get_candidates_from_review_list()?,
145✔
782
                    UnitFilter::Dependents { unit_ids } => {
82✔
783
                        let initial_stack = unit_ids
82✔
784
                            .iter()
82✔
785
                            .map(|unit_id| StackItem {
82✔
786
                                unit_id: *unit_id,
82✔
787
                                depth: 0,
82✔
788
                            })
82✔
789
                            .collect();
82✔
790
                        self.get_candidates_from_graph(initial_stack, None)?
82✔
791
                    }
792
                    UnitFilter::Dependencies { unit_ids, depth } => {
139✔
793
                        let dependencies: Vec<Ustr> = unit_ids
139✔
794
                            .iter()
139✔
795
                            .flat_map(|unit_id| {
139✔
796
                                self.data.get_dependencies_at_depth(*unit_id, depth)
139✔
797
                            })
139✔
798
                            .collect();
139✔
799
                        let initial_stack = dependencies
139✔
800
                            .iter()
139✔
801
                            .map(|unit_id| StackItem {
139✔
802
                                unit_id: *unit_id,
138✔
803
                                depth: 0,
138✔
804
                            })
139✔
805
                            .collect();
139✔
806
                        self.get_candidates_from_graph(initial_stack, None)?
139✔
807
                    }
808
                },
809
                ExerciseFilter::StudySession(session_data) => {
82✔
810
                    let unit_filter = self
82✔
811
                        .data
82✔
812
                        .get_session_filter(&session_data, Utc::now())?
82✔
813
                        .map(ExerciseFilter::UnitFilter);
82✔
814
                    self.get_initial_candidates(unit_filter)?
82✔
815
                }
816
            },
817
        };
818

819
        Ok(candidates)
8,810✔
820
    }
8,810✔
821
}
822

823
impl ExerciseScheduler for DepthFirstScheduler {
824
    fn get_exercise_batch(
8,728✔
825
        &self,
8,728✔
826
        filter: Option<ExerciseFilter>,
8,728✔
827
    ) -> Result<Vec<ExerciseManifest>, ExerciseSchedulerError> {
8,728✔
828
        // Retrieve an initial batch of candidates based on the type of the filter.
829
        let initial_candidates = self
8,728✔
830
            .get_initial_candidates(filter)
8,728✔
831
            .map_err(ExerciseSchedulerError::GetExerciseBatch)?;
8,728✔
832

833
        // Sort the candidates into buckets, select the right number from each, and convert them
834
        // into a final batch of exercises.
835
        let final_candidates = self
8,728✔
836
            .filter
8,728✔
837
            .filter_candidates(&initial_candidates)
8,728✔
838
            .map_err(ExerciseSchedulerError::GetExerciseBatch)?;
8,728✔
839

840
        // Increment the frequency of the exercises in the batch. These exercises will have a lower
841
        // chance of being selected in the future,so that exercises that have not been selected as
842
        // often have a higher chance of being selected.
843
        for exercise_manifest in &final_candidates {
90,169✔
844
            self.data.increment_exercise_frequency(exercise_manifest.id);
81,441✔
845
        }
81,441✔
846

847
        Ok(final_candidates)
8,728✔
848
    }
8,728✔
849

850
    fn score_exercise(
81,274✔
851
        &self,
81,274✔
852
        exercise_id: Ustr,
81,274✔
853
        score: MasteryScore,
81,274✔
854
        timestamp: i64,
81,274✔
855
    ) -> Result<(), ExerciseSchedulerError> {
81,274✔
856
        // Write the score to the practice stats database.
81,274✔
857
        self.data
81,274✔
858
            .practice_stats
81,274✔
859
            .write()
81,274✔
860
            .record_exercise_score(exercise_id, score, timestamp)
81,274✔
861
            .map_err(|e| ExerciseSchedulerError::ScoreExercise(e.into()))?;
81,274✔
862

863
        // Any cached score for this exercise and its parent lesson is now invalid. Remove it from
864
        // the exercise and lesson caches.
865
        self.score_cache.invalidate_cached_score(exercise_id);
81,274✔
866
        Ok(())
81,274✔
867
    }
81,274✔
868

869
    #[cfg_attr(coverage, coverage(off))]
870
    fn invalidate_cached_score(&self, unit_id: Ustr) {
871
        self.score_cache.invalidate_cached_score(unit_id);
872
    }
873

874
    #[cfg_attr(coverage, coverage(off))]
875
    fn invalidate_cached_scores_with_prefix(&self, prefix: &str) {
876
        self.score_cache
877
            .invalidate_cached_scores_with_prefix(prefix);
878
    }
879

880
    fn get_scheduler_options(&self) -> SchedulerOptions {
2✔
881
        self.data.options.clone()
2✔
882
    }
2✔
883

884
    fn set_scheduler_options(&mut self, options: SchedulerOptions) {
2✔
885
        self.data.options = options;
2✔
886
    }
2✔
887

888
    fn reset_scheduler_options(&mut self) {
1✔
889
        self.data.options = SchedulerOptions::default();
1✔
890
    }
1✔
891
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc