aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authortslil <tslil@posteo.de>2026-07-13 15:06:41 +0100
committertslil <tslil@posteo.de>2026-07-13 21:21:21 +0100
commit3f289981bd9c8e800a6cd310d4b1f2f36634c9f1 (patch)
treeba711eb220e39fe64388d370b16384a7bc84eaab /src
parente0241df8afde0483fc36cb2300b53127672486dc (diff)
Pure history
Diffstat (limited to 'src')
-rw-r--r--src/cli.rs60
-rw-r--r--src/config.rs47
-rw-r--r--src/learner.rs201
-rw-r--r--src/main.rs146
-rw-r--r--src/state.rs81
-rw-r--r--src/trajectory.rs39
6 files changed, 387 insertions, 187 deletions
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
}