diff --git a/CLAUDE.md b/CLAUDE.md index 7f3529f..b9ad59a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/src/tatami/tatami_masterplan.py b/src/tatami/tatami_masterplan.py index 7f00c17..48a4dd2 100644 --- a/src/tatami/tatami_masterplan.py +++ b/src/tatami/tatami_masterplan.py @@ -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: diff --git a/tests/test_routing.py b/tests/test_routing.py index 0413c6c..8c7b764 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -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):