├── testdata
└── test_audio.m4a
├── src
├── data
│ ├── music.rs
│ ├── music
│ │ ├── intervals.rs
│ │ ├── circle_fifths.rs
│ │ ├── notes.rs
│ │ └── scales.rs
│ ├── course_generator.rs
│ └── course_generator
│ │ ├── transcription
│ │ ├── course_instructions.md
│ │ └── constants.rs
│ │ └── music_piece.rs
├── course_builder
│ ├── music.rs
│ └── music
│ │ └── circle_fifths.rs
├── utils.rs
├── preferences_manager.rs
├── mantra_miner.rs
├── error.rs
├── review_list.rs
├── scheduler
│ └── reward_propagator.rs
├── study_session_manager.rs
├── filter_manager.rs
├── reward_scorer.rs
├── blacklist.rs
├── course_builder.rs
└── exercise_scorer.rs
├── Makefile
├── .gitignore
├── CONTRIBUTING.md
├── .github
└── workflows
│ ├── coverage.yaml
│ └── build.yml
├── Cargo.toml
├── tests
├── large_tests.rs
├── knowledge_base_tests.rs
├── music_piece_tests.rs
└── transcription_tests.rs
└── README.md
/testdata/test_audio.m4a:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/trane-project/trane/HEAD/testdata/test_audio.m4a
--------------------------------------------------------------------------------
/src/data/music.rs:
--------------------------------------------------------------------------------
1 | //! Contains types and functions for working with music-related courses.
2 |
3 | pub mod circle_fifths;
4 | pub mod intervals;
5 | pub mod notes;
6 | pub mod scales;
7 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | # Run all cargo checks and tests.
2 | build:
3 | cargo fmt
4 | cargo clippy
5 | RUSTDOCFLAGS="-D missing_docs" cargo doc --document-private-items --no-deps
6 | cargo test --release
7 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Generated by Cargo
2 | # will have compiled files and executables
3 | /target/
4 |
5 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
6 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
7 | Cargo.lock
8 |
9 | # These are backup files generated by rustfmt
10 | **/*.rs.bk
11 |
12 | # Added by cargo
13 |
14 | /target
15 |
16 | /*.svg
17 | /perf.data*
18 |
--------------------------------------------------------------------------------
/src/course_builder/music.rs:
--------------------------------------------------------------------------------
1 | //! Contains utilities to generate music related courses.
2 |
3 | use strum::Display;
4 |
5 | pub mod circle_fifths;
6 |
7 | /// Common metadata keys for all music courses and lessons.
8 | #[derive(Display)]
9 | #[strum(serialize_all = "snake_case")]
10 | #[allow(missing_docs)]
11 | pub enum MusicMetadata {
12 | Instrument,
13 | Key,
14 | MusicalConcept,
15 | MusicalSkill,
16 | ScaleType,
17 | }
18 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | At the moment, I am not accepting code contributions for this repository, as I would like to keep
4 | full control of the project for the time being. However, feel free to contribute to any of the other
5 | repositories listed in the Trane Project's [organization page](https://github.com/trane-project).
6 | These include at the moment the command line interface and the official courses. Look for the
7 | CONTRIBUTING.md file in those repositories to find out more specific ways to help.
8 |
9 | Feel free to open an issue if you have a feature request, find a bug, or notice an issue with
10 | usage of Rust (this is my first real project using the language).
11 |
--------------------------------------------------------------------------------
/.github/workflows/coverage.yaml:
--------------------------------------------------------------------------------
1 | name: Code Coverage
2 | on:
3 | pull_request:
4 | push:
5 | branches:
6 | - master
7 |
8 | jobs:
9 | code-coverage:
10 | name: Code Coverage
11 | runs-on: ubuntu-latest
12 | steps:
13 | - name: Checkout sources
14 | uses: actions/checkout@v1
15 |
16 | - name: Setup yt-dlp PPA
17 | run: sudo add-apt-repository ppa:tomtomtom/yt-dlp
18 |
19 | - name: Install yt-dlp
20 | run: sudo apt update && sudo apt install -y yt-dlp
21 |
22 | - name: Install Rust toolchain
23 | uses: actions-rs/toolchain@v1
24 | with:
25 | toolchain: nightly
26 | components: llvm-tools-preview
27 | override: true
28 |
29 | - name: Install cargo-llvm-cov
30 | uses: taiki-e/install-action@cargo-llvm-cov
31 |
32 | - name: Generate code coverage
33 | id: coverage
34 | run: cargo llvm-cov --all-features --exclude register_derive_impl --workspace --no-fail-fast --lcov --output-path lcov.info
35 | env:
36 | NODE_COVERALLS_DEBUG: true
37 |
38 | - name: Coveralls upload
39 | uses: coverallsapp/github-action@master
40 | with:
41 | github-token: ${{ secrets.GITHUB_TOKEN }}
42 | path-to-lcov: lcov.info
43 | debug: true
44 |
--------------------------------------------------------------------------------
/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "trane"
3 | version = "0.24.1"
4 | edition = "2024"
5 | description = "An automated system for learning complex skills"
6 | license = "AGPL-3.0-or-later"
7 | readme = "README.md"
8 | repository = "https://github.com/trane-project/trane"
9 |
10 | [lib]
11 | name = "trane"
12 |
13 | [dependencies]
14 | anyhow = "1.0.99"
15 | bincode = { version = "2.0.1", features = ["serde"] }
16 | chrono = { version = "0.4.41", features = ["serde"] }
17 | derive_builder = "0.20.2"
18 | fs_extra = "1.3.0"
19 | git2 = "0.20.2"
20 | hex = "0.4.3"
21 | indoc = "2.0.6"
22 | mantra-miner = "0.1.2"
23 | parking_lot = { version = "0.12.4", features = ["hardware-lock-elision"] }
24 | r2d2 = "0.8.10"
25 | r2d2_sqlite = "0.31.0"
26 | rand = "0.9.2"
27 | rayon = "1.11.0"
28 | rusqlite = { version = "0.37.0", features = ["bundled"] }
29 | rusqlite_migration = "2.3.0"
30 | serde = { version = "1.0.219", features = ["derive"] }
31 | serde_json = "1.0.143"
32 | sha1 = "0.10.6"
33 | strum = { version = "0.27.2", features = ["derive"] }
34 | tantivy = "0.25.0"
35 | tempfile = "3.21.0"
36 | thiserror = "2.0.16"
37 | url = "2.5.7"
38 | ustr = { version = "1.1.0", features = ["serde"] }
39 | walkdir = "2.5.0"
40 |
41 | [dev-dependencies]
42 | pretty_assertions = "1.4"
43 |
44 | [profile.bench]
45 | debug = true
46 |
47 | [lints.rust]
48 | unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage,coverage_nightly)'] }
49 |
--------------------------------------------------------------------------------
/src/data/music/intervals.rs:
--------------------------------------------------------------------------------
1 | //! Defines the musical intervals.
2 |
3 | use std::fmt::{Display, Formatter, Result};
4 |
5 | /// Defines the different musical intervals.
6 | #[derive(Clone, Copy, Debug, PartialEq, Eq)]
7 | #[allow(missing_docs)]
8 | pub enum Interval {
9 | Unison,
10 | MinorSecond,
11 | MajorSecond,
12 | MinorThird,
13 | MajorThird,
14 | PerfectFourth,
15 | Tritone,
16 | PerfectFifth,
17 | MinorSixth,
18 | MajorSixth,
19 | MinorSeventh,
20 | MajorSeventh,
21 | Octave,
22 | }
23 |
24 | impl Display for Interval {
25 | fn fmt(&self, f: &mut Formatter<'_>) -> Result {
26 | match self {
27 | Interval::Unison => write!(f, "Unison"),
28 | Interval::MinorSecond => write!(f, "Minor Second"),
29 | Interval::MajorSecond => write!(f, "Major Second"),
30 | Interval::MinorThird => write!(f, "Minor Third"),
31 | Interval::MajorThird => write!(f, "Major Third"),
32 | Interval::PerfectFourth => write!(f, "Perfect Fourth"),
33 | Interval::Tritone => write!(f, "Tritone"),
34 | Interval::PerfectFifth => write!(f, "Perfect Fifth"),
35 | Interval::MinorSixth => write!(f, "Minor Sixth"),
36 | Interval::MajorSixth => write!(f, "Major Sixth"),
37 | Interval::MinorSeventh => write!(f, "Minor Seventh"),
38 | Interval::MajorSeventh => write!(f, "Major Seventh"),
39 | Interval::Octave => write!(f, "Octave"),
40 | }
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/data/course_generator.rs:
--------------------------------------------------------------------------------
1 | //! Contains the logic to generate special types of courses on the fly.
2 | //!
3 | //! This module adds support for declaring special types of courses whose manifests are
4 | //! auto-generated on the fly when Trane first opens the library in which they belong. Doing so
5 | //! allows users to declare complex courses with minimal configuration and ensures the generated
6 | //! manifests always match the current version of Trane.
7 |
8 | use serde::{Deserialize, Serialize};
9 |
10 | pub mod knowledge_base;
11 | pub mod literacy;
12 | pub mod music_piece;
13 | pub mod transcription;
14 |
15 | //@@instrument
26 |
27 | #[cfg(test)]
28 | mod tests {
29 | use super::*;
30 |
31 | /// Verifies cloning an instrument. Done so that the auto-generated trait implementation is
32 | /// included in the code coverage reports.
33 | #[test]
34 | fn instrument_clone() {
35 | let instrument = Instrument {
36 | name: "Piano".to_string(),
37 | id: "piano".to_string(),
38 | };
39 | let instrument_clone = instrument.clone();
40 | assert_eq!(instrument.name, instrument_clone.name);
41 | assert_eq!(instrument.id, instrument_clone.id);
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: Build
2 | on:
3 | pull_request:
4 | push:
5 | branches:
6 | - master
7 |
8 | jobs:
9 | test:
10 | name: Test Suite
11 | runs-on: ubuntu-latest
12 | steps:
13 | - name: Checkout sources
14 | uses: actions/checkout@v2
15 |
16 | - name: Install stable toolchain
17 | uses: dtolnay/rust-toolchain@master
18 | with:
19 | toolchain: stable
20 |
21 | - name: Setup Rust cache
22 | uses: Swatinem/rust-cache@v2
23 |
24 | - name: Setup yt-dlp PPA
25 | run: sudo add-apt-repository ppa:tomtomtom/yt-dlp
26 |
27 | - name: Install yt-dlp
28 | run: sudo apt update && sudo apt install -y yt-dlp
29 |
30 | - name: Run cargo test
31 | run: cargo test --release
32 |
33 | lints:
34 | name: Lints
35 | runs-on: ubuntu-latest
36 | steps:
37 | - name: Checkout sources
38 | uses: actions/checkout@v2
39 | with:
40 | submodules: true
41 |
42 | - name: Install stable toolchain
43 | uses: dtolnay/rust-toolchain@master
44 | with:
45 | toolchain: stable
46 |
47 | - name: Install extra components
48 | run: rustup component add clippy rust-docs rustfmt
49 |
50 | - name: Setup Rust cache
51 | uses: Swatinem/rust-cache@v2
52 |
53 | - name: Run cargo fmt
54 | run: cargo fmt --all -- --check
55 |
56 | - name: Run cargo clippy
57 | run: cargo clippy -- -D warnings
58 |
59 | - name: Run rustdoc lints
60 | env:
61 | RUSTDOCFLAGS: "-D missing_docs -D rustdoc::missing_doc_code_examples"
62 | run: cargo doc --workspace --all-features --no-deps --document-private-items
63 |
--------------------------------------------------------------------------------
/tests/large_tests.rs:
--------------------------------------------------------------------------------
1 | //! End-to-end tests for verifying the correctness of Trane with large course libraries.
2 | //!
3 | //! These tests verify that Trane works correctly with large course libraries and can be used to
4 | //! measure its performance with the use of the `cargo flamegraph` command. These tests are slower
5 | //! than the unit tests and the basic end-to-end tests, but they still run under 10 seconds when
6 | //! compiled in release mode.
7 |
8 | use anyhow::{Ok, Result};
9 | use tempfile::TempDir;
10 | use trane::{data::MasteryScore, test_utils::*};
11 |
12 | /// Verifies that all the exercises are scheduled with no blacklist or filter when the user gives a
13 | /// score of five to every exercise, even in a course library with a lot of exercises.
14 | #[test]
15 | fn all_exercises_scheduled_random() -> Result<()> {
16 | // Initialize test course library.
17 | let temp_dir = TempDir::new()?;
18 | let random_library = RandomCourseLibrary {
19 | num_courses: 50,
20 | course_dependencies_range: (0, 5),
21 | lessons_per_course_range: (0, 5),
22 | lesson_dependencies_range: (0, 5),
23 | exercises_per_lesson_range: (0, 10),
24 | }
25 | .generate_library();
26 | let mut trane = init_test_simulation(temp_dir.path(), &random_library)?;
27 |
28 | // Run the simulation.
29 | let exercise_ids = all_test_exercises(&random_library);
30 | let mut simulation = TraneSimulation::new(
31 | exercise_ids.len() * 50,
32 | Box::new(|_| Some(MasteryScore::Five)),
33 | );
34 | simulation.run_simulation(&mut trane, &vec![], &None)?;
35 |
36 | // Every exercise ID should be in `simulation.answer_history`.
37 | for exercise_id in exercise_ids {
38 | assert!(
39 | simulation
40 | .answer_history
41 | .contains_key(&exercise_id.to_ustr()),
42 | "exercise {:?} should have been scheduled",
43 | exercise_id
44 | );
45 | assert_simulation_scores(exercise_id.to_ustr(), &trane, &simulation.answer_history)?;
46 | }
47 | Ok(())
48 | }
49 |
50 | /// Generates and reads a very large course library. Used mostly to keep track of how long this
51 | /// operation takes.
52 | #[test]
53 | fn generate_and_read_large_library() -> Result<()> {
54 | let temp_dir = TempDir::new()?;
55 | let random_library = RandomCourseLibrary {
56 | num_courses: 100,
57 | course_dependencies_range: (5, 5),
58 | lessons_per_course_range: (5, 5),
59 | lesson_dependencies_range: (5, 5),
60 | exercises_per_lesson_range: (10, 10),
61 | }
62 | .generate_library();
63 | init_test_simulation(temp_dir.path(), &random_library)?;
64 | Ok(())
65 | }
66 |
--------------------------------------------------------------------------------
/src/utils.rs:
--------------------------------------------------------------------------------
1 | //! Contains common utilities used in multiple modules.
2 |
3 | use r2d2::Pool;
4 | use r2d2_sqlite::SqliteConnectionManager;
5 | use rusqlite::Connection;
6 | use std::time::Duration;
7 |
8 | /// Returns the weighted average of the scores.
9 | #[must_use]
10 | pub fn weighted_average(values: &[f32], weights: &[f32]) -> f32 {
11 | // weighted average = (cross product of values and their weights) / (sum of weights)
12 | let cross_product: f32 = values.iter().zip(weights.iter()).map(|(s, w)| s * *w).sum();
13 | let weight_sum = weights.iter().sum::();
14 | if weight_sum == 0.0 {
15 | 0.0
16 | } else {
17 | cross_product / weight_sum
18 | }
19 | }
20 |
21 | /// Returns a new connection manager with the appropriate `SQLite` pragmas.
22 | #[must_use]
23 | pub fn new_connection_manager(db_path: &str) -> SqliteConnectionManager {
24 | SqliteConnectionManager::file(db_path).with_init(
25 | |connection: &mut Connection| -> Result<(), rusqlite::Error> {
26 | // The following pragma statements are set to improve the read and write performance
27 | // of SQLite. See the SQLite [docs](https://www.sqlite.org/pragma.html) for more
28 | // information.
29 | connection.pragma_update(None, "journal_mode", "WAL")?;
30 | connection.pragma_update(None, "synchronous", "NORMAL")
31 | },
32 | )
33 | }
34 |
35 | /// Returns a new connection pool with appropriate setting.
36 | pub fn new_connection_pool(
37 | connection_manager: SqliteConnectionManager,
38 | ) -> Result, r2d2::Error> {
39 | let builder = Pool::builder()
40 | .max_size(5)
41 | .min_idle(Some(1))
42 | .connection_timeout(Duration::from_secs(5));
43 | builder.build(connection_manager)
44 | }
45 |
46 | #[cfg(test)]
47 | mod test {
48 | use super::*;
49 |
50 | /// Veriifies the weighted average calculation.
51 | #[test]
52 | fn test_weighted_average() {
53 | // Valid rewards and weights.
54 | let rewards = vec![1.0, 2.0, 3.0];
55 | let weights = vec![0.2, 0.3, 0.5];
56 | let average = weighted_average(&rewards, &weights);
57 | assert_eq!(average, 2.3);
58 |
59 | // Empty weights result in a zero average.
60 | let rewards: Vec = vec![];
61 | let weights: Vec = vec![];
62 | let average = weighted_average(&rewards, &weights);
63 | assert_eq!(average, 0.0);
64 |
65 | // All zero weights result in a zero average.
66 | let rewards = vec![1.0, 2.0, 3.0];
67 | let weights = vec![0.0, 0.0, 0.0];
68 | let average = weighted_average(&rewards, &weights);
69 | assert_eq!(average, 0.0);
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/src/data/course_generator/transcription/course_instructions.md:
--------------------------------------------------------------------------------
1 | # Transcription Course Instructions
2 |
3 | This course is designed to help you learn how to transcribe passages of music onto your ear and your
4 | instrument(s). The aim of the exercises is not to notate the music, but to learn how to extract the
5 | musical elements of the passage into your preferred instruments, and ingrain them in your playing.
6 | Students that wish to notate the music are free to do so, but it is not required to make progress in
7 | the course.
8 |
9 | The goal of these courses is to present a similar experience to the apprenticeship model on which
10 | traditional African music and jazz was taught. In this model, the master shows the apprentice a
11 | musical passage, and the apprentice must learn how to reproduce it. Unlike a school or academy,
12 | graduation from the apprenticeship model is not based on completing a fixed curriculum, but on
13 | actual mastery of the material.
14 |
15 | ## How to use the course
16 |
17 | The questions in the course refer you to the passage that you must transcribe. Due to copyright
18 | laws, an audio file of the passage is not provided. Instead, you are given the passage as the
19 | information about the track (artist, album, and track number) and the start and end timestamps of
20 | the passage (which could just say to play over the entire song). There is the option to give an
21 | optional external link, containing a link to the YouTube video of the track, for example.
22 |
23 | You then get a copy of the track through your own means, load the track into your preferred music
24 | player (see the section on tools for the recommended player), and find the passage in the track. You
25 | then proceed according to the stage indicated by the exercises.
26 |
27 | ## Lessons
28 |
29 | The course is divided in several lessons, each of which will help you become familiar with a
30 | different aspect of the material.
31 |
32 | ### Singing
33 |
34 | First listen to the musical passage until you can audiate it clearly in your head. Then sing over
35 | the passage. At this stage it's not required to be accurate as possible. Rather, learn to sing the
36 | main elements of the passage and experiment with different melodies over it. The goal is to learn to
37 | navigate the context implied by the passage.
38 |
39 | ### Transcription
40 |
41 | With the basic context implied by the passage now internalized in your ear, try to play over
42 | it using your instrument. The goal at this point is not to accurately reproduce the passage,
43 | but rather about learning to navigate that context and use it as a basis for improvisation.
44 | You can focus on different elements or sections each time you practice.
45 |
46 | The lesson depends on sufficiently mastering the singing lesson.
47 |
48 | ### Advanced Singing
49 |
50 | At this stage, you should sing the passage with more detail and precision using solfège. The
51 | recommended solfège system is movable do, with a la-based minor. In this system, the note for the
52 | key is always represented by do, and the modes are represented by the different syllables. The minor
53 | mode, for example, starts with the syllable la. This system has the advantage that the same
54 | syllables can be used for all keys. Using the numbers one to seven is also equivalent. You should
55 | also transpose the passage up or down a random number of semitones.
56 |
57 | The lesson depends on sufficiently mastering the singing lesson.
58 |
59 | ### Advanced Transcription
60 |
61 | At this stage, you play over the passage, and sing it with more detail and precision. It's at this
62 | point that you can engage in what is traditionally called transcription. You should also transpose
63 | the passage up or down a random number of semitones.
64 |
65 | The passage is still used as a basis for improvisation, but the focus is much narrower than in the
66 | basic transcription lesson, and the actual music played in the passage take precedence over the
67 | context implied by it.
68 |
69 | The lesson depends on sufficiently mastering the advanced singing lesson and the transcription
70 | lesson.
71 |
72 | ## Tools
73 |
74 | ### Transcribe!
75 |
76 | The recommended tool for these exercises is
77 | [Transcribe!](https://www.seventhstring.com/xscribe/overview.html). This is proprietary and paid
78 | software, but is the best option for helping you transcribe the passages in these courses. It can
79 | slow down, loop, and transpose the audio. It also has built-in tools to help you find which notes
80 | and chords are being played.
81 |
82 | There exists similar software, although not as feature-rich as Transcribe. At the very least, you
83 | should be able to easily loop, slow down, and transpose the audio.
84 |
--------------------------------------------------------------------------------
/src/preferences_manager.rs:
--------------------------------------------------------------------------------
1 | //! A module containing methods to read and write user preferences.
2 |
3 | use anyhow::{Context, Result};
4 | use std::{fs, path::PathBuf};
5 |
6 | use crate::{PreferencesManagerError, data::UserPreferences};
7 |
8 | /// A trait for managing user preferences.
9 | pub trait PreferencesManager {
10 | /// Gets the current user preferences.
11 | fn get_user_preferences(&self) -> Result;
12 |
13 | /// Sets the user preferences to the given value.
14 | fn set_user_preferences(
15 | &mut self,
16 | preferences: UserPreferences,
17 | ) -> Result<(), PreferencesManagerError>;
18 | }
19 |
20 | /// A preferences manager backed by a local file containing a serialized `UserPreferences` object.
21 | pub struct LocalPreferencesManager {
22 | /// The path to the user preferences file.
23 | pub path: PathBuf,
24 | }
25 |
26 | impl LocalPreferencesManager {
27 | /// Helper function to get the current user preferences.
28 | fn get_user_preferences_helper(&self) -> Result {
29 | let raw_preferences =
30 | &fs::read_to_string(&self.path).context("failed to read user preferences")?;
31 | let preferences = serde_json::from_str::(raw_preferences)
32 | .context("invalid user preferences")?;
33 | Ok(preferences)
34 | }
35 |
36 | /// Helper function to set the user preferences to the given value.
37 | fn set_user_preferences_helper(&self, preferences: &UserPreferences) -> Result<()> {
38 | let pretty_json =
39 | serde_json::to_string_pretty(preferences).context("invalid user preferences")?;
40 | fs::write(&self.path, pretty_json).context("failed to write user preferences")
41 | }
42 | }
43 |
44 | impl PreferencesManager for LocalPreferencesManager {
45 | fn get_user_preferences(&self) -> Result {
46 | self.get_user_preferences_helper()
47 | .map_err(PreferencesManagerError::GetUserPreferences)
48 | }
49 |
50 | fn set_user_preferences(
51 | &mut self,
52 | preferences: UserPreferences,
53 | ) -> Result<(), PreferencesManagerError> {
54 | self.set_user_preferences_helper(&preferences)
55 | .map_err(PreferencesManagerError::SetUserPreferences)
56 | }
57 | }
58 |
59 | #[cfg(test)]
60 | mod tests {
61 | use std::{fs, os::unix::fs::PermissionsExt};
62 |
63 | use anyhow::Result;
64 | use tempfile::tempdir;
65 |
66 | use crate::{
67 | USER_PREFERENCES_PATH,
68 | data::UserPreferences,
69 | preferences_manager::{LocalPreferencesManager, PreferencesManager},
70 | };
71 |
72 | /// Verifies setting and getting user preferences using the local filesystem.
73 | #[test]
74 | fn local_preferences_manager() -> Result<()> {
75 | let temp_dir = tempdir().unwrap();
76 | let path = temp_dir.path().join(USER_PREFERENCES_PATH);
77 |
78 | let mut manager = LocalPreferencesManager { path };
79 |
80 | // Set and get the default user preferences.
81 | let preferences = UserPreferences::default();
82 | assert!(manager.get_user_preferences().is_err());
83 | manager.set_user_preferences(preferences.clone())?;
84 | assert_eq!(manager.get_user_preferences()?, preferences);
85 |
86 | // Set and get modified user preferences.
87 | let new_preferences = UserPreferences {
88 | ignored_paths: vec!["foo".to_string(), "bar".to_string()],
89 | ..Default::default()
90 | };
91 | manager.set_user_preferences(new_preferences.clone())?;
92 | assert_eq!(manager.get_user_preferences()?, new_preferences);
93 | Ok(())
94 | }
95 |
96 | /// Verifies that an error is returned when the preferences file is missing.
97 | #[test]
98 | fn missing_preferences_file() {
99 | let temp_dir = tempdir().unwrap();
100 | let path = temp_dir.path().join(USER_PREFERENCES_PATH);
101 | let manager = LocalPreferencesManager { path };
102 | assert!(manager.get_user_preferences().is_err());
103 | }
104 |
105 | /// Verifies that an error is returned when the preferences file cannot be written.
106 | #[test]
107 | fn unwritable_preferences_file() -> Result<()> {
108 | let temp_dir = tempdir().unwrap();
109 | fs::set_permissions(temp_dir.path(), fs::Permissions::from_mode(0o0))?;
110 | let path = temp_dir.path().join(USER_PREFERENCES_PATH);
111 | let mut manager = LocalPreferencesManager { path };
112 | let preferences = UserPreferences::default();
113 | assert!(manager.set_user_preferences(preferences).is_err());
114 | Ok(())
115 | }
116 | }
117 |
--------------------------------------------------------------------------------
/src/mantra_miner.rs:
--------------------------------------------------------------------------------
1 | //! Runs the mantra miner that recites mantras while Trane is running.
2 | //!
3 | //! As a symbolic way to let users of Trane contribute back to the project, Trane spins up an
4 | //! instance of the mantra-miner library that "recites" relevant mantras in a background thread.
5 | //! There is no explicit tradition or textual precedent for electronic mantra recitation, but
6 | //! carving mantras in stone and spinning them in prayer wheels is a common practice.
7 | //!
8 | //! The mantras chosen are:
9 | //!
10 | //! - The Song of the Vajra, a central song from the Dzogchen tradition that embodies the supreme
11 | //! realization that the true nature of all beings has been naturally perfect from the very
12 | //! beginning.
13 | //! - The mantra of the bodhisattva Manjushri, embodiment of wisdom.
14 | //! - The mantra of Tara Sarasvati, the female Buddha of knowledge, wisdom, and eloquence. She often
15 | //! appears with Manjushri.
16 | //! - Mantras used to dedicate the merit of one's practice.
17 |
18 | use indoc::indoc;
19 | use mantra_miner::{Mantra, MantraMiner, Options};
20 |
21 | /// Runs the mantra miner that recites mantra while Trane is running.
22 | pub struct TraneMantraMiner {
23 | /// An instance of the mantra miner.
24 | pub mantra_miner: MantraMiner,
25 | }
26 |
27 | impl TraneMantraMiner {
28 | fn options() -> Options {
29 | Options {
30 | // The Song of the Vajra.
31 | preparation: Some(
32 | indoc! {r"
33 | ཨ
34 |
35 | E MA KI RI KĪ RĪ
36 | MA SṬA VA LI VĀ LĪ
37 | SAMITA SU RU SŪ RŪ KUTALI MA SU MĀ SŪ
38 | E KARA SULI BHAṬAYE CI KIRA BHULI BHAṬHAYE
39 | SAMUNTA CARYA SUGHAYE BHETA SANA BHYA KU LAYE
40 | SAKARI DHU KA NA MATARI VAI TA NA
41 | PARALI HI SA NA MAKHARTA KHE LA NAM
42 | SAMBHA RA THA ME KHA CA NTA PA SŪRYA BHA TA RAI PA SHA NA PA
43 | RANA BI DHI SA GHU RA LA PA MAS MIN SA GHU LĪ TA YA PA
44 | GHU RA GHŪ RĀ SA GHA KHAR ṆA LAM
45 | NA RA NĀ RĀ ITHA PA ṬA LAM SIR ṆA SĪR ṆĀ BHE SARAS PA LAM
46 | BHUN DHA BHŪN DHĀ CI SHA SA KE LAM
47 | SA SĀ RI RĪ LI LĪ I Ī MI MĪ
48 | RA RA RĀ
49 | "}
50 | .to_string(),
51 | ),
52 | preparation_repeats: None,
53 | // The mantras of Manjushri and Tara Sarasvati, each recited 108 times.
54 | mantras: vec![
55 | Mantra {
56 | syllables: vec![
57 | "om".to_string(),
58 | "a".to_string(),
59 | "ra".to_string(),
60 | "pa".to_string(),
61 | "tsa".to_string(),
62 | "na".to_string(),
63 | "dhi".to_string(),
64 | ],
65 | repeats: Some(108),
66 | },
67 | Mantra {
68 | syllables: vec![
69 | "om".to_string(),
70 | "pe".to_string(),
71 | "mo".to_string(),
72 | "yo".to_string(),
73 | "gi".to_string(),
74 | "ni".to_string(),
75 | "ta".to_string(),
76 | "ré".to_string(),
77 | "tu".to_string(),
78 | "tta".to_string(),
79 | "ré".to_string(),
80 | "tu".to_string(),
81 | "ré".to_string(),
82 | "praj".to_string(),
83 | "na".to_string(),
84 | "hrim".to_string(),
85 | "hrim".to_string(),
86 | "so".to_string(),
87 | "ha".to_string(),
88 | ],
89 | repeats: Some(108),
90 | },
91 | ],
92 | // Mantras of dedication.
93 | conclusion: Some(
94 | indoc! {r"
95 | OM DHARE DHARE BHANDHARE SVĀHĀ
96 | JAYA JAYA SIDDHI SIDDHI PHALA PHALA
97 | HĂ A HA SHA SA MA
98 | MAMAKOLIṄ SAMANTA
99 | "}
100 | .to_string(),
101 | ),
102 | conclusion_repeats: None,
103 | repeats: None,
104 | rate_ns: 108_000,
105 | }
106 | }
107 | }
108 |
109 | impl Default for TraneMantraMiner {
110 | fn default() -> Self {
111 | Self {
112 | mantra_miner: MantraMiner::new(Self::options()),
113 | }
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/src/data/music/circle_fifths.rs:
--------------------------------------------------------------------------------
1 | //! Contains functions for working with the circle of fifths.
2 |
3 | use crate::data::music::notes::Note;
4 |
5 | impl Note {
6 | /// Returns all the notes in the circle of fifths.
7 | #[must_use]
8 | pub fn all_keys(include_enharmonic: bool) -> Vec {
9 | if include_enharmonic {
10 | vec![
11 | // Key with no sharps or flats.
12 | Note::C,
13 | // Keys with at least one sharp.
14 | Note::G,
15 | Note::D,
16 | Note::A,
17 | Note::E,
18 | Note::B,
19 | Note::F_SHARP,
20 | Note::C_SHARP,
21 | // Keys with at least one flat.
22 | Note::F,
23 | Note::B_FLAT,
24 | Note::E_FLAT,
25 | Note::A_FLAT,
26 | Note::D_FLAT,
27 | Note::G_FLAT,
28 | Note::C_FLAT,
29 | ]
30 | } else {
31 | vec![
32 | // Key with no sharps or flats.
33 | Note::C,
34 | // Keys with at least one sharp.
35 | Note::G,
36 | Note::D,
37 | Note::A,
38 | Note::E,
39 | Note::B,
40 | // Keys with at least one flat.
41 | Note::F,
42 | Note::B_FLAT,
43 | Note::E_FLAT,
44 | Note::A_FLAT,
45 | Note::D_FLAT,
46 | Note::G_FLAT,
47 | ]
48 | }
49 | }
50 |
51 | /// Returns the note obtained by moving clockwise through the circle of fifths.
52 | #[must_use]
53 | pub fn clockwise(&self) -> Option {
54 | match *self {
55 | Note::C => Some(Note::G),
56 | Note::G => Some(Note::D),
57 | Note::D => Some(Note::A),
58 | Note::A => Some(Note::E),
59 | Note::E => Some(Note::B),
60 | Note::B => Some(Note::F_SHARP),
61 | Note::F_SHARP => Some(Note::C_SHARP),
62 |
63 | Note::F => Some(Note::C),
64 | Note::B_FLAT => Some(Note::F),
65 | Note::E_FLAT => Some(Note::B_FLAT),
66 | Note::A_FLAT => Some(Note::E_FLAT),
67 | Note::D_FLAT => Some(Note::A_FLAT),
68 | Note::G_FLAT => Some(Note::D_FLAT),
69 | Note::C_FLAT => Some(Note::G_FLAT),
70 |
71 | _ => None,
72 | }
73 | }
74 |
75 | /// Returns the note obtained by moving counter-clockwise through the circle of fifths.
76 | #[must_use]
77 | pub fn counter_clockwise(&self) -> Option {
78 | match *self {
79 | Note::C => Some(Note::F),
80 | Note::F => Some(Note::B_FLAT),
81 | Note::B_FLAT => Some(Note::E_FLAT),
82 | Note::E_FLAT => Some(Note::A_FLAT),
83 | Note::A_FLAT => Some(Note::D_FLAT),
84 | Note::D_FLAT => Some(Note::G_FLAT),
85 | Note::G_FLAT => Some(Note::C_FLAT),
86 |
87 | Note::G => Some(Note::C),
88 | Note::D => Some(Note::G),
89 | Note::A => Some(Note::D),
90 | Note::E => Some(Note::A),
91 | Note::B => Some(Note::E),
92 | Note::F_SHARP => Some(Note::B),
93 | Note::C_SHARP => Some(Note::F_SHARP),
94 |
95 | _ => None,
96 | }
97 | }
98 |
99 | /// Returns the previous key in the circle of fifths, that is, the key with one fewer sharp or
100 | /// flat.
101 | #[must_use]
102 | pub fn previous_key_in_circle(&self) -> Option {
103 | match *self {
104 | // Both G and F lead back to C.
105 | Note::G | Note::F => Some(Note::C),
106 |
107 | // The keys with at least one sharp.
108 | Note::D => Some(Note::G),
109 | Note::A => Some(Note::D),
110 | Note::E => Some(Note::A),
111 | Note::B => Some(Note::E),
112 | Note::F_SHARP => Some(Note::B),
113 | Note::C_SHARP => Some(Note::F_SHARP),
114 |
115 | // The keys with at least one flat.
116 | Note::B_FLAT => Some(Note::F),
117 | Note::E_FLAT => Some(Note::B_FLAT),
118 | Note::A_FLAT => Some(Note::E_FLAT),
119 | Note::D_FLAT => Some(Note::A_FLAT),
120 | Note::G_FLAT => Some(Note::D_FLAT),
121 | Note::C_FLAT => Some(Note::G_FLAT),
122 |
123 | // Return None for any other note.
124 | _ => None,
125 | }
126 | }
127 |
128 | /// Returns the last keys accessible by traversing the circle of fifths in clockwise and
129 | /// counter-clockwise directions.
130 | #[must_use]
131 | pub fn last_keys_in_circle(include_enharmonic: bool) -> Vec {
132 | if include_enharmonic {
133 | vec![Note::C_SHARP, Note::C_FLAT]
134 | } else {
135 | vec![Note::B, Note::G_FLAT]
136 | }
137 | }
138 | }
139 |
--------------------------------------------------------------------------------
/src/error.rs:
--------------------------------------------------------------------------------
1 | //! Contains the errors returned by Trane.
2 |
3 | use thiserror::Error;
4 | use ustr::Ustr;
5 |
6 | use crate::data::UnitType;
7 |
8 | /// An error returned when dealing with the blacklist.
9 | #[derive(Debug, Error)]
10 | #[allow(missing_docs)]
11 | pub enum BlacklistError {
12 | #[error("cannot add unit {0} to the blacklist: {1}")]
13 | AddUnit(Ustr, #[source] anyhow::Error),
14 |
15 | #[error("cannot get entries from the blacklist: {0}")]
16 | GetEntries(#[source] anyhow::Error),
17 |
18 | #[error("cannot remove entries with prefix {0} from the blacklist: {1}")]
19 | RemovePrefix(String, #[source] anyhow::Error),
20 |
21 | #[error("cannot remove unit {0} from the blacklist: {1}")]
22 | RemoveUnit(Ustr, #[source] anyhow::Error),
23 | }
24 |
25 | /// An error returned when dealing with the course library.
26 | #[derive(Debug, Error)]
27 | #[allow(missing_docs)]
28 | pub enum CourseLibraryError {
29 | #[error("cannot process query {0}: {1}")]
30 | Search(String, #[source] anyhow::Error),
31 | }
32 |
33 | /// An error returned when dealing with the exercise scheduler.
34 | #[derive(Debug, Error)]
35 | #[allow(missing_docs)]
36 | pub enum ExerciseSchedulerError {
37 | #[error("cannot retrieve exercise batch: {0}")]
38 | GetExerciseBatch(#[source] anyhow::Error),
39 |
40 | #[error("cannot score exercise: {0}")]
41 | ScoreExercise(#[source] anyhow::Error),
42 |
43 | #[error("cannot get score for unit {0}: {1}")]
44 | GetUnitScore(Ustr, #[source] anyhow::Error),
45 | }
46 |
47 | /// An error returned when dealing with the practice stats.
48 | #[derive(Debug, Error)]
49 | #[allow(missing_docs)]
50 | pub enum PracticeRewardsError {
51 | #[error("cannot get rewards for unit {0}: {1}")]
52 | GetRewards(Ustr, #[source] anyhow::Error),
53 |
54 | #[error("cannot record reward for unit {0}: {1}")]
55 | RecordReward(Ustr, #[source] anyhow::Error),
56 |
57 | #[error("cannot trim rewards: {0}")]
58 | TrimReward(#[source] anyhow::Error),
59 |
60 | #[error("cannot remove rewards from units matching prefix {0}: {1}")]
61 | RemovePrefix(String, #[source] anyhow::Error),
62 | }
63 |
64 | /// An error returned when dealing with the practice stats.
65 | #[derive(Debug, Error)]
66 | #[allow(missing_docs)]
67 | pub enum PracticeStatsError {
68 | #[error("cannot get scores for unit {0}: {1}")]
69 | GetScores(Ustr, #[source] anyhow::Error),
70 |
71 | #[error("cannot record score for unit {0}: {1}")]
72 | RecordScore(Ustr, #[source] anyhow::Error),
73 |
74 | #[error("cannot trim scores: {0}")]
75 | TrimScores(#[source] anyhow::Error),
76 |
77 | #[error("cannot remove scores from units matching prefix {0}: {1}")]
78 | RemovePrefix(String, #[source] anyhow::Error),
79 | }
80 |
81 | /// An error returned when dealing with user preferences.
82 | #[derive(Debug, Error)]
83 | #[allow(missing_docs)]
84 | pub enum PreferencesManagerError {
85 | #[error("cannot get user preferences: {0}")]
86 | GetUserPreferences(#[source] anyhow::Error),
87 |
88 | #[error("cannot set user preferences: {0}")]
89 | SetUserPreferences(#[source] anyhow::Error),
90 | }
91 |
92 | /// An error returned when dealing with git repositories containing courses.
93 | #[derive(Debug, Error)]
94 | #[allow(missing_docs)]
95 | pub enum RepositoryManagerError {
96 | #[error("cannot add repository with URL {0}: {1}")]
97 | AddRepo(String, #[source] anyhow::Error),
98 |
99 | #[error("cannot list repositories: {0}")]
100 | ListRepos(#[source] anyhow::Error),
101 |
102 | #[error("cannot get repository with ID {0}: {1}")]
103 | RemoveRepo(String, #[source] anyhow::Error),
104 |
105 | #[error("cannot update repository with ID {0}: {1}")]
106 | UpdateRepo(String, #[source] anyhow::Error),
107 |
108 | #[error("cannot update repositories: {0}")]
109 | UpdateRepos(#[source] anyhow::Error),
110 | }
111 |
112 | /// An error returned when dealing with the review list.
113 | #[derive(Debug, Error)]
114 | #[allow(missing_docs)]
115 | pub enum ReviewListError {
116 | #[error("cannot add unit {0} to the review list: {1}")]
117 | AddUnit(Ustr, #[source] anyhow::Error),
118 |
119 | #[error("cannot retrieve the entries from the review list: {0}")]
120 | GetEntries(#[source] anyhow::Error),
121 |
122 | #[error("cannot remove unit {0} from the review list: {1}")]
123 | RemoveUnit(Ustr, #[source] anyhow::Error),
124 | }
125 |
126 | /// An error returned when downloading transcription assets.
127 | #[derive(Debug, Error)]
128 | #[allow(missing_docs)]
129 | pub enum TranscriptionDownloaderError {
130 | #[error("cannot download asset for exercise {0}: {1}")]
131 | DownloadAsset(Ustr, #[source] anyhow::Error),
132 | }
133 |
134 | /// An error returned when dealing with the unit graph.
135 | #[derive(Debug, Error)]
136 | #[allow(missing_docs)]
137 | pub enum UnitGraphError {
138 | #[error("cannot add dependencies for unit {0} of type {1} to the unit graph: {2}")]
139 | AddDependencies(Ustr, UnitType, #[source] anyhow::Error),
140 |
141 | #[error("cannot add unit {0} of type {1} to the unit graph: {2}")]
142 | AddUnit(Ustr, UnitType, #[source] anyhow::Error),
143 |
144 | #[error("checking for cycles in the unit graph failed: {0}")]
145 | CheckCycles(#[source] anyhow::Error),
146 | }
147 |
--------------------------------------------------------------------------------
/src/data/music/notes.rs:
--------------------------------------------------------------------------------
1 | //! Defines the notes and accidentals for use in generating music courses.
2 |
3 | use std::fmt::{Display, Formatter};
4 |
5 | /// Defines the names of the natural notes.
6 | #[derive(Clone, Copy, Debug, PartialEq, Eq)]
7 | #[allow(missing_docs)]
8 | pub enum NaturalNote {
9 | A,
10 | B,
11 | C,
12 | D,
13 | E,
14 | F,
15 | G,
16 | }
17 |
18 | impl Display for NaturalNote {
19 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
20 | match self {
21 | NaturalNote::A => write!(f, "A"),
22 | NaturalNote::B => write!(f, "B"),
23 | NaturalNote::C => write!(f, "C"),
24 | NaturalNote::D => write!(f, "D"),
25 | NaturalNote::E => write!(f, "E"),
26 | NaturalNote::F => write!(f, "F"),
27 | NaturalNote::G => write!(f, "G"),
28 | }
29 | }
30 | }
31 |
32 | /// Defines the pitch accidentals that can be applied to a note.
33 | #[derive(Clone, Copy, Debug, PartialEq, Eq)]
34 | #[allow(missing_docs)]
35 | pub enum Accidental {
36 | Natural,
37 | Flat,
38 | Sharp,
39 | }
40 |
41 | impl Display for Accidental {
42 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
43 | match self {
44 | Accidental::Natural => write!(f, ""),
45 | Accidental::Flat => write!(f, "♭"),
46 | Accidental::Sharp => write!(f, "♯"),
47 | }
48 | }
49 | }
50 |
51 | /// Defines the union of a natural note and an accidental that describes a note.
52 | #[derive(Clone, Copy, Debug, PartialEq, Eq)]
53 | pub struct Note(pub NaturalNote, pub Accidental);
54 |
55 | #[allow(missing_docs)]
56 | impl Note {
57 | pub const A: Note = Note(NaturalNote::A, Accidental::Natural);
58 | pub const A_FLAT: Note = Note(NaturalNote::A, Accidental::Flat);
59 | pub const A_SHARP: Note = Note(NaturalNote::A, Accidental::Sharp);
60 | pub const B: Note = Note(NaturalNote::B, Accidental::Natural);
61 | pub const B_FLAT: Note = Note(NaturalNote::B, Accidental::Flat);
62 | pub const B_SHARP: Note = Note(NaturalNote::B, Accidental::Sharp);
63 | pub const C: Note = Note(NaturalNote::C, Accidental::Natural);
64 | pub const C_FLAT: Note = Note(NaturalNote::C, Accidental::Flat);
65 | pub const C_SHARP: Note = Note(NaturalNote::C, Accidental::Sharp);
66 | pub const D: Note = Note(NaturalNote::D, Accidental::Natural);
67 | pub const D_FLAT: Note = Note(NaturalNote::D, Accidental::Flat);
68 | pub const D_SHARP: Note = Note(NaturalNote::D, Accidental::Sharp);
69 | pub const E: Note = Note(NaturalNote::E, Accidental::Natural);
70 | pub const E_FLAT: Note = Note(NaturalNote::E, Accidental::Flat);
71 | pub const E_SHARP: Note = Note(NaturalNote::E, Accidental::Sharp);
72 | pub const F: Note = Note(NaturalNote::F, Accidental::Natural);
73 | pub const F_FLAT: Note = Note(NaturalNote::F, Accidental::Flat);
74 | pub const F_SHARP: Note = Note(NaturalNote::F, Accidental::Sharp);
75 | pub const G: Note = Note(NaturalNote::G, Accidental::Natural);
76 | pub const G_FLAT: Note = Note(NaturalNote::G, Accidental::Flat);
77 | pub const G_SHARP: Note = Note(NaturalNote::G, Accidental::Sharp);
78 |
79 | /// Returns a representation of the note without Unicode characters for use in directory names
80 | /// and other contexts where Unicode is harder or impossible to use.
81 | #[must_use]
82 | pub fn to_ascii_string(&self) -> String {
83 | let accidental = match self.1 {
84 | Accidental::Natural => String::new(),
85 | Accidental::Flat => "_flat".to_string(),
86 | Accidental::Sharp => "_sharp".to_string(),
87 | };
88 | format!("{}{}", self.0, accidental)
89 | }
90 | }
91 |
92 | impl Display for Note {
93 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
94 | write!(f, "{}{}", self.0, self.1)
95 | }
96 | }
97 |
98 | #[cfg(test)]
99 | #[cfg_attr(coverage, coverage(off))]
100 | mod test {
101 | use super::*;
102 |
103 | /// Verifies converting a note to a string.
104 | #[test]
105 | fn to_string() {
106 | assert_eq!(NaturalNote::A.to_string(), "A");
107 | assert_eq!(NaturalNote::B.to_string(), "B");
108 | assert_eq!(NaturalNote::C.to_string(), "C");
109 | assert_eq!(NaturalNote::D.to_string(), "D");
110 | assert_eq!(NaturalNote::E.to_string(), "E");
111 | assert_eq!(NaturalNote::F.to_string(), "F");
112 | assert_eq!(NaturalNote::G.to_string(), "G");
113 |
114 | assert_eq!(Note(NaturalNote::A, Accidental::Natural).to_string(), "A");
115 | assert_eq!(Note(NaturalNote::A, Accidental::Flat).to_string(), "A♭");
116 | assert_eq!(Note(NaturalNote::A, Accidental::Sharp).to_string(), "A♯");
117 | }
118 |
119 | /// Verifies converting a note to an ASCII string.
120 | #[test]
121 | fn to_ascii_string() {
122 | assert_eq!(
123 | Note(NaturalNote::A, Accidental::Natural).to_ascii_string(),
124 | "A"
125 | );
126 | assert_eq!(
127 | Note(NaturalNote::A, Accidental::Flat).to_ascii_string(),
128 | "A_flat"
129 | );
130 | assert_eq!(
131 | Note(NaturalNote::A, Accidental::Sharp).to_ascii_string(),
132 | "A_sharp"
133 | );
134 | }
135 |
136 | /// Verifies that notes can be cloned. Done to ensure that the auto-generated trait
137 | /// implementation is included in the code coverage report.
138 | #[test]
139 | #[allow(clippy::clone_on_copy)]
140 | fn note_clone() {
141 | let note = Note(NaturalNote::A, Accidental::Natural);
142 | let clone = note.clone();
143 | assert_eq!(note, clone);
144 | }
145 | }
146 |
--------------------------------------------------------------------------------
/src/course_builder/music/circle_fifths.rs:
--------------------------------------------------------------------------------
1 | //! Contains utilities to generate courses based on the circles of fifths.
2 | //!
3 | //! Suppose that you have a course that contains guitar riffs that you would like to learn in
4 | //! all keys. The utilities in this module allow you to define a course in which the first lesson
5 | //! teaches you the riffs in the key of C (or A minor if the riffs were in a minor key). From this
6 | //! lesson, there are two dependent lessons one for the key of G and another for the key of F,
7 | //! because these keys contain only one sharp or flat respectively. The process repeats until the
8 | //! circle is traversed in both clockwise and counter-clockwise directions.
9 |
10 | use anyhow::Result;
11 |
12 | use crate::{
13 | course_builder::{AssetBuilder, CourseBuilder, LessonBuilder},
14 | data::{CourseManifest, LessonManifestBuilder, music::notes::*},
15 | };
16 |
17 | /// Generates a course builder that contains a lesson per key and which follows the circle of
18 | /// fifths, starting with the key of C (which has not flats nor sharps), and continuing in both
19 | /// directions, allowing each lesson to depend on the lesson that comes before in the circle of
20 | /// fifths. This is useful to create courses that teach the same exercises for each key.
21 | pub struct CircleFifthsCourse {
22 | /// Base name of the directory on which to store this lesson.
23 | pub directory_name: String,
24 |
25 | /// The manifest for the course.
26 | pub course_manifest: CourseManifest,
27 |
28 | /// The asset builders for the course.
29 | pub course_asset_builders: Vec,
30 |
31 | /// An optional closure that returns a different note from the one found by traversing the
32 | /// circle of fifths. This is useful, for example, to generate a course based on the minor scale
33 | /// in the correct order by the number of flats or sharps in the scale (i.e., the lesson based
34 | /// on A minor appears first because it's the relative minor of C major).
35 | pub note_alias: Option Result>,
36 |
37 | /// The template used to generate the lesson manifests.
38 | pub lesson_manifest_template: LessonManifestBuilder,
39 |
40 | /// A closure which generates the builder for each lesson.
41 | pub lesson_builder_generator: Box) -> Result>,
42 |
43 | /// An optional closure which generates extra lessons which do not follow the circle of fifths
44 | /// pattern.
45 | pub extra_lessons_generator: Option Result>>>,
46 | }
47 |
48 | impl CircleFifthsCourse {
49 | /// Generates the lesson builder for the lesson based on the given note. An optional note is
50 | /// also provided for the purpose of creating the dependencies of the lesson.
51 | fn generate_lesson_builder(
52 | &self,
53 | note: Note,
54 | previous_note: Option,
55 | ) -> Result {
56 | let note_alias = match &self.note_alias {
57 | None => note,
58 | Some(closure) => closure(note)?,
59 | };
60 |
61 | let previous_note_alias = match &self.note_alias {
62 | None => previous_note,
63 | Some(closure) => match previous_note {
64 | None => None,
65 | Some(previous_note) => Some(closure(previous_note)?),
66 | },
67 | };
68 |
69 | (self.lesson_builder_generator)(note_alias, previous_note_alias)
70 | }
71 |
72 | /// Traverses the circle of fifths in counter-clockwise motion and returns a list of the lesson
73 | /// builders generated along the way.
74 | fn generate_counter_clockwise(&self, note: Option) -> Result> {
75 | if note.is_none() {
76 | return Ok(vec![]);
77 | }
78 |
79 | let note = note.unwrap();
80 | let mut lessons = vec![self.generate_lesson_builder(note, note.clockwise())?];
81 | lessons.extend(self.generate_counter_clockwise(note.counter_clockwise())?);
82 | Ok(lessons)
83 | }
84 |
85 | /// Traverses the circle of fifths in clockwise motion and returns a list of the lesson builders
86 | /// generated along the way.
87 | fn generate_clockwise(&self, note: Option) -> Result> {
88 | if note.is_none() {
89 | return Ok(vec![]);
90 | }
91 |
92 | let note = note.unwrap();
93 | let mut lessons = vec![self.generate_lesson_builder(note, note.counter_clockwise())?];
94 | lessons.extend(self.generate_clockwise(note.clockwise())?);
95 | Ok(lessons)
96 | }
97 |
98 | /// Generates all the lesson builders for the course by starting at the note C and traversing
99 | /// the circle of fifths in both directions.
100 | fn generate_lesson_builders(&self) -> Result> {
101 | let mut lessons = vec![self.generate_lesson_builder(Note::C, None)?];
102 | lessons.extend(self.generate_counter_clockwise(Note::C.counter_clockwise())?);
103 | lessons.extend(self.generate_clockwise(Note::C.clockwise())?);
104 | if let Some(generator) = &self.extra_lessons_generator {
105 | lessons.extend(generator()?);
106 | }
107 | Ok(lessons)
108 | }
109 |
110 | /// Generates a course builder which contains lessons based on each key.
111 | pub fn generate_course_builder(&self) -> Result {
112 | Ok(CourseBuilder {
113 | directory_name: self.directory_name.clone(),
114 | course_manifest: self.course_manifest.clone(),
115 | asset_builders: self.course_asset_builders.clone(),
116 | lesson_builders: self.generate_lesson_builders()?,
117 | lesson_manifest_template: self.lesson_manifest_template.clone(),
118 | })
119 | }
120 | }
121 |
--------------------------------------------------------------------------------
/src/data/course_generator/transcription/constants.rs:
--------------------------------------------------------------------------------
1 | //! Contains constants used by the transcription courses.
2 |
3 | use std::sync::LazyLock;
4 |
5 | use indoc::indoc;
6 | use ustr::Ustr;
7 |
8 | /// The description of the singing lesson.
9 | pub const SINGING_DESCRIPTION: &str = indoc! {"
10 | Repeatedly listen to the passage until you can audiate and sing its main elements. You should
11 | also experiment with singing different melodies over the passage and see what works.
12 |
13 | Refer to the lesson instructions for more details.
14 | "};
15 |
16 | /// The description of the advanced singing lesson.
17 | pub const ADVANCED_SINGING_DESCRIPTION: &str = indoc! {"
18 | Repeatedly listen to the passage until you can audiate and sing it clearly in detail. Same as
19 | the singing lesson, but the passage should be audiated in more detail and precision, and
20 | transposed up or down a random number of semitones.
21 |
22 | Refer to the lesson instructions for more details.
23 | "};
24 |
25 | /// The description of the transcription lesson.
26 | pub const TRANSCRIPTION_DESCRIPTION: &str = indoc! {"
27 | Using the stated instrument, play over the passage, using it as a basis for improvising. Playing
28 | back the exact passage is not required at this stage. Rather, this lesson is about learning to
29 | navigate the context implied by it.
30 |
31 | Refer to the lesson instructions for more details.
32 | "};
33 |
34 | /// The description of the advanced transcription lesson.
35 | pub const ADVANCED_TRANSCRIPTION_DESCRIPTION: &str = indoc! {"
36 | Using the stated instrument, play over passage back, and use it as a basis for improvising.
37 | Same as the transcription exercise, but the passage should be played back in more detail and
38 | precision, and transposed up or down a random number of semitones.
39 |
40 | Refer to the lesson instructions for more details.
41 | "};
42 |
43 | /// The metadata key indicating this is a transcription course. Its value should be set to "true".
44 | pub const COURSE_METADATA: &str = "transcription_course";
45 |
46 | /// The metadata key indicating the type of the transcription lesson. Its value should be set to
47 | /// "true".
48 | pub const LESSON_METADATA: &str = "transcription_lesson";
49 |
50 | /// The metadata key indicating the artists included in the transcription course.
51 | pub const ARTIST_METADATA: &str = "transcription_artist";
52 |
53 | /// The metadata key indicating the album included in the transcription course.
54 | pub const ALBUM_METADATA: &str = "transcription_album";
55 |
56 | /// The metadata key indicating the instrument of the transcription lesson.
57 | pub const INSTRUMENT_METADATA: &str = "instrument";
58 |
59 | /// The file where the course instructions are stored.
60 | pub static COURSE_INSTRUCTIONS: LazyLock =
61 | LazyLock::new(|| Ustr::from(include_str!("course_instructions.md")));
62 |
63 | /// The instructions for the singing course.
64 | pub static SINGING_INSTRUCTIONS: LazyLock = LazyLock::new(|| {
65 | Ustr::from(indoc! {"
66 | First listen to the musical passage until you can audiate it clearly in your head. Then sing
67 | over the passage. At this stage it's not required to be accurate as possible. Rather, learn
68 | to sing the main elements of the passage and experiment with different melodies over it.
69 | The goal is to learn to navigate the context implied by the passage.
70 |
71 | There's no need to play on your instrument or write anything down, but you are free to do so
72 | if you wish.
73 | "})
74 | });
75 |
76 | /// The instructions for the transcription course.
77 | pub static TRANSCRIPTION_INSTRUCTIONS: LazyLock = LazyLock::new(|| {
78 | Ustr::from(indoc! {"
79 | With the basic context implied by the passage now internalized in your ear, try to play over
80 | it using your instrument. The goal at this point is not to accurately reproduce the passage,
81 | but rather about learning to navigate that context and use it as a basis for improvisation.
82 | You can focus on different elements or sections each time you practice.
83 |
84 | There's no need to write anything down, but you are free to do so if you wish.
85 | "})
86 | });
87 |
88 | /// The instructions for the advanced singing course.
89 | pub static ADVANCED_SINGING_INSTRUCTIONS: LazyLock = LazyLock::new(|| {
90 | Ustr::from(indoc! {"
91 | Listen to the musical passage until you can audiate it and sing over it like you did in the
92 | singing lesson. In that lesson, the passage was used as a basis for improvisation. In this
93 | lesson, the passage should be sung with more detail and precision, and transposed up or down
94 | a random number of semitones. You should also use solfege syllables or numbers to sing the
95 | passage.
96 |
97 | There's no need to play on your instrument or write anything down, but you are free to do so
98 | if you wish.
99 | "})
100 | });
101 |
102 | /// The instructions for the advanced transcription course.
103 | pub static ADVANCED_TRANSCRIPTION_INSTRUCTIONS: LazyLock = LazyLock::new(|| {
104 | Ustr::from(indoc! {"
105 | At this stage, you can sing and play over the context implied by the passage, and sing it
106 | with more detail and precision in a variety of keys. It's at this point that you can engage
107 | in what is traditionally called transcription.
108 |
109 | Play over the passage using your instrument, and try to reproduce it in more detail than
110 | in the basic transcription lesson. You should also transpose the passage up or down a random
111 | number of semitones. You should still use the passage as a basis for improvisation, but the
112 | focus is much narrower than in the basic transcription lesson, and the actual music played
113 | in the passage take precedence over the context implied by it.
114 |
115 | There's no need to write anything down, but you are free to do so if you wish.
116 | "})
117 | });
118 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Trane
2 |
3 | [](https://github.com/trane-project/trane/actions?query=branch%3Amaster)
4 | [](https://coveralls.io/github/trane-project/trane?branch=master)
5 | [](https://docs.rs/trane)
6 | [](https://crates.io/crates/trane)
7 | [](https://github.com/trane-project/trane/stargazers)
8 |
9 | ---
10 |
11 | The first end-to-end application of trane, [Pictures Are For
12 | Babies](https://picturesareforbabies.com), is coming. It combines the power of trane with the latest
13 | research on reading and writing acquisition to create the most complete and ambitious literacy
14 | program in history.
15 |
16 | If you are interested in that project, subscribe to the
17 | [newsletter](https://picturesareforbabies.substack.com) to receive product updates, development
18 | notes, and articles on literacy education and history.
19 |
20 | ---
21 |
22 | Trane is an automated practice system for the acquisition of arbitrary, complex, and highly
23 | hierarchical skills. That's quite a mouthful, so let's break it down.
24 |
25 | - **Practice system**: Deliberate practice is at the heart of the acquisition of new skills. Trane
26 | calls itself a practice system because it is designed to guide student's progress through
27 | arbitrary skills. Trane shows the student an exercise they can practice and then asks them to
28 | score it based on their mastery of the skill tested by the exercise.
29 | - **Automated**: Knowing what to practice, when to reinforce what has already been practiced, and
30 | when to move on to the next step is as important as establishing a consistent practice. Trane's
31 | main feature is to automate this process by providing students with an infinite stream of
32 | exercises. Internally, Trane uses the student feedback to determine which exercises are most
33 | appropriate for the current moment.
34 | - **Arbitrary**: Although originally envisioned for practicing Jazz improvisation, Trane is not
35 | limited to a specific domain. Trane primarily works via plain-text files that are easily sharable
36 | and extendable. This allows student to create their own materials, to use materials created by
37 | others, and to seamlessly combine them.
38 | - **Complex and hierarchical skills**: Consider the job of a master improviser, such as the namesake
39 | of this software, John Coltrane. Through years of practice, Coltrane developed mastery over a
40 | large set of interconnected skills. A few examples include the breathing control to play the fiery
41 | stream of notes that characterize his style, the aural training to recognize and play in any key,
42 | and the fine motor skills to play the intricate lines of his solos. All these skills came together
43 | to create his unique and spiritually powerful sound. Trane is designed to allow students to easily
44 | express these complex relationships and to take advantage of them to guide the student's practice.
45 | This is the feature that is at the core of Trane and the main difference between it and similar
46 | software, such as Anki, which already make use of some of the same learning principles.
47 |
48 | Trane is based on multiple proven principles of skill acquisition, like spaced repetition, mastery
49 | learning, interleaving, and chunking. For example, Trane makes sure that not too many very easy or
50 | hard exercises are shown to a student to avoid both extremes of frustration and boredom. Trane makes
51 | sure to periodically reinforce skills that have already been practiced and to include new skills
52 | automatically when the skills that they depend on have been sufficiently mastered.
53 |
54 | If you are familiar with the experience of traversing the skill tree of a video game by grinding and
55 | becoming better at the game, Trane aims to provide a way to help students complete a similar
56 | process, but applied to arbitrary skills, specified in plain-text files that are easy to share and
57 | augment.
58 |
59 | Trane is named after John Coltrane, whose nickname Trane was often used in wordplay with the word
60 | train (as in the vehicle) to describe the overwhelming power of his playing. It is used here as a
61 | play on its homophone (as in "*trane* a new skill").
62 |
63 | ## Quick Start
64 |
65 | For a guide to getting started with using Trane, see the [quick
66 | start](https://trane-project.github.io/quick_start.html) guide at the official site.
67 |
68 | For a video showing Trane in action, see the [Tour of
69 | Trane](https://www.youtube.com/watch?v=3ZTUBvYjWnw) video.
70 |
71 | ## Documentation
72 |
73 | Full documentation for The Trane Project, including this library, can be found at the [official
74 | site](https://trane-project.github.io/)
75 |
76 | ## A Code Tour of Trane
77 |
78 | A goal of Trane's code is to be as clean, well-documented, organized, and readable as possible. Most
79 | modules should have module-level documentation at the top of the file, which includes rationale
80 | behind the design choices made by the author. Below is a list of a few modules and files to get you
81 | started with understanding the code:
82 |
83 | - `data`: Contains the basic data structures used throughout Trane. Among other things, it defines:
84 | - Courses, lessons, and exercises and how their content and dependencies.
85 | - Student scores and exercise trials.
86 | - The filters that can be used to narrow down the units from which exercises are drawn.
87 | - `graph`: Contains the definition of the graph of units and their dependencies that is traversed by
88 | Trane as a student makes progress.
89 | - `course_library`: Defines how a collection of courses gathered by a student is written and read
90 | to and from storage.
91 | - `blacklist`: Defines the list of units which should be ignored and marked as mastered during
92 | exercise scheduling.
93 | - `practice_stats`: Defines how the student's progress is stored for later used by the scheduler.
94 | - `scorer`: Defines how an exercise is scored based on the scores and timestamps of previous trials.
95 | - `scheduler`: Contains the logic of how exercises that are to be presented to the user are
96 | selected. The core of Trane's logic sits in this module.
97 | - `review_list`: Defines a list of exercises the student wants to review at a later time.
98 | - `filter_manager`: Defines a way to save and load filters for later use. For example, to save a
99 | filter to only study exercises for the guitar.
100 | - `lib.rs`: This file defines the public API of the crate, which is the entry point for using Trane.
101 | - `course_builder`: Defines utilities to make it easier to build Trane courses.
102 |
103 | If there's a particular part of the code that is confusing, does not follow standard Rust idioms or
104 | conventions, could use better documentation, or whose rationale is not obvious, feel free to open an
105 | issue.
106 |
107 | ## Contributing
108 |
109 | See the [CONTRIBUTING](https://github.com/trane-project/trane/blob/master/CONTRIBUTING.md) file for
110 | more information on how to contribute to Trane.
111 |
--------------------------------------------------------------------------------
/src/review_list.rs:
--------------------------------------------------------------------------------
1 | //! Defines a list of units which the student wants to review.
2 | //!
3 | //! Students might identify exercises, lessons, or courses which need additional review. They can
4 | //! add them to the review list. The scheduler implements a special mode that will only schedule
5 | //! exercises from the units in the review list.
6 |
7 | use anyhow::{Context, Result};
8 | use r2d2::Pool;
9 | use r2d2_sqlite::SqliteConnectionManager;
10 | use rusqlite::{Connection, params};
11 | use rusqlite_migration::{M, Migrations};
12 | use ustr::Ustr;
13 |
14 | use crate::{error::ReviewListError, utils};
15 |
16 | /// An interface to store and read a list of units that need review.
17 | pub trait ReviewList {
18 | /// Adds the given unit to the review list.
19 | fn add_to_review_list(&mut self, unit_id: Ustr) -> Result<(), ReviewListError>;
20 |
21 | /// Removes the given unit from the review list. Do nothing if the unit is not already in the
22 | /// list.
23 | fn remove_from_review_list(&mut self, unit_id: Ustr) -> Result<(), ReviewListError>;
24 |
25 | /// Returns all the entries in the review list.
26 | fn get_review_list_entries(&self) -> Result, ReviewListError>;
27 | }
28 |
29 | /// An implementation of [`ReviewList`] backed by `SQLite`.
30 | pub struct LocalReviewList {
31 | /// A pool of connections to the database.
32 | pool: Pool,
33 | }
34 |
35 | impl LocalReviewList {
36 | /// Returns all the migrations needed to set up the database.
37 | fn migrations() -> Migrations<'static> {
38 | Migrations::new(vec![
39 | // Create a table with the IDs of the units in the review list.
40 | M::up("CREATE TABLE review_list(unit_id TEXT NOT NULL UNIQUE);")
41 | .down("DROP TABLE review_list"),
42 | // Create an index of the unit IDs in the review list.
43 | M::up("CREATE INDEX unit_id_index ON review_list (unit_id);")
44 | .down("DROP INDEX unit_id_index"),
45 | ])
46 | }
47 |
48 | /// Initializes the database by running the migrations. If the migrations have been applied
49 | /// already, they will have no effect on the database.
50 | fn init(&mut self) -> Result<()> {
51 | let mut connection = self.pool.get()?;
52 | let migrations = Self::migrations();
53 | migrations
54 | .to_latest(&mut connection)
55 | .context("failed to initialize review list DB")
56 | }
57 |
58 | /// Initializes the pool and the review list database.
59 | fn new(connection_manager: SqliteConnectionManager) -> Result {
60 | let pool = utils::new_connection_pool(connection_manager)?;
61 | let mut review_list = LocalReviewList { pool };
62 | review_list.init()?;
63 | Ok(review_list)
64 | }
65 |
66 | /// A constructor taking the path to the database file.
67 | pub fn new_from_disk(db_path: &str) -> Result {
68 | let connection_manager = SqliteConnectionManager::file(db_path).with_init(
69 | |connection: &mut Connection| -> Result<(), rusqlite::Error> {
70 | // The following pragma statements are set to improve the read and write performance
71 | // of SQLite. See the SQLite [docs](https://www.sqlite.org/pragma.html) for more
72 | // information.
73 | connection.pragma_update(None, "journal_mode", "WAL")?;
74 | connection.pragma_update(None, "synchronous", "NORMAL")
75 | },
76 | );
77 | Self::new(connection_manager)
78 | }
79 |
80 | /// Helper to add a unit to the review list.
81 | fn add_to_review_list_helper(&mut self, unit_id: Ustr) -> Result<()> {
82 | // Add the unit to the database.
83 | let connection = self.pool.get()?;
84 | let mut stmt =
85 | connection.prepare_cached("INSERT OR IGNORE INTO review_list (unit_id) VALUES (?1)")?;
86 | stmt.execute(params![unit_id.as_str()])?;
87 | Ok(())
88 | }
89 |
90 | /// Helper to remove a unit from the review list.
91 | fn remove_from_review_list_helper(&mut self, unit_id: Ustr) -> Result<()> {
92 | // Remove the unit from the database.
93 | let connection = self.pool.get()?;
94 | let mut stmt = connection.prepare_cached("DELETE FROM review_list WHERE unit_id = $1")?;
95 | stmt.execute(params![unit_id.as_str()])?;
96 | Ok(())
97 | }
98 |
99 | /// Helper to get all the entries in the review list.
100 | fn get_review_list_entries_helper(&self) -> Result> {
101 | // Retrieve all the units from the database.
102 | let connection = self.pool.get()?;
103 | let mut stmt = connection.prepare_cached("SELECT unit_id from review_list;")?;
104 | let mut rows = stmt.query(params![])?;
105 |
106 | // Convert the rows into a vector of unit IDs.
107 | let mut entries = Vec::new();
108 | while let Some(row) = rows.next()? {
109 | let unit_id: String = row.get(0)?;
110 | entries.push(Ustr::from(&unit_id));
111 | }
112 | Ok(entries)
113 | }
114 | }
115 |
116 | impl ReviewList for LocalReviewList {
117 | fn add_to_review_list(&mut self, unit_id: Ustr) -> Result<(), ReviewListError> {
118 | self.add_to_review_list_helper(unit_id)
119 | .map_err(|e| ReviewListError::AddUnit(unit_id, e))
120 | }
121 |
122 | fn remove_from_review_list(&mut self, unit_id: Ustr) -> Result<(), ReviewListError> {
123 | self.remove_from_review_list_helper(unit_id)
124 | .map_err(|e| ReviewListError::RemoveUnit(unit_id, e))
125 | }
126 |
127 | fn get_review_list_entries(&self) -> Result, ReviewListError> {
128 | self.get_review_list_entries_helper()
129 | .map_err(ReviewListError::GetEntries)
130 | }
131 | }
132 |
133 | #[cfg(test)]
134 | #[cfg_attr(coverage, coverage(off))]
135 | mod test {
136 | use anyhow::Result;
137 | use r2d2_sqlite::SqliteConnectionManager;
138 | use ustr::Ustr;
139 |
140 | use crate::review_list::{LocalReviewList, ReviewList};
141 |
142 | fn new_test_review_list() -> Result> {
143 | let connection_manager = SqliteConnectionManager::memory();
144 | let review_list = LocalReviewList::new(connection_manager)?;
145 | Ok(Box::new(review_list))
146 | }
147 |
148 | /// Verifies adding and removing units from the review list.
149 | #[test]
150 | fn add_and_remove_from_review_list() -> Result<()> {
151 | let mut review_list = new_test_review_list()?;
152 |
153 | let unit_id = Ustr::from("unit_id");
154 | let unit_id2 = Ustr::from("unit_id2");
155 | review_list.add_to_review_list(unit_id)?;
156 | review_list.add_to_review_list(unit_id)?;
157 | review_list.add_to_review_list(unit_id2)?;
158 |
159 | let entries = review_list.get_review_list_entries()?;
160 | assert_eq!(entries.len(), 2);
161 | assert!(entries.contains(&unit_id));
162 | assert!(entries.contains(&unit_id2));
163 |
164 | review_list.remove_from_review_list(unit_id)?;
165 | let entries = review_list.get_review_list_entries()?;
166 | assert_eq!(entries.len(), 1);
167 | assert!(!entries.contains(&unit_id));
168 | assert!(entries.contains(&unit_id2));
169 | Ok(())
170 | }
171 | }
172 |
--------------------------------------------------------------------------------
/tests/knowledge_base_tests.rs:
--------------------------------------------------------------------------------
1 | //! End-to-end tests for the knowledge base course.
2 |
3 | use std::path::Path;
4 |
5 | use anyhow::Result;
6 | use rand::Rng;
7 | use tempfile::TempDir;
8 | use trane::{
9 | Trane,
10 | course_builder::{
11 | AssetBuilder,
12 | knowledge_base_builder::{CourseBuilder, ExerciseBuilder, LessonBuilder},
13 | },
14 | course_library::CourseLibrary,
15 | data::{
16 | CourseGenerator, CourseManifest, MasteryScore,
17 | course_generator::knowledge_base::{
18 | KnowledgeBaseConfig, KnowledgeBaseExercise, KnowledgeBaseLesson,
19 | },
20 | },
21 | test_utils::TraneSimulation,
22 | };
23 | use ustr::Ustr;
24 |
25 | /// Generates a random number of dependencies for the lesson with the given index. All dependencies
26 | /// will have a lower index to avoid cycles.
27 | fn generate_lesson_dependencies(lesson_index: usize, rng: &mut impl Rng) -> Vec {
28 | let num_dependencies = rng.random_range(0..=lesson_index);
29 | if num_dependencies == 0 {
30 | return vec![];
31 | }
32 |
33 | let mut dependencies = Vec::with_capacity(num_dependencies);
34 | for _ in 0..num_dependencies.min(lesson_index) {
35 | let dependency_id = Ustr::from(&format!("lesson_{}", rng.random_range(0..lesson_index)));
36 | if dependencies.contains(&dependency_id) {
37 | continue;
38 | }
39 | dependencies.push(dependency_id);
40 | }
41 | dependencies
42 | }
43 |
44 | // Build a course with a given number of lessons and exercises per lesson. The dependencies are
45 | // randomly generated.
46 | fn knowledge_base_builder(
47 | directory_name: &str,
48 | course_manifest: CourseManifest,
49 | num_lessons: usize,
50 | num_exercises_per_lesson: usize,
51 | ) -> CourseBuilder {
52 | // Create the required number of lesson builders.
53 | let lessons = (0..num_lessons)
54 | .map(|lesson_index| {
55 | // Create the required number of exercise builders.
56 | let lesson_id = Ustr::from(&format!("lesson_{}", lesson_index));
57 | let exercises = (0..num_exercises_per_lesson)
58 | .map(|exercise_index| {
59 | let front_path = format!("exercise_{}.front.md", exercise_index);
60 | // Let even exercises have a back file and odds have none.
61 | let back_path = if exercise_index % 2 == 0 {
62 | Some(format!("exercise_{}.back.md", exercise_index))
63 | } else {
64 | None
65 | };
66 |
67 | // Create the asset and exercise builders.
68 | let mut asset_builders = vec![AssetBuilder {
69 | file_name: front_path.clone(),
70 | contents: "Front".into(),
71 | }];
72 | if let Some(back_path) = &back_path {
73 | asset_builders.push(AssetBuilder {
74 | file_name: back_path.clone(),
75 | contents: "Back".into(),
76 | });
77 | }
78 | ExerciseBuilder {
79 | exercise: KnowledgeBaseExercise {
80 | short_id: format!("exercise_{}", exercise_index),
81 | short_lesson_id: lesson_id,
82 | course_id: course_manifest.id,
83 | front_file: front_path,
84 | back_file: back_path,
85 | name: None,
86 | description: None,
87 | exercise_type: None,
88 | },
89 | asset_builders,
90 | }
91 | })
92 | .collect();
93 |
94 | // Create the lesson builder.
95 | LessonBuilder {
96 | lesson: KnowledgeBaseLesson {
97 | short_id: lesson_id,
98 | course_id: course_manifest.id,
99 | dependencies: generate_lesson_dependencies(lesson_index, &mut rand::rng()),
100 | superseded: vec![],
101 | name: None,
102 | description: None,
103 | metadata: None,
104 | has_instructions: false,
105 | has_material: false,
106 | },
107 | exercises,
108 | asset_builders: vec![],
109 | }
110 | })
111 | .collect();
112 |
113 | // Create the course builder.
114 | CourseBuilder {
115 | directory_name: directory_name.into(),
116 | lessons,
117 | assets: vec![],
118 | manifest: course_manifest,
119 | }
120 | }
121 |
122 | /// Creates the courses, initializes the Trane library, and returns a Trane instance.
123 | fn init_knowledge_base_simulation(
124 | library_root: &Path,
125 | course_builders: &[CourseBuilder],
126 | ) -> Result {
127 | // Build the courses.
128 | for builder in course_builders {
129 | let course_root = library_root.join(&builder.directory_name);
130 | builder.build(library_root)?;
131 |
132 | // Write a non-lesson directory to the the courses to verify it's skipped.
133 | std::fs::create_dir_all(course_root.join("not_a_lesson_directory"))?;
134 | }
135 |
136 | // Initialize the Trane library.
137 | let trane = Trane::new_local(library_root, library_root)?;
138 | Ok(trane)
139 | }
140 |
141 | // Verifies that generated knowledge base courses can be loaded and all their exercises can be
142 | // reached.
143 | #[test]
144 | fn all_exercises_visited() -> Result<()> {
145 | let course1_builder = knowledge_base_builder(
146 | "course1",
147 | CourseManifest {
148 | id: Ustr::from("course1"),
149 | name: "Course 1".into(),
150 | description: None,
151 | dependencies: vec![],
152 | superseded: vec![],
153 | authors: None,
154 | metadata: None,
155 | course_material: None,
156 | course_instructions: None,
157 | generator_config: Some(CourseGenerator::KnowledgeBase(KnowledgeBaseConfig {})),
158 | },
159 | 10,
160 | 5,
161 | );
162 | let course2_builder = knowledge_base_builder(
163 | "course2",
164 | CourseManifest {
165 | id: Ustr::from("course2"),
166 | name: "Course 2".into(),
167 | description: None,
168 | dependencies: vec!["course1".into()],
169 | superseded: vec![],
170 | authors: None,
171 | metadata: None,
172 | course_material: None,
173 | course_instructions: None,
174 | generator_config: Some(CourseGenerator::KnowledgeBase(KnowledgeBaseConfig {})),
175 | },
176 | 10,
177 | 5,
178 | );
179 |
180 | // Initialize the Trane library.
181 | let temp_dir = TempDir::new()?;
182 | let mut trane =
183 | init_knowledge_base_simulation(temp_dir.path(), &vec![course1_builder, course2_builder])?;
184 |
185 | // Run the simulation.
186 | let exercise_ids = trane.get_all_exercise_ids(None);
187 | assert!(!exercise_ids.is_empty());
188 | let mut simulation = TraneSimulation::new(
189 | exercise_ids.len() * 10,
190 | Box::new(|_| Some(MasteryScore::Five)),
191 | );
192 | simulation.run_simulation(&mut trane, &vec![], &None)?;
193 |
194 | // Find all the exercises in the simulation history. All exercises should be visited.
195 | let visited_exercises = simulation.answer_history.keys().collect::>();
196 | assert_eq!(visited_exercises.len(), exercise_ids.len());
197 | Ok(())
198 | }
199 |
--------------------------------------------------------------------------------
/src/scheduler/reward_propagator.rs:
--------------------------------------------------------------------------------
1 | //! Contains the main logic for propagating rewards through the graph. When an exercise submits a
2 | //! score, Trane uses the score and the unit graph to propagate a reward through the graph. Good
3 | //! scores propagate a positive reward to the dependencies of the exercise, that is to say down the
4 | //! graph. Bad scores propagate a negative reward to the dependents of the exercise, that is to say
5 | //! up the graph. During scheduling of new exercises, previous rewards are used to adjust the score
6 | //! of the exercises.
7 | //!
8 | //! The main goal of the propagation process is twofold. First, it tries to avoid repetition of
9 | //! exercises that have been implicitly mastered by doing harder exercises. Second, it tries to
10 | //! increase the repetition of exercises for which the user has not yet mastered the material that
11 | //! depends on them.
12 |
13 | use std::collections::VecDeque;
14 | use ustr::{Ustr, UstrMap};
15 |
16 | use crate::{
17 | data::{MasteryScore, UnitReward},
18 | scheduler::data::SchedulerData,
19 | };
20 |
21 | /// The minimum absolute value of the reward. Propagation stops when this value is reached.
22 | const MIN_ABS_REWARD: f32 = 0.1;
23 |
24 | /// The initial weight of the rewards.
25 | const INITIAL_WEIGHT: f32 = 1.0;
26 |
27 | /// The minimum weight of the rewards. Once the propagation reaches this weight, it stops.
28 | const MIN_WEIGHT: f32 = 0.2;
29 |
30 | /// The factor by which the weight decreases with each traversal of the graph.
31 | const WEIGHT_FACTOR: f32 = 0.7;
32 |
33 | /// The factor by which the absolute value of the reward decreases with each traversal of the graph.
34 | const DEPTH_FACTOR: f32 = 0.9;
35 |
36 | /// Contains the logic to rewards through the graph when submitting a score.
37 | pub(super) struct RewardPropagator {
38 | /// The external data used by the scheduler. Contains pointers to the graph, blacklist, and
39 | /// course library and provides convenient functions.
40 | pub data: SchedulerData,
41 | }
42 |
43 | impl RewardPropagator {
44 | /// Sets the initial reward for the given score.
45 | fn initial_reward(score: &MasteryScore) -> f32 {
46 | match score {
47 | MasteryScore::Five => 1.0,
48 | MasteryScore::Four => 0.5,
49 | MasteryScore::Three => -0.5,
50 | MasteryScore::Two => -1.0,
51 | MasteryScore::One => -1.5,
52 | }
53 | }
54 |
55 | /// Gets the next units to visit, depending on the sign of the reward.
56 | fn get_next_units(&self, unit_id: Ustr, reward: f32) -> Vec {
57 | if reward > 0.0 {
58 | self.data
59 | .unit_graph
60 | .read()
61 | .get_dependencies(unit_id)
62 | .unwrap_or_default()
63 | .into_iter()
64 | .collect()
65 | } else {
66 | self.data
67 | .unit_graph
68 | .read()
69 | .get_dependents(unit_id)
70 | .unwrap_or_default()
71 | .into_iter()
72 | .collect()
73 | }
74 | }
75 |
76 | /// Returns whether propagation should stop along the path with the given reward and weight.
77 | fn stop_propagation(reward: f32, weigh: f32) -> bool {
78 | reward.abs() < MIN_ABS_REWARD || weigh < MIN_WEIGHT
79 | }
80 |
81 | /// Propagates the given score through the graph.
82 | pub(super) fn propagate_rewards(
83 | &self,
84 | exercise_id: Ustr,
85 | score: &MasteryScore,
86 | timestamp: i64,
87 | ) -> Vec<(Ustr, UnitReward)> {
88 | // Get the lesson and course for this exercise.
89 | let lesson_id = self.data.get_lesson_id(exercise_id).unwrap_or_default();
90 | let course_id = self.data.get_course_id(lesson_id).unwrap_or_default();
91 | if lesson_id.is_empty() || course_id.is_empty() {
92 | return vec![];
93 | }
94 |
95 | // Populate the queue using the course and lesson with the initial reward and weight.
96 | let initial_reward = Self::initial_reward(score);
97 | let next_lessons = self.get_next_units(lesson_id, initial_reward);
98 | let next_courses = self.get_next_units(course_id, initial_reward);
99 | let mut queue: VecDeque<(Ustr, UnitReward)> = VecDeque::new();
100 | next_lessons
101 | .iter()
102 | .chain(next_courses.iter())
103 | .for_each(|id| {
104 | queue.push_back((
105 | *id,
106 | UnitReward {
107 | reward: initial_reward,
108 | weight: INITIAL_WEIGHT,
109 | timestamp,
110 | },
111 | ));
112 | });
113 |
114 | // While the queue is not empty, pop the first element, push it into the results, and
115 | // continue the search with updated rewards and weights.
116 | let mut results = UstrMap::default();
117 | while let Some((unit_id, unit_reward)) = queue.pop_front() {
118 | // Check if propagation should continue and if the unit has already been visited. If
119 | // not, push the unit into the results and continue the search.
120 | if Self::stop_propagation(unit_reward.reward, unit_reward.weight) {
121 | continue;
122 | }
123 | if results.contains_key(&unit_id) {
124 | continue;
125 | }
126 | results.insert(
127 | unit_id,
128 | UnitReward {
129 | reward: unit_reward.reward,
130 | weight: unit_reward.weight,
131 | timestamp,
132 | },
133 | );
134 |
135 | // Get the next units and push them into the queue with updated rewards and weights.
136 | self.get_next_units(unit_id, unit_reward.reward)
137 | .iter()
138 | .for_each(|next_unit_id| {
139 | queue.push_back((
140 | *next_unit_id,
141 | UnitReward {
142 | reward: unit_reward.reward * DEPTH_FACTOR,
143 | weight: unit_reward.weight * WEIGHT_FACTOR,
144 | timestamp,
145 | },
146 | ));
147 | });
148 | }
149 | results.into_iter().collect()
150 | }
151 | }
152 |
153 | #[cfg(test)]
154 | #[cfg_attr(coverage, coverage(off))]
155 | mod test {
156 | use crate::{
157 | data::MasteryScore,
158 | scheduler::reward_propagator::{MIN_ABS_REWARD, MIN_WEIGHT, RewardPropagator},
159 | };
160 |
161 | /// Verifies the initial reward for each score.
162 | #[test]
163 | fn initial_reward() {
164 | assert_eq!(RewardPropagator::initial_reward(&MasteryScore::Five), 1.0);
165 | assert_eq!(RewardPropagator::initial_reward(&MasteryScore::Four), 0.5);
166 | assert_eq!(RewardPropagator::initial_reward(&MasteryScore::Three), -0.5);
167 | assert_eq!(RewardPropagator::initial_reward(&MasteryScore::Two), -1.0);
168 | assert_eq!(RewardPropagator::initial_reward(&MasteryScore::One), -1.5);
169 | }
170 |
171 | /// Verifies stopping the propagation if the reward or weight is too small.
172 | #[test]
173 | fn stop_propagation() {
174 | assert!(!RewardPropagator::stop_propagation(
175 | MIN_ABS_REWARD,
176 | MIN_WEIGHT
177 | ));
178 | assert!(RewardPropagator::stop_propagation(
179 | MIN_ABS_REWARD - 0.001,
180 | MIN_WEIGHT
181 | ));
182 | assert!(RewardPropagator::stop_propagation(
183 | -MIN_ABS_REWARD + 0.001,
184 | MIN_WEIGHT
185 | ));
186 | assert!(RewardPropagator::stop_propagation(
187 | MIN_ABS_REWARD,
188 | MIN_WEIGHT - 0.001
189 | ));
190 | }
191 | }
192 |
--------------------------------------------------------------------------------
/src/study_session_manager.rs:
--------------------------------------------------------------------------------
1 | //! Contains utilities to use study sessions saved by the user.
2 | //!
3 | //! Trane's default mode for scheduling exercises is to traverse the entire graph. Study sessions
4 | //! allow students to traverse specific parts of the graph for the specified amount of time. This
5 | //! module allows them to re-use study sessions they have previously saved.
6 |
7 | use anyhow::{Context, Result, bail};
8 | use std::{collections::HashMap, fs::File, io::BufReader};
9 |
10 | use crate::data::filter::StudySession;
11 |
12 | /// A trait with functions to manage saved study session. Each session is given a unique name to use
13 | /// as an identifier.
14 | pub trait StudySessionManager {
15 | /// Gets the study session with the given ID.
16 | fn get_study_session(&self, id: &str) -> Option;
17 |
18 | /// Returns a list of study session IDs and descriptions.
19 | fn list_study_sessions(&self) -> Vec<(String, String)>;
20 | }
21 |
22 | /// An implementation of [`StudySessionManager`] backed by the local file system.
23 | pub struct LocalStudySessionManager {
24 | /// A map of session IDs to sessions.
25 | pub sessions: HashMap,
26 | }
27 |
28 | impl LocalStudySessionManager {
29 | /// Scans all study sessions in the given directory and returns a map of study sessions.
30 | fn scan_sessions(session_directory: &str) -> Result> {
31 | let mut sessions = HashMap::new();
32 | for entry in std::fs::read_dir(session_directory)
33 | .context("Failed to read study session directory")?
34 | {
35 | // Try to read the file as a `StudySession`.
36 | let entry = entry.context("Failed to read file entry for saved study session")?;
37 | let file = File::open(entry.path()).context(format!(
38 | "Failed to open saved study session file {}",
39 | entry.path().display()
40 | ))?;
41 | let reader = BufReader::new(file);
42 | let session: StudySession = serde_json::from_reader(reader).context(format!(
43 | "Failed to parse study session from {}",
44 | entry.path().display()
45 | ))?;
46 |
47 | // Check for duplicate IDs before inserting the study session.
48 | if sessions.contains_key(&session.id) {
49 | bail!("Found multiple study sessions with ID {}", session.id);
50 | }
51 | sessions.insert(session.id.clone(), session);
52 | }
53 | Ok(sessions)
54 | }
55 |
56 | /// Creates a new `LocalStudySessionManager`.
57 | pub fn new(session_directory: &str) -> Result {
58 | Ok(LocalStudySessionManager {
59 | sessions: LocalStudySessionManager::scan_sessions(session_directory)?,
60 | })
61 | }
62 | }
63 |
64 | impl StudySessionManager for LocalStudySessionManager {
65 | fn get_study_session(&self, id: &str) -> Option {
66 | self.sessions.get(id).cloned()
67 | }
68 |
69 | fn list_study_sessions(&self) -> Vec<(String, String)> {
70 | // Create a list of (ID, description) pairs.
71 | let mut sessions: Vec<(String, String)> = self
72 | .sessions
73 | .iter()
74 | .map(|(id, session)| (id.clone(), session.description.clone()))
75 | .collect();
76 |
77 | // Sort the session by their IDs.
78 | sessions.sort_by(|a, b| a.0.cmp(&b.0));
79 | sessions
80 | }
81 | }
82 |
83 | #[cfg(test)]
84 | #[cfg_attr(coverage, coverage(off))]
85 | mod test {
86 | use anyhow::{Ok, Result};
87 | use std::{os::unix::prelude::PermissionsExt, path::Path};
88 | use tempfile::TempDir;
89 |
90 | use crate::{
91 | data::filter::StudySession,
92 | study_session_manager::{LocalStudySessionManager, StudySessionManager},
93 | };
94 |
95 | /// Creates some study sessions for testing.
96 | fn test_sessions() -> Vec {
97 | vec![
98 | StudySession {
99 | id: "session1".into(),
100 | description: "Session 1".into(),
101 | parts: vec![],
102 | },
103 | StudySession {
104 | id: "session2".into(),
105 | description: "Session 2".into(),
106 | parts: vec![],
107 | },
108 | ]
109 | }
110 |
111 | /// Writes the sessions to the given directory.
112 | fn write_sessions(sessions: Vec, dir: &Path) -> Result<()> {
113 | for session in sessions {
114 | // Give each file a unique name.
115 | let timestamp_ns = chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0);
116 | let session_path = dir.join(format!("{}_{}.json", session.id, timestamp_ns));
117 | let session_json = serde_json::to_string(&session)?;
118 | std::fs::write(session_path, session_json)?;
119 | }
120 | Ok(())
121 | }
122 |
123 | /// Verifies creating a study session manager with valid sessions.
124 | #[test]
125 | fn session_manager() -> Result<()> {
126 | let temp_dir = TempDir::new()?;
127 | let sessions = test_sessions();
128 | write_sessions(sessions.clone(), temp_dir.path())?;
129 | let manager = LocalStudySessionManager::new(temp_dir.path().to_str().unwrap())?;
130 |
131 | let session_list = manager.list_study_sessions();
132 | assert_eq!(
133 | session_list,
134 | vec![
135 | ("session1".to_string(), "Session 1".to_string()),
136 | ("session2".to_string(), "Session 2".to_string())
137 | ]
138 | );
139 |
140 | for (index, (id, _)) in session_list.iter().enumerate() {
141 | let session = manager.get_study_session(id);
142 | assert!(session.is_some());
143 | let session = session.unwrap();
144 | assert_eq!(sessions[index], session);
145 | }
146 | Ok(())
147 | }
148 |
149 | /// Verifies that sessions with repeated IDs cause the study session manager to fail.
150 | #[test]
151 | fn sessions_repeated_ids() -> Result<()> {
152 | let sessions = vec![
153 | StudySession {
154 | id: "session1".into(),
155 | description: "Session 1".into(),
156 | parts: vec![],
157 | },
158 | StudySession {
159 | id: "session1".into(),
160 | description: "Session 2".into(),
161 | parts: vec![],
162 | },
163 | ];
164 |
165 | let temp_dir = TempDir::new()?;
166 | write_sessions(sessions.clone(), temp_dir.path())?;
167 | assert!(LocalStudySessionManager::new(temp_dir.path().to_str().unwrap()).is_err());
168 | Ok(())
169 | }
170 |
171 | /// Verifies that trying to read study sessions from an invalid directory fails.
172 | #[test]
173 | fn read_bad_directory() -> Result<()> {
174 | assert!(LocalStudySessionManager::new("bad_directory").is_err());
175 | Ok(())
176 | }
177 |
178 | /// Verifies that study sessions in an invalid format cause the study session manager to fail.
179 | #[test]
180 | fn read_bad_file_format() -> Result<()> {
181 | let temp_dir = TempDir::new()?;
182 | let bad_file = temp_dir.path().join("bad_file.json");
183 | std::fs::write(bad_file, "bad json")?;
184 | assert!(LocalStudySessionManager::new(temp_dir.path().to_str().unwrap()).is_err());
185 | Ok(())
186 | }
187 |
188 | /// Verifies that sessions with bad permissions cause the study session manager to fail.
189 | #[test]
190 | fn read_bad_file_permissions() -> Result<()> {
191 | let temp_dir = TempDir::new()?;
192 | let bad_file = temp_dir.path().join("bad_file.json");
193 | std::fs::write(bad_file.clone(), "bad json")?;
194 | std::fs::set_permissions(bad_file, std::fs::Permissions::from_mode(0o000))?;
195 | assert!(LocalStudySessionManager::new(temp_dir.path().to_str().unwrap()).is_err());
196 | Ok(())
197 | }
198 | }
199 |
--------------------------------------------------------------------------------
/src/filter_manager.rs:
--------------------------------------------------------------------------------
1 | //! Contains utilities to use filters saved by the user.
2 | //!
3 | //! Trane's default mode for scheduling exercises is to traverse the entire graph. Sometimes,
4 | //! students want to only schedule exercises from a subset of the graph. This module allows them to
5 | //! re-use filters they have previously saved.
6 |
7 | use anyhow::{Context, Result, bail};
8 | use std::{collections::HashMap, fs::File, io::BufReader};
9 |
10 | use crate::data::filter::SavedFilter;
11 |
12 | /// A trait with functions to manage saved filters. Each filter is given a unique name to use as an
13 | /// identifier and contains a `UnitFilter`.
14 | pub trait FilterManager {
15 | /// Gets the filter with the given ID.
16 | fn get_filter(&self, id: &str) -> Option;
17 |
18 | /// Returns a list of filter IDs and descriptions.
19 | fn list_filters(&self) -> Vec<(String, String)>;
20 | }
21 |
22 | /// An implementation of [`FilterManager`] backed by the local file system.
23 | pub struct LocalFilterManager {
24 | /// A map of filter IDs to filters.
25 | pub filters: HashMap,
26 | }
27 |
28 | impl LocalFilterManager {
29 | /// Scans all `NamedFilters` in the given directory and returns a map of filters.
30 | fn scan_filters(filter_directory: &str) -> Result> {
31 | let mut filters = HashMap::new();
32 | for entry in
33 | std::fs::read_dir(filter_directory).context("Failed to read filter directory")?
34 | {
35 | // Try to read the file as a `NamedFilter`.
36 | let entry = entry.context("Failed to read saved filter entry")?;
37 | let file = File::open(entry.path()).context(format!(
38 | "Failed to open saved filter file {}",
39 | entry.path().display()
40 | ))?;
41 | let reader = BufReader::new(file);
42 | let filter: SavedFilter = serde_json::from_reader(reader).context(format!(
43 | "Failed to parse named filter from {}",
44 | entry.path().display()
45 | ))?;
46 |
47 | // Check for duplicate IDs before inserting the filter.
48 | if filters.contains_key(&filter.id) {
49 | bail!("Found multiple filters with ID {}", filter.id);
50 | }
51 | filters.insert(filter.id.clone(), filter);
52 | }
53 | Ok(filters)
54 | }
55 |
56 | /// Creates a new `LocalFilterManager`.
57 | pub fn new(filter_directory: &str) -> Result {
58 | Ok(LocalFilterManager {
59 | filters: LocalFilterManager::scan_filters(filter_directory)?,
60 | })
61 | }
62 | }
63 |
64 | impl FilterManager for LocalFilterManager {
65 | fn get_filter(&self, id: &str) -> Option {
66 | self.filters.get(id).cloned()
67 | }
68 |
69 | fn list_filters(&self) -> Vec<(String, String)> {
70 | // Create a list of (ID, description) pairs.
71 | let mut filters: Vec<(String, String)> = self
72 | .filters
73 | .iter()
74 | .map(|(id, filter)| (id.clone(), filter.description.clone()))
75 | .collect();
76 |
77 | // Sort the filters by their IDs.
78 | filters.sort_by(|a, b| a.0.cmp(&b.0));
79 | filters
80 | }
81 | }
82 |
83 | #[cfg(test)]
84 | #[cfg_attr(coverage, coverage(off))]
85 | mod test {
86 | use anyhow::{Ok, Result};
87 | use std::{os::unix::prelude::PermissionsExt, path::Path};
88 | use tempfile::TempDir;
89 | use ustr::Ustr;
90 |
91 | use crate::{
92 | data::filter::{FilterOp, FilterType, KeyValueFilter, SavedFilter, UnitFilter},
93 | filter_manager::FilterManager,
94 | };
95 |
96 | use super::LocalFilterManager;
97 |
98 | /// Creates some unit filters for testing.
99 | fn test_filters() -> Vec {
100 | vec![
101 | SavedFilter {
102 | id: "filter1".to_string(),
103 | description: "Filter 1".to_string(),
104 | filter: UnitFilter::CourseFilter {
105 | course_ids: vec![Ustr::from("course1")],
106 | },
107 | },
108 | SavedFilter {
109 | id: "filter2".to_string(),
110 | description: "Filter 2".to_string(),
111 | filter: UnitFilter::MetadataFilter {
112 | filter: KeyValueFilter::CombinedFilter {
113 | op: FilterOp::All,
114 | filters: vec![
115 | KeyValueFilter::LessonFilter {
116 | key: "key1".to_string(),
117 | value: "value1".to_string(),
118 | filter_type: FilterType::Include,
119 | },
120 | KeyValueFilter::CombinedFilter {
121 | op: FilterOp::Any,
122 | filters: vec![
123 | KeyValueFilter::CourseFilter {
124 | key: "key2".to_string(),
125 | value: "value2".to_string(),
126 | filter_type: FilterType::Include,
127 | },
128 | KeyValueFilter::CourseFilter {
129 | key: "key3".to_string(),
130 | value: "value3".to_string(),
131 | filter_type: FilterType::Include,
132 | },
133 | ],
134 | },
135 | ],
136 | },
137 | },
138 | },
139 | ]
140 | }
141 |
142 | /// Writes the filters to the given directory.
143 | fn write_filters(filters: Vec, dir: &Path) -> Result<()> {
144 | for filter in filters {
145 | // Give each file a unique name.
146 | let timestamp_ns = chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0);
147 | let filter_path = dir.join(format!("{}_{}.json", filter.id, timestamp_ns));
148 | let filter_json = serde_json::to_string(&filter)?;
149 | std::fs::write(filter_path, filter_json)?;
150 | }
151 | Ok(())
152 | }
153 |
154 | /// Verifies creating a filter manager with valid filters.
155 | #[test]
156 | fn filter_manager() -> Result<()> {
157 | let temp_dir = TempDir::new()?;
158 | let filters = test_filters();
159 | write_filters(filters.clone(), temp_dir.path())?;
160 | let manager = LocalFilterManager::new(temp_dir.path().to_str().unwrap())?;
161 |
162 | let filter_list = manager.list_filters();
163 | assert_eq!(
164 | filter_list,
165 | vec![
166 | ("filter1".to_string(), "Filter 1".to_string()),
167 | ("filter2".to_string(), "Filter 2".to_string())
168 | ]
169 | );
170 |
171 | for (index, (id, _)) in filter_list.iter().enumerate() {
172 | let filter = manager.get_filter(id);
173 | assert!(filter.is_some());
174 | let filter = filter.unwrap();
175 | assert_eq!(filters[index], filter);
176 | }
177 | Ok(())
178 | }
179 |
180 | /// Verifies that filters with repeated IDs cause the filter manager to fail.
181 | #[test]
182 | fn filters_repeated_ids() -> Result<()> {
183 | let filters = vec![
184 | SavedFilter {
185 | id: "filter1".to_string(),
186 | description: "Filter 1".to_string(),
187 | filter: UnitFilter::CourseFilter {
188 | course_ids: vec![Ustr::from("course1")],
189 | },
190 | },
191 | SavedFilter {
192 | id: "filter1".to_string(),
193 | description: "Filter 1".to_string(),
194 | filter: UnitFilter::LessonFilter {
195 | lesson_ids: vec![Ustr::from("lesson1")],
196 | },
197 | },
198 | SavedFilter {
199 | id: "filter1".to_string(),
200 | description: "Filter 1".to_string(),
201 | filter: UnitFilter::ReviewListFilter,
202 | },
203 | ];
204 |
205 | let temp_dir = TempDir::new()?;
206 | write_filters(filters.clone(), temp_dir.path())?;
207 | assert!(LocalFilterManager::new(temp_dir.path().to_str().unwrap()).is_err());
208 | Ok(())
209 | }
210 |
211 | /// Verifies that trying to read filters from an invalid directory fails.
212 | #[test]
213 | fn read_bad_directory() -> Result<()> {
214 | assert!(LocalFilterManager::new("bad_directory").is_err());
215 | Ok(())
216 | }
217 |
218 | /// Verifies that filters in an invalid format cause the filter manager to fail.
219 | #[test]
220 | fn read_bad_file_format() -> Result<()> {
221 | let temp_dir = TempDir::new()?;
222 | let bad_file = temp_dir.path().join("bad_file.json");
223 | std::fs::write(bad_file, "bad json")?;
224 | assert!(LocalFilterManager::new(temp_dir.path().to_str().unwrap()).is_err());
225 | Ok(())
226 | }
227 |
228 | /// Verifies that filters with bad permissions cause the filter manager to fail.
229 | #[test]
230 | fn read_bad_file_permissions() -> Result<()> {
231 | let temp_dir = TempDir::new()?;
232 | let bad_file = temp_dir.path().join("bad_file.json");
233 | std::fs::write(bad_file.clone(), "bad json")?;
234 | std::fs::set_permissions(bad_file, std::fs::Permissions::from_mode(0o000))?;
235 | assert!(LocalFilterManager::new(temp_dir.path().to_str().unwrap()).is_err());
236 | Ok(())
237 | }
238 | }
239 |
--------------------------------------------------------------------------------
/src/reward_scorer.rs:
--------------------------------------------------------------------------------
1 | //! Rewards are propagated in the graph when a score is submitted to reflect performance of an
2 | //! exercise on related ones. This module contains the logic that combines into a single value that
3 | //! is then added to the score of an exercise that is computed from previous trials alone. The final
4 | //! value can be positive or negative.
5 |
6 | use anyhow::Result;
7 | use chrono::{TimeZone, Utc};
8 |
9 | use crate::data::UnitReward;
10 |
11 | /// A trait exposing a function to combine the rewards of a unit into a single value. The lesson
12 | /// and course rewards are given separately to allow the implementation to treat them differently.
13 | pub trait RewardScorer {
14 | /// Computes the final reward for a unit based on its previous course and lesson rewards.
15 | fn score_rewards(
16 | &self,
17 | previous_course_rewards: &[UnitReward],
18 | previous_lesson_rewards: &[UnitReward],
19 | ) -> Result;
20 | }
21 |
22 | /// The absolute value of the reward decreases by this amount each day to avoid old rewards from
23 | /// affecting the score indefinitely.
24 | const DAY_ADJUSTMENT: f32 = 0.025;
25 |
26 | /// The weight of the course rewards in the final score.
27 | const COURSE_REWARDS_WEIGHT: f32 = 0.3;
28 |
29 | /// The weight of the lesson rewards in the final score. Lesson rewards are given more weight than
30 | /// course rewards because lessons are more granular and related to the specific exercise.
31 | const LESSON_REWARDS_WEIGHT: f32 = 0.7;
32 |
33 | /// A simple implementation of the [`RewardScorer`] trait that computes a weighted average of the
34 | /// rewards.
35 | pub struct WeightedRewardScorer {}
36 |
37 | impl WeightedRewardScorer {
38 | /// Returns the number of days since the reward.
39 | #[inline]
40 | fn days_since(rewards: &[UnitReward]) -> Vec {
41 | rewards
42 | .iter()
43 | .map(|reward| {
44 | let now = Utc::now();
45 | let timestamp = Utc
46 | .timestamp_opt(reward.timestamp, 0)
47 | .earliest()
48 | .unwrap_or_default();
49 | (now - timestamp).num_days() as f32
50 | })
51 | .collect()
52 | }
53 |
54 | /// Returns the weights of the rewards.
55 | #[inline]
56 | fn reward_weights(rewards: &[UnitReward]) -> Vec {
57 | rewards.iter().map(|reward| reward.weight).collect()
58 | }
59 |
60 | /// Returns the adjusted rewards based on the number of days since the reward.
61 | #[inline]
62 | fn adjusted_rewards(rewards: &[UnitReward]) -> Vec {
63 | let days = Self::days_since(rewards);
64 | rewards
65 | .iter()
66 | .zip(days.iter())
67 | .map(|(reward, day)| {
68 | if reward.reward >= 0.0 {
69 | (reward.reward - (day * DAY_ADJUSTMENT)).max(0.0)
70 | } else {
71 | (reward.reward + (day * DAY_ADJUSTMENT)).min(0.0)
72 | }
73 | })
74 | .collect()
75 | }
76 |
77 | /// Returns the weighted average of the scores.
78 | #[inline]
79 | fn weighted_average(rewards: &[f32], weights: &[f32]) -> f32 {
80 | // weighted average = (cross product of scores and their weights) / (sum of weights)
81 | let cross_product: f32 = rewards
82 | .iter()
83 | .zip(weights.iter())
84 | .map(|(s, w)| s * *w)
85 | .sum();
86 | let weight_sum = weights.iter().sum::();
87 | if weight_sum == 0.0 {
88 | 0.0
89 | } else {
90 | cross_product / weight_sum
91 | }
92 | }
93 | }
94 |
95 | impl RewardScorer for WeightedRewardScorer {
96 | fn score_rewards(
97 | &self,
98 | previous_course_rewards: &[UnitReward],
99 | previous_lesson_rewards: &[UnitReward],
100 | ) -> Result {
101 | // Compute the lesson and course scores separately.
102 | let course_score = Self::weighted_average(
103 | &Self::adjusted_rewards(previous_course_rewards),
104 | &Self::reward_weights(previous_course_rewards),
105 | );
106 | let lesson_score = Self::weighted_average(
107 | &Self::adjusted_rewards(previous_lesson_rewards),
108 | &Self::reward_weights(previous_lesson_rewards),
109 | );
110 |
111 | // Calculate the final value, depending on which rewards are present.
112 | if previous_course_rewards.is_empty() && previous_lesson_rewards.is_empty() {
113 | Ok(0.0)
114 | } else if previous_course_rewards.is_empty() {
115 | Ok(lesson_score)
116 | } else if previous_lesson_rewards.is_empty() {
117 | Ok(course_score)
118 | } else {
119 | // If there are both course and lesson rewards, compute the lesson and course scores
120 | // separately and then combine them into a single score using another weighted average.
121 | Ok(Self::weighted_average(
122 | &[course_score, lesson_score],
123 | &[COURSE_REWARDS_WEIGHT, LESSON_REWARDS_WEIGHT],
124 | ))
125 | }
126 | }
127 | }
128 |
129 | #[cfg(test)]
130 | #[cfg_attr(coverage, coverage(off))]
131 | mod test {
132 | use chrono::Utc;
133 |
134 | use crate::{
135 | data::UnitReward,
136 | reward_scorer::{RewardScorer, WeightedRewardScorer},
137 | };
138 |
139 | const SECONDS_IN_DAY: i64 = 60 * 60 * 24;
140 |
141 | /// Generates a timestamp equal to the timestamp from `num_days` ago.
142 | fn generate_timestamp(num_days: i64) -> i64 {
143 | let now = Utc::now().timestamp();
144 | now - num_days * SECONDS_IN_DAY
145 | }
146 |
147 | /// Verifies adjusting the reward value based on the number of days since the reward.
148 | #[test]
149 | fn test_adjusted_rewards() {
150 | // Recent rewards still have some value.
151 | let rewards = vec![
152 | UnitReward {
153 | reward: 1.0,
154 | weight: 1.0,
155 | timestamp: generate_timestamp(1),
156 | },
157 | UnitReward {
158 | reward: -1.0,
159 | weight: 1.0,
160 | timestamp: generate_timestamp(1),
161 | },
162 | ];
163 | let adjusted_rewards = WeightedRewardScorer::adjusted_rewards(&rewards);
164 | assert_eq!(adjusted_rewards, vec![0.975, -0.975]);
165 |
166 | // The absolute value of older rewards trends to zero.
167 | let rewards = vec![
168 | UnitReward {
169 | reward: 1.0,
170 | weight: 1.0,
171 | timestamp: 1,
172 | },
173 | UnitReward {
174 | reward: -1.0,
175 | weight: 1.0,
176 | timestamp: 1,
177 | },
178 | ];
179 | let adjusted_rewards = WeightedRewardScorer::adjusted_rewards(&rewards);
180 | assert_eq!(adjusted_rewards, vec![0.0, 0.0]);
181 | }
182 |
183 | /// Verifies calculating the reward when no rewards are present.
184 | #[test]
185 | fn test_no_rewards() {
186 | let scorer = WeightedRewardScorer {};
187 | let result = scorer.score_rewards(&[], &[]).unwrap();
188 | assert_eq!(result, 0.0);
189 | }
190 |
191 | /// Verifies calculating the reward when only lesson rewards are present.
192 | #[test]
193 | fn test_only_lesson_rewards() {
194 | let scorer = WeightedRewardScorer {};
195 | let lesson_rewards = vec![
196 | UnitReward {
197 | reward: 1.0,
198 | weight: 1.0,
199 | timestamp: generate_timestamp(1),
200 | },
201 | UnitReward {
202 | reward: 2.0,
203 | weight: 1.0,
204 | timestamp: generate_timestamp(2),
205 | },
206 | ];
207 | let result = scorer.score_rewards(&[], &lesson_rewards).unwrap();
208 | assert!((result - 1.462).abs() < 0.001);
209 | }
210 |
211 | /// Verifies calculating the reward when only course rewards are present.
212 | #[test]
213 | fn test_only_course_rewards() {
214 | let scorer = WeightedRewardScorer {};
215 | let course_rewards = vec![
216 | UnitReward {
217 | reward: 1.0,
218 | weight: 1.0,
219 | timestamp: generate_timestamp(1),
220 | },
221 | UnitReward {
222 | reward: 2.0,
223 | weight: 1.0,
224 | timestamp: generate_timestamp(2),
225 | },
226 | ];
227 | let result = scorer.score_rewards(&course_rewards, &[]).unwrap();
228 | assert!((result - 1.462).abs() < 0.001);
229 | }
230 |
231 | /// Verifies calculating the reward when both course and lesson rewards are present.
232 | #[test]
233 | fn test_both_rewards() {
234 | let scorer = WeightedRewardScorer {};
235 | let course_rewards = vec![
236 | UnitReward {
237 | reward: 1.0,
238 | weight: 1.0,
239 | timestamp: generate_timestamp(1),
240 | },
241 | UnitReward {
242 | reward: 2.0,
243 | weight: 1.0,
244 | timestamp: generate_timestamp(2),
245 | },
246 | ];
247 | let lesson_rewards = vec![
248 | UnitReward {
249 | reward: 2.0,
250 | weight: 1.0,
251 | timestamp: generate_timestamp(1),
252 | },
253 | UnitReward {
254 | reward: 4.0,
255 | weight: 2.0,
256 | timestamp: generate_timestamp(2),
257 | },
258 | ];
259 | let result = scorer
260 | .score_rewards(&course_rewards, &lesson_rewards)
261 | .unwrap();
262 | assert!((result - 2.742).abs() < 0.001);
263 | }
264 |
265 | /// Verifies calculating the reward when the weight is below the minimum weight.
266 | #[test]
267 | fn test_min_weight() {
268 | let scorer = WeightedRewardScorer {};
269 | let lesson_rewards = vec![
270 | UnitReward {
271 | reward: 2.0,
272 | weight: 1.0,
273 | timestamp: generate_timestamp(0),
274 | },
275 | UnitReward {
276 | reward: 1.0,
277 | weight: 0.0001,
278 | timestamp: generate_timestamp(0) - 1,
279 | },
280 | ];
281 | let result = scorer.score_rewards(&[], &lesson_rewards).unwrap();
282 | assert!((result - 1.999).abs() < 0.001);
283 | }
284 | }
285 |
--------------------------------------------------------------------------------
/tests/music_piece_tests.rs:
--------------------------------------------------------------------------------
1 | //! End-to-end tests to verify that the music piece course generator works as expected.
2 |
3 | use anyhow::Result;
4 | use std::sync::LazyLock;
5 | use tempfile::TempDir;
6 | use trane::{
7 | course_builder::{AssetBuilder, CourseBuilder},
8 | course_library::CourseLibrary,
9 | data::{
10 | CourseGenerator, CourseManifest, LessonManifestBuilder, MasteryScore,
11 | course_generator::music_piece::{MusicAsset, MusicPassage, MusicPieceConfig},
12 | },
13 | test_utils::{TraneSimulation, assert_simulation_scores, init_simulation},
14 | };
15 | use ustr::Ustr;
16 |
17 | static COURSE_ID: LazyLock = LazyLock::new(|| Ustr::from("trane::test::music_piece_course"));
18 | static SOUNDSLICE_MUSIC_ASSET: LazyLock =
19 | LazyLock::new(|| MusicAsset::SoundSlice("soundslice_link".to_string()));
20 | static LOCAL_MUSIC_ASSET: LazyLock =
21 | LazyLock::new(|| MusicAsset::LocalFile("music_sheet.pdf".to_string()));
22 | static COMPLEX_PASSAGE: LazyLock = LazyLock::new(|| TestPassage {
23 | sub_passages: vec![
24 | TestPassage {
25 | sub_passages: vec![
26 | TestPassage {
27 | sub_passages: vec![
28 | TestPassage {
29 | sub_passages: vec![],
30 | },
31 | TestPassage {
32 | sub_passages: vec![],
33 | },
34 | ],
35 | },
36 | TestPassage {
37 | sub_passages: vec![
38 | TestPassage {
39 | sub_passages: vec![],
40 | },
41 | TestPassage {
42 | sub_passages: vec![],
43 | },
44 | TestPassage {
45 | sub_passages: vec![],
46 | },
47 | ],
48 | },
49 | ],
50 | },
51 | TestPassage {
52 | sub_passages: vec![
53 | TestPassage {
54 | sub_passages: vec![
55 | TestPassage {
56 | sub_passages: vec![],
57 | },
58 | TestPassage {
59 | sub_passages: vec![],
60 | },
61 | TestPassage {
62 | sub_passages: vec![],
63 | },
64 | TestPassage {
65 | sub_passages: vec![],
66 | },
67 | ],
68 | },
69 | TestPassage {
70 | sub_passages: vec![
71 | TestPassage {
72 | sub_passages: vec![],
73 | },
74 | TestPassage {
75 | sub_passages: vec![],
76 | },
77 | ],
78 | },
79 | TestPassage {
80 | sub_passages: vec![
81 | TestPassage {
82 | sub_passages: vec![],
83 | },
84 | TestPassage {
85 | sub_passages: vec![],
86 | },
87 | ],
88 | },
89 | ],
90 | },
91 | ],
92 | });
93 |
94 | /// A simpler representation of a music passage for testing.
95 | #[derive(Clone)]
96 | struct TestPassage {
97 | sub_passages: Vec,
98 | }
99 |
100 | impl From for MusicPassage {
101 | fn from(test_passage: TestPassage) -> Self {
102 | MusicPassage {
103 | start: "passage start".to_string(),
104 | end: "passage end".to_string(),
105 | sub_passages: test_passage
106 | .sub_passages
107 | .into_iter()
108 | .enumerate()
109 | .map(|(index, passage)| (index, MusicPassage::from(passage)))
110 | .collect(),
111 | }
112 | }
113 | }
114 |
115 | /// Returns a course builder with a music piece course generator.
116 | fn music_piece_builder(
117 | course_id: Ustr,
118 | course_index: usize,
119 | music_asset: MusicAsset,
120 | passages: MusicPassage,
121 | ) -> CourseBuilder {
122 | // If the music asset is a local file, generate its corresponding asset builder.
123 | let asset_builders = if let MusicAsset::LocalFile(path) = &music_asset {
124 | vec![AssetBuilder {
125 | file_name: path.clone(),
126 | contents: "music sheet contents".to_string(),
127 | }]
128 | } else {
129 | vec![]
130 | };
131 |
132 | CourseBuilder {
133 | directory_name: format!("improv_course_{}", course_index),
134 | course_manifest: CourseManifest {
135 | id: course_id,
136 | name: format!("Course {}", course_id),
137 | dependencies: vec![],
138 | superseded: vec![],
139 | description: None,
140 | authors: None,
141 | metadata: None,
142 | course_material: None,
143 | course_instructions: None,
144 | generator_config: Some(CourseGenerator::MusicPiece(MusicPieceConfig {
145 | music_asset,
146 | passages,
147 | })),
148 | },
149 | lesson_manifest_template: LessonManifestBuilder::default().clone(),
150 | lesson_builders: vec![],
151 | asset_builders,
152 | }
153 | }
154 |
155 | /// Verifies that all music piece exercises are visited with a simple passage and a local file.
156 | #[test]
157 | fn all_exercises_visited_simple_local() -> Result<()> {
158 | // Initialize test course library.
159 | let temp_dir = TempDir::new()?;
160 | let passages = TestPassage {
161 | sub_passages: vec![],
162 | };
163 | let mut trane = init_simulation(
164 | temp_dir.path(),
165 | &[music_piece_builder(
166 | *COURSE_ID,
167 | 0,
168 | LOCAL_MUSIC_ASSET.clone(),
169 | MusicPassage::from(passages),
170 | )],
171 | None,
172 | )?;
173 |
174 | // Run the simulation.
175 | let exercise_ids = trane.get_all_exercise_ids(None);
176 | assert!(!exercise_ids.is_empty());
177 | let mut simulation = TraneSimulation::new(
178 | exercise_ids.len() * 5,
179 | Box::new(|_| Some(MasteryScore::Five)),
180 | );
181 | simulation.run_simulation(&mut trane, &vec![], &None)?;
182 |
183 | // Every exercise ID should be in `simulation.answer_history`.
184 | for exercise_id in exercise_ids {
185 | assert!(
186 | simulation.answer_history.contains_key(&exercise_id),
187 | "exercise {:?} should have been scheduled",
188 | exercise_id
189 | );
190 | assert_simulation_scores(exercise_id, &trane, &simulation.answer_history)?;
191 | }
192 | Ok(())
193 | }
194 |
195 | /// Verifies that all music piece exercises are visited with a simple passage and a SoundSlice
196 | /// asset.
197 | #[test]
198 | fn all_exercises_visited_simple_soundslice() -> Result<()> {
199 | // Initialize test course library.
200 | let temp_dir = TempDir::new()?;
201 | let passages = TestPassage {
202 | sub_passages: vec![],
203 | };
204 | let mut trane = init_simulation(
205 | temp_dir.path(),
206 | &[music_piece_builder(
207 | *COURSE_ID,
208 | 0,
209 | SOUNDSLICE_MUSIC_ASSET.clone(),
210 | MusicPassage::from(passages),
211 | )],
212 | None,
213 | )?;
214 |
215 | // Run the simulation.
216 | let exercise_ids = trane.get_all_exercise_ids(None);
217 | assert!(!exercise_ids.is_empty());
218 | let mut simulation = TraneSimulation::new(
219 | exercise_ids.len() * 5,
220 | Box::new(|_| Some(MasteryScore::Five)),
221 | );
222 | simulation.run_simulation(&mut trane, &vec![], &None)?;
223 |
224 | // Every exercise ID should be in `simulation.answer_history`.
225 | for exercise_id in exercise_ids {
226 | assert!(
227 | simulation.answer_history.contains_key(&exercise_id),
228 | "exercise {:?} should have been scheduled",
229 | exercise_id
230 | );
231 | assert_simulation_scores(exercise_id, &trane, &simulation.answer_history)?;
232 | }
233 | Ok(())
234 | }
235 |
236 | /// Verifies that all music piece exercises are visited for a music piece with sub-passages.
237 | #[test]
238 | fn all_exercises_visited_complex() -> Result<()> {
239 | // Initialize test course library.
240 | let temp_dir = TempDir::new()?;
241 | let mut trane = init_simulation(
242 | temp_dir.path(),
243 | &[music_piece_builder(
244 | *COURSE_ID,
245 | 0,
246 | SOUNDSLICE_MUSIC_ASSET.clone(),
247 | MusicPassage::from(COMPLEX_PASSAGE.clone()),
248 | )],
249 | None,
250 | )?;
251 |
252 | // Run the simulation.
253 | let exercise_ids = trane.get_all_exercise_ids(None);
254 | assert!(!exercise_ids.is_empty());
255 | let mut simulation = TraneSimulation::new(
256 | exercise_ids.len() * 5,
257 | Box::new(|_| Some(MasteryScore::Five)),
258 | );
259 | simulation.run_simulation(&mut trane, &vec![], &None)?;
260 |
261 | // Every exercise ID should be in `simulation.answer_history`.
262 | for exercise_id in exercise_ids {
263 | assert!(
264 | simulation.answer_history.contains_key(&exercise_id),
265 | "exercise {:?} should have been scheduled",
266 | exercise_id
267 | );
268 | assert_simulation_scores(exercise_id, &trane, &simulation.answer_history)?;
269 | }
270 | Ok(())
271 | }
272 |
273 | /// Verifies that not all the exercises are visited when no progress is made with a complex passage.
274 | #[test]
275 | fn no_progress_complex() -> Result<()> {
276 | // Initialize test course library.
277 | let temp_dir = TempDir::new()?;
278 | let mut trane = init_simulation(
279 | temp_dir.path(),
280 | &[music_piece_builder(
281 | *COURSE_ID,
282 | 0,
283 | SOUNDSLICE_MUSIC_ASSET.clone(),
284 | MusicPassage::from(COMPLEX_PASSAGE.clone()),
285 | )],
286 | None,
287 | )?;
288 |
289 | // Run the simulation.
290 | let exercise_ids = trane.get_all_exercise_ids(None);
291 | assert!(!exercise_ids.is_empty());
292 | let mut simulation = TraneSimulation::new(
293 | exercise_ids.len() * 5,
294 | Box::new(|_| Some(MasteryScore::One)),
295 | );
296 | simulation.run_simulation(&mut trane, &vec![], &None)?;
297 |
298 | // Find all the exercises in the simulation history. There should be less than the total number
299 | // of exercises.
300 | let visited_exercises = simulation.answer_history.keys().collect::>();
301 | assert!(visited_exercises.len() < exercise_ids.len());
302 | Ok(())
303 | }
304 |
305 | /// Verifies that all the exercises are visited when no progress is made with a simple passage,
306 | /// since there is only one exercise in the course.
307 | #[test]
308 | fn no_progress_simple() -> Result<()> {
309 | // Initialize test course library.
310 | let temp_dir = TempDir::new()?;
311 | let passages = TestPassage {
312 | sub_passages: vec![],
313 | };
314 | let mut trane = init_simulation(
315 | temp_dir.path(),
316 | &[music_piece_builder(
317 | *COURSE_ID,
318 | 0,
319 | SOUNDSLICE_MUSIC_ASSET.clone(),
320 | MusicPassage::from(passages),
321 | )],
322 | None,
323 | )?;
324 |
325 | // Run the simulation.
326 | let exercise_ids = trane.get_all_exercise_ids(None);
327 | assert!(!exercise_ids.is_empty());
328 | let mut simulation = TraneSimulation::new(
329 | exercise_ids.len() * 5,
330 | Box::new(|_| Some(MasteryScore::One)),
331 | );
332 | simulation.run_simulation(&mut trane, &vec![], &None)?;
333 |
334 | // Find all the exercises in the simulation history. Given that there's only one exercise, it
335 | // has been visited.
336 | let visited_exercises = simulation.answer_history.keys().collect::>();
337 | assert_eq!(visited_exercises.len(), exercise_ids.len());
338 | Ok(())
339 | }
340 |
--------------------------------------------------------------------------------
/src/blacklist.rs:
--------------------------------------------------------------------------------
1 | //! Defines the list of units to ignore during scheduling.
2 | //!
3 | //! Users can add units to this list to prevent them from being scheduled, either because they
4 | //! already mastered the material, or because they simply do not want to practice certain skills.
5 | //!
6 | //! The blacklist exists for this purpose. A unit that is on it will never be scheduled. In
7 | //! addition, the scheduler will continue the search past its dependents as if the unit was already
8 | //! mastered. Courses, lessons, and exercises can be added to the blacklist.
9 |
10 | use anyhow::Result;
11 | use parking_lot::RwLock;
12 | use r2d2::Pool;
13 | use r2d2_sqlite::SqliteConnectionManager;
14 | use rusqlite::params;
15 | use rusqlite_migration::{M, Migrations};
16 | use ustr::{Ustr, UstrMap};
17 |
18 | use crate::{error::BlacklistError, utils};
19 |
20 | /// An interface to store and read the list of units which should be skipped during scheduling.
21 | pub trait Blacklist {
22 | /// Adds the given unit to the blacklist.
23 | fn add_to_blacklist(&mut self, unit_id: Ustr) -> Result<(), BlacklistError>;
24 |
25 | /// Removes the given unit from the blacklist. Do nothing if the unit is not already in the
26 | /// list.
27 | fn remove_from_blacklist(&mut self, unit_id: Ustr) -> Result<(), BlacklistError>;
28 |
29 | /// Removes all the units that match the given prefix from the blacklist.
30 | fn remove_prefix_from_blacklist(&mut self, prefix: &str) -> Result<(), BlacklistError>;
31 |
32 | /// Returns whether the given unit is in the blacklist and should be skipped during scheduling.
33 | fn blacklisted(&self, unit_id: Ustr) -> Result;
34 |
35 | /// Returns all the entries in the blacklist.
36 | fn get_blacklist_entries(&self) -> Result, BlacklistError>;
37 | }
38 |
39 | /// An implementation of [Blacklist] backed by `SQLite`.
40 | pub struct LocalBlacklist {
41 | /// A cache of the blacklist entries used to avoid unnecessary queries to the database.
42 | cache: RwLock>,
43 |
44 | /// A pool of connections to the database.
45 | pool: Pool,
46 | }
47 |
48 | impl LocalBlacklist {
49 | /// Returns all the migrations needed to set up the database.
50 | fn migrations() -> Migrations<'static> {
51 | Migrations::new(vec![
52 | // Create a table with the list of blacklisted units.
53 | M::up("CREATE TABLE blacklist(unit_id TEXT NOT NULL UNIQUE);")
54 | .down("DROP TABLE blacklist"),
55 | // Create an index of the blacklisted unit IDs.
56 | M::up("CREATE INDEX unit_id_index ON blacklist (unit_id);")
57 | .down("DROP INDEX unit_id_index"),
58 | ])
59 | }
60 |
61 | /// Initializes the database by running the migrations. If the migrations have been applied
62 | /// already, they will have no effect on the database.
63 | fn init(&mut self) -> Result<()> {
64 | let mut connection = self.pool.get()?;
65 | let migrations = Self::migrations();
66 | migrations.to_latest(&mut connection)?;
67 | Ok(())
68 | }
69 |
70 | /// Creates a connection pool and initializes the database and in-memory cache.
71 | fn new(connection_manager: SqliteConnectionManager) -> Result {
72 | let pool = utils::new_connection_pool(connection_manager)?;
73 | let mut blacklist = LocalBlacklist {
74 | cache: RwLock::new(UstrMap::default()),
75 | pool,
76 | };
77 | blacklist.init()?;
78 |
79 | // Initialize the cache with the existing blacklist entries.
80 | for unit_id in blacklist.get_blacklist_entries()? {
81 | blacklist.cache.write().insert(unit_id, true);
82 | }
83 |
84 | Ok(blacklist)
85 | }
86 |
87 | /// A constructor taking the path to the database file.
88 | pub fn new_from_disk(db_path: &str) -> Result {
89 | Self::new(utils::new_connection_manager(db_path))
90 | }
91 |
92 | /// Returns whether there's an entry for the given unit in the blacklist.
93 | #[inline]
94 | fn has_entry(&self, unit_id: Ustr) -> bool {
95 | let mut cache = self.cache.write();
96 | if let Some(has_entry) = cache.get(&unit_id) {
97 | *has_entry
98 | } else {
99 | // Because the cache was initialized with all the entries in the blacklist, and it's
100 | // kept updated, it's safe to assume that the entry is not in the blacklist and update
101 | // the cache accordingly.
102 | cache.insert(unit_id, false);
103 | false
104 | }
105 | }
106 |
107 | /// Helper function to add a unit to the blacklist.
108 | fn add_to_blacklist_helper(&mut self, unit_id: Ustr) -> Result<()> {
109 | // Check the cache first to avoid unnecessary queries.
110 | let has_entry = self.has_entry(unit_id);
111 | if has_entry {
112 | return Ok(());
113 | }
114 |
115 | // Add the entry to the database.
116 | let connection = self.pool.get()?;
117 | let mut stmt = connection.prepare_cached("INSERT INTO blacklist (unit_id) VALUES (?1)")?;
118 | stmt.execute(params![unit_id.as_str()])?;
119 |
120 | // Update the cache.
121 | self.cache.write().insert(unit_id, true);
122 | Ok(())
123 | }
124 |
125 | /// Helper function to remove a unit from the blacklist.
126 | fn remove_from_blacklist_helper(&mut self, unit_id: Ustr) -> Result<()> {
127 | // Remove the entry from the database.
128 | let connection = self.pool.get()?;
129 | let mut stmt = connection.prepare_cached("DELETE FROM blacklist WHERE unit_id = $1")?;
130 | stmt.execute(params![unit_id.as_str()])?;
131 |
132 | // Update the cache.
133 | self.cache.write().insert(unit_id, false);
134 | Ok(())
135 | }
136 |
137 | /// Helper function to remove all the entries with the given prefix from the blacklist.
138 | fn remove_prefix_from_blacklist_helper(&mut self, prefix: &str) -> Result<()> {
139 | // Search for all the entries with the given prefix.
140 | let connection = self.pool.get()?;
141 | let mut stmt =
142 | connection.prepare_cached("SELECT unit_id from blacklist WHERE unit_id LIKE $1;")?;
143 | let mut rows = stmt.query(params![format!("{}%", prefix)])?;
144 |
145 | // Remove all the entries with the given prefix.
146 | let mut stmt = connection.prepare_cached("DELETE FROM blacklist WHERE unit_id = $1")?;
147 | while let Some(row) = rows.next()? {
148 | let unit_id: String = row.get(0)?;
149 | stmt.execute(params![unit_id])?;
150 |
151 | // Update the cache.
152 | self.cache.write().insert(unit_id.into(), false);
153 | }
154 |
155 | // Call the `VACUUM` command to reclaim the space freed by the deleted entries.
156 | connection.execute_batch("VACUUM;")?;
157 | Ok(())
158 | }
159 |
160 | /// Helper function to retrieve all the entries in the blacklist.
161 | fn all_blacklist_entries_helper(&self) -> Result> {
162 | // Get all the entries from the database.
163 | let connection = self.pool.get()?;
164 | let mut stmt = connection.prepare_cached("SELECT unit_id from blacklist;")?;
165 | let mut rows = stmt.query(params![])?;
166 |
167 | // Convert the rows into a vector of `Ustr` values.
168 | let mut entries = Vec::new();
169 | while let Some(row) = rows.next()? {
170 | let unit_id: String = row.get(0)?;
171 | entries.push(Ustr::from(&unit_id));
172 | }
173 | Ok(entries)
174 | }
175 | }
176 |
177 | impl Blacklist for LocalBlacklist {
178 | fn add_to_blacklist(&mut self, unit_id: Ustr) -> Result<(), BlacklistError> {
179 | self.add_to_blacklist_helper(unit_id)
180 | .map_err(|e| BlacklistError::AddUnit(unit_id, e))
181 | }
182 |
183 | fn remove_from_blacklist(&mut self, unit_id: Ustr) -> Result<(), BlacklistError> {
184 | self.remove_from_blacklist_helper(unit_id)
185 | .map_err(|e| BlacklistError::RemoveUnit(unit_id, e))
186 | }
187 |
188 | fn remove_prefix_from_blacklist(&mut self, prefix: &str) -> Result<(), BlacklistError> {
189 | self.remove_prefix_from_blacklist_helper(prefix)
190 | .map_err(|e| BlacklistError::RemovePrefix(prefix.into(), e))
191 | }
192 |
193 | #[inline]
194 | fn blacklisted(&self, unit_id: Ustr) -> Result {
195 | Ok(self.has_entry(unit_id))
196 | }
197 |
198 | fn get_blacklist_entries(&self) -> Result, BlacklistError> {
199 | self.all_blacklist_entries_helper()
200 | .map_err(BlacklistError::GetEntries)
201 | }
202 | }
203 |
204 | #[cfg(test)]
205 | #[cfg_attr(coverage, coverage(off))]
206 | mod test {
207 | use anyhow::Result;
208 | use r2d2_sqlite::SqliteConnectionManager;
209 | use tempfile::tempdir;
210 | use ustr::Ustr;
211 |
212 | use crate::blacklist::{Blacklist, LocalBlacklist};
213 |
214 | fn new_test_blacklist() -> Result> {
215 | let connection_manager = SqliteConnectionManager::memory();
216 | let blacklist = LocalBlacklist::new(connection_manager)?;
217 | Ok(Box::new(blacklist))
218 | }
219 |
220 | /// Verifies checking for an element not in the blacklist.
221 | #[test]
222 | fn not_in_blacklist() -> Result<()> {
223 | let blacklist = new_test_blacklist()?;
224 | assert!(!blacklist.blacklisted(Ustr::from("unit_id"))?);
225 | Ok(())
226 | }
227 |
228 | /// Verifies adding and removing an element from the blacklist.
229 | #[test]
230 | fn add_and_remove_from_blacklist() -> Result<()> {
231 | let mut blacklist = new_test_blacklist()?;
232 |
233 | let unit_id = Ustr::from("unit_id");
234 | blacklist.add_to_blacklist(unit_id)?;
235 | assert!(blacklist.blacklisted(unit_id)?);
236 | blacklist.remove_from_blacklist(unit_id)?;
237 | assert!(!blacklist.blacklisted(unit_id)?);
238 | Ok(())
239 | }
240 |
241 | /// Verifies removing units that match a prefix from the blacklist.
242 | #[test]
243 | fn remove_prefix_from_blacklist() -> Result<()> {
244 | let mut blacklist = new_test_blacklist()?;
245 |
246 | // Add some units to the blacklist.
247 | let units = vec![
248 | Ustr::from("a"),
249 | Ustr::from("a::a"),
250 | Ustr::from("b"),
251 | Ustr::from("b::a"),
252 | Ustr::from("c"),
253 | Ustr::from("c::a"),
254 | ];
255 | for unit in &units {
256 | blacklist.add_to_blacklist(*unit).unwrap();
257 | }
258 |
259 | // Remove the units with the prefix "a" and verify only those are removed.
260 | blacklist.remove_prefix_from_blacklist("a").unwrap();
261 | for unit in units {
262 | if unit.as_str().starts_with('a') {
263 | assert!(!blacklist.blacklisted(unit).unwrap());
264 | } else {
265 | assert!(blacklist.blacklisted(unit).unwrap());
266 | }
267 | }
268 | Ok(())
269 | }
270 |
271 | /// Verifies the blacklist cache stores the correct values.
272 | #[test]
273 | fn blacklist_cache() -> Result<()> {
274 | let mut blacklist = new_test_blacklist()?;
275 | let unit_id = Ustr::from("unit_id");
276 | blacklist.add_to_blacklist(unit_id)?;
277 | assert!(blacklist.blacklisted(unit_id)?);
278 | // The value in the second call is retrieved from the cache.
279 | assert!(blacklist.blacklisted(unit_id)?);
280 | // The function should return early because it's already in the cache.
281 | blacklist.add_to_blacklist(unit_id)?;
282 | Ok(())
283 | }
284 |
285 | /// Verifies re-adding an existing entry to the blacklist.
286 | #[test]
287 | fn readd_to_blacklist() -> Result<()> {
288 | let mut blacklist = new_test_blacklist()?;
289 | let unit_id = Ustr::from("unit_id");
290 | blacklist.add_to_blacklist(unit_id)?;
291 | assert!(blacklist.blacklisted(unit_id)?);
292 | blacklist.remove_from_blacklist(unit_id)?;
293 | assert!(!blacklist.blacklisted(unit_id)?);
294 | blacklist.add_to_blacklist(unit_id)?;
295 | assert!(blacklist.blacklisted(unit_id)?);
296 | Ok(())
297 | }
298 |
299 | /// Verifies retrieving all the entries in the blacklist.
300 | #[test]
301 | fn all_entries() -> Result<()> {
302 | let mut blacklist = new_test_blacklist()?;
303 | let unit_id = Ustr::from("unit_id");
304 | let unit_id2 = Ustr::from("unit_id2");
305 | blacklist.add_to_blacklist(unit_id)?;
306 | assert!(blacklist.blacklisted(unit_id)?);
307 | blacklist.add_to_blacklist(unit_id2)?;
308 | assert!(blacklist.blacklisted(unit_id2)?);
309 | assert_eq!(blacklist.get_blacklist_entries()?, vec![unit_id, unit_id2]);
310 | Ok(())
311 | }
312 |
313 | /// Verifies that closing and re-opening the blacklist database preserves the blacklist.
314 | #[test]
315 | fn reopen_blacklist() -> Result<()> {
316 | let dir = tempdir()?;
317 | let mut blacklist =
318 | LocalBlacklist::new_from_disk(dir.path().join("blacklist.db").to_str().unwrap())?;
319 | let unit_id = Ustr::from("unit_id");
320 | blacklist.add_to_blacklist(unit_id)?;
321 | assert!(blacklist.blacklisted(unit_id)?);
322 |
323 | let new_blacklist =
324 | LocalBlacklist::new_from_disk(dir.path().join("blacklist.db").to_str().unwrap())?;
325 | assert!(new_blacklist.blacklisted(unit_id)?);
326 | Ok(())
327 | }
328 | }
329 |
--------------------------------------------------------------------------------
/src/course_builder.rs:
--------------------------------------------------------------------------------
1 | //! Defines utilities to make it easier to generate courses and lessons.
2 | //!
3 | //! Courses, lessons, and exercises are stored in JSON files that are the serialized versions of the
4 | //! manifests in the `data` module. This means that writers of Trane courses can simply generate the
5 | //! files by hand. However, this process is tedious and error-prone, so this module provides
6 | //! utilities to make it easier to generate these files. In addition, Trane is in early stages of
7 | //! development, so the format of the manifests is not stable yet. Generating the files by code
8 | //! makes it easier to make updates to the files as the format changes.
9 |
10 | pub mod knowledge_base_builder;
11 | #[cfg_attr(coverage, coverage(off))]
12 | pub mod music;
13 |
14 | use anyhow::{Context, Result, ensure};
15 | use serde::{Deserialize, Serialize};
16 | use std::{
17 | fs::{File, create_dir_all},
18 | io::Write,
19 | path::{Path, PathBuf},
20 | };
21 | use strum::Display;
22 |
23 | use crate::data::{CourseManifest, ExerciseManifestBuilder, LessonManifestBuilder, VerifyPaths};
24 |
25 | /// Common metadata keys for all courses and lessons.
26 | #[derive(Display)]
27 | #[strum(serialize_all = "snake_case")]
28 | #[allow(missing_docs)]
29 | pub enum TraneMetadata {
30 | Skill,
31 | }
32 |
33 | /// A builder to generate plain-text asset files.
34 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35 | pub struct AssetBuilder {
36 | /// The name of the file, which will be joined with the directory passed in the build function.
37 | pub file_name: String,
38 |
39 | /// The contents of the file as a string.
40 | pub contents: String,
41 | }
42 |
43 | impl AssetBuilder {
44 | /// Writes the asset to the given directory.
45 | pub fn build(&self, asset_directory: &Path) -> Result<()> {
46 | // Create the asset directory and verify there's not an existing file with the same name.
47 | create_dir_all(asset_directory)?;
48 | let asset_path = asset_directory.join(&self.file_name);
49 | ensure!(
50 | !asset_path.exists(),
51 | "asset path {} already exists",
52 | asset_path.display()
53 | );
54 |
55 | // Create any parent directories to the asset path to support specifying a directory in the
56 | // asset path.
57 | create_dir_all(asset_path.parent().unwrap())?;
58 |
59 | // Write the asset file.
60 | let mut asset_file = File::create(asset_path)?;
61 | asset_file.write_all(self.contents.as_bytes())?;
62 | Ok(())
63 | }
64 | }
65 |
66 | /// A builder that generates all the files needed to add an exercise to a lesson.
67 | pub struct ExerciseBuilder {
68 | /// The base name of the directory on which to store this lesson.
69 | pub directory_name: String,
70 |
71 | /// A closure taking a builder common to all exercises which returns the builder for a specific
72 | /// exercise manifest.
73 | pub manifest_closure: Box ExerciseManifestBuilder>,
74 |
75 | /// A list of asset builders to create assets specific to this exercise.
76 | pub asset_builders: Vec,
77 | }
78 |
79 | impl ExerciseBuilder {
80 | /// Writes the files needed for this exercise to the given directory.
81 | pub fn build(
82 | &self,
83 | exercise_directory: &PathBuf,
84 | manifest_template: ExerciseManifestBuilder,
85 | ) -> Result<()> {
86 | // Create the directory and write the exercise manifest.
87 | create_dir_all(exercise_directory)?;
88 | let manifest = (self.manifest_closure)(manifest_template).build()?;
89 | let manifest_json = serde_json::to_string_pretty(&manifest)? + "\n";
90 | let manifest_path = exercise_directory.join("exercise_manifest.json");
91 | let mut manifest_file = File::create(manifest_path)?;
92 | manifest_file.write_all(manifest_json.as_bytes())?;
93 |
94 | // Write all the assets.
95 | for asset_builder in &self.asset_builders {
96 | asset_builder.build(exercise_directory)?;
97 | }
98 |
99 | // Verify that all paths mentioned in the manifest are valid.
100 | manifest.verify_paths(exercise_directory).context(format!(
101 | "failed to verify files for exercise {}",
102 | manifest.id
103 | ))?;
104 | Ok(())
105 | }
106 | }
107 |
108 | /// A builder that generates the files needed to add a lesson to a course.
109 | pub struct LessonBuilder {
110 | /// Base name of the directory on which to store this lesson.
111 | pub directory_name: String,
112 |
113 | /// A closure taking a builder common to all lessons which returns the builder for a specific
114 | /// lesson manifest.
115 | pub manifest_closure: Box LessonManifestBuilder>,
116 |
117 | /// A template builder used to build the manifests for each exercise in the lesson. Common
118 | /// attributes to all exercises should be set here.
119 | pub exercise_manifest_template: ExerciseManifestBuilder,
120 |
121 | /// A list of tuples of exercise directory name and exercise builder to create the exercises in
122 | /// the lesson.
123 | pub exercise_builders: Vec,
124 |
125 | /// A list of asset builders to create assets specific to this lesson.
126 | pub asset_builders: Vec,
127 | }
128 |
129 | impl LessonBuilder {
130 | /// Writes the files needed for this lesson to the given directory.
131 | pub fn build(
132 | &self,
133 | lesson_directory: &PathBuf,
134 | manifest_template: LessonManifestBuilder,
135 | ) -> Result<()> {
136 | // Create the directory and write the lesson manifest.
137 | create_dir_all(lesson_directory)?;
138 | let manifest = (self.manifest_closure)(manifest_template).build()?;
139 | let manifest_json = serde_json::to_string_pretty(&manifest)? + "\n";
140 | let manifest_path = lesson_directory.join("lesson_manifest.json");
141 | let mut manifest_file = File::create(manifest_path)?;
142 | manifest_file.write_all(manifest_json.as_bytes())?;
143 |
144 | // Write all the assets.
145 | for asset_builder in &self.asset_builders {
146 | asset_builder.build(lesson_directory)?;
147 | }
148 |
149 | // Build all the exercises in the lesson.
150 | for exercise_builder in &self.exercise_builders {
151 | let exercise_directory = lesson_directory.join(&exercise_builder.directory_name);
152 | exercise_builder.build(&exercise_directory, self.exercise_manifest_template.clone())?;
153 | }
154 |
155 | // Verify that all paths mentioned in the manifest are valid.
156 | manifest
157 | .verify_paths(lesson_directory)
158 | .context(format!("failed to verify files for lesson {}", manifest.id))?;
159 | Ok(())
160 | }
161 | }
162 |
163 | /// A builder that generates the files needed to add a course.
164 | pub struct CourseBuilder {
165 | /// Base name of the directory on which to store this course.
166 | pub directory_name: String,
167 |
168 | /// The manifest for the course.
169 | pub course_manifest: CourseManifest,
170 |
171 | /// A template builder used to build the manifests for each lesson in the course. Attributes
172 | /// common to all lessons should be set here.
173 | pub lesson_manifest_template: LessonManifestBuilder,
174 |
175 | /// A list of tuples of directory names and lesson builders to create the lessons in the
176 | /// course.
177 | pub lesson_builders: Vec,
178 |
179 | /// A list of asset builders to create assets specific to this course.
180 | pub asset_builders: Vec,
181 | }
182 |
183 | impl CourseBuilder {
184 | /// Writes the files needed for this course to the given directory.
185 | pub fn build(&self, parent_directory: &Path) -> Result<()> {
186 | // Create the directory and write the course manifest.
187 | let course_directory = parent_directory.join(&self.directory_name);
188 | create_dir_all(&course_directory)?;
189 | let manifest_json = serde_json::to_string_pretty(&self.course_manifest)? + "\n";
190 | let manifest_path = course_directory.join("course_manifest.json");
191 | let mut manifest_file = File::create(manifest_path)?;
192 | manifest_file.write_all(manifest_json.as_bytes())?;
193 |
194 | // Write all the assets.
195 | for asset_builder in &self.asset_builders {
196 | asset_builder.build(&course_directory)?;
197 | }
198 |
199 | // Build all the lessons in the course.
200 | for lesson_builder in &self.lesson_builders {
201 | let lesson_directory = course_directory.join(&lesson_builder.directory_name);
202 | lesson_builder.build(&lesson_directory, self.lesson_manifest_template.clone())?;
203 | }
204 |
205 | // Verify that all paths mentioned in the manifest are valid.
206 | self.course_manifest
207 | .verify_paths(&course_directory)
208 | .context(format!(
209 | "failed to verify files for course {}",
210 | self.course_manifest.id
211 | ))?;
212 | Ok(())
213 | }
214 | }
215 |
216 | #[cfg(test)]
217 | #[cfg_attr(coverage, coverage(off))]
218 | mod test {
219 | use anyhow::Result;
220 | use std::io::Read;
221 |
222 | use super::*;
223 | use crate::data::{BasicAsset, ExerciseAsset, ExerciseType};
224 |
225 | /// Verifies the asset builder writes the contents to the correct file.
226 | #[test]
227 | fn asset_builer() -> Result<()> {
228 | let temp_dir = tempfile::tempdir()?;
229 | let asset_builder = AssetBuilder {
230 | file_name: "asset1.md".to_string(),
231 | contents: "asset1 contents".to_string(),
232 | };
233 | asset_builder.build(temp_dir.path())?;
234 | assert!(temp_dir.path().join("asset1.md").is_file());
235 | let mut file = File::open(temp_dir.path().join("asset1.md"))?;
236 | let mut contents = String::new();
237 | file.read_to_string(&mut contents)?;
238 | assert_eq!(contents, "asset1 contents");
239 | Ok(())
240 | }
241 |
242 | /// Verifies the asset builder fails if there's an existing file.
243 | #[test]
244 | fn asset_builer_existing() -> Result<()> {
245 | // Create the file first.
246 | let temp_dir = tempfile::tempdir()?;
247 | let asset_path = temp_dir.path().join("asset1.md");
248 | File::create(&asset_path)?;
249 |
250 | // Creating the asset builder should fail.
251 | let asset_builder = AssetBuilder {
252 | file_name: "asset1.md".to_string(),
253 | contents: "asset1 contents".to_string(),
254 | };
255 | assert!(asset_builder.build(temp_dir.path()).is_err());
256 | Ok(())
257 | }
258 |
259 | /// Verifies the course builder writes the correct files.
260 | #[test]
261 | fn course_builder() -> Result<()> {
262 | let exercise_builder = ExerciseBuilder {
263 | directory_name: "exercise1".to_string(),
264 | manifest_closure: Box::new(|builder| {
265 | builder
266 | .clone()
267 | .id("exercise1")
268 | .name("Exercise 1".into())
269 | .exercise_asset(ExerciseAsset::BasicAsset(BasicAsset::InlinedAsset {
270 | content: String::new(),
271 | }))
272 | .clone()
273 | }),
274 | asset_builders: vec![],
275 | };
276 | let lesson_builder = LessonBuilder {
277 | directory_name: "lesson1".to_string(),
278 | manifest_closure: Box::new(|builder| {
279 | builder
280 | .clone()
281 | .id("lesson1")
282 | .name("Lesson 1".into())
283 | .dependencies(vec![])
284 | .clone()
285 | }),
286 | exercise_manifest_template: ExerciseManifestBuilder::default()
287 | .lesson_id("lesson1")
288 | .course_id("course1")
289 | .exercise_type(ExerciseType::Procedural)
290 | .clone(),
291 | exercise_builders: vec![exercise_builder],
292 | asset_builders: vec![],
293 | };
294 | let course_builder = CourseBuilder {
295 | directory_name: "course1".to_string(),
296 | course_manifest: CourseManifest {
297 | id: "course1".into(),
298 | name: "Course 1".into(),
299 | dependencies: vec![],
300 | superseded: vec![],
301 | description: None,
302 | authors: None,
303 | metadata: None,
304 | course_material: None,
305 | course_instructions: None,
306 | generator_config: None,
307 | },
308 | lesson_manifest_template: LessonManifestBuilder::default()
309 | .course_id("course1")
310 | .clone(),
311 | lesson_builders: vec![lesson_builder],
312 | asset_builders: vec![],
313 | };
314 |
315 | let temp_dir = tempfile::tempdir()?;
316 | course_builder.build(temp_dir.path())?;
317 |
318 | let course_dir = temp_dir.path().join("course1");
319 | let lesson_dir = course_dir.join("lesson1");
320 | let exercise_dir = lesson_dir.join("exercise1");
321 | assert!(course_dir.is_dir());
322 | assert!(lesson_dir.is_dir());
323 | assert!(exercise_dir.is_dir());
324 | assert!(course_dir.join("course_manifest.json").is_file());
325 | assert!(lesson_dir.join("lesson_manifest.json").is_file());
326 | assert!(exercise_dir.join("exercise_manifest.json").is_file());
327 | Ok(())
328 | }
329 |
330 | /// Tests the Display implementation of TraneMetadata to satisfy coverage.
331 | #[test]
332 | fn trane_metadata_display() {
333 | assert_eq!("skill", TraneMetadata::Skill.to_string());
334 | }
335 | }
336 |
--------------------------------------------------------------------------------
/src/data/music/scales.rs:
--------------------------------------------------------------------------------
1 | //! Defines musical scales for use in generating music courses.
2 |
3 | use anyhow::{Result, anyhow};
4 | use std::fmt::{Display, Formatter};
5 |
6 | use crate::data::music::intervals::*;
7 | use crate::data::music::notes::*;
8 |
9 | /// Defines a tonal scale.
10 | #[derive(Clone, Debug)]
11 | pub struct Scale {
12 | /// The tonic of the scale.
13 | pub tonic: Note,
14 |
15 | /// The notes which form the scale in the correct order.
16 | pub notes: Vec,
17 | }
18 |
19 | /// Defines a type of scale.
20 | #[derive(Clone, Copy, Debug)]
21 | #[allow(missing_docs)]
22 | pub enum ScaleType {
23 | Major,
24 | Minor,
25 | MajorPentatonic,
26 | MinorPentatonic,
27 | }
28 |
29 | impl Display for ScaleType {
30 | fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
31 | match self {
32 | ScaleType::Major => write!(f, "Major"),
33 | ScaleType::Minor => write!(f, "Minor"),
34 | ScaleType::MajorPentatonic => write!(f, "Major Pentatonic"),
35 | ScaleType::MinorPentatonic => write!(f, "Minor Pentatonic"),
36 | }
37 | }
38 | }
39 |
40 | impl Note {
41 | /// Returns the note that is the relative minor of the given major key.
42 | pub fn relative_minor(&self) -> Result {
43 | match *self {
44 | Note::A => Ok(Note::F_SHARP),
45 | Note::A_FLAT => Ok(Note::F),
46 | Note::B => Ok(Note::G_SHARP),
47 | Note::B_FLAT => Ok(Note::G),
48 | Note::C => Ok(Note::A),
49 | Note::C_FLAT => Ok(Note::A_FLAT),
50 | Note::C_SHARP => Ok(Note::A_SHARP),
51 | Note::D => Ok(Note::B),
52 | Note::D_FLAT => Ok(Note::B_FLAT),
53 | Note::E => Ok(Note::C_SHARP),
54 | Note::E_FLAT => Ok(Note::C),
55 | Note::F => Ok(Note::D),
56 | Note::F_SHARP => Ok(Note::D_SHARP),
57 | Note::G => Ok(Note::E),
58 | Note::G_FLAT => Ok(Note::E_FLAT),
59 | _ => Err(anyhow!("relative minor not found for note {self}")),
60 | }
61 | }
62 |
63 | /// Returns the note that is the relative major of the given minor key.
64 | pub fn relative_major(&self) -> Result {
65 | match *self {
66 | Note::A => Ok(Note::C),
67 | Note::A_FLAT => Ok(Note::C_FLAT),
68 | Note::A_SHARP => Ok(Note::C_SHARP),
69 | Note::B => Ok(Note::D),
70 | Note::B_FLAT => Ok(Note::D_FLAT),
71 | Note::C => Ok(Note::E_FLAT),
72 | Note::C_SHARP => Ok(Note::E),
73 | Note::D => Ok(Note::F),
74 | Note::D_SHARP => Ok(Note::F_SHARP),
75 | Note::E => Ok(Note::G),
76 | Note::E_FLAT => Ok(Note::G_FLAT),
77 | Note::F => Ok(Note::A_FLAT),
78 | Note::F_SHARP => Ok(Note::A),
79 | Note::G => Ok(Note::B_FLAT),
80 | Note::G_SHARP => Ok(Note::B),
81 | _ => Err(anyhow!("relative major not found for note {self}")),
82 | }
83 | }
84 | }
85 |
86 | impl ScaleType {
87 | /// Returns a scale of the given type and tonic.
88 | pub fn notes(&self, tonic: Note) -> Result {
89 | match &self {
90 | ScaleType::Major => match tonic {
91 | // A – B – C♯ – D – E – F♯ – G♯
92 | Note::A => Ok(Scale {
93 | tonic,
94 | notes: vec![
95 | Note::A,
96 | Note::B,
97 | Note::C_SHARP,
98 | Note::D,
99 | Note::E,
100 | Note::F_SHARP,
101 | Note::G_SHARP,
102 | ],
103 | }),
104 |
105 | // A♭ – B♭ – C – D♭ – E♭ – F – G
106 | Note::A_FLAT => Ok(Scale {
107 | tonic,
108 | notes: vec![
109 | Note::A_FLAT,
110 | Note::B_FLAT,
111 | Note::C,
112 | Note::D_FLAT,
113 | Note::E_FLAT,
114 | Note::F,
115 | Note::G,
116 | ],
117 | }),
118 |
119 | // B – C♯ – D♯ – E – F♯ – G♯ – A♯
120 | Note::B => Ok(Scale {
121 | tonic,
122 | notes: vec![
123 | Note::B,
124 | Note::C_SHARP,
125 | Note::D_SHARP,
126 | Note::E,
127 | Note::F_SHARP,
128 | Note::G_SHARP,
129 | Note::A_SHARP,
130 | ],
131 | }),
132 |
133 | // B♭ – C – D – E♭ – F – G – A
134 | Note::B_FLAT => Ok(Scale {
135 | tonic,
136 | notes: vec![
137 | Note::B_FLAT,
138 | Note::C,
139 | Note::D,
140 | Note::E_FLAT,
141 | Note::F,
142 | Note::G,
143 | Note::A,
144 | ],
145 | }),
146 |
147 | // C - D - E - F - G - A - B
148 | Note::C => Ok(Scale {
149 | tonic,
150 | notes: vec![
151 | Note::C,
152 | Note::D,
153 | Note::E,
154 | Note::F,
155 | Note::G,
156 | Note::A,
157 | Note::B,
158 | ],
159 | }),
160 |
161 | // C♭ – D♭ – E♭ – F♭ – G♭ – A♭ – B♭
162 | Note::C_FLAT => Ok(Scale {
163 | tonic,
164 | notes: vec![
165 | Note::C_FLAT,
166 | Note::D_FLAT,
167 | Note::E_FLAT,
168 | Note::F_FLAT,
169 | Note::G_FLAT,
170 | Note::A_FLAT,
171 | Note::B_FLAT,
172 | ],
173 | }),
174 |
175 | // C♯ – D♯ – E♯ – F♯ – G♯ – A♯ – B♯
176 | Note::C_SHARP => Ok(Scale {
177 | tonic,
178 | notes: vec![
179 | Note::C_SHARP,
180 | Note::D_SHARP,
181 | Note::E_SHARP,
182 | Note::F_SHARP,
183 | Note::G_SHARP,
184 | Note::A_SHARP,
185 | Note::B_SHARP,
186 | ],
187 | }),
188 |
189 | // D – E – F♯ – G – A – B – C♯
190 | Note::D => Ok(Scale {
191 | tonic,
192 | notes: vec![
193 | Note::D,
194 | Note::E,
195 | Note::F_SHARP,
196 | Note::G,
197 | Note::A,
198 | Note::B,
199 | Note::C_SHARP,
200 | ],
201 | }),
202 |
203 | // D♭ – E♭ – F – G♭ – A♭ – B♭ – C
204 | Note::D_FLAT => Ok(Scale {
205 | tonic,
206 | notes: vec![
207 | Note::D_FLAT,
208 | Note::E_FLAT,
209 | Note::F,
210 | Note::G_FLAT,
211 | Note::A_FLAT,
212 | Note::B_FLAT,
213 | Note::C,
214 | ],
215 | }),
216 |
217 | // E – F♯ – G♯ – A – B – C♯ – D♯
218 | Note::E => Ok(Scale {
219 | tonic,
220 | notes: vec![
221 | Note::E,
222 | Note::F_SHARP,
223 | Note::G_SHARP,
224 | Note::A,
225 | Note::B,
226 | Note::C_SHARP,
227 | Note::D_SHARP,
228 | ],
229 | }),
230 |
231 | // E♭ – F – G – A♭ – B♭ – C – D
232 | Note::E_FLAT => Ok(Scale {
233 | tonic,
234 | notes: vec![
235 | Note::E_FLAT,
236 | Note::F,
237 | Note::G,
238 | Note::A_FLAT,
239 | Note::B_FLAT,
240 | Note::C,
241 | Note::D,
242 | ],
243 | }),
244 |
245 | // F – G – A – B♭ – C – D – E
246 | Note::F => Ok(Scale {
247 | tonic,
248 | notes: vec![
249 | Note::F,
250 | Note::G,
251 | Note::A,
252 | Note::B_FLAT,
253 | Note::C,
254 | Note::D,
255 | Note::E,
256 | ],
257 | }),
258 |
259 | // F♯ – G♯ – A♯ – B – C♯ – D♯ – E♯
260 | Note::F_SHARP => Ok(Scale {
261 | tonic,
262 | notes: vec![
263 | Note::F_SHARP,
264 | Note::G_SHARP,
265 | Note::A_SHARP,
266 | Note::B,
267 | Note::C_SHARP,
268 | Note::D_SHARP,
269 | Note::E_SHARP,
270 | ],
271 | }),
272 |
273 | // G – A – B – C – D – E – F♯
274 | Note::G => Ok(Scale {
275 | tonic,
276 | notes: vec![
277 | Note::G,
278 | Note::A,
279 | Note::B,
280 | Note::C,
281 | Note::D,
282 | Note::E,
283 | Note::F_SHARP,
284 | ],
285 | }),
286 |
287 | // G♭ – A♭ – B♭ – C♭ – D♭ – E♭ – F
288 | Note::G_FLAT => Ok(Scale {
289 | tonic,
290 | notes: vec![
291 | Note::G_FLAT,
292 | Note::A_FLAT,
293 | Note::B_FLAT,
294 | Note::C_FLAT,
295 | Note::D_FLAT,
296 | Note::E_FLAT,
297 | Note::F,
298 | ],
299 | }),
300 | _ => Err(anyhow!("major scale not found for note {tonic}")),
301 | },
302 |
303 | ScaleType::Minor => {
304 | let relative_major = ScaleType::Major
305 | .notes(tonic.relative_major()?)
306 | .map_err(|_| anyhow!("minor scale not found for note {tonic}"))?;
307 |
308 | Ok(Scale {
309 | tonic: relative_major.tonic,
310 | notes: vec![
311 | relative_major.notes[5],
312 | relative_major.notes[6],
313 | relative_major.notes[0],
314 | relative_major.notes[1],
315 | relative_major.notes[2],
316 | relative_major.notes[3],
317 | relative_major.notes[4],
318 | ],
319 | })
320 | }
321 |
322 | ScaleType::MajorPentatonic => {
323 | let major = ScaleType::Major
324 | .notes(tonic)
325 | .map_err(|_| anyhow!("major pentatonic scale not found for note {tonic}"))?;
326 | Ok(Scale {
327 | tonic,
328 | notes: vec![
329 | major.notes[0],
330 | major.notes[1],
331 | major.notes[2],
332 | major.notes[4],
333 | major.notes[5],
334 | ],
335 | })
336 | }
337 |
338 | ScaleType::MinorPentatonic => {
339 | let minor = ScaleType::Minor
340 | .notes(tonic)
341 | .map_err(|_| anyhow!("minor pentatonic scale not found for note {tonic}"))?;
342 | Ok(Scale {
343 | tonic,
344 | notes: vec![
345 | minor.notes[0],
346 | minor.notes[2],
347 | minor.notes[3],
348 | minor.notes[4],
349 | minor.notes[6],
350 | ],
351 | })
352 | }
353 | }
354 | }
355 |
356 | /// Returns the intervals in the scale.
357 | pub fn intervals(&self) -> Result> {
358 | match &self {
359 | ScaleType::Major => Ok(vec![
360 | Interval::Unison,
361 | Interval::MajorSecond,
362 | Interval::MajorThird,
363 | Interval::PerfectFourth,
364 | Interval::PerfectFifth,
365 | Interval::MajorSixth,
366 | Interval::MajorSeventh,
367 | ]),
368 | ScaleType::Minor => Ok(vec![
369 | Interval::Unison,
370 | Interval::MajorSecond,
371 | Interval::MinorThird,
372 | Interval::PerfectFourth,
373 | Interval::PerfectFifth,
374 | Interval::MinorSixth,
375 | Interval::MinorSeventh,
376 | ]),
377 | ScaleType::MajorPentatonic => Ok(vec![
378 | Interval::Unison,
379 | Interval::MajorSecond,
380 | Interval::MajorThird,
381 | Interval::PerfectFifth,
382 | Interval::MajorSixth,
383 | ]),
384 | ScaleType::MinorPentatonic => Ok(vec![
385 | Interval::Unison,
386 | Interval::MinorThird,
387 | Interval::PerfectFourth,
388 | Interval::PerfectFifth,
389 | Interval::MinorSeventh,
390 | ]),
391 | }
392 | }
393 | }
394 |
--------------------------------------------------------------------------------
/src/exercise_scorer.rs:
--------------------------------------------------------------------------------
1 | //! Contains the logic to score an exercise based on the results and timestamps of previous trials.
2 |
3 | use anyhow::{Result, anyhow};
4 | use chrono::{TimeZone, Utc};
5 |
6 | use crate::{data::ExerciseTrial, utils};
7 |
8 | /// A trait exposing a function to score an exercise based on the results of previous trials.
9 | pub trait ExerciseScorer {
10 | /// Returns a score (between 0.0 and 5.0) for the exercise based on the results and timestamps
11 | /// of previous trials. The trials are assumed to be sorted in descending order by timestamp.
12 | fn score(&self, previous_trials: &[ExerciseTrial]) -> Result;
13 | }
14 |
15 | /// The initial decay rate for the score of a trial. This is the rate at which the score decreases
16 | /// with each passing day.
17 | const INITIAL_DECAY_RATE: f32 = 0.15;
18 |
19 | /// The factor at which the decay rate is adjusted for each additional trial. This simulates how
20 | /// skill performance deteriorates more slowly after repeated practice.
21 | const DECAY_RATE_ADJUSTMENT_FACTOR: f32 = 0.8;
22 |
23 | /// The initial minimum score for an exercise after exponential decay is applied as a factor of the
24 | /// original score.
25 | const INITIAL_MIN_SCORE_FACTOR: f32 = 0.7;
26 |
27 | /// The minimum score factor will be adjusted by this factor with additional trials. This is to
28 | /// simulate how the performance floor of a skill increases with practice. The value must be greater
29 | /// than one.
30 | const MIN_SCORE_ADJUSTMENT_FACTOR: f32 = 1.05;
31 |
32 | /// The maximum score for an exercise after exponential decay is applied as a factor of the original
33 | /// score and the adjustment increases the minimum score. It always should be less than 1.0.
34 | const MAX_MIN_SCORE_FACTOR: f32 = 0.95;
35 |
36 | /// The initial weight for an individual trial.
37 | const INITIAL_WEIGHT: f32 = 1.0;
38 |
39 | /// The weight of a trial is adjusted based on the index of the trial in the list. The first trial
40 | /// has the initial weight, and the weight decreases with each subsequent trial by this factor.
41 | const WEIGHT_INDEX_FACTOR: f32 = 0.8;
42 |
43 | /// The minimum weight of a score, also used when there's an issue calculating the number of days
44 | /// since the trial (e.g., the score's timestamp is after the current timestamp).
45 | const MIN_WEIGHT: f32 = 0.1;
46 |
47 | /// A scorer that uses an exponential decay function to compute the score of an exercise. As the
48 | /// student completes more trials, the decay rate decreases and the minimum score increases to
49 | /// simulate how students increase skill retention with more practice.
50 | pub struct ExponentialDecayScorer {}
51 |
52 | impl ExponentialDecayScorer {
53 | /// Returns the number of days between trials.
54 | #[inline]
55 | fn day_diffs(previous_trials: &[ExerciseTrial]) -> Vec {
56 | let mut now_plus_trials = vec![ExerciseTrial {
57 | timestamp: Utc::now().timestamp(),
58 | score: 0.0,
59 | }];
60 | now_plus_trials.extend(previous_trials.iter().cloned());
61 | now_plus_trials
62 | .windows(2)
63 | .map(|w| {
64 | let t1 = Utc
65 | .timestamp_opt(w[0].timestamp, 0)
66 | .earliest()
67 | .unwrap_or_default();
68 | let t2 = Utc
69 | .timestamp_opt(w[1].timestamp, 0)
70 | .earliest()
71 | .unwrap_or_default();
72 | (t1 - t2).num_days() as f32
73 | })
74 | .collect()
75 | }
76 |
77 | /// Returns the decay rates for each score based on the number of trials.
78 | #[inline]
79 | fn decay_rates(num_trials: usize) -> Vec {
80 | (0..num_trials)
81 | .map(|i| (INITIAL_DECAY_RATE * DECAY_RATE_ADJUSTMENT_FACTOR.powf(i as f32)).abs())
82 | .rev()
83 | .collect()
84 | }
85 |
86 | /// Returns the minimum score factors for each trial based on the number of trials. The minimum
87 | /// score is the score of the trial times this factor.
88 | #[inline]
89 | fn min_score_factors(num_trials: usize) -> Vec {
90 | (0..num_trials)
91 | .map(|i| {
92 | (INITIAL_MIN_SCORE_FACTOR * MIN_SCORE_ADJUSTMENT_FACTOR.powf(i as f32))
93 | .min(MAX_MIN_SCORE_FACTOR)
94 | })
95 | .rev()
96 | .collect()
97 | }
98 |
99 | /// Performs the exponential decay on the score based on the number of days since the trial with
100 | /// the given minimum score and decay rate.
101 | #[inline]
102 | fn exponential_decay(
103 | initial_score: f32,
104 | num_days: f32,
105 | min_score_factor: f32,
106 | decay_rate: f32,
107 | ) -> f32 {
108 | // If the number of days is negative, return the score as is.
109 | if num_days < 0.0 {
110 | return initial_score;
111 | }
112 |
113 | // Compute the exponential decay using the formula:
114 | // min_score = initial_score * min_score_factor
115 | // S(num_days) = min_score + (initial_score - min_score) * e^(-decay_rate * num_days)
116 | let min_score = initial_score * min_score_factor;
117 | (min_score + (initial_score - min_score) * (-decay_rate * num_days).exp()).clamp(0.0, 5.0)
118 | }
119 |
120 | /// Returns the weights to used to compute the weighted average of the scores.
121 | #[inline]
122 | fn score_weights(num_trials: usize) -> Vec {
123 | (0..num_trials)
124 | .map(|i| {
125 | let weight = INITIAL_WEIGHT * WEIGHT_INDEX_FACTOR.powf(i as f32);
126 | weight.max(MIN_WEIGHT)
127 | })
128 | .collect()
129 | }
130 | }
131 |
132 | impl ExerciseScorer for ExponentialDecayScorer {
133 | fn score(&self, previous_trials: &[ExerciseTrial]) -> Result {
134 | // An exercise with no previous trials is assigned a score of 0.0.
135 | if previous_trials.is_empty() {
136 | return Ok(0.0);
137 | }
138 |
139 | // Check the sorting of the trials is in descending order by timestamp.
140 | if previous_trials
141 | .windows(2)
142 | .any(|w| w[0].timestamp < w[1].timestamp)
143 | {
144 | return Err(anyhow!(
145 | "Exercise trials are not sorted in descending order by timestamp"
146 | ));
147 | }
148 |
149 | // Compute the scores by running exponential decay on each trial.
150 | let days = Self::day_diffs(previous_trials);
151 | let decay_rates = Self::decay_rates(previous_trials.len());
152 | let min_score_factors = Self::min_score_factors(previous_trials.len());
153 | let scores: Vec = previous_trials
154 | .iter()
155 | .zip(days.iter())
156 | .zip(decay_rates.iter())
157 | .zip(min_score_factors.iter())
158 | .map(|(((trial, num_days), decay_rate), factor)| {
159 | Self::exponential_decay(trial.score, num_days.abs(), *factor, *decay_rate)
160 | })
161 | .collect();
162 |
163 | // Run a weighted average on the scores to compute the final score.
164 | let weights = Self::score_weights(previous_trials.len());
165 | Ok(utils::weighted_average(&scores, &weights))
166 | }
167 | }
168 |
169 | #[cfg(test)]
170 | #[cfg_attr(coverage, coverage(off))]
171 | mod test {
172 | use chrono::Utc;
173 |
174 | use crate::{data::ExerciseTrial, exercise_scorer::*};
175 |
176 | const SECONDS_IN_DAY: i64 = 60 * 60 * 24;
177 | const SCORER: ExponentialDecayScorer = ExponentialDecayScorer {};
178 |
179 | /// Generates a timestamp equal to the timestamp from `num_days` ago.
180 | fn generate_timestamp(num_days: i64) -> i64 {
181 | let now = Utc::now().timestamp();
182 | now - num_days * SECONDS_IN_DAY
183 | }
184 |
185 | /// Verifies the number of days between two timestamps is calculated correctly.
186 | #[test]
187 | fn day_diffs() {
188 | let trials = vec![
189 | ExerciseTrial {
190 | score: 0.0,
191 | timestamp: generate_timestamp(5),
192 | },
193 | ExerciseTrial {
194 | score: 0.0,
195 | timestamp: generate_timestamp(10),
196 | },
197 | ExerciseTrial {
198 | score: 0.0,
199 | timestamp: generate_timestamp(20),
200 | },
201 | ];
202 | let days = ExponentialDecayScorer::day_diffs(&trials);
203 | assert_eq!(days, vec![5.0, 5.0, 10.0]);
204 | }
205 |
206 | /// Verifies the decay rates are calculated correctly.
207 | #[test]
208 | fn decay_rates() {
209 | let num_trials = 4;
210 | let decay_rates = ExponentialDecayScorer::decay_rates(num_trials);
211 | assert_eq!(
212 | decay_rates,
213 | vec![
214 | INITIAL_DECAY_RATE * DECAY_RATE_ADJUSTMENT_FACTOR.powf(3.0),
215 | INITIAL_DECAY_RATE * DECAY_RATE_ADJUSTMENT_FACTOR.powf(2.0),
216 | INITIAL_DECAY_RATE * DECAY_RATE_ADJUSTMENT_FACTOR,
217 | INITIAL_DECAY_RATE,
218 | ]
219 | );
220 | }
221 |
222 | /// Verifies the minimum score factors are calculated correctly.
223 | #[test]
224 | fn min_score_factors() {
225 | let num_trials = 4;
226 | let min_score_factors = ExponentialDecayScorer::min_score_factors(num_trials);
227 | assert_eq!(
228 | min_score_factors,
229 | vec![
230 | INITIAL_MIN_SCORE_FACTOR * MIN_SCORE_ADJUSTMENT_FACTOR.powf(3.0),
231 | INITIAL_MIN_SCORE_FACTOR * MIN_SCORE_ADJUSTMENT_FACTOR.powf(2.0),
232 | INITIAL_MIN_SCORE_FACTOR * MIN_SCORE_ADJUSTMENT_FACTOR,
233 | INITIAL_MIN_SCORE_FACTOR,
234 | ]
235 | );
236 |
237 | // Assert that each value is greater than the next.
238 | for i in 0..min_score_factors.len() - 1 {
239 | assert!(min_score_factors[i] > min_score_factors[i + 1]);
240 | }
241 | }
242 |
243 | /// Verifies exponential decay returns the original score when the number of days is zero.
244 | #[test]
245 | fn exponential_decay_zero_days() {
246 | let initial_score = 5.0;
247 | let num_days = 0.0;
248 | let min_score_factor = 0.5;
249 | let decay_rate = 0.2;
250 |
251 | let adjusted_score = ExponentialDecayScorer::exponential_decay(
252 | initial_score,
253 | num_days,
254 | min_score_factor,
255 | decay_rate,
256 | );
257 | assert_eq!(adjusted_score, initial_score);
258 | }
259 |
260 | /// Verifies exponential decay converges to the minimum score.
261 | #[test]
262 | fn exponential_decay_converges() {
263 | let initial_score = 5.0;
264 | let num_days = 1000.0;
265 | let min_score_factor = 0.5;
266 | let decay_rate = 0.1;
267 |
268 | let adjusted_score = ExponentialDecayScorer::exponential_decay(
269 | initial_score,
270 | num_days,
271 | min_score_factor,
272 | decay_rate,
273 | );
274 | assert_eq!(adjusted_score, initial_score * min_score_factor);
275 | }
276 |
277 | /// Verifies exponential decay returns the original score when the number of days is negative.
278 | #[test]
279 | fn exponential_decay_negative_days() {
280 | let initial_score = 5.0;
281 | let num_days = -1.0;
282 | let min_score_factor = 0.5;
283 | let decay_rate = 0.2;
284 |
285 | let adjusted_score = ExponentialDecayScorer::exponential_decay(
286 | initial_score,
287 | num_days,
288 | min_score_factor,
289 | decay_rate,
290 | );
291 | assert_eq!(adjusted_score, initial_score);
292 | }
293 |
294 | /// Verifies the score for an exercise with no previous trials is 0.0.
295 | #[test]
296 | fn no_previous_trials() {
297 | assert_eq!(0.0, SCORER.score(&[]).unwrap());
298 | }
299 |
300 | /// Verifies that the scorer fails if the trials are not sorted in descending order.
301 | #[test]
302 | fn trials_not_sorted() {
303 | let trials = vec![
304 | ExerciseTrial {
305 | score: 5.0,
306 | timestamp: generate_timestamp(100),
307 | },
308 | ExerciseTrial {
309 | score: 4.0,
310 | timestamp: generate_timestamp(2),
311 | },
312 | ];
313 | assert!(SCORER.score(&trials).is_err());
314 | }
315 |
316 | /// Verifies the assignment of weights to trials based on their index.
317 | #[test]
318 | fn score_weights() {
319 | let num_trials = 4;
320 | let weights = ExponentialDecayScorer::score_weights(num_trials);
321 | assert_eq!(
322 | weights,
323 | vec![
324 | INITIAL_WEIGHT,
325 | INITIAL_WEIGHT * WEIGHT_INDEX_FACTOR,
326 | INITIAL_WEIGHT * WEIGHT_INDEX_FACTOR.powf(2.0),
327 | INITIAL_WEIGHT * WEIGHT_INDEX_FACTOR.powf(3.0),
328 | ]
329 | );
330 | }
331 |
332 | /// Verifies that the score weight is never less than the minimum weight.
333 | #[test]
334 | fn score_weight_capped_at_min() {
335 | let weights = ExponentialDecayScorer::score_weights(1000);
336 | assert_eq!(weights[weights.len() - 1], MIN_WEIGHT);
337 | }
338 |
339 | /// Verifies running the full scoring algorithm on a set of trials produces the expected score.
340 | #[test]
341 | fn score_trials() {
342 | let trials = vec![
343 | ExerciseTrial {
344 | score: 3.0,
345 | timestamp: generate_timestamp(1),
346 | },
347 | ExerciseTrial {
348 | score: 4.0,
349 | timestamp: generate_timestamp(2),
350 | },
351 | ExerciseTrial {
352 | score: 5.0,
353 | timestamp: generate_timestamp(3),
354 | },
355 | ];
356 | let score = SCORER.score(&trials).unwrap();
357 | assert!((score - 3.732).abs() < 0.001)
358 | }
359 |
360 | /// Verifies scoring an exercise with an invalid timestamp still returns a sane score.
361 | #[test]
362 | fn invalid_timestamp() -> Result<()> {
363 | // The timestamp is before the Unix epoch.
364 | assert_eq!(
365 | SCORER.score(&[ExerciseTrial {
366 | score: 5.0,
367 | timestamp: generate_timestamp(1e10 as i64)
368 | },])?,
369 | 5.0 * INITIAL_MIN_SCORE_FACTOR
370 | );
371 | Ok(())
372 | }
373 | }
374 |
--------------------------------------------------------------------------------
/tests/transcription_tests.rs:
--------------------------------------------------------------------------------
1 | //! End-to-end tests to verify that the transcription course generator works as expected.
2 |
3 | use anyhow::Result;
4 | use std::collections::HashMap;
5 | use std::sync::LazyLock;
6 | use tempfile::TempDir;
7 | use trane::{
8 | course_builder::{AssetBuilder, CourseBuilder},
9 | course_library::CourseLibrary,
10 | data::{
11 | CourseGenerator, CourseManifest, LessonManifestBuilder, MasteryScore, UserPreferences,
12 | course_generator::{
13 | Instrument,
14 | transcription::{
15 | TranscriptionAsset, TranscriptionConfig, TranscriptionPassages,
16 | TranscriptionPreferences,
17 | },
18 | },
19 | },
20 | test_utils::{TraneSimulation, assert_simulation_scores, init_simulation},
21 | };
22 | use ustr::Ustr;
23 |
24 | static COURSE0_ID: LazyLock =
25 | LazyLock::new(|| Ustr::from("trane::test::transcription_course_0"));
26 | static COURSE1_ID: LazyLock =
27 | LazyLock::new(|| Ustr::from("trane::test::transcription_course_1"));
28 | static USER_PREFS: LazyLock = LazyLock::new(|| UserPreferences {
29 | transcription: Some(TranscriptionPreferences {
30 | instruments: vec![
31 | Instrument {
32 | name: "Guitar".to_string(),
33 | id: "guitar".to_string(),
34 | },
35 | Instrument {
36 | name: "Piano".to_string(),
37 | id: "piano".to_string(),
38 | },
39 | ],
40 | ..Default::default()
41 | }),
42 | ignored_paths: vec![],
43 | scheduler: None,
44 | });
45 |
46 | /// Returns a course builder with a transcription generator.
47 | fn transcription_builder(
48 | course_id: Ustr,
49 | course_index: usize,
50 | dependencies: Vec,
51 | num_passages: usize,
52 | skip_singing_lessons: bool,
53 | skip_advanced_lessons: bool,
54 | ) -> CourseBuilder {
55 | // Create the passages for the course. Half of the passages will be stored in the `passages`
56 | // directory, and the other half will be inlined in the course manifest.
57 | let mut asset_builders = Vec::new();
58 | let mut inlined_passages = Vec::new();
59 | for i in 0..num_passages {
60 | // Create the passages. Create half of them with explicit intervals and half without.
61 | let passages = TranscriptionPassages {
62 | asset: TranscriptionAsset::Track {
63 | short_id: format!("passages_{}", i),
64 | track_name: format!("Track {}", i),
65 | artist_name: None,
66 | album_name: None,
67 | duration: None,
68 | external_link: None,
69 | },
70 | intervals: if i % 2 == 0 {
71 | HashMap::from([
72 | (0, ("0:00".to_string(), "0:01".to_string())),
73 | (1, ("0:05".to_string(), "0:10".to_string())),
74 | ])
75 | } else {
76 | HashMap::new()
77 | },
78 | };
79 |
80 | // In odd iterations, add the passage to the inlined passages.
81 | if i % 2 == 1 {
82 | inlined_passages.push(passages);
83 | continue;
84 | }
85 |
86 | // In even iterations, write the passage to the `passages` directory.
87 | let passage_path = format!("passages/passages_{}.json", i);
88 | asset_builders.push(AssetBuilder {
89 | file_name: passage_path.clone(),
90 | contents: serde_json::to_string_pretty(&passages).unwrap(),
91 | });
92 | }
93 |
94 | CourseBuilder {
95 | directory_name: format!("transcription_course_{}", course_index),
96 | course_manifest: CourseManifest {
97 | id: course_id,
98 | name: format!("Course {}", course_id),
99 | dependencies: vec![],
100 | superseded: vec![],
101 | description: None,
102 | authors: None,
103 | metadata: None,
104 | course_material: None,
105 | course_instructions: None,
106 | generator_config: Some(CourseGenerator::Transcription(TranscriptionConfig {
107 | transcription_dependencies: dependencies,
108 | passage_directory: "passages".to_string(),
109 | inlined_passages,
110 | skip_singing_lessons,
111 | skip_advanced_lessons,
112 | })),
113 | },
114 | lesson_manifest_template: LessonManifestBuilder::default().clone(),
115 | lesson_builders: vec![],
116 | asset_builders,
117 | }
118 | }
119 |
120 | /// Verifies that all transcription exercises are visited.
121 | #[test]
122 | fn all_exercises_visited() -> Result<()> {
123 | // Initialize test course library.
124 | let temp_dir = TempDir::new()?;
125 | let mut trane = init_simulation(
126 | temp_dir.path(),
127 | &[
128 | transcription_builder(*COURSE0_ID, 0, vec![], 5, false, false),
129 | transcription_builder(*COURSE1_ID, 1, vec![*COURSE0_ID], 5, false, false),
130 | ],
131 | Some(&USER_PREFS),
132 | )?;
133 |
134 | // Run the simulation.
135 | let exercise_ids = trane.get_all_exercise_ids(None);
136 | assert!(!exercise_ids.is_empty());
137 | let mut simulation = TraneSimulation::new(
138 | exercise_ids.len() * 5,
139 | Box::new(|_| Some(MasteryScore::Five)),
140 | );
141 | simulation.run_simulation(&mut trane, &vec![], &None)?;
142 |
143 | // Every exercise in the advanced singing and advanced transcription lessons should be in
144 | // `simulation.answer_history`. Most of the exercises in the singing and transcription lessons
145 | // should be there as well, but since they are superseded by the advanced lessons, it' not
146 | // guaranteed that all of them will be there.
147 | for exercise_id in exercise_ids {
148 | assert!(
149 | simulation.answer_history.contains_key(&exercise_id),
150 | "exercise {:?} should have been scheduled",
151 | exercise_id
152 | );
153 | assert_simulation_scores(exercise_id, &trane, &simulation.answer_history)?;
154 | }
155 | Ok(())
156 | }
157 |
158 | /// Verifies that not making progress on the singing lessons blocks all further progress.
159 | #[test]
160 | fn no_progress_past_singing_lessons() -> Result<()> {
161 | // Initialize test course library.
162 | let temp_dir = TempDir::new()?;
163 | let mut trane = init_simulation(
164 | temp_dir.path(),
165 | &[
166 | transcription_builder(*COURSE0_ID, 0, vec![], 5, false, false),
167 | transcription_builder(*COURSE1_ID, 1, vec![*COURSE0_ID], 5, false, false),
168 | ],
169 | Some(&USER_PREFS),
170 | )?;
171 |
172 | // Run the simulation. Give every exercise a score of one, which should block all further
173 | // progress past the starting lessons.
174 | let exercise_ids = trane.get_all_exercise_ids(None);
175 | let mut simulation = TraneSimulation::new(
176 | exercise_ids.len() * 5,
177 | Box::new(|_| Some(MasteryScore::One)),
178 | );
179 | simulation.run_simulation(&mut trane, &vec![], &None)?;
180 |
181 | // Only exercises from the singing lessons of the first are in the answer history.
182 | for exercise_id in exercise_ids {
183 | if exercise_id.contains("transcription_course_0::singing") {
184 | assert!(
185 | simulation.answer_history.contains_key(&exercise_id),
186 | "exercise {:?} should have been scheduled",
187 | exercise_id
188 | );
189 | assert_simulation_scores(exercise_id, &trane, &simulation.answer_history)?;
190 | } else {
191 | assert!(
192 | !simulation.answer_history.contains_key(&exercise_id),
193 | "exercise {:?} should not have been scheduled",
194 | exercise_id
195 | );
196 | }
197 | }
198 | Ok(())
199 | }
200 |
201 | /// Verifies that not making progress on the advanced singing lessons blocks the advanced
202 | /// transcription lessons.
203 | #[test]
204 | fn advanced_singing_blocks_advanced_transcription() -> Result<()> {
205 | // Initialize test course library.
206 | let temp_dir = TempDir::new()?;
207 | let mut trane = init_simulation(
208 | temp_dir.path(),
209 | &[
210 | transcription_builder(*COURSE0_ID, 0, vec![], 5, false, false),
211 | transcription_builder(*COURSE1_ID, 1, vec![*COURSE0_ID], 5, false, false),
212 | ],
213 | Some(&USER_PREFS),
214 | )?;
215 |
216 | // Run the simulation. Give every advanced singing exercise a score of one, which should block
217 | // all progress on the advanced transcription lessons.
218 | let exercise_ids = trane.get_all_exercise_ids(None);
219 | let mut simulation = TraneSimulation::new(
220 | exercise_ids.len() * 5,
221 | Box::new(|exercise_id| {
222 | if exercise_id.contains("advanced_singing") {
223 | Some(MasteryScore::One)
224 | } else {
225 | Some(MasteryScore::Five)
226 | }
227 | }),
228 | );
229 | simulation.run_simulation(&mut trane, &vec![], &None)?;
230 |
231 | // Exercises from the advanced transcription lessons should not be in the answer history.
232 | for exercise_id in exercise_ids {
233 | if exercise_id.contains("advanced_transcription") {
234 | assert!(
235 | !simulation.answer_history.contains_key(&exercise_id),
236 | "exercise {:?} should not have been scheduled",
237 | exercise_id
238 | );
239 | assert_simulation_scores(exercise_id, &trane, &simulation.answer_history)?;
240 | } else {
241 | assert!(
242 | simulation.answer_history.contains_key(&exercise_id),
243 | "exercise {:?} should have been scheduled",
244 | exercise_id
245 | );
246 | }
247 | }
248 | Ok(())
249 | }
250 |
251 | /// Verifies that not making progress on the transcription lessons blocks the advanced transcription
252 | /// lessons from the same course and the transcription lessons from the next course.
253 | #[test]
254 | fn transcription_blocks_advanced_transcription_and_dependents() -> Result<()> {
255 | // Initialize test course library.
256 | let temp_dir = TempDir::new()?;
257 | let mut trane = init_simulation(
258 | temp_dir.path(),
259 | &[
260 | transcription_builder(*COURSE0_ID, 0, vec![], 5, false, false),
261 | transcription_builder(*COURSE1_ID, 1, vec![*COURSE0_ID], 5, false, false),
262 | ],
263 | Some(&USER_PREFS),
264 | )?;
265 |
266 | // Run the simulation. Give every transcription exercise from the first course a score of one,
267 | // which should block all progress on the advanced transcription lessons. It also blocks the
268 | // transcription lessons from the second course.
269 | let exercise_ids = trane.get_all_exercise_ids(None);
270 | let mut simulation = TraneSimulation::new(
271 | exercise_ids.len() * 5,
272 | Box::new(|exercise_id| {
273 | if exercise_id.contains("trane::test::transcription_course_0::transcription::") {
274 | Some(MasteryScore::One)
275 | } else {
276 | Some(MasteryScore::Five)
277 | }
278 | }),
279 | );
280 | simulation.run_simulation(&mut trane, &vec![], &None)?;
281 |
282 | // Exercises from the advanced transcription lesson from the first and the transcription lesson
283 | // from the dependent course should not be in the answer history.
284 | for exercise_id in exercise_ids {
285 | if exercise_id.contains("advanced_transcription")
286 | || exercise_id.contains("transcription_course_1::transcription")
287 | {
288 | assert!(
289 | !simulation.answer_history.contains_key(&exercise_id),
290 | "exercise {:?} should not have been scheduled",
291 | exercise_id
292 | );
293 | } else {
294 | assert!(
295 | simulation.answer_history.contains_key(&exercise_id),
296 | "exercise {:?} should have been scheduled",
297 | exercise_id
298 | );
299 | assert_simulation_scores(exercise_id, &trane, &simulation.answer_history)?;
300 | }
301 | }
302 | Ok(())
303 | }
304 |
305 | /// Verifies that all transcription exercises are visited when the advanced lessons are skipped.
306 | #[test]
307 | fn skip_advanced_lessons() -> Result<()> {
308 | // Initialize test course library. Skip the advanced lessons.
309 | let temp_dir = TempDir::new()?;
310 | let mut trane = init_simulation(
311 | temp_dir.path(),
312 | &[
313 | transcription_builder(*COURSE0_ID, 0, vec![], 5, false, true),
314 | transcription_builder(*COURSE1_ID, 1, vec![*COURSE0_ID], 5, false, true),
315 | ],
316 | Some(&USER_PREFS),
317 | )?;
318 |
319 | // Run the simulation.
320 | let exercise_ids = trane.get_all_exercise_ids(None);
321 | assert!(!exercise_ids.is_empty());
322 | let mut simulation = TraneSimulation::new(
323 | exercise_ids.len() * 5,
324 | Box::new(|_| Some(MasteryScore::Five)),
325 | );
326 | simulation.run_simulation(&mut trane, &vec![], &None)?;
327 |
328 | // Every exercise ID should be in `simulation.answer_history`.
329 | for exercise_id in &exercise_ids {
330 | assert!(
331 | simulation.answer_history.contains_key(exercise_id),
332 | "exercise {:?} should have been scheduled",
333 | exercise_id
334 | );
335 | assert_simulation_scores(*exercise_id, &trane, &simulation.answer_history)?;
336 | }
337 |
338 | // No exercises from the advanced lessons should have been generated.
339 | for exercise_id in exercise_ids {
340 | assert!(
341 | !exercise_id.contains("advanced_"),
342 | "exercise {:?} should not have been generated",
343 | exercise_id
344 | );
345 | }
346 | Ok(())
347 | }
348 |
349 | /// Verifies that all transcription exercises are visited when the singing lessons are skipped.
350 | #[test]
351 | fn skip_singing_lessons() -> Result<()> {
352 | // Initialize test course library. Skip the advanced lessons.
353 | let temp_dir = TempDir::new()?;
354 | let mut trane = init_simulation(
355 | temp_dir.path(),
356 | &[
357 | transcription_builder(*COURSE0_ID, 0, vec![], 5, true, false),
358 | transcription_builder(*COURSE1_ID, 1, vec![*COURSE0_ID], 5, true, false),
359 | ],
360 | Some(&USER_PREFS),
361 | )?;
362 |
363 | // Run the simulation.
364 | let exercise_ids = trane.get_all_exercise_ids(None);
365 | assert!(!exercise_ids.is_empty());
366 | let mut simulation = TraneSimulation::new(
367 | exercise_ids.len() * 5,
368 | Box::new(|_| Some(MasteryScore::Five)),
369 | );
370 | simulation.run_simulation(&mut trane, &vec![], &None)?;
371 |
372 | // Every exercise ID should be in `simulation.answer_history`.
373 | for exercise_id in &exercise_ids {
374 | assert!(
375 | simulation.answer_history.contains_key(exercise_id),
376 | "exercise {:?} should have been scheduled",
377 | exercise_id
378 | );
379 | assert_simulation_scores(*exercise_id, &trane, &simulation.answer_history)?;
380 | }
381 |
382 | // No exercises from the singing lessons should have been generated.
383 | for exercise_id in exercise_ids {
384 | assert!(
385 | !exercise_id.contains("singing"),
386 | "exercise {:?} should not have been generated",
387 | exercise_id
388 | );
389 | }
390 | Ok(())
391 | }
392 |
--------------------------------------------------------------------------------
/src/data/course_generator/music_piece.rs:
--------------------------------------------------------------------------------
1 | //! Contains the logic for generating a course to learn a piece of music.
2 | //!
3 | //! Given a piece of music and the passages and sub-passages in which it is divided, this module
4 | //! generates a course that allows the user to learn the piece of music by first practicing the
5 | //! smallest passages and then working up until the full piece is mastered.
6 |
7 | use anyhow::Result;
8 | use indoc::{formatdoc, indoc};
9 | use serde::{Deserialize, Serialize};
10 | use std::{collections::HashMap, path::Path};
11 | use ustr::Ustr;
12 |
13 | use crate::data::{
14 | BasicAsset, CourseManifest, ExerciseAsset, ExerciseManifest, ExerciseType, GenerateManifests,
15 | GeneratedCourse, LessonManifest, UserPreferences,
16 | };
17 |
18 | /// The common instructions for all lessons in the course.
19 | const INSTRUCTIONS: &str = indoc! {"
20 | Given the following passage from the piece, start by listening to it repeatedly
21 | until you can audiate it clearly in your head. You can also attempt to hum or
22 | sing it if possible. Then, play the passage on your instrument.
23 | "};
24 |
25 | //@@music-asset
36 |
37 | impl MusicAsset {
38 | /// Generates an exercise asset from this music asset.
39 | #[must_use]
40 | pub fn generate_exercise_asset(&self, start: &str, end: &str) -> ExerciseAsset {
41 | match self {
42 | MusicAsset::SoundSlice(url) => {
43 | let description = formatdoc! {"
44 | {}
45 | - Passage start: {}
46 | - Passage end: {}
47 | ", INSTRUCTIONS, start, end};
48 | ExerciseAsset::SoundSliceAsset {
49 | link: url.clone(),
50 | description: Some(description),
51 | backup: None,
52 | }
53 | }
54 | MusicAsset::LocalFile(path) => {
55 | let description = formatdoc! {"
56 | {}
57 | - Passage start: {}
58 | - Passage end: {}
59 |
60 | The file containing the music sheet is located at {}. Relative paths are
61 | relative to the working directory.
62 | ", INSTRUCTIONS, start, end, path};
63 | ExerciseAsset::BasicAsset(BasicAsset::InlinedAsset {
64 | content: description,
65 | })
66 | }
67 | }
68 | }
69 | }
70 |
71 | //@,
87 | }
88 | //>@music-passage
89 |
90 | impl MusicPassage {
91 | /// Generates the lesson ID for this course and passage, identified by the given path.
92 | fn generate_lesson_id(course_manifest: &CourseManifest, passage_path: &[usize]) -> Ustr {
93 | let lesson_id = passage_path
94 | .iter()
95 | .map(|index| format!("{index}"))
96 | .collect::>()
97 | .join("::");
98 | Ustr::from(&format!("{}::{}", course_manifest.id, lesson_id))
99 | }
100 |
101 | /// Generates a clone of the given path with the given index appended.
102 | fn new_path(passage_path: &[usize], index: usize) -> Vec {
103 | let mut new_path = passage_path.to_vec();
104 | new_path.push(index);
105 | new_path
106 | }
107 |
108 | /// Generates the lesson and exercise manifests for this passage, recursively doing so if the
109 | /// dependencies are not empty.
110 | #[must_use]
111 | pub fn generate_lesson_helper(
112 | &self,
113 | course_manifest: &CourseManifest,
114 | passage_path: &[usize],
115 | music_asset: &MusicAsset,
116 | ) -> Vec<(LessonManifest, Vec)> {
117 | // Recursively generate the dependency lessons and IDs.
118 | let mut lessons = vec![];
119 | let mut dependency_ids = vec![];
120 | for (index, sub_passage) in &self.sub_passages {
121 | // Create the dependency path.
122 | let dependency_path = Self::new_path(passage_path, *index);
123 |
124 | // Generate the dependency ID and lessons.
125 | dependency_ids.push(Self::generate_lesson_id(course_manifest, &dependency_path));
126 | lessons.append(&mut sub_passage.generate_lesson_helper(
127 | course_manifest,
128 | &dependency_path,
129 | music_asset,
130 | ));
131 | }
132 |
133 | // Create the lesson and exercise manifests for this passage and add them to the list.
134 | let lesson_manifest = LessonManifest {
135 | id: Self::generate_lesson_id(course_manifest, passage_path),
136 | course_id: course_manifest.id,
137 | name: course_manifest.name.clone(),
138 | description: None,
139 | dependencies: dependency_ids,
140 | superseded: vec![],
141 | metadata: None,
142 | lesson_instructions: None,
143 | lesson_material: None,
144 | };
145 | let exercise_manifest = ExerciseManifest {
146 | id: Ustr::from(&format!("{}::exercise", lesson_manifest.id)),
147 | lesson_id: lesson_manifest.id,
148 | course_id: course_manifest.id,
149 | name: course_manifest.name.clone(),
150 | description: None,
151 | exercise_type: ExerciseType::Procedural,
152 | exercise_asset: music_asset.generate_exercise_asset(&self.start, &self.end),
153 | };
154 | lessons.push((lesson_manifest, vec![exercise_manifest]));
155 |
156 | lessons
157 | }
158 |
159 | /// Generates the lesson and exercise manifests for this passage.
160 | #[must_use]
161 | pub fn generate_lessons(
162 | &self,
163 | course_manifest: &CourseManifest,
164 | music_asset: &MusicAsset,
165 | ) -> Vec<(LessonManifest, Vec)> {
166 | // Use a starting path of [0].
167 | self.generate_lesson_helper(course_manifest, &[0], music_asset)
168 | }
169 | }
170 |
171 | //@@music-piece-config
182 |
183 | impl GenerateManifests for MusicPieceConfig {
184 | fn generate_manifests(
185 | &self,
186 | _course_root: &Path,
187 | course_manifest: &CourseManifest,
188 | _preferences: &UserPreferences,
189 | ) -> Result {
190 | Ok(GeneratedCourse {
191 | lessons: self
192 | .passages
193 | .generate_lessons(course_manifest, &self.music_asset),
194 | updated_instructions: None,
195 | updated_metadata: None,
196 | })
197 | }
198 | }
199 |
200 | #[cfg(test)]
201 | #[cfg_attr(coverage, coverage(off))]
202 | mod test {
203 | use super::*;
204 |
205 | // Verifies generating a valid exercise asset from a local file.
206 | #[test]
207 | fn generate_local_music_asset() {
208 | let music_asset = MusicAsset::LocalFile("music.pdf".to_string());
209 | let passage = MusicPassage {
210 | start: "start".to_string(),
211 | end: "end".to_string(),
212 | sub_passages: HashMap::new(),
213 | };
214 | let exercise_asset = music_asset.generate_exercise_asset(&passage.start, &passage.end);
215 | assert_eq!(
216 | exercise_asset,
217 | ExerciseAsset::BasicAsset(BasicAsset::InlinedAsset {
218 | content: indoc! {"
219 | Given the following passage from the piece, start by listening to it repeatedly
220 | until you can audiate it clearly in your head. You can also attempt to hum or
221 | sing it if possible. Then, play the passage on your instrument.
222 |
223 | - Passage start: start
224 | - Passage end: end
225 |
226 | The file containing the music sheet is located at music.pdf. Relative paths are
227 | relative to the working directory.
228 | "}
229 | .to_string()
230 | })
231 | );
232 | }
233 |
234 | // Verifies generating a valid exercise asset from a SoundSlice.
235 | #[test]
236 | fn generate_sound_slice_asset() {
237 | let music_asset = MusicAsset::SoundSlice("https://soundslice.com".to_string());
238 | let passage = MusicPassage {
239 | start: "start".to_string(),
240 | end: "end".to_string(),
241 | sub_passages: HashMap::new(),
242 | };
243 | let exercise_asset = music_asset.generate_exercise_asset(&passage.start, &passage.end);
244 | assert_eq!(
245 | exercise_asset,
246 | ExerciseAsset::SoundSliceAsset {
247 | link: "https://soundslice.com".to_string(),
248 | description: Some(
249 | indoc! {"
250 | Given the following passage from the piece, start by listening to it repeatedly
251 | until you can audiate it clearly in your head. You can also attempt to hum or
252 | sing it if possible. Then, play the passage on your instrument.
253 |
254 | - Passage start: start
255 | - Passage end: end
256 | "}
257 | .to_string()
258 | ),
259 | backup: None,
260 | }
261 | );
262 | }
263 |
264 | // Verfies generating lesson IDs for a music piece course.
265 | #[test]
266 | fn generate_lesson_id() {
267 | let course_manifest = CourseManifest {
268 | id: "course".into(),
269 | name: "Course".to_string(),
270 | description: None,
271 | dependencies: vec![],
272 | superseded: vec![],
273 | metadata: None,
274 | course_instructions: None,
275 | course_material: None,
276 | authors: None,
277 | generator_config: None,
278 | };
279 | assert_eq!(
280 | MusicPassage::generate_lesson_id(&course_manifest, &[0]),
281 | "course::0"
282 | );
283 | assert_eq!(
284 | MusicPassage::generate_lesson_id(&course_manifest, &[0, 1]),
285 | "course::0::1"
286 | );
287 | assert_eq!(
288 | MusicPassage::generate_lesson_id(&course_manifest, &[0, 1, 2]),
289 | "course::0::1::2"
290 | );
291 | }
292 |
293 | // Verifies the paths for the sub-passages are created correctly.
294 | #[test]
295 | fn new_path() {
296 | assert_eq!(MusicPassage::new_path(&[0], 1), vec![0, 1]);
297 | assert_eq!(MusicPassage::new_path(&[0, 1], 2), vec![0, 1, 2]);
298 | assert_eq!(MusicPassage::new_path(&[0, 1, 2], 3), vec![0, 1, 2, 3]);
299 | }
300 |
301 | // Verifies generating lessons for a music piece course.
302 | #[test]
303 | fn generate_lessons() {
304 | let course_manifest = CourseManifest {
305 | id: "course".into(),
306 | name: "Course".to_string(),
307 | description: None,
308 | dependencies: vec![],
309 | superseded: vec![],
310 | metadata: None,
311 | course_instructions: None,
312 | course_material: None,
313 | authors: None,
314 | generator_config: None,
315 | };
316 | let music_asset = MusicAsset::LocalFile("music.pdf".to_string());
317 | let passage = MusicPassage {
318 | start: "start 0".to_string(),
319 | end: "end 0".to_string(),
320 | sub_passages: HashMap::from([(
321 | 0,
322 | MusicPassage {
323 | start: "start 0::0".to_string(),
324 | end: "end 0::0".to_string(),
325 | sub_passages: HashMap::new(),
326 | },
327 | )]),
328 | };
329 | let lessons = passage.generate_lessons(&course_manifest, &music_asset);
330 | assert_eq!(lessons.len(), 2);
331 |
332 | let (lesson_manifest, exercise_manifests) = &lessons[1];
333 | assert_eq!(lesson_manifest.id, "course::0");
334 | assert_eq!(lesson_manifest.name, "Course");
335 | assert_eq!(lesson_manifest.description, None);
336 | assert_eq!(lesson_manifest.course_id, "course");
337 | assert_eq!(lesson_manifest.dependencies, vec!["course::0::0"]);
338 | assert_eq!(exercise_manifests.len(), 1);
339 |
340 | let exercise_manifest = &exercise_manifests[0];
341 | assert_eq!(exercise_manifest.id, "course::0::exercise");
342 | assert_eq!(exercise_manifest.name, "Course");
343 | assert_eq!(exercise_manifest.description, None);
344 | assert_eq!(exercise_manifest.lesson_id, "course::0");
345 | assert_eq!(exercise_manifest.course_id, "course");
346 | assert_eq!(
347 | exercise_manifest.exercise_asset,
348 | ExerciseAsset::BasicAsset(BasicAsset::InlinedAsset {
349 | content: indoc! {"
350 | Given the following passage from the piece, start by listening to it repeatedly
351 | until you can audiate it clearly in your head. You can also attempt to hum or
352 | sing it if possible. Then, play the passage on your instrument.
353 |
354 | - Passage start: start 0
355 | - Passage end: end 0
356 |
357 | The file containing the music sheet is located at music.pdf. Relative paths are
358 | relative to the working directory.
359 | "}
360 | .to_string()
361 | })
362 | );
363 |
364 | let (lesson_manifest, exercise_manifests) = &lessons[0];
365 | assert_eq!(lesson_manifest.id, "course::0::0");
366 | assert_eq!(lesson_manifest.name, "Course");
367 | assert_eq!(lesson_manifest.description, None);
368 | assert_eq!(lesson_manifest.course_id, "course");
369 | assert!(lesson_manifest.dependencies.is_empty());
370 | assert_eq!(exercise_manifests.len(), 1);
371 |
372 | let exercise_manifest = &exercise_manifests[0];
373 | assert_eq!(exercise_manifest.id, "course::0::0::exercise");
374 | assert_eq!(exercise_manifest.name, "Course");
375 | assert_eq!(exercise_manifest.description, None);
376 | assert_eq!(exercise_manifest.lesson_id, "course::0::0");
377 | assert_eq!(exercise_manifest.course_id, "course");
378 | assert_eq!(
379 | exercise_manifest.exercise_asset,
380 | ExerciseAsset::BasicAsset(BasicAsset::InlinedAsset {
381 | content: indoc! {"
382 | Given the following passage from the piece, start by listening to it repeatedly
383 | until you can audiate it clearly in your head. You can also attempt to hum or
384 | sing it if possible. Then, play the passage on your instrument.
385 |
386 | - Passage start: start 0::0
387 | - Passage end: end 0::0
388 |
389 | The file containing the music sheet is located at music.pdf. Relative paths are
390 | relative to the working directory.
391 | "}
392 | .to_string()
393 | })
394 | );
395 | }
396 | }
397 |
--------------------------------------------------------------------------------