Skip to content

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) at rotation.rules.ts:776; only rotation/skill_balanced honor gender/repeat/ELO — fixed_pairs and random structurally drop them (no history/genderMap params).
  • Constraints live only inside courtScore(), so anything added there no-ops on the crippled paths.
  • rotation alone 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 bracket silent-drop fix, no tournament knockout 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 (resolvePool accepts an availability predicate that defaults to always-true). The rsvps.availableFromRound/arrivesAt columns, RLS, and marking UI are Slice 2+.
  • No rolling lifecycle, no score-driven formats (Americano/Mexicano/KOTC) — Slice 4.
  • No change to the public API surfacegenerateAllRounds / buildRoundAssignmentsFromMatches stay exported from @twomore/app with identical signatures; add-round-sheet.tsx keeps 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)

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:

  1. Filter eligibleIds by poolConstraints.isAvailable(id, ctx.roundNumber)present. (Slice 1: predicate is () => true, so present === eligibleIdsbehavior identical.)
  2. Compute activeCourts = min(courtCount, floor(present.length / playersPerCourt)), benchCount = present.length − activeCourts * playersPerCourt.
  3. Run the existing fair-rotation bench sort (benchCount asc, then lastBenchedRound asc, shuffled for tie-break) over present{ playing, bench }. Availability that forces a player out this round joins bench without 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 RotationConfigAssignmentConstraints. 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, the fixed_pairs/random holes 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)

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)

ConstraintKindWhere appliedSlice 1
RSVP statusPOOLconfirmedUserIds upstreamunchanged
settle-before-playPOOLpartitionSettledMatchPool upstreamunchanged (seam allows folding into resolvePool later)
tier gatePOOLRSVP-creation triggerunchanged (never reaches generator)
availability / late-attendancePOOLresolvePool (NEW seam)predicate wired, defaults true
requireMixedGenderASSIGNMENTscoreArrangement (shared)now reaches ALL strategies
avoidRepeatPartnersASSIGNMENTscoreArrangementnow reaches ALL strategies
avoidRepeatOpponentsASSIGNMENTscoreArrangementnow reaches ALL strategies (fixes fixed_pairs' flag-ignoring)
balanceByElo + eloToleranceASSIGNMENTscoreArrangementnow reaches ALL strategies
bench / sit-out fairnessPOOL (per-round)resolvePoolunchanged
avoidPairs / lockedPairsASSIGNMENTscoreArrangement (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 partnerMode candidate-generation path receives the full GenerationContext (history, genderMap, ratings) and evaluates arrangements through the shared scoreArrangement with the full AssignmentConstraints. This closes the holes — fixed_pairs/random become 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 random preset (balanceByElo:false, avoidRepeat*:false) stays effectively random because its weights are zero — not because the code structurally drops the inputs. rotate_all/skill_balanced are byte-for-byte unchanged.
  • The one genuine bug fixed: generateFixedPairs applies opponent-repeat scoring unconditionally, ignoring config.avoidRepeatOpponents. Gate it on the flag, and set FIXED_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 rotation strategy whose generateCandidates branches internally on config.partnerMode for 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 just rotate_all structure + high ELO weight from its preset).

8. De-duplication targets (Slice 1, rotation-side only)

  • Circle methodrotation.rules.ts:226 circleMethodPairings (private) and tournament.rules.ts:59 roundRobinSchedule (public) are the same algorithm with a drifted guard. Slice 1 extracts one canonical matchmaking/circle-method.ts; rotation.rules imports it. tournament.rules switches to it in Slice 3 (when touched) — do not edit tournament.rules in 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

EngineSlice 1Later
rotation.rulesEXTRACT-FROM — primitives move to matchmaking/*; becomes the round_robin strategy; public API unchanged
tournament.rulesuntouched (may adopt shared circle-method if trivially test-green)Slice 3: salvage kernel, rewrite dead knockout to delegate to bracket
swiss.rulesuntouchedSlice 3: wrap as strategy, hook resolvePool/constraints
bracket.rulesuntouchedSlice 3: wrap as strategy, fix generateGroupKnockout silent-drop, move recommendFormat to orchestration
compass.rulesuntouchedSlice 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.ts unchanged. The 108 tournament-engine tests are untouched (engines not edited).
  • Intended-fix (tests UPDATED to encode correct behavior): fixed_pairs and random now 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_pairs respect requireMixedGender and avoidRepeatOpponents; (b) resolveStrategy returns round_robin for the default policy; (c) resolvePool with an all-true isAvailable is identical to today's selectBench; (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.tsx must compile and behave identically (they call only the unchanged public entry points).

11. Slice sequence (recap)

  1. Slice 1 (this spec): shared pipeline + registry + constraint model + availability pool-seam, on the rotation path. Foundation, mostly behavior-preserving.
  2. Slice 2: rotation-family UI — preset cards + feasibility forecast + availability-marking (round + time) + the rsvps availability columns/RLS. Weight panel = fast-follow.
  3. Slice 3 (pilot-gated): wire the tournament family (bracket + swiss) behind recommendFormat; fix/drop tournament knockout; unify output on TournamentRound.
  4. Slice 4 (as demand lands): score-driven formats (Americano/Mexicano/KOTC) + rolling lifecycle + 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:

ts
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/TournamentMatch shape 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 a toRounds() 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's Match model 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.tsswissStrategy (kind:'tournament', family 'swiss', requiresPriorResults:true). Wrap generateSwissTournament/generateNextSwissRound/getSwissFinalStandings. Adopt the shared assignBenchAndFriendlies (dedup its copy). Sound as-is otherwise.
  • bracket.rules.tsbracketStrategy (kind:'tournament', family 'bracket'). Wrap generateSingleElimination+advanceBracket (SE) and generateGroupKnockout+recordPoolResult+advanceToKnockout (group-KO). FIX the silent-drop bug at generateGroupKnockout (~:355 — Math.min(pairings.length, courtsPerGroup) drops pool matches beyond court count; route overflow through assignBenchAndFriendlies / queue instead of truncating). Move recommendFormat out to matchmaking/recommend-format.ts (orchestration — it advises which strategy per player-count: RR ≤8 / swiss 9–16 / group-KO 17+).
  • tournament.rules.tsroundRobinGroupsStrategy (kind:'tournament', family 'bracket'). Keep roundRobinSchedule+generateGroupStage+calculateStandings (the shared kernel). REWRITE the dead-end knockout (generateKnockoutStage/generateTournamentBracket produce permanent empty-string placeholder finals) to delegate to bracketStrategy's working SE. Dedup roundRobinSchedule against matchmaking/circle-method.ts (one canonical copy).
  • compass.rules.tscompassStrategy (kind:'tournament', family 'bracket'). Wrap generateCompassTournament+advanceCompassRound1/2. Finish the state machine (no round-3/final resolver today) by delegating each bracket direction's final to bracketStrategy's SE. Unify padToPowerOf2/seeding via a shared matchmaking/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}.ts by extracting from rotation.rules.ts (no behavior change). Keep rotation.rules.ts public wrappers.
  • Track B: matchmaking/{registry,strategies/rotation.strategy}.ts + swap generateRound's switch for resolveStrategy + route all four partnerMode variants through the shared score (the constraint-hole fix). Depends on A.
  • Track C: tests — update the fixed_pairs/random bug 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, run yarn typecheck after changes, report files + test result.

Markdown remains the source of truth. Run yarn docs:check before handoff.