4da624bfcf
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.
154 lines
5.9 KiB
Python
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))
|