diff --git a/src/matcher.test.ts b/src/matcher.test.ts index 3f071d4..6fe0745 100644 --- a/src/matcher.test.ts +++ b/src/matcher.test.ts @@ -1,14 +1,15 @@ import { describe, expect, test } from "bun:test"; import { - buildGroupCandidates, calculateExperienceStats, calculatePairScore, + calculateRecencyPenalty, createMatches, + findBestPartition, + generatePartition, getExperienceLevel, getExperienceMixScore, - getMeetingCount, - scoresToProbabilities, - weightedRandomSelect, + getRecentPairs, + scorePartition, } from "./matcher.ts"; import type { MatchHistory, Participant } from "./types.ts"; @@ -69,7 +70,7 @@ describe("createMatches", () => { expect(matchedIds.sort()).toEqual(participants.map((p) => p.id).sort()); }); - test("최근 매칭 이력이 있으면 같은 조를 피한다", () => { + test("직전 라운드 페어가 같은 조에 배치되지 않는다", () => { const participants = createParticipants(4); const history: MatchHistory = { matches: [ @@ -83,9 +84,10 @@ describe("createMatches", () => { ], }; - // 여러 번 실행해서 이력과 다른 매칭이 나오는지 확인 - let foundDifferentPairing = false; - for (let i = 0; i < 50; i++) { + // 4명 2인조: 가능한 조합은 (1-2,3-4), (1-3,2-4), (1-4,2-3) + // 직전 라운드의 페어 user1-user2와 user3-user4는 하드 제외 + // 따라서 (1-3,2-4) 또는 (1-4,2-3)만 가능 + for (let i = 0; i < 20; i++) { const pairs = createMatches(participants, history); const pairKeys = pairs.map((pair) => pair @@ -94,16 +96,9 @@ describe("createMatches", () => { .join(","), ); - if ( - !pairKeys.includes("user1,user2") && - !pairKeys.includes("user3,user4") - ) { - foundDifferentPairing = true; - break; - } + expect(pairKeys).not.toContain("user1,user2"); + expect(pairKeys).not.toContain("user3,user4"); } - - expect(foundDifferentPairing).toBe(true); }); test("2명일 때 1개 조가 생성된다", () => { @@ -202,49 +197,45 @@ describe("createMatches with groupSize", () => { }); }); -describe("getMeetingCount", () => { +describe("calculateRecencyPenalty", () => { test("만난 적 없으면 0을 반환한다", () => { const history: MatchHistory = { matches: [] }; - expect(getMeetingCount("user1", "user2", history)).toBe(0); + expect(calculateRecencyPenalty("user1", "user2", history)).toBe(0); }); - test("한 번 만났으면 1을 반환한다", () => { + test("직전 라운드에서 만났으면 1.0을 반환한다", () => { const history: MatchHistory = { - matches: [ - { - date: "2025-01-01", - pairs: [["user1", "user2"]], - }, - ], + matches: [{ date: "2025-01-01", pairs: [["user1", "user2"]] }], }; - expect(getMeetingCount("user1", "user2", history)).toBe(1); + expect(calculateRecencyPenalty("user1", "user2", history)).toBe(1); }); - test("여러 번 만났으면 정확한 횟수를 반환한다", () => { + test("여러 라운드에서 만났으면 1/roundsAgo 합산을 반환한다", () => { const history: MatchHistory = { matches: [ - { date: "2025-01-01", pairs: [["user1", "user2"]] }, - { date: "2025-01-08", pairs: [["user1", "user3"]] }, - { date: "2025-01-15", pairs: [["user1", "user2"]] }, - { date: "2025-01-22", pairs: [["user1", "user2"]] }, + { date: "2025-01-01", pairs: [["user1", "user2"]] }, // roundsAgo=3 + { date: "2025-01-08", pairs: [["user1", "user3"]] }, // roundsAgo=2 + { date: "2025-01-15", pairs: [["user1", "user2"]] }, // roundsAgo=1 ], }; - expect(getMeetingCount("user1", "user2", history)).toBe(3); - expect(getMeetingCount("user1", "user3", history)).toBe(1); - }); - - test("3인조에서도 만남을 카운트한다", () => { + // penalty = 1/1 + 1/3 = 1.333... + expect(calculateRecencyPenalty("user1", "user2", history)).toBeCloseTo( + 1 + 1 / 3, + 5, + ); + // penalty = 1/2 + expect(calculateRecencyPenalty("user1", "user3", history)).toBeCloseTo( + 1 / 2, + 5, + ); + }); + + test("3인조에서 만남도 카운트한다", () => { const history: MatchHistory = { - matches: [ - { - date: "2025-01-01", - pairs: [["user1", "user2", "user3"]], - }, - ], + matches: [{ date: "2025-01-01", pairs: [["user1", "user2", "user3"]] }], }; - expect(getMeetingCount("user1", "user2", history)).toBe(1); - expect(getMeetingCount("user1", "user3", history)).toBe(1); - expect(getMeetingCount("user2", "user3", history)).toBe(1); + expect(calculateRecencyPenalty("user1", "user2", history)).toBe(1); + expect(calculateRecencyPenalty("user2", "user3", history)).toBe(1); }); }); @@ -338,7 +329,7 @@ describe("getExperienceMixScore", () => { }); describe("calculatePairScore", () => { - test("만남 횟수가 적고 경험 믹싱이 좋으면 높은 점수", () => { + test("만남 이력이 없고 경험 믹싱이 좋으면 높은 점수", () => { const history: MatchHistory = { matches: [] }; const stats = { matchCounts: new Map([ @@ -349,13 +340,13 @@ describe("calculatePairScore", () => { }; const score = calculatePairScore("user1", "user2", history, stats); - // meetingScore = 1/(1+0) = 1.0 + // recencyPenalty = 0, meetingScore = 1/(1+0) = 1.0 // mixScore = 1.0 (newcomer-veteran) // total = 1.0 * 0.6 + 1.0 * 0.4 = 1.0 expect(score).toBe(1.0); }); - test("만남 횟수가 많으면 점수가 낮아진다", () => { + test("최근 만남이 많으면 점수가 낮아진다", () => { const history: MatchHistory = { matches: [ { date: "2025-01-01", pairs: [["user1", "user2"]] }, @@ -371,77 +362,244 @@ describe("calculatePairScore", () => { }; const score = calculatePairScore("user1", "user2", history, stats); - // meetingScore = 1/(1+2) = 0.333... + // recencyPenalty = 1/1 + 1/2 = 1.5 + // meetingScore = 1/(1+1.5) = 0.4 // mixScore = 0.6 (regular-regular) - // total = 0.333 * 0.6 + 0.6 * 0.4 = 0.2 + 0.24 = 0.44 - expect(score).toBeCloseTo(0.44, 2); + // total = 0.4 * 0.6 + 0.6 * 0.4 = 0.24 + 0.24 = 0.48 + expect(score).toBeCloseTo(0.48, 2); + }); + + test("오래된 만남은 페널티가 작다", () => { + const history: MatchHistory = { + matches: [ + { date: "2025-01-01", pairs: [["user1", "user2"]] }, + { date: "2025-01-08", pairs: [["user1", "user3"]] }, + { date: "2025-01-15", pairs: [["user1", "user3"]] }, + { date: "2025-01-22", pairs: [["user1", "user3"]] }, + { date: "2025-01-29", pairs: [["user1", "user3"]] }, + ], + }; + const stats = { + matchCounts: new Map([ + ["user1", 5], + ["user2", 5], + ]), + maxCount: 10, + }; + + const score = calculatePairScore("user1", "user2", history, stats); + // recencyPenalty = 1/5 = 0.2 (met in oldest round only) + // meetingScore = 1/(1+0.2) = 0.833... + // mixScore = 0.6 (regular-regular) + // total = 0.833 * 0.6 + 0.6 * 0.4 = 0.5 + 0.24 = 0.74 + expect(score).toBeCloseTo(0.74, 2); }); }); -describe("buildGroupCandidates", () => { - test("groupSize=2일 때 모든 페어를 생성한다", () => { - const participants = createParticipants(3); +describe("getRecentPairs", () => { + test("빈 히스토리면 빈 Set을 반환한다", () => { const history: MatchHistory = { matches: [] }; - const stats = calculateExperienceStats(participants, history); + expect(getRecentPairs(history).size).toBe(0); + }); + + test("직전 라운드의 모든 페어를 반환한다", () => { + const history: MatchHistory = { + matches: [ + { + date: "2025-01-01", + pairs: [ + ["user1", "user2"], + ["user3", "user4"], + ], + }, + ], + }; + const pairs = getRecentPairs(history); + expect(pairs.has("user1,user2")).toBe(true); + expect(pairs.has("user3,user4")).toBe(true); + expect(pairs.size).toBe(2); + }); - const candidates = buildGroupCandidates(participants, history, stats, 2); + test("3인조에서는 모든 페어 조합을 반환한다", () => { + const history: MatchHistory = { + matches: [ + { + date: "2025-01-01", + pairs: [["user1", "user2", "user3"]], + }, + ], + }; + const pairs = getRecentPairs(history); + expect(pairs.has("user1,user2")).toBe(true); + expect(pairs.has("user1,user3")).toBe(true); + expect(pairs.has("user2,user3")).toBe(true); + expect(pairs.size).toBe(3); + }); - // C(3,2) = 3 - expect(candidates).toHaveLength(3); - expect(candidates.every((c) => c.ids.length === 2)).toBe(true); + test("lookback=1이면 직전 라운드만 본다", () => { + const history: MatchHistory = { + matches: [ + { date: "2025-01-01", pairs: [["user1", "user2"]] }, + { date: "2025-01-08", pairs: [["user3", "user4"]] }, + ], + }; + const pairs = getRecentPairs(history, 1); + expect(pairs.has("user3,user4")).toBe(true); + expect(pairs.has("user1,user2")).toBe(false); }); +}); - test("groupSize=3일 때 모든 3인 조합을 생성한다", () => { - const participants = createParticipants(4); - const history: MatchHistory = { matches: [] }; - const stats = calculateExperienceStats(participants, history); +describe("generatePartition", () => { + test("참여자를 groupSize씩 나눈다", () => { + const participants = createParticipants(6); + const groups = generatePartition(participants, 2); + + expect(groups).toHaveLength(3); + expect(groups.every((g) => g.length === 2)).toBe(true); + }); - const candidates = buildGroupCandidates(participants, history, stats, 3); + test("모든 참여자가 포함된다", () => { + const participants = createParticipants(6); + const groups = generatePartition(participants, 2); + const allIds = groups + .flat() + .map((p) => p.id) + .sort(); - // C(4,3) = 4 - expect(candidates).toHaveLength(4); - expect(candidates.every((c) => c.ids.length === 3)).toBe(true); + expect(allIds).toEqual(participants.map((p) => p.id).sort()); }); -}); -describe("scoresToProbabilities", () => { - test("빈 배열은 빈 배열 반환", () => { - expect(scoresToProbabilities([])).toEqual([]); + test("나머지 인원은 기존 그룹에 분배된다", () => { + const participants = createParticipants(7); + const groups = generatePartition(participants, 3); + + expect(groups).toHaveLength(2); + const lengths = groups.map((g) => g.length).sort(); + expect(lengths).toEqual([3, 4]); }); +}); - test("높은 점수가 높은 확률을 갖는다", () => { - const candidates = [ - { ids: ["a", "b"], score: 1.0 }, - { ids: ["c", "d"], score: 0.5 }, +describe("scorePartition", () => { + test("그룹 내 모든 페어의 pairScore 합을 반환한다", () => { + const participants = createParticipants(4); + const history: MatchHistory = { matches: [] }; + const stats = calculateExperienceStats(participants, history); + const recentPairs = new Set(); + const pairScores = new Map(); + // 빈 히스토리에서 모든 meetingScore=1.0, 모든 mixScore=0.3 (newcomer-newcomer) + // pairScore = 1.0 * 0.6 + 0.3 * 0.4 = 0.72 per pair + for (let i = 0; i < participants.length; i++) { + for (let j = i + 1; j < participants.length; j++) { + const key = [participants[i].id, participants[j].id].sort().join(","); + pairScores.set( + key, + calculatePairScore( + participants[i].id, + participants[j].id, + history, + stats, + ), + ); + } + } + const groups = [ + [participants[0], participants[1]], + [participants[2], participants[3]], ]; - const probs = scoresToProbabilities(candidates, 0.5); + const score = scorePartition(groups, recentPairs, pairScores); - expect(probs[0].probability).toBeGreaterThan(probs[1].probability); + // 2 groups × 1 pair each = 2 pairs total + expect(score.total).toBeCloseTo(0.72 * 2, 5); + expect(score.hasViolation).toBe(false); }); - test("확률의 합은 1이다", () => { - const candidates = [ - { ids: ["a", "b"], score: 0.8 }, - { ids: ["c", "d"], score: 0.6 }, - { ids: ["e", "f"], score: 0.4 }, + test("하드 제외 위반 페어가 있으면 hasViolation이 true", () => { + const participants = createParticipants(4); + const history: MatchHistory = { matches: [] }; + const stats = calculateExperienceStats(participants, history); + const recentPairs = new Set(["user1,user2"]); + const pairScores = new Map(); + for (let i = 0; i < participants.length; i++) { + for (let j = i + 1; j < participants.length; j++) { + const key = [participants[i].id, participants[j].id].sort().join(","); + pairScores.set( + key, + calculatePairScore( + participants[i].id, + participants[j].id, + history, + stats, + ), + ); + } + } + const groups = [ + [participants[0], participants[1]], + [participants[2], participants[3]], ]; - const probs = scoresToProbabilities(candidates, 0.5); - const sum = probs.reduce((acc, p) => acc + p.probability, 0); + const score = scorePartition(groups, recentPairs, pairScores); - expect(sum).toBeCloseTo(1.0, 5); + expect(score.hasViolation).toBe(true); }); }); -describe("weightedRandomSelect", () => { - test("빈 배열은 null 반환", () => { - expect(weightedRandomSelect([])).toBeNull(); +describe("findBestPartition", () => { + test("유효한 파티션을 반환한다", () => { + const participants = createParticipants(6); + const history: MatchHistory = { matches: [] }; + + const groups = findBestPartition(participants, history, { groupSize: 2 }); + + expect(groups).toHaveLength(3); + expect(groups.every((g) => g.length === 2)).toBe(true); + const allIds = groups + .flat() + .map((p) => p.id) + .sort(); + expect(allIds).toEqual(participants.map((p) => p.id).sort()); }); - test("하나만 있으면 그것을 반환", () => { - const candidates = [{ id: "a", probability: 1.0 }]; - expect(weightedRandomSelect(candidates)).toEqual(candidates[0]); + test("하드 제외 위반 파티션을 피한다", () => { + const participants = createParticipants(4); + const history: MatchHistory = { + matches: [ + { + date: "2025-01-01", + pairs: [ + ["user1", "user2"], + ["user3", "user4"], + ], + }, + ], + }; + + // 여러 번 실행해서 직전 매칭 페어가 나오지 않는지 확인 + for (let i = 0; i < 20; i++) { + const groups = findBestPartition(participants, history, { groupSize: 2 }); + const pairKeys = groups.map((g) => + g + .map((p) => p.id) + .sort() + .join(","), + ); + // 직전 라운드의 개별 페어가 나오면 안 됨 + expect(pairKeys).not.toContain("user1,user2"); + expect(pairKeys).not.toContain("user3,user4"); + } + }); + + test("대안이 없으면 fallback으로 최선 파티션 반환", () => { + // 2명만 있으면 유일한 파티션이 직전 매칭과 동일 + const participants = createParticipants(2); + const history: MatchHistory = { + matches: [{ date: "2025-01-01", pairs: [["user1", "user2"]] }], + }; + + const groups = findBestPartition(participants, history, { groupSize: 2 }); + expect(groups).toHaveLength(1); + expect(groups[0]).toHaveLength(2); }); }); @@ -558,4 +716,99 @@ describe("통계적 검증", () => { // 신규-경험자 믹싱이 어느 정도 발생해야 함 expect(mixedPairCount).toBeGreaterThan(iterations * 0.3); }); + + test("최근에 만난 페어보다 오래전에 만난 페어가 선호된다", () => { + const participants = createParticipants(4); + const history: MatchHistory = { + matches: [ + { + date: "2025-01-01", + pairs: [ + ["user1", "user3"], + ["user2", "user4"], + ], + }, + { + date: "2025-01-08", + pairs: [ + ["user1", "user4"], + ["user2", "user3"], + ], + }, + { + date: "2025-01-15", + pairs: [ + ["user1", "user3"], + ["user2", "user4"], + ], + }, + { + date: "2025-01-22", + pairs: [ + ["user1", "user4"], + ["user2", "user3"], + ], + }, + // 직전: user1-user2, user3-user4 (하드 제외 대상) + { + date: "2025-01-29", + pairs: [ + ["user1", "user2"], + ["user3", "user4"], + ], + }, + ], + }; + + // 하드 제외로 user1-user2, user3-user4 불가 + // 남은 선택지: (user1-user3, user2-user4) vs (user1-user4, user2-user3) + // user1-user3: index 0(roundsAgo=5) & index 2(roundsAgo=3), penalty = 1/5+1/3 = 0.533 + // user1-user4: index 1(roundsAgo=4) & index 3(roundsAgo=2), penalty = 1/4+1/2 = 0.75 + // user1-user3의 penalty가 더 낮으므로 (user1-user3, user2-user4) 선호 + let user1user3Count = 0; + const iterations = 100; + + for (let i = 0; i < iterations; i++) { + const pairs = createMatches(participants, history); + const pairKeys = pairs.map((pair) => + pair + .map((p) => p.id) + .sort() + .join(","), + ); + if (pairKeys.includes("user1,user3")) { + user1user3Count++; + } + } + + // best-of-N은 결정적으로 최고점을 선택하므로 대부분 user1-user3을 선택해야 함 + expect(user1user3Count).toBeGreaterThan(iterations * 0.5); + }); +}); + +describe("성능", () => { + test("30명 매칭이 500ms 이내에 완료된다", () => { + const participants = createParticipants(30); + const history: MatchHistory = { + matches: Array.from({ length: 20 }, (_, i) => ({ + date: `2025-01-${String(i + 1).padStart(2, "0")}`, + pairs: [ + [`user${(i % 30) + 1}`, `user${((i + 1) % 30) + 1}`], + [`user${((i + 2) % 30) + 1}`, `user${((i + 3) % 30) + 1}`], + ], + })), + }; + + const start = performance.now(); + const groups = createMatches(participants, history); + const elapsed = performance.now() - start; + + expect(elapsed).toBeLessThan(500); + expect(groups.length).toBeGreaterThan(0); + const allIds = groups + .flat() + .map((p) => p.id) + .sort(); + expect(allIds).toEqual(participants.map((p) => p.id).sort()); + }); }); diff --git a/src/matcher.ts b/src/matcher.ts index 70dce46..e196c4b 100644 --- a/src/matcher.ts +++ b/src/matcher.ts @@ -40,46 +40,60 @@ export function shuffle(array: T[]): T[] { return result; } -function getRecentGroups( +function pairKey(idA: string, idB: string): string { + return idA < idB ? `${idA},${idB}` : `${idB},${idA}`; +} + +/** + * 최근 lookback 라운드의 모든 페어를 Set으로 반환 + * 페어 키는 "id1,id2" 형식 (정렬됨) + */ +export function getRecentPairs( history: MatchHistory, - lookback: number = 4, + lookback: number = 1, ): Set { const recentMatches = history.matches.slice(-lookback); - const groups = new Set(); + const pairs = new Set(); for (const match of recentMatches) { for (const group of match.pairs) { - const sorted = [...group].sort(); - groups.add(sorted.join(",")); + for (let i = 0; i < group.length; i++) { + for (let j = i + 1; j < group.length; j++) { + pairs.add(pairKey(group[i], group[j])); + } + } } } - return groups; -} - -function groupKey(ids: string[]): string { - return [...ids].sort().join(","); + return pairs; } // ===== 경험 & 점수 함수들 ===== /** - * 두 사람이 만난 횟수를 반환 + * 두 사람의 recency 기반 만남 페널티 계산 + * penalty = Σ(1 / roundsAgo) for each round where they met + * roundsAgo = totalRounds - matchIndex (1-indexed, most recent = 1) */ -export function getMeetingCount( +export function calculateRecencyPenalty( idA: string, idB: string, history: MatchHistory, ): number { - let count = 0; - for (const match of history.matches) { + const totalRounds = history.matches.length; + let penalty = 0; + + for (let i = 0; i < totalRounds; i++) { + const match = history.matches[i]; for (const pair of match.pairs) { if (pair.includes(idA) && pair.includes(idB)) { - count++; + const roundsAgo = totalRounds - i; + penalty += 1 / roundsAgo; } } } - return count; + + return penalty; } /** @@ -167,143 +181,142 @@ export function calculatePairScore( history: MatchHistory, stats: ExperienceStats, ): number { - const meetingCount = getMeetingCount(idA, idB, history); - const meetingScore = 1 / (1 + meetingCount); + const recencyPenalty = calculateRecencyPenalty(idA, idB, history); + const meetingScore = 1 / (1 + recencyPenalty); const mixScore = getExperienceMixScore(idA, idB, stats); return meetingScore * 0.6 + mixScore * 0.4; } -interface ScoredCandidate { - ids: string[]; - score: number; -} - /** - * 그룹 후보 생성: C(n, groupSize) 조합을 만들고 - * 그룹 내 모든 페어 점수의 평균을 그룹 점수로 사용 + * 참여자를 셔플 후 groupSize씩 나누어 파티션 생성 + * 나머지 인원은 앞 그룹부터 1명씩 분배 */ -export function buildGroupCandidates( +export function generatePartition( participants: Participant[], - history: MatchHistory, - stats: ExperienceStats, - groupSize: number = 2, -): ScoredCandidate[] { - const candidates: ScoredCandidate[] = []; - const ids = participants.map((p) => p.id); - - function* combinations(arr: string[], k: number): Generator { - if (k === 0) { - yield []; - return; - } - for (let i = 0; i <= arr.length - k; i++) { - for (const rest of combinations(arr.slice(i + 1), k - 1)) { - yield [arr[i], ...rest]; - } - } + groupSize: number, +): Participant[][] { + const shuffled = shuffle(participants); + const numGroups = Math.floor(shuffled.length / groupSize); + const extra = shuffled.length % groupSize; + + const groups: Participant[][] = []; + let idx = 0; + + // groupSize씩 나누기 + for (let g = 0; g < numGroups; g++) { + groups.push(shuffled.slice(idx, idx + groupSize)); + idx += groupSize; } - for (const combo of combinations(ids, groupSize)) { - // 그룹 내 모든 페어 점수의 평균 - let totalScore = 0; - let pairCount = 0; - for (let i = 0; i < combo.length; i++) { - for (let j = i + 1; j < combo.length; j++) { - totalScore += calculatePairScore(combo[i], combo[j], history, stats); - pairCount++; - } - } - const avgScore = pairCount > 0 ? totalScore / pairCount : 0; - candidates.push({ ids: combo, score: avgScore }); + // 나머지 인원을 앞 그룹부터 1명씩 분배 + for (let e = 0; e < extra; e++) { + groups[e % numGroups].push(shuffled[idx + e]); } - return candidates; + return groups; +} + +interface PartitionScore { + total: number; + hasViolation: boolean; } /** - * 소프트맥스 변환으로 점수를 확률로 변환 + * 모든 페어의 점수를 미리 계산하여 Map으로 반환 */ -export function scoresToProbabilities( - candidates: ScoredCandidate[], - temperature: number = 0.5, -): { ids: string[]; probability: number }[] { - if (candidates.length === 0) return []; - - // temperature로 나눈 후 exp 적용 - const expScores = candidates.map((c) => Math.exp(c.score / temperature)); - const sumExp = expScores.reduce((a, b) => a + b, 0); - - return candidates.map((c, i) => ({ - ids: c.ids, - probability: expScores[i] / sumExp, - })); +function precomputePairScores( + participants: Participant[], + history: MatchHistory, + stats: ExperienceStats, +): Map { + const scores = new Map(); + for (let i = 0; i < participants.length; i++) { + for (let j = i + 1; j < participants.length; j++) { + const key = pairKey(participants[i].id, participants[j].id); + scores.set( + key, + calculatePairScore( + participants[i].id, + participants[j].id, + history, + stats, + ), + ); + } + } + return scores; } /** - * 확률에 따른 가중 무작위 선택 + * 파티션의 총점 계산 + 하드 제외 위반 여부 확인 + * total = Σ(그룹 내 모든 페어의 pairScore) */ -export function weightedRandomSelect( - candidates: T[], -): T | null { - if (candidates.length === 0) return null; - - const random = Math.random(); - let cumulative = 0; - - for (const candidate of candidates) { - cumulative += candidate.probability; - if (random < cumulative) { - return candidate; +export function scorePartition( + groups: Participant[][], + recentPairs: Set, + pairScores: Map, +): PartitionScore { + let total = 0; + let hasViolation = false; + + for (const group of groups) { + for (let i = 0; i < group.length; i++) { + for (let j = i + 1; j < group.length; j++) { + const key = pairKey(group[i].id, group[j].id); + total += pairScores.get(key) ?? 0; + if (recentPairs.has(key)) { + hasViolation = true; + } + } } } - // 부동소수점 오차 대비 - return candidates[candidates.length - 1]; + return { total, hasViolation }; +} + +function getTrialCount(participantCount: number): number { + if (participantCount <= 16) return 5000; + if (participantCount <= 30) return 2000; + return 1000; } /** - * 나머지 인원을 기존 그룹 중 최적의 그룹에 배치 + * Best-of-N 파티션 탐색으로 최적 매칭을 찾는다 + * N개 파티션을 생성하여 하드 제외를 만족하는 최고점 파티션을 반환 + * 모든 파티션이 하드 제외를 위반하면 최고점 파티션을 fallback으로 반환 */ -export function assignExtraMembers( - extraMembers: Participant[], - groups: Participant[][], +export function findBestPartition( + participants: Participant[], history: MatchHistory, - stats: ExperienceStats, -): void { - for (const member of extraMembers) { - if (groups.length === 0) break; - if (groups.length === 1) { - groups[0].push(member); - continue; - } - - const minSize = Math.min(...groups.map((g) => g.length)); - let bestIndex = 0; - let bestScore = -1; + options: MatchingOptions = {}, +): Participant[][] { + const { groupSize = 2 } = options; + const stats = calculateExperienceStats(participants, history); + const recentPairs = getRecentPairs(history, 1); + const pairScores = precomputePairScores(participants, history, stats); + const trials = getTrialCount(participants.length); - for (let i = 0; i < groups.length; i++) { - if (groups[i].length > minSize) continue; + let bestValid: { groups: Participant[][]; score: number } | null = null; + let bestAny: { groups: Participant[][]; score: number } | null = null; - const group = groups[i]; - let totalScore = 0; - for (const existing of group) { - totalScore += calculatePairScore( - member.id, - existing.id, - history, - stats, - ); - } + for (let i = 0; i < trials; i++) { + const groups = generatePartition(participants, groupSize); + const result = scorePartition(groups, recentPairs, pairScores); - if (totalScore > bestScore) { - bestScore = totalScore; - bestIndex = i; - } + if (!bestAny || result.total > bestAny.score) { + bestAny = { groups, score: result.total }; } - groups[bestIndex].push(member); + if ( + !result.hasViolation && + (!bestValid || result.total > bestValid.score) + ) { + bestValid = { groups, score: result.total }; + } } + + return (bestValid ?? bestAny)!.groups; } export function createMatches( @@ -311,82 +324,10 @@ export function createMatches( history: MatchHistory, options: MatchingOptions = {}, ): Participant[][] { - const { temperature = 0.5, groupSize = 2 } = options; + const { groupSize = 2 } = options; - // 2명 미만이면 매칭 불가 if (participants.length < 2) return []; - // groupSize * 2 미만이면 한 그룹으로 if (participants.length < groupSize * 2) return [participants]; - const recentGroups = getRecentGroups(history); - const stats = calculateExperienceStats(participants, history); - const groups: Participant[][] = []; - - // 참여자 맵 생성 - const participantMap = new Map(); - for (const p of participants) { - participantMap.set(p.id, p); - } - - // 남은 참여자 ID 집합 - const remaining = new Set(participants.map((p) => p.id)); - - // 나머지 인원(total % groupSize)을 먼저 분리 - const extraCount = remaining.size % groupSize; - const extraMemberIds: string[] = []; - if (extraCount > 0) { - const shuffledIds = shuffle(Array.from(remaining)); - for (let i = 0; i < extraCount; i++) { - extraMemberIds.push(shuffledIds[i]); - remaining.delete(shuffledIds[i]); - } - } - - // 매칭 루프 - while (remaining.size >= groupSize) { - const remainingParticipants = Array.from(remaining).map( - (id) => participantMap.get(id)!, - ); - - // 그룹 후보 생성 - let candidates = buildGroupCandidates( - remainingParticipants, - history, - stats, - groupSize, - ); - - // 최근 중복 필터링 - const filteredCandidates = candidates.filter( - (c) => !recentGroups.has(groupKey(c.ids)), - ); - - // 필터링 후 후보가 있으면 사용, 없으면 전체 후보 사용 (fallback) - if (filteredCandidates.length > 0) { - candidates = filteredCandidates; - } - - // 확률 변환 및 선택 - const probabilities = scoresToProbabilities(candidates, temperature); - const selected = weightedRandomSelect(probabilities); - - if (!selected) break; - - // 선택된 그룹 추가 - const group = selected.ids.map((id) => participantMap.get(id)!); - groups.push(group); - - // 선택된 참여자 제거 - for (const id of selected.ids) { - remaining.delete(id); - } - } - - // 나머지 인원을 최적 그룹에 배치 - if (extraMemberIds.length > 0 && groups.length > 0) { - const extraMembers = extraMemberIds.map((id) => participantMap.get(id)!); - assignExtraMembers(extraMembers, groups, history, stats); - } - - return groups; + return findBestPartition(participants, history, options); } diff --git a/src/types.ts b/src/types.ts index 3746f3c..d1e9192 100644 --- a/src/types.ts +++ b/src/types.ts @@ -20,7 +20,6 @@ export interface ExperienceStats { } export interface MatchingOptions { - temperature?: number; // 기본 0.5 groupSize?: number; // 기본 2 }