Files
Tatami/tests/test_routing.py
lars e7bb0b9c69 Replace cyclic rotation with Latin-square design so groups meet once
The fixed +1/-4 offsets in get_courses only produced a collision-free
rotation for certain group counts; for n=9 (three groups per course) the
pairwise gaps collapsed and 9 pairs of groups met twice.

Replace it with a resolvable transversal design (_rotation_hosts): slots
form a k x 3 grid, each course is a parallel class partitioning all groups
into transversal tables of three, guaranteeing every pair meets at most
once for any n >= 9. n=3/6 are combinatorially impossible and fall back to
a degenerate same-row rotation that still satisfies the structural
invariants. Add a regression test asserting no pair meets more than once.
2026-07-10 18:15:37 +02:00

168 lines
6.7 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}
@pytest.mark.parametrize("n", [n for n in GROUP_COUNTS if n >= 9])
def test_no_two_groups_meet_more_than_once(self, n):
# The whole point of the Latin-square rotation: for n >= 9 (>= 3 groups per
# course) every pair of groups shares a table at most once across the evening.
# (n = 3 and 6 are combinatorially impossible and deliberately excluded.)
hosts_of = {i: set(get_courses(i, n)) for i in range(n)}
meetings = Counter()
for host in range(n):
guests = sorted(g for g in range(n) if host in hosts_of[g])
for a, b in itertools.combinations(guests, 2):
meetings[(a, b)] += 1
repeats = {pair: c for pair, c in meetings.items() if c > 1}
assert repeats == {}, f"pairs meeting more than once: {repeats}"
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))