aboutsummaryrefslogtreecommitdiff
// SM2-Anki
// https://gist.github.com/riceissa/1ead1b9881ffbb48793565ce69d7dbdd

use crate::model::difficulty::{Difficulty, Difficulty::*};
use serde::{Deserialize, Serialize};

// Learning
const LEARNING_INTERVALS: [f32; 2] = [
    1.0 / 60.0 / 24.0,  // 1 minute (in days)
    10.0 / 60.0 / 24.0, // 10 minutes (in days)
];

// Ease
const EASE_INIT: f32 = 2.5;
const EASE_MIN: f32 = 1.3;

// Interval
const INTERVAL_INIT: f32 = 1.0;
const INTERVAL_INIT_EASY: f32 = 4.0;
const INTERVAL_MIN: f32 = 0.1;
const INTERVAL_MAX: f32 = 36500.0;

// Learned
const EASE_AGAIN_SUB: f32 = 0.2;
const EASE_HARD_SUB: f32 = 0.15;
const EASE_EASY_ADD: f32 = 0.15;
const INTERVAL_AGAIN_MUL: f32 = 0.7;
const INTERVAL_HARD_MUL: f32 = 1.2;
const INTERVAL_EASY_MUL: f32 = 1.3;

// Relearning
const RELEARNING_INTERVALS: [f32; 1] = [
    10.0 / 60.0 / 24.0, // 10 minutes (in days)
];

#[derive(Debug, PartialEq, Deserialize, Serialize)]
pub enum State {
    Learning {
        step: usize,
    },
    Learned {
        ease: f32,     // ratio
        interval: f32, // in days
    },
    Relearning {
        step: usize,
        ease: f32,
        interval: f32,
    },
}

pub fn init() -> State {
    State::Learning { step: 0 }
}

impl State {
    pub fn get_interval_seconds(&self) -> u64 {
        let days = match self {
            State::Learning { step } => LEARNING_INTERVALS[*step],
            State::Learned { interval, .. } => *interval,
            State::Relearning { step, .. } => RELEARNING_INTERVALS[*step],
        };
        (days * 24.0 * 60.0 * 60.0).round() as u64
    }

    pub fn difficulties(&self) -> Vec<Difficulty> {
        match self {
            State::Learning { .. } => [Again, Good, Easy].to_vec(),
            State::Learned { .. } => [Again, Hard, Good, Easy].to_vec(),
            State::Relearning { .. } => [Again, Good].to_vec(),
        }
    }
}

pub fn update(state: State, difficulty: Difficulty) -> State {
    match state {
        State::Learning { step } => match difficulty {
            Again => State::Learning { step: 0 },
            Good => {
                let new_step = step + 1;
                if new_step < LEARNING_INTERVALS.len() {
                    State::Learning { step: new_step }
                } else {
                    State::Learned {
                        ease: EASE_INIT,
                        interval: INTERVAL_INIT,
                    }
                }
            }
            Easy => State::Learned {
                ease: EASE_INIT,
                interval: INTERVAL_INIT_EASY,
            },
            _ => panic!("Learning is incompatible with {:?}", difficulty),
        },
        State::Learned { ease, interval } => match difficulty {
            Again => State::Relearning {
                step: 0,
                ease: clamp_ease(ease - EASE_AGAIN_SUB),
                interval: clamp_interval(interval * INTERVAL_AGAIN_MUL),
            },
            Hard => State::Learned {
                ease: clamp_ease(ease - EASE_HARD_SUB),
                interval: clamp_interval(interval * INTERVAL_HARD_MUL),
            },
            Good => State::Learned {
                ease,
                interval: clamp_interval(interval * ease),
            },
            Easy => State::Learned {
                ease: clamp_ease(ease + EASE_EASY_ADD),
                interval: clamp_interval(interval * ease * INTERVAL_EASY_MUL),
            },
        },
        State::Relearning {
            step,
            ease,
            interval,
        } => match difficulty {
            Again => State::Relearning {
                step: 0,
                ease,
                interval,
            },
            Good => {
                let new_step = step + 1;
                if new_step < RELEARNING_INTERVALS.len() {
                    State::Relearning {
                        step: new_step,
                        ease,
                        interval,
                    }
                } else {
                    State::Learned { ease, interval }
                }
            }
            _ => panic!("Relearning is incompatible with {:?}.", difficulty),
        },
    }
}

fn clamp_ease(f: f32) -> f32 {
    if f < EASE_MIN {
        EASE_MIN
    } else {
        f
    }
}

fn clamp_interval(i: f32) -> f32 {
    if i < INTERVAL_MIN {
        INTERVAL_MIN
    } else if i > INTERVAL_MAX {
        INTERVAL_MAX
    } else {
        i
    }
}

#[cfg(test)]
mod tests {
    use super::{State::*, *};

    #[test]
    fn learning_again() {
        assert_eq!(update(Learning { step: 1 }, Again), Learning { step: 0 });
    }

    #[test]
    fn learning_good() {
        assert_eq!(update(Learning { step: 0 }, Good), Learning { step: 1 });

        assert_eq!(
            update(
                Learning {
                    step: LEARNING_INTERVALS.len() - 1
                },
                Good
            ),
            Learned {
                ease: EASE_INIT,
                interval: INTERVAL_INIT
            }
        );
    }

    #[test]
    fn learning_easy() {
        assert_eq!(
            update(Learning { step: 0 }, Easy),
            Learned {
                ease: EASE_INIT,
                interval: INTERVAL_INIT_EASY
            }
        );
    }

    #[test]
    fn learned_again() {
        assert_eq!(
            update(
                Learned {
                    ease: EASE_MIN,
                    interval: INTERVAL_MIN
                },
                Again
            ),
            Relearning {
                step: 0,
                ease: EASE_MIN,
                interval: INTERVAL_MIN
            }
        );

        assert_eq!(
            update(
                Learned {
                    ease: EASE_INIT,
                    interval: INTERVAL_INIT
                },
                Again
            ),
            Relearning {
                step: 0,
                ease: EASE_INIT - EASE_AGAIN_SUB,
                interval: INTERVAL_INIT * INTERVAL_AGAIN_MUL
            }
        );
    }

    #[test]
    fn learned_hard() {
        assert_eq!(
            update(
                Learned {
                    ease: EASE_MIN,
                    interval: INTERVAL_MAX
                },
                Hard
            ),
            Learned {
                ease: EASE_MIN,
                interval: INTERVAL_MAX
            }
        );

        assert_eq!(
            update(
                Learned {
                    ease: EASE_INIT,
                    interval: INTERVAL_INIT
                },
                Hard
            ),
            Learned {
                ease: EASE_INIT - EASE_HARD_SUB,
                interval: INTERVAL_INIT * INTERVAL_HARD_MUL
            }
        );
    }

    #[test]
    fn learned_good() {
        assert_eq!(
            update(
                Learned {
                    ease: EASE_INIT,
                    interval: INTERVAL_MAX
                },
                Good
            ),
            Learned {
                ease: EASE_INIT,
                interval: INTERVAL_MAX
            }
        );

        assert_eq!(
            update(
                Learned {
                    ease: EASE_INIT,
                    interval: INTERVAL_INIT
                },
                Good
            ),
            Learned {
                ease: EASE_INIT,
                interval: INTERVAL_INIT * EASE_INIT
            }
        );
    }

    #[test]
    fn learned_easy() {
        assert_eq!(
            update(
                Learned {
                    ease: EASE_INIT,
                    interval: INTERVAL_MAX
                },
                Easy
            ),
            Learned {
                ease: EASE_INIT + EASE_EASY_ADD,
                interval: INTERVAL_MAX
            }
        );

        assert_eq!(
            update(
                Learned {
                    ease: EASE_INIT,
                    interval: INTERVAL_INIT
                },
                Easy
            ),
            Learned {
                ease: EASE_INIT + EASE_EASY_ADD,
                interval: INTERVAL_INIT * EASE_INIT * INTERVAL_EASY_MUL
            }
        );
    }

    #[test]
    fn relearning_again() {
        let ease = EASE_INIT + EASE_EASY_ADD;
        let interval = INTERVAL_INIT * ease;
        assert_eq!(
            update(
                Relearning {
                    step: 1,
                    ease,
                    interval,
                },
                Again
            ),
            Relearning {
                step: 0,
                ease,
                interval
            }
        );
    }

    #[test]
    fn relearning_good() {
        let ease = EASE_INIT + EASE_EASY_ADD;
        let interval = INTERVAL_INIT * ease;
        assert_eq!(
            update(
                Relearning {
                    step: RELEARNING_INTERVALS.len() - 1,
                    ease,
                    interval,
                },
                Good
            ),
            Learned { ease, interval }
        );
    }
}