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

trane-project / trane / 14392837286

11 Apr 2025 12:23AM UTC coverage: 99.221% (-0.4%) from 99.655%
14392837286

Pull #340

github

web-flow
Merge 48f74d0a2 into e7a732a69
Pull Request #340: Fixes to support external applications

24 of 29 new or added lines in 3 files covered. (82.76%)

19 existing lines in 4 files now uncovered.

5478 of 5521 relevant lines covered (99.22%)

172767.94 hits per line

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

97.99
/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
pub mod data;
24
mod filter;
25
mod reward_propagator;
26
mod unit_scorer;
27

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

34
use crate::{
35
    data::{
36
        ExerciseManifest, MasteryScore, SchedulerOptions, UnitType,
37
        filter::{ExerciseFilter, KeyValueFilter, UnitFilter},
38
    },
39
    error::ExerciseSchedulerError,
40
    scheduler::{data::SchedulerData, filter::CandidateFilter, unit_scorer::UnitScorer},
41
};
42

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

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

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

70
    /// Gets the score for the given unit. The unit can be a course, lesson, or exercise.
71
    fn get_unit_score(&self, unit_id: Ustr) -> Result<Option<f32>, ExerciseSchedulerError>;
72

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

86
    /// Removes any cached scores from units with the given prefix. The same considerations as
87
    /// `invalidate_cached_score` apply.
88
    fn invalidate_cached_scores_with_prefix(&self, prefix: &str);
89

90
    /// Returns the options used to control the behavior of the scheduler.
91
    fn get_scheduler_options(&self) -> SchedulerOptions;
92

93
    /// Sets the options used to control the behavior of the scheduler.
94
    fn set_scheduler_options(&mut self, options: SchedulerOptions);
95

96
    /// Resets the options used to control the behavior of the scheduler to their default values.
97
    fn reset_scheduler_options(&mut self);
98
}
99

100
/// An item in the stack of units that are scheduled for traversal during the process of scheduling
101
/// the next batch of exercises.
102
struct StackItem {
103
    /// The ID of the unit contained in the item.
104
    unit_id: Ustr,
105

106
    /// The depth of this unit from the starting unit. That is, the number of hops the graph search
107
    /// needed to reach this exercise.
108
    depth: usize,
109
}
110

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

119
    // The ID of the exercise's lesson.
120
    lesson_id: Ustr,
121

122
    /// The depth of this unit from the starting unit. That is, the number of hops the graph search
123
    /// needed to reach this exercise.
124
    depth: f32,
125

126
    /// The score assigned to the exercise represented as a float number between 0.0 and 5.0
127
    /// inclusive. This score will be computed from the previous trials of this exercise.
128
    score: f32,
129

130
    /// The number of previous trials that have been recorded for this exercise.
131
    num_trials: usize,
132

133
    /// The number of times this exercise has been scheduled during the run of this scheduler. This
134
    /// value will be used to assign more weight to exercises that have been scheduled less often.
135
    frequency: usize,
136
}
137

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

144
    /// Contains the logic for computing the scores of exercises, lessons, and courses, as well as
145
    /// for deciding whether the dependencies of a unit are satisfied.
146
    unit_scorer: UnitScorer,
147

148
    /// Contains the logic for propagating rewards through the graph.
149
    reward_propagator: RewardPropagator,
150

151
    /// The filter used to build the final batch of exercises among the candidates found during the
152
    /// graph search.
153
    filter: CandidateFilter,
154
}
155

156
impl DepthFirstScheduler {
157
    /// Creates a new scheduler.
158
    #[must_use]
159
    pub fn new(data: SchedulerData) -> Self {
71✔
160
        Self {
71✔
161
            data: data.clone(),
71✔
162
            unit_scorer: UnitScorer::new(data.clone(), data.options.clone()),
71✔
163
            reward_propagator: RewardPropagator { data: data.clone() },
71✔
164
            filter: CandidateFilter::new(data),
71✔
165
        }
71✔
166
    }
71✔
167

168
    /// Shuffles the units and pushes them to the given stack. Used with the goal of ensuring that
169
    /// the units are traversed in a different order each time a new batch is requested.
170
    fn shuffle_to_stack(curr_unit: &StackItem, mut units: Vec<Ustr>, stack: &mut Vec<StackItem>) {
938,209✔
171
        units.shuffle(&mut thread_rng());
938,209✔
172
        stack.extend(units.iter().map(|id| StackItem {
1,006,712✔
173
            unit_id: *id,
1,006,712✔
174
            depth: curr_unit.depth + 1,
1,006,712✔
175
        }));
1,006,712✔
176
    }
938,209✔
177

178
    /// Returns all the courses and lessons without dependencies which are used to initialize a
179
    /// search of the entire graph.
180
    fn get_all_starting_units(&self) -> UstrSet {
7,699✔
181
        // Replace any missing units with their dependents and repeat this process until there are
7,699✔
182
        // no missing courses.
7,699✔
183
        let mut starting_courses = self.data.unit_graph.read().get_dependency_sinks();
7,699✔
184
        loop {
185
            let mut new_starting_courses = UstrSet::default();
7,759✔
186
            for course_id in &starting_courses {
65,503✔
187
                if self.data.unit_exists(*course_id).unwrap_or(false) {
57,744✔
188
                    new_starting_courses.insert(*course_id);
56,754✔
189
                } else {
56,754✔
190
                    new_starting_courses.extend(self.data.get_all_dependents(*course_id).iter());
990✔
191
                }
990✔
192
            }
193
            if new_starting_courses.len() == starting_courses.len() {
7,759✔
194
                break;
7,699✔
195
            }
60✔
196
            starting_courses = new_starting_courses;
60✔
197
        }
198

199
        // Some courses added to the original list in the previous steps might have other
200
        // dependencies, some of which exist in the course library. This means they cannot be
201
        // considered a starting course, so remove them from the final output.
202
        starting_courses
7,699✔
203
            .into_iter()
7,699✔
204
            .filter(|course_id| {
57,384✔
205
                self.data
57,384✔
206
                    .unit_graph
57,384✔
207
                    .read()
57,384✔
208
                    .get_dependencies(*course_id)
57,384✔
209
                    .unwrap_or_default()
57,384✔
210
                    .iter()
57,384✔
211
                    .all(|id| !self.data.unit_exists(*id).unwrap())
57,384✔
212
            })
57,384✔
213
            .collect()
7,699✔
214
    }
7,699✔
215

216
    /// Returns the lessons in the course that have no dependencies with other lessons in the course
217
    /// and whose dependencies are satisfied.
218
    pub fn get_course_valid_starting_lessons(
351,736✔
219
        &self,
351,736✔
220
        course_id: Ustr,
351,736✔
221
        depth: usize,
351,736✔
222
        metadata_filter: Option<&KeyValueFilter>,
351,736✔
223
    ) -> Result<Vec<Ustr>> {
351,736✔
224
        Ok(self
351,736✔
225
            .data
351,736✔
226
            .unit_graph
351,736✔
227
            .read()
351,736✔
228
            .get_starting_lessons(course_id)
351,736✔
229
            .unwrap_or_default()
351,736✔
230
            .into_iter()
351,736✔
231
            .filter(|id| {
412,979✔
232
                // Filter out lessons whose dependencies are not satisfied. Otherwise, those lessons
412,979✔
233
                // would be traversed prematurely.
412,979✔
234
                self.all_satisfied_dependencies(*id, depth, metadata_filter)
412,979✔
235
            })
412,979✔
236
            .collect())
351,736✔
237
    }
351,736✔
238

239
    //@<lp-example-1
240
    /// Returns an initial stack with all the starting units in the graph that are used to search
241
    /// the entire graph.
242
    fn get_initial_stack(&self, metadata_filter: Option<&KeyValueFilter>) -> Vec<StackItem> {
7,699✔
243
        // First get all the starting units and then all of their starting lessons.
7,699✔
244
        let starting_units = self.get_all_starting_units();
7,699✔
245
        let mut initial_stack: Vec<StackItem> = vec![];
7,699✔
246
        for course_id in starting_units {
64,903✔
247
            // Set the depth to zero since all the starting units are at the same depth.
248
            let lesson_ids = self
57,204✔
249
                .get_course_valid_starting_lessons(course_id, 0, metadata_filter)
57,204✔
250
                .unwrap_or_default();
57,204✔
251

57,204✔
252
            if lesson_ids.is_empty() {
57,204✔
253
                // For units with no lessons, insert the unit itself as a starting unit so that its
7,292✔
254
                // dependents are traversed.
7,292✔
255
                initial_stack.push(StackItem {
7,292✔
256
                    unit_id: course_id,
7,292✔
257
                    depth: 0,
7,292✔
258
                });
7,292✔
259
            } else {
7,292✔
260
                // Insert all the starting lessons in the stack.
261
                initial_stack.extend(
49,912✔
262
                    lesson_ids
49,912✔
263
                        .into_iter()
49,912✔
264
                        .map(|unit_id| StackItem { unit_id, depth: 0 }),
69,690✔
265
                );
266
            }
267
        }
268

269
        // Shuffle the lessons to follow a different ordering each time a new batch is requested.
270
        initial_stack.shuffle(&mut thread_rng());
7,699✔
271
        initial_stack
7,699✔
272
    }
7,699✔
273
    //>@lp-example-1
274

275
    /// Returns the list of candidates selected from the given lesson along with the average score.
276
    /// The average score is used to help decide whether to continue searching a path in the graph.
277
    fn get_candidates_from_lesson_helper(&self, item: &StackItem) -> Result<(Vec<Candidate>, f32)> {
426,672✔
278
        // Retrieve the lesson's exercises.
426,672✔
279
        let exercises = self.data.all_valid_exercises_in_lesson(item.unit_id);
426,672✔
280
        if exercises.is_empty() {
426,672✔
281
            // Return early to avoid division by zero later on.
282
            return Ok((vec![], 0.0));
45,599✔
283
        }
381,073✔
284

285
        // Generate a list of candidates from the lesson's exercises.
286
        let candidates = exercises
381,073✔
287
            .into_iter()
381,073✔
288
            .map(|exercise_id| {
2,422,627✔
289
                Ok(Candidate {
2,422,627✔
290
                    exercise_id,
2,422,627✔
291
                    lesson_id: item.unit_id, // It's assumed that the item is a lesson.
2,422,627✔
292
                    depth: (item.depth + 1) as f32,
2,422,627✔
293
                    score: self
2,422,627✔
294
                        .unit_scorer
2,422,627✔
295
                        .get_unit_score(exercise_id)?
2,422,627✔
296
                        .unwrap_or_default(),
2,422,627✔
297
                    num_trials: self
2,422,627✔
298
                        .unit_scorer
2,422,627✔
299
                        .get_num_trials(exercise_id)?
2,422,627✔
300
                        .unwrap_or_default(),
2,422,627✔
301
                    frequency: self.data.get_exercise_frequency(exercise_id),
2,422,627✔
302
                })
303
            })
2,422,627✔
304
            .collect::<Result<Vec<Candidate>>>()?;
381,073✔
305

306
        // Calculate the average score of the candidates.
307
        let avg_score = candidates.iter().map(|c| c.score).sum::<f32>() / (candidates.len() as f32);
2,422,627✔
308
        Ok((candidates, avg_score))
381,073✔
309
    }
426,672✔
310

311
    /// Returns whether the given dependency can be considered as satisfied. If all the dependencies
312
    /// of a unit are met, the search can continue with the unit.
313
    fn satisfied_dependency(
1,583,674✔
314
        &self,
1,583,674✔
315
        dependency_id: Ustr,
1,583,674✔
316
        depth: usize,
1,583,674✔
317
        metadata_filter: Option<&KeyValueFilter>,
1,583,674✔
318
    ) -> bool {
1,583,674✔
319
        // Dependencies which do not pass the metadata filter are considered as satisfied, so the
1,583,674✔
320
        // search can continue past them.
1,583,674✔
321
        let passes_filter = self
1,583,674✔
322
            .data
1,583,674✔
323
            .unit_passes_filter(dependency_id, metadata_filter)
1,583,674✔
324
            .unwrap_or(false);
1,583,674✔
325
        if !passes_filter {
1,583,674✔
326
            return true;
5,096✔
327
        }
1,578,578✔
328

1,578,578✔
329
        // Dependencies in the blacklist are considered as satisfied, so the search can continue
1,578,578✔
330
        // past them.
1,578,578✔
331
        let blacklisted = self.data.blacklisted(dependency_id);
1,578,578✔
332
        if blacklisted.unwrap_or(false) {
1,578,578✔
333
            return true;
304✔
334
        }
1,578,274✔
335

1,578,274✔
336
        // Dependencies which are a lesson belonging to a blacklisted course are considered as
1,578,274✔
337
        // satisfied, so the search can continue past them.
1,578,274✔
338
        let course_id = self
1,578,274✔
339
            .data
1,578,274✔
340
            .get_lesson_course(dependency_id)
1,578,274✔
341
            .unwrap_or_default();
1,578,274✔
342
        if self.data.blacklisted(course_id).unwrap_or(false) {
1,578,274✔
343
            return true;
241✔
344
        }
1,578,033✔
345

1,578,033✔
346
        // The dependency is considered as satisfied if it's been superseded by another unit.
1,578,033✔
347
        let superseding = self.unit_scorer.get_superseding_recursive(dependency_id);
1,578,033✔
348
        if let Some(superseding) = superseding {
1,578,033✔
349
            if self.unit_scorer.is_superseded(dependency_id, &superseding) {
23,026✔
350
                return true;
20,818✔
351
            }
2,208✔
352
        }
1,555,007✔
353

354
        // Finally, dependencies with a score equal or greater than the passing score are considered
355
        // as satisfied.
356
        let score = self.unit_scorer.get_unit_score(dependency_id);
1,557,215✔
357
        if let Ok(Some(score)) = score {
1,556,691✔
358
            score >= self.data.options.passing_score.compute_score(depth)
1,454,019✔
359
        } else {
360
            true
103,196✔
361
        }
362
    }
1,583,674✔
363

364
    /// Returns whether all the dependencies of the given unit are satisfied.
365
    fn all_satisfied_dependencies(
1,079,595✔
366
        &self,
1,079,595✔
367
        unit_id: Ustr,
1,079,595✔
368
        depth: usize,
1,079,595✔
369
        metadata_filter: Option<&KeyValueFilter>,
1,079,595✔
370
    ) -> bool {
1,079,595✔
371
        self.data
1,079,595✔
372
            .unit_graph
1,079,595✔
373
            .read()
1,079,595✔
374
            .get_dependencies(unit_id)
1,079,595✔
375
            .unwrap_or_default()
1,079,595✔
376
            .into_iter()
1,079,595✔
377
            .all(|dependency_id| self.satisfied_dependency(dependency_id, depth, metadata_filter))
1,583,674✔
378
    }
1,079,595✔
379

380
    /// Returns the valid dependents which can be visited after the given unit. A valid dependent is
381
    /// a unit whose full dependencies are met.
382
    fn get_valid_dependents(
645,418✔
383
        &self,
645,418✔
384
        unit_id: Ustr,
645,418✔
385
        depth: usize,
645,418✔
386
        metadata_filter: Option<&KeyValueFilter>,
645,418✔
387
    ) -> Vec<Ustr> {
645,418✔
388
        self.data
645,418✔
389
            .get_all_dependents(unit_id)
645,418✔
390
            .into_iter()
645,418✔
391
            .filter(|unit_id| self.all_satisfied_dependencies(*unit_id, depth, metadata_filter))
666,616✔
392
            .collect()
645,418✔
393
    }
645,418✔
394

395
    // Returns whether the given course should be skipped during the search. If so, the valid
396
    /// dependents of the course should be added to the stack.
397
    fn skip_course(
294,532✔
398
        &self,
294,532✔
399
        course_id: Ustr,
294,532✔
400
        metadata_filter: Option<&KeyValueFilter>,
294,532✔
401
        pending_course_lessons: &mut UstrMap<usize>,
294,532✔
402
    ) -> bool {
294,532✔
403
        // Check if the course is blacklisted.
294,532✔
404
        let blacklisted = self.data.blacklisted(course_id).unwrap_or(false);
294,532✔
405

294,532✔
406
        // Check if the course passes the metadata filter.
294,532✔
407
        let passes_filter = self
294,532✔
408
            .data
294,532✔
409
            .unit_passes_filter(course_id, metadata_filter)
294,532✔
410
            .unwrap_or(true);
294,532✔
411

294,532✔
412
        // Check the number of pending lessons in the course.
294,532✔
413
        let pending_lessons = pending_course_lessons
294,532✔
414
            .entry(course_id)
294,532✔
415
            .or_insert_with(|| self.data.get_num_lessons_in_course(course_id));
294,532✔
416

417
        // Check if the course has been superseded by another unit.
418
        let superseding_units = self
294,532✔
419
            .unit_scorer
294,532✔
420
            .get_superseding_recursive(course_id)
294,532✔
421
            .unwrap_or_default();
294,532✔
422
        let is_superseded = self
294,532✔
423
            .unit_scorer
294,532✔
424
            .is_superseded(course_id, &superseding_units);
294,532✔
425

294,532✔
426
        // The course should be skipped if the course is blacklisted, does not pass the filter, has
294,532✔
427
        // no pending lessons, or if it' been superseded.
294,532✔
428
        blacklisted || !passes_filter || *pending_lessons == 0 || is_superseded
294,532✔
429
    }
294,532✔
430

431
    /// Returns whether the given lesson should be skipped during the search. If so, the valid
432
    /// dependents of the lesson should be added to the stack.
433
    fn skip_lesson(&self, lesson_id: Ustr, metadata_filter: Option<&KeyValueFilter>) -> bool {
457,191✔
434
        // Check if the lesson is blacklisted.
457,191✔
435
        let blacklisted = self.data.blacklisted(lesson_id).unwrap_or(false);
457,191✔
436

457,191✔
437
        // Check if the lesson passes the metadata filter.
457,191✔
438
        let passes_filter = self
457,191✔
439
            .data
457,191✔
440
            .unit_passes_filter(lesson_id, metadata_filter)
457,191✔
441
            .unwrap_or(true);
457,191✔
442

457,191✔
443
        // Check if the lesson has been superseded by another unit.
457,191✔
444
        let superseding_units = self
457,191✔
445
            .unit_scorer
457,191✔
446
            .get_superseding_recursive(lesson_id)
457,191✔
447
            .unwrap_or_default();
457,191✔
448
        let is_lesson_superseded = self
457,191✔
449
            .unit_scorer
457,191✔
450
            .is_superseded(lesson_id, &superseding_units);
457,191✔
451

457,191✔
452
        // Check if the lesson's course has been superseded by another unit.
457,191✔
453
        let course_id = self.data.get_lesson_course(lesson_id).unwrap_or_default();
457,191✔
454
        let superseding_units = self
457,191✔
455
            .unit_scorer
457,191✔
456
            .get_superseding_recursive(course_id)
457,191✔
457
            .unwrap_or_default();
457,191✔
458
        let is_course_superseded = self
457,191✔
459
            .unit_scorer
457,191✔
460
            .is_superseded(course_id, &superseding_units);
457,191✔
461

457,191✔
462
        // The lesson should be skipped if it is blacklisted, does not pass the filter or if it or
457,191✔
463
        // its course have been superseded.
457,191✔
464
        blacklisted || !passes_filter || is_lesson_superseded || is_course_superseded
457,191✔
465
    }
457,191✔
466

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

7,921✔
480
        // Initialize the list of candidates and the set of visited units.
7,921✔
481
        let max_candidates = self.data.options.batch_size * MAX_CANDIDATE_FACTOR;
7,921✔
482
        let mut all_candidates: Vec<Candidate> = Vec::new();
7,921✔
483
        let mut visited = UstrSet::default();
7,921✔
484

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

496
        // Perform a depth-first search of the graph.
497
        while let Some(curr_unit) = stack.pop() {
1,250,658✔
498
            // Immediately skip the item if it has been visited.
499
            if visited.contains(&curr_unit.unit_id) {
1,242,737✔
500
                continue;
491,405✔
501
            }
751,332✔
502

751,332✔
503
            // The logic past this point depends on the type of the unit.
751,332✔
504
            let unit_type = self.data.get_unit_type(curr_unit.unit_id);
751,332✔
505
            if unit_type.is_none() {
751,332✔
506
                // The type of the unit is unknown. This can happen when a unit depends on some
507
                // unknown unit.
508
                continue;
810✔
509
            }
750,522✔
510
            let unit_type = unit_type.unwrap();
750,522✔
511

750,522✔
512
            // Handle courses and lessons. Exercises are skipped by the search.
750,522✔
513
            if unit_type == UnitType::Course {
750,522✔
514
                // Retrieve the starting lessons in the course and add them to the stack.
515
                let starting_lessons: Vec<Ustr> = self
294,532✔
516
                    .get_course_valid_starting_lessons(
294,532✔
517
                        curr_unit.unit_id,
294,532✔
518
                        curr_unit.depth,
294,532✔
519
                        metadata_filter,
294,532✔
520
                    )
294,532✔
521
                    .unwrap_or_default();
294,532✔
522
                Self::shuffle_to_stack(&curr_unit, starting_lessons, &mut stack);
294,532✔
523

294,532✔
524
                // The course can be skipped. Add it to the visited set, push its valid dependents
294,532✔
525
                // onto the stack, and continue.
294,532✔
526
                if self.skip_course(
294,532✔
527
                    curr_unit.unit_id,
294,532✔
528
                    metadata_filter,
294,532✔
529
                    &mut pending_course_lessons,
294,532✔
530
                ) {
188,227✔
531
                    visited.insert(curr_unit.unit_id);
188,227✔
532
                    let valid_deps = self.get_valid_dependents(
188,227✔
533
                        curr_unit.unit_id,
188,227✔
534
                        curr_unit.depth,
188,227✔
535
                        metadata_filter,
188,227✔
536
                    );
188,227✔
537
                    Self::shuffle_to_stack(&curr_unit, valid_deps, &mut stack);
188,227✔
538
                }
188,227✔
539
            } else if unit_type == UnitType::Lesson {
455,990✔
540
                // If the searched reached this point, the unit must be a lesson.
541
                visited.insert(curr_unit.unit_id);
455,990✔
542

543
                // Update the number of lessons pending to be processed.
544
                let course_id = self.data.get_course_id(curr_unit.unit_id)?;
455,990✔
545
                let pending_lessons = pending_course_lessons
455,990✔
546
                    .entry(course_id)
455,990✔
547
                    .or_insert_with(|| self.data.get_num_lessons_in_course(course_id));
455,990✔
548
                *pending_lessons -= 1;
455,990✔
549

455,990✔
550
                // Check whether there are pending lessons.
455,990✔
551
                if *pending_lessons == 0 {
455,990✔
552
                    // Once all the lessons in the course have been visited, re-add the course to
159,455✔
553
                    // the stack, so the search can continue exploring its dependents.
159,455✔
554
                    stack.push(StackItem {
159,455✔
555
                        unit_id: course_id,
159,455✔
556
                        depth: curr_unit.depth + 1,
159,455✔
557
                    });
159,455✔
558
                }
296,535✔
559

560
                // Retrieve the valid dependents of the lesson, and directly skip the lesson if
561
                // needed.
562
                let valid_deps =
455,990✔
563
                    self.get_valid_dependents(curr_unit.unit_id, curr_unit.depth, metadata_filter);
455,990✔
564
                if self.skip_lesson(curr_unit.unit_id, metadata_filter) {
455,990✔
565
                    Self::shuffle_to_stack(&curr_unit, valid_deps, &mut stack);
31,368✔
566
                    continue;
31,368✔
567
                }
424,622✔
568

569
                // Retrieve the candidates from the lesson and add them to the list of candidates.
570
                let (candidates, avg_score) = self.get_candidates_from_lesson_helper(&curr_unit)?;
424,622✔
571
                let num_candidates = candidates.len();
424,622✔
572
                all_candidates.extend(candidates);
424,622✔
573

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

422,903✔
593
                // The search should continue past this lesson. Add its valid dependents to the
422,903✔
594
                // stack.
422,903✔
595
                Self::shuffle_to_stack(&curr_unit, valid_deps, &mut stack);
422,903✔
596
            }
×
597
        }
598

599
        Ok(all_candidates)
7,921✔
600
    }
7,921✔
601

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

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

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

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

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

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

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

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

1,017✔
691
                // Add the lesson's valid dependents to the stack.
1,017✔
692
                Self::shuffle_to_stack(&curr_unit, valid_deps, &mut stack);
1,017✔
693
            }
×
694
        }
695

696
        Ok(all_candidates)
405✔
697
    }
405✔
698

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

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

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

753
        Ok(candidates)
146✔
754
    }
146✔
755

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

822
        Ok(candidates)
9,009✔
823
    }
9,009✔
824
}
825

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

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

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

850
        Ok(final_candidates)
8,927✔
851
    }
8,927✔
852

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

81,174✔
867
        // Propagate the rewards along the unit graph and store them.
81,174✔
868
        let rewards = self
81,174✔
869
            .reward_propagator
81,174✔
870
            .propagate_rewards(exercise_id, &score, timestamp);
81,174✔
871
        for (unit_id, reward) in &rewards {
468,972✔
872
            // Ignore rewards for units with no previous scores.
873
            let unit_score = self
387,798✔
874
                .unit_scorer
387,798✔
875
                .get_unit_score(*unit_id)
387,798✔
876
                .unwrap_or_default()
387,798✔
877
                .unwrap_or_default();
387,798✔
878
            if unit_score == 0.0 {
387,798✔
879
                continue;
77,360✔
880
            }
310,438✔
881

882
            let updated = self
310,438✔
883
                .data
310,438✔
884
                .practice_rewards
310,438✔
885
                .write()
310,438✔
886
                .record_unit_reward(*unit_id, reward)
310,438✔
887
                .map_err(|e| ExerciseSchedulerError::ScoreExercise(e.into()))?;
310,438✔
888
            if updated {
310,438✔
889
                self.unit_scorer.invalidate_cached_score(*unit_id);
404✔
890
            }
310,034✔
891
        }
892

893
        Ok(())
81,174✔
894
    }
81,174✔
895

NEW
896
    fn get_unit_score(&self, unit_id: Ustr) -> Result<Option<f32>, ExerciseSchedulerError> {
×
NEW
897
        self.unit_scorer
×
NEW
898
            .get_unit_score(unit_id)
×
NEW
899
            .map_err(|e| ExerciseSchedulerError::GetUnitScore(unit_id, e))
×
NEW
900
    }
×
901

902
    #[cfg_attr(coverage, coverage(off))]
903
    fn invalidate_cached_score(&self, unit_id: Ustr) {
904
        self.unit_scorer.invalidate_cached_score(unit_id);
905
    }
906

907
    #[cfg_attr(coverage, coverage(off))]
908
    fn invalidate_cached_scores_with_prefix(&self, prefix: &str) {
909
        self.unit_scorer
910
            .invalidate_cached_scores_with_prefix(prefix);
911
    }
912

913
    fn get_scheduler_options(&self) -> SchedulerOptions {
2✔
914
        self.data.options.clone()
2✔
915
    }
2✔
916

917
    fn set_scheduler_options(&mut self, options: SchedulerOptions) {
2✔
918
        self.data.options = options;
2✔
919
    }
2✔
920

921
    fn reset_scheduler_options(&mut self) {
1✔
922
        self.data.options = SchedulerOptions::default();
1✔
923
    }
1✔
924
}
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