Matchmaking Pipeline & Registry — Implementation Spec
Status: Archived Scope: the shared-pipeline refactor + strategy registry + constraint model on the LIVE rotation path. Grounding: derived from 7 research passes (theory · ~35 products · engine internals · constraint-wiring audit · use-case coverage). Companion artifact: the engine-architecture brief. Canon: docs/canon/match-generation.md, generation-policy.entity.ts.
1. Purpose
Turn match generation from one hardcoded engine with a switch into a shared pipeline that applies every constraint once and dispatches strategy through a registry — so (a) no strategy can silently drop a constraint (today's fixed_pairs/random bug), (b) adding an algorithm = adding a descriptor, and (c) availability/late-attendance wires in as a first-class pool constraint.
This is a refactor of the live rotation path, not a rewrite. rotation.rules.ts is the reference; its primitives become the shared stages. The four tournament engines are out of scope for Slice 1 (they are unwired + pilot-gated — see §9).
Why (from the audits, one line each)
- The real generator dispatches on a closed 4-value
switch(config.partnerMode)atrotation.rules.ts:776; onlyrotation/skill_balancedhonor gender/repeat/ELO —fixed_pairsandrandomstructurally drop them (nohistory/genderMapparams). - Constraints live only inside
courtScore(), so anything added there no-ops on the crippled paths. rotationalone covers every shipped-need use-case; the registry is what makes the pilot-gated engines cheap to add later.
2. Non-goals (explicitly NOT in Slice 1)
- No tournament-engine work — no wiring, no
bracketsilent-drop fix, notournamentknockout rewrite, no output-shape unification across families. That is Slice 3 (pilot-gated), done when the engines are actually wired. - No UI — no picker, no weight panel, no feasibility surface, no availability-marking screen. Slice 2.
- No availability feature — Slice 1 adds the pool-constraint seam only (
resolvePoolaccepts an availability predicate that defaults to always-true). Thersvps.availableFromRound/arrivesAtcolumns, RLS, and marking UI are Slice 2+. - No
rollinglifecycle, no score-driven formats (Americano/Mexicano/KOTC) — Slice 4. - No change to the public API surface —
generateAllRounds/buildRoundAssignmentsFromMatchesstay exported from@twomore/appwith identical signatures;add-round-sheet.tsxkeeps working untouched.
3. Target module structure
New folder packages/app/src/domain/rules/matchmaking/ (pure domain — ARCH-1: zero outer imports):
domain/rules/matchmaking/
types.ts — pipeline types (§4)
registry.ts — STRATEGY_REGISTRY + resolveStrategy(policy) (§6)
resolve-pool.ts — resolvePool() (§5.1)
constraints.ts — buildConstraintSet(config), hard-constraint candidate filters (§5.2, §7)
score.ts — scoreArrangement() + pairCost() — the shared weighted objective (§5.3)
assign.ts — assignBenchAndFriendlies() shared helper (§5.4)
circle-method.ts — the ONE canonical circle-method (dedup target, §8)
seed-order.ts — seedOrder(size, method) (dedup target, §8)
strategies/
rotation.strategy.ts — wraps the greedy+2-opt (doubles) / circle (singles) as a MatchmakingStrategy
index.ts — barrel for the above (re-exported through domain/index.ts as needed)rotation.rules.ts keeps generateAllRounds / generateRound / buildRoundAssignmentsFromMatches / computeFairnessReport as the public entry points, but their bodies delegate into matchmaking/. Its private primitives (courtScore, pairCost, selectBench, circleMethodPairings, freedomGreedy*, rebalanceDoublesCourts) move into the new modules; rotation.rules.ts imports them back for the public wrappers. Net public-API change: none.
4. Core types (matchmaking/types.ts)
// The eligible pool for a single round, split into who plays vs who sits.
export interface RoundPool {
playing: string[]; // eligible + available + selected to play this round
bench: string[]; // eligible but sitting out (fairness rotation, or unavailable)
}
// Assignment constraints — shape HOW the eligible pool is arranged. Applied by
// every strategy via the shared score/filter, so none can be dropped.
export interface AssignmentConstraints {
requireMixedGender: boolean;
avoidRepeatPartners: boolean;
avoidRepeatOpponents: boolean;
balanceByElo: boolean;
eloTolerance: number;
avoidPairs: ReadonlyArray<readonly [string, string]>; // NEW — never partner/oppose (empty in Slice 1 presets)
lockedPairs: ReadonlyArray<readonly [string, string]>; // NEW — always partner (empty in Slice 1 presets)
}
// Pool constraints — decide WHO is eligible for a round. Applied before arrangement.
export interface PoolConstraints {
// roundNumber-aware availability. Slice 1: () => true (seam only).
isAvailable: (playerId: string, roundNumber: number) => boolean;
// settle-before-play + tier are resolved UPSTREAM into the eligible id list today
// (match-board-screen.tsx). The seam lets them fold in here later without a caller change.
}
// Everything a strategy needs beyond the raw pool.
export interface GenerationContext {
roundNumber: number;
genderMap: Map<string, 'M' | 'F' | null>; // was GenerateOptions.genderMap
ratings: Map<string, number>; // was GenerateOptions.eloSnapshot
history: RotationHistory; // partner/opponent/bench counts (existing type)
}
// A candidate arrangement for one round, strategy-agnostic.
// courts hold player IDs (singles: 1 per side; doubles: 2 per side). Bye = null slot.
export interface CandidateArrangement {
courts: Array<{ courtNumber: number; team1: (string | null)[]; team2: (string | null)[] }>;
bench: string[];
}
// The registry entry. Adding an algorithm = adding one of these.
export interface MatchmakingStrategy {
id: string; // 'round_robin' | 'americano' | 'swiss' | …
label: LocalizedText; // Korean UI label (app content only)
family: 'rotation' | 'rank_adaptive' | 'ladder' | 'swiss' | 'bracket';
requiresPriorResults: boolean; // the lifecycle fork — false for rotation family
defaultConfig: RotationConfig; // reuse existing config type in Slice 1
// Build one round from the resolved pool. MAY call scoreArrangement/applyConstraints internally
// (rotation's greedy interleaves construction + scoring — this is expected, not a violation).
generateCandidates(
pool: RoundPool,
courtCount: number,
ctx: GenerationContext,
constraints: AssignmentConstraints
): CandidateArrangement;
// Pre-generation forecast (Slice 2 surfaces it; the fn may land in Slice 1 as a stub returning
// the min-players floor only, then fill in Slice 2).
feasibility(config: RotationConfig, poolSize: number, courtCount: number): FeasibilityReport;
}Note on score vs generateCandidates: rotation's greedy scores partial courts during construction, so scoreArrangement is a shared primitive the strategy invokes, not a rigid post-stage. The pipeline's genuinely-sequential shared stages are resolvePool (before) and assignBenchAndFriendlies/output-normalization (after); constraints + scoring are shared primitives the strategy calls. Do not force a clean generate-then-score split — it would change rotation's output and break its tests.
5. The shared stages / primitives
5.1 resolvePool(eligibleIds, courtCount, config, ctx, poolConstraints) → RoundPool
Merges today's selectBench (rotation.rules.ts:707) with the new availability seam:
- Filter
eligibleIdsbypoolConstraints.isAvailable(id, ctx.roundNumber)→present. (Slice 1: predicate is() => true, sopresent === eligibleIds— behavior identical.) - Compute
activeCourts = min(courtCount, floor(present.length / playersPerCourt)),benchCount = present.length − activeCourts * playersPerCourt. - Run the existing fair-rotation bench sort (
benchCountasc, thenlastBenchedRoundasc, shuffled for tie-break) overpresent→{ playing, bench }. Availability that forces a player out this round joinsbenchwithout consuming a fairness slot (unavailable ≠ chose-to-sit) — a small, documented addition to the sort, dormant in Slice 1 since everyone is available.
5.2 buildConstraintSet(config) → AssignmentConstraints
Pure mapping from RotationConfig → AssignmentConstraints. avoidPairs/lockedPairs default [] (no preset sets them in Slice 1). This is the single place config becomes constraints; every strategy receives the same set.
5.3 scoreArrangement(arrangement, ctx, constraints) → number (+ pairCost)
The generalization of today's courtScore (rotation.rules.ts:160) + pairCost (k²). Weights preserved exactly for the rotation path:
- partner-repeat 100·k² (gated
avoidRepeatPartners) - opponent-repeat 30·k² doubles / 100·k² singles (gated
avoidRepeatOpponents) - ELO near-hard 10000-base + linear excess doubles / linear-only singles (gated
balanceByElo,eloTolerance) - same-gender-team 500 (gated
requireMixedGender) - NEW:
avoidPairs→ large penalty (or hard filter, see §7);lockedPairs→ negative/zero cost to prefer. Because all four strategies now call this same function with the full constraint set, thefixed_pairs/randomholes close.
5.4 assignBenchAndFriendlies(...) — dedup of the copy-pasted idle/friendly/bench block (rotation + the future tournament engines). Slice 1 extracts rotation's version; tournament engines adopt it in Slice 3.
6. The strategy registry (matchmaking/registry.ts)
export const STRATEGY_REGISTRY: Record<string, MatchmakingStrategy> = {
round_robin: rotationStrategy, // Slice 1: the only real entry (wraps today's greedy/circle)
// americano, mexicano, swiss, bracket … added in later slices
};
// Resolve which strategy to run from the policy. Slice 1: composition 'rotation' → round_robin;
// styleId (if set) overrides. Falls back to round_robin so existing behavior is the default.
export function resolveStrategy(policy: GenerationPolicy): MatchmakingStrategy {
if (policy.styleId && STRATEGY_REGISTRY[policy.styleId]) return STRATEGY_REGISTRY[policy.styleId];
// composition → default strategy per family (Slice 1 only maps 'rotation')
return STRATEGY_REGISTRY.round_robin;
}generateRound replaces its switch(config.partnerMode) (rotation.rules.ts:776) with: resolve the strategy (Slice 1 always round_robin), then call strategy.generateCandidates(pool, courtCount, ctx, constraints). The four partnerMode variants (rotate_all/skill_balanced/fixed_pairs/random) become config inside the one rotation strategy, not separate dispatch arms — matching how the market models sub-variants and eliminating the skill_balanced-is-an-alias smell.
7. Constraint model — pool vs assignment (the core of the wiring fix)
| Constraint | Kind | Where applied | Slice 1 |
|---|---|---|---|
| RSVP status | POOL | confirmedUserIds upstream | unchanged |
| settle-before-play | POOL | partitionSettledMatchPool upstream | unchanged (seam allows folding into resolvePool later) |
| tier gate | POOL | RSVP-creation trigger | unchanged (never reaches generator) |
| availability / late-attendance | POOL | resolvePool (NEW seam) | predicate wired, defaults true |
| requireMixedGender | ASSIGNMENT | scoreArrangement (shared) | now reaches ALL strategies |
| avoidRepeatPartners | ASSIGNMENT | scoreArrangement | now reaches ALL strategies |
| avoidRepeatOpponents | ASSIGNMENT | scoreArrangement | now reaches ALL strategies (fixes fixed_pairs' flag-ignoring) |
| balanceByElo + eloTolerance | ASSIGNMENT | scoreArrangement | now reaches ALL strategies |
| bench / sit-out fairness | POOL (per-round) | resolvePool | unchanged |
| avoidPairs / lockedPairs | ASSIGNMENT | scoreArrangement (or hard filter) | types + plumbing added, [] in presets |
Hard vs soft: today there is no hard-skip; even "near-hard" ELO is a big penalty. Keep it that way — avoidPairs is a very large penalty by default (so a draw still forms if unavoidable), with a documented option to make it a true candidate filter later.
7.1 Structural fix vs preset semantics (Track B principle)
The "constraint-hole fix" is structural, not a blanket "every strategy honors every constraint." Two layers:
- Structural (the fix): every
partnerModecandidate-generation path receives the fullGenerationContext(history, genderMap, ratings) and evaluates arrangements through the sharedscoreArrangementwith the fullAssignmentConstraints. This closes the holes —fixed_pairs/randombecome structurally capable of honoring any constraint; today they cannot (no params in their signatures). - Preset semantics (preserved): what each shipped preset actually does is a function of its config weights, and those are preserved. The
randompreset (balanceByElo:false, avoidRepeat*:false) stays effectively random because its weights are zero — not because the code structurally drops the inputs.rotate_all/skill_balancedare byte-for-byte unchanged. - The one genuine bug fixed:
generateFixedPairsapplies opponent-repeat scoring unconditionally, ignoringconfig.avoidRepeatOpponents. Gate it on the flag, and setFIXED_DOUBLES_PRESET.avoidRepeatOpponents = true(fixed partners should face varied opponents across rounds — the sensible 고정 복식 UX). This is the one intended behavior change; update its tests with a comment referencing this spec. - partnerMode collapses to config: one
rotationstrategy whosegenerateCandidatesbranches internally onconfig.partnerModefor candidate structure (greedy / fixed-adjacency / random-slice), all sharing the scoring/constraint/context machinery — not four registry entries.skill_balanced's literal-alias smell disappears (it's justrotate_allstructure + high ELO weight from its preset).
8. De-duplication targets (Slice 1, rotation-side only)
- Circle method —
rotation.rules.ts:226 circleMethodPairings(private) andtournament.rules.ts:59 roundRobinSchedule(public) are the same algorithm with a drifted guard. Slice 1 extracts one canonicalmatchmaking/circle-method.ts;rotation.rulesimports it.tournament.rulesswitches to it in Slice 3 (when touched) — do not edittournament.rulesin Slice 1 beyond a re-export if trivial and test-green. assignBenchAndFriendlies— extract rotation's version now; tournament engines adopt in Slice 3.seedOrder(size, method: 'fold' | 'linear')— bracket/compass concern; Slice 3 (listed here for the canonical location only).
9. Per-engine plan
| Engine | Slice 1 | Later |
|---|---|---|
rotation.rules | EXTRACT-FROM — primitives move to matchmaking/*; becomes the round_robin strategy; public API unchanged | — |
tournament.rules | untouched (may adopt shared circle-method if trivially test-green) | Slice 3: salvage kernel, rewrite dead knockout to delegate to bracket |
swiss.rules | untouched | Slice 3: wrap as strategy, hook resolvePool/constraints |
bracket.rules | untouched | Slice 3: wrap as strategy, fix generateGroupKnockout silent-drop, move recommendFormat to orchestration |
compass.rules | untouched | Slice 4 / park |
10. Verification
- Behavior-preserving (tests MUST stay green): the entire rotation default path (
rotate_all,skill_balanced, singles) — same weights, same greedy, same bench sort ⇒rotation.rules.test.tsunchanged. The 108 tournament-engine tests are untouched (engines not edited). - Intended-fix (tests UPDATED to encode correct behavior):
fixed_pairsandrandomnow honor gender/ELO/repeat via the shared score. Any existing test asserting the old dropped-constraint behavior is updated with a comment referencing this spec. New tests assert: (a)random/fixed_pairsrespectrequireMixedGenderandavoidRepeatOpponents; (b)resolveStrategyreturnsround_robinfor the default policy; (c)resolvePoolwith an all-trueisAvailableis identical to today'sselectBench; (d) an availability predicate that excludes a player benches them without consuming a fairness slot. yarn check(typecheck + full test suite) green. No public-API/type-drift. No new lint waivers.- Blast radius:
add-round-sheet.tsx+match-board-screen.tsxmust compile and behave identically (they call only the unchanged public entry points).
11. Slice sequence (recap)
- Slice 1 (this spec): shared pipeline + registry + constraint model + availability pool-seam, on the rotation path. Foundation, mostly behavior-preserving.
- Slice 2: rotation-family UI — preset cards + feasibility forecast + availability-marking (round + time) + the
rsvpsavailability columns/RLS. Weight panel = fast-follow. - Slice 3 (pilot-gated): wire the tournament family (
bracket+swiss) behindrecommendFormat; fix/droptournamentknockout; unify output onTournamentRound. - Slice 4 (as demand lands): score-driven formats (Americano/Mexicano/KOTC) +
rollinglifecycle + IA consolidation.
13. Slice 3 — wire the tournament family (owner: "build everything in")
Wire the four built-but-unwired engines (swiss/bracket/tournament/compass.rules.ts) into the registry. They are backend-complete + tested (108 tests) but unreachable. This is a WIRING + refactor job, not new algorithms.
13.1 The contract splits into two kinds (the lifecycle fork)
The Slice-1 MatchmakingStrategy (generateCandidates → one round) fits the rotation family only. Tournaments precompute a bracket/schedule then advance on results. So:
export type MatchmakingStrategy = RoundStrategy | TournamentStrategy;
export interface RoundStrategy {
// rotation family — round-by-round
kind: 'round';
id: string;
label: LocalizedText;
family: 'rotation' | 'rank_adaptive' | 'ladder';
requiresPriorResults: false;
defaultConfig: RotationConfig;
generateCandidates(pool, courtCount, ctx, constraints, config): CandidateArrangement; // unchanged
feasibility(config, poolSize, courtCount): FeasibilityReport;
}
export interface TournamentStrategy {
// swiss/bracket family — precompute + advance
kind: 'tournament';
id: string;
label: LocalizedText;
family: 'swiss' | 'bracket';
requiresPriorResults: boolean; // swiss/compass: true (round-by-round from results); SE bracket: advance-on-result
/** Build the initial state (round 1 / full bracket skeleton) from the seeded pool. */
init(players: string[], courtCount: number, format: SessionFormat): TournamentState;
/** Advance after a round's results are entered. Idempotent per completed round. */
advance(state: TournamentState, results: RoundResult[]): TournamentState;
/** Current standings (wins/losses/points/Buchholz as the engine computes). */
standings(state: TournamentState): GroupStanding[];
feasibility(playerCount: number, courtCount: number): FeasibilityReport;
}TournamentState / RoundResult / GroupStanding reuse the engines' existing tournament.rules.ts types (TournamentBracket-ish + GroupStanding) — do NOT invent parallel ones. generateRound in rotation.rules.ts narrows resolveStrategy(...) to kind === 'round' (Slice 1 only registers round strategies, so this is a safe type-narrow with an exhaustiveness guard).
13.2 Output unification
All four engines already mostly emit TournamentRound/TournamentMatch (tournament.rules.ts). Standardize:
- One
TournamentRound/TournamentMatchshape across swiss/tournament/compass; bracket's single-elimination tree (BracketRound) stays a documented structural exception (a knockout tree is genuinely not a flat round list) but exposes atoRounds()view for uniform rendering. - Bye =
null(drop the'__BYE__'/'__BYE_${i}__'string sentinels). - A converter
tournamentRoundsToMatches(rounds, sessionId): Omit<Match,'id'|'createdAt'>[]bridges to the app'sMatchmodel so the existing match-board / scorecard can render tournament matches (doubles via pre-paired team-id strings, documented).
13.3 Per-engine work
swiss.rules.ts→swissStrategy(kind:'tournament', family'swiss',requiresPriorResults:true). WrapgenerateSwissTournament/generateNextSwissRound/getSwissFinalStandings. Adopt the sharedassignBenchAndFriendlies(dedup its copy). Sound as-is otherwise.bracket.rules.ts→bracketStrategy(kind:'tournament', family'bracket'). WrapgenerateSingleElimination+advanceBracket(SE) andgenerateGroupKnockout+recordPoolResult+advanceToKnockout(group-KO). FIX the silent-drop bug atgenerateGroupKnockout(~:355 —Math.min(pairings.length, courtsPerGroup)drops pool matches beyond court count; route overflow throughassignBenchAndFriendlies/ queue instead of truncating). MoverecommendFormatout tomatchmaking/recommend-format.ts(orchestration — it advises which strategy per player-count: RR ≤8 / swiss 9–16 / group-KO 17+).tournament.rules.ts→roundRobinGroupsStrategy(kind:'tournament', family'bracket'). KeeproundRobinSchedule+generateGroupStage+calculateStandings(the shared kernel). REWRITE the dead-end knockout (generateKnockoutStage/generateTournamentBracketproduce permanent empty-string placeholder finals) to delegate tobracketStrategy's working SE. DeduproundRobinScheduleagainstmatchmaking/circle-method.ts(one canonical copy).compass.rules.ts→compassStrategy(kind:'tournament', family'bracket'). WrapgenerateCompassTournament+advanceCompassRound1/2. Finish the state machine (no round-3/final resolver today) by delegating each bracket direction's final tobracketStrategy's SE. UnifypadToPowerOf2/seeding via a sharedmatchmaking/seed-order.ts(seedOrder(size, method:'fold'|'linear')).
13.4 Registry + selection
STRATEGY_REGISTRY gains swiss/bracket/round_robin_groups/compass. resolveStrategy(policy) still resolves by styleId; a new recommendStrategy(playerCount, courtCount) (from recommend-format.ts) is what a tournament-create UI calls to pick a default.
13.5 Create-tournament path + surface (minimal, inspectable)
The Tournament entity + TournamentRepositoryPort + tournament.supabase.ts adapter + public.tournaments table (00097) already exist, wired in registry.ts, but have zero UI. Add: a useCreateTournament mutation (persists a Tournament row with format + initial state from strategy.init), a useAdvanceTournament mutation, and a minimal bracket/standings surface (a TournamentBoardScreen reachable from session detail when session.style === 'tournament') — enough to inspect, not pixel-final. Standings via strategy.standings. Reuse MatchScoreboard for match cells.
13.6 Verification
The 108 existing engine tests stay green (behavior preserved; only wrappers + the 2 bug fixes change output — update those specific tests). New tests: registry resolves each tournament strategy; the bracket silent-drop fix (16p/4c produces all pool matches); tournament knockout now resolves a real final; compass completes 3 rounds. yarn check green. Non-goals for Slice 3: the net-new score-driven formats (Americano/Mexicano/KOTC — Slice 4) and the full IA consolidation.
12. Agent execution notes
Slice 1 is delegable to Sonnet agents in ≤3 parallel tracks:
- Track A: create
matchmaking/{types,circle-method,score,constraints,resolve-pool,assign}.tsby extracting fromrotation.rules.ts(no behavior change). Keeprotation.rules.tspublic wrappers. - Track B:
matchmaking/{registry,strategies/rotation.strategy}.ts+ swapgenerateRound's switch forresolveStrategy+ route all fourpartnerModevariants through the shared score (the constraint-hole fix). Depends on A. - Track C: tests — update the
fixed_pairs/randombug tests, add the new registry/resolvePool/availability-seam tests. Depends on B. Run A first, then B+C. Each agent: read this spec + CLAUDE.md, runyarn typecheckafter changes, report files + test result.