lib/rationalize/search.ex

defmodule Rationalize.Search do
  @moduledoc """
  Implementation of the search strategy for neaby rational numbers that bracket
  a given number.

  The search for the bracketing rationals involves a traversal of the
  Stern-Brocot tree. Since the Stern-Brocot tree is essentially a binary tree,
  the search amounts to a binary search.

  Every node in the Stern-Brocot tree is a rational number with relatively
  prime numerator and denominator (no common factors). It follows that each
  rational that brackets `num` has relatively prime components and cannot
  be further reduced.
  """

  alias Rationalize.Rational, as: R

  @type bracket :: {R.t(), R.t()}

  @doc """
  Return the rational fraction closest to the number `num` with denominator
  no larger than integer `den_max`.

  ## Examples

      iex> Rationalize.closest_rational(0.27, 10)
      %Rationalize.Rational{n: 2, d: 7}
  """
  @spec closest_rational(number(), pos_integer()) :: R.t()
  def closest_rational(0, _den_max), do: R.new(0, 1)

  def closest_rational(num, den_max) when is_number(num) and is_integer(den_max) do
    {r1, r2} = closest_bracket(num, den_max)

    # CHECK ME: I think it's safe to assume `closest_bracket` never returns a
    # rational with denominator 0 when `num` != 0.
    {:ok, diff_left} = R.diff_num(num, r1)
    {:ok, diff_right} = R.diff_num(r2, num)
    if diff_left < diff_right, do: r1, else: r2
  end

  @doc """
  Return the bracket of rational numbers `{r1, r2}` that most closely bounds
  the number `num`, such that both rationals have denominators no larger than
  integer `den_max`.

  ## Examples

      iex> Rationalize.Search.closest_bracket(0.27, 10)
      {%Rationalize.Rational{n: 1, d: 4}, %Rationalize.Rational{n: 2, d: 7}}

  """
  @spec closest_bracket(number(), pos_integer()) :: bracket
  def closest_bracket(num, den_max) when is_number(num) and is_integer(den_max) and den_max > 0 do
    # Find the positive rationals that bracket the absolute value of the input number.
    {r1, r2} =
      {R.new(0, 1), R.new(1, 0)}
      |> search(abs(num), den_max)

    # If the input number is negative, flip the order and negate the bracketing rationals.
    if num < 0 do
      {R.negate(r2), R.negate(r1)}
    else
      {r1, r2}
    end
  end

  @doc """
  Recursively narrow the rational bracket around number `num` until one of the
  termination conditions is satisfied.

  The search terminates when:
    1. either end of the input bracket numerically equals `num`.
    2. the denominator of the mediant of the input bracket exceeds `den_max`.
    3. the mediant of the input bracket equals `num`.
  """
  def search({%R{} = r1, %R{} = r2} = bracket, num, den_max) when num >= 0 do
    c1 = R.compare_to_num(r1, num)
    c2 = R.compare_to_num(r2, num)

    if :eq in [c1, c2] do
      # Return the bracket if either rational equals `num`.
      bracket
    else
      # Compute the mediant and either return or narrow the bracket and recurse.
      m = R.mediant(r1, r2)
      cm = R.compare_to_num(m, num)

      cond do
        m.d > den_max ->
          # Return the bracket if the denominator of the mediant exceeds the max.
          bracket

        cm == :eq ->
          # If the mediant equals `num`, collapse the bracket and return.
          {m, m}

        cm == :gt ->
          search({r1, m}, num, den_max)

        cm == :lt ->
          search({m, r2}, num, den_max)
      end
    end
  end
end