Skip to content

Commit 8c6b4e6

Browse files
fix(adapter-github): stable tiebreaker for language-distribution ranking (#23)
computeLanguageDistribution sorted language counts descending then `.slice(0, 5)`, but ties on count fell back to Map insertion order (the GitHub repo API response order). With the top-5 cutoff, a tie at the boundary changed which language landed in the profile — and that result flows into buildProfileEvidence → scoring, so output could differ across runs for the same input. Third instance of the #10 / #19 pattern. Add a final localeCompare tiebreaker before the slice so equal-count languages sort deterministically. Test mirrors #19 / H-10: Java and Ruby tie at count 1 straddling the top-5 boundary (Ruby listed first so the unshuffled order wrongly picks it), shuffled via deterministic LCG across 100 seeds, asserting an identical top-5 every time. Verified RED before the fix, GREEN after. Verification: adapter-github 38/38, full suite 794 passing / 1 skipped, lint 14/14, typecheck 27/27, build 14/14. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7a87e49 commit 8c6b4e6

2 files changed

Lines changed: 63 additions & 1 deletion

File tree

packages/adapters/adapter-github/src/__tests__/github-adapter.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,63 @@ describe('extractEmailsFromCommits (H-10 follow-up: stable tiebreaker)', () => {
449449
});
450450
});
451451

452+
describe('computeLanguageDistribution (H-10 follow-up: stable tiebreaker, #23)', () => {
453+
// LCG-based shuffle so test outcome is reproducible (mirrors the H-10 test).
454+
function deterministicShuffle<T>(arr: readonly T[], seed: number): T[] {
455+
const a = [...arr];
456+
let s = seed >>> 0;
457+
for (let i = a.length - 1; i > 0; i--) {
458+
s = (s * 1103515245 + 12345) >>> 0;
459+
const j = s % (i + 1);
460+
[a[i], a[j]] = [a[j], a[i]];
461+
}
462+
return a;
463+
}
464+
465+
function makeLangRepo(language: string) {
466+
return {
467+
name: `r-${language}`,
468+
stargazers_count: 0,
469+
fork: false,
470+
full_name: `u/r-${language}`,
471+
language,
472+
forks_count: 0,
473+
topics: [],
474+
updated_at: '2026-01-01T00:00:00Z',
475+
pushed_at: '2026-01-01T00:00:00Z',
476+
html_url: 'https://github.com/u/r',
477+
};
478+
}
479+
480+
it('produces identical top-5 languages across 100 shuffles when counts tie at the boundary', () => {
481+
// Java and Ruby tie at count 1, straddling the top-5 cutoff. Ruby is listed
482+
// first so that without a tiebreaker the unshuffled order would (wrongly)
483+
// pick Ruby; the repo API response order would then flip it run to run.
484+
const repos = [
485+
...Array.from({ length: 5 }, () => makeLangRepo('TypeScript')),
486+
...Array.from({ length: 4 }, () => makeLangRepo('Python')),
487+
...Array.from({ length: 3 }, () => makeLangRepo('Go')),
488+
...Array.from({ length: 2 }, () => makeLangRepo('Rust')),
489+
makeLangRepo('Ruby'),
490+
makeLangRepo('Java'),
491+
];
492+
493+
const expected = computeLanguageDistribution(repos as GitHubRepo[]).map(
494+
(s) => s.language,
495+
);
496+
// localeCompare ascending → 'Java' beats 'Ruby' for the 5th slot.
497+
expect(expected).toEqual(['TypeScript', 'Python', 'Go', 'Rust', 'Java']);
498+
499+
for (let seed = 1; seed <= 100; seed++) {
500+
const shuffled = deterministicShuffle(repos, seed);
501+
const result = computeLanguageDistribution(shuffled as GitHubRepo[]).map(
502+
(s) => s.language,
503+
);
504+
expect(result).toEqual(expected);
505+
}
506+
});
507+
});
508+
452509
describe('GitHubClient API contract validation (H-11)', () => {
453510
it('throws ApiContractError with the missing field path', async () => {
454511
mockFetch.mockImplementation(async () => ({

packages/adapters/adapter-github/src/parsers.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,12 @@ export function computeLanguageDistribution(
8484
}
8585

8686
return [...counts.entries()]
87-
.sort((a, b) => b[1] - a[1])
87+
.sort((a, b) => {
88+
if (b[1] !== a[1]) return b[1] - a[1]; // higher count first
89+
// Deterministic tiebreaker before the top-5 cutoff so equal-count
90+
// languages can't flip in/out of the profile with repo API order (#23).
91+
return a[0].localeCompare(b[0]);
92+
})
8893
.slice(0, 5)
8994
.map(([language, count]) => ({
9095
language,

0 commit comments

Comments
 (0)