lib/memorex/scheduler/card_state_machine.ex

defmodule Memorex.Scheduler.CardStateMachine do
  @moduledoc """
  This is the heart of the Anki SM-2 algorithm.  Note that this file consists of pure functions; `Memorex.Domain.Card`
  structs come into the `answer_card/4` function, and a map containing changes are returned.  There is no database
  interaction in this file. Time is deterministic (i.e., there are no `Timex.now()` calls within this module), and the
  specific value of the current time is fed into each function.  `Memorex.Scheduler.CardStateMachine` is not truly a
  state machine per se (as it actually maintains no state), but is really a set of transforms on `Memorex.Domain.Card`s.
  """

  alias Memorex.Domain.Card
  alias Memorex.Scheduler.Config
  alias Timex.Duration

  # --------------- New Cards ------------------------------------------------------------------------------------------
  @spec convert_new_card_to_learn_card(Card.t(), Config.t(), DateTime.t()) :: map()
  def convert_new_card_to_learn_card(%Card{card_type: :new} = _card, config, time_now) do
    %{
      card_type: :learn,
      current_step: 0,
      due: time_now,
      interval: config.learn_steps |> List.first(),
      lapses: 0,
      reps: 0
    }
  end

  @doc """
  This is the main function used when drilling/reviewing `Memorex.Domain.Card`s.  Cards start out as `:new`, and then
  progress to `:learn` and then `:review` based on the Anki algorithm.  If a `:review` card is failed (by answering
  `:again`), then it becomes a `:relearn` card.

  Note that a map containing the difference in the card state is returned by `answer_card/4`, not a new version of the
  card.  Only values that actually modified by the answer are returned in this map.

  Also note that `Memorex.Domain.Card` values such as `:reps` are incremented outside of
  `Memorex.Scheduler.CardStateMachine` in `Memorex.Scheduler.CardReviewer` (otherwise every function in this file would
  increment `:rep` by one, which seemed redundant).  The `:due` field on `Memorex.Domain.Card` is also computed outside
  of `Memorex.Scheduler.CardStateMachine` based on the value of `:interval`; i.e., a new `:interval` is computed in
  `Memorex.Scheduler.CardStateMachine` and its value is added to the old `:due` datetime to find a new `:due` in
  `Memorex.Scheduler.CardReviewer`.

  """
  @spec answer_card(Card.t(), Card.answer_choice(), Config.t(), DateTime.t()) :: map()

  # --------------- Learn Cards ----------------------------------------------------------------------------------------

  def answer_card(%Card{card_type: :learn} = _card, :again, _config, _time_now) do
    %{current_step: 0}
  end

  def answer_card(%Card{card_type: :learn} = _card, :hard, _config, _time_now) do
    %{}
  end

  def answer_card(%Card{card_type: :learn} = card, :good, config, _time_now) do
    current_step = card.current_step + 1

    if current_step >= length(config.learn_steps) do
      %{card_type: :review, current_step: nil, ease_factor: config.initial_ease, interval: config.graduating_interval_good}
    else
      {interval, _rest} = config.learn_steps |> List.pop_at(current_step)
      %{current_step: current_step, interval: interval}
    end
  end

  def answer_card(%Card{card_type: :learn} = _card, :easy, config, _time_now) do
    %{card_type: :review, ease_factor: config.initial_ease, interval: config.graduating_interval_easy}
  end

  # --------------- Review Cards ---------------------------------------------------------------------------------------

  def answer_card(%Card{card_type: :review} = card, :again, config, _time_now) do
    {card_type, interval_prior_to_lapse} =
      if Enum.empty?(config.relearn_steps) do
        {:review, nil}
      else
        {:relearn, card.interval}
      end

    scaled_interval = Duration.scale(card.interval, config.lapse_multiplier)

    interval =
      if Duration.to_seconds(scaled_interval) < Duration.to_seconds(config.min_review_interval) do
        config.min_review_interval
      else
        scaled_interval
      end

    ease_factor = (card.ease_factor + config.ease_again) |> floor_for_ease_factor(config.ease_minimum)
    lapses = card.lapses + 1

    changes = %{
      card_type: card_type,
      current_step: 0,
      ease_factor: ease_factor,
      interval_prior_to_lapse: interval_prior_to_lapse,
      interval: interval,
      lapses: lapses
    }

    if lapses >= config.leech_threshold, do: Map.put(changes, :card_status, :suspended), else: changes
  end

  def answer_card(%Card{card_type: :review} = card, :hard, config, _time_now) do
    # Note that interval_scale doesn't multiply by ease_factor for this case
    interval_scale = config.hard_multiplier * config.interval_multiplier
    ease_factor = (card.ease_factor + config.ease_hard) |> floor_for_ease_factor(config.ease_minimum)
    interval = Duration.scale(card.interval, interval_scale) |> cap_duration(config.max_review_interval)
    %{ease_factor: ease_factor, interval: interval}
  end

  def answer_card(%Card{card_type: :review} = card, :good, config, time_now) do
    gap =
      case Timex.compare(card.due, time_now) do
        -1 -> Timex.diff(time_now, card.due, :duration) |> Duration.scale(0.5)
        _ -> Duration.parse!("PT0S")
      end

    interval_scale = card.ease_factor * config.interval_multiplier
    ease_factor = card.ease_factor + config.ease_good

    interval =
      Duration.add(card.interval, gap)
      |> Duration.scale(interval_scale)
      |> cap_duration(config.max_review_interval)

    %{ease_factor: ease_factor, interval: interval}
  end

  def answer_card(%Card{card_type: :review} = card, :easy, config, time_now) do
    gap =
      case Timex.compare(card.due, time_now) do
        -1 -> Timex.diff(time_now, card.due, :duration)
        _ -> Duration.parse!("PT0S")
      end

    interval_scale = card.ease_factor * config.interval_multiplier * config.easy_multiplier
    ease_factor = card.ease_factor + config.ease_easy

    interval =
      Duration.add(card.interval, gap)
      |> Duration.scale(interval_scale)
      |> cap_duration(config.max_review_interval)

    %{ease_factor: ease_factor, interval: interval}
  end

  # --------------- Re-Learn Cards -------------------------------------------------------------------------------------

  def answer_card(%Card{card_type: :relearn} = _card, :again, _config, _time_now) do
    %{current_step: 0}
  end

  def answer_card(%Card{card_type: :relearn} = _card, :hard, _config, _time_now) do
    %{}
  end

  def answer_card(%Card{card_type: :relearn} = card, :good, config, _time_now) do
    current_step = card.current_step + 1

    if current_step >= length(config.relearn_steps) do
      %{card_type: :review, interval: config.min_review_interval, current_step: nil, interval_prior_to_lapse: nil}
    else
      {interval, _rest} = config.relearn_steps |> List.pop_at(current_step)
      %{current_step: current_step, interval: interval}
    end
  end

  def answer_card(%Card{card_type: :relearn} = card, :easy, config, _time_now) do
    # interval = Duration.add(config.min_review_interval, config.relearn_easy_adj)

    # I think it should be this instead:
    interval = Duration.add(card.interval, config.relearn_easy_adj)
    %{card_type: :review, interval: interval, current_step: nil, interval_prior_to_lapse: nil}
  end

  # --------------- Utilities ------------------------------------------------------------------------------------------

  @spec cap_duration(Duration.t(), Duration.t()) :: Duration.t()
  def cap_duration(duration, max_duration) do
    if Duration.to_seconds(duration) <= Duration.to_seconds(max_duration) do
      duration
    else
      max_duration
    end
  end

  @spec floor_for_ease_factor(float(), float()) :: float()
  defp floor_for_ease_factor(ease_factor, ease_minimum) do
    if ease_factor < ease_minimum, do: ease_minimum, else: ease_factor
  end
end