├── 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 | [![Github Checks Status](https://img.shields.io/github/checks-status/trane-project/trane/master)](https://github.com/trane-project/trane/actions?query=branch%3Amaster) 4 | [![Coverage Status](https://img.shields.io/coverallsCoverage/github/trane-project/trane)](https://coveralls.io/github/trane-project/trane?branch=master) 5 | [![docs.rs](https://img.shields.io/docsrs/trane)](https://docs.rs/trane) 6 | [![Latest Version](https://img.shields.io/crates/v/trane)](https://crates.io/crates/trane) 7 | [![Stars](https://img.shields.io/github/stars/trane-project/trane?style=social)](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 | --------------------------------------------------------------------------------