Skip to content

Commit c20f798

Browse files
committed
fix(tracker): preserve distinct role identities
1 parent 12b2d7b commit c20f798

7 files changed

Lines changed: 357 additions & 58 deletions

File tree

dedup-tracker.mjs

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* - Day-based: data/applications/YYYY-MM-DD.md (preferred)
77
* - Single-file: data/applications.md or applications.md (legacy)
88
*
9-
* Groups by normalized company + fuzzy role match.
9+
* Groups by normalized company + conservative role identity.
1010
* Keeps entry with highest score. If discarded entry had more advanced status,
1111
* preserves that status. Merges notes.
1212
*
@@ -21,6 +21,7 @@ import {
2121
usesDayFiles, ensureDayDir, getHeader, formatAppLine, parseAppLine,
2222
readAllEntries, writeToDayFiles, listDayFiles, dayFilePath,
2323
} from './tracker-lib.mjs';
24+
import { trackerRolesMatch } from './lib/tracker-role-identity.mjs';
2425

2526
const DRY_RUN = process.argv.includes('--dry-run');
2627

@@ -62,21 +63,6 @@ function normalizeCompany(name) {
6263
.trim();
6364
}
6465

65-
function normalizeRole(role) {
66-
return role.toLowerCase()
67-
.replace(/[()]/g, ' ')
68-
.replace(/\s+/g, ' ')
69-
.replace(/[^a-z0-9 /]/g, '')
70-
.trim();
71-
}
72-
73-
function roleMatch(a, b) {
74-
const wordsA = normalizeRole(a).split(/\s+/).filter(w => w.length > 3);
75-
const wordsB = normalizeRole(b).split(/\s+/).filter(w => w.length > 3);
76-
const overlap = wordsA.filter(w => wordsB.some(wb => wb.includes(w) || w.includes(wb)));
77-
return overlap.length >= 2;
78-
}
79-
8066
function parseScore(s) {
8167
const m = s.replace(/\*\*/g, '').match(/([\d.]+)/);
8268
return m ? parseFloat(m[1]) : 0;
@@ -115,7 +101,7 @@ for (const [company, companyEntries] of groups) {
115101

116102
for (let j = i + 1; j < companyEntries.length; j++) {
117103
if (processed.has(j)) continue;
118-
if (roleMatch(companyEntries[i].role, companyEntries[j].role)) {
104+
if (trackerRolesMatch(companyEntries[i].role, companyEntries[j].role)) {
119105
cluster.push(companyEntries[j]);
120106
processed.add(j);
121107
}

lib/tracker-role-identity.mjs

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
// Tracker deduplication is intentionally stricter than search ranking. A false
2+
// positive can delete a row or promote another role's status, so uncertain
3+
// titles remain distinct.
4+
const TOKEN_ALIASES = new Map([
5+
['backend', ['back', 'end']],
6+
['dev', ['developer']],
7+
['eng', ['engineer']],
8+
['frontend', ['front', 'end']],
9+
['fullstack', ['full', 'stack']],
10+
['jr', ['junior']],
11+
['mgr', ['manager']],
12+
['ml', ['machine', 'learning']],
13+
['programme', ['program']],
14+
['snr', ['senior']],
15+
['sr', ['senior']],
16+
['sre', ['site', 'reliability', 'engineer']],
17+
['swe', ['software', 'engineer']],
18+
]);
19+
20+
const SENIORITY_TOKENS = new Set([
21+
'associate',
22+
'distinguished',
23+
'junior',
24+
'lead',
25+
'midlevel',
26+
'principal',
27+
'senior',
28+
'staff',
29+
]);
30+
const ROLE_HEAD_TOKENS = new Set([
31+
'administrator',
32+
'analyst',
33+
'architect',
34+
'attorney',
35+
'consultant',
36+
'coordinator',
37+
'counsel',
38+
'designer',
39+
'developer',
40+
'director',
41+
'editor',
42+
'engineer',
43+
'executive',
44+
'manager',
45+
'officer',
46+
'producer',
47+
'recruiter',
48+
'representative',
49+
'researcher',
50+
'scientist',
51+
'specialist',
52+
'strategist',
53+
'technician',
54+
'writer',
55+
]);
56+
const ROLE_FAMILY_PREFIX_TOKENS = new Set([
57+
'account',
58+
'application',
59+
'back',
60+
'cloud',
61+
'customer',
62+
'data',
63+
'end',
64+
'front',
65+
'full',
66+
'general',
67+
'infrastructure',
68+
'learning',
69+
'machine',
70+
'mobile',
71+
'platform',
72+
'product',
73+
'program',
74+
'project',
75+
'reliability',
76+
'sales',
77+
'security',
78+
'site',
79+
'software',
80+
'solution',
81+
'stack',
82+
'success',
83+
'system',
84+
'technical',
85+
'web',
86+
]);
87+
88+
// This is deliberately an allowlist rather than a general stemmer. Substring
89+
// or broad suffix matching makes unrelated titles such as product/production
90+
// and data/database collide.
91+
const TOKEN_EQUIVALENTS = new Map([
92+
['developers', 'developer'],
93+
['engineers', 'engineer'],
94+
['managers', 'manager'],
95+
['payments', 'payment'],
96+
['platforms', 'platform'],
97+
['products', 'product'],
98+
['programmes', 'program'],
99+
['programs', 'program'],
100+
['projects', 'project'],
101+
['services', 'service'],
102+
['solutions', 'solution'],
103+
['systems', 'system'],
104+
]);
105+
106+
function normalizedRoleTokens(role) {
107+
const rawTokens = String(role || '')
108+
.normalize('NFKD')
109+
.replace(/[\u0300-\u036f]/g, '')
110+
.toLowerCase()
111+
.replace(/\bc\+\+(?=$|[^a-z0-9])/g, 'cplusplus')
112+
.replace(/\bc#(?=$|[^a-z0-9])/g, 'csharp')
113+
.replace(/\bf#(?=$|[^a-z0-9])/g, 'fsharp')
114+
.replace(/\.net\b/g, ' dotnet')
115+
.replace(/&/g, ' and ')
116+
.replace(/['\u2019]/g, '')
117+
.replace(/[^a-z0-9]+/g, ' ')
118+
.trim()
119+
.split(/\s+/)
120+
.filter(Boolean);
121+
const tokens = [];
122+
123+
for (let index = 0; index < rawTokens.length; index++) {
124+
const token = rawTokens[index];
125+
const next = rawTokens[index + 1];
126+
if (token === 'entry' && next === 'level') {
127+
tokens.push('junior');
128+
index++;
129+
continue;
130+
}
131+
if (token === 'mid' && next === 'level') {
132+
tokens.push('midlevel');
133+
index++;
134+
continue;
135+
}
136+
137+
tokens.push(...(TOKEN_ALIASES.get(token) || [token]));
138+
}
139+
140+
for (let index = 0; index < tokens.length; index++) {
141+
tokens[index] = TOKEN_EQUIVALENTS.get(tokens[index]) || tokens[index];
142+
}
143+
144+
let seniorityLength = 0;
145+
while (seniorityLength < tokens.length && SENIORITY_TOKENS.has(tokens[seniorityLength])) {
146+
seniorityLength++;
147+
}
148+
149+
if (seniorityLength > 0) {
150+
const tail = tokens.slice(seniorityLength);
151+
const headIndex = tail.findIndex((token) => ROLE_HEAD_TOKENS.has(token));
152+
const clearlyPositional = headIndex >= 0 && tail
153+
.slice(0, headIndex)
154+
.every((token) => ROLE_FAMILY_PREFIX_TOKENS.has(token));
155+
if (clearlyPositional) tokens.splice(0, seniorityLength);
156+
}
157+
158+
return tokens;
159+
}
160+
161+
export function trackerRoleIdentity(role) {
162+
const tokens = normalizedRoleTokens(role);
163+
return {
164+
key: tokens.join(':'),
165+
tokens,
166+
};
167+
}
168+
169+
export function trackerRolesMatch(leftRole, rightRole) {
170+
const left = normalizedRoleTokens(leftRole);
171+
const right = normalizedRoleTokens(rightRole);
172+
173+
if (left.length === 0 || right.length === 0 || left.length !== right.length) {
174+
return false;
175+
}
176+
177+
return left.every((token, index) => token === right[index]);
178+
}

merge-tracker.mjs

Lines changed: 3 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
* - 8-col: num\tdate\tcompany\trole\tstatus\tscore\tpdf\treport (no notes)
1212
* - Pipe-delimited (markdown table row): | col | col | ... |
1313
*
14-
* Dedup: company normalized + role fuzzy match + report number match
14+
* Dedup: company normalized + conservative role identity + report number match
1515
* If duplicate with higher score → update in-place, update report link
1616
* Validates status against templates/states.yml when present (else built-in labels)
1717
*
@@ -49,6 +49,7 @@ import {
4949
writeJobForgeLineage,
5050
} from './lib/jobforge-lineage.mjs';
5151
import { compareTrackerAdditionBasenames } from './lib/tracker-addition-order.mjs';
52+
import { trackerRolesMatch } from './lib/tracker-role-identity.mjs';
5253

5354
const ADDITIONS_DIR = join(PROJECT_DIR, 'batch/tracker-additions');
5455
const MERGED_DIR = join(ADDITIONS_DIR, 'merged');
@@ -129,35 +130,6 @@ function normalizeCompany(name) {
129130
return name.toLowerCase().replace(/[^a-z0-9]/g, '');
130131
}
131132

132-
// Generic seniority + engineering words that appear across most SWE roles
133-
// and carry no role-specialty signal. A "discriminator" is any remaining
134-
// word longer than 3 chars (e.g. "Observability", "Telemetry", "Platform").
135-
const ROLE_STOPWORDS = new Set([
136-
'staff', 'senior', 'principal', 'lead', 'junior',
137-
'software', 'engineer', 'engineering', 'developer',
138-
'backend', 'frontend', 'fullstack', 'full-stack', 'full', 'stack',
139-
'technical', 'applied',
140-
]);
141-
142-
function roleFuzzyMatch(a, b) {
143-
// Split on whitespace AND role punctuation (commas, colons, dashes, parens)
144-
// so "Staff SWE, Observability K8s" tokenizes past the comma.
145-
const split = (s) => s.toLowerCase()
146-
.split(/[\s,:\-()\/]+/)
147-
.map(w => w.trim())
148-
.filter(w => w.length > 3 && !ROLE_STOPWORDS.has(w));
149-
150-
const wordsA = split(a);
151-
const wordsB = split(b);
152-
153-
// Match on discriminator-word overlap only. Prevents "Staff Software
154-
// Engineer, ML Observability" and "Staff Backend Engineer, Adaptive
155-
// Telemetry" from colliding (same company, different specialty) while
156-
// still collapsing re-evaluations of the same role (same discriminators).
157-
const overlap = wordsA.filter(w => wordsB.some(wb => wb.includes(w) || w.includes(wb)));
158-
return overlap.length >= 2;
159-
}
160-
161133
function extractReportNum(reportStr) {
162134
const m = reportStr.match(/\[(\d+)\]/);
163135
return m ? parseInt(m[1]) : null;
@@ -329,7 +301,7 @@ for (const file of tsvFiles) {
329301
const normCompany = normalizeCompany(addition.company);
330302
duplicate = existingApps.find(app => {
331303
if (normalizeCompany(app.company) !== normCompany) return false;
332-
return roleFuzzyMatch(addition.role, app.role);
304+
return trackerRolesMatch(addition.role, app.role);
333305
});
334306
}
335307

modes/scan.md

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -224,15 +224,15 @@ The levels are additive — all are executed, results are merged and deduplicate
224224
- `scan-history.tsv` → exact URL already seen
225225
- `pipeline.md` → exact URL already in pending or processed
226226

227-
**Layer 2 — Company + role fuzzy match (catches reposts with new URLs):**
228-
- all day files in `data/applications/` → normalize company name (lowercase, strip non-alphanumeric) + fuzzy role match (2+ significant words in common, words > 3 chars). This is the same logic used in `dedup-tracker.mjs` and `merge-tracker.mjs`.
229-
- `scan-history.tsv` → same fuzzy match against company + title columns (not just URL). A role reposted on a new URL but with the same company and similar title is a duplicate.
230-
- `pipeline.md` → same fuzzy match against company + title in pending items that include metadata (format: `- [ ] {url} | {company} | {title}`)
227+
**Layer 2 — Company + conservative role identity (catches true reposts with new URLs):**
228+
- all day files in `data/applications/` → normalize company name (lowercase, strip non-alphanumeric) + a conservative scan-layer role comparison. This prompt-driven prefilter does not execute the tracker matcher. Keep the candidate whenever identity is uncertain; `dedup-tracker.mjs` and `merge-tracker.mjs` apply their shared deterministic tracker identity during settlement.
229+
- `scan-history.tsv`apply the same conservative identity policy to company + title columns (not just URL). A role reposted on a new URL is a duplicate only when the normalized role identities agree.
230+
- `pipeline.md`apply the same conservative identity policy to company + title in pending items that include metadata (format: `- [ ] {url} | {company} | {title}`)
231231

232-
**Fuzzy match rules:**
232+
**Role identity rules:**
233233
- Normalize company: `company.toLowerCase().replace(/[^a-z0-9]/g, '')`
234-
- Fuzzy role match: split both titles into words > 3 chars, match if 2+ words overlap (substring match, case-insensitive). E.g., "Senior AI Engineer" and "Staff AI Engineer" share "engineer" — only 1 overlap, not a match. But "AI Platform Engineer" and "AI Platform Eng" share "platform" + partial "engineer" — match.
235-
- When a fuzzy match is found but the URL is new, log it as `skipped_repost` (not `skipped_dup`) with a note referencing the original entry number.
234+
- Preserve every normalized role word and its order; never apply global role stopwords or derivational stemming such as `engineering``engineer` or `management``manager`. Preserve semantic punctuation before tokenization: `C++`, `C#`, `F#`, and `.NET` remain distinct from `C`, `F`, and `NET`. Location or work-mode words such as `US`, `United States`, `remote`, `hybrid`, and `New York` remain identity-bearing. Do not use substring or shared generic-title overlap. Remove only clearly positional seniority prefixes; never globally erase ambiguous words such as `lead`, `staff`, or `associate`. Short specialty tokens remain significant: `UI Engineer` and `UX Engineer` are distinct; `Product Manager, Cash Platform` and `AI Product Manager, Professional Services` are distinct; `Senior Product Managers, Payments` and `Product Manager - Payment` match after safe plural and seniority normalization.
235+
- When a role-identity match is found but the URL is new, log it as `skipped_repost` (not `skipped_dup`) with a note referencing the original entry number.
236236

237237
10. **Balance the eligible, deduplicated shortlist** using `diversity_policy`:
238238
- Write the normalized candidate array to `/tmp/jobforge-scan-candidates-{YYYY-MM-DD}.json`, including posting-derived `country_code`, `relevance_score`, `region`, and `location_status`.

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "job-forge",
3-
"version": "2.14.69",
3+
"version": "2.14.70",
44
"description": "AI-powered job search pipeline for Codex and OpenCode",
55
"type": "module",
66
"bin": {

0 commit comments

Comments
 (0)