Files
Tatami/tests/test_routing.py
T
lars 4da624bfcf Fix route optimizer and group assignment; add test suite
Route building / optimization (tatami_masterplan.py):
- fast_total_time was permutation-invariant: it applied get_courses to
  group *values* instead of slots and ignored the permutation, so every
  ordering scored identically and the annealing optimized nothing. It now
  maps each rotation slot to its assigned group via the permutation.
- Replaced the broken next_permutation/simulated_annealing (enumerated n!
  orderings per iteration, fed unnormalized Boltzmann weights to
  np.random.choice -> ValueError, and returned the last random sample) with
  a standard neighbor-swap annealer that tracks and returns the best
  solution and handles <2 slots.
- Convert the reduced Timedelta matrix to float seconds before annealing
  (np.exp can't operate on Timedelta).

Group building (tatami_masterplan.py):
- assign_courses set each group's hosts (sorted by course) before all
  courses were assigned, so hosts whose course was still None got
  mis-ordered. Assign all courses first, then wire up hosts.
- get_masterplan no longer mutates the caller's participant list.

classes.py:
- Narrow casts on distance-matrix lookups to satisfy the mypy gate
  (pre-existing failures).

Tests:
- Add pytest suite (74 tests) covering the rotation topology, route cost
  and optimization, the domain model, the masterplan pipeline, and the
  Routes API wrapper (HTTP mocked). The cost cross-check caught the
  assign_courses ordering bug above.
2026-06-19 14:19:00 +02:00

154 lines
5.9 KiB
Python

"""Tests for the dinner-rotation topology and the route-cost / optimization logic.
These cover the two areas most prone to subtle bugs: the ``get_courses`` rotation
(who hosts whom) and ``fast_total_time`` / ``simulated_annealing`` (the route cost
and its optimization).
"""
import itertools
from collections import Counter
import numpy as np
import pytest
from tatami.tatami_masterplan import (
fast_total_time,
get_courses,
simulated_annealing,
)
# Group counts must be multiples of 3 (one slot per course); test a range of sizes.
GROUP_COUNTS = [3, 6, 9, 12, 15]
def asymmetric_matrix(n: int, seed: int = 0) -> np.ndarray:
"""Random asymmetric (n+1)x(n+1) cost matrix, last index = after party."""
rng = np.random.default_rng(seed)
matrix = rng.integers(60, 1200, size=(n + 1, n + 1)).astype(float)
np.fill_diagonal(matrix, 0)
return matrix
class TestGetCourses:
@pytest.mark.parametrize("n", GROUP_COUNTS)
def test_group_is_its_own_first_host(self, n):
# A group always hosts the course it cooks, so it appears in its own host list.
for i in range(n):
assert i in get_courses(i, n)
@pytest.mark.parametrize("n", GROUP_COUNTS)
def test_each_group_visits_three_distinct_courses(self, n):
for i in range(n):
hosts = get_courses(i, n)
assert len(hosts) == 3
assert len(set(hosts)) == 3, "a group must visit three distinct hosts"
# The three hosts must cook three different courses (course == index % 3).
assert {h % 3 for h in hosts} == {0, 1, 2}
@pytest.mark.parametrize("n", GROUP_COUNTS)
def test_hosts_returned_in_starter_main_dessert_order(self, n):
# get_courses sorts by index % 3 -> starter(0), main(1), dessert(2).
for i in range(n):
a, b, c = get_courses(i, n)
assert (a % 3, b % 3, c % 3) == (0, 1, 2)
@pytest.mark.parametrize("n", GROUP_COUNTS)
def test_every_host_receives_exactly_three_groups(self, n):
# The defining invariant of a running dinner: every hosting location serves
# exactly three groups (itself + two guests) for its course.
received = Counter()
for i in range(n):
for host in get_courses(i, n):
received[host] += 1
assert set(received.values()) == {3}
assert len(received) == n
@pytest.mark.parametrize("n", GROUP_COUNTS)
def test_guests_at_each_host_cook_distinct_courses(self, n):
# Invert the relation: the (exactly three) groups that show up at host h for
# its course must each be responsible for a different course in the rotation.
hosts_of = {i: set(get_courses(i, n)) for i in range(n)}
for host in range(n):
guests = [g for g in range(n) if host in hosts_of[g]]
assert len(guests) == 3
assert {g % 3 for g in guests} == {0, 1, 2}
class TestFastTotalTime:
def test_matches_hand_computed_value_n3(self):
# For n=3 every slot resolves to hosts (0, 1, 2), so the cost is
# 3 * (D[s0][s1] + D[s1][s2] + D[s2][afterparty]).
matrix = np.array(
[
[0, 10, 20, 30],
[40, 0, 50, 60],
[70, 80, 0, 90],
[1, 2, 3, 0], # after-party row (unused as origin)
],
dtype=float,
)
solution = [0, 1, 2]
expected = 3 * (matrix[0][1] + matrix[1][2] + matrix[2][3])
assert fast_total_time(matrix, solution) == pytest.approx(expected)
def test_cost_depends_on_permutation(self):
# The whole point of the optimizer: different slot->group assignments must
# generally produce different costs on an asymmetric matrix.
matrix = asymmetric_matrix(6, seed=2)
costs = {
fast_total_time(matrix, list(p))
for p in itertools.islice(itertools.permutations(range(6)), 50)
}
assert len(costs) > 1
def test_returns_float(self):
matrix = asymmetric_matrix(6, seed=2)
assert isinstance(fast_total_time(matrix, list(range(6))), float)
class TestSimulatedAnnealing:
@pytest.mark.parametrize("seed", [0, 1, 2, 3, 4])
def test_never_returns_worse_than_optimum(self, seed):
# Sanity floor: the cost can never be below the true (brute-force) optimum.
n = 6
matrix = asymmetric_matrix(n, seed=seed)
optimum = min(
fast_total_time(matrix, list(p)) for p in itertools.permutations(range(n))
)
result = simulated_annealing(matrix, list(range(n)), 1000, 0.99, 5000)
assert fast_total_time(matrix, result) >= optimum
@pytest.mark.parametrize("seed", [0, 1, 2, 3, 4])
def test_beats_random_baseline(self, seed):
# SA should reliably do better than the average random ordering.
n = 9
matrix = asymmetric_matrix(n, seed=seed)
random_costs = []
for _ in range(500):
perm = list(range(n))
np.random.shuffle(perm)
random_costs.append(fast_total_time(matrix, perm))
result = simulated_annealing(matrix, list(range(n)), 1000, 0.99, 5000)
assert fast_total_time(matrix, result) < np.mean(random_costs)
def test_returns_valid_permutation(self):
n = 9
matrix = asymmetric_matrix(n, seed=3)
result = simulated_annealing(matrix, list(range(n)), 1000, 0.99, 1000)
assert sorted(result) == list(range(n))
def test_handles_single_slot(self):
matrix = asymmetric_matrix(1, seed=0)
assert simulated_annealing(matrix, [0], 1000, 0.99, 100) == [0]
def test_handles_empty(self):
matrix = np.zeros((1, 1))
assert simulated_annealing(matrix, [], 1000, 0.99, 100) == []
def test_does_not_mutate_input_indices(self):
n = 6
matrix = asymmetric_matrix(n, seed=1)
indices = list(range(n))
simulated_annealing(matrix, indices, 1000, 0.99, 100)
assert indices == list(range(n))