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.
This commit is contained in:
@@ -30,7 +30,7 @@ The pipeline (see `src/tatami/tatami_masterplan.py` `__main__` block) is:
|
||||
- Splits participants into `hosts` (one per group, `len(participants)//6` groups of 3 courses each) and `semi_hosts` (non-hosting members assigned round-robin into existing groups), ranked by each participant's `get_after_party_time` (kitchen size penalty + distance to after-party).
|
||||
- Reduces the full distance matrix to just host-to-host distances (`reduce_distance_matrix`), adding each host's kitchen-size penalty into their row.
|
||||
- Runs `run_simulated_annealing` / `simulated_annealing` (Boltzmann-style annealing over `itertools.permutations` of group order — note this is brute-force over all permutations per iteration, so it only scales to a small number of groups) to find a low-travel-time ordering of groups.
|
||||
- `assign_courses` assigns each group a course (starter/main/dessert cycling) and, via `get_courses`, determines which other groups host it for each course (offsets of `+1` and `-4` mod total groups — this fixed relationship is what defines the dinner-rotation topology).
|
||||
- `assign_courses` assigns each group a course (starter/main/dessert cycling) and, via `get_courses`, determines which other groups host it for each course. The rotation is a resolvable "Latin-square"/transversal design (`_rotation_hosts`): slots form a `k x 3` grid (`k = n // 3` groups per course), each course is a parallel class partitioning all groups into transversal tables of three (one starter/main/dessert each), so for any `n >= 9` no two groups ever meet more than once. `n = 3`/`6` are combinatorially impossible and fall back to a degenerate same-row rotation. (This replaced an earlier fixed `+1`/`-4` cyclic offset that produced repeat meetings for group counts like 9.)
|
||||
4. `get_masterplan` returns two lists of plain dicts (`group.dict()`, `participant.dict()`) suitable for serialization; `compute_masterplan_groups` returns the live `Group`/`Participant` objects, which is what the `Plan`/sheet export step needs (`.hosts`, `.get_guests(...)`).
|
||||
5. **Wrap in a `Plan` and save** (`src/tatami/plan.py`) — `__main__` bundles the computed `groups` + `after_party_group` with the event config (`course_times`, `organizer_contacts`, `info_text`, `spreadsheet_id`) into a `Plan` and calls `plan.save(PLAN_FILE)`. On the next invocation, if that file exists, `Plan.load()` reads it back instead of recomputing — this is the save/reload/edit path: hand-edit `masterplan.json` (move a member between groups, change a course, fill in `spreadsheet_id`, ...) and rerun to pick up the edit without hitting the Routes API again.
|
||||
6. **Export to Google Sheets (optional)** — if the loaded/built `Plan.spreadsheet_id` is set, `__main__` calls `sheets_export.export_masterplan_to_sheet` to populate a pre-existing, pre-shared spreadsheet with an Overview tab and one tab per group. This is opt-in and never sends anything directly to participants — the organizer still shares the sheet link manually.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from tatami.classes import Participant, Group, Course
|
||||
@@ -205,15 +206,68 @@ def simulated_annealing(
|
||||
return best
|
||||
|
||||
|
||||
# The rotation is a resolvable "Latin-square" / transversal design rather than a
|
||||
# fixed cyclic offset. Slots are laid out as a k x 3 grid: slot ``i`` cooks course
|
||||
# ``i % 3`` (starter/main/dessert) and sits in row ``i // 3``, where ``k = n // 3``
|
||||
# is the number of groups per course. Each course is one "parallel class" that
|
||||
# partitions all n groups into k dinner tables of three; every table is a
|
||||
# transversal (exactly one starter, one main, one dessert group), so no two groups
|
||||
# that cook the same course ever share a table.
|
||||
#
|
||||
# Table ``a`` of the class for course ``c`` is
|
||||
# {S_a, M_{a + p[c]}, D_{a + q[c]}} (row indices mod k)
|
||||
# where S/M/D are the starter/main/dessert groups. Two groups meet at most once iff
|
||||
# the three ``p`` values are distinct, the three ``q`` values are distinct, and the
|
||||
# three ``q - p`` values are distinct (mod k) -- these guard S-M, S-D and M-D
|
||||
# repeats respectively. ``p = (0, 1, 2)``, ``q = (0, 2, 1)`` satisfies all three for
|
||||
# every ``k >= 3`` (the values 0, 1, k-1 are distinct there), which covers every
|
||||
# real event size. ``k < 3`` (n = 3 or 6) cannot be made collision-free at all -- a
|
||||
# group would have to meet more distinct groups than exist -- so we fall back to a
|
||||
# degenerate same-row rotation (``p = q = 0``) that still satisfies every structural
|
||||
# invariant (see ``tests/test_routing.py``) even though tables then repeat.
|
||||
_COURSE_OFFSETS_P = (0, 1, 2)
|
||||
_COURSE_OFFSETS_Q = (0, 2, 1)
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def _rotation_hosts(total_meetings: int) -> tuple[tuple[int, int, int], ...]:
|
||||
"""Precompute, per slot, the (starter, main, dessert) host slots it dines at.
|
||||
|
||||
Returns a tuple indexed by slot; entry ``i`` is the three host slots the group
|
||||
in slot ``i`` visits, ordered starter -> main -> dessert. The group is always
|
||||
its own host for the course it cooks, so ``i`` appears in its own entry.
|
||||
"""
|
||||
k = total_meetings // 3
|
||||
if k < 3:
|
||||
p = q = (0, 0, 0)
|
||||
else:
|
||||
p, q = _COURSE_OFFSETS_P, _COURSE_OFFSETS_Q
|
||||
|
||||
def slot(course: int, row: int) -> int:
|
||||
return 3 * (row % k) + course
|
||||
|
||||
hosts: list[list[int]] = [[-1, -1, -1] for _ in range(total_meetings)]
|
||||
for course in range(3): # each course is one parallel class
|
||||
for table in range(k):
|
||||
members = (
|
||||
slot(0, table),
|
||||
slot(1, table + p[course]),
|
||||
slot(2, table + q[course]),
|
||||
)
|
||||
host = members[
|
||||
course
|
||||
] # the class for course `course` is hosted by its course-`course` member
|
||||
for member in members:
|
||||
hosts[member][course] = host
|
||||
return tuple((h[0], h[1], h[2]) for h in hosts)
|
||||
|
||||
|
||||
def get_courses(
|
||||
group_index: int,
|
||||
total_meetings: int,
|
||||
):
|
||||
a = group_index
|
||||
b = (group_index + 1) % total_meetings
|
||||
c = (group_index - 4) % total_meetings
|
||||
order = sorted((a, b, c), key=lambda x: x % 3)
|
||||
return order
|
||||
) -> tuple[int, int, int]:
|
||||
"""The three host slots (starter, main, dessert order) that ``group_index`` visits."""
|
||||
return _rotation_hosts(total_meetings)[group_index]
|
||||
|
||||
|
||||
def fast_total_time(distance_matrix: np.ndarray, solution: list[int]) -> float:
|
||||
|
||||
@@ -73,6 +73,20 @@ class TestGetCourses:
|
||||
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):
|
||||
|
||||
Reference in New Issue
Block a user