Skip to main content

lib/examples/traveling_salesman.ex

defmodule Jido.Evolve.Examples.TravelingSalesman do
  @moduledoc """
  Traveling Salesman Problem (TSP): Find the shortest route visiting all cities.

  ## Problem Description

  Given a list of cities with x,y coordinates, find the shortest route that
  visits each city exactly once and returns to the starting city.

  This is an NP-hard problem that demonstrates:
  - **Permutation genomes**: Order of cities matters
  - **Specialized operators**: PMX crossover preserves valid permutations
  - **Local optima**: GAs can escape local minima through crossover

  ## Genome Representation

  Permutation of city indices:

      [0, 3, 1, 4, 2]  # Visit cities in order: 0 -> 3 -> 1 -> 4 -> 2 -> 0

  ## Fitness Evaluation

  Fitness = -total_distance (negative so shorter is better)

  ## Usage

      iex> Jido.Evolve.Examples.TravelingSalesman.run()
      # Evolution progress shown...
      # Converges to near-optimal route

  ## Expected Results

  Converges to within 5-10% of optimal in 100-300 generations,
  demonstrating how PMX crossover combines good route segments.
  """

  alias Jido.Evolve.Examples.Utils

  @cities [
    %{name: "A", x: 0.0, y: 0.0},
    %{name: "B", x: 1.0, y: 3.0},
    %{name: "C", x: 4.0, y: 1.0},
    %{name: "D", x: 6.0, y: 2.0},
    %{name: "E", x: 5.0, y: 5.0},
    %{name: "F", x: 2.0, y: 6.0},
    %{name: "G", x: 1.0, y: 4.0},
    %{name: "H", x: 3.0, y: 0.0},
    %{name: "I", x: 7.0, y: 4.0},
    %{name: "J", x: 5.0, y: 1.0}
  ]

  use Jido.Evolve.Fitness

  @impl true
  def evaluate(permutation, context) do
    distance = calculate_distance(permutation, context.dist_matrix)
    {:ok, -distance}
  end

  @impl true
  def batch_evaluate(permutations, context) do
    results =
      Enum.map(permutations, fn perm ->
        {:ok, score} = evaluate(perm, context)
        {perm, score}
      end)

    {:ok, results}
  end

  def run(opts \\ []) do
    population_size = Keyword.get(opts, :population_size, 100)
    generations = Keyword.get(opts, :generations, 200)
    verbose = Keyword.get(opts, :verbose, true)
    print_every = Keyword.get(opts, :print_every, 10)

    cities = Keyword.get(opts, :cities, @cities)
    n = length(cities)

    # Precompute distance matrix
    dist_matrix = build_distance_matrix(cities)

    # Random permutation seeds
    initial_population =
      Enum.map(1..population_size, fn _ ->
        Utils.random_permutation(n)
      end)

    context = %{
      dist_matrix: dist_matrix,
      cities: cities
    }

    if verbose do
      Utils.print_header("Traveling Salesman Problem Demo", [
        {"Cities", n},
        {"Population Size", population_size},
        {"Generations", generations}
      ])
    end

    {:ok, config} =
      Jido.Evolve.Config.new(
        population_size: population_size,
        generations: generations,
        mutation_rate: 0.25,
        crossover_rate: 0.9,
        elitism_rate: 0.02,
        selection_strategy: Jido.Evolve.Selection.Tournament,
        mutation_strategy: Jido.Evolve.Mutation.Permutation,
        crossover_strategy: Jido.Evolve.Crossover.PMX,
        termination_criteria: []
      )

    result =
      try do
        Jido.Evolve.evolve(
          initial_population: initial_population,
          config: config,
          fitness: __MODULE__,
          context: context
        )
        |> Stream.with_index()
        |> Stream.map(fn {state, _generation} ->
          if verbose and Utils.should_log?(state.generation, print_every) do
            print_generation(state, cities)
          end

          state
        end)
        |> Stream.take(generations + 1)
        |> Enum.to_list()
        |> List.last()
      catch
        kind, error ->
          if verbose do
            IO.puts("\nError during evolution: #{inspect(kind)} - #{inspect(error)}")
          end

          nil
      end

    if result && verbose do
      print_final_solution(result, cities, dist_matrix)
    end

    result
  end

  def demo, do: run(verbose: true)

  defp build_distance_matrix(cities) do
    n = length(cities)

    for i <- 0..(n - 1), j <- 0..(n - 1), into: %{} do
      city_i = Enum.at(cities, i)
      city_j = Enum.at(cities, j)
      distance = euclidean_distance(city_i, city_j)
      {{i, j}, distance}
    end
  end

  defp euclidean_distance(city1, city2) do
    dx = city1.x - city2.x
    dy = city1.y - city2.y
    :math.sqrt(dx * dx + dy * dy)
  end

  defp calculate_distance(permutation, dist_matrix) do
    permutation
    |> Enum.chunk_every(2, 1, [Enum.at(permutation, 0)])
    |> Enum.reduce(0.0, fn [i, j], acc ->
      acc + Map.get(dist_matrix, {i, j}, 0.0)
    end)
  end

  defp print_generation(state, cities) do
    distance = -state.best_score

    route_preview =
      state.best_entity
      |> Enum.take(5)
      |> Enum.map_join(" -> ", fn idx -> Enum.at(cities, idx).name end)

    IO.puts(
      "Gen #{String.pad_leading(to_string(state.generation), 3)}: " <>
        "Distance=#{Float.round(distance, 2)} " <>
        "Route: #{route_preview}..."
    )
  end

  defp print_final_solution(state, cities, dist_matrix) do
    distance = calculate_distance(state.best_entity, dist_matrix)

    IO.puts("\n" <> String.duplicate("=", 50))
    IO.puts("Final Solution (Generation #{state.generation})")
    IO.puts(String.duplicate("=", 50))
    IO.puts("Total Distance: #{Float.round(distance, 2)}")
    IO.puts("\nRoute:")

    route_names =
      Enum.map_join(state.best_entity, " -> ", fn idx -> Enum.at(cities, idx).name end)

    IO.puts("  #{route_names} -> #{Enum.at(cities, Enum.at(state.best_entity, 0)).name}")
    IO.puts("")
  end
end