Problem
The Missing list is the part of JD match a user acts on — it is the edit queue. So a term that lands there wrongly does not just cost accuracy, it sends someone to make a résumé change that accomplishes nothing.
The noun pass produces exactly that failure. In computeCoverageFromCorpus (src/lib/jd-match/coverage.ts) the two term sources are matched by different rules:
const hit =
term.source === "skill"
? corpusMentionsSkill(corpus, term.id)
: corpusMentionsPhrase(corpus, term.display);
corpusMentionsSkill resolves through the alias dictionary in skills.ts, so a JD asking for js matches a résumé that says JavaScript. corpusMentionsPhrase matches the JD's surface phrase literally against the corpus. A noun-pass term the résumé expresses in different words is therefore reported as missing, and JdMatch.tsx renders it under Missing (N) indistinguishably from a term the résumé genuinely never addresses.
Concretely, a JD phrase like on-call rotation against a résumé saying production support rotation, or distributed systems against large-scale backend services, is currently a miss.
We have already decided this is a defect once
#607 fixed the same class in the skill-guidance lane — guidance was suggesting skills the résumé already stated in other words. The reasoning there applies unchanged here; only the lane is different. This issue is that decision carried into the noun pass.
Why not just wait for the semantic path
#156's WebLLM requirement matching is the better long-term answer and this issue does not compete with it. But the keyword path is not going away: JdMatchResult types it as a first-class path, and types.ts records it as "also the semantic path's fallback." It is what runs when the model is unavailable, still loading, or declined. A fallback that reports confident false misses is worse than one that is merely coarse, because the user cannot tell which mode produced the list.
Proposal
Narrow the gap in the deterministic path, without pretending it becomes semantic matching.
Two options, and I lean toward the first:
(a) Match noun phrases on a normalized head, not the literal string. Stem/lemmatize and drop determiners and modifiers before comparing, so distributed system and distributed systems stop being different terms, and a JD phrase whose head noun appears in the corpus under a light variation is credited. Cheap, deterministic, testable, no dictionary to maintain.
(b) Extend the alias dictionary to cover common non-skill phrases. More precise where it applies, but it is an open-ended maintenance surface, and skills.ts is deliberately a skill dictionary — its DICTIONARY_VERSION contract and the aliasToId canonicalization exist for chips and guidance, and widening it into general JD prose would change what that version number means.
Either way, a third behaviour is worth having independently: when a term is neither clearly covered nor clearly absent, say so. A "possibly covered — your résumé says X" bucket is more honest than forcing a binary, and it is directly actionable in a way a false miss is not. CoverageResult is { covered, missing } today, so this is a shape change and needs to be weighed against every current consumer, including the score.
Scoring
Any change here moves score. That is correct — the current score is inflated in the other direction by counting real coverage as missing — but it must be a deliberate, recorded move rather than a silent drift, since the number appears in the UI and users compare it across runs. If a middle bucket lands, decide its weight explicitly rather than defaulting it to zero or to NOUN_WEIGHT.
Out of scope
Acceptance criteria
Problem
The Missing list is the part of JD match a user acts on — it is the edit queue. So a term that lands there wrongly does not just cost accuracy, it sends someone to make a résumé change that accomplishes nothing.
The noun pass produces exactly that failure. In
computeCoverageFromCorpus(src/lib/jd-match/coverage.ts) the two term sources are matched by different rules:corpusMentionsSkillresolves through the alias dictionary inskills.ts, so a JD asking forjsmatches a résumé that saysJavaScript.corpusMentionsPhrasematches the JD's surface phrase literally against the corpus. A noun-pass term the résumé expresses in different words is therefore reported as missing, andJdMatch.tsxrenders it underMissing (N)indistinguishably from a term the résumé genuinely never addresses.Concretely, a JD phrase like
on-call rotationagainst a résumé sayingproduction support rotation, ordistributed systemsagainstlarge-scale backend services, is currently a miss.We have already decided this is a defect once
#607 fixed the same class in the skill-guidance lane — guidance was suggesting skills the résumé already stated in other words. The reasoning there applies unchanged here; only the lane is different. This issue is that decision carried into the noun pass.
Why not just wait for the semantic path
#156's WebLLM requirement matching is the better long-term answer and this issue does not compete with it. But the keyword path is not going away:
JdMatchResulttypes it as a first-class path, andtypes.tsrecords it as "also the semantic path's fallback." It is what runs when the model is unavailable, still loading, or declined. A fallback that reports confident false misses is worse than one that is merely coarse, because the user cannot tell which mode produced the list.Proposal
Narrow the gap in the deterministic path, without pretending it becomes semantic matching.
Two options, and I lean toward the first:
(a) Match noun phrases on a normalized head, not the literal string. Stem/lemmatize and drop determiners and modifiers before comparing, so
distributed systemanddistributed systemsstop being different terms, and a JD phrase whose head noun appears in the corpus under a light variation is credited. Cheap, deterministic, testable, no dictionary to maintain.(b) Extend the alias dictionary to cover common non-skill phrases. More precise where it applies, but it is an open-ended maintenance surface, and
skills.tsis deliberately a skill dictionary — itsDICTIONARY_VERSIONcontract and thealiasToIdcanonicalization exist for chips and guidance, and widening it into general JD prose would change what that version number means.Either way, a third behaviour is worth having independently: when a term is neither clearly covered nor clearly absent, say so. A "possibly covered — your résumé says X" bucket is more honest than forcing a binary, and it is directly actionable in a way a false miss is not.
CoverageResultis{ covered, missing }today, so this is a shape change and needs to be weighed against every current consumer, including the score.Scoring
Any change here moves
score. That is correct — the current score is inflated in the other direction by counting real coverage as missing — but it must be a deliberate, recorded move rather than a silent drift, since the number appears in the UI and users compare it across runs. If a middle bucket lands, decide its weight explicitly rather than defaulting it to zero or toNOUN_WEIGHT.Out of scope
Acceptance criteria
scoreis deliberate, documented in the module docblock, and covered by a test that pins the new weightingcomputeCoverageFromCorpusstays the only matcher, per its own docblock