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.
9.9 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
What this is
Tatami ("Tool for Arranging Tasty Appointments, Meetings & Invitations") generates a "running dinner" masterplan: given a list of participants (with home addresses), it groups them into hosting groups for starter/main/dessert courses, assigns which groups visit which hosts for each course, and orders things to minimize travel time (by bike, via the Google Maps Routes API), finishing at a shared after-party location.
The actively developed package is src/tatami/. running_dinner/running_dinner.py is a legacy, pre-package standalone script (German-language field names, brute-force search over team pairings) kept for reference — it is not wired into the tatami package and uses a different/older Google Maps API (the legacy Distance Matrix API vs. the new Routes API used in traveltimes.py). Don't assume code or conventions from running_dinner.py apply to src/tatami/.
Commands
This project uses uv for dependency management (Python >=3.13).
- Install deps:
uv sync - Run the masterplan script:
uv run python -m tatami.tatami_masterplan(requirestest-config.csvin the working directory andGOOGLE_MAPS_API_KEYset; optionally setGOOGLE_SHEETS_CREDENTIALS_FILEin.env+spreadsheet_idin the saved plan to also export to a shared Google Sheet — see.env.example). The first run computes a plan and saves it tomasterplan.json(override viaPLAN_FILE); later runs load that file instead of recomputing, so it's the place to hand-edit groups/courses/contacts/spreadsheet_idbetween runs. - Lint:
uv run ruff check - Format:
uv run ruff format - Type check:
uv run mypy --allow-redefinition src/(mypy is configured to treat untyped imports as errors except where ignored) - Test:
uv run pytest(offline suite, HTTP mocked);uv run pytest -m e2efor the opt-in live-API tests - Pre-commit runs ruff check, ruff format, and mypy automatically (see
.pre-commit-config.yaml); install hooks withuv run pre-commit installif working interactively.
Architecture
The pipeline (see src/tatami/tatami_masterplan.py __main__ block) is:
- Load participants —
load_csv_to_participantsreads a tab-separated CSV (name,address,phone,kitchen_size,allergies) intoParticipantobjects (src/tatami/classes.py). - Fetch travel times —
traveltimes.get_participant_distance_matrixcalls the Google Routes API (GOOGLE_MAPS_API_KEYenv var required) to build a full pairwise duration matrix between all participant addresses plus the after-party address, indexed by participant UUID. - Build masterplan —
compute_masterplan_groups(the live-object core;get_masterplanis a thin wrapper around it that returns plain dicts instead):- Splits participants into
hosts(one per group,len(participants)//6groups of 3 courses each) andsemi_hosts(non-hosting members assigned round-robin into existing groups), ranked by each participant'sget_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 overitertools.permutationsof 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_coursesassigns each group a course (starter/main/dessert cycling) and, viaget_courses, determines which other groups host it for each course. The rotation is a resolvable "Latin-square"/transversal design (_rotation_hosts): slots form ak x 3grid (k = n // 3groups per course), each course is a parallel class partitioning all groups into transversal tables of three (one starter/main/dessert each), so for anyn >= 9no two groups ever meet more than once.n = 3/6are combinatorially impossible and fall back to a degenerate same-row rotation. (This replaced an earlier fixed+1/-4cyclic offset that produced repeat meetings for group counts like 9.)
- Splits participants into
get_masterplanreturns two lists of plain dicts (group.dict(),participant.dict()) suitable for serialization;compute_masterplan_groupsreturns the liveGroup/Participantobjects, which is what thePlan/sheet export step needs (.hosts,.get_guests(...)).- Wrap in a
Planand save (src/tatami/plan.py) —__main__bundles the computedgroups+after_party_groupwith the event config (course_times,organizer_contacts,info_text,spreadsheet_id) into aPlanand callsplan.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-editmasterplan.json(move a member between groups, change a course, fill inspreadsheet_id, ...) and rerun to pick up the edit without hitting the Routes API again. - Export to Google Sheets (optional) — if the loaded/built
Plan.spreadsheet_idis set,__main__callssheets_export.export_masterplan_to_sheetto 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.
Core domain model (src/tatami/classes.py, src/tatami/plan.py)
Participant and Group are pydantic BaseModels (so they support model_dump()/model_dump_json()/model_validate_json() directly); Plan wraps the whole thing for persistence.
Participant: a person with an address, phone, kitchen size (0–10, used as a "willingness/suitability to host" proxy viaget_penalty, which adds travel-time-equivalent minutes for smaller kitchens), and allergies.Group: a hosting unit with amain_member(a property resolved frommain_member_uuidagainstmembers— used as the group's representative location for all distance lookups; other members' addresses are not used for travel calculations), acourse, and ahostsproperty (the groups that host this group across the evening, kept sorted starter→main→dessert viasort_hosts).get_total_timesums travel + penalty across this group's full route (its hosts, then the after-party).hostsreferences otherGroups and these references are genuinely cyclic (a group's hosts can host it back), so they can't be embedded directly in JSON. The livehostslist is a private, non-persisted attribute set viaset_hosts()/add_host(); the persisted field ishost_uuids(kept in sync automatically). AfterPlan.load(),Group.resolve_hosts()re-linkshostsfromhost_uuidsagainst the sibling groups in the samePlan— call it yourself if you ever constructGroups outside of aPlanand need.hostspopulated fromhost_uuids.
Plan(plan.py): bundlesgroups,after_party_group, and the event-wide config (course_times,organizer_contacts,info_text,spreadsheet_id).Plan.save(path)/Plan.load(path)round-trip the whole thing to/from JSON;plan.participantsis a derived property (flattened, deduplicatedgroup.membersacross all groups + the after party), not a separately stored field, so editing a participant's data in a group'smembersis the single source of truth.- Distance/time lookups throughout the codebase are keyed by
Participant.uuid(host groups are addressed viamain_member.uuid), not by name — when adding new matrix operations, index by uuid for consistency withtraveltimes.pyandclasses.py.
Travel times (src/tatami/traveltimes.py)
- Wraps the Google Routes API
computeRouteMatrixendpoint. Raises at import time ifGOOGLE_MAPS_API_KEYis unset. get_distance_matrixreturns a squareDataFrameofpd.Timedelta(or float meters) indexed/columned by integer position;get_participant_distance_matrixrelabels both axes to participant UUIDs.reduce_distance_matrixis the bridge between the full participant-level matrix and the group-level matrix used by the annealing step.
Sheet export (src/tatami/sheets_export.py)
- Wraps
gspread(Google Sheets API). Unliketraveltimes.py, validation ofGOOGLE_SHEETS_CREDENTIALS_FILEhappens lazily insideload_sheets_client, not at import time — this feature is optional/opt-in, so importing the module must not require the env var. export_masterplan_to_sheetexpects an existinggspread.Spreadsheet(organizer pre-creates it and shares Editor access with the service account'sclient_emailonce) and rewrites it idempotently: an "Overview" tab listing every participant, plus one tab per group named after all of that group's members joined with&(deduplicated; see_group_titles— deliberately not a single member's name, since a group can have several members) showing that group's course, route (theirhosts, sorted starter→main→dessert, plus the after-party), and their guests for the course they host (Group.get_guests). Course start times are a fixed dict passed in by the caller (COURSE_TIMESintatami_masterplan.py) — the dinner runs on a synchronized schedule, not on travel-time-derived timing.- The Overview tab also gets three caller-supplied sections below the group assignments, each rendered as-is (no computation) and each skippable: a "Meal Times" table built straight from
course_times, a "Support Contacts" table from the optionalorganizer_contacts: list[tuple[str, str]]argument, and a free-text "Info" block from the optionalinfo_text: strargument (split into one row per line). All three are configured intatami_masterplan.py(COURSE_TIMES,ORGANIZER_CONTACTS,INFO_TEXT) and just passed through —sheets_export.pyhas no event-specific content baked in. - Tabs left over from a previous run with a different group count are deleted so reruns don't accumulate stale tabs.