diff --git a/apps/extension/src/lib/core/scoring/dedup.ts b/apps/extension/src/lib/core/scoring/dedup.ts index 07d45176..de871dea 100644 --- a/apps/extension/src/lib/core/scoring/dedup.ts +++ b/apps/extension/src/lib/core/scoring/dedup.ts @@ -139,6 +139,22 @@ const tokenize = (text: string | null | undefined): Set => .filter((token) => token.length > 1 && !STOP_WORDS.has(token)) ); +/** + * True if any member of `needles` is present in `tokens`. Iterates `needles` + * (typically a tiny constant set such as REMOTE_LOCATION_TOKENS) and does O(1) + * membership checks against `tokens`, allocating nothing. Replaces the previous + * `[...aTokens, ...bTokens].some(...)` in compareLocations, which allocated a + * merged array on every candidate pair — a hot path during deduplication. + */ +const hasAnyToken = (tokens: Set, needles: Set): boolean => { + for (const needle of needles) { + if (tokens.has(needle)) { + return true; + } + } + return false; +}; + /** * Counts shared elements between two sets, iterating the smaller set to minimise * membership checks. Allocates no intermediate arrays/sets — important because @@ -316,7 +332,8 @@ const compareLocations = ( const hasRemoteContext = a.remote === 'full' || b.remote === 'full' || - [...aTokens, ...bTokens].some((token) => REMOTE_LOCATION_TOKENS.has(token)); + hasAnyToken(aTokens, REMOTE_LOCATION_TOKENS) || + hasAnyToken(bTokens, REMOTE_LOCATION_TOKENS); return { compatible: hasRemoteContext, score: hasRemoteContext ? 0.4 : 0 }; }; diff --git a/apps/extension/tests/unit/scoring/dedup.test.ts b/apps/extension/tests/unit/scoring/dedup.test.ts index 0d99d0e2..502c6e13 100644 --- a/apps/extension/tests/unit/scoring/dedup.test.ts +++ b/apps/extension/tests/unit/scoring/dedup.test.ts @@ -312,6 +312,34 @@ describe('deduplicateMissions', () => { expect(result).toHaveLength(2); }); + it('dedupes across disjoint locations when one carries a remote token', () => { + // Exercises the compareLocations remote-context fallback: location token + // sets are disjoint (no weighted similarity), but "teletravail" is in + // REMOTE_LOCATION_TOKENS, so the pair stays compatible and merges. + // Regression guard for the allocation-free hasAnyToken implementation. + const missions = [ + makeMission({ + id: 'paris', + title: 'Developpeur React Senior', + stack: ['React', 'TypeScript'], + location: 'Paris', + remote: null, + }), + makeMission({ + id: 'remote', + title: 'Developpeur React Senior', + stack: ['React'], + location: 'Teletravail', + remote: null, + }), + ]; + + const result = deduplicateMissionsDetailed(missions); + expect(result.missions).toHaveLength(1); + expect(result.duplicateRelations).toHaveLength(1); + expect(result.duplicateRelations[0].confidence).toBeGreaterThanOrEqual(0.8); + }); + it('handles complex duplicate scenarios', () => { const missions = [ makeMission({