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

trane-project / trane / 13581533471

28 Feb 2025 04:47AM UTC coverage: 99.445% (-0.2%) from 99.674%
13581533471

Pull #323

github

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

205 of 218 new or added lines in 1 file covered. (94.04%)

2 existing lines in 2 files now uncovered.

5016 of 5044 relevant lines covered (99.44%)

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

44,787✔
244
            if lesson_ids.is_empty() {
44,787✔
245
                // For units with no lessons, insert the unit itself as a starting unit so that its
6,266✔
246
                // dependents are traversed.
6,266✔
247
                initial_stack.push(StackItem {
6,266✔
248
                    unit_id: course_id,
6,266✔
249
                    depth: 0,
6,266✔
250
                });
6,266✔
251
            } else {
38,521✔
252
                // Insert all the starting lessons in the stack.
38,521✔
253
                initial_stack.extend(
38,521✔
254
                    lesson_ids
38,521✔
255
                        .into_iter()
38,521✔
256
                        .map(|unit_id| StackItem { unit_id, depth: 0 }),
55,238✔
257
                );
38,521✔
258
            }
38,521✔
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,121✔
263
        initial_stack
7,121✔
264
    }
7,121✔
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)> {
326,417✔
270
        // Retrieve the lesson's exercises.
326,417✔
271
        let exercises = self.data.all_valid_exercises_in_lesson(item.unit_id);
326,417✔
272
        if exercises.is_empty() {
326,417✔
273
            // Return early to avoid division by zero later on.
274
            return Ok((vec![], 0.0));
27,864✔
275
        }
298,553✔
276

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

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

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

1,657,206✔
338
        // The dependency is considered as satisfied if it's been superseded by another unit.
1,657,206✔
339
        let superseding = self.score_cache.get_superseding_recursive(dependency_id);
1,657,206✔
340
        if let Some(superseding) = superseding {
1,657,206✔
341
            if self.score_cache.is_superseded(dependency_id, &superseding) {
22,356✔
342
                return true;
20,120✔
343
            }
2,236✔
344
        }
1,634,850✔
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,637,086✔
349
        if let Ok(Some(score)) = score {
1,636,569✔
350
            score >= self.data.options.passing_score.compute_score(depth)
1,253,027✔
351
        } else {
352
            true
384,059✔
353
        }
354
    }
1,662,818✔
355

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

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

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

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

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

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

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

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

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

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

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

488
        // Perform a depth-first search of the graph.
489
        while let Some(curr_unit) = stack.pop() {
1,063,354✔
490
            // Immediately skip the item if it has been visited.
491
            if visited.contains(&curr_unit.unit_id) {
1,056,012✔
492
                continue;
455,636✔
493
            }
600,376✔
494

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

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

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

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

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

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

557
                // Retrieve the valid dependents of the lesson, and directly skip the lesson if
558
                // needed.
559
                let valid_deps =
354,828✔
560
                    self.get_valid_dependents(curr_unit.unit_id, curr_unit.depth, metadata_filter);
354,828✔
561
                if self.skip_lesson(curr_unit.unit_id, metadata_filter) {
354,828✔
562
                    Self::shuffle_to_stack(&curr_unit, valid_deps, &mut stack);
30,323✔
563
                    continue;
30,323✔
564
                }
324,505✔
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)?;
324,505✔
568
                let num_candidates = candidates.len();
324,505✔
569
                all_candidates.extend(candidates);
324,505✔
570

324,505✔
571
                // The average score is considered valid only if at least one candidate was
324,505✔
572
                // retrieved. Compare it against the passing score to decide whether the search
324,505✔
573
                // should continue past this lesson.
324,505✔
574
                if num_candidates > 0
324,505✔
575
                    && avg_score
296,723✔
576
                        < self
296,723✔
577
                            .data
296,723✔
578
                            .options
296,723✔
579
                            .passing_score
296,723✔
580
                            .compute_score(curr_unit.depth)
296,723✔
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,633✔
UNCOV
585
                        break;
×
586
                    }
1,633✔
587
                    continue;
1,633✔
588
                }
322,872✔
589

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

596
        Ok(all_candidates)
7,342✔
597
    }
7,342✔
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,296✔
755
        // Retrieve an initial list of candidates based on the type of the filter.
756
        let candidates = match filter {
8,296✔
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);
6,718✔
761
                self.get_candidates_from_graph(initial_stack, None)?
6,718✔
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,296✔
820
    }
8,296✔
821
}
822

823
impl ExerciseScheduler for DepthFirstScheduler {
824
    fn get_exercise_batch(
8,214✔
825
        &self,
8,214✔
826
        filter: Option<ExerciseFilter>,
8,214✔
827
    ) -> Result<Vec<ExerciseManifest>, ExerciseSchedulerError> {
8,214✔
828
        // Retrieve an initial batch of candidates based on the type of the filter.
829
        let initial_candidates = self
8,214✔
830
            .get_initial_candidates(filter)
8,214✔
831
            .map_err(ExerciseSchedulerError::GetExerciseBatch)?;
8,214✔
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,214✔
836
            .filter
8,214✔
837
            .filter_candidates(&initial_candidates)
8,214✔
838
            .map_err(ExerciseSchedulerError::GetExerciseBatch)?;
8,214✔
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 {
84,348✔
844
            self.data.increment_exercise_frequency(exercise_manifest.id);
76,134✔
845
        }
76,134✔
846

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

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