From 3d0a1625744605b28de83aa6d5e71916b95c48b9 Mon Sep 17 00:00:00 2001 From: Evan Lee Date: Mon, 23 Mar 2026 08:29:52 -0400 Subject: [PATCH 01/10] feat: add calculateRecencyPenalty with 1/roundsAgo decay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements recency-weighted meeting penalty using Σ(1/roundsAgo) formula, replacing the flat getMeetingCount approach for better recent-history sensitivity. Co-Authored-By: Claude Sonnet 4.6 --- src/matcher.test.ts | 41 +++++++++++++++++++++++++++++++++++++++++ src/matcher.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/src/matcher.test.ts b/src/matcher.test.ts index 3f071d4..4455372 100644 --- a/src/matcher.test.ts +++ b/src/matcher.test.ts @@ -3,6 +3,7 @@ import { buildGroupCandidates, calculateExperienceStats, calculatePairScore, + calculateRecencyPenalty, createMatches, getExperienceLevel, getExperienceMixScore, @@ -248,6 +249,46 @@ describe("getMeetingCount", () => { }); }); +describe("calculateRecencyPenalty", () => { + test("만난 적 없으면 0을 반환한다", () => { + const history: MatchHistory = { matches: [] }; + expect(calculateRecencyPenalty("user1", "user2", history)).toBe(0); + }); + + test("직전 라운드에서 만났으면 1.0을 반환한다", () => { + const history: MatchHistory = { + matches: [ + { date: "2025-01-01", pairs: [["user1", "user2"]] }, + ], + }; + expect(calculateRecencyPenalty("user1", "user2", history)).toBe(1); + }); + + test("여러 라운드에서 만났으면 1/roundsAgo 합산을 반환한다", () => { + const history: MatchHistory = { + matches: [ + { 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 + ], + }; + // 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"]] }, + ], + }; + expect(calculateRecencyPenalty("user1", "user2", history)).toBe(1); + expect(calculateRecencyPenalty("user2", "user3", history)).toBe(1); + }); +}); + describe("calculateExperienceStats", () => { test("히스토리가 없으면 모두 0 카운트", () => { const participants = createParticipants(3); diff --git a/src/matcher.ts b/src/matcher.ts index 70dce46..0a982dc 100644 --- a/src/matcher.ts +++ b/src/matcher.ts @@ -82,6 +82,32 @@ export function getMeetingCount( return count; } +/** + * 두 사람의 recency 기반 만남 페널티 계산 + * penalty = Σ(1 / roundsAgo) for each round where they met + * roundsAgo = totalRounds - matchIndex (1-indexed, most recent = 1) + */ +export function calculateRecencyPenalty( + idA: string, + idB: string, + history: MatchHistory, +): number { + 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)) { + const roundsAgo = totalRounds - i; + penalty += 1 / roundsAgo; + } + } + } + + return penalty; +} + /** * 참여자들의 경험 통계 계산 (각 참여자의 총 매칭 횟수) */ From d81ccf7cbadd7869ea40e11cba5d74d0c769cfb5 Mon Sep 17 00:00:00 2001 From: Evan Lee Date: Mon, 23 Mar 2026 08:32:09 -0400 Subject: [PATCH 02/10] feat: use recency decay in calculatePairScore Co-Authored-By: Claude Sonnet 4.6 --- src/matcher.test.ts | 39 +++++++++++++++++++++++++++++++++------ src/matcher.ts | 4 ++-- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/src/matcher.test.ts b/src/matcher.test.ts index 4455372..dd4bc07 100644 --- a/src/matcher.test.ts +++ b/src/matcher.test.ts @@ -379,7 +379,7 @@ describe("getExperienceMixScore", () => { }); describe("calculatePairScore", () => { - test("만남 횟수가 적고 경험 믹싱이 좋으면 높은 점수", () => { + test("만남 이력이 없고 경험 믹싱이 좋으면 높은 점수", () => { const history: MatchHistory = { matches: [] }; const stats = { matchCounts: new Map([ @@ -390,13 +390,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"]] }, @@ -412,10 +412,37 @@ 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); }); }); diff --git a/src/matcher.ts b/src/matcher.ts index 0a982dc..b1b77bb 100644 --- a/src/matcher.ts +++ b/src/matcher.ts @@ -193,8 +193,8 @@ 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; From bd622f52b3150c6715959cbcf7973826d52b2ef6 Mon Sep 17 00:00:00 2001 From: Evan Lee Date: Mon, 23 Mar 2026 08:32:54 -0400 Subject: [PATCH 03/10] feat: add getRecentPairs for pair-level hard exclusion Co-Authored-By: Claude Sonnet 4.6 --- src/matcher.test.ts | 51 +++++++++++++++++++++++++++++++++++++++++++++ src/matcher.ts | 25 ++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/src/matcher.test.ts b/src/matcher.test.ts index dd4bc07..e6b210c 100644 --- a/src/matcher.test.ts +++ b/src/matcher.test.ts @@ -8,6 +8,7 @@ import { getExperienceLevel, getExperienceMixScore, getMeetingCount, + getRecentPairs, scoresToProbabilities, weightedRandomSelect, } from "./matcher.ts"; @@ -446,6 +447,56 @@ describe("calculatePairScore", () => { }); }); +describe("getRecentPairs", () => { + test("빈 히스토리면 빈 Set을 반환한다", () => { + const history: MatchHistory = { matches: [] }; + 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); + }); + + 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); + }); + + 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); + }); +}); + describe("buildGroupCandidates", () => { test("groupSize=2일 때 모든 페어를 생성한다", () => { const participants = createParticipants(3); diff --git a/src/matcher.ts b/src/matcher.ts index b1b77bb..d4f9fb7 100644 --- a/src/matcher.ts +++ b/src/matcher.ts @@ -61,6 +61,31 @@ function groupKey(ids: string[]): string { return [...ids].sort().join(","); } +/** + * 최근 lookback 라운드의 모든 페어를 Set으로 반환 + * 페어 키는 "id1,id2" 형식 (정렬됨) + */ +export function getRecentPairs( + history: MatchHistory, + lookback: number = 1, +): Set { + const recentMatches = history.matches.slice(-lookback); + const pairs = new Set(); + + for (const match of recentMatches) { + for (const group of match.pairs) { + for (let i = 0; i < group.length; i++) { + for (let j = i + 1; j < group.length; j++) { + const key = [group[i], group[j]].sort().join(","); + pairs.add(key); + } + } + } + } + + return pairs; +} + // ===== 경험 & 점수 함수들 ===== /** From eba1c3de5b82ff3a9340b0506f5d39079a40803b Mon Sep 17 00:00:00 2001 From: Evan Lee Date: Mon, 23 Mar 2026 08:35:57 -0400 Subject: [PATCH 04/10] feat: add generatePartition and scorePartition Co-Authored-By: Claude Sonnet 4.6 --- src/matcher.test.ts | 59 ++++++++++++++++++++++++++++++++++++++++++ src/matcher.ts | 62 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+) diff --git a/src/matcher.test.ts b/src/matcher.test.ts index e6b210c..36eec48 100644 --- a/src/matcher.test.ts +++ b/src/matcher.test.ts @@ -5,10 +5,12 @@ import { calculatePairScore, calculateRecencyPenalty, createMatches, + generatePartition, getExperienceLevel, getExperienceMixScore, getMeetingCount, getRecentPairs, + scorePartition, scoresToProbabilities, weightedRandomSelect, } from "./matcher.ts"; @@ -564,6 +566,63 @@ describe("weightedRandomSelect", () => { }); }); +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); + }); + + test("모든 참여자가 포함된다", () => { + const participants = createParticipants(6); + const groups = generatePartition(participants, 2); + const allIds = groups.flat().map((p) => p.id).sort(); + + expect(allIds).toEqual(participants.map((p) => p.id).sort()); + }); + + 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]); + }); +}); + +describe("scorePartition", () => { + test("그룹 내 모든 페어의 pairScore 합을 반환한다", () => { + const participants = createParticipants(4); + const history: MatchHistory = { matches: [] }; + const stats = calculateExperienceStats(participants, history); + const recentPairs = new Set(); + const groups = [[participants[0], participants[1]], [participants[2], participants[3]]]; + + const score = scorePartition(groups, history, stats, recentPairs); + + // 빈 히스토리에서 모든 meetingScore=1.0, 모든 mixScore=0.3 (newcomer-newcomer) + // pairScore = 1.0 * 0.6 + 0.3 * 0.4 = 0.72 per pair + // 2 groups × 1 pair each = 2 pairs total + expect(score.total).toBeCloseTo(0.72 * 2, 5); + expect(score.hasViolation).toBe(false); + }); + + test("하드 제외 위반 페어가 있으면 hasViolation이 true", () => { + const participants = createParticipants(4); + const history: MatchHistory = { matches: [] }; + const stats = calculateExperienceStats(participants, history); + const recentPairs = new Set(["user1,user2"]); + const groups = [[participants[0], participants[1]], [participants[2], participants[3]]]; + + const score = scorePartition(groups, history, stats, recentPairs); + + expect(score.hasViolation).toBe(true); + }); +}); + describe("통계적 검증", () => { test("만난 적 없는 페어가 더 자주 선택된다", () => { const participants = createParticipants(4); diff --git a/src/matcher.ts b/src/matcher.ts index d4f9fb7..01a9c62 100644 --- a/src/matcher.ts +++ b/src/matcher.ts @@ -357,6 +357,68 @@ export function assignExtraMembers( } } +/** + * 참여자를 셔플 후 groupSize씩 나누어 파티션 생성 + * 나머지 인원은 앞 그룹부터 1명씩 분배 + */ +export function generatePartition( + participants: Participant[], + 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; + } + + // 나머지 인원을 앞 그룹부터 1명씩 분배 + for (let e = 0; e < extra; e++) { + groups[e % numGroups].push(shuffled[idx + e]); + } + + return groups; +} + +interface PartitionScore { + total: number; + hasViolation: boolean; +} + +/** + * 파티션의 총점 계산 + 하드 제외 위반 여부 확인 + * total = Σ(그룹 내 모든 페어의 pairScore) + */ +export function scorePartition( + groups: Participant[][], + history: MatchHistory, + stats: ExperienceStats, + recentPairs: Set, +): 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++) { + total += calculatePairScore(group[i].id, group[j].id, history, stats); + const key = [group[i].id, group[j].id].sort().join(","); + if (recentPairs.has(key)) { + hasViolation = true; + } + } + } + } + + return { total, hasViolation }; +} + export function createMatches( participants: Participant[], history: MatchHistory, From 6144a9bc564b4fbec9f6e1e4458cace11169628b Mon Sep 17 00:00:00 2001 From: Evan Lee Date: Mon, 23 Mar 2026 08:37:48 -0400 Subject: [PATCH 05/10] feat: add findBestPartition with best-of-N optimization Co-Authored-By: Claude Sonnet 4.6 --- src/matcher.test.ts | 53 +++++++++++++++++++++++++++++++++++++++++++++ src/matcher.ts | 40 ++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/src/matcher.test.ts b/src/matcher.test.ts index 36eec48..f26e9b6 100644 --- a/src/matcher.test.ts +++ b/src/matcher.test.ts @@ -5,6 +5,7 @@ import { calculatePairScore, calculateRecencyPenalty, createMatches, + findBestPartition, generatePartition, getExperienceLevel, getExperienceMixScore, @@ -623,6 +624,58 @@ describe("scorePartition", () => { }); }); +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 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(","), + ); + // 직전 라운드의 user1-user2와 user3-user4가 동시에 나오면 안 됨 + expect( + pairKeys.includes("user1,user2") && pairKeys.includes("user3,user4"), + ).toBe(false); + } + }); + + 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); + }); +}); + describe("통계적 검증", () => { test("만난 적 없는 페어가 더 자주 선택된다", () => { const participants = createParticipants(4); diff --git a/src/matcher.ts b/src/matcher.ts index 01a9c62..0aada5d 100644 --- a/src/matcher.ts +++ b/src/matcher.ts @@ -419,6 +419,46 @@ export function scorePartition( 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 findBestPartition( + participants: Participant[], + history: MatchHistory, + options: MatchingOptions = {}, +): Participant[][] { + const { groupSize = 2 } = options; + const stats = calculateExperienceStats(participants, history); + const recentPairs = getRecentPairs(history, 1); + const trials = getTrialCount(participants.length); + + let bestValid: { groups: Participant[][]; score: number } | null = null; + let bestAny: { groups: Participant[][]; score: number } | null = null; + + for (let i = 0; i < trials; i++) { + const groups = generatePartition(participants, groupSize); + const result = scorePartition(groups, history, stats, recentPairs); + + if (!bestAny || result.total > bestAny.score) { + bestAny = { groups, score: result.total }; + } + + if (!result.hasViolation && (!bestValid || result.total > bestValid.score)) { + bestValid = { groups, score: result.total }; + } + } + + return (bestValid ?? bestAny)!.groups; +} + export function createMatches( participants: Participant[], history: MatchHistory, From bbda320e75d63d4c93a423d4538af50bd57b703a Mon Sep 17 00:00:00 2001 From: Evan Lee Date: Mon, 23 Mar 2026 08:40:10 -0400 Subject: [PATCH 06/10] feat: rewrite createMatches to use findBestPartition --- src/matcher.test.ts | 25 +++++---------- src/matcher.ts | 76 ++------------------------------------------- 2 files changed, 10 insertions(+), 91 deletions(-) diff --git a/src/matcher.test.ts b/src/matcher.test.ts index f26e9b6..97a6fed 100644 --- a/src/matcher.test.ts +++ b/src/matcher.test.ts @@ -74,7 +74,7 @@ describe("createMatches", () => { expect(matchedIds.sort()).toEqual(participants.map((p) => p.id).sort()); }); - test("최근 매칭 이력이 있으면 같은 조를 피한다", () => { + test("직전 라운드 페어가 같은 조에 배치되지 않는다", () => { const participants = createParticipants(4); const history: MatchHistory = { matches: [ @@ -88,27 +88,18 @@ 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 - .map((p) => p.id) - .sort() - .join(","), + pair.map((p) => p.id).sort().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개 조가 생성된다", () => { diff --git a/src/matcher.ts b/src/matcher.ts index 0aada5d..362737c 100644 --- a/src/matcher.ts +++ b/src/matcher.ts @@ -464,82 +464,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); } From ce0bee977985d6494439bda1fcec30b97a6acb16 Mon Sep 17 00:00:00 2001 From: Evan Lee Date: Mon, 23 Mar 2026 08:41:35 -0400 Subject: [PATCH 07/10] refactor: remove dead code and temperature option --- src/matcher.test.ts | 117 ------------------------------ src/matcher.ts | 172 -------------------------------------------- src/types.ts | 1 - 3 files changed, 290 deletions(-) diff --git a/src/matcher.test.ts b/src/matcher.test.ts index 97a6fed..6cb4cd7 100644 --- a/src/matcher.test.ts +++ b/src/matcher.test.ts @@ -1,6 +1,5 @@ import { describe, expect, test } from "bun:test"; import { - buildGroupCandidates, calculateExperienceStats, calculatePairScore, calculateRecencyPenalty, @@ -9,11 +8,8 @@ import { generatePartition, getExperienceLevel, getExperienceMixScore, - getMeetingCount, getRecentPairs, scorePartition, - scoresToProbabilities, - weightedRandomSelect, } from "./matcher.ts"; import type { MatchHistory, Participant } from "./types.ts"; @@ -198,52 +194,6 @@ describe("createMatches with groupSize", () => { }); }); -describe("getMeetingCount", () => { - test("만난 적 없으면 0을 반환한다", () => { - const history: MatchHistory = { matches: [] }; - expect(getMeetingCount("user1", "user2", history)).toBe(0); - }); - - test("한 번 만났으면 1을 반환한다", () => { - const history: MatchHistory = { - matches: [ - { - date: "2025-01-01", - pairs: [["user1", "user2"]], - }, - ], - }; - expect(getMeetingCount("user1", "user2", history)).toBe(1); - }); - - 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", "user2"]] }, - { date: "2025-01-22", pairs: [["user1", "user2"]] }, - ], - }; - expect(getMeetingCount("user1", "user2", history)).toBe(3); - expect(getMeetingCount("user1", "user3", history)).toBe(1); - }); - - test("3인조에서도 만남을 카운트한다", () => { - const history: MatchHistory = { - 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); - }); -}); - describe("calculateRecencyPenalty", () => { test("만난 적 없으면 0을 반환한다", () => { const history: MatchHistory = { matches: [] }; @@ -491,73 +441,6 @@ describe("getRecentPairs", () => { }); }); -describe("buildGroupCandidates", () => { - test("groupSize=2일 때 모든 페어를 생성한다", () => { - const participants = createParticipants(3); - const history: MatchHistory = { matches: [] }; - const stats = calculateExperienceStats(participants, history); - - const candidates = buildGroupCandidates(participants, history, stats, 2); - - // C(3,2) = 3 - expect(candidates).toHaveLength(3); - expect(candidates.every((c) => c.ids.length === 2)).toBe(true); - }); - - test("groupSize=3일 때 모든 3인 조합을 생성한다", () => { - const participants = createParticipants(4); - const history: MatchHistory = { matches: [] }; - const stats = calculateExperienceStats(participants, history); - - const candidates = buildGroupCandidates(participants, history, stats, 3); - - // C(4,3) = 4 - expect(candidates).toHaveLength(4); - expect(candidates.every((c) => c.ids.length === 3)).toBe(true); - }); -}); - -describe("scoresToProbabilities", () => { - test("빈 배열은 빈 배열 반환", () => { - expect(scoresToProbabilities([])).toEqual([]); - }); - - test("높은 점수가 높은 확률을 갖는다", () => { - const candidates = [ - { ids: ["a", "b"], score: 1.0 }, - { ids: ["c", "d"], score: 0.5 }, - ]; - - const probs = scoresToProbabilities(candidates, 0.5); - - expect(probs[0].probability).toBeGreaterThan(probs[1].probability); - }); - - test("확률의 합은 1이다", () => { - const candidates = [ - { ids: ["a", "b"], score: 0.8 }, - { ids: ["c", "d"], score: 0.6 }, - { ids: ["e", "f"], score: 0.4 }, - ]; - - const probs = scoresToProbabilities(candidates, 0.5); - const sum = probs.reduce((acc, p) => acc + p.probability, 0); - - expect(sum).toBeCloseTo(1.0, 5); - }); -}); - -describe("weightedRandomSelect", () => { - test("빈 배열은 null 반환", () => { - expect(weightedRandomSelect([])).toBeNull(); - }); - - test("하나만 있으면 그것을 반환", () => { - const candidates = [{ id: "a", probability: 1.0 }]; - expect(weightedRandomSelect(candidates)).toEqual(candidates[0]); - }); -}); - describe("generatePartition", () => { test("참여자를 groupSize씩 나눈다", () => { const participants = createParticipants(6); diff --git a/src/matcher.ts b/src/matcher.ts index 362737c..e9feadb 100644 --- a/src/matcher.ts +++ b/src/matcher.ts @@ -40,27 +40,6 @@ export function shuffle(array: T[]): T[] { return result; } -function getRecentGroups( - history: MatchHistory, - lookback: number = 4, -): Set { - const recentMatches = history.matches.slice(-lookback); - const groups = new Set(); - - for (const match of recentMatches) { - for (const group of match.pairs) { - const sorted = [...group].sort(); - groups.add(sorted.join(",")); - } - } - - return groups; -} - -function groupKey(ids: string[]): string { - return [...ids].sort().join(","); -} - /** * 최근 lookback 라운드의 모든 페어를 Set으로 반환 * 페어 키는 "id1,id2" 형식 (정렬됨) @@ -88,25 +67,6 @@ export function getRecentPairs( // ===== 경험 & 점수 함수들 ===== -/** - * 두 사람이 만난 횟수를 반환 - */ -export function getMeetingCount( - idA: string, - idB: string, - history: MatchHistory, -): number { - let count = 0; - for (const match of history.matches) { - for (const pair of match.pairs) { - if (pair.includes(idA) && pair.includes(idB)) { - count++; - } - } - } - return count; -} - /** * 두 사람의 recency 기반 만남 페널티 계산 * penalty = Σ(1 / roundsAgo) for each round where they met @@ -225,138 +185,6 @@ export function calculatePairScore( return meetingScore * 0.6 + mixScore * 0.4; } -interface ScoredCandidate { - ids: string[]; - score: number; -} - -/** - * 그룹 후보 생성: C(n, groupSize) 조합을 만들고 - * 그룹 내 모든 페어 점수의 평균을 그룹 점수로 사용 - */ -export function buildGroupCandidates( - 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]; - } - } - } - - 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 }); - } - - return candidates; -} - -/** - * 소프트맥스 변환으로 점수를 확률로 변환 - */ -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, - })); -} - -/** - * 확률에 따른 가중 무작위 선택 - */ -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; - } - } - - // 부동소수점 오차 대비 - return candidates[candidates.length - 1]; -} - -/** - * 나머지 인원을 기존 그룹 중 최적의 그룹에 배치 - */ -export function assignExtraMembers( - extraMembers: Participant[], - groups: 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; - - for (let i = 0; i < groups.length; i++) { - if (groups[i].length > minSize) continue; - - const group = groups[i]; - let totalScore = 0; - for (const existing of group) { - totalScore += calculatePairScore( - member.id, - existing.id, - history, - stats, - ); - } - - if (totalScore > bestScore) { - bestScore = totalScore; - bestIndex = i; - } - } - - groups[bestIndex].push(member); - } -} - /** * 참여자를 셔플 후 groupSize씩 나누어 파티션 생성 * 나머지 인원은 앞 그룹부터 1명씩 분배 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 } From f77ee89bd248499411d2abdb201b06cf70b2e7b1 Mon Sep 17 00:00:00 2001 From: Evan Lee Date: Mon, 23 Mar 2026 08:42:29 -0400 Subject: [PATCH 08/10] test: add recency-aware statistical and performance tests --- src/matcher.test.ts | 59 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/src/matcher.test.ts b/src/matcher.test.ts index 6cb4cd7..9bc85c4 100644 --- a/src/matcher.test.ts +++ b/src/matcher.test.ts @@ -663,4 +663,63 @@ 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()); + }); }); From 9b66112af76def67658e54e3e669561f8e3b6230 Mon Sep 17 00:00:00 2001 From: Evan Lee Date: Mon, 23 Mar 2026 08:53:31 -0400 Subject: [PATCH 09/10] perf: precompute pair scores and strengthen hard exclusion test - Extract pairKey helper to deduplicate key generation logic - Precompute all pair scores before trial loop in findBestPartition, eliminating redundant calculateRecencyPenalty calls across N trials - Strengthen findBestPartition hard exclusion test to check individual pair violations instead of only checking both pairs simultaneously Co-Authored-By: Claude Opus 4.6 (1M context) --- src/matcher.test.ts | 31 ++++++++++++++++++++++--------- src/matcher.ts | 35 ++++++++++++++++++++++++++++------- 2 files changed, 50 insertions(+), 16 deletions(-) diff --git a/src/matcher.test.ts b/src/matcher.test.ts index 9bc85c4..c4c6479 100644 --- a/src/matcher.test.ts +++ b/src/matcher.test.ts @@ -474,12 +474,19 @@ describe("scorePartition", () => { 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 score = scorePartition(groups, history, stats, recentPairs); + const score = scorePartition(groups, recentPairs, pairScores); - // 빈 히스토리에서 모든 meetingScore=1.0, 모든 mixScore=0.3 (newcomer-newcomer) - // pairScore = 1.0 * 0.6 + 0.3 * 0.4 = 0.72 per pair // 2 groups × 1 pair each = 2 pairs total expect(score.total).toBeCloseTo(0.72 * 2, 5); expect(score.hasViolation).toBe(false); @@ -490,9 +497,16 @@ describe("scorePartition", () => { 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 score = scorePartition(groups, history, stats, recentPairs); + const score = scorePartition(groups, recentPairs, pairScores); expect(score.hasViolation).toBe(true); }); @@ -522,16 +536,15 @@ describe("findBestPartition", () => { ], }; - // 여러 번 실행해서 직전 매칭이 반복되지 않는지 확인 + // 여러 번 실행해서 직전 매칭 페어가 나오지 않는지 확인 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(","), ); - // 직전 라운드의 user1-user2와 user3-user4가 동시에 나오면 안 됨 - expect( - pairKeys.includes("user1,user2") && pairKeys.includes("user3,user4"), - ).toBe(false); + // 직전 라운드의 개별 페어가 나오면 안 됨 + expect(pairKeys).not.toContain("user1,user2"); + expect(pairKeys).not.toContain("user3,user4"); } }); diff --git a/src/matcher.ts b/src/matcher.ts index e9feadb..6037abf 100644 --- a/src/matcher.ts +++ b/src/matcher.ts @@ -40,6 +40,10 @@ export function shuffle(array: T[]): T[] { return result; } +function pairKey(idA: string, idB: string): string { + return idA < idB ? `${idA},${idB}` : `${idB},${idA}`; +} + /** * 최근 lookback 라운드의 모든 페어를 Set으로 반환 * 페어 키는 "id1,id2" 형식 (정렬됨) @@ -55,8 +59,7 @@ export function getRecentPairs( for (const group of match.pairs) { for (let i = 0; i < group.length; i++) { for (let j = i + 1; j < group.length; j++) { - const key = [group[i], group[j]].sort().join(","); - pairs.add(key); + pairs.add(pairKey(group[i], group[j])); } } } @@ -219,15 +222,32 @@ interface PartitionScore { hasViolation: boolean; } +/** + * 모든 페어의 점수를 미리 계산하여 Map으로 반환 + */ +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 scorePartition( groups: Participant[][], - history: MatchHistory, - stats: ExperienceStats, recentPairs: Set, + pairScores: Map, ): PartitionScore { let total = 0; let hasViolation = false; @@ -235,8 +255,8 @@ export function scorePartition( for (const group of groups) { for (let i = 0; i < group.length; i++) { for (let j = i + 1; j < group.length; j++) { - total += calculatePairScore(group[i].id, group[j].id, history, stats); - const key = [group[i].id, group[j].id].sort().join(","); + const key = pairKey(group[i].id, group[j].id); + total += pairScores.get(key) ?? 0; if (recentPairs.has(key)) { hasViolation = true; } @@ -266,6 +286,7 @@ export function findBestPartition( 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); let bestValid: { groups: Participant[][]; score: number } | null = null; @@ -273,7 +294,7 @@ export function findBestPartition( for (let i = 0; i < trials; i++) { const groups = generatePartition(participants, groupSize); - const result = scorePartition(groups, history, stats, recentPairs); + const result = scorePartition(groups, recentPairs, pairScores); if (!bestAny || result.total > bestAny.score) { bestAny = { groups, score: result.total }; From bc4a72935c85b9fd97771e7bf236e2cc61c1c0c8 Mon Sep 17 00:00:00 2001 From: Evan Lee Date: Mon, 23 Mar 2026 09:02:12 -0400 Subject: [PATCH 10/10] style: fix biome formatting Co-Authored-By: Claude Opus 4.6 (1M context) --- src/matcher.test.ts | 132 ++++++++++++++++++++++++++++++++++---------- src/matcher.ts | 15 ++++- 2 files changed, 117 insertions(+), 30 deletions(-) diff --git a/src/matcher.test.ts b/src/matcher.test.ts index c4c6479..6fe0745 100644 --- a/src/matcher.test.ts +++ b/src/matcher.test.ts @@ -90,7 +90,10 @@ describe("createMatches", () => { for (let i = 0; i < 20; i++) { const pairs = createMatches(participants, history); const pairKeys = pairs.map((pair) => - pair.map((p) => p.id).sort().join(","), + pair + .map((p) => p.id) + .sort() + .join(","), ); expect(pairKeys).not.toContain("user1,user2"); @@ -202,9 +205,7 @@ describe("calculateRecencyPenalty", () => { test("직전 라운드에서 만났으면 1.0을 반환한다", () => { const history: MatchHistory = { - matches: [ - { date: "2025-01-01", pairs: [["user1", "user2"]] }, - ], + matches: [{ date: "2025-01-01", pairs: [["user1", "user2"]] }], }; expect(calculateRecencyPenalty("user1", "user2", history)).toBe(1); }); @@ -218,16 +219,20 @@ describe("calculateRecencyPenalty", () => { ], }; // penalty = 1/1 + 1/3 = 1.333... - expect(calculateRecencyPenalty("user1", "user2", history)).toBeCloseTo(1 + 1 / 3, 5); + expect(calculateRecencyPenalty("user1", "user2", history)).toBeCloseTo( + 1 + 1 / 3, + 5, + ); // penalty = 1/2 - expect(calculateRecencyPenalty("user1", "user3", history)).toBeCloseTo(1 / 2, 5); + 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(calculateRecencyPenalty("user1", "user2", history)).toBe(1); expect(calculateRecencyPenalty("user2", "user3", history)).toBe(1); @@ -402,7 +407,10 @@ describe("getRecentPairs", () => { matches: [ { date: "2025-01-01", - pairs: [["user1", "user2"], ["user3", "user4"]], + pairs: [ + ["user1", "user2"], + ["user3", "user4"], + ], }, ], }; @@ -453,7 +461,10 @@ describe("generatePartition", () => { test("모든 참여자가 포함된다", () => { const participants = createParticipants(6); const groups = generatePartition(participants, 2); - const allIds = groups.flat().map((p) => p.id).sort(); + const allIds = groups + .flat() + .map((p) => p.id) + .sort(); expect(allIds).toEqual(participants.map((p) => p.id).sort()); }); @@ -480,10 +491,21 @@ describe("scorePartition", () => { 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)); + pairScores.set( + key, + calculatePairScore( + participants[i].id, + participants[j].id, + history, + stats, + ), + ); } } - const groups = [[participants[0], participants[1]], [participants[2], participants[3]]]; + const groups = [ + [participants[0], participants[1]], + [participants[2], participants[3]], + ]; const score = scorePartition(groups, recentPairs, pairScores); @@ -501,10 +523,21 @@ describe("scorePartition", () => { 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)); + pairScores.set( + key, + calculatePairScore( + participants[i].id, + participants[j].id, + history, + stats, + ), + ); } } - const groups = [[participants[0], participants[1]], [participants[2], participants[3]]]; + const groups = [ + [participants[0], participants[1]], + [participants[2], participants[3]], + ]; const score = scorePartition(groups, recentPairs, pairScores); @@ -521,7 +554,10 @@ describe("findBestPartition", () => { expect(groups).toHaveLength(3); expect(groups.every((g) => g.length === 2)).toBe(true); - const allIds = groups.flat().map((p) => p.id).sort(); + const allIds = groups + .flat() + .map((p) => p.id) + .sort(); expect(allIds).toEqual(participants.map((p) => p.id).sort()); }); @@ -531,7 +567,10 @@ describe("findBestPartition", () => { matches: [ { date: "2025-01-01", - pairs: [["user1", "user2"], ["user3", "user4"]], + pairs: [ + ["user1", "user2"], + ["user3", "user4"], + ], }, ], }; @@ -540,7 +579,10 @@ describe("findBestPartition", () => { 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(","), + g + .map((p) => p.id) + .sort() + .join(","), ); // 직전 라운드의 개별 페어가 나오면 안 됨 expect(pairKeys).not.toContain("user1,user2"); @@ -552,9 +594,7 @@ describe("findBestPartition", () => { // 2명만 있으면 유일한 파티션이 직전 매칭과 동일 const participants = createParticipants(2); const history: MatchHistory = { - matches: [ - { date: "2025-01-01", pairs: [["user1", "user2"]] }, - ], + matches: [{ date: "2025-01-01", pairs: [["user1", "user2"]] }], }; const groups = findBestPartition(participants, history, { groupSize: 2 }); @@ -681,12 +721,42 @@ describe("통계적 검증", () => { 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"]] }, + { + 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"]] }, + { + date: "2025-01-29", + pairs: [ + ["user1", "user2"], + ["user3", "user4"], + ], + }, ], }; @@ -701,7 +771,10 @@ describe("통계적 검증", () => { for (let i = 0; i < iterations; i++) { const pairs = createMatches(participants, history); const pairKeys = pairs.map((pair) => - pair.map((p) => p.id).sort().join(","), + pair + .map((p) => p.id) + .sort() + .join(","), ); if (pairKeys.includes("user1,user3")) { user1user3Count++; @@ -732,7 +805,10 @@ describe("성능", () => { expect(elapsed).toBeLessThan(500); expect(groups.length).toBeGreaterThan(0); - const allIds = groups.flat().map((p) => p.id).sort(); + 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 6037abf..e196c4b 100644 --- a/src/matcher.ts +++ b/src/matcher.ts @@ -234,7 +234,15 @@ function precomputePairScores( 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)); + scores.set( + key, + calculatePairScore( + participants[i].id, + participants[j].id, + history, + stats, + ), + ); } } return scores; @@ -300,7 +308,10 @@ export function findBestPartition( bestAny = { groups, score: result.total }; } - if (!result.hasViolation && (!bestValid || result.total > bestValid.score)) { + if ( + !result.hasViolation && + (!bestValid || result.total > bestValid.score) + ) { bestValid = { groups, score: result.total }; } }