Files
Tatami/CLAUDE.md
T
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

9.9 KiB
Raw Blame History

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 (requires test-config.csv in the working directory and GOOGLE_MAPS_API_KEY set; optionally set GOOGLE_SHEETS_CREDENTIALS_FILE in .env + spreadsheet_id in the saved plan to also export to a shared Google Sheet — see .env.example). The first run computes a plan and saves it to masterplan.json (override via PLAN_FILE); later runs load that file instead of recomputing, so it's the place to hand-edit groups/courses/contacts/spreadsheet_id between 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 e2e for the opt-in live-API tests
  • Pre-commit runs ruff check, ruff format, and mypy automatically (see .pre-commit-config.yaml); install hooks with uv run pre-commit install if working interactively.

Architecture

The pipeline (see src/tatami/tatami_masterplan.py __main__ block) is:

  1. Load participantsload_csv_to_participants reads a tab-separated CSV (name, address, phone, kitchen_size, allergies) into Participant objects (src/tatami/classes.py).
  2. Fetch travel timestraveltimes.get_participant_distance_matrix calls the Google Routes API (GOOGLE_MAPS_API_KEY env var required) to build a full pairwise duration matrix between all participant addresses plus the after-party address, indexed by participant UUID.
  3. Build masterplancompute_masterplan_groups (the live-object core; get_masterplan is a thin wrapper around it that returns plain dicts instead):
    • 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. 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.

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 (010, used as a "willingness/suitability to host" proxy via get_penalty, which adds travel-time-equivalent minutes for smaller kitchens), and allergies.
  • Group: a hosting unit with a main_member (a property resolved from main_member_uuid against members — used as the group's representative location for all distance lookups; other members' addresses are not used for travel calculations), a course, and a hosts property (the groups that host this group across the evening, kept sorted starter→main→dessert via sort_hosts). get_total_time sums travel + penalty across this group's full route (its hosts, then the after-party).
    • hosts references other Groups and these references are genuinely cyclic (a group's hosts can host it back), so they can't be embedded directly in JSON. The live hosts list is a private, non-persisted attribute set via set_hosts()/add_host(); the persisted field is host_uuids (kept in sync automatically). After Plan.load(), Group.resolve_hosts() re-links hosts from host_uuids against the sibling groups in the same Plan — call it yourself if you ever construct Groups outside of a Plan and need .hosts populated from host_uuids.
  • Plan (plan.py): bundles groups, 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.participants is a derived property (flattened, deduplicated group.members across all groups + the after party), not a separately stored field, so editing a participant's data in a group's members is the single source of truth.
  • Distance/time lookups throughout the codebase are keyed by Participant.uuid (host groups are addressed via main_member.uuid), not by name — when adding new matrix operations, index by uuid for consistency with traveltimes.py and classes.py.

Travel times (src/tatami/traveltimes.py)

  • Wraps the Google Routes API computeRouteMatrix endpoint. Raises at import time if GOOGLE_MAPS_API_KEY is unset.
  • get_distance_matrix returns a square DataFrame of pd.Timedelta (or float meters) indexed/columned by integer position; get_participant_distance_matrix relabels both axes to participant UUIDs.
  • reduce_distance_matrix is 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). Unlike traveltimes.py, validation of GOOGLE_SHEETS_CREDENTIALS_FILE happens lazily inside load_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_sheet expects an existing gspread.Spreadsheet (organizer pre-creates it and shares Editor access with the service account's client_email once) 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 (their hosts, 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_TIMES in tatami_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 optional organizer_contacts: list[tuple[str, str]] argument, and a free-text "Info" block from the optional info_text: str argument (split into one row per line). All three are configured in tatami_masterplan.py (COURSE_TIMES, ORGANIZER_CONTACTS, INFO_TEXT) and just passed through — sheets_export.py has 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.