From 6e97de4e355618d7cefc125865ec330af062b6c6 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Mon, 10 Aug 2026 04:26:45 +0530 Subject: [PATCH 1/3] perf: streamline ATS keyword scoring --- .gitignore | 1 + src/__tests__/ats-score-performance.test.ts | 55 +++++++++++++++++++++ src/lib/ats-score.ts | 21 +++++--- 3 files changed, 69 insertions(+), 8 deletions(-) create mode 100644 src/__tests__/ats-score-performance.test.ts diff --git a/.gitignore b/.gitignore index 3f4e267..66afb75 100644 --- a/.gitignore +++ b/.gitignore @@ -73,3 +73,4 @@ cache.bin # local agent logs .devin/logs/ .agent-logs/ +.codevetter/ diff --git a/src/__tests__/ats-score-performance.test.ts b/src/__tests__/ats-score-performance.test.ts new file mode 100644 index 0000000..97715eb --- /dev/null +++ b/src/__tests__/ats-score-performance.test.ts @@ -0,0 +1,55 @@ +import { createHash } from 'node:crypto'; +import { performance } from 'node:perf_hooks'; + +import { expect, test } from 'vitest'; + +import { calculateATSScore } from '@/lib/ats-score'; + +const SIZES = [2_000, 10_000, 20_000]; +const ITERATIONS = 25; +const VOCABULARY = Array.from({ length: 800 }, (_, index) => `skill${index}`); +const EXPECTED_HASHES = new Map([ + [2_000, '252044279edd44086b42af44df09f0e59b63f257b9579fcf3e101d2a6202b68b'], + [10_000, '0709ebe2ba2564eb32f453122c4ce111d574a20f25665a6257831e29191f0701'], + [20_000, '016ad6058b2347dcc3f00257d9e00709d60cab9250aef690b4e36a94e5a55cbb'], +]); + +test('ATS scoring scales through the supported resume size', { timeout: 30_000 }, () => { + const metrics: string[] = []; + + for (const size of SIZES) { + const resume = buildText(size, 2); + const jobDescription = buildText(size, 1); + const expected = JSON.stringify(calculateATSScore(resume, jobDescription)); + const expectedHash = createHash('sha256').update(expected).digest('hex'); + expect(expectedHash).toBe(EXPECTED_HASHES.get(size)); + let durationMs = 0; + + for (let iteration = 0; iteration < ITERATIONS; iteration += 1) { + const startedAt = performance.now(); + const result = calculateATSScore(resume, jobDescription); + durationMs += performance.now() - startedAt; + const serialized = JSON.stringify(result); + expect(serialized).toBe(expected); + expect(createHash('sha256').update(serialized).digest('hex')).toBe(expectedHash); + } + + metrics.push(`size${size}=${(durationMs / ITERATIONS).toFixed(3)}ms/op`); + } + + console.log(`[benchmark] ${metrics.join(' ')} (${ITERATIONS} iterations)`); + console.log(`[resource] maximum_supported_resume_chars=${SIZES.at(-1)}`); +}); + +function buildText(targetCharacters: number, stride: number): string { + const words: string[] = []; + let length = 0; + let index = 0; + while (length < targetCharacters) { + const word = VOCABULARY[(index * stride) % VOCABULARY.length]; + words.push(word); + length += word.length + 1; + index += 1; + } + return words.join(' ').slice(0, targetCharacters); +} diff --git a/src/lib/ats-score.ts b/src/lib/ats-score.ts index 50de8e4..af480d5 100644 --- a/src/lib/ats-score.ts +++ b/src/lib/ats-score.ts @@ -224,19 +224,24 @@ export function calculateATSScore(resumeText: string, jdText: string): ATSResult regular.clear(); } - const allKeywords = new Set([...important, ...regular]); - if (allKeywords.size === 0) { + const totalKeywords = important.size + regular.size; + if (totalKeywords === 0) { return { score: 0, matchedKeywords: [], missingKeywords: [], totalKeywords: 0 }; } - function matches(keyword: string): boolean { - return resumeLower.includes(keyword); + const matchedImportant: string[] = []; + const matchedRegular: string[] = []; + const missingKeywords: string[] = []; + for (const keyword of important) { + if (resumeLower.includes(keyword)) matchedImportant.push(keyword); + else missingKeywords.push(keyword); + } + for (const keyword of regular) { + if (resumeLower.includes(keyword)) matchedRegular.push(keyword); + else missingKeywords.push(keyword); } - const matchedImportant = [...important].filter(matches); - const matchedRegular = [...regular].filter(matches); const matchedKeywords = [...matchedImportant, ...matchedRegular]; - const missingKeywords = [...allKeywords].filter((k) => !matches(k)); // Score: important keywords worth 70%, regular worth 30% let score: number; @@ -253,6 +258,6 @@ export function calculateATSScore(resumeText: string, jdText: string): ATSResult score: Math.round(score), matchedKeywords, missingKeywords, - totalKeywords: allKeywords.size, + totalKeywords, }; } From 654664fdc063264c982d49e73d3adfb77dc8e8e4 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 12 Aug 2026 18:23:37 +0530 Subject: [PATCH 2/3] perf: match ATS keywords in one pass --- src/__tests__/ats-score.test.ts | 5 ++++ src/lib/ats-score.ts | 52 +++++++++++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/__tests__/ats-score.test.ts b/src/__tests__/ats-score.test.ts index 166366f..5c371f2 100644 --- a/src/__tests__/ats-score.test.ts +++ b/src/__tests__/ats-score.test.ts @@ -39,6 +39,11 @@ describe('calculateATSScore', () => { ); }); + it('preserves substring matching for overlapping keyword names', () => { + const result = calculateATSScore('JavaScript platform work', 'Java Java JavaScript JavaScript'); + expect(result.matchedKeywords).toEqual(expect.arrayContaining(['java', 'javascript'])); + }); + it('filters stop words and filler words', () => { const jd = 'the company is looking for a strong candidate with good experience'; const resume = 'the company is looking for a strong candidate with good experience'; diff --git a/src/lib/ats-score.ts b/src/lib/ats-score.ts index af480d5..3021972 100644 --- a/src/lib/ats-score.ts +++ b/src/lib/ats-score.ts @@ -182,6 +182,53 @@ function extractBigrams(words: string[]): string[] { return bigrams; } +function findContainedKeywords(text: string, keywords: Iterable): Set { + const nodes: Array<{ + next: Map; + failure: number; + outputs: string[]; + }> = [{ next: new Map(), failure: 0, outputs: [] }]; + + for (const keyword of keywords) { + let state = 0; + for (const character of keyword) { + let next = nodes[state].next.get(character); + if (next === undefined) { + next = nodes.length; + nodes[state].next.set(character, next); + nodes.push({ next: new Map(), failure: 0, outputs: [] }); + } + state = next; + } + nodes[state].outputs.push(keyword); + } + + const queue = [...nodes[0].next.values()]; + for (let offset = 0; offset < queue.length; offset += 1) { + const state = queue[offset]; + for (const [character, next] of nodes[state].next) { + queue.push(next); + let failure = nodes[state].failure; + while (failure !== 0 && !nodes[failure].next.has(character)) { + failure = nodes[failure].failure; + } + nodes[next].failure = nodes[failure].next.get(character) ?? 0; + nodes[next].outputs.push(...nodes[nodes[next].failure].outputs); + } + } + + const matches = new Set(); + let state = 0; + for (const character of text) { + while (state !== 0 && !nodes[state].next.has(character)) { + state = nodes[state].failure; + } + state = nodes[state].next.get(character) ?? 0; + for (const keyword of nodes[state].outputs) matches.add(keyword); + } + return matches; +} + export function calculateATSScore(resumeText: string, jdText: string): ATSResult { if (!resumeText.trim() || !jdText.trim()) { return { score: 0, matchedKeywords: [], missingKeywords: [], totalKeywords: 0 }; @@ -232,12 +279,13 @@ export function calculateATSScore(resumeText: string, jdText: string): ATSResult const matchedImportant: string[] = []; const matchedRegular: string[] = []; const missingKeywords: string[] = []; + const containedKeywords = findContainedKeywords(resumeLower, [...important, ...regular]); for (const keyword of important) { - if (resumeLower.includes(keyword)) matchedImportant.push(keyword); + if (containedKeywords.has(keyword)) matchedImportant.push(keyword); else missingKeywords.push(keyword); } for (const keyword of regular) { - if (resumeLower.includes(keyword)) matchedRegular.push(keyword); + if (containedKeywords.has(keyword)) matchedRegular.push(keyword); else missingKeywords.push(keyword); } From 75abe145cfb5d560a9d177b38217547fff9317f6 Mon Sep 17 00:00:00 2001 From: Sarthak Agrawal Date: Wed, 12 Aug 2026 18:32:51 +0530 Subject: [PATCH 3/3] fix: split keyword automaton construction --- src/lib/ats-score.ts | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/lib/ats-score.ts b/src/lib/ats-score.ts index 3021972..a04a290 100644 --- a/src/lib/ats-score.ts +++ b/src/lib/ats-score.ts @@ -182,13 +182,13 @@ function extractBigrams(words: string[]): string[] { return bigrams; } -function findContainedKeywords(text: string, keywords: Iterable): Set { - const nodes: Array<{ - next: Map; - failure: number; - outputs: string[]; - }> = [{ next: new Map(), failure: 0, outputs: [] }]; +interface KeywordNode { + next: Map; + failure: number; + outputs: string[]; +} +function addKeywordNodes(nodes: KeywordNode[], keywords: Iterable): void { for (const keyword of keywords) { let state = 0; for (const character of keyword) { @@ -202,7 +202,9 @@ function findContainedKeywords(text: string, keywords: Iterable): Set): Set): KeywordNode[] { + const nodes: KeywordNode[] = [{ next: new Map(), failure: 0, outputs: [] }]; + addKeywordNodes(nodes, keywords); + addKeywordFailureLinks(nodes); + return nodes; +} + +function findContainedKeywords(text: string, keywords: Iterable): Set { + const nodes = buildKeywordNodes(keywords); const matches = new Set(); let state = 0; for (const character of text) {