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.
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.