diff options
| author | tslil <tslil@posteo.de> | 2026-07-13 15:06:41 +0100 |
|---|---|---|
| committer | tslil <tslil@posteo.de> | 2026-07-13 21:21:21 +0100 |
| commit | 3f289981bd9c8e800a6cd310d4b1f2f36634c9f1 (patch) | |
| tree | ba711eb220e39fe64388d370b16384a7bc84eaab | |
| parent | e0241df8afde0483fc36cb2300b53127672486dc (diff) | |
Pure history
| -rw-r--r-- | Cargo.lock | 363 | ||||
| -rw-r--r-- | Cargo.toml | 11 | ||||
| -rw-r--r-- | src/cli.rs | 60 | ||||
| -rw-r--r-- | src/config.rs | 47 | ||||
| -rw-r--r-- | src/learner.rs | 201 | ||||
| -rw-r--r-- | src/main.rs | 146 | ||||
| -rw-r--r-- | src/state.rs | 81 | ||||
| -rw-r--r-- | src/trajectory.rs | 39 |
8 files changed, 680 insertions, 268 deletions
@@ -3,6 +3,15 @@ version = 4 [[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] name = "arrayref" version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -15,18 +24,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" [[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] name = "base64" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" [[package]] -name = "bitflags" -version = "2.13.0" +name = "bincode" +version = "1.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" dependencies = [ - "serde_core", + "serde", ] [[package]] @@ -41,6 +56,22 @@ dependencies = [ ] [[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cc" +version = "1.2.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -58,19 +89,29 @@ dependencies = [ ] [[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] name = "constant_time_eq" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" [[package]] -name = "convert_case" -version = "0.10.0" +name = "core-foundation-sys" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" -dependencies = [ - "unicode-segmentation", -] +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "cpufeatures" @@ -88,29 +129,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] -name = "derive_more" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" -dependencies = [ - "derive_more-impl", -] - -[[package]] -name = "derive_more-impl" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" -dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "rustc_version", - "syn", - "unicode-xid", -] - -[[package]] name = "directories" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -154,6 +172,36 @@ dependencies = [ ] [[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] name = "getrandom" version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -188,6 +236,47 @@ dependencies = [ ] [[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -209,19 +298,41 @@ dependencies = [ ] [[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] name = "mood" version = "0.1.0" dependencies = [ - "derive_more", + "bincode", + "chrono", "directories", "expanduser", "rand", - "ron", "serde", + "serde_json", "walkdir", ] [[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -234,6 +345,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] name = "proc-macro2" version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -313,20 +430,6 @@ dependencies = [ ] [[package]] -name = "ron" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3" -dependencies = [ - "bitflags", - "once_cell", - "serde", - "serde_derive", - "typeid", - "unicode-ident", -] - -[[package]] name = "rust-argon2" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -339,13 +442,10 @@ dependencies = [ ] [[package]] -name = "rustc_version" -version = "0.4.1" +name = "rustversion" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "same-file" @@ -357,12 +457,6 @@ dependencies = [ ] [[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - -[[package]] name = "serde" version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -393,6 +487,31 @@ dependencies = [ ] [[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] name = "syn" version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -444,30 +563,12 @@ dependencies = [ ] [[package]] -name = "typeid" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" - -[[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] -name = "unicode-segmentation" -version = "1.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" - -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - -[[package]] name = "walkdir" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -490,6 +591,51 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] name = "winapi" version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -521,12 +667,65 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] name = "windows-sys" version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -534,3 +733,9 @@ checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" @@ -1,13 +1,20 @@ [package] name = "mood" version = "0.1.0" +description = "A music player governed by your moods." edition = "2024" [dependencies] -derive_more = { version = "2.1.1", features = ["display"] } +bincode = "=1.3.3" +chrono = "0.4.45" directories = "6.0.0" expanduser = "1.2.2" rand = "0.10.2" -ron = "0.12.2" serde = {version = "1.0.228", features = ["derive"] } +serde_json = "1.0.150" walkdir = "2.5.0" + +[profile.release] +strip = true +lto = "thin" +opt-level = 3 diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..f1563b3 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,60 @@ +use std::env; + +#[derive(Debug, PartialEq)] +pub enum Action { + NewConfig, + NewState, + DumpTrajectory, + DumpLearner, + DumpAlbums, + Run, +} + +const NAME: &str = env!("CARGO_PKG_NAME"); +const VERSION: &str = env!("CARGO_PKG_VERSION"); +const DESCRIPTION: &str = env!("CARGO_PKG_DESCRIPTION"); + +fn print_help() { + println!( + "\ + {NAME}: {DESCRIPTION} +Usage: {NAME} [OPTION] + +Options: + --help Show this help message. + --version Show version information. + --new-config Write a default config file and exit. + --new-state Write a default state file and exit. + --dump-trajectory Dump the play trajectory as JSON to stdout. + --dump-learner Dump learner weights as JSON to stdout. + --dump-albums Dump album list from state as JSON to stdout. +<no option> Suggest and play the next album" + ); +} + +pub fn parse() -> Option<Action> { + let mut args = env::args(); + + let _binary_name = args.next(); + + match args.next().as_deref() { + None => Some(Action::Run), + Some("--help") | Some("-h") => { + print_help(); + None + } + Some("--version") | Some("-V") | Some("-v") => { + println!("{NAME} {VERSION}"); + None + } + Some("--new-config") => Some(Action::NewConfig), + Some("--new-state") => Some(Action::NewState), + Some("--dump-trajectory") => Some(Action::DumpTrajectory), + Some("--dump-learner") => Some(Action::DumpLearner), + Some("--dump-albums") => Some(Action::DumpAlbums), + Some(unknown) => { + eprintln!("Unknown option: {unknown}"); + std::process::exit(1); + } + } +} diff --git a/src/config.rs b/src/config.rs index 0be1d09..f38b571 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,26 +1,44 @@ use directories::{ProjectDirs, UserDirs}; use serde::{Deserialize, Serialize}; +use std::fs; + +use expanduser::expanduser; #[derive(Serialize, Deserialize)] pub struct Config { pub music_roots: Vec<String>, pub audio_exts: Vec<String>, pub skip_window_secs: u64, - pub learning_rate: f32, - pub decay_rate: f32, - pub min_score_thresh: f32, pub temperature: f32, + pub low_weight: f32, + pub mid_weight: f32, + pub high_weight: f32, + pub max_weight: f32, } -pub fn default_state_path() -> String { +fn default_config_path() -> String { + let name = "mood.json"; ProjectDirs::from("qualifier", "organisation", "mood") - .and_then(|pd| { - pd.config_dir() - .join("mood.ron") - .to_str() - .map(|x| x.to_string()) - }) - .unwrap_or("~/.config/mood/mood.ron".to_string()) + .and_then(|pd| pd.config_dir().join(name).to_str().map(|x| x.to_string())) + .unwrap_or(format!("~/.config/mood/{name}")) +} + +impl Config { + pub fn try_load() -> Result<Config, String> { + expanduser(default_config_path()) + .map_err(|e| e.to_string()) + .and_then(|p| fs::read_to_string(p).map_err(|e| e.to_string())) + .and_then(|contents| serde_json::from_str(&contents).map_err(|e| e.to_string())) + } + + pub fn try_save(&self) -> Result<(), String> { + let path = expanduser(default_config_path()).map_err(|e| e.to_string())?; + _ = path.parent().map(std::fs::create_dir_all); + let serialised = serde_json::to_string_pretty(self) + .map_err(|e| format!("Failed to serialize config: {}", e))?; + fs::write(&path, serialised).map_err(|e| format!("Failed to write config file: {}", e))?; + Ok(()) + } } impl Default for Config { @@ -39,10 +57,11 @@ impl Default for Config { .map(String::from) .collect(), skip_window_secs: 60, - learning_rate: 0.1, - decay_rate: 0.001, - min_score_thresh: 1e-5, temperature: 0.6, + low_weight: 0.25, + mid_weight: 0.5, + high_weight: 0.75, + max_weight: 1.0, } } } diff --git a/src/learner.rs b/src/learner.rs index 91b24fe..59db567 100644 --- a/src/learner.rs +++ b/src/learner.rs @@ -5,24 +5,15 @@ use serde::{Deserialize, Serialize}; use crate::{config::Config, trajectory::Action}; -fn key(a: &str, b: &str) -> (String, String) { - let a = a.to_string(); - let b = b.to_string(); - if a < b { (a, b) } else { (b, a) } -} - -fn compute_sum( - cand: &str, - known_keys: &HashSet<&String>, - map: &HashMap<(String, String), f32>, -) -> f32 { - known_keys - .iter() - .filter_map(|k| map.get(&key(k, cand))) - .sum() +fn compute_weight(past: &HashSet<String>, now: &HashSet<&String>) -> f32 { + let inter = now.iter().filter(|&&p| past.contains(p)).count(); + if inter == 0 { + return 0.0; + } + (inter as f32) / ((now.len() as f32).sqrt() * (past.len() as f32).sqrt()) } -fn softmax_sample(items: &[(&String, f32)], temperature: f32) -> Option<(String, f32)> { +fn softmax_sample(items: &[(String, f32)], temperature: f32) -> Option<(String, f32)> { if items.is_empty() { return None; } @@ -35,7 +26,7 @@ fn softmax_sample(items: &[(&String, f32)], temperature: f32) -> Option<(String, let exp_values: Vec<_> = items .iter() - .map(|&(k, val)| (k, ((val - max_val) / temperature).exp())) + .map(|(k, val)| (k, ((val - max_val) / temperature).exp())) .collect(); let sum: f32 = exp_values.iter().map(|(_, v)| *v).sum(); @@ -48,80 +39,79 @@ fn softmax_sample(items: &[(&String, f32)], temperature: f32) -> Option<(String, .ok() } +#[derive(Serialize, Deserialize)] +enum Episode { + Continue { + group: HashSet<String>, + avoid: Option<String>, + committed: String, + }, + Escape { + from: HashSet<String>, + to: String, + committed: String, + }, +} + +impl Episode { + fn prune_mut(&mut self, valid: &HashSet<String>) -> bool { + match self { + Episode::Continue { group, avoid, .. } => { + if let Some(a) = avoid + && valid.contains(a) + { + } else { + *avoid = None; + }; + group.retain(|g| valid.contains(g)); + !group.is_empty() + } + Episode::Escape { from, to, .. } => { + if !valid.contains(to) { + return false; + }; + from.retain(|f| valid.contains(f)); + !from.is_empty() + } + } + } +} + #[derive(Serialize, Deserialize, Default)] pub struct Learner { - similar: HashMap<(String, String), f32>, - different: HashMap<(String, String), f32>, + history: Vec<Episode>, } -#[derive(Debug)] pub enum Learning { - SkipExtend, - MoreExtend(Vec<String>, String), SkipToMore(Vec<String>, String), MoreToSkip(Vec<String>, String), } impl Learner { pub fn prune(&mut self, valid: &HashSet<String>) { - self.similar - .retain(|(a, b), _v| valid.contains(a) && valid.contains(b)); - self.different - .retain(|(a, b), _v| valid.contains(a) && valid.contains(b)); + self.history.retain_mut(|e| e.prune_mut(valid)); } +} - pub fn learn(&mut self, config: &Config, learning: &Learning) { - // global decay - for (_k, v) in self.similar.iter_mut().chain(self.different.iter_mut()) { - *v *= 1.0 - config.decay_rate; - } - // update +impl Learner { + pub fn learn(&mut self, learning: &Learning) { + let stamp = chrono::Utc::now().format("U%Y%m%d-%H%M%S").to_string(); match learning { - Learning::SkipExtend => { - // This is the least information carrying case, it does not - // follow that the trajectory contains similar or different - // items, we may simply be seeking something in particular. - } - Learning::MoreExtend(trajectory, new) => { - // We're continuing a good run so `new` is similar to everything - // in `trajectory`, but to account for the possibilty that our - // mood has changed over the course of this streak we damp - // sub-linearly that update by a proxy of temporal distance. - for (distance, e) in trajectory.iter().rev().enumerate() { - let damp = ((distance + 1) as f32).powf(-0.5); - let v = self.similar.entry(key(e, new)).or_default(); - *v = (1.0 - config.learning_rate) * *v + config.learning_rate * damp; - } - } Learning::SkipToMore(trajectory, new) => { - // We have learnt that `new` is different to everything in - // `trajectory`, the strongest signal we have. - for e in trajectory { - let v = self.different.entry(key(e, new)).or_default(); - *v = (1.0 - config.learning_rate) * *v + config.learning_rate; - } + self.history.push(Episode::Escape { + from: trajectory.iter().map(String::clone).collect(), + to: new.clone(), + committed: stamp, + }); } Learning::MoreToSkip(trajectory, new) => { - // `new` could be different to everything in `trajectory`, or we - // simply changed our minds, so we have only weak evidence of - // difference. The positive coherence of trajectory was taken - // care of during MoreExtend above. - let damp = (trajectory.len() + 1) as f32; - for e in trajectory { - let v = self.different.entry(key(e, new)).or_default(); - *v = (1.0 - config.learning_rate) * *v + config.learning_rate / damp; - } + self.history.push(Episode::Continue { + group: trajectory.iter().map(String::clone).collect(), + avoid: Some(new.clone()), + committed: stamp, + }); } }; - // global threshold drop - self.similar = self - .similar - .extract_if(|_k, v| (*v).abs() > config.min_score_thresh) - .collect(); - self.different = self - .different - .extract_if(|_k, v| (*v).abs() > config.min_score_thresh) - .collect(); } pub fn sample( @@ -129,10 +119,9 @@ impl Learner { trajectory: &[String], action: &Action, candidates: &HashSet<String>, - temperature: f32, + config: &Config, ) -> Option<(String, f32)> { let trajectory: HashSet<_> = trajectory.iter().collect(); - let normalisation: f32 = f32::max(trajectory.len() as f32, 1.0); let candidates: Vec<_> = candidates .iter() @@ -143,19 +132,57 @@ impl Learner { return None; } - let items = candidates - .iter() - .map(|&c| { - let sim = compute_sum(c, &trajectory, &self.similar); - let dif = compute_sum(c, &trajectory, &self.different); - let score = match action { - Action::Skip => dif - sim, - Action::More => sim - dif, - }; - (c, score / normalisation) - }) - .collect::<Vec<_>>(); + let mut items: HashMap<String, f32> = + candidates.into_iter().map(|c| (c.clone(), 0.0)).collect(); + + for episode in self.history.iter() { + match (episode, action) { + (Episode::Escape { from, to, .. }, Action::Skip) => { + let w = compute_weight(from, &trajectory); + if let Some(to_w) = items.get_mut(to) { + *to_w += config.max_weight * w; + } + for f in from { + if let Some(from_weight) = items.get_mut(f) { + *from_weight -= config.mid_weight * w; + } + } + } + (Episode::Escape { from, to, .. }, Action::More) => { + let w = compute_weight(from, &trajectory); + if trajectory.contains(to) { + for f in from { + if let Some(f_w) = items.get_mut(f) { + *f_w -= config.low_weight * w; + } + } + } + } + (Episode::Continue { group, avoid, .. }, Action::More) => { + let w = compute_weight(group, &trajectory); + for g in group { + if let Some(g_w) = items.get_mut(g) { + *g_w += config.max_weight * w; + } + } + if let Some(a) = avoid + && let Some(a_w) = items.get_mut(a) + { + *a_w -= config.low_weight * w; + } + } + (Episode::Continue { group, .. }, Action::Skip) => { + let w = compute_weight(group, &trajectory); + for g in group { + if let Some(v) = items.get_mut(g) { + *v -= config.high_weight * w; + } + } + } + } + } - softmax_sample(&items, temperature) + let pairs: Vec<_> = items.into_iter().collect(); + softmax_sample(&pairs, config.temperature) } } diff --git a/src/main.rs b/src/main.rs index 74f90c8..86c6341 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,61 +1,135 @@ -use std::process::Command; +use std::{process::Command, process::exit}; +mod cli; mod config; mod learner; mod state; mod trajectory; -use crate::{config::default_state_path, state::State}; +use crate::cli::{Action, parse}; +use crate::config::Config; +use crate::state::State; fn mpc_load_and_play(path: &str) { - _ = Command::new("mpc") - .arg("clear") - .output() - .expect("failed to execute mpc clear"); - - _ = Command::new("mpc") - .args(["add", path]) - .output() - .expect("failed to execute mpc add"); - - _ = Command::new("mpc") - .arg("play") - .output() - .expect("failed to execute mpc play"); + _ = Command::new("mpc").arg("clear").output(); + + _ = Command::new("mpc").args(["add", path]).output(); + + _ = Command::new("mpc").arg("play").output(); } fn notify(text: &str) { if let Err(e) = Command::new("notify-send") - .args(["-t", "5000", "-a", "mood", &text]) + .args(["-t", "5000", "-a", "mood", text]) .status() { eprintln!("Failed to show notification: {e}"); } } +fn load_state_no_default() -> State { + State::try_load().unwrap_or_else(|e| { + eprintln!("Failed to load state: {e}, nothing to dump."); + exit(1); + }) +} + fn main() { - let state = State::load(&default_state_path()); - let mut state = match state { - Err(e) => { - eprintln!("Failed to load state: {e}"); - State::default() - } - Ok(s) => s, + let action = parse(); + if action.is_none() { + return; }; - let ur = state.update_albums(); - println!("Update result: {ur}"); + let action = action.unwrap(); - if let Some((next_album, prob)) = state.next() { - let text = format!("Now playing: {next_album} ({:.2}%)", prob * 100.0); - println!("{text}"); - mpc_load_and_play(&next_album); - notify(&text); - } else { - println!("No next album available."); - } + match action { + Action::DumpAlbums => { + let state = load_state_no_default(); + println!( + "{}", + state.dump_albums().unwrap_or_else(|e| { + eprintln!("Failed to serialize state (dump-albums): {e}"); + exit(1); + }) + ); + } + + Action::DumpLearner => { + let state = load_state_no_default(); + println!( + "{}", + state.dump_learner().unwrap_or_else(|e| { + eprintln!("Failed to serialize state (dump-learner): {e}"); + exit(1); + }) + ); + } + + Action::DumpTrajectory => { + let state = load_state_no_default(); + println!( + "{}", + state.dump_trajectory().unwrap_or_else(|e| { + eprintln!("Failed to serialize state (dump-trajectory): {e}"); + exit(1); + }) + ); + } + + Action::NewState => { + let default = State::default(); + if let Err(e) = default.try_save() { + eprintln!("Failed to write new state: {e}"); + exit(1); + } + println!("New state written."); + } + + Action::NewConfig => { + let default = Config::default(); + if let Err(e) = default.try_save() { + eprintln!("Failed to write new config: {e}"); + exit(1); + } + println!("New config written."); + } - if let Err(e) = state.try_save() { - eprintln!("Failed to save state: {e}"); + Action::Run => { + let config = match Config::try_load() { + Ok(c) => c, + Err(e) => { + eprintln!("Failed to load config: {e}, writing defaults."); + let default = Config::default(); + if let Err(se) = default.try_save() { + panic!("Failed to save default config: {se}"); + } + default + } + }; + + let mut state = match State::try_load() { + Ok(s) => s, + Err(e) => { + eprintln!("Failed to load state: {e}"); + State::default() + } + }; + + let ur = state.update_albums(&config); + println!("Update result: {ur}"); + + if let Some((next_album, prob)) = state.next(&config) { + let text = format!("Now playing: {next_album} ({:.2}%)", prob * 100.0); + println!("{text}"); + mpc_load_and_play(&next_album); + notify(&text); + } else { + println!("No next album available."); + } + + if let Err(e) = state.try_save() { + panic!("Failed to save state: {e}"); + } + } } } diff --git a/src/state.rs b/src/state.rs index b9a6618..a04412f 100644 --- a/src/state.rs +++ b/src/state.rs @@ -1,36 +1,36 @@ -use std::{collections::HashSet, fs}; - -use derive_more::Display; +use std::{collections::HashSet, fmt, fs}; +use directories::ProjectDirs; use expanduser::expanduser; use serde::{Deserialize, Serialize}; use walkdir::WalkDir; -use crate::{ - config::{Config, default_state_path}, - learner::Learner, - trajectory::Trajectory, -}; +use crate::{config::Config, learner::Learner, trajectory::Trajectory}; + +fn default_state_path() -> String { + let name = "mood.bin"; + ProjectDirs::from("qualifier", "organisation", "mood") + .and_then(|pd| pd.config_dir().join(name).to_str().map(|x| x.to_string())) + .unwrap_or(format!("~/.config/mood/{name}")) +} #[derive(Serialize, Deserialize, Default)] pub struct State { - pub config: Config, trajectory: Trajectory, learner: Learner, albums: HashSet<String>, } impl State { - pub fn next(&mut self) -> Option<(String, f32)> { - let learning = self.trajectory.step(self.config.skip_window_secs); + pub fn next(&mut self, config: &Config) -> Option<(String, f32)> { + let learning = self.trajectory.step(config.skip_window_secs); if let Some(ref learning) = learning { - println!("Obtained learning: {learning:?}"); - self.learner.learn(&self.config, learning); + self.learner.learn(learning); } if let Some((new_album, prob)) = self.learner.sample( self.trajectory.slice(), &learning.as_ref().into(), &self.albums, - self.config.temperature, + config, ) { self.trajectory.log(&new_album); Some((new_album, prob)) @@ -40,36 +40,39 @@ impl State { } } -#[derive(Display)] -#[display("+{added}/-{removed}, total {new_total}")] pub struct UpdateResult { added: usize, removed: usize, new_total: usize, } +impl fmt::Display for UpdateResult { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "+{}/-{}, total {}", self.added, self.removed, self.new_total) + } +} + impl State { - pub fn load(path: &str) -> Result<State, String> { - expanduser(path) + pub fn try_load() -> Result<State, String> { + expanduser(default_state_path()) .map_err(|e| e.to_string()) - .and_then(|p| fs::read_to_string(p).map_err(|e| e.to_string())) - .and_then(|contents| ron::from_str(&contents).map_err(|e| e.to_string())) + .and_then(|p| fs::read(p).map_err(|e| e.to_string())) + .and_then(|contents| bincode::deserialize(&contents).map_err(|e| e.to_string())) } pub fn try_save(&self) -> Result<(), String> { let str_path = default_state_path(); let path = expanduser(&str_path).map_err(|e| e.to_string())?; - _ = path.parent().map(|parent| std::fs::create_dir_all(parent)); - let serialised = ron::ser::to_string_pretty(self, ron::ser::PrettyConfig::default()) - .map_err(|e| format!("Failed to serialize state: {}", e))?; + _ = path.parent().map(std::fs::create_dir_all); + let serialised = + bincode::serialize(self).map_err(|e| format!("Failed to serialize state: {}", e))?; fs::write(&path, serialised) .map_err(|e| format!("Failed to write state file at {}: {}", str_path, e))?; Ok(()) } - pub fn update_albums(&mut self) -> UpdateResult { - let roots: Vec<_> = self - .config + pub fn update_albums(&mut self, config: &Config) -> UpdateResult { + let roots: Vec<_> = config .music_roots .iter() .filter_map(|x| expanduser(x).ok().filter(|p| p.is_dir())) @@ -77,14 +80,14 @@ impl State { let mut new_albums: HashSet<String> = HashSet::new(); - for root_path in roots { + for root_path in &roots { for entry in WalkDir::new(&root_path) { let entry = match entry { Ok(e) if e.path().is_dir() => e, _ => continue, }; - if !self.contains_audio(&entry) { + if !Self::contains_audio(&entry, config) { continue; } @@ -113,21 +116,35 @@ impl State { } } - fn contains_audio(&self, entry: &walkdir::DirEntry) -> bool { + fn contains_audio(entry: &walkdir::DirEntry, config: &Config) -> bool { fs::read_dir(entry.path()).ok().is_some_and(|mut entries| { entries.any(|e| { e.as_ref() - .is_ok_and(|entry| self.is_audio_file(&entry.path())) + .is_ok_and(|entry| Self::is_audio_file(&entry.path(), config)) }) }) } - fn is_audio_file(&self, path: &std::path::Path) -> bool { + fn is_audio_file(path: &std::path::Path, config: &Config) -> bool { !path.is_dir() && path .extension() .and_then(|ext| ext.to_str()) .map(|s| s.to_lowercase()) - .is_some_and(|ext| self.config.audio_exts.contains(&format!(".{ext}"))) + .is_some_and(|ext| config.audio_exts.contains(&format!(".{ext}"))) + } +} + +impl State { + pub fn dump_albums(&self) -> Result<String, String> { + serde_json::to_string_pretty(&self.albums).map_err(|e| e.to_string()) + } + + pub fn dump_learner(&self) -> Result<String, String> { + serde_json::to_string_pretty(&self.learner).map_err(|e| e.to_string()) + } + + pub fn dump_trajectory(&self) -> Result<String, String> { + serde_json::to_string_pretty(&self.trajectory).map_err(|e| e.to_string()) } } diff --git a/src/trajectory.rs b/src/trajectory.rs index 214e16c..cbb0661 100644 --- a/src/trajectory.rs +++ b/src/trajectory.rs @@ -3,7 +3,7 @@ use std::{collections::HashSet, time::SystemTime}; use crate::learner::Learning; -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize)] struct LastData { timestamp: SystemTime, album: String, @@ -16,7 +16,7 @@ pub struct Trajectory { history: Vec<String>, } -#[derive(Serialize, Deserialize, Clone, PartialEq, Debug)] +#[derive(Serialize, Deserialize, Clone, PartialEq)] pub enum Action { Skip, More, @@ -26,8 +26,8 @@ impl From<Option<&Learning>> for Action { fn from(learning: Option<&Learning>) -> Action { if let Some(ts) = learning { match ts { - Learning::SkipExtend | Learning::MoreToSkip(_, _) => Action::Skip, - Learning::MoreExtend(_, _) | Learning::SkipToMore(_, _) => Action::More, + Learning::MoreToSkip(_, _) => Action::Skip, + Learning::SkipToMore(_, _) => Action::More, } } else { Action::Skip @@ -71,26 +71,29 @@ impl Trajectory { let last_album = last_data.album.clone(); let current_streak = self.history.clone(); + let learnt_nothing = self + .streak_kind + .as_ref() + .map(|kind| *kind == action) + .unwrap_or(false); - let learning = if let Some(ref kind) = self.streak_kind - && kind == &action - { - self.history.push(last_album.clone()); - match action { - Action::Skip => Learning::SkipExtend, - Action::More => Learning::MoreExtend(current_streak, last_album), + let learning = match (learnt_nothing, &action) { + (false, Action::Skip) => { + Some(Learning::MoreToSkip(current_streak, last_album.clone())) } - } else { - self.history.clear(); - self.history.push(last_album.clone()); - match action { - Action::Skip => Learning::MoreToSkip(current_streak, last_album), - Action::More => Learning::SkipToMore(current_streak, last_album), + (false, Action::More) => { + Some(Learning::SkipToMore(current_streak, last_album.clone())) } + _ => None, + }; + + if !learnt_nothing { + self.history.clear(); }; + self.history.push(last_album); self.streak_kind = Some(action); - Some(learning) + learning } else { None } |
