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

trane-project / trane / 12735888711

12 Jan 2025 06:06PM UTC coverage: 92.973% (-6.9%) from 99.866%
12735888711

Pull #319

github

web-flow
Merge 2bba403ef into f4478d7eb
Pull Request #319: Update to rust 1.84

69 of 77 new or added lines in 18 files covered. (89.61%)

236 existing lines in 13 files now uncovered.

9672 of 10403 relevant lines covered (92.97%)

87046.61 hits per line

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

98.0
/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>) {
923,939✔
163
        units.shuffle(&mut thread_rng());
923,939✔
164
        stack.extend(units.iter().map(|id| StackItem {
1,085,252✔
165
            unit_id: *id,
1,085,252✔
166
            depth: curr_unit.depth + 1,
1,085,252✔
167
        }));
1,085,252✔
168
    }
923,939✔
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,470✔
173
        // Replace any missing units with their dependents and repeat this process until there are
7,470✔
174
        // no missing courses.
7,470✔
175
        let mut starting_courses = self.data.unit_graph.read().get_dependency_sinks();
7,470✔
176
        loop {
177
            let mut new_starting_courses = UstrSet::default();
7,528✔
178
            for course_id in &starting_courses {
41,490✔
179
                if self.data.unit_exists(*course_id).unwrap_or(false) {
33,962✔
180
                    new_starting_courses.insert(*course_id);
32,982✔
181
                } else {
32,982✔
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,528✔
186
                break;
7,470✔
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,470✔
195
            .into_iter()
7,470✔
196
            .filter(|course_id| {
33,614✔
197
                self.data
33,614✔
198
                    .unit_graph
33,614✔
199
                    .read()
33,614✔
200
                    .get_dependencies(*course_id)
33,614✔
201
                    .unwrap_or_default()
33,614✔
202
                    .iter()
33,614✔
203
                    .all(|id| !self.data.unit_exists(*id).unwrap())
33,614✔
204
            })
33,614✔
205
            .collect()
7,470✔
206
    }
7,470✔
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(
319,989✔
211
        &self,
319,989✔
212
        course_id: Ustr,
319,989✔
213
        depth: usize,
319,989✔
214
        metadata_filter: Option<&KeyValueFilter>,
319,989✔
215
    ) -> Result<Vec<Ustr>> {
319,989✔
216
        Ok(self
319,989✔
217
            .data
319,989✔
218
            .unit_graph
319,989✔
219
            .read()
319,989✔
220
            .get_starting_lessons(course_id)
319,989✔
221
            .unwrap_or_default()
319,989✔
222
            .into_iter()
319,989✔
223
            .filter(|id| {
368,807✔
224
                // Filter out lessons whose dependencies are not satisfied. Otherwise, those lessons
368,807✔
225
                // would be traversed prematurely.
368,807✔
226
                self.all_satisfied_dependencies(*id, depth, metadata_filter)
368,807✔
227
            })
368,807✔
228
            .collect())
319,989✔
229
    }
319,989✔
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,470✔
235
        // First get all the starting units and then all of their starting lessons.
7,470✔
236
        let starting_units = self.get_all_starting_units();
7,470✔
237
        let mut initial_stack: Vec<StackItem> = vec![];
7,470✔
238
        for course_id in starting_units {
40,910✔
239
            // Set the depth to zero since all the starting units are at the same depth.
240
            let lesson_ids = self
33,440✔
241
                .get_course_valid_starting_lessons(course_id, 0, metadata_filter)
33,440✔
242
                .unwrap_or_default();
33,440✔
243

33,440✔
244
            if lesson_ids.is_empty() {
33,440✔
245
                // For units with no lessons, insert the unit itself as a starting unit so that its
892✔
246
                // dependents are traversed.
892✔
247
                initial_stack.push(StackItem {
892✔
248
                    unit_id: course_id,
892✔
249
                    depth: 0,
892✔
250
                });
892✔
251
            } else {
32,548✔
252
                // Insert all the starting lessons in the stack.
32,548✔
253
                initial_stack.extend(
32,548✔
254
                    lesson_ids
32,548✔
255
                        .into_iter()
32,548✔
256
                        .map(|unit_id| StackItem { unit_id, depth: 0 }),
36,058✔
257
                );
32,548✔
258
            }
32,548✔
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,470✔
263
        initial_stack
7,470✔
264
    }
7,470✔
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)> {
430,180✔
270
        // Retrieve the lesson's exercises.
430,180✔
271
        let exercises = self.data.all_valid_exercises_in_lesson(item.unit_id);
430,180✔
272
        if exercises.is_empty() {
430,180✔
273
            // Return early to avoid division by zero later on.
274
            return Ok((vec![], 0.0));
52,480✔
275
        }
377,700✔
276

277
        // Generate a list of candidates from the lesson's exercises.
278
        let candidates = exercises
377,700✔
279
            .into_iter()
377,700✔
280
            .map(|exercise_id| {
2,205,322✔
281
                Ok(Candidate {
2,205,322✔
282
                    exercise_id,
2,205,322✔
283
                    lesson_id: item.unit_id, // It's assumed that the item is a lesson.
2,205,322✔
284
                    depth: (item.depth + 1) as f32,
2,205,322✔
285
                    score: self
2,205,322✔
286
                        .score_cache
2,205,322✔
287
                        .get_unit_score(exercise_id)?
2,205,322✔
288
                        .unwrap_or_default(),
2,205,322✔
289
                    num_trials: self
2,205,322✔
290
                        .score_cache
2,205,322✔
291
                        .get_num_trials(exercise_id)?
2,205,322✔
292
                        .unwrap_or_default(),
2,205,322✔
293
                    frequency: self.data.get_exercise_frequency(exercise_id),
2,205,322✔
294
                })
295
            })
2,205,322✔
296
            .collect::<Result<Vec<Candidate>>>()?;
377,700✔
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,205,322✔
300
        Ok((candidates, avg_score))
377,700✔
301
    }
430,180✔
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,838,110✔
306
        &self,
1,838,110✔
307
        dependency_id: Ustr,
1,838,110✔
308
        depth: usize,
1,838,110✔
309
        metadata_filter: Option<&KeyValueFilter>,
1,838,110✔
310
    ) -> bool {
1,838,110✔
311
        // Dependencies which do not pass the metadata filter are considered as satisfied, so the
1,838,110✔
312
        // search can continue past them.
1,838,110✔
313
        let passes_filter = self
1,838,110✔
314
            .data
1,838,110✔
315
            .unit_passes_filter(dependency_id, metadata_filter)
1,838,110✔
316
            .unwrap_or(false);
1,838,110✔
317
        if !passes_filter {
1,838,110✔
318
            return true;
5,072✔
319
        }
1,833,038✔
320

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

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

1,832,498✔
338
        // The dependency is considered as satisfied if it's been superseded by another unit.
1,832,498✔
339
        let superseding = self.score_cache.get_superseding_recursive(dependency_id);
1,832,498✔
340
        if let Some(superseding) = superseding {
1,832,498✔
341
            if self.score_cache.is_superseded(dependency_id, &superseding) {
22,361✔
342
                return true;
20,113✔
343
            }
2,248✔
344
        }
1,810,137✔
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,812,385✔
349
        if let Ok(Some(score)) = score {
1,811,866✔
350
            score >= self.data.options.passing_score.compute_score(depth)
1,442,301✔
351
        } else {
352
            true
370,084✔
353
        }
354
    }
1,838,110✔
355

356
    /// Returns whether all the dependencies of the given unit are satisfied.
357
    fn all_satisfied_dependencies(
1,124,488✔
358
        &self,
1,124,488✔
359
        unit_id: Ustr,
1,124,488✔
360
        depth: usize,
1,124,488✔
361
        metadata_filter: Option<&KeyValueFilter>,
1,124,488✔
362
    ) -> bool {
1,124,488✔
363
        self.data
1,124,488✔
364
            .unit_graph
1,124,488✔
365
            .read()
1,124,488✔
366
            .get_dependencies(unit_id)
1,124,488✔
367
            .unwrap_or_default()
1,124,488✔
368
            .into_iter()
1,124,488✔
369
            .all(|dependency_id| self.satisfied_dependency(dependency_id, depth, metadata_filter))
1,838,110✔
370
    }
1,124,488✔
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(
639,077✔
375
        &self,
639,077✔
376
        unit_id: Ustr,
639,077✔
377
        depth: usize,
639,077✔
378
        metadata_filter: Option<&KeyValueFilter>,
639,077✔
379
    ) -> Vec<Ustr> {
639,077✔
380
        self.data
639,077✔
381
            .get_all_dependents(unit_id)
639,077✔
382
            .into_iter()
639,077✔
383
            .filter(|unit_id| self.all_satisfied_dependencies(*unit_id, depth, metadata_filter))
755,681✔
384
            .collect()
639,077✔
385
    }
639,077✔
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(
286,549✔
390
        &self,
286,549✔
391
        course_id: Ustr,
286,549✔
392
        metadata_filter: Option<&KeyValueFilter>,
286,549✔
393
        pending_course_lessons: &mut UstrMap<usize>,
286,549✔
394
    ) -> bool {
286,549✔
395
        // Check if the course is blacklisted.
286,549✔
396
        let blacklisted = self.data.blacklisted(course_id).unwrap_or(false);
286,549✔
397

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

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

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

286,549✔
418
        // The course should be skipped if the course is blacklisted, does not pass the filter, has
286,549✔
419
        // no pending lessons, or if it' been superseded.
286,549✔
420
        blacklisted || !passes_filter || *pending_lessons == 0 || is_superseded
286,549✔
421
    }
286,549✔
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 {
459,823✔
426
        // Check if the lesson is blacklisted.
459,823✔
427
        let blacklisted = self.data.blacklisted(lesson_id).unwrap_or(false);
459,823✔
428

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

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

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

459,823✔
454
        // The lesson should be skipped if it is blacklisted, does not pass the filter or if it or
459,823✔
455
        // its course have been superseded.
459,823✔
456
        blacklisted || !passes_filter || is_lesson_superseded || is_course_superseded
459,823✔
457
    }
459,823✔
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,691✔
463
        &self,
7,691✔
464
        initial_stack: Vec<StackItem>,
7,691✔
465
        metadata_filter: Option<&KeyValueFilter>,
7,691✔
466
    ) -> Result<Vec<Candidate>> {
7,691✔
467
        // Initialize the stack with every starting lesson, which are those units with no
7,691✔
468
        // dependencies that are needed to reach all the units in the graph.
7,691✔
469
        let mut stack: Vec<StackItem> = Vec::new();
7,691✔
470
        stack.extend(initial_stack);
7,691✔
471

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

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

488
        // Perform a depth-first search of the graph.
489
        while let Some(curr_unit) = stack.pop() {
1,272,369✔
490
            // Immediately skip the item if it has been visited.
491
            if visited.contains(&curr_unit.unit_id) {
1,264,678✔
492
                continue;
518,696✔
493
            }
745,982✔
494

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

745,176✔
504
            // Handle exercises. All of them should be skipped as the search only considers lessons
745,176✔
505
            // and courses.
745,176✔
506
            if unit_type == UnitType::Exercise {
745,176✔
UNCOV
507
                continue;
×
508
            }
745,176✔
509

745,176✔
510
            // Handle courses.
745,176✔
511
            if unit_type == UnitType::Course {
745,176✔
512
                // Retrieve the starting lessons in the course and add them to the stack.
513
                let starting_lessons: Vec<Ustr> = self
286,549✔
514
                    .get_course_valid_starting_lessons(
286,549✔
515
                        curr_unit.unit_id,
286,549✔
516
                        curr_unit.depth,
286,549✔
517
                        metadata_filter,
286,549✔
518
                    )
286,549✔
519
                    .unwrap_or_default();
286,549✔
520
                Self::shuffle_to_stack(&curr_unit, starting_lessons, &mut stack);
286,549✔
521

286,549✔
522
                // The course can be skipped. Add it to the visited set, push its valid dependents
286,549✔
523
                // onto the stack, and continue.
286,549✔
524
                if self.skip_course(
286,549✔
525
                    curr_unit.unit_id,
286,549✔
526
                    metadata_filter,
286,549✔
527
                    &mut pending_course_lessons,
286,549✔
528
                ) {
286,549✔
529
                    visited.insert(curr_unit.unit_id);
179,254✔
530
                    let valid_deps = self.get_valid_dependents(
179,254✔
531
                        curr_unit.unit_id,
179,254✔
532
                        curr_unit.depth,
179,254✔
533
                        metadata_filter,
179,254✔
534
                    );
179,254✔
535
                    Self::shuffle_to_stack(&curr_unit, valid_deps, &mut stack);
179,254✔
536
                    continue;
179,254✔
537
                }
107,295✔
538

107,295✔
539
                // The course has pending lessons, so it cannot be marked as visited yet. Simply
107,295✔
540
                // continue with the search.
107,295✔
541
                continue;
107,295✔
542
            }
458,627✔
543

458,627✔
544
            // If the searched reached this point, the unit must be a lesson.
458,627✔
545
            visited.insert(curr_unit.unit_id);
458,627✔
546

547
            // Update the number of lessons pending to be processed.
548
            let course_id = self.data.get_course_id(curr_unit.unit_id)?;
458,627✔
549
            let pending_lessons = pending_course_lessons
458,627✔
550
                .entry(course_id)
458,627✔
551
                .or_insert_with(|| self.data.get_num_lessons_in_course(course_id));
458,627✔
552
            *pending_lessons -= 1;
458,627✔
553

458,627✔
554
            // Check whether there are pending lessons.
458,627✔
555
            if *pending_lessons == 0 {
458,627✔
556
                // Once all the lessons in the course have been visited, re-add the course to the
142,886✔
557
                // stack, so the search can continue exploring its dependents.
142,886✔
558
                stack.push(StackItem {
142,886✔
559
                    unit_id: course_id,
142,886✔
560
                    depth: curr_unit.depth + 1,
142,886✔
561
                });
142,886✔
562
            }
315,741✔
563

564
            // Retrieve the valid dependents of the lesson, and directly skip the lesson if needed.
565
            let valid_deps =
458,627✔
566
                self.get_valid_dependents(curr_unit.unit_id, curr_unit.depth, metadata_filter);
458,627✔
567
            if self.skip_lesson(curr_unit.unit_id, metadata_filter) {
458,627✔
568
                Self::shuffle_to_stack(&curr_unit, valid_deps, &mut stack);
30,359✔
569
                continue;
30,359✔
570
            }
428,268✔
571

572
            // Retrieve the candidates from the lesson and add them to the list of candidates.
573
            let (candidates, avg_score) = self.get_candidates_from_lesson_helper(&curr_unit)?;
428,268✔
574
            let num_candidates = candidates.len();
428,268✔
575
            all_candidates.extend(candidates);
428,268✔
576

428,268✔
577
            // The average score is considered valid only if at least one candidate was retrieved.
428,268✔
578
            // Compare it against the passing score to decide whether the search should continue
428,268✔
579
            // past this lesson.
428,268✔
580
            if num_candidates > 0
428,268✔
581
                && avg_score
375,870✔
582
                    < self
375,870✔
583
                        .data
375,870✔
584
                        .options
375,870✔
585
                        .passing_score
375,870✔
586
                        .compute_score(curr_unit.depth)
375,870✔
587
            {
588
                // If the search reaches a dead-end and there are already enough candidates,
589
                // terminate the search. Otherwise, continue with the search.
590
                if all_candidates.len() >= max_candidates {
1,664✔
UNCOV
591
                    break;
×
592
                }
1,664✔
593
                continue;
1,664✔
594
            }
426,604✔
595

426,604✔
596
            // The search should continue past this lesson. Add its valid dependents to the stack.
426,604✔
597
            Self::shuffle_to_stack(&curr_unit, valid_deps, &mut stack);
426,604✔
598
        }
599

600
        Ok(all_candidates)
7,691✔
601
    }
7,691✔
602

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

626
        // Initialize the list of candidates.
627
        let max_candidates = self.data.options.batch_size * MAX_CANDIDATE_FACTOR;
403✔
628
        let mut all_candidates: Vec<Candidate> = Vec::new();
403✔
629

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

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

1,196✔
647
            // Handle courses. They should be skipped, as all the courses that should be considered
1,196✔
648
            // were already handled when their starting lessons were added to the stack.
1,196✔
649
            if unit_type == UnitType::Course {
1,196✔
UNCOV
650
                continue;
×
651
            }
1,196✔
652

1,196✔
653
            // Handle exercises. They should be skipped as well, as only lessons should be
1,196✔
654
            // traversed.
1,196✔
655
            if unit_type == UnitType::Exercise {
1,196✔
UNCOV
656
                continue;
×
657
            }
1,196✔
658

1,196✔
659
            // If the searched reached this point, the unit must be a lesson. Ignore lessons from
1,196✔
660
            // other courses that might have been added to the stack.
1,196✔
661
            let lesson_course_id = self
1,196✔
662
                .data
1,196✔
663
                .get_lesson_course(curr_unit.unit_id)
1,196✔
664
                .unwrap_or_default();
1,196✔
665
            if !course_ids.contains(&lesson_course_id) {
1,196✔
UNCOV
666
                continue;
×
667
            }
1,196✔
668

1,196✔
669
            // Retrieve the valid dependents of the lesson, and directly skip the lesson if needed.
1,196✔
670
            let valid_deps = self.get_valid_dependents(curr_unit.unit_id, curr_unit.depth, None);
1,196✔
671
            if self.skip_lesson(curr_unit.unit_id, None) {
1,196✔
672
                Self::shuffle_to_stack(&curr_unit, valid_deps, &mut stack);
162✔
673
                continue;
162✔
674
            }
1,034✔
675

676
            // Retrieve the candidates from the lesson and add them to the list of candidates.
677
            let (candidates, avg_score) = self.get_candidates_from_lesson_helper(&curr_unit)?;
1,034✔
678
            let num_candidates = candidates.len();
1,034✔
679
            all_candidates.extend(candidates);
1,034✔
680

1,034✔
681
            // The average score is considered valid only if at least one candidate was retrieved.
1,034✔
682
            // Compare it against the passing score to decide whether the search should continue
1,034✔
683
            // past this lesson.
1,034✔
684
            if num_candidates > 0
1,034✔
685
                && avg_score
1,034✔
686
                    < self
1,034✔
687
                        .data
1,034✔
688
                        .options
1,034✔
689
                        .passing_score
1,034✔
690
                        .compute_score(curr_unit.depth)
1,034✔
691
            {
692
                // If the search reaches a dead-end and there are already enough candidates,
693
                // terminate the search. Continue otherwise.
694
                if all_candidates.len() >= max_candidates {
23✔
UNCOV
695
                    break;
×
696
                }
23✔
697
                continue;
23✔
698
            }
1,011✔
699

1,011✔
700
            // Add the lesson's valid dependents to the stack.
1,011✔
701
            Self::shuffle_to_stack(&curr_unit, valid_deps, &mut stack);
1,011✔
702
        }
703

704
        Ok(all_candidates)
403✔
705
    }
403✔
706

707
    /// Searches for candidates from the given lesson.
708
    fn get_candidates_from_lesson(&self, lesson_id: Ustr) -> Result<Vec<Candidate>> {
878✔
709
        let (candidates, _) = self.get_candidates_from_lesson_helper(&StackItem {
878✔
710
            unit_id: lesson_id,
878✔
711
            depth: 0,
878✔
712
        })?;
878✔
713
        Ok(candidates)
878✔
714
    }
878✔
715

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

100✔
742
                    // If the unit is an exercise, directly add it to the list of candidates.
100✔
743
                    candidates.push(Candidate {
100✔
744
                        exercise_id: unit_id,
100✔
745
                        lesson_id,
100✔
746
                        depth: 0.0,
100✔
747
                        score: self
100✔
748
                            .score_cache
100✔
749
                            .get_unit_score(unit_id)?
100✔
750
                            .unwrap_or_default(),
100✔
751
                        num_trials: self
100✔
752
                            .score_cache
100✔
753
                            .get_num_trials(unit_id)?
100✔
754
                            .unwrap_or_default(),
100✔
755
                        frequency: *self.data.frequency_map.read().get(&unit_id).unwrap_or(&0),
100✔
756
                    });
757
                }
758
            }
759
        }
760

761
        Ok(candidates)
145✔
762
    }
145✔
763

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

830
        Ok(candidates)
8,645✔
831
    }
8,645✔
832
}
833

834
impl ExerciseScheduler for DepthFirstScheduler {
835
    fn get_exercise_batch(
8,563✔
836
        &self,
8,563✔
837
        filter: Option<ExerciseFilter>,
8,563✔
838
    ) -> Result<Vec<ExerciseManifest>, ExerciseSchedulerError> {
8,563✔
839
        // Retrieve an initial batch of candidates based on the type of the filter.
840
        let initial_candidates = self
8,563✔
841
            .get_initial_candidates(filter)
8,563✔
842
            .map_err(ExerciseSchedulerError::GetExerciseBatch)?;
8,563✔
843

844
        // Sort the candidates into buckets, select the right number from each, and convert them
845
        // into a final batch of exercises.
846
        let final_candidates = self
8,563✔
847
            .filter
8,563✔
848
            .filter_candidates(&initial_candidates)
8,563✔
849
            .map_err(ExerciseSchedulerError::GetExerciseBatch)?;
8,563✔
850

851
        // Increment the frequency of the exercises in the batch. These exercises will have a lower
852
        // chance of being selected in the future,so that exercises that have not been selected as
853
        // often have a higher chance of being selected.
854
        for exercise_manifest in &final_candidates {
88,201✔
855
            self.data.increment_exercise_frequency(exercise_manifest.id);
79,638✔
856
        }
79,638✔
857

858
        Ok(final_candidates)
8,563✔
859
    }
8,563✔
860

861
    fn score_exercise(
79,474✔
862
        &self,
79,474✔
863
        exercise_id: Ustr,
79,474✔
864
        score: MasteryScore,
79,474✔
865
        timestamp: i64,
79,474✔
866
    ) -> Result<(), ExerciseSchedulerError> {
79,474✔
867
        // Write the score to the practice stats database.
79,474✔
868
        self.data
79,474✔
869
            .practice_stats
79,474✔
870
            .write()
79,474✔
871
            .record_exercise_score(exercise_id, score, timestamp)
79,474✔
872
            .map_err(|e| ExerciseSchedulerError::ScoreExercise(e.into()))?;
79,474✔
873

874
        // Any cached score for this exercise and its parent lesson is now invalid. Remove it from
875
        // the exercise and lesson caches.
876
        self.score_cache.invalidate_cached_score(exercise_id);
79,474✔
877
        Ok(())
79,474✔
878
    }
79,474✔
879

880
    fn invalidate_cached_score(&self, unit_id: Ustr) {
78✔
881
        self.score_cache.invalidate_cached_score(unit_id);
78✔
882
    }
78✔
883

UNCOV
884
    fn invalidate_cached_scores_with_prefix(&self, prefix: &str) {
×
UNCOV
885
        self.score_cache
×
UNCOV
886
            .invalidate_cached_scores_with_prefix(prefix);
×
UNCOV
887
    }
×
888

889
    fn get_scheduler_options(&self) -> SchedulerOptions {
2✔
890
        self.data.options.clone()
2✔
891
    }
2✔
892

893
    fn set_scheduler_options(&mut self, options: SchedulerOptions) {
2✔
894
        self.data.options = options;
2✔
895
    }
2✔
896

897
    fn reset_scheduler_options(&mut self) {
1✔
898
        self.data.options = SchedulerOptions::default();
1✔
899
    }
1✔
900
}
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